1use crate::Result;
2use crate::error::{self, Error};
3use crate::model::{
4 DeviceInfo, DeviceStatus, LightComponent, LightKind, LightParams, LightStatus, PowerReading,
5 SwitchStatus,
6};
7
8use super::{FirmwareInfo, SwitchResult};
9
10pub struct Gen2Device {
11 info: DeviceInfo,
12 base_host: String,
13 client: reqwest::Client,
14 password: Option<String>,
15}
16
17impl Gen2Device {
18 pub fn new(info: DeviceInfo, client: reqwest::Client, password: Option<String>) -> Self {
19 let base_host = info.ip.to_string();
20 Self::new_with_host(info, base_host, client, password)
21 }
22
23 pub fn new_with_host(
28 info: DeviceInfo,
29 base_host: String,
30 client: reqwest::Client,
31 password: Option<String>,
32 ) -> Self {
33 Self {
34 info,
35 base_host,
36 client,
37 password,
38 }
39 }
40
41 fn rpc_url(&self, method: &str) -> String {
42 format!("http://{}/rpc/{method}", self.base_host)
43 }
44
45 async fn rpc_call(
46 &self,
47 method: &str,
48 params: Option<serde_json::Value>,
49 ) -> Result<serde_json::Value> {
50 let url = self.rpc_url(method);
51
52 let resp = if let Some(params) = params {
53 let mut req = self.client.post(&url).json(¶ms);
54 if let Some(ref password) = self.password {
55 req = req.basic_auth("admin", Some(password));
56 }
57 req.send().await?
58 } else {
59 let mut req = self.client.get(&url);
60 if let Some(ref password) = self.password {
61 req = req.basic_auth("admin", Some(password));
62 }
63 req.send().await?
64 };
65
66 let status = resp.status();
67 if !status.is_success() {
68 let body = resp.text().await.unwrap_or_default();
69 return Err(error::status_error(status, &url, &body));
70 }
71
72 let body: serde_json::Value = resp.json().await?;
73
74 if let Some(err_obj) = body.get("error") {
77 return Err(Error::Rejected {
78 message: rpc_error_message(method, err_obj),
79 });
80 }
81
82 Ok(body)
83 }
84
85 pub fn info(&self) -> &DeviceInfo {
86 &self.info
87 }
88
89 pub async fn rpc_raw(
95 &self,
96 method: &str,
97 params: Option<serde_json::Value>,
98 ) -> Result<serde_json::Value> {
99 self.rpc_call(method, params).await
100 }
101
102 pub async fn status(&self) -> Result<DeviceStatus> {
103 let status = self.rpc_call("Shelly.GetStatus", None).await?;
104 Ok(DeviceStatus::from_gen2(&status))
105 }
106
107 pub async fn switch_status(&self, id: u8) -> Result<SwitchStatus> {
108 let params = serde_json::json!({ "id": id });
109 let resp = self.rpc_call("Switch.GetStatus", Some(params)).await?;
110 Ok(SwitchStatus::from_gen2_switch_json(&resp))
111 }
112
113 pub async fn switch_set(&self, id: u8, on: bool) -> Result<SwitchResult> {
114 let params = serde_json::json!({ "id": id, "on": on });
115 let resp = self.rpc_call("Switch.Set", Some(params)).await?;
116
117 let was_on = resp
118 .get("was_on")
119 .and_then(|v| v.as_bool())
120 .unwrap_or(false);
121
122 Ok(SwitchResult { was_on })
123 }
124
125 pub async fn switch_toggle(&self, id: u8) -> Result<SwitchResult> {
126 let params = serde_json::json!({ "id": id });
127 let resp = self.rpc_call("Switch.Toggle", Some(params)).await?;
128
129 let was_on = resp
130 .get("was_on")
131 .and_then(|v| v.as_bool())
132 .unwrap_or(false);
133
134 Ok(SwitchResult { was_on })
135 }
136
137 pub async fn light_components(&self) -> Result<Vec<LightComponent>> {
138 let status = self.rpc_call("Shelly.GetStatus", None).await?;
139 Ok(LightComponent::from_status(&status))
140 }
141
142 pub async fn light_set(
143 &self,
144 kind: LightKind,
145 id: u8,
146 params: &LightParams,
147 ) -> Result<SwitchResult> {
148 let body = build_set_body(kind, id, params);
149 let method = format!("{}.Set", kind.rpc_namespace());
150 let resp = self.rpc_call(&method, Some(body)).await?;
151 let was_on = resp
152 .get("was_on")
153 .and_then(|v| v.as_bool())
154 .unwrap_or(false);
155 Ok(SwitchResult { was_on })
156 }
157
158 pub async fn light_toggle(&self, kind: LightKind, id: u8) -> Result<SwitchResult> {
159 let method = format!("{}.Toggle", kind.rpc_namespace());
160 let resp = self
161 .rpc_call(&method, Some(serde_json::json!({ "id": id })))
162 .await?;
163 let was_on = resp
164 .get("was_on")
165 .and_then(|v| v.as_bool())
166 .unwrap_or(false);
167 Ok(SwitchResult { was_on })
168 }
169
170 pub async fn light_status(&self, kind: LightKind, id: u8) -> Result<LightStatus> {
171 let method = format!("{}.GetStatus", kind.rpc_namespace());
172 let resp = self
173 .rpc_call(&method, Some(serde_json::json!({ "id": id })))
174 .await?;
175 Ok(LightStatus::from_component_json(kind, id, &resp))
176 }
177
178 pub async fn power(&self, id: u8) -> Result<PowerReading> {
179 let params = serde_json::json!({ "id": id });
180 let resp = self.rpc_call("Switch.GetStatus", Some(params)).await?;
181
182 let power = resp.get("apower").and_then(|v| v.as_f64()).unwrap_or(0.0);
183 let voltage = resp.get("voltage").and_then(|v| v.as_f64());
184 let current = resp.get("current").and_then(|v| v.as_f64());
185 let total = resp
186 .get("aenergy")
187 .and_then(|v| v.get("total"))
188 .and_then(|v| v.as_f64())
189 .unwrap_or(0.0);
190
191 Ok(PowerReading {
192 id,
193 power_watts: power,
194 voltage,
195 current,
196 total_energy_wh: total,
197 })
198 }
199
200 pub async fn firmware_check(&self) -> Result<FirmwareInfo> {
201 let resp = self.rpc_call("Shelly.CheckForUpdate", None).await?;
202 let dev_info = self.rpc_call("Shelly.GetDeviceInfo", None).await?;
203
204 let current = dev_info
205 .get("ver")
206 .and_then(|v| v.as_str())
207 .unwrap_or("unknown")
208 .to_string();
209
210 let stable = resp
211 .get("stable")
212 .and_then(|v| v.get("version"))
213 .and_then(|v| v.as_str())
214 .map(String::from);
215
216 let beta = resp
217 .get("beta")
218 .and_then(|v| v.get("version"))
219 .and_then(|v| v.as_str())
220 .map(String::from);
221
222 let has_update = stable.is_some();
223
224 Ok(FirmwareInfo {
225 current_version: current,
226 has_update,
227 stable_version: stable,
228 beta_version: beta,
229 })
230 }
231
232 pub async fn config_get(&self) -> Result<serde_json::Value> {
233 self.rpc_call("Shelly.GetConfig", None).await
234 }
235
236 pub async fn reboot(&self) -> Result<()> {
237 self.rpc_call("Shelly.Reboot", None).await?;
238 Ok(())
239 }
240
241 pub async fn firmware_update(&self) -> Result<()> {
242 let params = serde_json::json!({ "stage": "stable" });
243 self.rpc_call("Shelly.Update", Some(params)).await?;
244 Ok(())
245 }
246
247 pub async fn config_set(&self, key: &str, value: &str) -> Result<serde_json::Value> {
248 let (component, config_key) = match key {
250 "name" => ("sys", "device"),
251 "eco_mode" => ("sys", "device"),
252 "led_status_disable" | "led" => ("sys", "ui"),
253 _ => {
254 return Err(Error::Unsupported {
255 message: format!(
256 "unknown config key '{key}'. Supported keys: name, eco_mode, led_status_disable"
257 ),
258 });
259 }
260 };
261
262 let parsed_value: serde_json::Value = match value {
263 "true" => serde_json::Value::Bool(true),
264 "false" => serde_json::Value::Bool(false),
265 v if v.parse::<f64>().is_ok() => {
266 serde_json::Value::Number(serde_json::Number::from_f64(v.parse().unwrap()).unwrap())
267 }
268 v => serde_json::Value::String(v.to_string()),
269 };
270
271 let config = match key {
272 "name" => serde_json::json!({ component: { config_key: { "name": parsed_value } } }),
273 "eco_mode" => {
274 serde_json::json!({ component: { config_key: { "eco_mode": parsed_value } } })
275 }
276 "led_status_disable" | "led" => {
277 serde_json::json!({ component: { config_key: { "led_status_disable": parsed_value } } })
279 }
280 _ => unreachable!(),
281 };
282
283 self.rpc_call(
284 "Sys.SetConfig",
285 Some(serde_json::json!({ "config": config[component] })),
286 )
287 .await
288 }
289
290 pub async fn schedule_list(&self) -> Result<serde_json::Value> {
291 let resp = self.rpc_call("Schedule.List", None).await?;
292 Ok(resp
293 .get("jobs")
294 .cloned()
295 .unwrap_or(serde_json::Value::Array(vec![])))
296 }
297
298 pub async fn webhook_list(&self) -> Result<serde_json::Value> {
299 let resp = self.rpc_call("Webhook.List", None).await?;
300 Ok(resp
301 .get("hooks")
302 .cloned()
303 .unwrap_or(serde_json::Value::Array(vec![])))
304 }
305
306 pub async fn config_restore(&self, config: &serde_json::Value) -> Result<()> {
307 const SKIP_COMPONENTS: &[&str] = &["wifi", "eth", "ble", "cloud", "mqtt", "ws"];
309
310 let obj = config.as_object().ok_or_else(|| Error::Parse {
311 message: "config must be a JSON object".to_string(),
312 })?;
313
314 for (component, value) in obj {
315 let base_component = component.split(':').next().unwrap_or(component);
317 if SKIP_COMPONENTS.contains(&base_component) {
318 continue;
319 }
320
321 if !value.is_object() {
323 continue;
324 }
325
326 let params = serde_json::json!({
328 "config": { component: value }
329 });
330
331 let method = if component == "sys" {
333 "Sys.SetConfig"
334 } else if component.starts_with("switch:") {
335 "Switch.SetConfig"
336 } else if component.starts_with("input:") {
337 "Input.SetConfig"
338 } else {
339 continue;
341 };
342
343 let params = if component.contains(':') {
345 let id: u8 = component
346 .split(':')
347 .nth(1)
348 .and_then(|s| s.parse().ok())
349 .unwrap_or(0);
350 serde_json::json!({
351 "id": id,
352 "config": value
353 })
354 } else {
355 params
356 };
357
358 match self.rpc_call(method, Some(params)).await {
359 Ok(_) => {}
360 Err(e) => {
361 eprintln!(" warning: failed to restore {component}: {e}");
362 }
363 }
364 }
365
366 Ok(())
367 }
368
369 pub async fn set_name(&self, name: &str) -> Result<()> {
370 let params = serde_json::json!({
371 "config": { "device": { "name": name } }
372 });
373 self.rpc_call("Sys.SetConfig", Some(params)).await?;
374 Ok(())
375 }
376}
377
378fn rpc_error_message(method: &str, err: &serde_json::Value) -> String {
381 let code = err.get("code").and_then(|v| v.as_i64());
382 let message = err
383 .get("message")
384 .and_then(|v| v.as_str())
385 .unwrap_or("unknown error");
386 match code {
387 Some(code) => format!("{method} rejected (code {code}): {message}"),
388 None => format!("{method} rejected: {message}"),
389 }
390}
391
392fn build_set_body(kind: LightKind, id: u8, params: &LightParams) -> serde_json::Value {
396 let mut body = serde_json::Map::new();
397 body.insert("id".to_string(), serde_json::json!(id));
398 if let Some(on) = params.on {
399 body.insert("on".to_string(), serde_json::json!(on));
400 }
401 if let Some(b) = params.brightness {
402 body.insert("brightness".to_string(), serde_json::json!(b));
403 }
404 if kind.supports_rgb()
405 && let Some(rgb) = params.rgb
406 {
407 body.insert("rgb".to_string(), serde_json::json!(rgb));
408 }
409 if kind.supports_white()
410 && let Some(w) = params.white
411 {
412 body.insert("white".to_string(), serde_json::json!(w));
413 }
414 if kind.supports_ct()
415 && let Some(ct) = params.ct
416 {
417 body.insert("ct".to_string(), serde_json::json!(ct));
418 }
419 serde_json::Value::Object(body)
420}
421
422#[cfg(test)]
423mod tests {
424 use super::*;
425 use crate::model::{LightKind, LightParams};
426
427 #[test]
428 fn rgb_set_body_includes_color_and_brightness() {
429 let params = LightParams {
430 on: Some(true),
431 rgb: Some([0, 255, 136]),
432 brightness: Some(80),
433 ..Default::default()
434 };
435 let body = build_set_body(LightKind::Rgb, 0, ¶ms);
436 assert_eq!(
437 body,
438 serde_json::json!({ "id": 0, "on": true, "brightness": 80, "rgb": [0, 255, 136] })
439 );
440 }
441
442 #[test]
443 fn rgb_body_omits_white_and_ct() {
444 let params = LightParams {
445 on: Some(true),
446 rgb: Some([1, 2, 3]),
447 white: Some(50),
448 ct: Some(3000),
449 ..Default::default()
450 };
451 let body = build_set_body(LightKind::Rgb, 0, ¶ms);
452 assert!(body.get("white").is_none());
453 assert!(body.get("ct").is_none());
454 }
455
456 #[test]
457 fn rgbw_body_includes_white() {
458 let params = LightParams {
459 on: Some(true),
460 rgb: Some([1, 2, 3]),
461 white: Some(255),
462 ..Default::default()
463 };
464 let body = build_set_body(LightKind::Rgbw, 1, ¶ms);
465 assert_eq!(body.get("white"), Some(&serde_json::json!(255)));
466 assert_eq!(body.get("id"), Some(&serde_json::json!(1)));
467 }
468
469 #[test]
470 fn cct_body_includes_ct_not_rgb() {
471 let params = LightParams {
472 on: Some(false),
473 ct: Some(3000),
474 rgb: Some([1, 2, 3]),
475 ..Default::default()
476 };
477 let body = build_set_body(LightKind::Cct, 0, ¶ms);
478 assert_eq!(body.get("ct"), Some(&serde_json::json!(3000)));
479 assert!(body.get("rgb").is_none());
480 }
481
482 #[test]
483 fn set_preserving_power_carries_current_on() {
484 let params = LightParams {
485 on: Some(true),
486 brightness: Some(40),
487 ..Default::default()
488 };
489 let body = build_set_body(LightKind::Light, 0, ¶ms);
490 assert_eq!(body.get("on"), Some(&serde_json::json!(true)));
491 assert_eq!(body.get("brightness"), Some(&serde_json::json!(40)));
492 }
493
494 #[test]
495 fn rpc_error_message_includes_code_and_message() {
496 let err = serde_json::json!({ "code": -103, "message": "invalid argument" });
497 let message = rpc_error_message("Shelly.Reboot", &err);
498 assert!(message.contains("-103"));
499 assert!(message.contains("invalid argument"));
500 assert!(message.contains("Shelly.Reboot"));
501 }
502}