1use 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
31pub struct ShellyClient {
36 http: reqwest::Client,
37}
38
39impl ShellyClient {
40 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
59fn to_password(target: &DeviceTarget) -> Option<String> {
63 target.credentials.as_ref().map(|c| c.password.clone())
64}
65
66fn 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
80fn 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
94fn snapshot_from(host: &str, info: &DeviceInfo, status: DeviceStatus) -> DeviceSnapshot {
99 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 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 today_kwh: None,
139 });
140
141 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 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 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 firmware: non_sentinel(&info.firmware_version).map(|version| Firmware {
181 version: Some(version),
182 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
197fn 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 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 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 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 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 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 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 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 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 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 #[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}