use crate::light::state::{ColorMode, SceneState};
pub const CLUSTER_ON_OFF: u16 = 0x0006;
pub const CLUSTER_LEVEL_CONTROL: u16 = 0x0008;
pub const CLUSTER_COLOR_CONTROL: u16 = 0x0300;
pub const CMD_SCENES_ADD_SCENE: u8 = 0x00;
pub const CMD_SCENES_ENHANCED_ADD_SCENE: u8 = 0x40;
const ZCL_STATUS_MALFORMED_CMD: u8 = 0x80;
const ZCL_STATUS_INSUFF_SPACE: u8 = 0x89;
pub const MAX_SCENE_FIELDS: usize = 12;
const MAX_TOTAL_SCENE_FIELD_BYTES: usize = 128;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SceneParseError {
Malformed,
InsufficientSpace,
}
impl SceneParseError {
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 {}
#[derive(Debug, Clone, PartialEq)]
pub struct SceneField {
pub cluster_id: u16,
pub data: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ParsedAddScene {
pub group_id: u16,
pub scene_id: u8,
pub transition_time: u16,
pub fields: Vec<SceneField>,
}
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)?;
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();
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)
}
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)
}
}
}
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
}
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 {
scene.color_mode = Some(ColorMode::XY);
scene.color_temp_mireds = read_u16_le(data, 11);
} else {
scene.color_mode = Some(ColorMode::HueSaturation);
}
}
}
}
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, 0x07, 0x0A, 0x00, 0x03, b'f', b'o', b'o', 0x06, 0x00, 0x01, 0x01, 0x08, 0x00, 0x01, 0xFE, ];
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, 0x07, 0x0A, 0x00, 0x06, 0x00, 0x01, 0x01, 0x08, 0x00, 0x01, 0xFE, ];
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() {
let payload = [
0x34, 0x12, 0x07, 0x05, 0x00, 0x06, 0x00, 0x01, 0x01, ];
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, 0x07, 0xFF, 0xFF, ];
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() {
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() {
let mut data = vec![0x0F, 0x50, 0x39, 0x54, 0x00, 0x80, 0xC8];
data.extend_from_slice(&[0, 0, 0, 0]); 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() {
let mut payload = vec![
0x00, 0x00, 0x01, 0x00, 0x00, ];
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, 0x05, 0x00, 0x00, 0x06, 0x00, 0x00, ];
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());
}
}