huesmith-core 0.1.0

Platform-agnostic core for Hue-compatible Zigbee lights: color math, ZCL light state machine, and scene parsing
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
//! Platform-agnostic parsing and encoding of Zigbee scene data.
//!
//! The Hue Bridge stores and recalls scenes via the ZCL Scenes cluster. This
//! module turns raw Add Scene command payloads and scene extension fields into a
//! [`SceneState`] (and back), with defensive bounds checks. It contains no FFI,
//! so it is fully unit-tested on the host; `huesmith` wraps it with the ZBOSS
//! linked-list and command plumbing.

use crate::light::state::{ColorMode, SceneState};

// ── ZCL cluster IDs carried inside scene extension fields (ZCL r7) ───────────
pub const CLUSTER_ON_OFF: u16 = 0x0006;
pub const CLUSTER_LEVEL_CONTROL: u16 = 0x0008;
pub const CLUSTER_COLOR_CONTROL: u16 = 0x0300;

// ── ZCL Scenes cluster command IDs ────────────────────────────────────────────
/// Add Scene — transition time field is in whole seconds (ZCL §3.7.2.4.2).
pub const CMD_SCENES_ADD_SCENE: u8 = 0x00;
/// Enhanced Add Scene — transition time field is in tenths of a second.
pub const CMD_SCENES_ENHANCED_ADD_SCENE: u8 = 0x40;

// ZCL status codes carried by [`SceneParseError`].
const ZCL_STATUS_MALFORMED_CMD: u8 = 0x80;
const ZCL_STATUS_INSUFF_SPACE: u8 = 0x89;

/// Maximum number of extension fields accepted from a single scene payload.
pub const MAX_SCENE_FIELDS: usize = 12;
/// Defensive cap on the total extension-field bytes accepted from one payload.
const MAX_TOTAL_SCENE_FIELD_BYTES: usize = 128;

/// Why an Add Scene payload was rejected.
///
/// Convert to the ZCL status code for the Add Scene Response with
/// [`SceneParseError::zcl_status`] or `u8::from`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SceneParseError {
    /// Truncated or internally inconsistent payload (ZCL `MALFORMED_COMMAND`).
    Malformed,
    /// Field count or total byte size exceeds the defensive caps
    /// (ZCL `INSUFFICIENT_SPACE`).
    InsufficientSpace,
}

impl SceneParseError {
    /// The ZCL status code to return in the Add Scene Response.
    pub const fn zcl_status(self) -> u8 {
        match self {
            Self::Malformed => ZCL_STATUS_MALFORMED_CMD,
            Self::InsufficientSpace => ZCL_STATUS_INSUFF_SPACE,
        }
    }
}

impl From<SceneParseError> for u8 {
    fn from(err: SceneParseError) -> Self {
        err.zcl_status()
    }
}

impl core::fmt::Display for SceneParseError {
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        match self {
            Self::Malformed => write!(f, "malformed Add Scene payload (ZCL 0x80)"),
            Self::InsufficientSpace => write!(f, "scene fields exceed size limits (ZCL 0x89)"),
        }
    }
}

impl std::error::Error for SceneParseError {}

/// A single ZCL scene extension field: a cluster ID plus its opaque value bytes.
#[derive(Debug, Clone, PartialEq)]
pub struct SceneField {
    pub cluster_id: u16,
    pub data: Vec<u8>,
}

/// A parsed Add Scene / Enhanced Add Scene command payload.
///
/// `transition_time` is always normalized to tenths of a second, regardless of
/// which command variant carried it.
#[derive(Debug, Clone, PartialEq)]
pub struct ParsedAddScene {
    pub group_id: u16,
    pub scene_id: u8,
    /// Recall transition time in tenths of a second (normalized).
    pub transition_time: u16,
    pub fields: Vec<SceneField>,
}

/// Parse an Add Scene (0x00) or Enhanced Add Scene (0x40) command payload.
///
/// `command_id` selects the transition-time unit defined by ZCL: Add Scene
/// carries whole seconds, Enhanced Add Scene carries tenths of a second. The
/// returned [`ParsedAddScene::transition_time`] is always normalized to tenths,
/// so plain Add Scene values are scaled ×10 (saturating).
///
/// Handles both the named (scene-name field present) and unnamed Hue variants.
///
/// # Errors
///
/// Returns [`SceneParseError`] on truncated/inconsistent payloads or when the
/// extension fields exceed the defensive size caps.
pub fn parse_add_scene_payload(
    payload: &[u8],
    command_id: u8,
) -> Result<ParsedAddScene, SceneParseError> {
    if payload.len() < 5 {
        return Err(SceneParseError::Malformed);
    }

    let group_id = read_u16_le(payload, 0).ok_or(SceneParseError::Malformed)?;
    let scene_id = payload[2];
    let raw_transition_time = read_u16_le(payload, 3).ok_or(SceneParseError::Malformed)?;
    // ZCL: Add Scene (0x00) is in seconds, Enhanced Add Scene (0x40) in tenths.
    let transition_time = if command_id == CMD_SCENES_ADD_SCENE {
        raw_transition_time.saturating_mul(10)
    } else {
        raw_transition_time
    };

    let standard_fields = parse_named_add_scene_fields(payload);
    let fields = match standard_fields {
        Ok(fields) => fields,
        Err(named_status) => parse_extension_fields(payload, 5).map_err(|_| named_status)?,
    };

    Ok(ParsedAddScene {
        group_id,
        scene_id,
        transition_time,
        fields,
    })
}

fn parse_named_add_scene_fields(payload: &[u8]) -> Result<Vec<SceneField>, SceneParseError> {
    let scene_name_len = *payload.get(5).ok_or(SceneParseError::Malformed)? as usize;
    let field_start = 6usize
        .checked_add(scene_name_len)
        .ok_or(SceneParseError::Malformed)?;

    if field_start > payload.len() {
        return Err(SceneParseError::Malformed);
    }

    parse_extension_fields(payload, field_start)
}

fn parse_extension_fields(
    payload: &[u8],
    mut pos: usize,
) -> Result<Vec<SceneField>, SceneParseError> {
    if pos > payload.len() {
        return Err(SceneParseError::Malformed);
    }

    let mut fields = Vec::new();
    // Defensive running total of field bytes across the whole payload.
    let mut total_bytes = 0usize;
    while pos < payload.len() {
        if payload.len() - pos < 3 {
            return Err(SceneParseError::Malformed);
        }

        let cluster_id = read_u16_le(payload, pos).ok_or(SceneParseError::Malformed)?;
        let len = payload[pos + 2] as usize;
        pos += 3;

        if payload.len() - pos < len {
            return Err(SceneParseError::Malformed);
        }

        if fields.len() >= MAX_SCENE_FIELDS {
            return Err(SceneParseError::InsufficientSpace);
        }

        if total_bytes + len > MAX_TOTAL_SCENE_FIELD_BYTES {
            return Err(SceneParseError::InsufficientSpace);
        }
        total_bytes += len;

        fields.push(SceneField {
            cluster_id,
            data: payload[pos..pos + len].to_vec(),
        });
        pos += len;
    }

    Ok(fields)
}

/// Build the scene extension fields that capture the current light state.
///
/// Color fields are emitted only when `has_color_control` is set, so a dimmable
/// endpoint never stores color data.
pub fn fields_from_scene_state(scene: &SceneState, has_color_control: bool) -> Vec<SceneField> {
    let mut fields = Vec::new();

    if let Some(on) = scene.on {
        fields.push(SceneField {
            cluster_id: CLUSTER_ON_OFF,
            data: vec![if on { 1 } else { 0 }],
        });
    }

    if let Some(level) = scene.brightness {
        fields.push(SceneField {
            cluster_id: CLUSTER_LEVEL_CONTROL,
            data: vec![level],
        });
    }

    if has_color_control {
        if let Some(color_field) = color_field_from_scene_state(scene) {
            fields.push(SceneField {
                cluster_id: CLUSTER_COLOR_CONTROL,
                data: color_field,
            });
        }
    }

    fields
}

fn color_field_from_scene_state(scene: &SceneState) -> Option<Vec<u8>> {
    match scene.color_mode {
        Some(ColorMode::ColorTemperature) => {
            let temp = scene.color_temp_mireds?;
            Some(temp.to_le_bytes().to_vec())
        }
        Some(ColorMode::HueSaturation) => {
            let mut data = Vec::with_capacity(7);
            data.extend_from_slice(&scene.color_x.unwrap_or(20495).to_le_bytes());
            data.extend_from_slice(&scene.color_y.unwrap_or(21561).to_le_bytes());
            data.extend_from_slice(&scene.enhanced_hue.unwrap_or(0).to_le_bytes());
            data.push(scene.saturation.unwrap_or(0));
            Some(data)
        }
        Some(ColorMode::XY) | None => {
            let x = scene.color_x?;
            let y = scene.color_y?;
            let mut data = Vec::with_capacity(4);
            data.extend_from_slice(&x.to_le_bytes());
            data.extend_from_slice(&y.to_le_bytes());
            Some(data)
        }
    }
}

/// Reconstruct a [`SceneState`] from recalled scene extension fields.
pub fn scene_state_from_fields(fields: &[SceneField]) -> SceneState {
    let mut scene = SceneState::default();

    for field in fields {
        match field.cluster_id {
            CLUSTER_ON_OFF => {
                if let Some(on) = field.data.first() {
                    scene.on = Some(*on != 0);
                }
            }
            CLUSTER_LEVEL_CONTROL => {
                if let Some(level) = field.data.first() {
                    scene.brightness = Some(*level);
                }
            }
            CLUSTER_COLOR_CONTROL => {
                merge_color_field(&field.data, &mut scene);
            }
            _ => {
                log::debug!(
                    "Ignoring unsupported scene extension field: cluster=0x{:04X} len={}",
                    field.cluster_id,
                    field.data.len(),
                );
            }
        }
    }

    scene
}

/// Parse a Color Control scene extension field into a [`SceneState`].
///
/// # Byte layout (empirically derived from Hue Bridge payloads)
///
/// The Hue Bridge sends partial extension fields matching the current color mode
/// rather than the full 13-byte ZCL spec blob. Our write path (`color_field_from_scene_state`)
/// uses the same compact formats:
///
/// | `data.len()` | Format                              | Mode            |
/// |---|---|---|
/// | 2            | `[temp_lo, temp_hi]`                | ColorTemperature|
/// | 4..=6        | `[x_lo, x_hi, y_lo, y_hi]`         | XY              |
/// | 7..=12       | `[x(2), y(2), enhanced_hue(2), sat(1)]` (+ padding) | HueSaturation |
/// | ≥ 13         | above + `[color_temp(2)]` at offset 11 | XY |
///
/// This differs from ZCL r7 §5.2.2.3.1's 13-byte canonical layout
/// `[hue(1), sat(1), X(2), Y(2), enhanced_hue(2), loop_active(1), loop_dir(1), loop_time(2), temp(2)]`.
/// If a standards-compliant controller sends the 13-byte canonical form, the X/Y values will
/// be misread. In practice, the Hue Bridge uses the compact form above.
fn merge_color_field(data: &[u8], scene: &mut SceneState) {
    match data.len() {
        0 | 1 => {}
        2 => {
            if let Some(temp) = read_u16_le(data, 0) {
                scene.color_temp_mireds = Some(temp);
                scene.color_mode = Some(ColorMode::ColorTemperature);
            }
        }
        3 => {}
        4..=6 => {
            scene.color_x = read_u16_le(data, 0);
            scene.color_y = read_u16_le(data, 2);
            scene.color_mode = Some(ColorMode::XY);
        }
        _ => {
            scene.color_x = read_u16_le(data, 0);
            scene.color_y = read_u16_le(data, 2);
            scene.enhanced_hue = read_u16_le(data, 4);
            scene.saturation = data.get(6).copied();

            if data.len() >= 13 {
                // Full layout with a color-temp tail: treat as an XY snapshot
                // (the temp is carried alongside for CCT-capable recalls).
                scene.color_mode = Some(ColorMode::XY);
                scene.color_temp_mireds = read_u16_le(data, 11);
            } else {
                // 7..=12 bytes: bytes 0-6 follow the HueSaturation layout and we
                // just populated enhanced_hue/saturation from them, so the mode
                // must match (a padded 8-12 byte variant is still HueSaturation).
                scene.color_mode = Some(ColorMode::HueSaturation);
            }
        }
    }
}

/// Read a little-endian `u16` at `offset`, or `None` if out of bounds.
pub fn read_u16_le(data: &[u8], offset: usize) -> Option<u16> {
    let lo = *data.get(offset)?;
    let hi = *data.get(offset + 1)?;
    Some(u16::from_le_bytes([lo, hi]))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_named_add_scene_safely() {
        let payload = [
            0x34, 0x12, // group
            0x07, // scene
            0x0A, 0x00, // transition
            0x03, b'f', b'o', b'o', // name
            0x06, 0x00, 0x01, 0x01, // on/off field
            0x08, 0x00, 0x01, 0xFE, // level field
        ];

        let parsed = parse_add_scene_payload(&payload, CMD_SCENES_ENHANCED_ADD_SCENE).unwrap();
        assert_eq!(parsed.group_id, 0x1234);
        assert_eq!(parsed.scene_id, 7);
        assert_eq!(parsed.transition_time, 10);
        assert_eq!(parsed.fields.len(), 2);
    }

    #[test]
    fn rejects_overlong_scene_name() {
        let payload = [0x34, 0x12, 0x07, 0x0A, 0x00, 0x20, b'f'];
        assert_eq!(
            parse_add_scene_payload(&payload, CMD_SCENES_ENHANCED_ADD_SCENE),
            Err(SceneParseError::Malformed)
        );
    }

    #[test]
    fn accepts_add_scene_without_scene_name_field() {
        let payload = [
            0x34, 0x12, // group
            0x07, // scene
            0x0A, 0x00, // transition
            0x06, 0x00, 0x01, 0x01, // on/off field begins immediately
            0x08, 0x00, 0x01, 0xFE, // level field
        ];

        let parsed = parse_add_scene_payload(&payload, CMD_SCENES_ENHANCED_ADD_SCENE).unwrap();
        assert_eq!(parsed.group_id, 0x1234);
        assert_eq!(parsed.scene_id, 7);
        assert_eq!(parsed.transition_time, 10);
        assert_eq!(parsed.fields.len(), 2);
        assert_eq!(parsed.fields[0].cluster_id, CLUSTER_ON_OFF);
    }

    #[test]
    fn add_scene_seconds_are_normalized_to_tenths() {
        // ZCL: plain Add Scene carries whole seconds; we store tenths.
        let payload = [
            0x34, 0x12, // group
            0x07, // scene
            0x05, 0x00, // transition = 5 s
            0x06, 0x00, 0x01, 0x01, // on/off field
        ];

        let parsed = parse_add_scene_payload(&payload, CMD_SCENES_ADD_SCENE).unwrap();
        assert_eq!(parsed.transition_time, 50);

        let enhanced = parse_add_scene_payload(&payload, CMD_SCENES_ENHANCED_ADD_SCENE).unwrap();
        assert_eq!(enhanced.transition_time, 5);
    }

    #[test]
    fn add_scene_seconds_scaling_saturates() {
        let payload = [
            0x34, 0x12, // group
            0x07, // scene
            0xFF, 0xFF, // transition = 65535 s — would overflow ×10
        ];

        let parsed = parse_add_scene_payload(&payload, CMD_SCENES_ADD_SCENE).unwrap();
        assert_eq!(parsed.transition_time, u16::MAX);
    }

    #[test]
    fn parses_scene_fields_into_light_state() {
        let fields = vec![
            SceneField {
                cluster_id: CLUSTER_ON_OFF,
                data: vec![1],
            },
            SceneField {
                cluster_id: CLUSTER_LEVEL_CONTROL,
                data: vec![200],
            },
            SceneField {
                cluster_id: CLUSTER_COLOR_CONTROL,
                data: vec![0x0f, 0x50, 0x39, 0x54],
            },
        ];

        let scene = scene_state_from_fields(&fields);
        assert_eq!(scene.on, Some(true));
        assert_eq!(scene.brightness, Some(200));
        assert_eq!(scene.color_x, Some(0x500f));
        assert_eq!(scene.color_y, Some(0x5439));
        assert_eq!(scene.color_mode, Some(ColorMode::XY));
    }

    #[test]
    fn padded_hue_sat_color_field_keeps_hue_saturation_mode() {
        // 8-byte color field: HueSaturation layout (7 bytes) + 1 padding byte.
        // The mode must match the populated enhanced_hue/saturation fields.
        let field = SceneField {
            cluster_id: CLUSTER_COLOR_CONTROL,
            data: vec![0x0F, 0x50, 0x39, 0x54, 0x00, 0x80, 0xC8, 0x00],
        };

        let scene = scene_state_from_fields(&[field]);
        assert_eq!(scene.enhanced_hue, Some(0x8000));
        assert_eq!(scene.saturation, Some(0xC8));
        assert_eq!(scene.color_mode, Some(ColorMode::HueSaturation));
    }

    #[test]
    fn full_color_field_with_temp_tail_is_xy_mode() {
        // 13-byte field: HueSaturation prefix + color_temp at offset 11.
        let mut data = vec![0x0F, 0x50, 0x39, 0x54, 0x00, 0x80, 0xC8];
        data.extend_from_slice(&[0, 0, 0, 0]); // padding to offset 11
        data.extend_from_slice(&370u16.to_le_bytes());

        let scene = scene_state_from_fields(&[SceneField {
            cluster_id: CLUSTER_COLOR_CONTROL,
            data,
        }]);
        assert_eq!(scene.color_mode, Some(ColorMode::XY));
        assert_eq!(scene.color_temp_mireds, Some(370));
    }

    #[test]
    fn scene_snapshot_fields_skip_color_for_dimmable_endpoint() {
        let scene = SceneState {
            on: Some(true),
            brightness: Some(128),
            color_mode: Some(ColorMode::ColorTemperature),
            color_temp_mireds: Some(370),
            ..SceneState::default()
        };

        let fields = fields_from_scene_state(&scene, false);

        assert_eq!(fields.len(), 2);
        assert!(fields
            .iter()
            .all(|field| field.cluster_id != CLUSTER_COLOR_CONTROL));
    }

    #[test]
    fn scene_snapshot_fields_keep_color_for_cct_endpoint() {
        let scene = SceneState {
            on: Some(true),
            brightness: Some(128),
            color_mode: Some(ColorMode::ColorTemperature),
            color_temp_mireds: Some(370),
            ..SceneState::default()
        };

        let fields = fields_from_scene_state(&scene, true);

        assert!(fields
            .iter()
            .any(|field| field.cluster_id == CLUSTER_COLOR_CONTROL));
    }

    #[test]
    fn rejects_scene_fields_exceeding_total_byte_limit() {
        // Craft a payload that exceeds MAX_TOTAL_SCENE_FIELD_BYTES (currently 128)
        // by having one very large extension field.
        let mut payload = vec![
            0x00, 0x00, // group
            0x01, // scene
            0x00, 0x00, // transition
        ];
        // One huge field (cluster 0x0006, length 200 > limit when combined with others)
        payload.extend_from_slice(&[0x06, 0x00, 200u8]);
        payload.extend(vec![0u8; 200]);

        assert_eq!(
            parse_add_scene_payload(&payload, CMD_SCENES_ENHANCED_ADD_SCENE),
            Err(SceneParseError::InsufficientSpace)
        );
    }

    #[test]
    fn accepts_zero_length_extension_field() {
        let payload = [
            0x12, 0x34, // group
            0x05, // scene
            0x00, 0x00, // transition
            0x06, 0x00, 0x00, // on/off cluster, length 0 (edge case)
        ];

        // A zero-length field is structurally valid: it parses to an empty
        // value that scene_state_from_fields then ignores.
        let parsed = parse_add_scene_payload(&payload, CMD_SCENES_ENHANCED_ADD_SCENE).unwrap();
        assert_eq!(parsed.fields.len(), 1);
        assert!(parsed.fields[0].data.is_empty());
    }
}