rshelly 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/CHANGELOG.md +14 -0
- data/LICENSE.txt +21 -0
- data/README.md +207 -0
- data/exe/shelly +8 -0
- data/lib/rshelly/cli.rb +1067 -0
- data/lib/rshelly/client.rb +400 -0
- data/lib/rshelly/discovery.rb +371 -0
- data/lib/rshelly/error.rb +9 -0
- data/lib/rshelly/version.rb +5 -0
- data/lib/rshelly.rb +20 -0
- metadata +59 -0
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "ipaddr"
|
|
5
|
+
require "resolv"
|
|
6
|
+
require "shellwords"
|
|
7
|
+
require "socket"
|
|
8
|
+
require "timeout"
|
|
9
|
+
|
|
10
|
+
module Shelly
|
|
11
|
+
class Discovery
|
|
12
|
+
HTTP_SERVICE = "_http._tcp"
|
|
13
|
+
DEFAULT_TIMEOUT = 5
|
|
14
|
+
DEFAULT_CONCURRENCY = 32
|
|
15
|
+
COMMAND_TIMEOUT_GRACE = 1
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
def scan(network: nil, timeout: DEFAULT_TIMEOUT, concurrency: DEFAULT_CONCURRENCY, max_hosts: 4096, client_options: {})
|
|
19
|
+
network ||= default_ipv4_network
|
|
20
|
+
raise DiscoveryError, "could not determine local IPv4 network; pass a CIDR such as 192.168.0.0/24" unless network
|
|
21
|
+
|
|
22
|
+
addresses = network_addresses(network)
|
|
23
|
+
if max_hosts && addresses.length > max_hosts
|
|
24
|
+
raise DiscoveryError, "refusing to scan #{addresses.length} hosts; use --max-hosts to raise the limit"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
scan_addresses(addresses, timeout: timeout, concurrency: concurrency, client_options: client_options).sort_by do |device|
|
|
28
|
+
IPAddr.new(device["ip"]).to_i
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def discover(timeout: DEFAULT_TIMEOUT, verify: false, concurrency: DEFAULT_CONCURRENCY, client_options: {})
|
|
33
|
+
records = if executable?("avahi-browse")
|
|
34
|
+
discover_with_avahi(timeout: timeout)
|
|
35
|
+
elsif executable?("dns-sd")
|
|
36
|
+
discover_with_dns_sd(timeout: timeout)
|
|
37
|
+
else
|
|
38
|
+
raise DiscoveryError, "no mDNS discovery command found; install avahi-browse on Linux or use dns-sd on macOS"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
records = records.select { |record| shelly_name?(record[:name]) || shelly_name?(record[:hostname]) }
|
|
42
|
+
return records.map { |record| stringify_keys(record) } unless verify
|
|
43
|
+
|
|
44
|
+
records = records.reject { |record| link_local_ipv6?(record[:ip]) }
|
|
45
|
+
verify_records(records, timeout: timeout, concurrency: concurrency, client_options: client_options)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def discover_with_avahi(timeout: DEFAULT_TIMEOUT)
|
|
49
|
+
command_timeout = timeout + COMMAND_TIMEOUT_GRACE
|
|
50
|
+
output, status = capture_command_for(command_timeout, "avahi-browse", "-r", "-p", "-t", HTTP_SERVICE)
|
|
51
|
+
raise DiscoveryError, output.strip.empty? ? "avahi-browse failed" : output.strip unless status&.success?
|
|
52
|
+
|
|
53
|
+
parse_avahi_output(output)
|
|
54
|
+
rescue Errno::ENOENT
|
|
55
|
+
raise DiscoveryError, "avahi-browse not found"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def discover_with_dns_sd(timeout: DEFAULT_TIMEOUT)
|
|
59
|
+
browse_output = capture_for(timeout, "dns-sd", "-B", HTTP_SERVICE, "local")
|
|
60
|
+
instances = parse_dns_sd_browse(browse_output)
|
|
61
|
+
|
|
62
|
+
instances.filter_map do |instance|
|
|
63
|
+
output = capture_for(timeout, "dns-sd", "-L", instance, HTTP_SERVICE, "local")
|
|
64
|
+
parse_dns_sd_resolve(instance, output)
|
|
65
|
+
rescue DiscoveryError
|
|
66
|
+
nil
|
|
67
|
+
end
|
|
68
|
+
rescue Errno::ENOENT
|
|
69
|
+
raise DiscoveryError, "dns-sd not found"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def parse_avahi_output(output)
|
|
73
|
+
output.lines.filter_map do |line|
|
|
74
|
+
fields = line.strip.split(";")
|
|
75
|
+
next unless fields[0] == "=" && fields.length >= 9
|
|
76
|
+
|
|
77
|
+
name = fields[3]
|
|
78
|
+
hostname = fields[6]
|
|
79
|
+
ip = fields[7]
|
|
80
|
+
port = fields[8].to_i
|
|
81
|
+
txt = parse_avahi_txt(fields[9])
|
|
82
|
+
|
|
83
|
+
{
|
|
84
|
+
source: "avahi",
|
|
85
|
+
interface: fields[1],
|
|
86
|
+
protocol: fields[2],
|
|
87
|
+
name: name,
|
|
88
|
+
hostname: hostname,
|
|
89
|
+
ip: ip,
|
|
90
|
+
port: port,
|
|
91
|
+
gen: txt["gen"]&.to_i,
|
|
92
|
+
mac: extract_mac(name),
|
|
93
|
+
txt: txt
|
|
94
|
+
}
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def parse_dns_sd_browse(output)
|
|
99
|
+
output.lines.filter_map do |line|
|
|
100
|
+
next unless line.include?(HTTP_SERVICE)
|
|
101
|
+
|
|
102
|
+
parts = line.strip.split(/\s+/, 7)
|
|
103
|
+
instance = parts[6]
|
|
104
|
+
next unless instance && shelly_name?(instance)
|
|
105
|
+
|
|
106
|
+
instance
|
|
107
|
+
end.uniq
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def parse_dns_sd_resolve(instance, output)
|
|
111
|
+
target = output.lines.find { |line| line.include?(" can be reached at ") }
|
|
112
|
+
return nil unless target
|
|
113
|
+
|
|
114
|
+
match = target.match(/ can be reached at ([^:]+):(\d+)/)
|
|
115
|
+
return nil unless match
|
|
116
|
+
|
|
117
|
+
hostname = match[1]
|
|
118
|
+
port = match[2].to_i
|
|
119
|
+
ip = Resolv.getaddress(hostname)
|
|
120
|
+
txt = parse_dns_sd_txt(output)
|
|
121
|
+
|
|
122
|
+
{
|
|
123
|
+
source: "dns-sd",
|
|
124
|
+
name: instance,
|
|
125
|
+
hostname: hostname,
|
|
126
|
+
ip: ip,
|
|
127
|
+
port: port,
|
|
128
|
+
gen: txt["gen"]&.to_i,
|
|
129
|
+
mac: extract_mac(instance),
|
|
130
|
+
txt: txt
|
|
131
|
+
}
|
|
132
|
+
rescue Resolv::ResolvError
|
|
133
|
+
nil
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def parse_avahi_txt(txt_field)
|
|
137
|
+
return {} unless txt_field
|
|
138
|
+
|
|
139
|
+
txt_field.scan(/"([^"]*)"/).flatten.each_with_object({}) do |part, result|
|
|
140
|
+
key, value = part.split("=", 2)
|
|
141
|
+
result[key] = value if key && value
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def parse_dns_sd_txt(output)
|
|
146
|
+
output.lines.each_with_object({}) do |line, result|
|
|
147
|
+
line.scan(/([\w.-]+)=("[^"]*"|\S+)/).each do |key, value|
|
|
148
|
+
result[key] = value.delete_prefix('"').delete_suffix('"')
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def extract_mac(name)
|
|
154
|
+
match = name.to_s.match(/-([0-9a-fA-F]{6,12})$/)
|
|
155
|
+
match && match[1].upcase
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def shelly_name?(value)
|
|
159
|
+
value.to_s.downcase.include?("shelly")
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def link_local_ipv6?(value)
|
|
163
|
+
value.to_s.start_with?("fe80:")
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def default_ipv4_network
|
|
167
|
+
if executable?("ip")
|
|
168
|
+
network = default_ipv4_network_from_ip
|
|
169
|
+
return network if network
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
if executable?("route") && executable?("ipconfig")
|
|
173
|
+
network = default_ipv4_network_from_macos
|
|
174
|
+
return network if network
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
fallback_ipv4_network
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def network_addresses(network)
|
|
181
|
+
ipaddr = IPAddr.new(network)
|
|
182
|
+
raise DiscoveryError, "scan only supports IPv4 networks" unless ipaddr.ipv4?
|
|
183
|
+
|
|
184
|
+
range = ipaddr.to_range.to_a
|
|
185
|
+
range = range[1...-1] if network.to_s.include?("/") && range.length > 2
|
|
186
|
+
range.map(&:to_s)
|
|
187
|
+
rescue ArgumentError => e
|
|
188
|
+
raise DiscoveryError, "invalid network #{network.inspect}: #{e.message}"
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
private
|
|
192
|
+
|
|
193
|
+
def scan_addresses(addresses, timeout:, concurrency:, client_options:)
|
|
194
|
+
queue = Queue.new
|
|
195
|
+
addresses.each { |address| queue << address }
|
|
196
|
+
results = Queue.new
|
|
197
|
+
worker_count = [[concurrency.to_i, 1].max, addresses.length].min
|
|
198
|
+
|
|
199
|
+
workers = Array.new(worker_count) do
|
|
200
|
+
Thread.new do
|
|
201
|
+
loop do
|
|
202
|
+
address = queue.pop(true)
|
|
203
|
+
client = Client.new(address, **client_options.merge(timeout: timeout))
|
|
204
|
+
results << client.normalized_info.merge("discovery" => { "source" => "ip-scan" })
|
|
205
|
+
rescue ThreadError
|
|
206
|
+
break
|
|
207
|
+
rescue Error, URI::InvalidURIError, JSON::ParserError
|
|
208
|
+
next
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
workers.each(&:join)
|
|
213
|
+
|
|
214
|
+
found = []
|
|
215
|
+
found << results.pop(true) until results.empty?
|
|
216
|
+
found
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def verify_records(records, timeout:, concurrency:, client_options:)
|
|
220
|
+
queue = Queue.new
|
|
221
|
+
records.each { |record| queue << record }
|
|
222
|
+
results = Queue.new
|
|
223
|
+
worker_count = [[concurrency.to_i, 1].max, records.length].min
|
|
224
|
+
|
|
225
|
+
workers = Array.new(worker_count) do
|
|
226
|
+
Thread.new do
|
|
227
|
+
loop do
|
|
228
|
+
record = queue.pop(true)
|
|
229
|
+
client = Client.new(record[:ip], **client_options.merge(timeout: timeout))
|
|
230
|
+
results << client.normalized_info.merge("discovery" => stringify_keys(record))
|
|
231
|
+
rescue ThreadError
|
|
232
|
+
break
|
|
233
|
+
rescue Error, URI::InvalidURIError, JSON::ParserError
|
|
234
|
+
next
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
workers.each(&:join)
|
|
239
|
+
|
|
240
|
+
found = []
|
|
241
|
+
found << results.pop(true) until results.empty?
|
|
242
|
+
found.sort_by do |device|
|
|
243
|
+
[0, IPAddr.new(device["ip"]).to_i]
|
|
244
|
+
rescue ArgumentError
|
|
245
|
+
[1, device["ip"].to_s]
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def default_ipv4_network_from_ip
|
|
250
|
+
route_output = capture_command("ip", "-o", "route", "show", "default")
|
|
251
|
+
iface = route_output[/\bdev\s+(\S+)/, 1]
|
|
252
|
+
|
|
253
|
+
addr_output = capture_command("ip", "-o", "-f", "inet", "addr", "show", "scope", "global")
|
|
254
|
+
candidates = addr_output.lines.filter_map do |line|
|
|
255
|
+
fields = line.split
|
|
256
|
+
next unless fields[2] == "inet"
|
|
257
|
+
next if iface && fields[1] != iface
|
|
258
|
+
|
|
259
|
+
cidr = fields[3]
|
|
260
|
+
prefix = cidr.split("/", 2).last.to_i
|
|
261
|
+
"#{IPAddr.new(cidr).mask(prefix)}/#{prefix}"
|
|
262
|
+
rescue ArgumentError
|
|
263
|
+
nil
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
candidates.first
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def default_ipv4_network_from_macos
|
|
270
|
+
route_output = capture_command("route", "-n", "get", "default")
|
|
271
|
+
iface = route_output[/interface:\s+(\S+)/, 1]
|
|
272
|
+
return nil unless iface
|
|
273
|
+
|
|
274
|
+
address = capture_command("ipconfig", "getifaddr", iface).strip
|
|
275
|
+
mask = capture_command("ipconfig", "getoption", iface, "subnet_mask").strip
|
|
276
|
+
return nil if address.empty? || mask.empty?
|
|
277
|
+
|
|
278
|
+
prefix = IPAddr.new(mask).to_i.to_s(2).count("1")
|
|
279
|
+
"#{IPAddr.new(address).mask(prefix)}/#{prefix}"
|
|
280
|
+
rescue ArgumentError
|
|
281
|
+
nil
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def fallback_ipv4_network
|
|
285
|
+
addr = Socket.ip_address_list.find { |info| info.ipv4_private? && !info.ipv4_loopback? }
|
|
286
|
+
return nil unless addr
|
|
287
|
+
|
|
288
|
+
"#{IPAddr.new(addr.ip_address).mask(24)}/24"
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def executable?(name)
|
|
292
|
+
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |dir|
|
|
293
|
+
path = File.join(dir, name)
|
|
294
|
+
File.executable?(path) && !File.directory?(path)
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def capture_for(seconds, *cmd)
|
|
299
|
+
output, = run_command_for(seconds, *cmd, timeout_error: false)
|
|
300
|
+
output
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def capture_command_for(seconds, *cmd)
|
|
304
|
+
run_command_for(seconds, *cmd, timeout_error: true)
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def run_command_for(seconds, *cmd, timeout_error:)
|
|
308
|
+
output = +""
|
|
309
|
+
status = nil
|
|
310
|
+
timed_out = false
|
|
311
|
+
Open3.popen2e(*cmd) do |_stdin, stdout_err, wait_thr|
|
|
312
|
+
begin
|
|
313
|
+
Timeout.timeout(seconds) do
|
|
314
|
+
until stdout_err.eof?
|
|
315
|
+
output << stdout_err.readpartial(4096)
|
|
316
|
+
end
|
|
317
|
+
status = wait_thr.value
|
|
318
|
+
end
|
|
319
|
+
rescue Timeout::Error
|
|
320
|
+
timed_out = true
|
|
321
|
+
terminate_process(wait_thr)
|
|
322
|
+
ensure
|
|
323
|
+
status ||= wait_thr.value rescue nil
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
if timed_out && timeout_error
|
|
328
|
+
raise DiscoveryError, "#{cmd.shelljoin} timed out after #{seconds} seconds"
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
[output, status]
|
|
332
|
+
rescue Errno::ENOENT
|
|
333
|
+
raise
|
|
334
|
+
rescue DiscoveryError
|
|
335
|
+
raise
|
|
336
|
+
rescue => e
|
|
337
|
+
raise DiscoveryError, "#{cmd.shelljoin} failed: #{e.message}"
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def terminate_process(wait_thr)
|
|
341
|
+
Process.kill("TERM", wait_thr.pid)
|
|
342
|
+
return if wait_thr.join(1)
|
|
343
|
+
|
|
344
|
+
Process.kill("KILL", wait_thr.pid)
|
|
345
|
+
wait_thr.join
|
|
346
|
+
rescue Errno::ESRCH, Errno::ECHILD
|
|
347
|
+
nil
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def stringify_keys(value)
|
|
351
|
+
case value
|
|
352
|
+
when Hash
|
|
353
|
+
value.each_with_object({}) do |(key, item), result|
|
|
354
|
+
result[key.to_s] = stringify_keys(item)
|
|
355
|
+
end
|
|
356
|
+
when Array
|
|
357
|
+
value.map { |item| stringify_keys(item) }
|
|
358
|
+
else
|
|
359
|
+
value
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def capture_command(*cmd)
|
|
364
|
+
stdout, _stderr, status = Open3.capture3(*cmd)
|
|
365
|
+
status.success? ? stdout : ""
|
|
366
|
+
rescue Errno::ENOENT
|
|
367
|
+
""
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
end
|
data/lib/rshelly.rb
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "rshelly/version"
|
|
4
|
+
require_relative "rshelly/error"
|
|
5
|
+
require_relative "rshelly/client"
|
|
6
|
+
require_relative "rshelly/discovery"
|
|
7
|
+
|
|
8
|
+
module Shelly
|
|
9
|
+
def self.client(host, **options)
|
|
10
|
+
Client.new(host, **options)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.discover(**options)
|
|
14
|
+
Discovery.discover(**options)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.scan(**options)
|
|
18
|
+
Discovery.scan(**options)
|
|
19
|
+
end
|
|
20
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: rshelly
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Jonas Tehler
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: The rshelly Ruby library and shelly command for discovery, inspection,
|
|
13
|
+
control, metering, and firmware management of Shelly Gen1 and Gen2+ devices.
|
|
14
|
+
email:
|
|
15
|
+
- jonas@tehler.se
|
|
16
|
+
executables:
|
|
17
|
+
- shelly
|
|
18
|
+
extensions: []
|
|
19
|
+
extra_rdoc_files: []
|
|
20
|
+
files:
|
|
21
|
+
- CHANGELOG.md
|
|
22
|
+
- LICENSE.txt
|
|
23
|
+
- README.md
|
|
24
|
+
- exe/shelly
|
|
25
|
+
- lib/rshelly.rb
|
|
26
|
+
- lib/rshelly/cli.rb
|
|
27
|
+
- lib/rshelly/client.rb
|
|
28
|
+
- lib/rshelly/discovery.rb
|
|
29
|
+
- lib/rshelly/error.rb
|
|
30
|
+
- lib/rshelly/version.rb
|
|
31
|
+
homepage: https://github.com/jegt/rshelly
|
|
32
|
+
licenses:
|
|
33
|
+
- MIT
|
|
34
|
+
metadata:
|
|
35
|
+
homepage_uri: https://github.com/jegt/rshelly
|
|
36
|
+
source_code_uri: https://github.com/jegt/rshelly/tree/main
|
|
37
|
+
documentation_uri: https://github.com/jegt/rshelly/blob/main/README.md
|
|
38
|
+
bug_tracker_uri: https://github.com/jegt/rshelly/issues
|
|
39
|
+
changelog_uri: https://github.com/jegt/rshelly/blob/main/CHANGELOG.md
|
|
40
|
+
allowed_push_host: https://rubygems.org
|
|
41
|
+
rubygems_mfa_required: 'true'
|
|
42
|
+
rdoc_options: []
|
|
43
|
+
require_paths:
|
|
44
|
+
- lib
|
|
45
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
46
|
+
requirements:
|
|
47
|
+
- - ">="
|
|
48
|
+
- !ruby/object:Gem::Version
|
|
49
|
+
version: '3.1'
|
|
50
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - ">="
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '0'
|
|
55
|
+
requirements: []
|
|
56
|
+
rubygems_version: 4.0.7
|
|
57
|
+
specification_version: 4
|
|
58
|
+
summary: Ruby library and CLI for managing Shelly Gen1 and Gen2 devices
|
|
59
|
+
test_files: []
|