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 +7 -0
- data/.rubocop.yml +8 -0
- data/LICENSE.txt +21 -0
- data/README.org +73 -0
- data/Rakefile +12 -0
- data/exe/tapocon +90 -0
- data/lib/tapocon/scanner.rb +125 -0
- data/lib/tapocon/switch.rb +256 -0
- data/lib/tapocon/version.rb +5 -0
- data/lib/tapocon.rb +18 -0
- data/sig/tapocon.rbs +4 -0
- metadata +69 -0
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
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
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
|
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
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: []
|