Skip to main content

shelly_core/api/
mod.rs

1pub mod discovery;
2pub mod gen1;
3pub mod gen2;
4
5use std::net::{IpAddr, SocketAddr};
6
7use crate::Result;
8use crate::error::Error;
9use crate::model::{
10    DeviceInfo, DeviceStatus, LightComponent, LightKind, LightParams, LightStatus, PowerReading,
11    SwitchStatus,
12};
13
14#[derive(Debug, Clone)]
15pub struct SwitchResult {
16    pub was_on: bool,
17}
18
19#[derive(Debug, Clone)]
20pub struct FirmwareInfo {
21    pub current_version: String,
22    pub has_update: bool,
23    pub stable_version: Option<String>,
24    pub beta_version: Option<String>,
25}
26
27pub enum ShellyDevice {
28    Gen1(gen1::Gen1Device),
29    Gen2(gen2::Gen2Device),
30}
31
32/// A Gen1 device does not implement light control at all; every light
33/// operation on it is genuinely unsupported.
34fn gen1_light_unsupported() -> Error {
35    Error::Unsupported {
36        message: "light control for Gen1 devices is not yet implemented (planned)".to_string(),
37    }
38}
39
40impl ShellyDevice {
41    pub fn info(&self) -> &DeviceInfo {
42        match self {
43            Self::Gen1(d) => d.info(),
44            Self::Gen2(d) => d.info(),
45        }
46    }
47
48    pub async fn status(&self) -> Result<DeviceStatus> {
49        match self {
50            Self::Gen1(d) => d.status().await,
51            Self::Gen2(d) => d.status().await,
52        }
53    }
54
55    pub async fn switch_status(&self, id: u8) -> Result<SwitchStatus> {
56        match self {
57            Self::Gen1(d) => d.switch_status(id).await,
58            Self::Gen2(d) => d.switch_status(id).await,
59        }
60    }
61
62    pub async fn switch_set(&self, id: u8, on: bool) -> Result<SwitchResult> {
63        match self {
64            Self::Gen1(d) => d.switch_set(id, on).await,
65            Self::Gen2(d) => d.switch_set(id, on).await,
66        }
67    }
68
69    pub async fn switch_toggle(&self, id: u8) -> Result<SwitchResult> {
70        match self {
71            Self::Gen1(d) => d.switch_toggle(id).await,
72            Self::Gen2(d) => d.switch_toggle(id).await,
73        }
74    }
75
76    pub async fn light_components(&self) -> Result<Vec<LightComponent>> {
77        match self {
78            Self::Gen1(_) => Err(gen1_light_unsupported()),
79            Self::Gen2(d) => d.light_components().await,
80        }
81    }
82
83    pub async fn light_set(
84        &self,
85        kind: LightKind,
86        id: u8,
87        params: &LightParams,
88    ) -> Result<SwitchResult> {
89        match self {
90            Self::Gen1(_) => Err(gen1_light_unsupported()),
91            Self::Gen2(d) => d.light_set(kind, id, params).await,
92        }
93    }
94
95    pub async fn light_toggle(&self, kind: LightKind, id: u8) -> Result<SwitchResult> {
96        match self {
97            Self::Gen1(_) => Err(gen1_light_unsupported()),
98            Self::Gen2(d) => d.light_toggle(kind, id).await,
99        }
100    }
101
102    pub async fn light_status(&self, kind: LightKind, id: u8) -> Result<LightStatus> {
103        match self {
104            Self::Gen1(_) => Err(gen1_light_unsupported()),
105            Self::Gen2(d) => d.light_status(kind, id).await,
106        }
107    }
108
109    pub async fn power(&self, id: u8) -> Result<PowerReading> {
110        match self {
111            Self::Gen1(d) => d.power(id).await,
112            Self::Gen2(d) => d.power(id).await,
113        }
114    }
115
116    pub async fn firmware_check(&self) -> Result<FirmwareInfo> {
117        match self {
118            Self::Gen1(d) => d.firmware_check().await,
119            Self::Gen2(d) => d.firmware_check().await,
120        }
121    }
122
123    pub async fn config_get(&self) -> Result<serde_json::Value> {
124        match self {
125            Self::Gen1(d) => d.config_get().await,
126            Self::Gen2(d) => d.config_get().await,
127        }
128    }
129
130    pub async fn reboot(&self) -> Result<()> {
131        match self {
132            Self::Gen1(d) => d.reboot().await,
133            Self::Gen2(d) => d.reboot().await,
134        }
135    }
136
137    pub async fn firmware_update(&self) -> Result<()> {
138        match self {
139            Self::Gen1(d) => d.firmware_update().await,
140            Self::Gen2(d) => d.firmware_update().await,
141        }
142    }
143
144    pub async fn config_set(&self, key: &str, value: &str) -> Result<serde_json::Value> {
145        match self {
146            Self::Gen1(d) => d.config_set(key, value).await,
147            Self::Gen2(d) => d.config_set(key, value).await,
148        }
149    }
150
151    pub async fn schedule_list(&self) -> Result<serde_json::Value> {
152        match self {
153            Self::Gen1(d) => d.schedule_list().await,
154            Self::Gen2(d) => d.schedule_list().await,
155        }
156    }
157
158    pub async fn webhook_list(&self) -> Result<serde_json::Value> {
159        match self {
160            Self::Gen1(d) => d.webhook_list().await,
161            Self::Gen2(d) => d.webhook_list().await,
162        }
163    }
164
165    pub async fn config_restore(&self, config: &serde_json::Value) -> Result<()> {
166        match self {
167            Self::Gen1(d) => d.config_restore(config).await,
168            Self::Gen2(d) => d.config_restore(config).await,
169        }
170    }
171
172    pub async fn set_name(&self, name: &str) -> Result<()> {
173        match self {
174            Self::Gen1(d) => d.set_name(name).await,
175            Self::Gen2(d) => d.set_name(name).await,
176        }
177    }
178}
179
180pub fn create_device(
181    info: DeviceInfo,
182    client: reqwest::Client,
183    password: Option<String>,
184) -> ShellyDevice {
185    let base_host = info.ip.to_string();
186    create_device_with_host(info, base_host, client, password)
187}
188
189/// Build a `ShellyDevice` that addresses the device via an explicit
190/// `host[:port]` string rather than `info.ip`. `info` still carries the
191/// device identity (model, generation, mac, ...); `base_host` is only used
192/// to build the HTTP/RPC URLs, so it can carry a port that `info.ip` cannot.
193pub fn create_device_with_host(
194    info: DeviceInfo,
195    base_host: String,
196    client: reqwest::Client,
197    password: Option<String>,
198) -> ShellyDevice {
199    match info.generation {
200        crate::model::DeviceGeneration::Gen1 => ShellyDevice::Gen1(
201            gen1::Gen1Device::new_with_host(info, base_host, client, password),
202        ),
203        crate::model::DeviceGeneration::Gen2 | crate::model::DeviceGeneration::Gen3 => {
204            ShellyDevice::Gen2(gen2::Gen2Device::new_with_host(
205                info, base_host, client, password,
206            ))
207        }
208    }
209}
210
211/// Probe a device by IP address. Thin wrapper around `probe_target` that
212/// exists so existing callers (CLI subnet discovery) keep working unchanged.
213pub async fn probe_device(ip: IpAddr, client: &reqwest::Client) -> Result<DeviceInfo> {
214    probe_target(&ip.to_string(), client).await
215}
216
217/// Probe a device by `host[:port]` string, fetching `/shelly` and
218/// classifying the outcome uniformly with every other transport path:
219/// unreachable/non-success -> `Network` (or `Auth` for 401/403), a JSON body
220/// that fails to decode -> `Parse`, and a JSON body that decodes but does
221/// not describe a Shelly device -> `Parse` as well (reachable, answered,
222/// just not a Shelly). A `/shelly` response that DOES describe a Shelly, on
223/// a `host` that isn't an IP address (hostname/mDNS) -> `Unsupported`: the
224/// device is real, so this must not collapse into the "not a Shelly" case.
225/// This is what lets a caller distinguish "nothing there" (`Network`/`Err`)
226/// from "something else is there" (`Parse`/`Err` still, but a different
227/// variant) from "a Shelly is there but this client can't address it yet"
228/// (`Unsupported`/`Err`) from "a Shelly is there" (`Ok`).
229pub async fn probe_target(host: &str, client: &reqwest::Client) -> Result<DeviceInfo> {
230    let url = format!("http://{host}/shelly");
231    let resp = client.get(&url).send().await?;
232
233    let status = resp.status();
234    if !status.is_success() {
235        let body = resp.text().await.unwrap_or_default();
236        return Err(crate::error::status_error(status, &url, &body));
237    }
238
239    let shelly: serde_json::Value = resp.json().await?;
240
241    // `/shelly` answered - a real device (Shelly or otherwise) is there.
242    // A host we can't turn into an `IpAddr` (a hostname/mDNS name) is NOT
243    // "reachable, answered, not a Shelly" (`Error::Parse`); that would
244    // silently misclassify a real Shelly as absent. It genuinely can't be
245    // represented by `DeviceInfo.ip: IpAddr` yet, so report it honestly as
246    // unsupported rather than folding it into the not-a-Shelly case.
247    let ip = parse_host_ip(host).ok_or_else(|| Error::Unsupported {
248        message: format!(
249            "host '{host}' is not an IP address; hostname/mDNS targets are not yet supported (use the device IP)"
250        ),
251    })?;
252
253    let mut info = DeviceInfo::from_shelly_response(ip, &shelly).ok_or_else(|| Error::Parse {
254        message: format!("unrecognized Shelly response from {host}"),
255    })?;
256
257    // Gen2/Gen3 devices don't report num_outputs in /shelly, so count switch
258    // components from the full status response.
259    if matches!(
260        info.generation,
261        crate::model::DeviceGeneration::Gen2 | crate::model::DeviceGeneration::Gen3
262    ) && let Ok((num_outputs, num_meters)) = count_gen2_outputs(host, client).await
263    {
264        info.num_outputs = num_outputs;
265        info.num_meters = num_meters;
266    }
267
268    Ok(info)
269}
270
271/// Resolve a `host` or `host:port` string down to the bare `IpAddr` used to
272/// populate `DeviceInfo.ip`. Handles bracketed IPv6 (`[::1]:8080`), bare
273/// IPv6 (`::1`), and IPv4 with or without a port.
274fn parse_host_ip(host: &str) -> Option<IpAddr> {
275    if let Ok(addr) = host.parse::<SocketAddr>() {
276        return Some(addr.ip());
277    }
278    if let Ok(ip) = host.parse::<IpAddr>() {
279        return Some(ip);
280    }
281    // IPv4 host:port without brackets (SocketAddr parses this too, but be
282    // defensive in case of formats it rejects).
283    if let Some((host_part, _port)) = host.rsplit_once(':')
284        && let Ok(ip) = host_part.parse::<IpAddr>()
285    {
286        return Some(ip);
287    }
288    None
289}
290
291/// Count switch components from a Gen2/Gen3 `Shelly.GetStatus` response.
292async fn count_gen2_outputs(host: &str, client: &reqwest::Client) -> Result<(u8, u8)> {
293    let url = format!("http://{host}/rpc/Shelly.GetStatus");
294    let resp = client.get(&url).send().await?;
295
296    let status = resp.status();
297    if !status.is_success() {
298        let body = resp.text().await.unwrap_or_default();
299        return Err(crate::error::status_error(status, &url, &body));
300    }
301
302    let status: serde_json::Value = resp.json().await?;
303
304    let obj = status.as_object().ok_or_else(|| Error::Parse {
305        message: format!("expected a JSON object from {url}"),
306    })?;
307
308    let num_switches = obj.keys().filter(|k| k.starts_with("switch:")).count() as u8;
309
310    // Gen2 power metering is embedded in each switch component
311    Ok((num_switches.max(1), num_switches.max(1)))
312}