1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Serialize, Deserialize)]
5#[serde(rename_all = "camelCase")]
6pub struct Device {
7 pub device: String,
9
10 pub sku: String,
12
13 pub device_name: String,
15
16 #[serde(rename = "type")]
18 pub device_type: Option<String>,
19
20 #[serde(default)]
22 pub capabilities: Vec<Capability>,
23}
24
25impl Device {
26 pub fn is_group(&self) -> bool {
28 self.sku == "SameModeGroup"
29 }
30
31 pub fn supports_power(&self) -> bool {
33 self.has_capability("devices.capabilities.on_off")
34 }
35
36 pub fn supports_brightness(&self) -> bool {
38 self.has_instance("devices.capabilities.range", "brightness")
39 }
40
41 pub fn supports_color(&self) -> bool {
43 self.has_instance("devices.capabilities.color_setting", "colorRgb")
44 }
45
46 pub fn supports_color_temp(&self) -> bool {
48 self.has_instance("devices.capabilities.color_setting", "colorTemperatureK")
49 }
50
51 pub fn supports_scenes(&self) -> bool {
53 self.has_capability("devices.capabilities.dynamic_scene")
54 }
55
56 pub fn supports_segments(&self) -> bool {
58 self.has_capability("devices.capabilities.segment_color_setting")
59 }
60
61 fn has_capability(&self, capability_type: &str) -> bool {
62 self.capabilities
63 .iter()
64 .any(|c| c.capability_type == capability_type)
65 }
66
67 fn has_instance(&self, capability_type: &str, instance: &str) -> bool {
68 self.capabilities
69 .iter()
70 .any(|c| c.capability_type == capability_type && c.instance == instance)
71 }
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize)]
76#[serde(rename_all = "camelCase")]
77pub struct Capability {
78 #[serde(rename = "type")]
80 pub capability_type: String,
81
82 pub instance: String,
84
85 #[serde(default)]
87 pub parameters: serde_json::Value,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
97pub struct Scene {
98 pub name: String,
100
101 pub id: i64,
103
104 pub param_id: Option<i64>,
106
107 pub capability_type: String,
111
112 pub instance: String,
115}
116
117impl Scene {
118 pub fn control_value(&self) -> serde_json::Value {
122 match self.param_id {
123 Some(param_id) => serde_json::json!({ "paramId": param_id, "id": self.id }),
124 None => serde_json::json!(self.id),
125 }
126 }
127
128 pub fn from_capabilities(capabilities: &[Capability]) -> Vec<Scene> {
131 let mut scenes = Vec::new();
132
133 for cap in capabilities {
134 let Some(options) = cap.parameters.get("options").and_then(|o| o.as_array()) else {
135 continue;
136 };
137
138 for option in options {
139 let Some(name) = option.get("name").and_then(|n| n.as_str()) else {
140 continue;
141 };
142 let Some(value) = option.get("value") else {
143 continue;
144 };
145
146 let (id, param_id) = if let Some(id) = value.as_i64() {
147 (id, None)
148 } else if let Some(id) = value.get("id").and_then(|v| v.as_i64()) {
149 (id, value.get("paramId").and_then(|v| v.as_i64()))
150 } else {
151 continue;
152 };
153
154 scenes.push(Scene {
155 name: name.to_string(),
156 id,
157 param_id,
158 capability_type: cap.capability_type.clone(),
159 instance: cap.instance.clone(),
160 });
161 }
162 }
163
164 scenes
165 }
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize)]
170pub struct DeviceStatePayload {
171 pub sku: String,
172 pub device: String,
173 pub capabilities: Vec<CapabilityState>,
174}
175
176#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct CapabilityState {
179 #[serde(rename = "type")]
180 pub capability_type: String,
181 pub instance: String,
182 pub state: StateValue,
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize)]
187#[serde(untagged)]
188pub enum StateValue {
189 Bool { value: bool },
190 Int { value: i64 },
191 String { value: String },
192 Object { value: serde_json::Value },
193}
194
195#[derive(Debug, Clone)]
197pub struct DeviceState {
198 pub power: bool,
200
201 pub online: Option<bool>,
203
204 pub brightness: Option<i32>,
206
207 pub color: Option<Color>,
209
210 pub color_temperature_kelvin: Option<i32>,
212
213 pub light_scene: Option<i64>,
215
216 pub diy_scene: Option<i64>,
218
219 pub has_segments: bool,
221}
222
223impl DeviceState {
224 pub fn from_capabilities(capabilities: Vec<CapabilityState>) -> Self {
226 let mut power = false;
227 let mut online = None;
228 let mut brightness = None;
229 let mut color = None;
230 let mut color_temperature_kelvin = None;
231 let mut light_scene = None;
232 let mut diy_scene = None;
233 let mut has_segments = false;
234
235 for cap in capabilities {
236 if cap.capability_type == "devices.capabilities.segment_color_setting" {
237 has_segments = true;
238 continue;
239 }
240
241 match cap.instance.as_str() {
242 "powerSwitch" => match cap.state {
243 StateValue::Int { value } => power = value == 1,
244 StateValue::Bool { value } => power = value,
245 _ => {}
246 },
247 "online" => match cap.state {
248 StateValue::Bool { value } => online = Some(value),
249 StateValue::Int { value } => online = Some(value == 1),
250 _ => {}
251 },
252 "brightness" => {
253 if let StateValue::Int { value } = cap.state {
254 brightness = Some(value as i32);
255 }
256 }
257 "colorRgb" => {
258 if let StateValue::Int { value } = cap.state {
260 let r = ((value >> 16) & 0xFF) as u8;
261 let g = ((value >> 8) & 0xFF) as u8;
262 let b = (value & 0xFF) as u8;
263 color = Some(Color { r, g, b });
264 }
265 }
266 "colorTemperatureK" | "colorTem" => {
267 if let StateValue::Int { value } = cap.state {
268 color_temperature_kelvin = Some(value as i32);
269 }
270 }
271 "lightScene" => {
272 if let StateValue::Int { value } = cap.state {
273 light_scene = Some(value);
274 }
275 }
276 "diyScene" => {
277 if let StateValue::Int { value } = cap.state {
278 diy_scene = Some(value);
279 }
280 }
281 _ => {}
282 }
283 }
284
285 Self {
286 power,
287 online,
288 brightness,
289 color,
290 color_temperature_kelvin,
291 light_scene,
292 diy_scene,
293 has_segments,
294 }
295 }
296}
297
298#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
299#[serde(rename_all = "lowercase")]
300pub enum PowerState {
301 On,
302 Off,
303}
304
305impl PowerState {
306 pub fn is_on(&self) -> bool {
307 matches!(self, PowerState::On)
308 }
309}
310
311#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
312pub struct Color {
313 pub r: u8,
314 pub g: u8,
315 pub b: u8,
316}
317
318impl Color {
319 pub fn new(r: u8, g: u8, b: u8) -> Self {
320 Self { r, g, b }
321 }
322
323 pub fn to_hex(&self) -> String {
324 format!("#{:02X}{:02X}{:02X}", self.r, self.g, self.b)
325 }
326
327 pub fn to_packed(&self) -> i64 {
329 ((self.r as i64) << 16) | ((self.g as i64) << 8) | (self.b as i64)
330 }
331}
332
333#[derive(Debug, Deserialize)]
335pub(crate) struct ApiResponse<T> {
336 #[serde(default)]
337 pub code: i32,
338 #[serde(default, alias = "msg")]
339 pub message: String,
340 pub data: T,
341}
342
343#[derive(Debug, Deserialize)]
345pub(crate) struct DeviceStateResponse {
346 #[serde(default)]
347 pub code: i32,
348 #[serde(default, alias = "message")]
349 pub msg: String,
350 pub payload: DeviceStatePayload,
351}
352
353#[derive(Debug, Deserialize)]
356pub(crate) struct ScenesResponse {
357 #[serde(default)]
358 pub code: i32,
359 #[serde(default, alias = "message")]
360 pub msg: String,
361 pub payload: ScenesPayload,
362}
363
364#[derive(Debug, Deserialize)]
365pub(crate) struct ScenesPayload {
366 #[serde(default)]
367 pub capabilities: Vec<Capability>,
368}
369
370#[derive(Debug, Deserialize)]
372pub(crate) struct ControlResponse {
373 #[serde(default)]
374 pub code: i32,
375 #[serde(default, alias = "message")]
376 pub msg: String,
377}
378
379#[derive(Debug, Serialize)]
380pub(crate) struct ControlRequest {
381 #[serde(rename = "requestId")]
382 pub request_id: String,
383 pub payload: ControlPayload,
384}
385
386#[derive(Debug, Serialize)]
387pub(crate) struct ControlPayload {
388 pub sku: String,
389 pub device: String,
390 pub capability: CapabilityCommand,
391}
392
393#[derive(Debug, Serialize)]
394pub(crate) struct CapabilityCommand {
395 #[serde(rename = "type")]
396 pub capability_type: String,
397 pub instance: String,
398 pub value: serde_json::Value,
399}
400
401#[cfg(test)]
402mod tests {
403 use super::*;
404
405 fn device_from_json(json: serde_json::Value) -> Device {
406 serde_json::from_value(json).unwrap()
407 }
408
409 fn full_featured_device() -> Device {
412 device_from_json(serde_json::json!({
413 "sku": "H6072",
414 "device": "9D:FA:85:EB:D3:00:8B:FF",
415 "deviceName": "Floor Lamp",
416 "type": "devices.types.light",
417 "capabilities": [
418 { "type": "devices.capabilities.on_off", "instance": "powerSwitch",
419 "parameters": { "dataType": "ENUM" } },
420 { "type": "devices.capabilities.range", "instance": "brightness",
421 "parameters": { "dataType": "INTEGER", "range": { "min": 1, "max": 100, "precision": 1 } } },
422 { "type": "devices.capabilities.segment_color_setting", "instance": "segmentedColorRgb",
423 "parameters": { "dataType": "STRUCT" } },
424 { "type": "devices.capabilities.color_setting", "instance": "colorRgb",
425 "parameters": { "dataType": "INTEGER" } },
426 { "type": "devices.capabilities.color_setting", "instance": "colorTemperatureK",
427 "parameters": { "dataType": "INTEGER", "range": { "min": 2000, "max": 9000, "precision": 1 } } },
428 { "type": "devices.capabilities.dynamic_scene", "instance": "lightScene",
429 "parameters": { "dataType": "ENUM" } }
430 ]
431 }))
432 }
433
434 fn rgb_only_device() -> Device {
437 device_from_json(serde_json::json!({
438 "sku": "H6008",
439 "device": "AA:BB:CC:DD:EE:FF:00:11",
440 "deviceName": "Bulb",
441 "type": "devices.types.light",
442 "capabilities": [
443 { "type": "devices.capabilities.on_off", "instance": "powerSwitch",
444 "parameters": { "dataType": "ENUM" } },
445 { "type": "devices.capabilities.range", "instance": "brightness",
446 "parameters": { "dataType": "INTEGER" } },
447 { "type": "devices.capabilities.color_setting", "instance": "colorRgb",
448 "parameters": { "dataType": "INTEGER" } }
449 ]
450 }))
451 }
452
453 #[test]
454 fn capability_detection_full_featured() {
455 let device = full_featured_device();
456 assert!(!device.is_group());
457 assert!(device.supports_power());
458 assert!(device.supports_brightness());
459 assert!(device.supports_color());
460 assert!(device.supports_color_temp());
461 assert!(device.supports_scenes());
462 assert!(device.supports_segments());
463 }
464
465 #[test]
466 fn color_temp_requires_color_temperature_instance() {
467 let device = rgb_only_device();
469 assert!(device.supports_color());
470 assert!(!device.supports_color_temp());
471 assert!(!device.supports_scenes());
472 assert!(!device.supports_segments());
473 }
474
475 #[test]
476 fn group_detection() {
477 let group = device_from_json(serde_json::json!({
478 "sku": "SameModeGroup",
479 "device": "group-1",
480 "deviceName": "Living Room",
481 "type": null,
482 "capabilities": []
483 }));
484 assert!(group.is_group());
485 }
486
487 #[test]
488 fn device_state_from_realistic_capabilities() {
489 let capabilities: Vec<CapabilityState> = serde_json::from_value(serde_json::json!([
491 { "type": "devices.capabilities.online", "instance": "online",
492 "state": { "value": true } },
493 { "type": "devices.capabilities.on_off", "instance": "powerSwitch",
494 "state": { "value": 1 } },
495 { "type": "devices.capabilities.range", "instance": "brightness",
496 "state": { "value": 42 } },
497 { "type": "devices.capabilities.color_setting", "instance": "colorRgb",
498 "state": { "value": 16711935 } },
499 { "type": "devices.capabilities.color_setting", "instance": "colorTemperatureK",
500 "state": { "value": 4000 } },
501 { "type": "devices.capabilities.dynamic_scene", "instance": "lightScene",
502 "state": { "value": 3853 } },
503 { "type": "devices.capabilities.segment_color_setting", "instance": "segmentedColorRgb",
504 "state": { "value": "" } }
505 ]))
506 .unwrap();
507
508 let state = DeviceState::from_capabilities(capabilities);
509 assert!(state.power);
510 assert_eq!(state.online, Some(true));
511 assert_eq!(state.brightness, Some(42));
512 let color = state.color.unwrap();
513 assert_eq!((color.r, color.g, color.b), (255, 0, 255));
514 assert_eq!(state.color_temperature_kelvin, Some(4000));
515 assert_eq!(state.light_scene, Some(3853));
516 assert_eq!(state.diy_scene, None);
517 assert!(state.has_segments);
518 }
519
520 #[test]
521 fn device_state_handles_empty_and_missing_values() {
522 let capabilities: Vec<CapabilityState> = serde_json::from_value(serde_json::json!([
525 { "type": "devices.capabilities.online", "instance": "online",
526 "state": { "value": false } },
527 { "type": "devices.capabilities.on_off", "instance": "powerSwitch",
528 "state": { "value": 0 } },
529 { "type": "devices.capabilities.range", "instance": "brightness",
530 "state": { "value": "" } }
531 ]))
532 .unwrap();
533
534 let state = DeviceState::from_capabilities(capabilities);
535 assert!(!state.power);
536 assert_eq!(state.online, Some(false));
537 assert_eq!(state.brightness, None);
538 assert_eq!(state.color, None);
539 assert_eq!(state.color_temperature_kelvin, None);
540 assert!(!state.has_segments);
541 }
542
543 #[test]
544 fn scenes_from_dynamic_scene_capabilities() {
545 let capabilities: Vec<Capability> = serde_json::from_value(serde_json::json!([
547 {
548 "type": "devices.capabilities.dynamic_scene",
549 "instance": "lightScene",
550 "parameters": {
551 "dataType": "ENUM",
552 "options": [
553 { "name": "Sunrise", "value": { "paramId": 4280, "id": 3853 } },
554 { "name": "Sunset", "value": { "paramId": 4281, "id": 3854 } }
555 ]
556 }
557 }
558 ]))
559 .unwrap();
560
561 let scenes = Scene::from_capabilities(&capabilities);
562 assert_eq!(scenes.len(), 2);
563 assert_eq!(scenes[0].name, "Sunrise");
564 assert_eq!(scenes[0].id, 3853);
565 assert_eq!(scenes[0].param_id, Some(4280));
566 assert_eq!(
567 scenes[0].capability_type,
568 "devices.capabilities.dynamic_scene"
569 );
570 assert_eq!(scenes[0].instance, "lightScene");
571 assert_eq!(
572 scenes[0].control_value(),
573 serde_json::json!({ "paramId": 4280, "id": 3853 })
574 );
575 }
576
577 #[test]
578 fn scenes_from_diy_capabilities() {
579 let capabilities: Vec<Capability> = serde_json::from_value(serde_json::json!([
582 {
583 "type": "devices.capabilities.diy_color_setting",
584 "instance": "diyScene",
585 "parameters": {
586 "dataType": "ENUM",
587 "options": [
588 { "name": "Xmas lights 2", "value": 8216931 },
589 { "name": "test", "value": 8216643 }
590 ]
591 }
592 }
593 ]))
594 .unwrap();
595
596 let scenes = Scene::from_capabilities(&capabilities);
597 assert_eq!(scenes.len(), 2);
598 assert_eq!(scenes[0].name, "Xmas lights 2");
599 assert_eq!(scenes[0].id, 8216931);
600 assert_eq!(scenes[0].param_id, None);
601 assert_eq!(
602 scenes[0].capability_type,
603 "devices.capabilities.diy_color_setting"
604 );
605 assert_eq!(scenes[0].instance, "diyScene");
606 assert_eq!(scenes[0].control_value(), serde_json::json!(8216931));
607 }
608
609 #[test]
610 fn scenes_skip_capabilities_without_options() {
611 let capabilities: Vec<Capability> = serde_json::from_value(serde_json::json!([
612 { "type": "devices.capabilities.diy_color_setting", "instance": "diyScene",
613 "parameters": {} }
614 ]))
615 .unwrap();
616 assert!(Scene::from_capabilities(&capabilities).is_empty());
617 }
618
619 #[test]
620 fn color_packing() {
621 assert_eq!(Color::new(255, 0, 255).to_packed(), 16711935);
622 assert_eq!(Color::new(0, 0, 255).to_packed(), 255);
623 assert_eq!(Color::new(255, 255, 255).to_packed(), 16777215);
624 assert_eq!(Color::new(1, 2, 3).to_hex(), "#010203");
625 }
626}