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,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
|