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
32fn 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
189pub 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
211pub async fn probe_device(ip: IpAddr, client: &reqwest::Client) -> Result<DeviceInfo> {
214 probe_target(&ip.to_string(), client).await
215}
216
217pub 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 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 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
271fn 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 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
291async 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 Ok((num_switches.max(1), num_switches.max(1)))
312}