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.
@@ -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
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shelly
4
+ Error = Class.new(StandardError)
5
+ HttpError = Class.new(Error)
6
+ RpcError = Class.new(Error)
7
+ UnsupportedDeviceError = Class.new(Error)
8
+ DiscoveryError = Class.new(Error)
9
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shelly
4
+ VERSION = "0.1.0"
5
+ 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: []