Skip to main content

shelly_core/api/
gen2.rs

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    /// Build a `Gen2Device` addressed by an explicit `host[:port]` string
24    /// rather than `info.ip`, so a device that isn't reachable on the
25    /// default port (or that must be reached by a test harness on an
26    /// ephemeral loopback port) can still be targeted.
27    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(&params);
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        // An HTTP 200 with an RPC-error body is not success: the device was
75        // reached and answered, but explicitly refused the request.
76        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    /// Raw JSON-RPC passthrough: call `method` with `params` verbatim and
90    /// return the device's raw JSON response. Used by the console command
91    /// path, where the caller (not this crate) has already classified the
92    /// method's hazard; a device-side `error` object still becomes
93    /// `Error::Rejected` via `rpc_call`.
94    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        // Map user-friendly keys to Gen2 RPC config paths
249        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                // Gen3 Mini uses sys.ui, but not all devices support it
278                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        // Skip network-related config to avoid bricking the device
308        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            // Skip network/connectivity components
316            let base_component = component.split(':').next().unwrap_or(component);
317            if SKIP_COMPONENTS.contains(&base_component) {
318                continue;
319            }
320
321            // Skip non-object values (e.g. null, string)
322            if !value.is_object() {
323                continue;
324            }
325
326            // Try to apply config for this component
327            let params = serde_json::json!({
328                "config": { component: value }
329            });
330
331            // Determine the RPC method based on component type
332            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                // Generic: try the component-based method
340                continue;
341            };
342
343            // For Switch/Input, extract the ID and restructure params
344            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
378/// Format a Gen2 RPC `error` object (from an HTTP 200 body) into a
379/// diagnostic message for `Error::Rejected`.
380fn 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
392/// Build the JSON body for a `<Kind>.Set` call from light params. Only the
393/// fields relevant to the component kind and present in `params` are included.
394/// Always includes `id`.
395fn 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, &params);
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, &params);
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, &params);
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, &params);
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, &params);
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}