Skip to main content

shelly_core/model/
light.rs

1use serde::Serialize;
2
3/// The four Gen2/Gen3 light-output component kinds `shelly light` controls.
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub enum LightKind {
6    Rgb,
7    Rgbw,
8    Cct,
9    Light,
10}
11
12impl LightKind {
13    /// Component key prefix as it appears in `Shelly.GetStatus` (e.g. "rgb").
14    pub fn as_str(self) -> &'static str {
15        match self {
16            LightKind::Rgb => "rgb",
17            LightKind::Rgbw => "rgbw",
18            LightKind::Cct => "cct",
19            LightKind::Light => "light",
20        }
21    }
22
23    /// RPC method namespace (e.g. "RGB" for "RGB.Set").
24    pub fn rpc_namespace(self) -> &'static str {
25        match self {
26            LightKind::Rgb => "RGB",
27            LightKind::Rgbw => "RGBW",
28            LightKind::Cct => "CCT",
29            LightKind::Light => "Light",
30        }
31    }
32
33    pub fn supports_rgb(self) -> bool {
34        matches!(self, LightKind::Rgb | LightKind::Rgbw)
35    }
36
37    pub fn supports_white(self) -> bool {
38        matches!(self, LightKind::Rgbw)
39    }
40
41    pub fn supports_ct(self) -> bool {
42        matches!(self, LightKind::Cct)
43    }
44
45    /// Minimum accepted brightness: RGB/RGBW require 1, CCT/Light allow 0.
46    pub fn brightness_min(self) -> u8 {
47        match self {
48            LightKind::Rgb | LightKind::Rgbw => 1,
49            LightKind::Cct | LightKind::Light => 0,
50        }
51    }
52}
53
54/// A detected light component: its kind and instance id.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub struct LightComponent {
57    pub kind: LightKind,
58    pub id: u8,
59}
60
61impl LightComponent {
62    /// Detect light components from a `Shelly.GetStatus` response by scanning
63    /// keys of the form "<kind>:<id>" (rgb, rgbw, cct, light). Sorted by kind
64    /// then id for stable output.
65    pub fn from_status(status: &serde_json::Value) -> Vec<LightComponent> {
66        let kinds = [
67            ("rgb", LightKind::Rgb),
68            ("rgbw", LightKind::Rgbw),
69            ("cct", LightKind::Cct),
70            ("light", LightKind::Light),
71        ];
72        let mut out = Vec::new();
73        if let Some(obj) = status.as_object() {
74            for key in obj.keys() {
75                let Some((prefix, id_str)) = key.split_once(':') else {
76                    continue;
77                };
78                if let Some((_, kind)) = kinds.iter().find(|(p, _)| *p == prefix)
79                    && let Ok(id) = id_str.parse::<u8>()
80                {
81                    out.push(LightComponent { kind: *kind, id });
82                }
83            }
84        }
85        out.sort_by_key(|c| (c.kind.as_str(), c.id));
86        out
87    }
88}
89
90/// Attributes to apply in a single `*.Set` call. Fields left `None` are omitted
91/// from the RPC body and therefore unchanged on the device.
92#[derive(Debug, Clone, Default, PartialEq)]
93pub struct LightParams {
94    pub on: Option<bool>,
95    pub rgb: Option<[u8; 3]>,
96    pub white: Option<u8>,
97    pub brightness: Option<u8>,
98    pub ct: Option<u32>,
99}
100
101/// Current state of one light component, for `shelly light status`.
102///
103/// `brightness` and `ct` are kept as `f64` to round-trip whatever numeric form
104/// the device reports without truncation; they are only displayed, never used in
105/// further arithmetic.
106#[derive(Debug, Clone, Serialize, PartialEq)]
107pub struct LightStatus {
108    pub kind: String,
109    pub id: u8,
110    pub output: bool,
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub brightness: Option<f64>,
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub rgb: Option<[u8; 3]>,
115    #[serde(skip_serializing_if = "Option::is_none")]
116    pub white: Option<u8>,
117    #[serde(skip_serializing_if = "Option::is_none")]
118    pub ct: Option<f64>,
119}
120
121impl LightStatus {
122    /// Parse from a single component status object (the value of e.g. "rgb:0"
123    /// in `Shelly.GetStatus`, or the body of `<Kind>.GetStatus`).
124    pub fn from_component_json(kind: LightKind, id: u8, v: &serde_json::Value) -> LightStatus {
125        let rgb = v.get("rgb").and_then(|a| a.as_array()).and_then(|a| {
126            if a.len() == 3 {
127                Some([
128                    a[0].as_u64().unwrap_or(0) as u8,
129                    a[1].as_u64().unwrap_or(0) as u8,
130                    a[2].as_u64().unwrap_or(0) as u8,
131                ])
132            } else {
133                None
134            }
135        });
136        LightStatus {
137            kind: kind.as_str().to_string(),
138            id,
139            output: v.get("output").and_then(|o| o.as_bool()).unwrap_or(false),
140            brightness: v.get("brightness").and_then(|b| b.as_f64()),
141            rgb,
142            white: v.get("white").and_then(|w| w.as_u64()).map(|w| w as u8),
143            ct: v.get("ct").and_then(|c| c.as_f64()),
144        }
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use serde_json::json;
152
153    #[test]
154    fn capability_flags() {
155        assert!(LightKind::Rgb.supports_rgb());
156        assert!(LightKind::Rgbw.supports_rgb());
157        assert!(!LightKind::Cct.supports_rgb());
158        assert!(!LightKind::Light.supports_rgb());
159        assert!(LightKind::Rgbw.supports_white());
160        assert!(!LightKind::Rgb.supports_white());
161        assert!(LightKind::Cct.supports_ct());
162        assert_eq!(LightKind::Rgb.brightness_min(), 1);
163        assert_eq!(LightKind::Cct.brightness_min(), 0);
164        assert_eq!(LightKind::Light.brightness_min(), 0);
165    }
166
167    #[test]
168    fn detect_components_from_status() {
169        let status = json!({
170            "rgb:0": {},
171            "rgbw:0": {},
172            "cct:0": {},
173            "light:0": {},
174            "switch:0": {},
175            "sys": {},
176        });
177        let comps = LightComponent::from_status(&status);
178        assert_eq!(
179            comps,
180            vec![
181                LightComponent {
182                    kind: LightKind::Cct,
183                    id: 0
184                },
185                LightComponent {
186                    kind: LightKind::Light,
187                    id: 0
188                },
189                LightComponent {
190                    kind: LightKind::Rgb,
191                    id: 0
192                },
193                LightComponent {
194                    kind: LightKind::Rgbw,
195                    id: 0
196                },
197            ]
198        );
199    }
200
201    #[test]
202    fn detect_no_light_components() {
203        let status = json!({ "switch:0": {}, "switch:1": {}, "sys": {} });
204        assert!(LightComponent::from_status(&status).is_empty());
205    }
206
207    #[test]
208    fn parse_status_fields() {
209        let v = json!({
210            "id": 0,
211            "output": true,
212            "brightness": 80.0,
213            "rgb": [0, 255, 136]
214        });
215        let s = LightStatus::from_component_json(LightKind::Rgb, 0, &v);
216        assert!(s.output);
217        assert_eq!(s.brightness, Some(80.0));
218        assert_eq!(s.rgb, Some([0, 255, 136]));
219        assert_eq!(s.white, None);
220    }
221}