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,400 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "ipaddr"
5
+ require "net/http"
6
+ require "openssl"
7
+ require "uri"
8
+
9
+ module Shelly
10
+ class Client
11
+ GEN1_MODELS = {
12
+ "SHPLG-S" => "PlugS",
13
+ "SHHT-1" => "H&T",
14
+ "SHSW-1" => "Shelly1",
15
+ "SHRGBW2" => "RGBW2"
16
+ }.freeze
17
+
18
+ attr_reader :host, :port, :timeout, :scheme
19
+
20
+ def initialize(host, port: nil, timeout: 5, scheme: "http", generation: nil)
21
+ uri = normalize_uri(host, port, scheme)
22
+ @scheme = uri.scheme
23
+ @host = uri.host
24
+ @port = uri.port
25
+ @timeout = timeout
26
+ @generation = normalize_generation(generation)
27
+ @shelly_info = nil
28
+ end
29
+
30
+ def base_url
31
+ default_port = scheme == "https" ? 443 : 80
32
+ port_part = port == default_port ? "" : ":#{port}"
33
+ "#{scheme}://#{host}#{port_part}"
34
+ end
35
+
36
+ def gen
37
+ @generation ||= (shelly_info["gen"] || 1).to_i
38
+ end
39
+
40
+ def gen1?
41
+ gen == 1
42
+ end
43
+
44
+ def gen2?
45
+ gen >= 2
46
+ end
47
+
48
+ def shelly_info
49
+ @shelly_info ||= get_json("/shelly")
50
+ end
51
+
52
+ def status
53
+ gen1? ? get_json("/status") : rpc("Shelly.GetStatus")
54
+ end
55
+
56
+ def config
57
+ gen1? ? get_json("/settings") : rpc("Shelly.GetConfig")
58
+ end
59
+
60
+ def settings
61
+ get_json("/settings")
62
+ end
63
+
64
+ def get(path, params = {})
65
+ request(:get, path, params: params)
66
+ end
67
+
68
+ def post(path, body = nil, params: {}, headers: {})
69
+ request(:post, path, params: params, body: body, headers: headers)
70
+ end
71
+
72
+ def get_json(path, params = {})
73
+ parse_json(get(path, params))
74
+ end
75
+
76
+ def rpc(method, params = nil, **keyword_params)
77
+ params = keyword_params unless keyword_params.empty?
78
+ payload = { id: 1, method: method }
79
+ payload[:params] = params unless params.nil?
80
+
81
+ response = parse_json(post("/rpc", JSON.generate(payload), headers: { "Content-Type" => "application/json" }))
82
+ raise RpcError, response["error"]["message"] if response.is_a?(Hash) && response["error"]
83
+
84
+ result = response.fetch("result", response)
85
+ result.nil? ? {} : result
86
+ end
87
+
88
+ def switch_status(id = 0)
89
+ gen1? ? get_json("/relay/#{id}") : rpc("Switch.GetStatus", { id: id })
90
+ end
91
+
92
+ def switch_set(id: 0, on:)
93
+ if gen1?
94
+ get_json("/relay/#{id}", turn: on ? "on" : "off")
95
+ else
96
+ rpc("Switch.Set", { id: id, on: !!on })
97
+ end
98
+ end
99
+
100
+ def switch_toggle(id = 0)
101
+ if gen1?
102
+ get_json("/relay/#{id}", turn: "toggle")
103
+ else
104
+ rpc("Switch.Toggle", { id: id })
105
+ end
106
+ end
107
+
108
+ def meter_status(id = 0)
109
+ if gen1?
110
+ get_json("/meter/#{id}")
111
+ else
112
+ rpc("Switch.GetStatus", { id: id })
113
+ end
114
+ end
115
+
116
+ def temperature_status(id: 100)
117
+ if gen1?
118
+ status.fetch("ext_temperature", {})
119
+ else
120
+ rpc("Temperature.GetStatus", { id: id })
121
+ end
122
+ end
123
+
124
+ def rgbw_status(id = 0)
125
+ ensure_gen2!("RGBW")
126
+ rpc("RGBW.GetStatus", { id: id })
127
+ end
128
+
129
+ def light_status(id = 0)
130
+ ensure_gen2!("Light")
131
+ rpc("Light.GetStatus", { id: id })
132
+ end
133
+
134
+ def light_set(id: 0, on: nil, brightness: nil, transition_duration: nil)
135
+ ensure_gen2!("Light")
136
+ params = compact_hash(
137
+ id: id,
138
+ on: on.nil? ? nil : !!on,
139
+ brightness: brightness,
140
+ transition_duration: transition_duration
141
+ )
142
+ rpc("Light.Set", params)
143
+ end
144
+
145
+ def rgbw_set(id: 0, on: nil, brightness: nil, white: nil, red: nil, green: nil, blue: nil, transition_duration: nil)
146
+ ensure_gen2!("RGBW")
147
+ params = compact_hash(
148
+ id: id,
149
+ on: on.nil? ? nil : !!on,
150
+ brightness: brightness,
151
+ white: white,
152
+ red: red,
153
+ green: green,
154
+ blue: blue,
155
+ transition_duration: transition_duration
156
+ )
157
+ rpc("RGBW.Set", params)
158
+ end
159
+
160
+ def input_set_config(id:, type:, name: nil, enable: nil)
161
+ ensure_gen2!("input configuration")
162
+ config = compact_hash(type: type, name: name, enable: enable)
163
+ rpc("Input.SetConfig", { id: id, config: config })
164
+ end
165
+
166
+ def webhook_delete_all
167
+ ensure_gen2!("webhooks")
168
+ rpc("Webhook.DeleteAll")
169
+ end
170
+
171
+ def webhook_create(cid:, event:, urls:, name: event, enable: true)
172
+ ensure_gen2!("webhooks")
173
+ rpc("Webhook.Create", {
174
+ cid: cid,
175
+ enable: enable,
176
+ name: name,
177
+ event: event,
178
+ urls: Array(urls)
179
+ })
180
+ end
181
+
182
+ def set_device_name(name)
183
+ if gen1?
184
+ get_json("/settings", name: name)
185
+ else
186
+ rpc("Sys.SetConfig", { config: { device: { name: name } } })
187
+ end
188
+ end
189
+
190
+ def enable_mqtt(server:, enable: true)
191
+ if gen1?
192
+ get_json("/settings", mqtt_enable: enable, mqtt_server: server)
193
+ else
194
+ rpc("MQTT.SetConfig", { config: { enable: enable, server: server } })
195
+ end
196
+ end
197
+
198
+ def set_wifi(slot:, ssid:, password:, ip: nil, netmask: nil, gw: nil, dns: nil, enable: true)
199
+ slot = normalize_wifi_slot(slot)
200
+ ip_config = build_wifi_ip_config(ip: ip, netmask: netmask, gw: gw, dns: dns)
201
+
202
+ if gen1?
203
+ params = {
204
+ enabled: enable ? 1 : 0,
205
+ ssid: ssid,
206
+ key: password,
207
+ ipv4_method: ip ? "static" : "dhcp"
208
+ }.merge(ip_config[:gen1])
209
+ get_json(slot == :primary ? "/settings/sta" : "/settings/sta1", params)
210
+ else
211
+ sta_key = slot == :primary ? :sta : :sta1
212
+ sta_config = {
213
+ ssid: ssid,
214
+ pass: password,
215
+ enable: enable,
216
+ ipv4mode: ip ? "static" : "dhcp"
217
+ }.merge(ip_config[:gen2])
218
+ rpc("WiFi.SetConfig", { config: { sta_key => sta_config } })
219
+ end
220
+ end
221
+
222
+ def update_info
223
+ if gen1?
224
+ ota = get_json("/ota")
225
+ current = shelly_info["fw"] || shelly_info.dig("build_info", "build_id")
226
+ new_version = ota["new_version"]
227
+ new_version = nil if new_version == current
228
+ {
229
+ "current_version" => current,
230
+ "has_update" => !!ota["has_update"],
231
+ "new_version" => new_version
232
+ }
233
+ else
234
+ st = status
235
+ stable = st.dig("sys", "available_updates", "stable")
236
+ {
237
+ "current_version" => shelly_info["fw_id"] || shelly_info["ver"],
238
+ "has_update" => !stable.nil?,
239
+ "new_version" => stable && stable["version"]
240
+ }
241
+ end
242
+ end
243
+
244
+ def update_start(stage: "stable")
245
+ if gen1?
246
+ get_json("/ota", update: true)
247
+ else
248
+ rpc("Shelly.Update", { stage: stage })
249
+ end
250
+ end
251
+
252
+ def reboot
253
+ if gen1?
254
+ response = get("/reboot").to_s.strip
255
+ response = JSON.parse(response) if response.start_with?('"')
256
+ response.empty? ? { "reboot" => true } : { "reboot" => true, "response" => response }
257
+ else
258
+ rpc("Shelly.Reboot")
259
+ { "reboot" => true }
260
+ end
261
+ rescue JSON::ParserError
262
+ { "reboot" => true, "response" => response }
263
+ end
264
+
265
+ def normalized_info
266
+ info = shelly_info
267
+ cfg = config rescue {}
268
+ st = status rescue {}
269
+ update = update_info rescue {}
270
+ merged = deep_merge(info, cfg, st)
271
+ device = merged["device"] || merged.dig("sys", "device") || {}
272
+
273
+ {
274
+ "ip" => host,
275
+ "gen" => gen,
276
+ "model_id" => info["type"] || info["model"] || merged["model"],
277
+ "model" => merged["app"] || GEN1_MODELS[info["type"]],
278
+ "mac" => device["mac"] || merged["mac"],
279
+ "name" => merged["name"] || device["name"] || device["hostname"],
280
+ "hostname" => device["hostname"] || merged["hostname"],
281
+ "firmware" => info["fw"] || info["fw_id"] || info["ver"] || device["fw_id"] || merged.dig("build_info", "build_id"),
282
+ "update" => update,
283
+ "raw" => merged
284
+ }
285
+ end
286
+
287
+ private
288
+
289
+ def normalize_uri(host, port, scheme)
290
+ raw = host.to_s
291
+ raw = "#{scheme}://#{raw}" unless raw.match?(%r{\Ahttps?://})
292
+ uri = URI(raw)
293
+ uri.port = port if port
294
+ uri
295
+ end
296
+
297
+ def request(method, path, params: {}, body: nil, headers: {})
298
+ uri = URI.join(base_url, path)
299
+ unless params.empty?
300
+ existing = URI.decode_www_form(uri.query.to_s)
301
+ uri.query = URI.encode_www_form(existing + params.map { |key, value| [key.to_s, value.to_s] })
302
+ end
303
+
304
+ http = Net::HTTP.new(uri.host, uri.port)
305
+ http.use_ssl = uri.scheme == "https"
306
+ http.open_timeout = timeout
307
+ http.read_timeout = timeout
308
+ http.write_timeout = timeout
309
+
310
+ klass = method == :post ? Net::HTTP::Post : Net::HTTP::Get
311
+ req = klass.new(uri)
312
+ headers.each { |key, value| req[key] = value }
313
+ req.body = body if body
314
+
315
+ response = http.request(req)
316
+ return response.body if response.is_a?(Net::HTTPSuccess)
317
+
318
+ raise HttpError, "#{method.to_s.upcase} #{uri} failed with #{response.code}: #{response.body}"
319
+ rescue Timeout::Error, SystemCallError, SocketError, EOFError, IOError, Net::HTTPError, OpenSSL::SSL::SSLError => e
320
+ raise HttpError, "#{method.to_s.upcase} #{uri} failed: #{e.class}: #{e.message}"
321
+ end
322
+
323
+ def parse_json(body)
324
+ stripped = body.to_s.strip
325
+ return {} if stripped.empty? || stripped == "null"
326
+
327
+ JSON.parse(stripped)
328
+ rescue JSON::ParserError => e
329
+ raise HttpError, "invalid JSON response: #{e.message}: #{stripped.inspect}"
330
+ end
331
+
332
+ def normalize_generation(generation)
333
+ return nil if generation.nil?
334
+
335
+ value = Integer(generation)
336
+ raise ArgumentError unless value.positive?
337
+
338
+ value
339
+ rescue ArgumentError, TypeError
340
+ raise ArgumentError, "generation must be a positive integer"
341
+ end
342
+
343
+ def ensure_gen2!(feature)
344
+ raise UnsupportedDeviceError, "#{feature} requires a Gen2+ device" unless gen2?
345
+ end
346
+
347
+ def normalize_wifi_slot(slot)
348
+ case slot.to_s.downcase
349
+ when "primary", "wifi1", "sta", "0", "1" then :primary
350
+ when "secondary", "wifi2", "sta1", "2" then :secondary
351
+ else
352
+ raise ArgumentError, "wifi slot must be primary or secondary"
353
+ end
354
+ end
355
+
356
+ def build_wifi_ip_config(ip:, netmask:, gw:, dns:)
357
+ return { gen1: {}, gen2: {} } unless ip
358
+
359
+ ipaddr = IPAddr.new(ip)
360
+ raise ArgumentError, "static IP must be an IPv4 address" unless ipaddr.ipv4?
361
+
362
+ netmask ||= "255.255.255.0"
363
+ gw ||= derived_gateway(ipaddr)
364
+ dns ||= gw
365
+
366
+ {
367
+ gen1: { ip: ip, netmask: netmask, gateway: gw, dns: dns },
368
+ gen2: { ip: ip, netmask: netmask, gw: gw, nameserver: dns }
369
+ }
370
+ rescue ArgumentError => e
371
+ raise ArgumentError, "invalid wifi static IP configuration: #{e.message}"
372
+ end
373
+
374
+ def derived_gateway(ipaddr)
375
+ octets = ipaddr.to_s.split(".")
376
+ octets[-1] = "1"
377
+ octets.join(".")
378
+ end
379
+
380
+ def compact_hash(hash)
381
+ hash.reject { |_, value| value.nil? }
382
+ end
383
+
384
+ def deep_merge(*hashes)
385
+ hashes.reduce({}) do |memo, hash|
386
+ deep_merge_pair(memo, hash || {})
387
+ end
388
+ end
389
+
390
+ def deep_merge_pair(left, right)
391
+ left.merge(right) do |_, old_value, new_value|
392
+ if old_value.is_a?(Hash) && new_value.is_a?(Hash)
393
+ deep_merge_pair(old_value, new_value)
394
+ else
395
+ new_value
396
+ end
397
+ end
398
+ end
399
+ end
400
+ end