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 +7 -0
- data/LICENSE +21 -0
- data/README.md +76 -0
- data/lib/tapo/client.rb +254 -0
- data/lib/tapo/discovery.rb +91 -0
- data/lib/tapo/errors.rb +15 -0
- data/lib/tapo/klap_cipher.rb +71 -0
- data/lib/tapo/protocol.rb +42 -0
- data/lib/tapo/version.rb +5 -0
- data/lib/tapo.rb +27 -0
- metadata +53 -0
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
|
+
```
|
data/lib/tapo/client.rb
ADDED
|
@@ -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
|
data/lib/tapo/errors.rb
ADDED
|
@@ -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
|
data/lib/tapo/version.rb
ADDED
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: []
|