use std::time::Duration;
use serde_json::Value;
use switchkit::{
Capabilities, DeviceSnapshot, DeviceTarget, Energy, Firmware, NetInfo, PowerAction, Relay,
RelayState, Signal, SmartDevice, Vendor,
};
use crate::api::{ShellyDevice, create_device_with_host, probe_target};
use crate::error::Error as CoreError;
use crate::model::{DeviceGeneration, DeviceInfo, DeviceStatus};
pub struct ShellyClient {
http: reqwest::Client,
}
impl ShellyClient {
pub fn new() -> Self {
let http = reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.connect_timeout(Duration::from_secs(3))
.build()
.unwrap_or_default();
Self { http }
}
}
impl Default for ShellyClient {
fn default() -> Self {
Self::new()
}
}
fn to_password(target: &DeviceTarget) -> Option<String> {
target.credentials.as_ref().map(|c| c.password.clone())
}
fn map_err(err: CoreError, host: &str) -> switchkit::Error {
let host = host.to_string();
match err {
CoreError::Network { message } => switchkit::Error::Network { host, message },
CoreError::Auth { message } => switchkit::Error::Auth { host, message },
CoreError::Rejected { message } => switchkit::Error::Rejected { host, message },
CoreError::Parse { message } => switchkit::Error::Parse { host, message },
CoreError::Unsupported { message } => switchkit::Error::Unsupported { host, message },
}
}
fn non_sentinel(s: &str) -> Option<String> {
let trimmed = s.trim();
if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("unknown") {
None
} else {
Some(s.to_string())
}
}
fn snapshot_from(host: &str, info: &DeviceInfo, status: DeviceStatus) -> DeviceSnapshot {
let relays: Vec<Relay> = status
.switches
.iter()
.map(|sw| Relay {
index: sw.id,
state: if sw.output {
RelayState::On
} else {
RelayState::Off
},
raw: sw.output.to_string(),
})
.collect();
let energy = status
.switches
.iter()
.find(|sw| {
sw.power_watts.is_some()
|| sw.voltage.is_some()
|| sw.current.is_some()
|| sw.total_energy_wh.is_some()
})
.map(|sw| Energy {
power_w: sw.power_watts,
voltage_v: sw.voltage,
current_a: sw.current,
total_kwh: sw.total_energy_wh.map(|wh| wh / 1000.0),
today_kwh: None,
});
let signal = status
.wifi
.as_ref()
.and_then(|w| w.rssi)
.map(|dbm| Signal::from_dbm(i64::from(dbm)));
let capabilities = Capabilities {
metering: energy.is_some(),
multi_channel: status.switches.len() > 1,
firmware_ota: true,
config_backup: true,
console: matches!(
info.generation,
DeviceGeneration::Gen2 | DeviceGeneration::Gen3
),
};
DeviceSnapshot {
host: host.to_string(),
name: info.name.clone(),
model: non_sentinel(&info.model),
generation: Some(info.generation.to_string()),
capabilities,
relays,
energy,
signal,
temperature_c: status.temperature_c,
firmware: non_sentinel(&info.firmware_version).map(|version| Firmware {
version: Some(version),
update_available: None,
}),
net: NetInfo {
ip: Some(info.ip.to_string()),
mac: Some(info.mac.clone()),
hostname: None,
},
uptime: status.uptime.map(|s| s.to_string()),
}
}
fn parse_console_command(command: &str, host: &str) -> switchkit::Result<(String, Option<Value>)> {
let trimmed = command.trim();
let (method, rest) = match trimmed.split_once(char::is_whitespace) {
Some((method, rest)) => (method, rest.trim()),
None => (trimmed, ""),
};
if rest.is_empty() {
return Ok((method.to_string(), None));
}
let params = serde_json::from_str(rest).map_err(|e| switchkit::Error::Parse {
host: host.to_string(),
message: format!("invalid JSON params in console command: {e}"),
})?;
Ok((method.to_string(), Some(params)))
}
impl ShellyClient {
async fn open(&self, target: &DeviceTarget) -> switchkit::Result<ShellyDevice> {
let info = probe_target(&target.host, &self.http)
.await
.map_err(|e| map_err(e, &target.host))?;
Ok(create_device_with_host(
info,
target.host.clone(),
self.http.clone(),
to_password(target),
))
}
}
#[async_trait::async_trait]
impl SmartDevice for ShellyClient {
fn vendor(&self) -> Vendor {
Vendor::Shelly
}
async fn probe(&self, target: &DeviceTarget) -> switchkit::Result<Option<DeviceSnapshot>> {
match probe_target(&target.host, &self.http).await {
Ok(_) => {
let dev = self.open(target).await?;
let status = dev.status().await.map_err(|e| map_err(e, &target.host))?;
Ok(Some(snapshot_from(&target.host, dev.info(), status)))
}
Err(CoreError::Parse { .. }) => Ok(None),
Err(e) => Err(map_err(e, &target.host)),
}
}
async fn status(&self, target: &DeviceTarget) -> switchkit::Result<DeviceSnapshot> {
let dev = self.open(target).await?;
let status = dev.status().await.map_err(|e| map_err(e, &target.host))?;
Ok(snapshot_from(&target.host, dev.info(), status))
}
async fn set_power(
&self,
target: &DeviceTarget,
channel: Option<u8>,
action: PowerAction,
) -> switchkit::Result<Relay> {
let dev = self.open(target).await?;
let id = channel.unwrap_or(0);
match action {
PowerAction::On => dev.switch_set(id, true).await,
PowerAction::Off => dev.switch_set(id, false).await,
PowerAction::Toggle => dev.switch_toggle(id).await,
}
.map_err(|e| map_err(e, &target.host))?;
let status = dev
.switch_status(id)
.await
.map_err(|e| map_err(e, &target.host))?;
let state = if status.output {
RelayState::On
} else {
RelayState::Off
};
Ok(Relay {
index: id,
state,
raw: status.output.to_string(),
})
}
async fn firmware_version(&self, target: &DeviceTarget) -> switchkit::Result<Option<String>> {
let dev = self.open(target).await?;
Ok(non_sentinel(&dev.info().firmware_version))
}
async fn firmware_update(
&self,
target: &DeviceTarget,
_ota_url: Option<&str>,
) -> switchkit::Result<()> {
let dev = self.open(target).await?;
dev.firmware_update()
.await
.map_err(|e| map_err(e, &target.host))?;
Ok(())
}
async fn config_get(&self, target: &DeviceTarget, setting: &str) -> switchkit::Result<Value> {
let dev = self.open(target).await?;
let config = dev
.config_get()
.await
.map_err(|e| map_err(e, &target.host))?;
if setting.is_empty() {
return Ok(config);
}
config
.get(setting)
.cloned()
.ok_or_else(|| switchkit::Error::Rejected {
host: target.host.clone(),
message: format!("no such setting `{setting}`"),
})
}
async fn config_set(
&self,
target: &DeviceTarget,
setting: &str,
value: &str,
) -> switchkit::Result<Value> {
let dev = self.open(target).await?;
dev.config_set(setting, value)
.await
.map_err(|e| map_err(e, &target.host))
}
async fn backup(&self, target: &DeviceTarget) -> switchkit::Result<Vec<u8>> {
let dev = self.open(target).await?;
let config = dev
.config_get()
.await
.map_err(|e| map_err(e, &target.host))?;
serde_json::to_vec_pretty(&config).map_err(|e| switchkit::Error::Parse {
host: target.host.clone(),
message: format!("failed to serialize config for backup: {e}"),
})
}
async fn console(&self, target: &DeviceTarget, command: &str) -> switchkit::Result<Value> {
let dev = self.open(target).await?;
match dev {
ShellyDevice::Gen2(ref device) => {
let (method, params) = parse_console_command(command, &target.host)?;
device
.rpc_raw(&method, params)
.await
.map_err(|e| map_err(e, &target.host))
}
ShellyDevice::Gen1(_) => Err(switchkit::Error::Unsupported {
host: target.host.clone(),
message: "Gen1 devices have no RPC console".to_string(),
}),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use httpmock::prelude::*;
use switchkit::DeviceTarget;
fn empty_gen2_status() -> serde_json::Value {
serde_json::json!({})
}
#[tokio::test]
async fn snapshot_omits_sentinel_model_and_firmware() {
let server = MockServer::start_async().await;
server
.mock_async(|when, then| {
when.method(GET).path("/shelly");
then.status(200).json_body(serde_json::json!({
"id": "shellyplus1-abc",
"mac": "AABBCCDDEEFF",
"gen": 2
}));
})
.await;
server
.mock_async(|when, then| {
when.method(GET).path("/rpc/Shelly.GetStatus");
then.status(200).json_body(empty_gen2_status());
})
.await;
let client = ShellyClient::default();
let target = DeviceTarget::new(server.address().to_string());
let snapshot = client
.status(&target)
.await
.expect("status should succeed against the mock");
assert_eq!(
snapshot.model, None,
"the 'unknown' sentinel must not be exposed as a real model"
);
assert_eq!(
snapshot.firmware, None,
"the 'unknown' sentinel must not be exposed as a real firmware version"
);
}
#[tokio::test]
async fn snapshot_reports_real_model_and_firmware() {
let server = MockServer::start_async().await;
server
.mock_async(|when, then| {
when.method(GET).path("/shelly");
then.status(200).json_body(serde_json::json!({
"id": "shellyplus1pm-aabbccddeeff",
"mac": "AABBCCDDEEFF",
"model": "SNSW-001P16EU",
"gen": 2,
"ver": "1.2.3",
"app": "Plus1PM"
}));
})
.await;
server
.mock_async(|when, then| {
when.method(GET).path("/rpc/Shelly.GetStatus");
then.status(200).json_body(empty_gen2_status());
})
.await;
let client = ShellyClient::default();
let target = DeviceTarget::new(server.address().to_string());
let snapshot = client
.status(&target)
.await
.expect("status should succeed against the mock");
assert_eq!(snapshot.model.as_deref(), Some("SNSW-001P16EU"));
assert_eq!(
snapshot.firmware.and_then(|f| f.version).as_deref(),
Some("1.2.3")
);
}
#[tokio::test]
async fn firmware_version_omits_sentinel() {
let server = MockServer::start_async().await;
server
.mock_async(|when, then| {
when.method(GET).path("/shelly");
then.status(200).json_body(serde_json::json!({
"id": "shellyplus1-abc",
"mac": "AABBCCDDEEFF",
"gen": 2
}));
})
.await;
let client = ShellyClient::default();
let target = DeviceTarget::new(server.address().to_string());
let firmware = client
.firmware_version(&target)
.await
.expect("firmware_version should succeed against the mock");
assert_eq!(
firmware, None,
"the 'unknown' sentinel must not be exposed as a real firmware version"
);
}
#[tokio::test]
async fn firmware_version_reports_real_value() {
let server = MockServer::start_async().await;
server
.mock_async(|when, then| {
when.method(GET).path("/shelly");
then.status(200).json_body(serde_json::json!({
"id": "shellyplus1pm-aabbccddeeff",
"mac": "AABBCCDDEEFF",
"model": "SNSW-001P16EU",
"gen": 2,
"ver": "1.2.3",
"app": "Plus1PM"
}));
})
.await;
let client = ShellyClient::default();
let target = DeviceTarget::new(server.address().to_string());
let firmware = client
.firmware_version(&target)
.await
.expect("firmware_version should succeed against the mock");
assert_eq!(firmware.as_deref(), Some("1.2.3"));
}
}