tapo 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: 0b795da61e6ac0d9e745b5a1008855df9cf4b7aeb5a34bf1e985ab32de9ac49c
4
+ data.tar.gz: 666774cf9506187ad94b35bded144825b39ca5810bcc1deb23da61f78417d474
5
+ SHA512:
6
+ metadata.gz: 0107d09fda4b54bef29ccc188e72b62a2888f4723f7b0c21c47f5b6f248162b5b625ae7ea747563b138b5b503e525f0ac4cdc76e07c0e73e67ecbb4aea92f9aa
7
+ data.tar.gz: a7d92c3da872d88be6b8c30168379fbda9b9e9770539e303f861a857e81f7cea4f3324eb126b0ff510b34a7753a2e51ae2e596145e5cb1215235200fb0d03426
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ashish Rao
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 all
13
+ 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 THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,76 @@
1
+ # Tapo Ruby Client
2
+
3
+ A Ruby library for controlling TP-Link Tapo smart devices (P100, P110).
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ ```ruby
10
+ gem 'tapo'
11
+ ```
12
+
13
+ Or install it yourself:
14
+
15
+ ```bash
16
+ gem install tapo
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ ### Basic Control
22
+
23
+ ```ruby
24
+ require 'tapo'
25
+
26
+ # Connect to a device
27
+ client = Tapo::Client.new('192.168.1.100', 'your-email@example.com', 'your-password')
28
+ client.authenticate!
29
+
30
+ # Control device
31
+ client.on
32
+ client.off
33
+
34
+ # Check status
35
+ client.on? # => true/false
36
+ client.nickname # => "Living Room Lamp"
37
+
38
+ # Get device info
39
+ info = client.device_info
40
+ ```
41
+
42
+ ### Energy Monitoring (P110 only)
43
+
44
+ ```ruby
45
+ # Quick access to electrical measurements
46
+ client.power_usage # => 12.5 (watts)
47
+ client.voltage # => 230.5 (volts) (if supported)
48
+ client.current # => 0.054 (amps) (if supported)
49
+
50
+ energy = client.energy_usage
51
+ # => {
52
+ # "power_w" => 12.5, # Power in watts (W)
53
+ # "power_kw" => 0.012, # Power in kilowatts (kW)
54
+ # "voltage_v" => 230.5, # Voltage in volts (V)
55
+ # "current_a" => 0.054, # Current in amps (A)
56
+ # "today_energy_wh" => 25, # Today's energy in watt-hours (Wh)
57
+ # "today_energy_kwh" => 0.025, # Today's energy in kilowatt-hours (kWh)
58
+ # "month_energy_wh" => 350, # Month's energy in watt-hours (Wh)
59
+ # "month_energy_kwh" => 0.350, # Month's energy in kilowatt-hours (kWh)
60
+ # "today_runtime_min" => 120, # Today's runtime in minutes
61
+ # "today_runtime_hours" => 2.0, # Today's runtime in hours
62
+ # "month_runtime_min" => 5400, # Month's runtime in minutes
63
+ # "month_runtime_hours" => 90.0 # Month's runtime in hours
64
+ # }
65
+ ```
66
+
67
+ ### Device Discovery
68
+
69
+ ```ruby
70
+ # Find all devices on your network
71
+ devices = Tapo::Discovery.discover
72
+
73
+ devices.each do |ip|
74
+ puts "Found device at #{ip}"
75
+ end
76
+ ```
@@ -0,0 +1,254 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'json'
5
+ require 'base64'
6
+ require 'digest'
7
+
8
+ module Tapo
9
+ class Client
10
+ attr_reader :device_ip
11
+
12
+ # @param device_ip [String] IP address of the Tapo device
13
+ # @param username [String] Tapo account email address
14
+ # @param password [String] Tapo account password
15
+ def initialize(device_ip, username, password)
16
+ @device_ip = device_ip
17
+ @username = username
18
+ @password = password
19
+ @session = nil
20
+ end
21
+
22
+ # @return [Boolean] true if authentication successful
23
+ # @raise [AuthenticationError] if authentication fails
24
+ def authenticate!
25
+ protocol = Protocol.detect(@device_ip)
26
+
27
+ case protocol
28
+ when :klap
29
+ authenticate_klap!
30
+ when :passthrough
31
+ raise UnsupportedProtocolError, 'Passthrough protocol not yet implemented'
32
+ else
33
+ raise AuthenticationError, 'Unable to detect device protocol'
34
+ end
35
+
36
+ true
37
+ end
38
+
39
+ # @return [Boolean] true if authenticated
40
+ def authenticated?
41
+ !@session.nil?
42
+ end
43
+
44
+ # @return [Hash] Response from device
45
+ def on
46
+ set_device_info(device_on: true)
47
+ end
48
+
49
+ # @return [Hash] Response from device
50
+ def off
51
+ set_device_info(device_on: false)
52
+ end
53
+
54
+ # @return [Hash] Device information including name, state, model, etc.
55
+ def device_info
56
+ response = request(method: 'get_device_info')
57
+ result = response['result']
58
+
59
+ # Decode base64-encoded fields
60
+ result['nickname'] = Base64.decode64(result['nickname']) if result['nickname']
61
+ result['ssid'] = Base64.decode64(result['ssid']) if result['ssid']
62
+
63
+ result
64
+ end
65
+
66
+ # @return [Hash] Energy usage data with proper units
67
+ def energy_usage
68
+ response = request(method: 'get_energy_usage')
69
+ result = response['result']
70
+
71
+ # Convert power: milliwatts -> watts
72
+ if result['current_power']
73
+ result['power_w'] = (result['current_power'] / 1000.0).round(3)
74
+ result['power_kw'] = (result['current_power'] / 1_000_000.0).round(3)
75
+ end
76
+
77
+ # Convert voltage: millivolts -> volts
78
+ if result['voltage_mv']
79
+ result['voltage_v'] = (result['voltage_mv'] / 1000.0).round(2)
80
+ end
81
+
82
+ # Convert current: milliamps -> amps
83
+ if result['current_ma']
84
+ result['current_a'] = (result['current_ma'] / 1000.0).round(3)
85
+ end
86
+
87
+ # Energy consumption with units
88
+ if result['today_energy']
89
+ result['today_energy_wh'] = result['today_energy']
90
+ result['today_energy_kwh'] = (result['today_energy'] / 1000.0).round(3)
91
+ end
92
+
93
+ if result['month_energy']
94
+ result['month_energy_wh'] = result['month_energy']
95
+ result['month_energy_kwh'] = (result['month_energy'] / 1000.0).round(3)
96
+ end
97
+
98
+ # Runtime with units
99
+ result['today_runtime_min'] = result['today_runtime'] if result['today_runtime']
100
+ result['month_runtime_min'] = result['month_runtime'] if result['month_runtime']
101
+
102
+ if result['today_runtime']
103
+ result['today_runtime_hours'] = (result['today_runtime'] / 60.0).round(2)
104
+ end
105
+
106
+ if result['month_runtime']
107
+ result['month_runtime_hours'] = (result['month_runtime'] / 60.0).round(2)
108
+ end
109
+
110
+ result
111
+ end
112
+
113
+ # @return [Float] Current power in watts (W)
114
+ def power_usage
115
+ energy = energy_usage
116
+ energy['power_w']
117
+ end
118
+
119
+
120
+ # @return [Float, nil] Current voltage in volts (V)
121
+ def voltage
122
+ energy = energy_usage
123
+ energy['voltage_v']
124
+ end
125
+
126
+ # @return [Float, nil] Current in amps (A)
127
+ def current
128
+ energy = energy_usage
129
+ energy['current_a']
130
+ end
131
+
132
+ # @return [Boolean] true if device is on
133
+ def on?
134
+ device_info['device_on']
135
+ end
136
+
137
+ # Check if device is off
138
+ #
139
+ # @return [Boolean] true if device is off
140
+ def off?
141
+ !on?
142
+ end
143
+
144
+ # @return [String] Device nickname
145
+ def nickname
146
+ device_info['nickname']
147
+ end
148
+
149
+ # @param params [Hash] Parameters to set (e.g., device_on: true)
150
+ # @return [Hash] Response from device
151
+ def set_device_info(params)
152
+ request(method: 'set_device_info', params: params)
153
+ end
154
+
155
+ # @param request_data [Hash] Request data
156
+ # @return [Hash] Response from device
157
+ # @raise [SessionExpiredError] if session has expired
158
+ # @raise [RequestError] if request fails
159
+ def request(request_data)
160
+ authenticate! unless authenticated?
161
+
162
+ json_payload = request_data.to_json
163
+
164
+ encrypted_payload, seq = @session[:cipher].encrypt(json_payload)
165
+
166
+ http = @session[:http]
167
+ request = Net::HTTP::Post.new("/app/request?seq=#{seq}", 'Content-Type' => 'application/octet-stream')
168
+ request['Cookie'] = @session[:cookie]
169
+ request.body = encrypted_payload
170
+
171
+ response = http.request(request)
172
+
173
+ if response.code == '403'
174
+ @session = nil
175
+ raise SessionExpiredError, 'Session expired, please re-authenticate'
176
+ end
177
+
178
+ raise RequestError, "Request failed with status #{response.code}" if response.code != '200'
179
+
180
+ decrypted_response = @session[:cipher].decrypt(response.body, seq)
181
+ result = JSON.parse(decrypted_response)
182
+
183
+ if result['error_code'] != 0
184
+ raise RequestError, "Device returned error code: #{result['error_code']}"
185
+ end
186
+
187
+ result
188
+ rescue SessionExpiredError
189
+ authenticate!
190
+ retry
191
+ end
192
+
193
+ private
194
+
195
+ def authenticate_klap!
196
+ base_url = "http://#{@device_ip}/app"
197
+ uri = URI(base_url)
198
+ http = Net::HTTP.new(uri.host, uri.port)
199
+ http.read_timeout = 5
200
+ http.open_timeout = 5
201
+
202
+ username_hash = Digest::SHA1.digest(@username)
203
+ password_hash = Digest::SHA1.digest(@password)
204
+ auth_hash = Digest::SHA256.digest(username_hash + password_hash)
205
+
206
+ local_seed = OpenSSL::Random.random_bytes(16)
207
+
208
+ handshake1_req = Net::HTTP::Post.new('/app/handshake1', 'Content-Type' => 'application/octet-stream')
209
+ handshake1_req.body = local_seed
210
+
211
+ handshake1_res = http.request(handshake1_req)
212
+ raise AuthenticationError, "Handshake1 failed: #{handshake1_res.code}" if handshake1_res.code != '200'
213
+
214
+ cookie_header = handshake1_res['set-cookie']
215
+ cookie = nil
216
+ if cookie_header && cookie_header =~ /TP_SESSIONID=([^;]+)/
217
+ cookie = "TP_SESSIONID=#{Regexp.last_match(1)}"
218
+ end
219
+
220
+ response_body = handshake1_res.body
221
+ raise AuthenticationError, 'Response too small' if response_body.bytesize < 48
222
+
223
+ response_body = response_body[0, 48] if response_body.bytesize > 48
224
+
225
+ remote_seed = response_body[0, 16]
226
+ server_hash = response_body[16, 32]
227
+
228
+ local_hash = Digest::SHA256.digest(local_seed + remote_seed + auth_hash)
229
+ if local_hash != server_hash
230
+ raise AuthenticationError, 'Authentication failed - wrong credentials'
231
+ end
232
+
233
+ client_hash = Digest::SHA256.digest(remote_seed + local_seed + auth_hash)
234
+
235
+ handshake2_req = Net::HTTP::Post.new('/app/handshake2', 'Content-Type' => 'application/octet-stream')
236
+ handshake2_req['Cookie'] = cookie if cookie
237
+ handshake2_req.body = client_hash
238
+
239
+ handshake2_res = http.request(handshake2_req)
240
+ raise AuthenticationError, "Handshake2 failed: #{handshake2_res.code}" if handshake2_res.code != '200'
241
+
242
+ cipher = KlapCipher.new(local_seed, remote_seed, auth_hash)
243
+
244
+ @session = {
245
+ cookie: cookie,
246
+ cipher: cipher,
247
+ device_ip: @device_ip,
248
+ http: http
249
+ }
250
+ rescue Net::OpenTimeout, Net::ReadTimeout => e
251
+ raise AuthenticationError, "Device not responding: #{e.message}"
252
+ end
253
+ end
254
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'socket'
4
+ require 'timeout'
5
+ require 'openssl'
6
+ require 'json'
7
+ require 'digest/crc32'
8
+
9
+ module Tapo
10
+ module Discovery
11
+ DISCOVERY_PORT = 20002
12
+ BROADCAST_ADDR = '255.255.255.255'
13
+
14
+ # @param timeout [Integer] How long to listen for responses in seconds
15
+ # @return [Array<String>] Array of discovered device IP addresses
16
+ def self.discover(timeout: 5)
17
+ socket = UDPSocket.new
18
+ socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_BROADCAST, true)
19
+ socket.bind('0.0.0.0', 0)
20
+
21
+ payload = build_discovery_payload
22
+ socket.send(payload, 0, BROADCAST_ADDR, DISCOVERY_PORT)
23
+
24
+ listen_for_responses(socket, timeout)
25
+ ensure
26
+ socket&.close
27
+ end
28
+
29
+ # @return [String] Binary discovery packet
30
+ def self.build_discovery_payload
31
+ rsa_key = OpenSSL::PKey::RSA.new(1024)
32
+ public_key_pem = rsa_key.public_key.to_pem
33
+
34
+ json_body = {
35
+ params: {
36
+ rsa_key: public_key_pem
37
+ }
38
+ }.to_json
39
+
40
+ version = 2
41
+ msg_type = 0
42
+ op_code = 1
43
+ msg_size = json_body.bytesize
44
+ flags = 17
45
+ padding = 0
46
+ device_serial = rand(0..0xFFFFFFFF)
47
+ crc_placeholder = 0x5A6B7C8D
48
+
49
+ header = [
50
+ version, msg_type, op_code, msg_size, flags, padding,
51
+ device_serial, crc_placeholder
52
+ ].pack('CCnnCCNN')
53
+
54
+ full_message_for_crc = header + json_body
55
+ real_crc = Digest::CRC32.checksum(full_message_for_crc)
56
+
57
+ header[12, 4] = [real_crc].pack('N')
58
+
59
+ header + json_body
60
+ end
61
+
62
+ # @param socket [UDPSocket] The socket to listen on
63
+ # @param timeout [Integer] How long to listen in seconds
64
+ # @return [Array<String>] Array of discovered IP addresses
65
+ def self.listen_for_responses(socket, timeout)
66
+ discovered_ips = []
67
+
68
+ begin
69
+ Timeout.timeout(timeout) do
70
+ loop do
71
+ ready = IO.select([socket])
72
+ next unless ready
73
+
74
+ _, addr_info = socket.recvfrom_nonblock(2048)
75
+ device_ip = addr_info[2]
76
+
77
+ discovered_ips << device_ip unless discovered_ips.include?(device_ip)
78
+ end
79
+ end
80
+ rescue Timeout::Error
81
+ puts "Timeout reached"
82
+ rescue IO::WaitReadable
83
+ puts "No response received"
84
+ end
85
+
86
+ discovered_ips
87
+ end
88
+
89
+ private_class_method :build_discovery_payload, :listen_for_responses
90
+ end
91
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tapo
4
+ class Error < StandardError; end
5
+
6
+ class DiscoveryError < Error; end
7
+
8
+ class AuthenticationError < Error; end
9
+
10
+ class RequestError < Error; end
11
+
12
+ class SessionExpiredError < Error; end
13
+
14
+ class UnsupportedProtocolError < Error; end
15
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+ require 'digest'
5
+
6
+ module Tapo
7
+ class KlapCipher
8
+ attr_reader :seq
9
+
10
+ # @param local_seed [String] 16-byte local seed
11
+ # @param remote_seed [String] 16-byte remote seed from device
12
+ # @param auth_hash [String] 32-byte authentication hash
13
+ def initialize(local_seed, remote_seed, auth_hash)
14
+ combined = local_seed + remote_seed + auth_hash
15
+
16
+ # Derive encryption key (16 bytes)
17
+ @key = Digest::SHA256.digest('lsk' + combined)[0, 16]
18
+
19
+ # Derive IV base (12 bytes) and initial sequence number
20
+ iv_hash = Digest::SHA256.digest('iv' + combined)
21
+ @iv_base = iv_hash[0, 12]
22
+ @seq = iv_hash[-4..-1].unpack1('N').to_i
23
+
24
+ # Derive signature key (28 bytes)
25
+ @signature_key = Digest::SHA256.digest('ldk' + combined)[0, 28]
26
+ end
27
+
28
+ # @param plaintext [String] The plaintext to encrypt
29
+ # @return [Array<String, Integer>] Encrypted payload (signature + ciphertext) and sequence number
30
+ def encrypt(plaintext)
31
+ @seq += 1
32
+
33
+ # Create message-specific IV
34
+ iv = @iv_base + [@seq].pack('N')
35
+
36
+ # Encrypt with AES-128-CBC
37
+ cipher = OpenSSL::Cipher.new('AES-128-CBC')
38
+ cipher.encrypt
39
+ cipher.key = @key
40
+ cipher.iv = iv
41
+ cipher.padding = 1 # PKCS7 padding
42
+
43
+ ciphertext = cipher.update(plaintext) + cipher.final
44
+
45
+ # Create signature
46
+ signature = Digest::SHA256.digest(@signature_key + [@seq].pack('N') + ciphertext)
47
+
48
+ [signature + ciphertext, @seq]
49
+ end
50
+
51
+ # @param encrypted_data [String] The encrypted data (signature + ciphertext)
52
+ # @param seq [Integer] The sequence number used for this message
53
+ # @return [String] Decrypted plaintext
54
+ def decrypt(encrypted_data, seq)
55
+ # Skip signature (first 32 bytes) and decrypt the rest
56
+ ciphertext = encrypted_data[32..-1]
57
+
58
+ # Create message-specific IV
59
+ iv = @iv_base + [seq].pack('N')
60
+
61
+ # Decrypt with AES-128-CBC
62
+ decipher = OpenSSL::Cipher.new('AES-128-CBC')
63
+ decipher.decrypt
64
+ decipher.key = @key
65
+ decipher.iv = iv
66
+ decipher.padding = 1 # PKCS7 padding
67
+
68
+ decipher.update(ciphertext) + decipher.final
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'json'
5
+
6
+ module Tapo
7
+ module Protocol
8
+
9
+ # @param device_ip [String] IP address of the device
10
+ # @return [Symbol, nil] :klap, :passthrough, or nil if not a Tapo device
11
+ def self.detect(device_ip)
12
+ uri = URI("http://#{device_ip}/")
13
+ http = Net::HTTP.new(uri.host, uri.port)
14
+ http.read_timeout = 5
15
+ http.open_timeout = 5
16
+
17
+ request = Net::HTTP::Post.new(uri.request_uri, 'Content-Type' => 'application/json')
18
+ request.body = { method: 'get_device_info' }.to_json
19
+
20
+ response = http.request(request)
21
+
22
+ return :klap if response.code == '401'
23
+
24
+ begin
25
+ json_response = JSON.parse(response.body)
26
+ error_code = json_response['error_code']
27
+
28
+ # Error code 1003 = method not found - device doesn't support Passthrough Protocol
29
+ return :klap if error_code == 1003
30
+
31
+ # Other error codes indicate Passthrough protocol
32
+ :passthrough
33
+ rescue JSON::ParserError
34
+ # HTML/XML response - not a Tapo device
35
+ nil
36
+ end
37
+ rescue StandardError => e
38
+ warn "Protocol detection error for #{device_ip}: #{e.message}" if ENV['DEBUG']
39
+ nil
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tapo
4
+ VERSION = '0.1.0'
5
+ end
data/lib/tapo.rb ADDED
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'tapo/version'
4
+ require_relative 'tapo/errors'
5
+ require_relative 'tapo/klap_cipher'
6
+ require_relative 'tapo/discovery'
7
+ require_relative 'tapo/protocol'
8
+ require_relative 'tapo/client'
9
+
10
+ # TP-Link Tapo Ruby Client
11
+ #
12
+ # A Ruby library for controlling TP-Link Tapo smart devices.
13
+ # Supports device discovery, authentication, and control operations.
14
+ #
15
+ # @example Basic usage
16
+ # client = Tapo::Client.new('192.168.1.100', 'user@example.com', 'password')
17
+ # client.authenticate!
18
+ # client.on
19
+ # puts "Power usage: #{client.power_usage}W"
20
+ #
21
+ # @example Discovery
22
+ # devices = Tapo::Discovery.discover
23
+ # devices.each do |ip|
24
+ # puts "Found device at #{ip}"
25
+ # end
26
+ module Tapo
27
+ end
metadata ADDED
@@ -0,0 +1,53 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: tapo
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Ashish Rao
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Control TP-Link Tapo smart plugs (P100, P110) with Ruby. Supports device
13
+ discovery, authentication via KLAP protocol, and energy monitoring.
14
+ email:
15
+ - ashishrao2598@gmail.com
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - LICENSE
21
+ - README.md
22
+ - lib/tapo.rb
23
+ - lib/tapo/client.rb
24
+ - lib/tapo/discovery.rb
25
+ - lib/tapo/errors.rb
26
+ - lib/tapo/klap_cipher.rb
27
+ - lib/tapo/protocol.rb
28
+ - lib/tapo/version.rb
29
+ homepage: https://github.com/ashishra0/tapo-ruby
30
+ licenses:
31
+ - MIT
32
+ metadata:
33
+ homepage_uri: https://github.com/ashishra0/tapo-ruby
34
+ source_code_uri: https://github.com/ashishrao7/tapo-ruby
35
+ changelog_uri: https://github.com/ashishrao7/tapo-ruby/blob/main/CHANGELOG.md
36
+ rdoc_options: []
37
+ require_paths:
38
+ - lib
39
+ required_ruby_version: !ruby/object:Gem::Requirement
40
+ requirements:
41
+ - - ">="
42
+ - !ruby/object:Gem::Version
43
+ version: 2.7.0
44
+ required_rubygems_version: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '0'
49
+ requirements: []
50
+ rubygems_version: 3.6.9
51
+ specification_version: 4
52
+ summary: Ruby client for TP-Link Tapo smart devices
53
+ test_files: []