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,1067 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "optparse"
5
+ require_relative "../rshelly"
6
+
7
+ module Shelly
8
+ class CLI
9
+ COMMAND_ALIASES = {
10
+ "temperature" => "temp",
11
+ "get" => "gen1"
12
+ }.freeze
13
+
14
+ def initialize(argv, stdout: $stdout, stderr: $stderr)
15
+ @argv = argv.dup
16
+ @stdout = stdout
17
+ @stderr = stderr
18
+ @options = { timeout: 5, json: false }
19
+ end
20
+
21
+ def run
22
+ parse_global_options!
23
+ command = @argv.shift
24
+ return command_help(command) if command && consume_help_flag!
25
+
26
+ case command
27
+ when "discover" then discover
28
+ when "scan" then scan
29
+ when "info" then with_client { |client| output(client.normalized_info, :info) }
30
+ when "status" then with_client { |client| output(client.status, :status, host: client.host) }
31
+ when "config" then with_client { |client| output(client.config, :config, host: client.host) }
32
+ when "switch" then switch
33
+ when "meter" then meter
34
+ when "temp", "temperature" then temperature
35
+ when "rgbw" then rgbw
36
+ when "light" then light
37
+ when "update" then update
38
+ when "reboot" then reboot
39
+ when "wifi" then wifi
40
+ when "rpc" then rpc
41
+ when "gen1", "get" then gen1_get
42
+ when "mqtt" then mqtt
43
+ when "input" then input
44
+ when "webhook" then webhook
45
+ when "help" then command_help(@argv.shift)
46
+ when nil then help
47
+ when "version", "--version", "-v"
48
+ @stdout.puts VERSION
49
+ 0
50
+ else
51
+ raise Error, "unknown command: #{command}"
52
+ end
53
+ rescue Error, OptionParser::ParseError, ArgumentError, JSON::ParserError => e
54
+ @stderr.puts "shelly: #{e.message}"
55
+ @stderr.puts "Run `shelly help` for usage."
56
+ 1
57
+ end
58
+
59
+ private
60
+
61
+ def parse_global_options!
62
+ parser = OptionParser.new do |opts|
63
+ opts.on("--json", "Print JSON") { @options[:json] = true }
64
+ opts.on("--timeout SECONDS", Float, "HTTP/discovery timeout") { |value| @options[:timeout] = value }
65
+ opts.on("-h", "--help", "Print help") { @options[:help] = true }
66
+ opts.on("-v", "--version", "Print version") { @options[:version] = true }
67
+ end
68
+ parser.order!(@argv)
69
+ @argv.unshift("version") if @options[:version]
70
+ @argv.unshift("help") if @options[:help]
71
+ end
72
+
73
+ def discover
74
+ verify = consume_flag!("--verify")
75
+ concurrency = consume_option!("--concurrency", Integer) || Discovery::DEFAULT_CONCURRENCY
76
+ raise Error, "unexpected arguments: #{@argv.join(" ")}" unless @argv.empty?
77
+
78
+ output(
79
+ Discovery.discover(
80
+ timeout: @options[:timeout],
81
+ verify: verify,
82
+ concurrency: concurrency,
83
+ client_options: client_options
84
+ ),
85
+ verify ? :devices : :discovery
86
+ )
87
+ end
88
+
89
+ def scan
90
+ concurrency = consume_option!("--concurrency", Integer) || Discovery::DEFAULT_CONCURRENCY
91
+ max_hosts = consume_option!("--max-hosts", Integer) || 4096
92
+ network = @argv.shift
93
+ raise Error, "unexpected arguments: #{@argv.join(" ")}" unless @argv.empty?
94
+
95
+ output(Discovery.scan(
96
+ network: network,
97
+ timeout: @options[:timeout],
98
+ concurrency: concurrency,
99
+ max_hosts: max_hosts,
100
+ client_options: client_options
101
+ ), :devices)
102
+ end
103
+
104
+ def switch
105
+ host = require_arg!("HOST")
106
+ action = require_arg!("ACTION")
107
+ id = consume_option!("--id", Integer) || 0
108
+ client = Client.new(host, **client_options)
109
+
110
+ result = case action
111
+ when "get", "status" then client.switch_status(id)
112
+ when "on" then client.switch_set(id: id, on: true)
113
+ when "off" then client.switch_set(id: id, on: false)
114
+ when "toggle" then client.switch_toggle(id)
115
+ else raise Error, "switch action must be get, on, off, or toggle"
116
+ end
117
+ output(result, :switch, host: host, action: action, id: id)
118
+ end
119
+
120
+ def meter
121
+ host = require_arg!("HOST")
122
+ id = consume_option!("--id", Integer) || 0
123
+ output(Client.new(host, **client_options).meter_status(id), :meter, host: host, id: id)
124
+ end
125
+
126
+ def temperature
127
+ host = require_arg!("HOST")
128
+ id = consume_option!("--id", Integer) || 100
129
+ output(Client.new(host, **client_options).temperature_status(id: id), :temperature, host: host, id: id)
130
+ end
131
+
132
+ def rgbw
133
+ host = require_arg!("HOST")
134
+ action = require_arg!("ACTION")
135
+ client = Client.new(host, **client_options)
136
+
137
+ case action
138
+ when "get", "status"
139
+ id = consume_option!("--id", Integer) || 0
140
+ output(client.rgbw_status(id), :rgbw, host: host, action: action, id: id)
141
+ when "set"
142
+ opts = parse_rgbw_set_options
143
+ output(client.rgbw_set(**opts), :write, action: "RGBW set", host: host)
144
+ else
145
+ raise Error, "rgbw action must be get or set"
146
+ end
147
+ end
148
+
149
+ def light
150
+ host = require_arg!("HOST")
151
+ action = require_arg!("ACTION")
152
+ id = consume_option!("--id", Integer) || 0
153
+ client = Client.new(host, **client_options)
154
+
155
+ case action
156
+ when "get", "status"
157
+ output(client.light_status(id), :light, host: host, action: action, id: id)
158
+ when "set"
159
+ on = consume_flag!("--on") ? true : nil
160
+ on = false if consume_flag!("--off")
161
+ brightness = consume_option!("--brightness", Integer)
162
+ transition_duration = consume_option!("--transition", Float)
163
+ output(client.light_set(id: id, on: on, brightness: brightness, transition_duration: transition_duration), :write, action: "Light set", host: host)
164
+ else
165
+ raise Error, "light action must be get or set"
166
+ end
167
+ end
168
+
169
+ def update
170
+ action = require_arg!("ACTION")
171
+ host = require_arg!("HOST")
172
+ client = Client.new(host, **client_options)
173
+
174
+ case action
175
+ when "check" then output(client.update_info, :update_check, host: host)
176
+ when "start" then output(client.update_start(stage: consume_option!("--stage") || "stable"), :write, action: "Update started", host: host)
177
+ else raise Error, "update action must be check or start"
178
+ end
179
+ end
180
+
181
+ def reboot
182
+ host = require_arg!("HOST")
183
+ output(Client.new(host, **client_options).reboot, :write, action: "Reboot requested", host: host)
184
+ end
185
+
186
+ def wifi
187
+ options = parse_wifi_options
188
+ host = require_arg!("HOST")
189
+ slot = require_arg!("primary|secondary")
190
+ ssid = require_arg!("SSID")
191
+ password = @argv.shift
192
+ raise Error, "missing PASSWORD" unless password
193
+ raise Error, "unexpected arguments: #{@argv.join(" ")}" unless @argv.empty?
194
+
195
+ output(Client.new(host, **client_options).set_wifi(
196
+ slot: slot,
197
+ ssid: ssid,
198
+ password: password,
199
+ ip: options[:ip],
200
+ netmask: options[:netmask],
201
+ gw: options[:gw],
202
+ dns: options[:dns]
203
+ ), :write, action: "Wi-Fi #{slot} configured", host: host)
204
+ end
205
+
206
+ def rpc
207
+ host = require_arg!("HOST")
208
+ method = require_arg!("METHOD")
209
+ params = @argv.empty? ? nil : parse_json_or_file(@argv.shift)
210
+ output(Client.new(host, **client_options).rpc(method, params), :raw, title: "#{host} #{method}")
211
+ end
212
+
213
+ def gen1_get
214
+ host = require_arg!("HOST")
215
+ path = require_arg!("PATH")
216
+ params = parse_key_values(@argv)
217
+ output(Client.new(host, **client_options).get_json(path, params), :raw, title: "#{host} #{path}")
218
+ end
219
+
220
+ def mqtt
221
+ host = require_arg!("HOST")
222
+ server = require_arg!("SERVER")
223
+ enable = !consume_flag!("--disable")
224
+ output(Client.new(host, **client_options).enable_mqtt(server: server, enable: enable), :write, action: enable ? "MQTT configured" : "MQTT disabled", host: host)
225
+ end
226
+
227
+ def input
228
+ action = require_arg!("ACTION")
229
+ raise Error, "input action must be setup" unless action == "setup"
230
+
231
+ host = require_arg!("HOST")
232
+ device_id = consume_option!("--device-id") || consume_option!("--prefix") || require_arg!("DEVICE_ID")
233
+ base_url = consume_option!("--base-url") || "http://home.home/inputs"
234
+ count = consume_option!("--count", Integer) || 4
235
+ client = Client.new(host, **client_options)
236
+
237
+ client.set_device_name("input_device_#{device_id}")
238
+ client.webhook_delete_all
239
+ created = []
240
+
241
+ (0...count).each do |cid|
242
+ input_name = "input_#{cid + 1}"
243
+ input_id = "#{device_id}-#{cid + 1}"
244
+ client.input_set_config(id: cid, type: "button", name: input_name)
245
+
246
+ %w[push longpush doublepush].each do |event|
247
+ url = "#{base_url}/#{input_id}/#{event}"
248
+ client.webhook_create(
249
+ cid: cid,
250
+ name: event,
251
+ event: "input.button_#{event}",
252
+ urls: [url]
253
+ )
254
+ created << { input: input_id, cid: cid, event: event, url: url }
255
+ end
256
+ end
257
+
258
+ output(created, :input_setup, host: host, device_id: device_id)
259
+ end
260
+
261
+ def webhook
262
+ action = require_arg!("ACTION")
263
+ host = require_arg!("HOST")
264
+ client = Client.new(host, **client_options)
265
+
266
+ case action
267
+ when "delete-all" then output(client.webhook_delete_all, :write, action: "Webhooks deleted", host: host)
268
+ else raise Error, "webhook action must be delete-all"
269
+ end
270
+ end
271
+
272
+ def with_client
273
+ host = require_arg!("HOST")
274
+ yield Client.new(host, **client_options)
275
+ end
276
+
277
+ def parse_rgbw_set_options
278
+ result = { id: 0 }
279
+ parser = OptionParser.new do |opts|
280
+ opts.on("--id ID", Integer) { |value| result[:id] = value }
281
+ opts.on("--on") { result[:on] = true }
282
+ opts.on("--off") { result[:on] = false }
283
+ opts.on("--brightness N", Integer) { |value| result[:brightness] = value }
284
+ opts.on("--white N", Integer) { |value| result[:white] = value }
285
+ opts.on("--red N", Integer) { |value| result[:red] = value }
286
+ opts.on("--green N", Integer) { |value| result[:green] = value }
287
+ opts.on("--blue N", Integer) { |value| result[:blue] = value }
288
+ opts.on("--transition SECONDS", Float) { |value| result[:transition_duration] = value }
289
+ end
290
+ parser.parse!(@argv)
291
+ result
292
+ end
293
+
294
+ def parse_wifi_options
295
+ result = {}
296
+ parser = OptionParser.new do |opts|
297
+ opts.on("--ip IP") { |value| result[:ip] = value }
298
+ opts.on("--netmask MASK") { |value| result[:netmask] = value }
299
+ opts.on("--mask MASK") { |value| result[:netmask] = value }
300
+ opts.on("--gw IP") { |value| result[:gw] = value }
301
+ opts.on("--gateway IP") { |value| result[:gw] = value }
302
+ opts.on("--dns IP") { |value| result[:dns] = value }
303
+ opts.on("--nameserver IP") { |value| result[:dns] = value }
304
+ end
305
+ parser.parse!(@argv)
306
+ result
307
+ end
308
+
309
+ def output(value, format = :raw, context = {})
310
+ if @options[:json]
311
+ @stdout.puts JSON.pretty_generate(value)
312
+ else
313
+ @stdout.puts human_output(value, format, context)
314
+ end
315
+ 0
316
+ end
317
+
318
+ def human_output(value, format, context)
319
+ case format
320
+ when :devices then devices_table(value)
321
+ when :discovery then discovery_table(value)
322
+ when :info then info_block(value)
323
+ when :status then status_block(value, context)
324
+ when :config then config_block(value, context)
325
+ when :switch then switch_line(value, context)
326
+ when :meter then meter_block(value, context)
327
+ when :temperature then temperature_block(value, context)
328
+ when :light, :rgbw then component_block(value, context)
329
+ when :update_check then update_check_block(value, context)
330
+ when :write then write_line(value, context)
331
+ when :input_setup then input_setup_block(value, context)
332
+ else raw_block(value, context[:title])
333
+ end
334
+ end
335
+
336
+ def devices_table(devices)
337
+ rows = Array(devices).map do |device|
338
+ raw = device["raw"] || {}
339
+ wifi = raw["wifi"] || {}
340
+ update = device["update"] || {}
341
+ [
342
+ device["ip"],
343
+ device["gen"],
344
+ device["model"] || device["model_id"],
345
+ device["name"],
346
+ device["hostname"],
347
+ update_label(update),
348
+ wifi["ssid"] || raw.dig("wifi_sta", "ssid"),
349
+ wifi["rssi"] || raw.dig("wifi_sta", "rssi"),
350
+ short_version(device["firmware"] || update["current_version"])
351
+ ]
352
+ end
353
+ table(%w[IP Gen Model Name Hostname Update Wi-Fi RSSI Firmware], rows)
354
+ end
355
+
356
+ def discovery_table(records)
357
+ rows = Array(records).map do |record|
358
+ [
359
+ record[:ip] || record["ip"],
360
+ record[:name] || record["name"],
361
+ record[:hostname] || record["hostname"],
362
+ record[:source] || record["source"],
363
+ record[:gen] || record["gen"],
364
+ record[:mac] || record["mac"]
365
+ ]
366
+ end
367
+ table(%w[IP Name Hostname Source Gen MAC], rows)
368
+ end
369
+
370
+ def info_block(device)
371
+ raw = device["raw"] || {}
372
+ wifi = raw["wifi"] || {}
373
+ update = device["update"] || {}
374
+ lines = []
375
+ lines << (device["name"] || "Unnamed device").to_s
376
+ lines << ""
377
+ lines.concat(key_values([
378
+ ["IP", device["ip"]],
379
+ ["Model", model_label(device)],
380
+ ["Model ID", device["model_id"]],
381
+ ["Generation", device["gen"]],
382
+ ["MAC", device["mac"]],
383
+ ["Hostname", device["hostname"] || raw["id"] || raw.dig("device", "hostname")],
384
+ ["Firmware", device["firmware"]],
385
+ ["Update", update_label(update)],
386
+ ["New Version", update["new_version"]],
387
+ ["Wi-Fi", wifi_summary(wifi, raw)],
388
+ ["Cloud", connection_label(raw["cloud"])],
389
+ ["MQTT", connection_label(raw["mqtt"])],
390
+ ["Uptime", duration(raw.dig("sys", "uptime"))],
391
+ ["Restart Required", raw.dig("sys", "restart_required")],
392
+ ["Config Rev", raw.dig("sys", "cfg_rev")]
393
+ ]))
394
+ component_summary(raw).tap { |summary| lines.concat(["", summary]) unless summary.empty? }
395
+ lines.join("\n")
396
+ end
397
+
398
+ def status_block(status, context)
399
+ lines = ["Status #{context[:host]}".strip, ""]
400
+ lines.concat(key_values([
401
+ ["Wi-Fi", wifi_summary(status["wifi"] || {}, status)],
402
+ ["Cloud", connection_label(status["cloud"])],
403
+ ["MQTT", connection_label(status["mqtt"])],
404
+ ["Uptime", duration(status.dig("sys", "uptime"))],
405
+ ["Time", status.dig("sys", "time")],
406
+ ["Restart Required", status.dig("sys", "restart_required")]
407
+ ]))
408
+ component_summary(status).tap { |summary| lines.concat(["", summary]) unless summary.empty? }
409
+ lines.join("\n")
410
+ end
411
+
412
+ def config_block(config, context)
413
+ device = config["device"] || config.dig("sys", "device") || {}
414
+ sys = config["sys"] || {}
415
+ lines = ["Config #{context[:host]}".strip, ""]
416
+ lines.concat(key_values([
417
+ ["Name", device["name"] || config["name"]],
418
+ ["MAC", device["mac"] || config["mac"]],
419
+ ["Discoverable", device.key?("discoverable") ? device["discoverable"] : config["discoverable"]],
420
+ ["Firmware", device["fw_id"] || config["fw"]],
421
+ ["Cloud", enabled_label(config["cloud"])],
422
+ ["MQTT", mqtt_config_label(config["mqtt"])],
423
+ ["Config Rev", sys["cfg_rev"] || config["cfg_rev"]]
424
+ ]))
425
+ wifi_config_summary(config["wifi"] || config).tap { |summary| lines.concat(["", summary]) unless summary.empty? }
426
+ component_config_summary(config).tap { |summary| lines.concat(["", summary]) unless summary.empty? }
427
+ lines.join("\n")
428
+ end
429
+
430
+ def switch_line(value, context)
431
+ state = if value.key?("output") then value["output"] else value["ison"] end
432
+ power = value["apower"] || value["power"]
433
+ temp = temperature_value(value)
434
+ details = []
435
+ details << "power #{format_number(power)} W" if power
436
+ details << "temp #{format_number(temp)} C" if temp
437
+ "switch:#{context[:id]} #{on_off(state)}#{details.empty? ? "" : " (#{details.join(", ")})"}"
438
+ end
439
+
440
+ def meter_block(value, context)
441
+ lines = ["Meter #{context[:host]} id #{context[:id]}"]
442
+ lines.concat(key_values([
443
+ ["Power", value["power"] && "#{format_number(value["power"])} W"],
444
+ ["Voltage", value["voltage"] && "#{format_number(value["voltage"])} V"],
445
+ ["Current", value["current"] && "#{format_number(value["current"])} A"],
446
+ ["Total", value["total"] || value["aenergy"]]
447
+ ]))
448
+ lines.join("\n")
449
+ end
450
+
451
+ def temperature_block(value, context)
452
+ lines = ["Temperature #{context[:host]} id #{context[:id]}"]
453
+ if value.values.all? { |entry| entry.is_a?(Hash) } && !value.key?("tC")
454
+ value.each do |id, entry|
455
+ lines << "#{id}: #{format_number(entry["tC"] || entry["temperature"])} C"
456
+ end
457
+ else
458
+ lines.concat(key_values([
459
+ ["Temperature", (value["tC"] || value["temperature"]) && "#{format_number(value["tC"] || value["temperature"])} C"],
460
+ ["Valid", value["is_valid"]]
461
+ ]))
462
+ end
463
+ lines.join("\n")
464
+ end
465
+
466
+ def component_block(value, context)
467
+ lines = ["#{context[:action].to_s.capitalize} #{context[:host]} id #{context[:id]}"]
468
+ lines.concat(key_values(value.map { |key, val| [key, scalar?(val) ? val : nil] }))
469
+ nested = value.reject { |_, val| scalar?(val) }
470
+ lines.concat(["", render_tree(nested)]) unless nested.empty?
471
+ lines.join("\n")
472
+ end
473
+
474
+ def update_check_block(value, context)
475
+ state = value["has_update"] ? "update available" : "up to date"
476
+ line = "#{context[:host]}: #{state}"
477
+ current = value["current_version"]
478
+ new_version = value["new_version"]
479
+ [line, current && "Current: #{current}", new_version && "New: #{new_version}"].compact.join("\n")
480
+ end
481
+
482
+ def write_line(value, context)
483
+ suffix = value.is_a?(Hash) && !value.empty? ? " #{compact_inline(value)}" : ""
484
+ "#{context[:action]}: #{context[:host]}#{suffix}"
485
+ end
486
+
487
+ def input_setup_block(value, context)
488
+ "Input setup complete: #{context[:host]} device #{context[:device_id]} (#{Array(value).size} webhooks)"
489
+ end
490
+
491
+ def raw_block(value, title = nil)
492
+ lines = []
493
+ lines << title if title
494
+ lines << render_tree(value)
495
+ lines.join("\n")
496
+ end
497
+
498
+ def table(headers, rows)
499
+ rows = rows.map { |row| row.map { |value| display(value) } }
500
+ widths = headers.each_with_index.map do |header, index|
501
+ ([header.length] + rows.map { |row| row[index].to_s.length }).max
502
+ end
503
+ lines = []
504
+ lines << headers.each_with_index.map { |header, index| header.ljust(widths[index]) }.join(" ")
505
+ lines << widths.map { |width| "-" * width }.join(" ")
506
+ rows.each do |row|
507
+ lines << row.each_with_index.map { |value, index| value.ljust(widths[index]) }.join(" ")
508
+ end
509
+ lines.join("\n")
510
+ end
511
+
512
+ def key_values(pairs)
513
+ pairs = pairs.reject { |_, value| blank?(value) }
514
+ width = pairs.map { |key, _| key.length }.max || 0
515
+ pairs.map { |key, value| "#{key.ljust(width)}: #{display(value)}" }
516
+ end
517
+
518
+ def render_tree(value, indent = 0)
519
+ case value
520
+ when Hash
521
+ return "#{" " * indent}{}" if value.empty?
522
+
523
+ value.map do |key, child|
524
+ prefix = "#{" " * indent}#{key}:"
525
+ if scalar?(child) || child.nil?
526
+ "#{prefix} #{display(child)}"
527
+ else
528
+ "#{prefix}\n#{render_tree(child, indent + 1)}"
529
+ end
530
+ end.join("\n")
531
+ when Array
532
+ return "#{" " * indent}[]" if value.empty?
533
+
534
+ value.map do |child|
535
+ prefix = "#{" " * indent}-"
536
+ if scalar?(child) || child.nil?
537
+ "#{prefix} #{display(child)}"
538
+ else
539
+ "#{prefix}\n#{render_tree(child, indent + 1)}"
540
+ end
541
+ end.join("\n")
542
+ else
543
+ "#{" " * indent}#{display(value)}"
544
+ end
545
+ end
546
+
547
+ def component_summary(hash)
548
+ lines = []
549
+ component_keys(hash).each do |key|
550
+ entry = hash[key] || {}
551
+ label = key.tr(":", " ")
552
+ values = []
553
+ values << on_off(entry["output"]) if entry.key?("output")
554
+ values << "input #{on_off(entry["state"])}" if entry.key?("state")
555
+ values << "#{format_number(entry["apower"] || entry["power"])} W" if entry["apower"] || entry["power"]
556
+ temp = temperature_value(entry)
557
+ values << "#{format_number(temp)} C" if temp
558
+ values << "source #{entry["source"]}" if entry["source"]
559
+ lines << "#{label}: #{values.empty? ? compact_inline(entry) : values.join(", ")}"
560
+ end
561
+ lines.join("\n")
562
+ end
563
+
564
+ def component_config_summary(hash)
565
+ lines = []
566
+ component_keys(hash).each do |key|
567
+ entry = hash[key] || {}
568
+ bits = []
569
+ bits << "name #{entry["name"]}" if entry["name"]
570
+ bits << "type #{entry["type"]}" if entry["type"]
571
+ bits << "enabled #{display(entry["enable"])}" if entry.key?("enable")
572
+ bits << "mode #{entry["in_mode"]}" if entry["in_mode"]
573
+ bits << "initial #{entry["initial_state"]}" if entry["initial_state"]
574
+ bits << "auto_off #{entry["auto_off_delay"]}s" if entry["auto_off"]
575
+ lines << "#{key.tr(":", " ")}: #{bits.empty? ? compact_inline(entry) : bits.join(", ")}"
576
+ end
577
+ lines.join("\n")
578
+ end
579
+
580
+ def component_keys(hash)
581
+ hash.keys.grep(/\A(?:switch|input|light|rgbw|temperature|humidity):\d+\z/).sort
582
+ end
583
+
584
+ def temperature_value(hash)
585
+ value = hash["temperature"]
586
+ value.is_a?(Hash) ? value["tC"] : value
587
+ end
588
+
589
+ def wifi_summary(wifi, raw = {})
590
+ ssid = wifi["ssid"] || raw.dig("wifi_sta", "ssid")
591
+ ip = wifi["sta_ip"] || wifi.dig("sta", "ip") || raw.dig("wifi_sta", "ip")
592
+ bssid = wifi["bssid"]
593
+ rssi = wifi["rssi"] || raw.dig("wifi_sta", "rssi")
594
+ status = wifi["status"] || (raw.dig("wifi_sta", "connected") == true ? "connected" : nil)
595
+ [ssid, ip, bssid, rssi && "#{rssi} dBm", status].compact.join(", ")
596
+ end
597
+
598
+ def wifi_config_summary(wifi)
599
+ rows = []
600
+ if wifi["sta"] || wifi["sta1"]
601
+ %w[sta sta1].each do |slot|
602
+ sta = wifi[slot] || {}
603
+ rows << [slot, sta["enable"], sta["ssid"], sta["ipv4mode"], sta["ip"], sta["gw"], sta["nameserver"]]
604
+ end
605
+ elsif wifi["wifi_sta"] || wifi["wifi_sta1"]
606
+ %w[wifi_sta wifi_sta1].each do |slot|
607
+ sta = wifi[slot] || {}
608
+ rows << [slot, sta["enabled"], sta["ssid"], sta["ipv4_method"], sta["ip"], sta["gw"], sta["dns"]]
609
+ end
610
+ end
611
+ return "" if rows.empty?
612
+
613
+ "Wi-Fi\n" + table(%w[Slot Enabled SSID Mode IP GW DNS], rows).lines.map { |line| " #{line}" }.join
614
+ end
615
+
616
+ def model_label(device)
617
+ model = device["model"]
618
+ model_id = device["model_id"]
619
+ model && model_id && model != model_id ? "#{model} (#{model_id})" : (model || model_id)
620
+ end
621
+
622
+ def update_label(update)
623
+ return nil unless update
624
+ return "available #{short_version(update["current_version"])} -> #{short_version(update["new_version"])}" if update["has_update"]
625
+
626
+ current = update["current_version"]
627
+ current ? "ok #{short_version(current)}" : "ok"
628
+ end
629
+
630
+ def connection_label(hash)
631
+ return nil unless hash
632
+ connected = hash["connected"]
633
+ enabled = hash["enable"] || hash["enabled"]
634
+ if connected.nil?
635
+ enabled.nil? ? nil : enabled_label(hash)
636
+ else
637
+ connected ? "connected" : "disconnected"
638
+ end
639
+ end
640
+
641
+ def enabled_label(hash)
642
+ return nil unless hash
643
+ value = hash["enable"]
644
+ value = hash["enabled"] if value.nil?
645
+ value.nil? ? nil : (value ? "enabled" : "disabled")
646
+ end
647
+
648
+ def mqtt_config_label(hash)
649
+ return nil unless hash
650
+ label = enabled_label(hash)
651
+ server = hash["server"]
652
+ [label, server].compact.join(" ")
653
+ end
654
+
655
+ def compact_inline(hash)
656
+ return display(hash) unless hash.is_a?(Hash)
657
+
658
+ hash.select { |_, value| scalar?(value) || value.nil? }
659
+ .map { |key, value| "#{key}=#{display(value)}" }
660
+ .join(", ")
661
+ end
662
+
663
+ def on_off(value)
664
+ case value
665
+ when true then "on"
666
+ when false then "off"
667
+ when nil then "unknown"
668
+ else value.to_s
669
+ end
670
+ end
671
+
672
+ def duration(seconds)
673
+ return nil unless seconds
674
+
675
+ seconds = seconds.to_i
676
+ days, rem = seconds.divmod(86_400)
677
+ hours, rem = rem.divmod(3600)
678
+ minutes, = rem.divmod(60)
679
+ parts = []
680
+ parts << "#{days}d" if days.positive?
681
+ parts << "#{hours}h" if hours.positive? || parts.any?
682
+ parts << "#{minutes}m"
683
+ parts.join(" ")
684
+ end
685
+
686
+ def short_version(value)
687
+ value.to_s.split("/").last unless blank?(value)
688
+ end
689
+
690
+ def format_number(value)
691
+ return nil if value.nil?
692
+ number = Float(value)
693
+ number == number.to_i ? number.to_i.to_s : format("%.2f", number).sub(/0+\z/, "").sub(/\.\z/, "")
694
+ rescue ArgumentError, TypeError
695
+ value.to_s
696
+ end
697
+
698
+ def display(value)
699
+ case value
700
+ when nil then "-"
701
+ when true then "true"
702
+ when false then "false"
703
+ when Array then value.join(", ")
704
+ else value.to_s.empty? ? "-" : value.to_s
705
+ end
706
+ end
707
+
708
+ def scalar?(value)
709
+ value.is_a?(String) || value.is_a?(Numeric) || value == true || value == false
710
+ end
711
+
712
+ def blank?(value)
713
+ value.nil? || value == "" || value == []
714
+ end
715
+
716
+ def client_options
717
+ { timeout: @options[:timeout] }
718
+ end
719
+
720
+ def consume_flag!(name)
721
+ index = @argv.index(name)
722
+ return false unless index
723
+
724
+ @argv.delete_at(index)
725
+ true
726
+ end
727
+
728
+ def consume_help_flag!
729
+ consume_flag!("--help") || consume_flag!("-h")
730
+ end
731
+
732
+ def consume_option!(name, type = nil)
733
+ index = @argv.index(name)
734
+ return nil unless index
735
+
736
+ @argv.delete_at(index)
737
+ value = @argv.delete_at(index)
738
+ raise Error, "#{name} requires a value" unless value
739
+
740
+ type ? convert_option_value(type, value) : value
741
+ end
742
+
743
+ def convert_option_value(type, value)
744
+ case type.name
745
+ when "Integer" then Integer(value)
746
+ when "Float" then Float(value)
747
+ when "String" then value
748
+ else type.call(value)
749
+ end
750
+ end
751
+
752
+ def require_arg!(name)
753
+ value = @argv.shift
754
+ raise Error, "missing #{name}" unless value
755
+
756
+ value
757
+ end
758
+
759
+ def parse_json_or_file(value)
760
+ json = File.exist?(value) ? File.read(value) : value
761
+ JSON.parse(json)
762
+ end
763
+
764
+ def parse_key_values(values)
765
+ values.each_with_object({}) do |pair, params|
766
+ key, value = pair.split("=", 2)
767
+ raise Error, "expected key=value, got #{pair.inspect}" unless key && value
768
+
769
+ params[key] = value
770
+ end
771
+ end
772
+
773
+ def help
774
+ @stdout.puts <<~USAGE
775
+ Usage:
776
+ shelly [--json] [--timeout SECONDS] COMMAND ...
777
+ shelly help [COMMAND]
778
+ shelly COMMAND --help
779
+
780
+ Discovery and inspection:
781
+ shelly discover [--verify] [--concurrency N]
782
+ shelly scan [CIDR] [--concurrency N] [--max-hosts N]
783
+ shelly info HOST
784
+ shelly status HOST
785
+ shelly config HOST
786
+
787
+ Device control:
788
+ shelly switch HOST get|on|off|toggle [--id ID]
789
+ shelly meter HOST [--id ID]
790
+ shelly temp HOST [--id ID]
791
+ shelly light HOST get [--id ID]
792
+ shelly light HOST set [--id ID] [--on|--off] [--brightness N] [--transition SECONDS]
793
+ shelly rgbw HOST get [--id ID]
794
+ shelly rgbw HOST set [--id ID] [--on|--off] [--white N] [--brightness N] [--red N] [--green N] [--blue N] [--transition SECONDS]
795
+
796
+ Management:
797
+ shelly reboot HOST
798
+ shelly wifi HOST primary|secondary SSID PASSWORD [--ip IP] [--netmask MASK] [--gw IP] [--dns IP]
799
+ shelly update check HOST
800
+ shelly update start HOST [--stage stable]
801
+ shelly mqtt HOST SERVER [--disable]
802
+ shelly input setup HOST DEVICE_ID [--base-url URL] [--count N]
803
+ shelly webhook delete-all HOST
804
+
805
+ Escape hatches:
806
+ shelly rpc HOST METHOD [JSON_OR_FILE]
807
+ shelly gen1 HOST PATH [key=value ...]
808
+ USAGE
809
+ 0
810
+ end
811
+
812
+ def command_help(command)
813
+ return help unless command
814
+
815
+ key = COMMAND_ALIASES.fetch(command, command)
816
+ text = command_help_text(key)
817
+ raise Error, "unknown help topic: #{command}" unless text
818
+
819
+ @stdout.puts text
820
+ 0
821
+ end
822
+
823
+ def command_help_text(command)
824
+ case command
825
+ when "discover"
826
+ <<~HELP
827
+ Usage:
828
+ shelly discover [--verify] [--concurrency N]
829
+
830
+ Find Shelly devices via mDNS.
831
+
832
+ Options:
833
+ --verify Connect to each discovered IPv4 device and print normalized device info.
834
+ --concurrency N Number of parallel HTTP verification requests with --verify. Default: 32.
835
+
836
+ Notes:
837
+ Uses avahi-browse on Linux and dns-sd on macOS. This depends on working mDNS.
838
+ HELP
839
+ when "scan"
840
+ <<~HELP
841
+ Usage:
842
+ shelly scan [CIDR] [--concurrency N] [--max-hosts N]
843
+
844
+ Probe IPv4 addresses for Shelly devices without using mDNS.
845
+
846
+ Arguments:
847
+ CIDR Optional network, for example 192.168.0.0/24. If omitted, the current IPv4 network is used.
848
+
849
+ Options:
850
+ --concurrency N Number of parallel HTTP probes. Default: 32.
851
+ --max-hosts N Safety cap for addresses to scan. Default: 4096.
852
+
853
+ Examples:
854
+ shelly scan
855
+ shelly --timeout 2 scan 192.168.0.0/24 --concurrency 64
856
+ HELP
857
+ when "info"
858
+ <<~HELP
859
+ Usage:
860
+ shelly info HOST
861
+
862
+ Print normalized device information: generation, model, MAC, name, firmware, update state, and raw merged data.
863
+
864
+ Example:
865
+ shelly --json info 192.168.0.55
866
+ HELP
867
+ when "status"
868
+ <<~HELP
869
+ Usage:
870
+ shelly status HOST
871
+
872
+ Print current device status. Uses /status on Gen1 and Shelly.GetStatus on Gen2+.
873
+ HELP
874
+ when "config"
875
+ <<~HELP
876
+ Usage:
877
+ shelly config HOST
878
+
879
+ Print current device configuration. Uses /settings on Gen1 and Shelly.GetConfig on Gen2+.
880
+ HELP
881
+ when "switch"
882
+ <<~HELP
883
+ Usage:
884
+ shelly switch HOST get|on|off|toggle [--id ID]
885
+
886
+ Read or control a switch/relay output.
887
+
888
+ Options:
889
+ --id ID Output id. Default: 0.
890
+
891
+ Examples:
892
+ shelly switch 192.168.0.71 get
893
+ shelly switch 192.168.0.71 off --id 0
894
+ HELP
895
+ when "meter"
896
+ <<~HELP
897
+ Usage:
898
+ shelly meter HOST [--id ID]
899
+
900
+ Print power meter data. On Gen2+ this reads Switch.GetStatus for the given id.
901
+
902
+ Options:
903
+ --id ID Meter or switch id. Default: 0.
904
+ HELP
905
+ when "temp"
906
+ <<~HELP
907
+ Usage:
908
+ shelly temp HOST [--id ID]
909
+ shelly temperature HOST [--id ID]
910
+
911
+ Print temperature sensor data.
912
+
913
+ Options:
914
+ --id ID Gen2+ temperature component id. Default: 100.
915
+ HELP
916
+ when "light"
917
+ <<~HELP
918
+ Usage:
919
+ shelly light HOST get [--id ID]
920
+ shelly light HOST set [--id ID] [--on|--off] [--brightness N] [--transition SECONDS]
921
+
922
+ Read or control a Gen2+ Light component.
923
+
924
+ Options:
925
+ --id ID Light id. Default: 0.
926
+ --on, --off Desired output state.
927
+ --brightness N Brightness value accepted by the device.
928
+ --transition SECONDS Transition duration.
929
+ HELP
930
+ when "rgbw"
931
+ <<~HELP
932
+ Usage:
933
+ shelly rgbw HOST get [--id ID]
934
+ shelly rgbw HOST set [--id ID] [--on|--off] [--white N] [--brightness N] [--red N] [--green N] [--blue N] [--transition SECONDS]
935
+
936
+ Read or control a Gen2+ RGBW component.
937
+
938
+ Options:
939
+ --id ID RGBW id. Default: 0.
940
+ --on, --off Desired output state.
941
+ --white N White channel value.
942
+ --brightness N Brightness value accepted by the device.
943
+ --red N --green N --blue N
944
+ --transition SECONDS Transition duration.
945
+ HELP
946
+ when "reboot"
947
+ <<~HELP
948
+ Usage:
949
+ shelly reboot HOST
950
+
951
+ Reboot a device. Uses /reboot on Gen1 and Shelly.Reboot on Gen2+.
952
+ HELP
953
+ when "wifi"
954
+ <<~HELP
955
+ Usage:
956
+ shelly wifi HOST primary|secondary SSID PASSWORD [--ip IP] [--netmask MASK] [--gw IP] [--dns IP]
957
+
958
+ Configure a Wi-Fi station slot.
959
+
960
+ Slot mapping:
961
+ primary Gen2+ WiFi sta, Gen1 /settings/sta.
962
+ secondary Gen2+ WiFi sta1, Gen1 /settings/sta1.
963
+
964
+ By default the station uses DHCP. If --ip is provided, static IPv4 is configured.
965
+
966
+ Static defaults:
967
+ --netmask Defaults to 255.255.255.0.
968
+ --gw Defaults to .1 in the same /24 as --ip.
969
+ --dns Defaults to the gateway.
970
+
971
+ Examples:
972
+ shelly wifi 192.168.0.55 secondary NewSSID 'new password'
973
+ shelly wifi 192.168.0.55 primary NewSSID 'new password' --ip 192.168.10.55
974
+ HELP
975
+ when "update"
976
+ <<~HELP
977
+ Usage:
978
+ shelly update check HOST
979
+ shelly update start HOST [--stage stable|beta]
980
+
981
+ Check for firmware updates or start an update.
982
+
983
+ Options:
984
+ --stage NAME Gen2+ update stage for start. Default: stable.
985
+ HELP
986
+ when "mqtt"
987
+ <<~HELP
988
+ Usage:
989
+ shelly mqtt HOST SERVER [--disable]
990
+
991
+ Enable or disable MQTT and set the MQTT server.
992
+
993
+ Examples:
994
+ shelly mqtt 192.168.0.55 192.168.0.10:1883
995
+ shelly mqtt 192.168.0.55 192.168.0.10:1883 --disable
996
+ HELP
997
+ when "input"
998
+ <<~HELP
999
+ Usage:
1000
+ shelly input setup HOST DEVICE_ID [--base-url URL] [--count N]
1001
+
1002
+ Configure a Gen2+ input device for the home.home input webhook workflow.
1003
+
1004
+ Actions:
1005
+ setup Set device/input names, delete existing webhooks, and create push, longpush, and doublepush webhooks.
1006
+
1007
+ Options:
1008
+ --base-url URL Base webhook URL. Default: http://home.home/inputs.
1009
+ --count N Number of inputs to configure. Default: 4.
1010
+ --device-id ID Alternative way to provide DEVICE_ID.
1011
+ HELP
1012
+ when "webhook"
1013
+ <<~HELP
1014
+ Usage:
1015
+ shelly webhook delete-all HOST
1016
+
1017
+ Delete all Gen2+ webhooks from a device.
1018
+ HELP
1019
+ when "rpc"
1020
+ <<~HELP
1021
+ Usage:
1022
+ shelly rpc HOST METHOD [JSON_OR_FILE]
1023
+
1024
+ Call a raw Gen2+ RPC method. The optional params argument can be an inline JSON object or a path to a JSON file.
1025
+
1026
+ Examples:
1027
+ shelly rpc 192.168.0.55 Shelly.GetStatus
1028
+ shelly rpc 192.168.0.55 Switch.Set '{"id":0,"on":false}'
1029
+ HELP
1030
+ when "gen1"
1031
+ <<~HELP
1032
+ Usage:
1033
+ shelly gen1 HOST PATH [key=value ...]
1034
+ shelly get HOST PATH [key=value ...]
1035
+
1036
+ Call a raw Gen1 HTTP JSON endpoint.
1037
+
1038
+ Examples:
1039
+ shelly gen1 192.168.0.51 /settings
1040
+ shelly gen1 192.168.0.51 /relay/0 turn=off
1041
+ HELP
1042
+ when "help"
1043
+ <<~HELP
1044
+ Usage:
1045
+ shelly help [COMMAND]
1046
+ shelly COMMAND --help
1047
+
1048
+ Print the global command summary or detailed help for a specific command.
1049
+
1050
+ Examples:
1051
+ shelly help
1052
+ shelly help wifi
1053
+ shelly scan --help
1054
+ HELP
1055
+ when "version"
1056
+ <<~HELP
1057
+ Usage:
1058
+ shelly version
1059
+ shelly --version
1060
+ shelly -v
1061
+
1062
+ Print the rshelly gem version.
1063
+ HELP
1064
+ end
1065
+ end
1066
+ end
1067
+ end