kasa 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 43b88d71bcfcf597efbc974a100df7386365bb90b5bb113ea1a07801a8bcf565
4
+ data.tar.gz: 72be110483b25f002b025743c3f5896ccc690a69d9e0b1a2bfef5b92e79fd1db
5
+ SHA512:
6
+ metadata.gz: 7116bd99634440e8416ae926cde96814f9b56c209bcca52d22ab2d5f5b6393f5b2b4ad3c09a35d66cad6ab65c5d2aec59ad6f92701010a939438bcf2d8d990bd
7
+ data.tar.gz: b996de906614906a8de6b19e5b46e4b6d0ab7aeb05f1f221e5af541d36bc2fe8208f3e626a6931d164a8078c251df20e9eddf8bc69f462de526a363cc8112a4d
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.rubocop.yml ADDED
@@ -0,0 +1,18 @@
1
+ ---
2
+ require:
3
+ - rubocop-rspec
4
+ AllCops:
5
+ Exclude:
6
+ - 'bin/*'
7
+ - 'vendor/**/*'
8
+ - '.bundle/**/*'
9
+ - '*.gemspec'
10
+ NewCops: enable
11
+
12
+ Metrics/BlockLength:
13
+ Exclude:
14
+ - 'Rakefile'
15
+ - '**/*.rake'
16
+ - 'spec/**/*_spec.rb'
17
+ Style/DocumentationMethod:
18
+ Enabled: true
data/Gemfile ADDED
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ # Specify your gem's dependencies in kasa.gemspec
6
+ gemspec
7
+
8
+ gem 'rake', '~> 13.0'
9
+
10
+ gem 'rspec', '~> 3.0'
11
+
12
+ gem 'rubocop', '~> 1.21'
13
+
14
+ gem 'json', '~> 2.6'
data/README.md ADDED
@@ -0,0 +1,50 @@
1
+ # Kasa
2
+
3
+ Connect directly to TP Link Kasa devices on your local network
4
+ ## Installation
5
+
6
+ Add this line to your application's Gemfile:
7
+
8
+ ```ruby
9
+ gem 'kasa'
10
+ ```
11
+
12
+ And then execute:
13
+
14
+ $ bundle install
15
+
16
+ Or install it yourself as:
17
+
18
+ $ gem install kasa
19
+
20
+ ## Usage
21
+ To scan local network and turn on first device:
22
+ ```
23
+ require 'kasa'
24
+
25
+ kasa = Kasa.new
26
+ kasa.devices.first.on
27
+ ```
28
+
29
+ Local network device is detected but can be optionally overridden:
30
+ ```
31
+ Kasa.new(discover_interface_override: 'eth1')
32
+ ```
33
+
34
+ To connect to known host and adjust brightness:
35
+ ```
36
+ require 'kasa'
37
+
38
+ device = Kasa::Factory.new('192.168.1.55')
39
+ device.brightness=50
40
+ ```
41
+
42
+ ## Development
43
+
44
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
45
+
46
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
47
+
48
+ ## Contributing
49
+
50
+ Bug reports and pull requests are welcome on GitHub at https://github.com/sevendials/ruby-kasa.
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require 'rubocop/rake_task'
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
data/bin/console ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "kasa"
6
+
7
+ # You can add fixtures and/or initialization code here to make experimenting
8
+ # with your gem easier. You can also use a different console, if you like.
9
+
10
+ # (If you use this, don't forget to add pry to your Gemfile!)
11
+ # require "pry"
12
+ # Pry.start
13
+
14
+ require "irb"
15
+ IRB.start(__FILE__)
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Kasa
4
+ # Common methods across kasa devices
5
+ class Device
6
+ ON = 1
7
+ OFF = 0
8
+
9
+ attr_reader :ip
10
+
11
+ # initialize
12
+ def initialize(ip)
13
+ @ip = ip
14
+ @sysinfo = sysinfo
15
+ end
16
+
17
+ # Get system information
18
+ def sysinfo
19
+ Kasa::Protocol.get(@ip, location: '/system/get_sysinfo')
20
+ end
21
+
22
+ # Turn on light
23
+ def on
24
+ relay ON
25
+ end
26
+
27
+ # Turn off light
28
+ def off
29
+ relay OFF
30
+ end
31
+
32
+ # Is relay off?
33
+ def off?
34
+ relay_state.zero?
35
+ end
36
+
37
+ # Is relay on?
38
+ def on?
39
+ relay_state.eql? 1
40
+ end
41
+
42
+ private
43
+
44
+ # Check light state
45
+ def relay_state
46
+ Kasa::Protocol.get(@ip, location: '/system/get_sysinfo/relay_state')
47
+ end
48
+
49
+ def relay(state)
50
+ Kasa::Protocol.get(
51
+ @ip,
52
+ location: '/system/set_relay_state/state',
53
+ value: state
54
+ )
55
+ end
56
+ end
57
+
58
+ # Most devices are not dimmable
59
+ class NonDimmable < Device
60
+ end
61
+
62
+ # add dimmable device
63
+ class Dimmable < NonDimmable
64
+ # Get brightness
65
+ def brightness
66
+ sysinfo['brightness']
67
+ end
68
+
69
+ # Set brightness
70
+ def brightness=(level)
71
+ Kasa::Protocol.get(
72
+ @ip,
73
+ location: '/smartlife.iot.dimmer/set_brightness/brightness',
74
+ value: level
75
+ )
76
+ end
77
+ end
78
+
79
+ # add dimmable device
80
+ class SmartStrip < NonDimmable
81
+ attr_accessor :children
82
+
83
+ def initialize(ip)
84
+ super
85
+ @children = @sysinfo['children'].map { |c| c['id'] }
86
+ end
87
+
88
+ # Turn on light
89
+ def on(index)
90
+ relay ON, index
91
+ end
92
+
93
+ # Turn off light
94
+ def off(index)
95
+ relay OFF, index
96
+ end
97
+
98
+ def off?(index)
99
+ relay_state(index).zero?
100
+ end
101
+
102
+ def on?(index)
103
+ relay_state(index).eql? 1
104
+ end
105
+
106
+ private
107
+
108
+ def relay_state(index)
109
+ Kasa::Protocol.get(
110
+ @ip,
111
+ location: '/system/get_sysinfo/children'
112
+ )[index]['state']
113
+ end
114
+
115
+ def relay(state, index)
116
+ Kasa::Protocol.get(
117
+ @ip,
118
+ location: '/system/set_relay_state/state',
119
+ value: state,
120
+ extra: { context: { child_ids: [@children[index]] } }
121
+ )
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'devices'
4
+ require_relative 'protocol'
5
+
6
+ class Kasa
7
+ DEVICE_TYPES = {
8
+ Dimmable => [
9
+ 'HS220(US)'
10
+ ],
11
+ NonDimmable => [
12
+ 'HS200(US)',
13
+ 'HS105(US)',
14
+ 'HS210(US)'
15
+ ],
16
+ SmartStrip => [
17
+ 'HS300(US)',
18
+ 'KP303(US)',
19
+ 'KP400(US)'
20
+ ]
21
+ }.freeze
22
+
23
+ # Common methods across kasa devices
24
+ class Factory
25
+ # Factory
26
+ def self.new(ip)
27
+ model = Kasa::Protocol.get(ip, location: '/system/get_sysinfo')['model']
28
+ begin
29
+ object = DEVICE_TYPES.detect { |_k, v| v.include? model }.first.allocate
30
+ rescue StandardError => _e
31
+ raise "#{model} not supported"
32
+ end
33
+ object.send :initialize, ip
34
+ object
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'socket'
5
+
6
+ class Kasa
7
+ # TP link protocol
8
+ class Protocol
9
+ START_KEY = 171
10
+ TIMEOUT = 2
11
+ KASA_PORT = 9999
12
+
13
+ # get result from request
14
+ def self.get(ip, location:, value: nil, extra: {})
15
+ request = request_to_hash location, value
16
+ request.merge! extra
17
+
18
+ encoded_response = Timeout.timeout(TIMEOUT) do
19
+ transport(ip, encode(request.to_json))
20
+ end
21
+
22
+ strip_location(location, decode(encoded_response))
23
+ end
24
+
25
+ # strip away the request location from the response
26
+ def self.strip_location(location, response)
27
+ location = location.split('/').reject(&:empty?)
28
+ response = JSON.parse response
29
+ location.each do |j|
30
+ response = (response[j] or response)
31
+ end
32
+
33
+ response
34
+ end
35
+
36
+ # convert location and value to a hash
37
+ def self.request_to_hash(location, value)
38
+ request = value
39
+ location = location.split('/').reject(&:empty?).reverse
40
+ location.each do |e|
41
+ request = { e => request }
42
+ end
43
+
44
+ request
45
+ end
46
+
47
+ # Open socket and send request and response
48
+ def self.transport(ip, request)
49
+ Socket.tcp(ip, KASA_PORT) do |s|
50
+ s.write request
51
+
52
+ result_length = s.recv(4).unpack1('I>')
53
+ result = ''
54
+ while result_length.positive?
55
+ result += s.recv(1024)
56
+ result_length -= 1024
57
+ end
58
+ result
59
+ end
60
+ end
61
+
62
+ # Encrypt and encode
63
+ def self.encode(plain)
64
+ key = START_KEY
65
+
66
+ enc_bytes = plain.unpack('C*').map do |byte|
67
+ key = key ^ byte
68
+ key
69
+ end
70
+ ([enc_bytes.length] + enc_bytes).pack('I>C*')
71
+ end
72
+
73
+ # Decrypt and decode
74
+ def self.decode(line)
75
+ key = START_KEY
76
+
77
+ line.unpack('C*').map do |enc_byte|
78
+ byte = key ^ enc_byte
79
+ key = enc_byte
80
+ byte
81
+ end.pack('C*')
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Kasa
4
+ VERSION = '0.1.0'
5
+ end
data/lib/kasa.rb ADDED
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'timeout'
4
+ require 'base64'
5
+ require 'logger'
6
+ require_relative 'kasa/version'
7
+ require_relative 'kasa/factory'
8
+
9
+ # Control local Kasa devices
10
+ class Kasa
11
+ attr_reader :devices
12
+
13
+ # initialize
14
+ def initialize(discover: true, discover_interface_override: false, debug: false)
15
+ @logger = Logger.new($stdout)
16
+ @logger.level = debug ? Logger::DEBUG : Logger::INFO
17
+ @devices = []
18
+ @discover_interface_override = discover_interface_override
19
+ refresh if discover
20
+ end
21
+
22
+ # Populate devices hash with Kasa devices
23
+ def refresh
24
+ threads = []
25
+ ip_range.each do |ip|
26
+ threads << Thread.new do
27
+ @devices << Kasa::Factory.new(ip)
28
+ rescue StandardError => e
29
+ @logger.debug e
30
+ end
31
+ end
32
+ threads.each(&:join)
33
+ @devices
34
+ end
35
+
36
+ private
37
+
38
+ # Return CIDR of first IPv4 interface or user-specified interface
39
+ def cidr
40
+ interface = Socket.getifaddrs.detect do |intf|
41
+ intf.addr.ipv4_private? && (@discover_interface_override ? intf.name.eql?(@discover_interface_override) : true)
42
+ end
43
+
44
+ raise 'Interface does not exist' if interface.nil?
45
+
46
+ "#{interface.addr.ip_address}/#{interface.netmask.ip_address}"
47
+ end
48
+
49
+ # Generate array of IP addresses from local subnet
50
+ def ip_range
51
+ IPAddr.new(cidr).to_range.to_a.map(&:to_s)
52
+ end
53
+ end
metadata ADDED
@@ -0,0 +1,99 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: kasa
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Christopher Jenkins
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2022-02-19 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rubocop-rspec
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '2.8'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '2.8'
27
+ - !ruby/object:Gem::Dependency
28
+ name: irb
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.4'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.4'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rdoc
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '6.4'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '6.4'
55
+ description: Directly control Kasa devices
56
+ email:
57
+ - christj@gmail.com
58
+ executables: []
59
+ extensions: []
60
+ extra_rdoc_files: []
61
+ files:
62
+ - ".rspec"
63
+ - ".rubocop.yml"
64
+ - Gemfile
65
+ - README.md
66
+ - Rakefile
67
+ - bin/console
68
+ - bin/setup
69
+ - lib/kasa.rb
70
+ - lib/kasa/devices.rb
71
+ - lib/kasa/factory.rb
72
+ - lib/kasa/protocol.rb
73
+ - lib/kasa/version.rb
74
+ homepage: https://github.com/sevendials/ruby-kasa
75
+ licenses:
76
+ - GPL-3.0
77
+ metadata:
78
+ homepage_uri: https://github.com/sevendials/ruby-kasa
79
+ source_code_uri: https://github.com/sevendials/ruby-kasa
80
+ post_install_message:
81
+ rdoc_options: []
82
+ require_paths:
83
+ - lib
84
+ required_ruby_version: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: 2.6.0
89
+ required_rubygems_version: !ruby/object:Gem::Requirement
90
+ requirements:
91
+ - - ">="
92
+ - !ruby/object:Gem::Version
93
+ version: '0'
94
+ requirements: []
95
+ rubygems_version: 3.2.22
96
+ signing_key:
97
+ specification_version: 4
98
+ summary: TP-Link Kasa
99
+ test_files: []