use crate::Result;
use crate::error::{self, Error};
use crate::model::{
DeviceInfo, DeviceStatus, LightComponent, LightKind, LightParams, LightStatus, PowerReading,
SwitchStatus,
};
use super::{FirmwareInfo, SwitchResult};
pub struct Gen2Device {
info: DeviceInfo,
base_host: String,
client: reqwest::Client,
password: Option<String>,
}
impl Gen2Device {
pub fn new(info: DeviceInfo, client: reqwest::Client, password: Option<String>) -> Self {
let base_host = info.ip.to_string();
Self::new_with_host(info, base_host, client, password)
}
pub fn new_with_host(
info: DeviceInfo,
base_host: String,
client: reqwest::Client,
password: Option<String>,
) -> Self {
Self {
info,
base_host,
client,
password,
}
}
fn rpc_url(&self, method: &str) -> String {
format!("http://{}/rpc/{method}", self.base_host)
}
async fn rpc_call(
&self,
method: &str,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let url = self.rpc_url(method);
let resp = if let Some(params) = params {
let mut req = self.client.post(&url).json(¶ms);
if let Some(ref password) = self.password {
req = req.basic_auth("admin", Some(password));
}
req.send().await?
} else {
let mut req = self.client.get(&url);
if let Some(ref password) = self.password {
req = req.basic_auth("admin", Some(password));
}
req.send().await?
};
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(error::status_error(status, &url, &body));
}
let body: serde_json::Value = resp.json().await?;
if let Some(err_obj) = body.get("error") {
return Err(Error::Rejected {
message: rpc_error_message(method, err_obj),
});
}
Ok(body)
}
pub fn info(&self) -> &DeviceInfo {
&self.info
}
pub async fn rpc_raw(
&self,
method: &str,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
self.rpc_call(method, params).await
}
pub async fn status(&self) -> Result<DeviceStatus> {
let status = self.rpc_call("Shelly.GetStatus", None).await?;
Ok(DeviceStatus::from_gen2(&status))
}
pub async fn switch_status(&self, id: u8) -> Result<SwitchStatus> {
let params = serde_json::json!({ "id": id });
let resp = self.rpc_call("Switch.GetStatus", Some(params)).await?;
Ok(SwitchStatus::from_gen2_switch_json(&resp))
}
pub async fn switch_set(&self, id: u8, on: bool) -> Result<SwitchResult> {
let params = serde_json::json!({ "id": id, "on": on });
let resp = self.rpc_call("Switch.Set", Some(params)).await?;
let was_on = resp
.get("was_on")
.and_then(|v| v.as_bool())
.unwrap_or(false);
Ok(SwitchResult { was_on })
}
pub async fn switch_toggle(&self, id: u8) -> Result<SwitchResult> {
let params = serde_json::json!({ "id": id });
let resp = self.rpc_call("Switch.Toggle", Some(params)).await?;
let was_on = resp
.get("was_on")
.and_then(|v| v.as_bool())
.unwrap_or(false);
Ok(SwitchResult { was_on })
}
pub async fn light_components(&self) -> Result<Vec<LightComponent>> {
let status = self.rpc_call("Shelly.GetStatus", None).await?;
Ok(LightComponent::from_status(&status))
}
pub async fn light_set(
&self,
kind: LightKind,
id: u8,
params: &LightParams,
) -> Result<SwitchResult> {
let body = build_set_body(kind, id, params);
let method = format!("{}.Set", kind.rpc_namespace());
let resp = self.rpc_call(&method, Some(body)).await?;
let was_on = resp
.get("was_on")
.and_then(|v| v.as_bool())
.unwrap_or(false);
Ok(SwitchResult { was_on })
}
pub async fn light_toggle(&self, kind: LightKind, id: u8) -> Result<SwitchResult> {
let method = format!("{}.Toggle", kind.rpc_namespace());
let resp = self
.rpc_call(&method, Some(serde_json::json!({ "id": id })))
.await?;
let was_on = resp
.get("was_on")
.and_then(|v| v.as_bool())
.unwrap_or(false);
Ok(SwitchResult { was_on })
}
pub async fn light_status(&self, kind: LightKind, id: u8) -> Result<LightStatus> {
let method = format!("{}.GetStatus", kind.rpc_namespace());
let resp = self
.rpc_call(&method, Some(serde_json::json!({ "id": id })))
.await?;
Ok(LightStatus::from_component_json(kind, id, &resp))
}
pub async fn power(&self, id: u8) -> Result<PowerReading> {
let params = serde_json::json!({ "id": id });
let resp = self.rpc_call("Switch.GetStatus", Some(params)).await?;
let power = resp.get("apower").and_then(|v| v.as_f64()).unwrap_or(0.0);
let voltage = resp.get("voltage").and_then(|v| v.as_f64());
let current = resp.get("current").and_then(|v| v.as_f64());
let total = resp
.get("aenergy")
.and_then(|v| v.get("total"))
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
Ok(PowerReading {
id,
power_watts: power,
voltage,
current,
total_energy_wh: total,
})
}
pub async fn firmware_check(&self) -> Result<FirmwareInfo> {
let resp = self.rpc_call("Shelly.CheckForUpdate", None).await?;
let dev_info = self.rpc_call("Shelly.GetDeviceInfo", None).await?;
let current = dev_info
.get("ver")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
let stable = resp
.get("stable")
.and_then(|v| v.get("version"))
.and_then(|v| v.as_str())
.map(String::from);
let beta = resp
.get("beta")
.and_then(|v| v.get("version"))
.and_then(|v| v.as_str())
.map(String::from);
let has_update = stable.is_some();
Ok(FirmwareInfo {
current_version: current,
has_update,
stable_version: stable,
beta_version: beta,
})
}
pub async fn config_get(&self) -> Result<serde_json::Value> {
self.rpc_call("Shelly.GetConfig", None).await
}
pub async fn reboot(&self) -> Result<()> {
self.rpc_call("Shelly.Reboot", None).await?;
Ok(())
}
pub async fn firmware_update(&self) -> Result<()> {
let params = serde_json::json!({ "stage": "stable" });
self.rpc_call("Shelly.Update", Some(params)).await?;
Ok(())
}
pub async fn config_set(&self, key: &str, value: &str) -> Result<serde_json::Value> {
let (component, config_key) = match key {
"name" => ("sys", "device"),
"eco_mode" => ("sys", "device"),
"led_status_disable" | "led" => ("sys", "ui"),
_ => {
return Err(Error::Unsupported {
message: format!(
"unknown config key '{key}'. Supported keys: name, eco_mode, led_status_disable"
),
});
}
};
let parsed_value: serde_json::Value = match value {
"true" => serde_json::Value::Bool(true),
"false" => serde_json::Value::Bool(false),
v if v.parse::<f64>().is_ok() => {
serde_json::Value::Number(serde_json::Number::from_f64(v.parse().unwrap()).unwrap())
}
v => serde_json::Value::String(v.to_string()),
};
let config = match key {
"name" => serde_json::json!({ component: { config_key: { "name": parsed_value } } }),
"eco_mode" => {
serde_json::json!({ component: { config_key: { "eco_mode": parsed_value } } })
}
"led_status_disable" | "led" => {
serde_json::json!({ component: { config_key: { "led_status_disable": parsed_value } } })
}
_ => unreachable!(),
};
self.rpc_call(
"Sys.SetConfig",
Some(serde_json::json!({ "config": config[component] })),
)
.await
}
pub async fn schedule_list(&self) -> Result<serde_json::Value> {
let resp = self.rpc_call("Schedule.List", None).await?;
Ok(resp
.get("jobs")
.cloned()
.unwrap_or(serde_json::Value::Array(vec![])))
}
pub async fn webhook_list(&self) -> Result<serde_json::Value> {
let resp = self.rpc_call("Webhook.List", None).await?;
Ok(resp
.get("hooks")
.cloned()
.unwrap_or(serde_json::Value::Array(vec![])))
}
pub async fn config_restore(&self, config: &serde_json::Value) -> Result<()> {
const SKIP_COMPONENTS: &[&str] = &["wifi", "eth", "ble", "cloud", "mqtt", "ws"];
let obj = config.as_object().ok_or_else(|| Error::Parse {
message: "config must be a JSON object".to_string(),
})?;
for (component, value) in obj {
let base_component = component.split(':').next().unwrap_or(component);
if SKIP_COMPONENTS.contains(&base_component) {
continue;
}
if !value.is_object() {
continue;
}
let params = serde_json::json!({
"config": { component: value }
});
let method = if component == "sys" {
"Sys.SetConfig"
} else if component.starts_with("switch:") {
"Switch.SetConfig"
} else if component.starts_with("input:") {
"Input.SetConfig"
} else {
continue;
};
let params = if component.contains(':') {
let id: u8 = component
.split(':')
.nth(1)
.and_then(|s| s.parse().ok())
.unwrap_or(0);
serde_json::json!({
"id": id,
"config": value
})
} else {
params
};
match self.rpc_call(method, Some(params)).await {
Ok(_) => {}
Err(e) => {
eprintln!(" warning: failed to restore {component}: {e}");
}
}
}
Ok(())
}
pub async fn set_name(&self, name: &str) -> Result<()> {
let params = serde_json::json!({
"config": { "device": { "name": name } }
});
self.rpc_call("Sys.SetConfig", Some(params)).await?;
Ok(())
}
}
fn rpc_error_message(method: &str, err: &serde_json::Value) -> String {
let code = err.get("code").and_then(|v| v.as_i64());
let message = err
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("unknown error");
match code {
Some(code) => format!("{method} rejected (code {code}): {message}"),
None => format!("{method} rejected: {message}"),
}
}
fn build_set_body(kind: LightKind, id: u8, params: &LightParams) -> serde_json::Value {
let mut body = serde_json::Map::new();
body.insert("id".to_string(), serde_json::json!(id));
if let Some(on) = params.on {
body.insert("on".to_string(), serde_json::json!(on));
}
if let Some(b) = params.brightness {
body.insert("brightness".to_string(), serde_json::json!(b));
}
if kind.supports_rgb()
&& let Some(rgb) = params.rgb
{
body.insert("rgb".to_string(), serde_json::json!(rgb));
}
if kind.supports_white()
&& let Some(w) = params.white
{
body.insert("white".to_string(), serde_json::json!(w));
}
if kind.supports_ct()
&& let Some(ct) = params.ct
{
body.insert("ct".to_string(), serde_json::json!(ct));
}
serde_json::Value::Object(body)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{LightKind, LightParams};
#[test]
fn rgb_set_body_includes_color_and_brightness() {
let params = LightParams {
on: Some(true),
rgb: Some([0, 255, 136]),
brightness: Some(80),
..Default::default()
};
let body = build_set_body(LightKind::Rgb, 0, ¶ms);
assert_eq!(
body,
serde_json::json!({ "id": 0, "on": true, "brightness": 80, "rgb": [0, 255, 136] })
);
}
#[test]
fn rgb_body_omits_white_and_ct() {
let params = LightParams {
on: Some(true),
rgb: Some([1, 2, 3]),
white: Some(50),
ct: Some(3000),
..Default::default()
};
let body = build_set_body(LightKind::Rgb, 0, ¶ms);
assert!(body.get("white").is_none());
assert!(body.get("ct").is_none());
}
#[test]
fn rgbw_body_includes_white() {
let params = LightParams {
on: Some(true),
rgb: Some([1, 2, 3]),
white: Some(255),
..Default::default()
};
let body = build_set_body(LightKind::Rgbw, 1, ¶ms);
assert_eq!(body.get("white"), Some(&serde_json::json!(255)));
assert_eq!(body.get("id"), Some(&serde_json::json!(1)));
}
#[test]
fn cct_body_includes_ct_not_rgb() {
let params = LightParams {
on: Some(false),
ct: Some(3000),
rgb: Some([1, 2, 3]),
..Default::default()
};
let body = build_set_body(LightKind::Cct, 0, ¶ms);
assert_eq!(body.get("ct"), Some(&serde_json::json!(3000)));
assert!(body.get("rgb").is_none());
}
#[test]
fn set_preserving_power_carries_current_on() {
let params = LightParams {
on: Some(true),
brightness: Some(40),
..Default::default()
};
let body = build_set_body(LightKind::Light, 0, ¶ms);
assert_eq!(body.get("on"), Some(&serde_json::json!(true)));
assert_eq!(body.get("brightness"), Some(&serde_json::json!(40)));
}
#[test]
fn rpc_error_message_includes_code_and_message() {
let err = serde_json::json!({ "code": -103, "message": "invalid argument" });
let message = rpc_error_message("Shelly.Reboot", &err);
assert!(message.contains("-103"));
assert!(message.contains("invalid argument"));
assert!(message.contains("Shelly.Reboot"));
}
}