Skip to main content

govee_api2/
types.rs

1use serde::{Deserialize, Serialize};
2
3/// A Govee device or device group
4#[derive(Debug, Clone, Serialize, Deserialize)]
5#[serde(rename_all = "camelCase")]
6pub struct Device {
7    /// Device/group identifier
8    pub device: String,
9
10    /// SKU/model identifier
11    pub sku: String,
12
13    /// User-friendly device name
14    pub device_name: String,
15
16    /// Device type (e.g., "devices.types.light"), None for groups
17    #[serde(rename = "type")]
18    pub device_type: Option<String>,
19
20    /// List of capabilities this device supports
21    #[serde(default)]
22    pub capabilities: Vec<Capability>,
23}
24
25impl Device {
26    /// Returns true if this is a device group (not an individual device)
27    pub fn is_group(&self) -> bool {
28        self.sku == "SameModeGroup"
29    }
30
31    /// Returns true if the device supports on/off control
32    pub fn supports_power(&self) -> bool {
33        self.has_capability("devices.capabilities.on_off")
34    }
35
36    /// Returns true if the device supports brightness control
37    pub fn supports_brightness(&self) -> bool {
38        self.has_instance("devices.capabilities.range", "brightness")
39    }
40
41    /// Returns true if the device supports RGB color control
42    pub fn supports_color(&self) -> bool {
43        self.has_instance("devices.capabilities.color_setting", "colorRgb")
44    }
45
46    /// Returns true if the device supports color temperature control
47    pub fn supports_color_temp(&self) -> bool {
48        self.has_instance("devices.capabilities.color_setting", "colorTemperatureK")
49    }
50
51    /// Returns true if the device supports dynamic light scenes
52    pub fn supports_scenes(&self) -> bool {
53        self.has_capability("devices.capabilities.dynamic_scene")
54    }
55
56    /// Returns true if the device supports per-segment color/brightness control
57    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/// A device capability
75#[derive(Debug, Clone, Serialize, Deserialize)]
76#[serde(rename_all = "camelCase")]
77pub struct Capability {
78    /// Capability type (e.g., "devices.capabilities.on_off")
79    #[serde(rename = "type")]
80    pub capability_type: String,
81
82    /// Instance identifier
83    pub instance: String,
84
85    /// Capability-specific parameters
86    #[serde(default)]
87    pub parameters: serde_json::Value,
88}
89
90/// A light scene (dynamic or DIY) that can be activated on a device.
91///
92/// Dynamic light scenes (from `/router/api/v1/device/scenes`) carry a
93/// `param_id` alongside the scene `id`; DIY scenes (from
94/// `/router/api/v1/device/diy-scenes`) are identified by a single integer
95/// value, so `param_id` is `None`.
96#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
97pub struct Scene {
98    /// Human-readable scene name (e.g., "Sunrise")
99    pub name: String,
100
101    /// Scene identifier sent in the control request
102    pub id: i64,
103
104    /// Companion parameter id (dynamic light scenes only)
105    pub param_id: Option<i64>,
106
107    /// Capability type to send when activating this scene
108    /// (e.g., "devices.capabilities.dynamic_scene" or
109    /// "devices.capabilities.diy_color_setting")
110    pub capability_type: String,
111
112    /// Capability instance to send when activating this scene
113    /// (e.g., "lightScene" or "diyScene")
114    pub instance: String,
115}
116
117impl Scene {
118    /// The control-request value for this scene:
119    /// `{"paramId": .., "id": ..}` for dynamic scenes, a bare integer for
120    /// DIY scenes.
121    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    /// Extract scenes from a list of scene capabilities (as returned by the
129    /// scenes and diy-scenes endpoints).
130    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/// Raw device state response from API (wrapped in payload)
169#[derive(Debug, Clone, Serialize, Deserialize)]
170pub struct DeviceStatePayload {
171    pub sku: String,
172    pub device: String,
173    pub capabilities: Vec<CapabilityState>,
174}
175
176/// A capability state from the device
177#[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/// State value can be bool, int, string, or object
186#[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/// Parsed device state information (user-friendly)
196#[derive(Debug, Clone)]
197pub struct DeviceState {
198    /// Power on/off
199    pub power: bool,
200
201    /// Whether the device is reachable over wifi (if reported)
202    pub online: Option<bool>,
203
204    /// Brightness (0-100)
205    pub brightness: Option<i32>,
206
207    /// RGB color
208    pub color: Option<Color>,
209
210    /// Color temperature in Kelvin
211    pub color_temperature_kelvin: Option<i32>,
212
213    /// Active dynamic light scene id (if the device reports one)
214    pub light_scene: Option<i64>,
215
216    /// Active DIY scene id (if the device reports one)
217    pub diy_scene: Option<i64>,
218
219    /// Whether the device reports segmented color control in its state
220    pub has_segments: bool,
221}
222
223impl DeviceState {
224    /// Parse from raw capabilities array
225    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                    // colorRgb is a single integer packed as: (r << 16) | (g << 8) | b
259                    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    /// Pack into the single integer the API expects: (r << 16) | (g << 8) | b
328    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// Internal API response types
334#[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// Device state uses "payload" instead of "data"
344#[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// Scene list responses (/device/scenes and /device/diy-scenes) wrap
354// capabilities in a payload like the state endpoint.
355#[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// Device control response has "msg" and "capability" at top level
371#[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    /// Capability list modeled on the H6072 example in Govee's
410    /// "Get You Devices" reference documentation.
411    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    /// A color-only bulb: has colorRgb but no colorTemperatureK, no scenes,
435    /// no segments.
436    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        // Regression test: colorRgb alone must not imply color temperature.
468        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        // Modeled on the "Get Device State" response example in the docs.
490        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        // The docs note that an empty value means "instance does not
523        // support query"; make sure those don't break parsing.
524        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        // Shape from the "Get Dynamic Scene" docs: value is {paramId, id}.
546        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        // Shape from the DIY section of the docs: value is a bare integer
580        // and the capability type is diy_color_setting.
581        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}