Skip to main content

shelly_core/
switchkit_impl.rs

1//! `switchkit::SmartDevice` adapter for Shelly Gen1 (REST) and Gen2/3
2//! (JSON-RPC) devices.
3//!
4//! [`ShellyClient`] is stateless: one instance serves every
5//! `switchkit::DeviceTarget`. Every trait method opens a fresh
6//! [`ShellyDevice`] addressed by `DeviceTarget.host` (never `DeviceInfo.ip`,
7//! which drops any port a target carries), then maps the shelly-core
8//! response onto switchkit's vendor-neutral types.
9//!
10//! # Honesty
11//! [`snapshot_from`] only ever converts a value the device actually
12//! reported. A switch absent from the device's status response is simply
13//! absent from `relays` (never a fabricated `Off`); metering fields
14//! (`energy`) are `None` unless at least one switch actually reports a
15//! metering value; `signal` is only ever built from a real dBm reading via
16//! `Signal::from_dbm`, never a guessed percentage. See the field-level
17//! comments in `snapshot_from` for the full mapping.
18
19use std::time::Duration;
20
21use serde_json::Value;
22use switchkit::{
23    Capabilities, DeviceSnapshot, DeviceTarget, Energy, Firmware, NetInfo, PowerAction, Relay,
24    RelayState, Signal, SmartDevice, Vendor,
25};
26
27use crate::api::{ShellyDevice, create_device_with_host, probe_target};
28use crate::error::Error as CoreError;
29use crate::model::{DeviceGeneration, DeviceInfo, DeviceStatus};
30
31/// Stateless `switchkit::SmartDevice` adapter for Shelly devices. No
32/// per-device state is kept here; every call re-opens the device (a cheap
33/// HTTP probe) so the adapter always reflects the device's current
34/// generation and identity.
35pub struct ShellyClient {
36    http: reqwest::Client,
37}
38
39impl ShellyClient {
40    /// Build a client with a bounded request timeout and connect timeout, so
41    /// a dead or unreachable device fails fast instead of hanging a caller
42    /// indefinitely.
43    pub fn new() -> Self {
44        let http = reqwest::Client::builder()
45            .timeout(Duration::from_secs(5))
46            .connect_timeout(Duration::from_secs(3))
47            .build()
48            .unwrap_or_default();
49        Self { http }
50    }
51}
52
53impl Default for ShellyClient {
54    fn default() -> Self {
55        Self::new()
56    }
57}
58
59/// Shelly authentication is a single password (HTTP Basic for both
60/// generations); `DeviceCredentials.user` is not used by any Shelly
61/// transport path.
62fn to_password(target: &DeviceTarget) -> Option<String> {
63    target.credentials.as_ref().map(|c| c.password.clone())
64}
65
66/// Map a `shelly-core` transport error onto its `switchkit` equivalent. The
67/// two enums are 1:1 by design (see `shelly_core::error::Error`), so this is
68/// a plain re-tag, carrying `host` along for the vendor-neutral error.
69fn map_err(err: CoreError, host: &str) -> switchkit::Error {
70    let host = host.to_string();
71    match err {
72        CoreError::Network { message } => switchkit::Error::Network { host, message },
73        CoreError::Auth { message } => switchkit::Error::Auth { host, message },
74        CoreError::Rejected { message } => switchkit::Error::Rejected { host, message },
75        CoreError::Parse { message } => switchkit::Error::Parse { host, message },
76        CoreError::Unsupported { message } => switchkit::Error::Unsupported { host, message },
77    }
78}
79
80/// `DeviceInfo.model`/`firmware_version` default to the sentinel `"unknown"`
81/// when the device's `/shelly` response omits them (see
82/// `Gen2ShellyResponse::default_unknown`). Reporting that sentinel as a real
83/// value would fabricate absent-as-a-plausible-value, so this maps it (and
84/// an empty/whitespace-only string) back to `None`.
85fn non_sentinel(s: &str) -> Option<String> {
86    let trimmed = s.trim();
87    if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("unknown") {
88        None
89    } else {
90        Some(s.to_string())
91    }
92}
93
94/// Map a Shelly `DeviceStatus` (a snapshot from the device, already parsed)
95/// onto a vendor-neutral `DeviceSnapshot`. `host` is `DeviceTarget.host`,
96/// passed through explicitly rather than derived from `info.ip`, so any port
97/// the caller addressed the device by is preserved in the snapshot identity.
98fn snapshot_from(host: &str, info: &DeviceInfo, status: DeviceStatus) -> DeviceSnapshot {
99    // One `Relay` per switch the device actually reported. A switch id
100    // missing from `status.switches` (device offline mid-refresh, a channel
101    // that doesn't exist on this model) is simply not in `relays` - never a
102    // fabricated `Off`.
103    let relays: Vec<Relay> = status
104        .switches
105        .iter()
106        .map(|sw| Relay {
107            index: sw.id,
108            state: if sw.output {
109                RelayState::On
110            } else {
111                RelayState::Off
112            },
113            raw: sw.output.to_string(),
114        })
115        .collect();
116
117    // Metering is `Some` only when the device actually meters: at least one
118    // switch reports any of power/voltage/current/total-energy. Values come
119    // from `SwitchStatus` (already `Option`, never defaulted), not from
120    // `PowerReading`, whose `power_watts`/`total_energy_wh` are `0.0`
121    // defaulted and would fabricate a reading on a non-metering device.
122    let energy = status
123        .switches
124        .iter()
125        .find(|sw| {
126            sw.power_watts.is_some()
127                || sw.voltage.is_some()
128                || sw.current.is_some()
129                || sw.total_energy_wh.is_some()
130        })
131        .map(|sw| Energy {
132            power_w: sw.power_watts,
133            voltage_v: sw.voltage,
134            current_a: sw.current,
135            total_kwh: sw.total_energy_wh.map(|wh| wh / 1000.0),
136            // Shelly's status response has no daily energy counter; leaving
137            // this `None` is honest, not an oversight.
138            today_kwh: None,
139        });
140
141    // A real dBm reading via `Signal::from_dbm`, never a fabricated
142    // percentage. `WifiStatus.rssi` is `Option<i32>`; `Signal::from_dbm`
143    // takes `i64`.
144    let signal = status
145        .wifi
146        .as_ref()
147        .and_then(|w| w.rssi)
148        .map(|dbm| Signal::from_dbm(i64::from(dbm)));
149
150    let capabilities = Capabilities {
151        metering: energy.is_some(),
152        multi_channel: status.switches.len() > 1,
153        firmware_ota: true,
154        config_backup: true,
155        // Gen1 has no RPC console at all; report `false` so the app hides
156        // the control rather than offering one that always fails.
157        console: matches!(
158            info.generation,
159            DeviceGeneration::Gen2 | DeviceGeneration::Gen3
160        ),
161    };
162
163    DeviceSnapshot {
164        host: host.to_string(),
165        name: info.name.clone(),
166        // The sentinel `"unknown"` `DeviceInfo` falls back to when the
167        // device's `/shelly` response omits `model` is never surfaced as a
168        // real value here - only a model the device actually reported.
169        model: non_sentinel(&info.model),
170        generation: Some(info.generation.to_string()),
171        capabilities,
172        relays,
173        energy,
174        signal,
175        temperature_c: status.temperature_c,
176        // Same sentinel handling for firmware. If there is no real version
177        // to report, the whole `Firmware` block is omitted rather than
178        // emitting a `Firmware { version: None, .. }` shell that would
179        // falsely claim a firmware block exists with an unknown version.
180        firmware: non_sentinel(&info.firmware_version).map(|version| Firmware {
181            version: Some(version),
182            // Shelly's status/info responses don't carry an "update
183            // available" flag cheaply alongside them (that's a separate
184            // `Shelly.CheckForUpdate` round trip); leave it unknown rather
185            // than guessing.
186            update_available: None,
187        }),
188        net: NetInfo {
189            ip: Some(info.ip.to_string()),
190            mac: Some(info.mac.clone()),
191            hostname: None,
192        },
193        uptime: status.uptime.map(|s| s.to_string()),
194    }
195}
196
197/// Parse a console command of the form `"Method [json-params]"` into an RPC
198/// method name and optional JSON params. Malformed JSON params are reported
199/// as `Error::Parse` rather than silently dropped, so a typo in the console
200/// input never turns into an unintended parameterless call.
201fn parse_console_command(command: &str, host: &str) -> switchkit::Result<(String, Option<Value>)> {
202    let trimmed = command.trim();
203    let (method, rest) = match trimmed.split_once(char::is_whitespace) {
204        Some((method, rest)) => (method, rest.trim()),
205        None => (trimmed, ""),
206    };
207
208    if rest.is_empty() {
209        return Ok((method.to_string(), None));
210    }
211
212    let params = serde_json::from_str(rest).map_err(|e| switchkit::Error::Parse {
213        host: host.to_string(),
214        message: format!("invalid JSON params in console command: {e}"),
215    })?;
216
217    Ok((method.to_string(), Some(params)))
218}
219
220impl ShellyClient {
221    /// Probe `target` for its generation and open a `ShellyDevice` addressed
222    /// by `target.host` (with any port), not `info.ip`.
223    async fn open(&self, target: &DeviceTarget) -> switchkit::Result<ShellyDevice> {
224        let info = probe_target(&target.host, &self.http)
225            .await
226            .map_err(|e| map_err(e, &target.host))?;
227
228        Ok(create_device_with_host(
229            info,
230            target.host.clone(),
231            self.http.clone(),
232            to_password(target),
233        ))
234    }
235}
236
237#[async_trait::async_trait]
238impl SmartDevice for ShellyClient {
239    fn vendor(&self) -> Vendor {
240        Vendor::Shelly
241    }
242
243    /// Reachable-but-not-Shelly (`probe_target` returning `Error::Parse`)
244    /// maps to `Ok(None)`, never a guessed vendor. Any other error (offline,
245    /// auth, ...) propagates as `Err`.
246    async fn probe(&self, target: &DeviceTarget) -> switchkit::Result<Option<DeviceSnapshot>> {
247        match probe_target(&target.host, &self.http).await {
248            Ok(_) => {
249                let dev = self.open(target).await?;
250                let status = dev.status().await.map_err(|e| map_err(e, &target.host))?;
251                Ok(Some(snapshot_from(&target.host, dev.info(), status)))
252            }
253            Err(CoreError::Parse { .. }) => Ok(None),
254            Err(e) => Err(map_err(e, &target.host)),
255        }
256    }
257
258    async fn status(&self, target: &DeviceTarget) -> switchkit::Result<DeviceSnapshot> {
259        let dev = self.open(target).await?;
260        let status = dev.status().await.map_err(|e| map_err(e, &target.host))?;
261        Ok(snapshot_from(&target.host, dev.info(), status))
262    }
263
264    /// Issues the power action, then reads back the CONFIRMED post-change
265    /// state via `switch_status` rather than trusting `SwitchResult.was_on`
266    /// (the previous state for Gen2's `Switch.Set`/`Switch.Toggle`, with
267    /// inconsistent semantics across generations).
268    async fn set_power(
269        &self,
270        target: &DeviceTarget,
271        channel: Option<u8>,
272        action: PowerAction,
273    ) -> switchkit::Result<Relay> {
274        let dev = self.open(target).await?;
275        let id = channel.unwrap_or(0);
276
277        match action {
278            PowerAction::On => dev.switch_set(id, true).await,
279            PowerAction::Off => dev.switch_set(id, false).await,
280            PowerAction::Toggle => dev.switch_toggle(id).await,
281        }
282        .map_err(|e| map_err(e, &target.host))?;
283
284        let status = dev
285            .switch_status(id)
286            .await
287            .map_err(|e| map_err(e, &target.host))?;
288
289        let state = if status.output {
290            RelayState::On
291        } else {
292            RelayState::Off
293        };
294
295        Ok(Relay {
296            index: id,
297            state,
298            raw: status.output.to_string(),
299        })
300    }
301
302    async fn firmware_version(&self, target: &DeviceTarget) -> switchkit::Result<Option<String>> {
303        let dev = self.open(target).await?;
304        Ok(non_sentinel(&dev.info().firmware_version))
305    }
306
307    /// Shelly's stable-channel OTA update takes no URL parameter; `ota_url`
308    /// is accepted for trait compatibility but unused in v1.
309    async fn firmware_update(
310        &self,
311        target: &DeviceTarget,
312        _ota_url: Option<&str>,
313    ) -> switchkit::Result<()> {
314        let dev = self.open(target).await?;
315        dev.firmware_update()
316            .await
317            .map_err(|e| map_err(e, &target.host))?;
318        Ok(())
319    }
320
321    /// Shelly has no single-setting GET; config is one blob. `setting == ""`
322    /// returns the whole config. A non-empty `setting` returns that
323    /// top-level key's value when present; an absent key is `Err(Rejected)`,
324    /// never a fabricated `null`/`{}` (absent is not a value).
325    async fn config_get(&self, target: &DeviceTarget, setting: &str) -> switchkit::Result<Value> {
326        let dev = self.open(target).await?;
327        let config = dev
328            .config_get()
329            .await
330            .map_err(|e| map_err(e, &target.host))?;
331
332        if setting.is_empty() {
333            return Ok(config);
334        }
335
336        config
337            .get(setting)
338            .cloned()
339            .ok_or_else(|| switchkit::Error::Rejected {
340                host: target.host.clone(),
341                message: format!("no such setting `{setting}`"),
342            })
343    }
344
345    /// Returns the device's actual response to the settings write, never a
346    /// fabricated `{"ok":true}`.
347    async fn config_set(
348        &self,
349        target: &DeviceTarget,
350        setting: &str,
351        value: &str,
352    ) -> switchkit::Result<Value> {
353        let dev = self.open(target).await?;
354        dev.config_set(setting, value)
355            .await
356            .map_err(|e| map_err(e, &target.host))
357    }
358
359    /// A Shelly config backup is its settings JSON, pretty-printed.
360    async fn backup(&self, target: &DeviceTarget) -> switchkit::Result<Vec<u8>> {
361        let dev = self.open(target).await?;
362        let config = dev
363            .config_get()
364            .await
365            .map_err(|e| map_err(e, &target.host))?;
366        serde_json::to_vec_pretty(&config).map_err(|e| switchkit::Error::Parse {
367            host: target.host.clone(),
368            message: format!("failed to serialize config for backup: {e}"),
369        })
370    }
371
372    /// Gen2/3 only: `command` is `"Method [json-params]"`, passed through
373    /// verbatim to the device's JSON-RPC endpoint. Gen1 has no RPC console,
374    /// so it is genuinely `Unsupported`, not an empty/guessed response.
375    async fn console(&self, target: &DeviceTarget, command: &str) -> switchkit::Result<Value> {
376        let dev = self.open(target).await?;
377        match dev {
378            ShellyDevice::Gen2(ref device) => {
379                let (method, params) = parse_console_command(command, &target.host)?;
380                device
381                    .rpc_raw(&method, params)
382                    .await
383                    .map_err(|e| map_err(e, &target.host))
384            }
385            ShellyDevice::Gen1(_) => Err(switchkit::Error::Unsupported {
386                host: target.host.clone(),
387                message: "Gen1 devices have no RPC console".to_string(),
388            }),
389        }
390    }
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396    use httpmock::prelude::*;
397    use switchkit::DeviceTarget;
398
399    /// Minimal Gen2 `Shelly.GetStatus` body: no switch components, so
400    /// `snapshot_from` builds an empty `relays`/`energy`. Only
401    /// model/firmware are under test here.
402    fn empty_gen2_status() -> serde_json::Value {
403        serde_json::json!({})
404    }
405
406    #[tokio::test]
407    async fn snapshot_omits_sentinel_model_and_firmware() {
408        let server = MockServer::start_async().await;
409        server
410            .mock_async(|when, then| {
411                when.method(GET).path("/shelly");
412                then.status(200).json_body(serde_json::json!({
413                    "id": "shellyplus1-abc",
414                    "mac": "AABBCCDDEEFF",
415                    "gen": 2
416                }));
417            })
418            .await;
419        server
420            .mock_async(|when, then| {
421                when.method(GET).path("/rpc/Shelly.GetStatus");
422                then.status(200).json_body(empty_gen2_status());
423            })
424            .await;
425
426        let client = ShellyClient::default();
427        let target = DeviceTarget::new(server.address().to_string());
428        let snapshot = client
429            .status(&target)
430            .await
431            .expect("status should succeed against the mock");
432
433        assert_eq!(
434            snapshot.model, None,
435            "the 'unknown' sentinel must not be exposed as a real model"
436        );
437        assert_eq!(
438            snapshot.firmware, None,
439            "the 'unknown' sentinel must not be exposed as a real firmware version"
440        );
441    }
442
443    #[tokio::test]
444    async fn snapshot_reports_real_model_and_firmware() {
445        let server = MockServer::start_async().await;
446        server
447            .mock_async(|when, then| {
448                when.method(GET).path("/shelly");
449                then.status(200).json_body(serde_json::json!({
450                    "id": "shellyplus1pm-aabbccddeeff",
451                    "mac": "AABBCCDDEEFF",
452                    "model": "SNSW-001P16EU",
453                    "gen": 2,
454                    "ver": "1.2.3",
455                    "app": "Plus1PM"
456                }));
457            })
458            .await;
459        server
460            .mock_async(|when, then| {
461                when.method(GET).path("/rpc/Shelly.GetStatus");
462                then.status(200).json_body(empty_gen2_status());
463            })
464            .await;
465
466        let client = ShellyClient::default();
467        let target = DeviceTarget::new(server.address().to_string());
468        let snapshot = client
469            .status(&target)
470            .await
471            .expect("status should succeed against the mock");
472
473        assert_eq!(snapshot.model.as_deref(), Some("SNSW-001P16EU"));
474        assert_eq!(
475            snapshot.firmware.and_then(|f| f.version).as_deref(),
476            Some("1.2.3")
477        );
478    }
479
480    /// `firmware_version` must agree with `snapshot_from`: a device whose
481    /// `/shelly` response omits firmware falls back to the sentinel
482    /// `"unknown"` in `DeviceInfo.firmware_version`, and that sentinel must
483    /// never be reported as a real value.
484    #[tokio::test]
485    async fn firmware_version_omits_sentinel() {
486        let server = MockServer::start_async().await;
487        server
488            .mock_async(|when, then| {
489                when.method(GET).path("/shelly");
490                then.status(200).json_body(serde_json::json!({
491                    "id": "shellyplus1-abc",
492                    "mac": "AABBCCDDEEFF",
493                    "gen": 2
494                }));
495            })
496            .await;
497
498        let client = ShellyClient::default();
499        let target = DeviceTarget::new(server.address().to_string());
500        let firmware = client
501            .firmware_version(&target)
502            .await
503            .expect("firmware_version should succeed against the mock");
504
505        assert_eq!(
506            firmware, None,
507            "the 'unknown' sentinel must not be exposed as a real firmware version"
508        );
509    }
510
511    #[tokio::test]
512    async fn firmware_version_reports_real_value() {
513        let server = MockServer::start_async().await;
514        server
515            .mock_async(|when, then| {
516                when.method(GET).path("/shelly");
517                then.status(200).json_body(serde_json::json!({
518                    "id": "shellyplus1pm-aabbccddeeff",
519                    "mac": "AABBCCDDEEFF",
520                    "model": "SNSW-001P16EU",
521                    "gen": 2,
522                    "ver": "1.2.3",
523                    "app": "Plus1PM"
524                }));
525            })
526            .await;
527
528        let client = ShellyClient::default();
529        let target = DeviceTarget::new(server.address().to_string());
530        let firmware = client
531            .firmware_version(&target)
532            .await
533            .expect("firmware_version should succeed against the mock");
534
535        assert_eq!(firmware.as_deref(), Some("1.2.3"));
536    }
537}