use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Device {
pub device: String,
pub sku: String,
pub device_name: String,
#[serde(rename = "type")]
pub device_type: Option<String>,
#[serde(default)]
pub capabilities: Vec<Capability>,
}
impl Device {
pub fn is_group(&self) -> bool {
self.sku == "SameModeGroup"
}
pub fn supports_power(&self) -> bool {
self.has_capability("devices.capabilities.on_off")
}
pub fn supports_brightness(&self) -> bool {
self.has_instance("devices.capabilities.range", "brightness")
}
pub fn supports_color(&self) -> bool {
self.has_instance("devices.capabilities.color_setting", "colorRgb")
}
pub fn supports_color_temp(&self) -> bool {
self.has_instance("devices.capabilities.color_setting", "colorTemperatureK")
}
pub fn supports_scenes(&self) -> bool {
self.has_capability("devices.capabilities.dynamic_scene")
}
pub fn supports_segments(&self) -> bool {
self.has_capability("devices.capabilities.segment_color_setting")
}
fn has_capability(&self, capability_type: &str) -> bool {
self.capabilities
.iter()
.any(|c| c.capability_type == capability_type)
}
fn has_instance(&self, capability_type: &str, instance: &str) -> bool {
self.capabilities
.iter()
.any(|c| c.capability_type == capability_type && c.instance == instance)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Capability {
#[serde(rename = "type")]
pub capability_type: String,
pub instance: String,
#[serde(default)]
pub parameters: serde_json::Value,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Scene {
pub name: String,
pub id: i64,
pub param_id: Option<i64>,
pub capability_type: String,
pub instance: String,
}
impl Scene {
pub fn control_value(&self) -> serde_json::Value {
match self.param_id {
Some(param_id) => serde_json::json!({ "paramId": param_id, "id": self.id }),
None => serde_json::json!(self.id),
}
}
pub fn from_capabilities(capabilities: &[Capability]) -> Vec<Scene> {
let mut scenes = Vec::new();
for cap in capabilities {
let Some(options) = cap.parameters.get("options").and_then(|o| o.as_array()) else {
continue;
};
for option in options {
let Some(name) = option.get("name").and_then(|n| n.as_str()) else {
continue;
};
let Some(value) = option.get("value") else {
continue;
};
let (id, param_id) = if let Some(id) = value.as_i64() {
(id, None)
} else if let Some(id) = value.get("id").and_then(|v| v.as_i64()) {
(id, value.get("paramId").and_then(|v| v.as_i64()))
} else {
continue;
};
scenes.push(Scene {
name: name.to_string(),
id,
param_id,
capability_type: cap.capability_type.clone(),
instance: cap.instance.clone(),
});
}
}
scenes
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeviceStatePayload {
pub sku: String,
pub device: String,
pub capabilities: Vec<CapabilityState>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CapabilityState {
#[serde(rename = "type")]
pub capability_type: String,
pub instance: String,
pub state: StateValue,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum StateValue {
Bool { value: bool },
Int { value: i64 },
String { value: String },
Object { value: serde_json::Value },
}
#[derive(Debug, Clone)]
pub struct DeviceState {
pub power: bool,
pub online: Option<bool>,
pub brightness: Option<i32>,
pub color: Option<Color>,
pub color_temperature_kelvin: Option<i32>,
pub light_scene: Option<i64>,
pub diy_scene: Option<i64>,
pub has_segments: bool,
}
impl DeviceState {
pub fn from_capabilities(capabilities: Vec<CapabilityState>) -> Self {
let mut power = false;
let mut online = None;
let mut brightness = None;
let mut color = None;
let mut color_temperature_kelvin = None;
let mut light_scene = None;
let mut diy_scene = None;
let mut has_segments = false;
for cap in capabilities {
if cap.capability_type == "devices.capabilities.segment_color_setting" {
has_segments = true;
continue;
}
match cap.instance.as_str() {
"powerSwitch" => match cap.state {
StateValue::Int { value } => power = value == 1,
StateValue::Bool { value } => power = value,
_ => {}
},
"online" => match cap.state {
StateValue::Bool { value } => online = Some(value),
StateValue::Int { value } => online = Some(value == 1),
_ => {}
},
"brightness" => {
if let StateValue::Int { value } = cap.state {
brightness = Some(value as i32);
}
}
"colorRgb" => {
if let StateValue::Int { value } = cap.state {
let r = ((value >> 16) & 0xFF) as u8;
let g = ((value >> 8) & 0xFF) as u8;
let b = (value & 0xFF) as u8;
color = Some(Color { r, g, b });
}
}
"colorTemperatureK" | "colorTem" => {
if let StateValue::Int { value } = cap.state {
color_temperature_kelvin = Some(value as i32);
}
}
"lightScene" => {
if let StateValue::Int { value } = cap.state {
light_scene = Some(value);
}
}
"diyScene" => {
if let StateValue::Int { value } = cap.state {
diy_scene = Some(value);
}
}
_ => {}
}
}
Self {
power,
online,
brightness,
color,
color_temperature_kelvin,
light_scene,
diy_scene,
has_segments,
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PowerState {
On,
Off,
}
impl PowerState {
pub fn is_on(&self) -> bool {
matches!(self, PowerState::On)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Color {
pub r: u8,
pub g: u8,
pub b: u8,
}
impl Color {
pub fn new(r: u8, g: u8, b: u8) -> Self {
Self { r, g, b }
}
pub fn to_hex(&self) -> String {
format!("#{:02X}{:02X}{:02X}", self.r, self.g, self.b)
}
pub fn to_packed(&self) -> i64 {
((self.r as i64) << 16) | ((self.g as i64) << 8) | (self.b as i64)
}
}
#[derive(Debug, Deserialize)]
pub(crate) struct ApiResponse<T> {
#[serde(default)]
pub code: i32,
#[serde(default, alias = "msg")]
pub message: String,
pub data: T,
}
#[derive(Debug, Deserialize)]
pub(crate) struct DeviceStateResponse {
#[serde(default)]
pub code: i32,
#[serde(default, alias = "message")]
pub msg: String,
pub payload: DeviceStatePayload,
}
#[derive(Debug, Deserialize)]
pub(crate) struct ScenesResponse {
#[serde(default)]
pub code: i32,
#[serde(default, alias = "message")]
pub msg: String,
pub payload: ScenesPayload,
}
#[derive(Debug, Deserialize)]
pub(crate) struct ScenesPayload {
#[serde(default)]
pub capabilities: Vec<Capability>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct ControlResponse {
#[serde(default)]
pub code: i32,
#[serde(default, alias = "message")]
pub msg: String,
}
#[derive(Debug, Serialize)]
pub(crate) struct ControlRequest {
#[serde(rename = "requestId")]
pub request_id: String,
pub payload: ControlPayload,
}
#[derive(Debug, Serialize)]
pub(crate) struct ControlPayload {
pub sku: String,
pub device: String,
pub capability: CapabilityCommand,
}
#[derive(Debug, Serialize)]
pub(crate) struct CapabilityCommand {
#[serde(rename = "type")]
pub capability_type: String,
pub instance: String,
pub value: serde_json::Value,
}
#[cfg(test)]
mod tests {
use super::*;
fn device_from_json(json: serde_json::Value) -> Device {
serde_json::from_value(json).unwrap()
}
fn full_featured_device() -> Device {
device_from_json(serde_json::json!({
"sku": "H6072",
"device": "9D:FA:85:EB:D3:00:8B:FF",
"deviceName": "Floor Lamp",
"type": "devices.types.light",
"capabilities": [
{ "type": "devices.capabilities.on_off", "instance": "powerSwitch",
"parameters": { "dataType": "ENUM" } },
{ "type": "devices.capabilities.range", "instance": "brightness",
"parameters": { "dataType": "INTEGER", "range": { "min": 1, "max": 100, "precision": 1 } } },
{ "type": "devices.capabilities.segment_color_setting", "instance": "segmentedColorRgb",
"parameters": { "dataType": "STRUCT" } },
{ "type": "devices.capabilities.color_setting", "instance": "colorRgb",
"parameters": { "dataType": "INTEGER" } },
{ "type": "devices.capabilities.color_setting", "instance": "colorTemperatureK",
"parameters": { "dataType": "INTEGER", "range": { "min": 2000, "max": 9000, "precision": 1 } } },
{ "type": "devices.capabilities.dynamic_scene", "instance": "lightScene",
"parameters": { "dataType": "ENUM" } }
]
}))
}
fn rgb_only_device() -> Device {
device_from_json(serde_json::json!({
"sku": "H6008",
"device": "AA:BB:CC:DD:EE:FF:00:11",
"deviceName": "Bulb",
"type": "devices.types.light",
"capabilities": [
{ "type": "devices.capabilities.on_off", "instance": "powerSwitch",
"parameters": { "dataType": "ENUM" } },
{ "type": "devices.capabilities.range", "instance": "brightness",
"parameters": { "dataType": "INTEGER" } },
{ "type": "devices.capabilities.color_setting", "instance": "colorRgb",
"parameters": { "dataType": "INTEGER" } }
]
}))
}
#[test]
fn capability_detection_full_featured() {
let device = full_featured_device();
assert!(!device.is_group());
assert!(device.supports_power());
assert!(device.supports_brightness());
assert!(device.supports_color());
assert!(device.supports_color_temp());
assert!(device.supports_scenes());
assert!(device.supports_segments());
}
#[test]
fn color_temp_requires_color_temperature_instance() {
let device = rgb_only_device();
assert!(device.supports_color());
assert!(!device.supports_color_temp());
assert!(!device.supports_scenes());
assert!(!device.supports_segments());
}
#[test]
fn group_detection() {
let group = device_from_json(serde_json::json!({
"sku": "SameModeGroup",
"device": "group-1",
"deviceName": "Living Room",
"type": null,
"capabilities": []
}));
assert!(group.is_group());
}
#[test]
fn device_state_from_realistic_capabilities() {
let capabilities: Vec<CapabilityState> = serde_json::from_value(serde_json::json!([
{ "type": "devices.capabilities.online", "instance": "online",
"state": { "value": true } },
{ "type": "devices.capabilities.on_off", "instance": "powerSwitch",
"state": { "value": 1 } },
{ "type": "devices.capabilities.range", "instance": "brightness",
"state": { "value": 42 } },
{ "type": "devices.capabilities.color_setting", "instance": "colorRgb",
"state": { "value": 16711935 } },
{ "type": "devices.capabilities.color_setting", "instance": "colorTemperatureK",
"state": { "value": 4000 } },
{ "type": "devices.capabilities.dynamic_scene", "instance": "lightScene",
"state": { "value": 3853 } },
{ "type": "devices.capabilities.segment_color_setting", "instance": "segmentedColorRgb",
"state": { "value": "" } }
]))
.unwrap();
let state = DeviceState::from_capabilities(capabilities);
assert!(state.power);
assert_eq!(state.online, Some(true));
assert_eq!(state.brightness, Some(42));
let color = state.color.unwrap();
assert_eq!((color.r, color.g, color.b), (255, 0, 255));
assert_eq!(state.color_temperature_kelvin, Some(4000));
assert_eq!(state.light_scene, Some(3853));
assert_eq!(state.diy_scene, None);
assert!(state.has_segments);
}
#[test]
fn device_state_handles_empty_and_missing_values() {
let capabilities: Vec<CapabilityState> = serde_json::from_value(serde_json::json!([
{ "type": "devices.capabilities.online", "instance": "online",
"state": { "value": false } },
{ "type": "devices.capabilities.on_off", "instance": "powerSwitch",
"state": { "value": 0 } },
{ "type": "devices.capabilities.range", "instance": "brightness",
"state": { "value": "" } }
]))
.unwrap();
let state = DeviceState::from_capabilities(capabilities);
assert!(!state.power);
assert_eq!(state.online, Some(false));
assert_eq!(state.brightness, None);
assert_eq!(state.color, None);
assert_eq!(state.color_temperature_kelvin, None);
assert!(!state.has_segments);
}
#[test]
fn scenes_from_dynamic_scene_capabilities() {
let capabilities: Vec<Capability> = serde_json::from_value(serde_json::json!([
{
"type": "devices.capabilities.dynamic_scene",
"instance": "lightScene",
"parameters": {
"dataType": "ENUM",
"options": [
{ "name": "Sunrise", "value": { "paramId": 4280, "id": 3853 } },
{ "name": "Sunset", "value": { "paramId": 4281, "id": 3854 } }
]
}
}
]))
.unwrap();
let scenes = Scene::from_capabilities(&capabilities);
assert_eq!(scenes.len(), 2);
assert_eq!(scenes[0].name, "Sunrise");
assert_eq!(scenes[0].id, 3853);
assert_eq!(scenes[0].param_id, Some(4280));
assert_eq!(
scenes[0].capability_type,
"devices.capabilities.dynamic_scene"
);
assert_eq!(scenes[0].instance, "lightScene");
assert_eq!(
scenes[0].control_value(),
serde_json::json!({ "paramId": 4280, "id": 3853 })
);
}
#[test]
fn scenes_from_diy_capabilities() {
let capabilities: Vec<Capability> = serde_json::from_value(serde_json::json!([
{
"type": "devices.capabilities.diy_color_setting",
"instance": "diyScene",
"parameters": {
"dataType": "ENUM",
"options": [
{ "name": "Xmas lights 2", "value": 8216931 },
{ "name": "test", "value": 8216643 }
]
}
}
]))
.unwrap();
let scenes = Scene::from_capabilities(&capabilities);
assert_eq!(scenes.len(), 2);
assert_eq!(scenes[0].name, "Xmas lights 2");
assert_eq!(scenes[0].id, 8216931);
assert_eq!(scenes[0].param_id, None);
assert_eq!(
scenes[0].capability_type,
"devices.capabilities.diy_color_setting"
);
assert_eq!(scenes[0].instance, "diyScene");
assert_eq!(scenes[0].control_value(), serde_json::json!(8216931));
}
#[test]
fn scenes_skip_capabilities_without_options() {
let capabilities: Vec<Capability> = serde_json::from_value(serde_json::json!([
{ "type": "devices.capabilities.diy_color_setting", "instance": "diyScene",
"parameters": {} }
]))
.unwrap();
assert!(Scene::from_capabilities(&capabilities).is_empty());
}
#[test]
fn color_packing() {
assert_eq!(Color::new(255, 0, 255).to_packed(), 16711935);
assert_eq!(Color::new(0, 0, 255).to_packed(), 255);
assert_eq!(Color::new(255, 255, 255).to_packed(), 16777215);
assert_eq!(Color::new(1, 2, 3).to_hex(), "#010203");
}
}