tapocon 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: a429caf2dbda4dbfc26ccceb514e8970052f444bc140fa55fc47d1583afabc0d
4
+ data.tar.gz: 51308025fc9e4519e86b5802e60992651448a0c656616c4ade50e569899dbabc
5
+ SHA512:
6
+ metadata.gz: 5deb333b1e3b74df1347c31aba89f97e78f103868a2245021dadb18a16090cd4eaa7465d465855bdd9625aa6dc8770d7fcb36410e14942e662a62f604c459f7a
7
+ data.tar.gz: abd21aa82394a76ef4e1d480645eee4d962e6ef866f8f3f1f25ffbf94703b2ababd1c455b296005030818ac5044e24d860795042e7a8f4f415bdbcd0f648d88d
data/.rubocop.yml ADDED
@@ -0,0 +1,8 @@
1
+ AllCops:
2
+ TargetRubyVersion: 3.0
3
+
4
+ Style/StringLiterals:
5
+ EnforcedStyle: double_quotes
6
+
7
+ Style/StringLiteralsInInterpolation:
8
+ EnforcedStyle: double_quotes
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2024 Yoshinari Nomura
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.org ADDED
@@ -0,0 +1,73 @@
1
+ #+TITLE: tapocon -- CLI for TAPO P105 and variants
2
+ #+AUTHOR: Yoshinari Nomura
3
+ #+EMAIL:
4
+ #+DATE: 2024-12-12
5
+ #+OPTIONS: H:3 num:2 toc:nil
6
+ #+OPTIONS: ^:nil @:t \n:nil ::t |:t f:t TeX:t
7
+ #+OPTIONS: skip:nil
8
+ #+OPTIONS: author:t
9
+ #+OPTIONS: email:nil
10
+ #+OPTIONS: creator:nil
11
+ #+OPTIONS: timestamp:nil
12
+ #+OPTIONS: timestamps:nil
13
+ #+OPTIONS: d:nil
14
+ #+OPTIONS: tags:t
15
+ #+TEXT:
16
+ #+DESCRIPTION:
17
+ #+KEYWORDS:
18
+ #+LANGUAGE: ja
19
+ #+LATEX_CLASS: jsarticle
20
+ #+LATEX_CLASS_OPTIONS: [a4j]
21
+ # #+LATEX_HEADER: \usepackage{plain-article}
22
+ # #+LATEX_HEADER: \renewcommand\maketitle{}
23
+ # #+LATEX_HEADER: \pagestyle{empty}
24
+ # #+LaTeX: \thispagestyle{empty}
25
+
26
+ [[file:https://badge.fury.io/rb/tapocon.svg]]
27
+
28
+ * DESCRIPTION
29
+ tapocon is a CLI for TAPO P105 and variants.
30
+
31
+ You can get the latest version from:
32
+ + https://github.com/yoshinari-nomura/tapocon
33
+
34
+ * INSTALL AND SETUP
35
+ ** Ruby CLI
36
+ tapocon CLI command can be installed from rubygems.org.
37
+ #+BEGIN_SRC shell-script
38
+ $ gem install tapocon
39
+ #+END_SRC
40
+
41
+ Or, if you want to install tapocon in a sandbox (recommended),
42
+ Bunlder would help you:
43
+ #+BEGIN_SRC shell-script
44
+ $ gem install bundler
45
+ $ mkdir -p /path/to/install/tapocon
46
+ $ cd /path/to/install/tapocon
47
+ $ bundle init
48
+ $ echo 'gem "tapocon"' >> Gemfile
49
+ $ bundle config set path vendor/bundle
50
+ $ bundle install
51
+ $ export PATH=/path/to/install/tapocon/exe:$PATH
52
+ $ tapocon -h
53
+ #+END_SRC
54
+
55
+ * USAGE
56
+ ** Ruby CLI (tapocon)
57
+ #+begin_example
58
+ Usage: tapocon scan
59
+ Usage: tapocon -t TARGET -u USERNAME -p PASSWORD OPERATION
60
+ HOSTNAME: IP address of the Tapo device
61
+ USERNAME: email of TP-Link ID
62
+ PASSWORD: password of TP-Link ID
63
+ OPERATION: on, off, toggle, info
64
+ #+end_example
65
+
66
+ * Development
67
+ TBD
68
+
69
+ * Contributing
70
+ Bug reports and pull requests are welcome on GitHub at https://github.com/yoshinari-nomura/tapocon.
71
+
72
+ * License
73
+ The gem is available as open source under the terms of the [[https://opensource.org/licenses/MIT][MIT License]]
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "minitest/test_task"
5
+
6
+ Minitest::TestTask.create
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[test rubocop]
data/exe/tapocon ADDED
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # rbenv support:
4
+ # If this file is a symlink, and bound to a specific ruby
5
+ # version via rbenv (indicated by RBENV_VERSION),
6
+ # I want to resolve the symlink and re-exec
7
+ # the original executable respecting the .ruby-version
8
+ # which should indicate the right version.
9
+ #
10
+ if File.symlink?(__FILE__) and ENV["RBENV_VERSION"]
11
+ ENV["RBENV_VERSION"] = nil
12
+ shims_path = File.expand_path("shims", ENV["RBENV_ROOT"])
13
+ ENV["PATH"] = shims_path + ":" + ENV["PATH"]
14
+ exec(File.readlink(__FILE__), *ARGV)
15
+ end
16
+
17
+ # If this file is located with Gemfile.lock,
18
+ # require bundler/setup
19
+ #
20
+ gemfile = File.expand_path("../../Gemfile", __FILE__)
21
+
22
+ if File.exist?(gemfile + ".lock")
23
+ ENV["BUNDLE_GEMFILE"] = gemfile
24
+ require "bundler/setup"
25
+ end
26
+
27
+ require 'tapocon'
28
+
29
+ def usage
30
+ puts 'Usage: tapocon scan'
31
+ puts 'Usage: tapocon -t TARGET -u USERNAME -p PASSWORD OPERATION'
32
+ puts ' HOSTNAME: IP address of the Tapo device'
33
+ puts ' USERNAME: email of TP-Link ID'
34
+ puts ' PASSWORD: password of TP-Link ID'
35
+ puts ' OPERATION: on, off, toggle, info'
36
+ end
37
+
38
+ # Usage: tapocon -t TARGET -u USERNAME -p PASSWORD OPERATION
39
+ # HOSTNAME: 192.168.0.1
40
+ # USERNAME: from TP-Link ID (alice@example.com)
41
+ # PASSWORD: from TP-Link ID (password)
42
+ # OPERATION: on, off, toggle, info, scan
43
+ #
44
+ while ARGV[0] =~ /^-(.)/
45
+ opt, val = $1, ARGV[1]
46
+
47
+ case opt
48
+ when 't'; target = val
49
+ when 'u'; username = val
50
+ when 'p'; password = val
51
+ else
52
+ puts "Unknown option: #{ARGV[0]}"
53
+ usage
54
+ exit 1
55
+ end
56
+ ARGV.shift(2)
57
+ end
58
+
59
+ if ARGV.size != 1
60
+ usage
61
+ exit 1
62
+ end
63
+
64
+ operation = ARGV.shift
65
+
66
+ if operation =~ /^on|off|toggle|info$/
67
+ if target.nil? || username.nil? || password.nil?
68
+ puts 'Missing target, username or password'
69
+ usage
70
+ exit 1
71
+ else
72
+ tapo = Tapocon::Switch.new(target, username, password)
73
+ tapo.handshake
74
+ case operation
75
+ when 'on'; tapo.turn_on
76
+ when 'off'; tapo.turn_off
77
+ when 'toggle'; tapo.toggle
78
+ when 'info'; puts tapo.info.to_h
79
+ end
80
+ end
81
+
82
+ elsif operation == 'scan'
83
+ tapo = Tapocon::Scanner.new
84
+ tapo.scan
85
+
86
+ else
87
+ puts "Unknown operation: #{operation}"
88
+ usage
89
+ exit 1
90
+ end
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # Find HS105 (legacy) and P105 (new)
4
+ # https://github.com/python-kasa/python-kasa/blob/master/kasa/discover.py#L169
5
+
6
+ require 'socket'
7
+ require 'openssl'
8
+ require 'securerandom'
9
+ require 'json'
10
+ require 'zlib'
11
+
12
+ module Tapocon
13
+ class Scanner
14
+ def initialize
15
+ @socket = UDPSocket.new
16
+ @socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_BROADCAST, true)
17
+ end
18
+
19
+ def scan
20
+ scan_new_devices(@socket)
21
+ scan_old_devices(@socket)
22
+ end
23
+
24
+ private
25
+
26
+ def scan_old_devices(socket)
27
+ addr, port = '255.255.255.255', 9999
28
+ cipher = Cipher.new
29
+ msg = cipher.xor_encrypt('{"system": {"get_sysinfo": null}}')
30
+
31
+ socket.send(msg, 0, addr, port)
32
+ socket.timeout = 3
33
+
34
+ res = nil
35
+ begin
36
+ res, sender = socket.recvfrom(1024)
37
+ res = cipher.xor_decrypt(res)
38
+ puts "Received response from #{sender}: #{res}"
39
+ rescue IO::WaitReadable, Errno::ETIMEDOUT
40
+ puts "No response received within timeout period."
41
+ ensure
42
+ # socket.close
43
+ end
44
+ return res
45
+ end
46
+
47
+ def scan_new_devices(socket)
48
+ addr, port = '255.255.255.255', 20002
49
+ msg = QueryGenerator.new.generate_query
50
+
51
+ socket.send(msg, 0, addr, port)
52
+ socket.timeout = 3
53
+
54
+ res = nil
55
+ begin
56
+ res, sender = socket.recvfrom(1024)
57
+ res = res[16..]
58
+ puts "Received response from #{sender}: #{res}"
59
+ rescue IO::WaitReadable, Errno::ETIMEDOUT
60
+ puts "No response received within timeout period."
61
+ ensure
62
+ # socket.close
63
+ end
64
+ return res
65
+ end
66
+
67
+ class Cipher
68
+ INITIAL_KEY = 171
69
+
70
+ def xor_encrypt(data, key = INITIAL_KEY)
71
+ data.bytes.map do |byte|
72
+ key = byte ^ key
73
+ end.pack("C*")
74
+ end
75
+
76
+ def xor_decrypt(data, key = INITIAL_KEY)
77
+ data.bytes.map do |byte|
78
+ result = byte ^ key
79
+ key = byte
80
+ result
81
+ end.pack("C*")
82
+ end
83
+ end
84
+
85
+ class QueryGenerator
86
+ def initialize
87
+ @keys = OpenSSL::PKey::RSA.new(2048)
88
+ end
89
+
90
+ def generate_query
91
+ secret = SecureRandom.random_bytes(4)
92
+
93
+ params = {params:{rsa_key: @keys.public_key.to_pem}}
94
+ json = JSON.generate(params).force_encoding("ASCII-8BIT")
95
+
96
+ version = 2
97
+ msgtype = 0
98
+ op_code = 1
99
+ msgsize = json.bytesize
100
+ flagchr = 17
101
+ padding = 0
102
+ serials = secret.unpack1("N")
103
+ crc_ini = 0x5a6b7c8d
104
+
105
+ header = [version, msgtype, op_code,
106
+ msgsize, flagchr, padding,
107
+ serials, crc_ini].pack("CCSSCCNN")
108
+ query = header + json
109
+
110
+ crc = Zlib.crc32(query).to_s(16).rjust(8, '0').scan(/../).map {
111
+ |x| x.to_i(16).chr
112
+ }.join
113
+
114
+ query[12, 4] = crc
115
+ query
116
+ end
117
+ end
118
+ end
119
+ end
120
+
121
+ # Example usage
122
+ if __FILE__ == $PROGRAM_NAME
123
+ scanner = Tapocon::Scanner.new
124
+ scanner.scan
125
+ end
@@ -0,0 +1,256 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'logger'
5
+ require 'net/http'
6
+ require 'openssl'
7
+ require 'securerandom'
8
+
9
+ $LOGLEVEL = Logger::ERROR
10
+ # $LOGLEVEL = Logger::DEBUG
11
+
12
+ $DEBUG = ($LOGLEVEL == Logger::DEBUG)
13
+
14
+ module Tapocon
15
+ def dump_hex(str)
16
+ str.each_byte.map{|f| "%02x" % f}.join(' ')
17
+ end
18
+
19
+ class Cipher
20
+ attr_reader :local_seed, :sequence
21
+
22
+ def initialize(username, password, logger = nil)
23
+ @username, @password = username, password
24
+ @local_seed = SecureRandom.random_bytes(16)
25
+ @aes_key, @aes_iv, @sequence, @signature = nil, nil, nil, nil
26
+ @logger = logger || Logger.new(nil)
27
+ end
28
+
29
+ def aes_setup(handshake_key)
30
+ remote_seed = handshake_key[0, 16] # 16B seed
31
+ server_hash = handshake_key[16..] # SHA256 (32B)
32
+
33
+ puts "remote_seed: #{dump_hex(remote_seed)}" if $DEBUG
34
+ puts "server_hash: #{dump_hex(server_hash)}" if $DEBUG
35
+
36
+ credentials = [
37
+ [@username, @password],
38
+ ['', ''],
39
+ ['kasa@tp-link.net', 'kasaSetup'],
40
+ ]
41
+
42
+ auth_hash = nil
43
+ credentials.each do |username, password|
44
+ @logger.debug("Try Auth: #{username}, #{password}")
45
+ ah = calc_auth_hash(username, password)
46
+ if sha256(@local_seed + remote_seed + ah) == server_hash
47
+ auth_hash = ah
48
+ @logger.debug("Authenticated with #{username}")
49
+ break
50
+ end
51
+ end
52
+ raise "Failed to authenticate" unless auth_hash
53
+
54
+ combined_seed = @local_seed + remote_seed + auth_hash
55
+ @aes_key = sha256("lsk".b + combined_seed)[0, 16]
56
+ ivseq = sha256("iv".b + combined_seed)
57
+ @aes_iv = ivseq[0, 12]
58
+ @sequence = ivseq[-4, 4].unpack1("N") # uint32 big endian
59
+ @signature = sha256("ldk".b + combined_seed)[0, 28]
60
+ @logger.debug("Initialized")
61
+
62
+ res = sha256(remote_seed + @local_seed + auth_hash)
63
+ return res
64
+ end
65
+
66
+ def encrypt(data)
67
+ raise "Cipher not initialized" unless @aes_key && @aes_iv && @signature
68
+
69
+ @sequence += 1
70
+ seq = [@sequence].pack("N")
71
+
72
+ pad_size = 16 - (data.bytesize % 16)
73
+ padded_data = data + (pad_size.chr * pad_size)
74
+
75
+ cipher = OpenSSL::Cipher.new('AES-128-CBC')
76
+ cipher.encrypt
77
+ cipher.key = @aes_key
78
+ cipher.iv = @aes_iv + seq
79
+
80
+ bin = cipher.update(padded_data) + cipher.final
81
+ sig = sha256(@signature + seq + bin)
82
+ sig + bin
83
+ end
84
+
85
+ def decrypt(data)
86
+ raise "Cipher not initialized" unless @aes_key && @aes_iv && @sequence
87
+
88
+ seq = [@sequence].pack("N")
89
+
90
+ cipher = OpenSSL::Cipher.new('AES-128-CBC')
91
+ cipher.decrypt
92
+ cipher.key = @aes_key
93
+ cipher.iv = @aes_iv + seq
94
+
95
+ decrypted_data = cipher.update(data[32..]) + cipher.final
96
+
97
+ if $DEBUG
98
+ # padding seems to be already removed
99
+ puts "decrypted_data size: #{decrypted_data.size}"
100
+ puts "pad size: #{decrypted_data[-1].ord}"
101
+ end
102
+ decrypted_data
103
+ end
104
+
105
+ private
106
+
107
+ def sha1(data)
108
+ OpenSSL::Digest::SHA1.digest(data)
109
+ end
110
+
111
+ def sha256(data)
112
+ OpenSSL::Digest::SHA256.digest(data)
113
+ end
114
+
115
+ def calc_auth_hash(username, password)
116
+ sha256(sha1(username) + sha1(password))
117
+ end
118
+ end # class Cipher
119
+
120
+ class Switch
121
+ def initialize(ip, username, password)
122
+ @logger = Logger.new(STDOUT, level: $LOGLEVEL)
123
+ @ip = ip
124
+ @cipher = Cipher.new(username, password, @logger)
125
+ @cookie = nil
126
+ end
127
+
128
+ def handshake
129
+ handshake1_key = rpc_raw(:handshake1, @cipher.local_seed)
130
+ handshake2_key = @cipher.aes_setup(handshake1_key)
131
+ res = rpc_raw(:handshake2, handshake2_key)
132
+ end
133
+
134
+ def turn_on(delay: 0)
135
+ turn_to(true, delay: delay)
136
+ end
137
+
138
+ def turn_off(delay: 0)
139
+ turn_to(false, delay: delay)
140
+ end
141
+
142
+ def toggle(delay: 0)
143
+ if on?
144
+ turn_off(delay: delay)
145
+ else
146
+ turn_on(delay: delay)
147
+ end
148
+ end
149
+
150
+ def turn_to(state, delay: 0)
151
+ if delay > 0
152
+ rpc(:add_countdown_rule,
153
+ delay: delay,
154
+ desired_states: state ? :on : :off)
155
+ else
156
+ rpc(:set_device_info,
157
+ device_on: state)
158
+ end
159
+ end
160
+
161
+ def on?
162
+ info()['device_on'] == true
163
+ end
164
+
165
+ def info
166
+ rpc(:get_device_info)
167
+ end
168
+
169
+ private
170
+
171
+ # XXX: WIP: connection pool for keep-alive?
172
+ def http_connect(uri)
173
+ return @connection if @connection
174
+ @connection = Net::HTTP.new(uri.host, uri.port, nil, nil)
175
+ @connection.use_ssl = (uri.scheme == 'https')
176
+ @connection.open_timeout = 1.5
177
+ @connection.read_timeout = 1.5
178
+ @connection.keep_alive_timeout = 5
179
+ @connection.start
180
+ end
181
+
182
+ def rpc_raw(name, data, **params)
183
+ uri = URI("http://#{@ip}/app/#{name}")
184
+ uri.query = URI.encode_www_form(params) unless params.empty?
185
+ connection = http_connect(uri)
186
+
187
+ req = Net::HTTP::Post.new(uri)
188
+ req.content_type = 'application/octet-stream'
189
+ req.body = data
190
+ req['Connection'] = 'Keep-Alive'
191
+ req['User-Agent'] = 'python-requests/2.25.1'
192
+ req['Accept-Encoding'] = 'gzip, deflate'
193
+ req['Accept'] = '*/*'
194
+
195
+ if @cookie
196
+ puts "Cookie: #{@cookie}" if $DEBUG
197
+ req['Cookie'] = @cookie
198
+ end
199
+
200
+ res = connection.request(req)
201
+
202
+ if $DEBUG
203
+ puts "URI: #{uri}"
204
+ puts "Request: #{req}"
205
+ puts "HTTP Method: #{req.method}"
206
+ puts "Headers:"
207
+ req.each_header { |key, value| puts "#{key}: #{value}" }
208
+ query_params = uri.query
209
+ puts "Query Parameters: '#{query_params}'"
210
+ end
211
+
212
+ if res['Set-Cookie']
213
+ @cookie = res['Set-Cookie'].sub(/;.*$/, '')
214
+ end
215
+
216
+ if $DEBUG
217
+ puts "RPC_RAW result:"
218
+ puts "code: #{res.code}, body-size: #{res.body.size}"
219
+ puts "body: #{dump_hex(res.body)}"
220
+ puts "body_raw: #{res.body}"
221
+ end
222
+ return res.body
223
+ end
224
+
225
+ def rpc(name, **params)
226
+ req = {method: name}
227
+ req.merge!(params: params) unless params.empty?
228
+
229
+ @logger.debug("Request: #{req.to_json}")
230
+ res = rpc_raw(:request, @cipher.encrypt(req.to_json),
231
+ seq: @cipher.sequence)
232
+
233
+ data = JSON.parse(@cipher.decrypt(res))
234
+ @logger.debug("Response: #{data}")
235
+
236
+ if data['error_code'] != 0
237
+ @logger.error("Error: #{data}")
238
+ @aes_key = nil
239
+ raise "Error code: #{data['error_code']}"
240
+ end
241
+ data['result']
242
+ end
243
+ end # class P100
244
+ end # module Tapo
245
+
246
+ # Example usage
247
+ if __FILE__ == $PROGRAM_NAME
248
+ tapo = Tapocon::Switch.new("192.168.11.31", "alice@example.com", "password")
249
+ tapo.handshake
250
+ puts tapo.info
251
+
252
+ while true
253
+ tapo.toggle
254
+ sleep(5)
255
+ end
256
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tapocon
4
+ VERSION = "0.1.0"
5
+ end
data/lib/tapocon.rb ADDED
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ # TP-Link TAPO P105 client in Ruby
4
+ # Yoshinari Nomura 2024-12-09 nom@quickhack.net
5
+ #
6
+ # Original Python version:
7
+ # GitHub almottier/TapoP100
8
+ # https://github.com/almottier/TapoP100
9
+
10
+ module Tapocon
11
+ class Error < StandardError; end
12
+
13
+ dir = File.dirname(__FILE__) + '/tapocon'
14
+ autoload :Scanner, "#{dir}/scanner.rb"
15
+ autoload :Switch, "#{dir}/switch.rb"
16
+ autoload :Version, "#{dir}/version.rb"
17
+ autoload :MQTT, "#{dir}/mqtt.rb"
18
+ end
data/sig/tapocon.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module Tapocon
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,69 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: tapocon
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Yoshinari Nomura
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2024-12-12 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: mqtt
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: 0.6.0
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: 0.6.0
27
+ description: CLI for TAPO P105 and variants.
28
+ email:
29
+ - nom@quickhack.net
30
+ executables:
31
+ - tapocon
32
+ extensions: []
33
+ extra_rdoc_files: []
34
+ files:
35
+ - ".rubocop.yml"
36
+ - LICENSE.txt
37
+ - README.org
38
+ - Rakefile
39
+ - exe/tapocon
40
+ - lib/tapocon.rb
41
+ - lib/tapocon/scanner.rb
42
+ - lib/tapocon/switch.rb
43
+ - lib/tapocon/version.rb
44
+ - sig/tapocon.rbs
45
+ homepage: https://github.com/yoshinari-nomura/tapocon
46
+ licenses:
47
+ - MIT
48
+ metadata:
49
+ homepage_uri: https://github.com/yoshinari-nomura/tapocon
50
+ post_install_message:
51
+ rdoc_options: []
52
+ require_paths:
53
+ - lib
54
+ required_ruby_version: !ruby/object:Gem::Requirement
55
+ requirements:
56
+ - - ">="
57
+ - !ruby/object:Gem::Version
58
+ version: '0'
59
+ required_rubygems_version: !ruby/object:Gem::Requirement
60
+ requirements:
61
+ - - ">="
62
+ - !ruby/object:Gem::Version
63
+ version: '0'
64
+ requirements: []
65
+ rubygems_version: 3.5.22
66
+ signing_key:
67
+ specification_version: 4
68
+ summary: CLI for TAPO P105 and variants
69
+ test_files: []