From e638207f1e2b44dcb6c5956f7a685dbb4146be1f Mon Sep 17 00:00:00 2001 From: Christian Duvholt Date: Wed, 28 May 2025 22:26:08 +0200 Subject: [PATCH 01/10] Improve accuracy of effection duration calculation --- crates/hue/src/effect_duration.rs | 129 ++++++++++++++++++++++++++---- 1 file changed, 112 insertions(+), 17 deletions(-) diff --git a/crates/hue/src/effect_duration.rs b/crates/hue/src/effect_duration.rs index 9c013b9c..4d1f9a34 100644 --- a/crates/hue/src/effect_duration.rs +++ b/crates/hue/src/effect_duration.rs @@ -7,38 +7,46 @@ const RESOLUTION_01S_BASE: u8 = 0xFC; const RESOLUTION_05S_BASE: u8 = 0xCC; const RESOLUTION_15S_BASE: u8 = 0xA5; const RESOLUTION_01M_BASE: u8 = 0x79; -const RESOLUTION_60M_BASE: u8 = 0x3F; +const RESOLUTION_05M_BASE: u8 = 0x4A; const RESOLUTION_01S: u32 = 1; // 1s. const RESOLUTION_05S: u32 = 5; // 5s. const RESOLUTION_15S: u32 = 15; // 15s. const RESOLUTION_01M: u32 = 60; // 1min. -// This value is just a guess. More real world testing is required -const RESOLUTION_60M: u32 = 60 * 60; // 60min. - -const RESOLUTION_01S_LIMIT: u32 = 60; // 01min. -const RESOLUTION_05S_LIMIT: u32 = 5 * 60; // 05min. -const RESOLUTION_15S_LIMIT: u32 = 15 * 60; // 15min. -const RESOLUTION_01M_LIMIT: u32 = 60 * 60; // 60min. -const RESOLUTION_60M_LIMIT: u32 = 6 * 60 * 60; // 06hrs. +const RESOLUTION_05M: u32 = 5 * 60; // 5min. impl EffectDuration { #[allow(clippy::cast_possible_truncation)] - pub const fn from_seconds(seconds: u32) -> HueResult { - let (base, resolution) = if seconds < RESOLUTION_01S_LIMIT { + #[allow(clippy::cast_sign_loss)] + pub fn from_seconds(seconds: u32) -> HueResult { + let (base, resolution) = if seconds < 60 { + // 1min (RESOLUTION_01S_BASE, RESOLUTION_01S) - } else if seconds < RESOLUTION_05S_LIMIT { + } else if seconds < 293 { + // ~5min (RESOLUTION_05S_BASE, RESOLUTION_05S) - } else if seconds < RESOLUTION_15S_LIMIT { + } else if seconds < 295 { + // 293 and 294 do not fit into any of the bases as they both output 145 + return Ok(Self(146)); + } else if seconds < 878 { + // ~15min (RESOLUTION_15S_BASE, RESOLUTION_15S) - } else if seconds < RESOLUTION_01M_LIMIT { + } else if seconds < 885 { + return Ok(Self(107)); + } else if seconds < 3510 { + // ~60min (RESOLUTION_01M_BASE, RESOLUTION_01M) - } else if seconds < RESOLUTION_60M_LIMIT { - (RESOLUTION_60M_BASE, RESOLUTION_60M) + } else if seconds < 3540 { + return Ok(Self(63)); + } else if seconds <= 6 * 60 * 60 { + // 06hrs + (RESOLUTION_05M_BASE, RESOLUTION_05M) } else { return Err(crate::error::HueError::EffectDurationOutOfRange(seconds)); }; - Ok(Self(base - ((seconds / resolution) as u8))) + Ok(Self( + base - ((f64::from(seconds) / f64::from(resolution)).round() as u8), + )) } } @@ -91,4 +99,91 @@ mod tests { let seconds = 10 * 60 * 60; // 10h assert!(EffectDuration::from_seconds(seconds).is_err()); } + + #[test] + #[allow(clippy::unreadable_literal)] + #[allow(clippy::cast_possible_truncation)] + #[allow(clippy::cast_sign_loss)] + pub fn timed_effect_zigbee_dump() { + // these values were recorded by request timed_effects to a light + // "timed_effects": { + // "effect": "sunrise", + // "duration": 3539000 + // } + let input: Vec<(u32, u64)> = vec![ + (1500, 0xb000040009fa), + (2500, 0xb000040009f9), + (58000, 0xb000040009c2), + (59000, 0xb000040009c1), + (60000, 0xb000040009c0), + (61000, 0xb000040009c0), + (63000, 0xb000040009bf), + (68000, 0xb000040009be), + (73000, 0xb000040009bd), + (277000, 0xb00004000995), + (278000, 0xb00004000994), + (282000, 0xb00004000994), + (283000, 0xb00004000993), + (287000, 0xb00004000993), + (288000, 0xb00004000992), + (294000, 0xb00004000992), + (295000, 0xb00004000991), + (308000, 0xb00004000990), + (323000, 0xb0000400098f), + (338000, 0xb0000400098e), + (353000, 0xb0000400098d), + (862000, 0xb0000400096c), + (863000, 0xb0000400096b), + (864000, 0xb0000400096b), + (872000, 0xb0000400096b), + (873000, 0xb0000400096b), + (874000, 0xb0000400096b), + (875000, 0xb0000400096b), + (876000, 0xb0000400096b), + (877000, 0xb0000400096b), + (878000, 0xb0000400096b), + (879000, 0xb0000400096b), + (880000, 0xb0000400096b), + (881000, 0xb0000400096b), + (882000, 0xb0000400096b), + (883000, 0xb0000400096b), + (884000, 0xb0000400096b), + (885000, 0xb0000400096a), + (886000, 0xb0000400096a), + (887000, 0xb0000400096a), + (888000, 0xb0000400096a), + (899000, 0xb0000400096a), + (900000, 0xb0000400096a), + (901000, 0xb0000400096a), + (930000, 0xb00004000969), + (990000, 0xb00004000968), + (1050000, 0xb00004000967), + (3390000, 0xb00004000940), + (3450000, 0xb0000400093f), + (3510000, 0xb0000400093f), + (3539000, 0xb0000400093f), + (3540000, 0xb0000400093e), + (3599000, 0xb0000400093e), + (3600000, 0xb0000400093e), + (3601000, 0xb0000400093e), + (3750000, 0xb0000400093d), + (4050000, 0xb0000400093c), + (4350000, 0xb0000400093b), + (20850000, 0xb00004000904), + (21150000, 0xb00004000903), + (21450000, 0xb00004000902), + // max + (21600000, 0xb00004000902), + ]; + + for (input_ms, zigbee_data) in input { + let nearest_second: u32 = (f64::from(input_ms) / 1000.0).round() as u32; + let ed = EffectDuration::from_seconds(nearest_second).unwrap(); + let zigbee_effect_duration = (zigbee_data & 0xff) as u8; // last byte is effect duration + assert_eq!( + ed.0, zigbee_effect_duration, + "Failed to convert {input_ms}ms ({nearest_second}s) into effect duration {zigbee_effect_duration}" + ); + } + } } From 6e3c4afea54e2c0f0569d7de9a26c26450f8f33a Mon Sep 17 00:00:00 2001 From: Christian Duvholt Date: Wed, 28 May 2025 23:26:01 +0200 Subject: [PATCH 02/10] Implement timed effects for Hue lights --- crates/hue/src/api/light.rs | 20 +++++++++++++++++++- crates/hue/src/api/mod.rs | 4 ++-- crates/hue/src/zigbee/composite.rs | 15 +++++++++++++-- src/backend/z2m/backend_event.rs | 13 +++++++++++++ 4 files changed, 47 insertions(+), 5 deletions(-) diff --git a/crates/hue/src/api/light.rs b/crates/hue/src/api/light.rs index 93268069..41ebe8d5 100644 --- a/crates/hue/src/api/light.rs +++ b/crates/hue/src/api/light.rs @@ -501,7 +501,6 @@ pub enum LightEffect { Cosmos, Sunbeam, Enchant, - Sunrise, } impl LightEffect { @@ -607,6 +606,15 @@ pub struct LightEffectStatus { pub parameters: Option, } +#[derive(Debug, Default, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum LightTimedEffect { + #[default] + NoEffect, + Sunrise, + Sunset, +} + #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub struct LightTimedEffects { pub status_values: Value, @@ -614,6 +622,14 @@ pub struct LightTimedEffects { pub effect_values: Value, } +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +pub struct LightTimedEffectsUpdate { + #[serde(skip_serializing_if = "Option::is_none")] + pub effect: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, +} + #[derive(Debug, Serialize, Deserialize, Clone, Default)] pub struct LightUpdate { #[serde(skip_serializing_if = "Option::is_none")] @@ -642,6 +658,8 @@ pub struct LightUpdate { pub dynamics: Option, #[serde(skip_serializing_if = "Option::is_none")] pub identify: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub timed_effects: Option, } impl LightUpdate { diff --git a/crates/hue/src/api/mod.rs b/crates/hue/src/api/mod.rs index 81a30b24..faa64d01 100644 --- a/crates/hue/src/api/mod.rs +++ b/crates/hue/src/api/mod.rs @@ -38,8 +38,8 @@ pub use light::{ LightEffectValues, LightEffects, LightEffectsV2, LightEffectsV2Update, LightFunction, LightGradient, LightGradientMode, LightGradientPoint, LightGradientUpdate, LightMetadata, LightMode, LightPowerup, LightPowerupColor, LightPowerupDimming, LightPowerupOn, - LightPowerupPreset, LightProductData, LightSignal, LightSignaling, LightTimedEffects, - LightUpdate, MirekSchema, On, + LightPowerupPreset, LightProductData, LightSignal, LightSignaling, LightTimedEffect, + LightTimedEffects, LightTimedEffectsUpdate, LightUpdate, MirekSchema, On, }; pub use resource::{RType, ResourceLink, ResourceRecord}; pub use room::{Room, RoomArchetype, RoomMetadata, RoomMetadataUpdate, RoomUpdate}; diff --git a/crates/hue/src/zigbee/composite.rs b/crates/hue/src/zigbee/composite.rs index 342c2d6b..6b13c85a 100644 --- a/crates/hue/src/zigbee/composite.rs +++ b/crates/hue/src/zigbee/composite.rs @@ -5,7 +5,7 @@ use byteorder::{LittleEndian as LE, ReadBytesExt, WriteBytesExt}; use packed_struct::derive::{PackedStruct, PrimitiveEnum_u8}; use packed_struct::{PackedStruct, PackedStructSlice, PrimitiveEnum}; -use crate::api::{LightEffect, LightGradientMode}; +use crate::api::{LightEffect, LightGradientMode, LightTimedEffect}; use crate::error::{HueError, HueResult}; use crate::flags::TakeFlag; use crate::xy::XY; @@ -20,6 +20,7 @@ pub enum EffectType { Sparkle = 0x0a, Opal = 0x0b, Glisten = 0x0c, + Sunset = 0x0d, Underwater = 0x0e, Cosmos = 0x0f, Sunbeam = 0x10, @@ -41,7 +42,17 @@ impl From for EffectType { LightEffect::Cosmos => Self::Cosmos, LightEffect::Sunbeam => Self::Sunbeam, LightEffect::Enchant => Self::Enchant, - LightEffect::Sunrise => Self::Sunrise, + } + } +} + +#[cfg_attr(coverage_nightly, coverage(off))] +impl From for EffectType { + fn from(value: LightTimedEffect) -> Self { + match value { + LightTimedEffect::NoEffect => Self::NoEffect, + LightTimedEffect::Sunrise => Self::Sunrise, + LightTimedEffect::Sunset => Self::Sunset, } } } diff --git a/src/backend/z2m/backend_event.rs b/src/backend/z2m/backend_event.rs index 28a82336..30181e5f 100644 --- a/src/backend/z2m/backend_event.rs +++ b/src/backend/z2m/backend_event.rs @@ -2,6 +2,7 @@ use std::collections::{BTreeMap, BTreeSet}; use std::sync::Arc; use hue::clamp::Clamp; +use hue::effect_duration::EffectDuration; use hue::zigbee::{GradientParams, GradientStyle, HueZigbeeUpdate}; use tokio::time::sleep; use uuid::Uuid; @@ -63,6 +64,18 @@ impl Z2mBackend { } } + if let Some(act) = &upd.timed_effects { + if let Some(fx) = act.effect { + hz = hz.with_effect_type(fx.into()); + } + + if let Some(duration) = &act.duration { + let EffectDuration(effect_duration) = + EffectDuration::from_seconds((f64::from(*duration) / 1000.0).round() as u32)?; + hz = hz.with_effect_speed(effect_duration); + } + } + Ok(hz) } From bd4f680afab86b67d2ede2abdc0965232addbc9f Mon Sep 17 00:00:00 2001 From: Christian Duvholt Date: Wed, 28 May 2025 23:38:08 +0200 Subject: [PATCH 03/10] Use LightTimedEffect to set values for LightTimedEffects --- crates/hue/src/api/light.rs | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/crates/hue/src/api/light.rs b/crates/hue/src/api/light.rs index 41ebe8d5..fae339f4 100644 --- a/crates/hue/src/api/light.rs +++ b/crates/hue/src/api/light.rs @@ -2,7 +2,7 @@ use std::collections::BTreeSet; use std::ops::{AddAssign, Sub}; use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; +use serde_json::Value; use crate::api::device::DeviceIdentifyUpdate; use crate::api::{DeviceArchetype, Identify, Metadata, MetadataUpdate, ResourceLink, Stub}; @@ -113,9 +113,9 @@ impl Light { gradient: None, identify: Identify {}, timed_effects: Some(LightTimedEffects { - status_values: json!(["no_effect", "sunrise", "sunset"]), - status: json!("no_effect"), - effect_values: json!(["no_effect", "sunrise", "sunset"]), + status_values: Vec::from(LightTimedEffect::ALL), + status: LightTimedEffect::NoEffect, + effect_values: Vec::from(LightTimedEffect::ALL), }), mode: LightMode::Normal, on: On { on: true }, @@ -615,11 +615,15 @@ pub enum LightTimedEffect { Sunset, } +impl LightTimedEffect { + pub const ALL: [Self; 3] = [Self::NoEffect, Self::Sunrise, Self::Sunset]; +} + #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub struct LightTimedEffects { - pub status_values: Value, - pub status: Value, - pub effect_values: Value, + pub status_values: Vec, + pub status: LightTimedEffect, + pub effect_values: Vec, } #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] From 2e4951d3108e766c02ffa551b4daaaefe85be220 Mon Sep 17 00:00:00 2001 From: Christian Duvholt Date: Wed, 28 May 2025 23:47:25 +0200 Subject: [PATCH 04/10] Helper method to create effect duration from ms --- crates/hue/src/effect_duration.rs | 9 ++++++++- src/backend/z2m/backend_event.rs | 5 ++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/crates/hue/src/effect_duration.rs b/crates/hue/src/effect_duration.rs index 4d1f9a34..e7e73b2f 100644 --- a/crates/hue/src/effect_duration.rs +++ b/crates/hue/src/effect_duration.rs @@ -16,6 +16,13 @@ const RESOLUTION_01M: u32 = 60; // 1min. const RESOLUTION_05M: u32 = 5 * 60; // 5min. impl EffectDuration { + #[allow(clippy::cast_possible_truncation)] + #[allow(clippy::cast_sign_loss)] + pub fn from_ms(milliseconds: u32) -> HueResult { + let rounded_seconds = (f64::from(milliseconds) / 1000.0).round() as u32; + Self::from_seconds(rounded_seconds) + } + #[allow(clippy::cast_possible_truncation)] #[allow(clippy::cast_sign_loss)] pub fn from_seconds(seconds: u32) -> HueResult { @@ -178,7 +185,7 @@ mod tests { for (input_ms, zigbee_data) in input { let nearest_second: u32 = (f64::from(input_ms) / 1000.0).round() as u32; - let ed = EffectDuration::from_seconds(nearest_second).unwrap(); + let ed = EffectDuration::from_ms(input_ms).unwrap(); let zigbee_effect_duration = (zigbee_data & 0xff) as u8; // last byte is effect duration assert_eq!( ed.0, zigbee_effect_duration, diff --git a/src/backend/z2m/backend_event.rs b/src/backend/z2m/backend_event.rs index 30181e5f..69067769 100644 --- a/src/backend/z2m/backend_event.rs +++ b/src/backend/z2m/backend_event.rs @@ -69,9 +69,8 @@ impl Z2mBackend { hz = hz.with_effect_type(fx.into()); } - if let Some(duration) = &act.duration { - let EffectDuration(effect_duration) = - EffectDuration::from_seconds((f64::from(*duration) / 1000.0).round() as u32)?; + if let Some(duration) = act.duration { + let EffectDuration(effect_duration) = EffectDuration::from_ms(duration)?; hz = hz.with_effect_speed(effect_duration); } } From 51dc949e3fe7101ca95879428ef74d9133cff6e3 Mon Sep 17 00:00:00 2001 From: Christian Duvholt Date: Mon, 2 Jun 2025 19:25:25 +0200 Subject: [PATCH 05/10] Document sunset effect --- doc/hue-zigbee-format.md | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/hue-zigbee-format.md b/doc/hue-zigbee-format.md index 26e48e4f..31824aa1 100644 --- a/doc/hue-zigbee-format.md +++ b/doc/hue-zigbee-format.md @@ -211,6 +211,7 @@ Size: 1 byte (specifically, [`zigbee::EffectType`]) | `Sparkle` | 0x0a | | `Opal` | 0x0b | | `Glisten` | 0x0c | +| `Sunset` | 0x0d | | `Underwater` | 0x0e | | `Cosmos` | 0x0f | | `Sunbeam` | 0x10 | From 1fb922ff25e7065939bea90dd423dd5c1fbb8fd6 Mon Sep 17 00:00:00 2001 From: Christian Duvholt Date: Mon, 2 Jun 2025 20:04:01 +0200 Subject: [PATCH 06/10] Test all possible effect duration input values --- crates/hue/src/effect_duration.rs | 430 ++++++++++++++++++++---------- 1 file changed, 294 insertions(+), 136 deletions(-) diff --git a/crates/hue/src/effect_duration.rs b/crates/hue/src/effect_duration.rs index e7e73b2f..68901a61 100644 --- a/crates/hue/src/effect_duration.rs +++ b/crates/hue/src/effect_duration.rs @@ -1,23 +1,20 @@ -use crate::error::HueResult; +use crate::error::{HueError, HueResult}; #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub struct EffectDuration(pub u8); -const RESOLUTION_01S_BASE: u8 = 0xFC; -const RESOLUTION_05S_BASE: u8 = 0xCC; -const RESOLUTION_15S_BASE: u8 = 0xA5; -const RESOLUTION_01M_BASE: u8 = 0x79; -const RESOLUTION_05M_BASE: u8 = 0x4A; - -const RESOLUTION_01S: u32 = 1; // 1s. -const RESOLUTION_05S: u32 = 5; // 5s. -const RESOLUTION_15S: u32 = 15; // 15s. -const RESOLUTION_01M: u32 = 60; // 1min. -const RESOLUTION_05M: u32 = 5 * 60; // 5min. - impl EffectDuration { - #[allow(clippy::cast_possible_truncation)] - #[allow(clippy::cast_sign_loss)] + const RESOLUTION_01S_BASE: u8 = 0xFC; + const RESOLUTION_05S_BASE: u8 = 0xCC; + const RESOLUTION_15S_BASE: u8 = 0xA5; + const RESOLUTION_01M_BASE: u8 = 0x79; + const RESOLUTION_05M_BASE: u8 = 0x4A; + + const RESOLUTION_01S: u32 = 1; // 1s. + const RESOLUTION_05S: u32 = 5; // 5s. + const RESOLUTION_15S: u32 = 15; // 15s. + const RESOLUTION_01M: u32 = 60; // 1min. + const RESOLUTION_05M: u32 = 5 * 60; // 5min. pub fn from_ms(milliseconds: u32) -> HueResult { let rounded_seconds = (f64::from(milliseconds) / 1000.0).round() as u32; Self::from_seconds(rounded_seconds) @@ -26,30 +23,24 @@ impl EffectDuration { #[allow(clippy::cast_possible_truncation)] #[allow(clippy::cast_sign_loss)] pub fn from_seconds(seconds: u32) -> HueResult { - let (base, resolution) = if seconds < 60 { - // 1min - (RESOLUTION_01S_BASE, RESOLUTION_01S) - } else if seconds < 293 { - // ~5min - (RESOLUTION_05S_BASE, RESOLUTION_05S) - } else if seconds < 295 { - // 293 and 294 do not fit into any of the bases as they both output 145 - return Ok(Self(146)); - } else if seconds < 878 { - // ~15min - (RESOLUTION_15S_BASE, RESOLUTION_15S) - } else if seconds < 885 { - return Ok(Self(107)); - } else if seconds < 3510 { - // ~60min - (RESOLUTION_01M_BASE, RESOLUTION_01M) - } else if seconds < 3540 { - return Ok(Self(63)); - } else if seconds <= 6 * 60 * 60 { - // 06hrs - (RESOLUTION_05M_BASE, RESOLUTION_05M) - } else { - return Err(crate::error::HueError::EffectDurationOutOfRange(seconds)); + let (base, resolution) = match seconds { + 0..60 => (Self::RESOLUTION_01S_BASE, Self::RESOLUTION_01S), + 60..293 => (Self::RESOLUTION_05S_BASE, Self::RESOLUTION_05S), + 293..295 => { + return Ok(Self(146)); + } + 295..878 => (Self::RESOLUTION_15S_BASE, Self::RESOLUTION_15S), + 878..885 => { + return Ok(Self(107)); + } + 885..3510 => (Self::RESOLUTION_01M_BASE, Self::RESOLUTION_01M), + 3510..3540 => { + return Ok(Self(63)); + } + 3540..=21600 => (Self::RESOLUTION_05M_BASE, Self::RESOLUTION_05M), + _ => { + return Err(HueError::EffectDurationOutOfRange(seconds)); + } }; Ok(Self( base - ((f64::from(seconds) / f64::from(resolution)).round() as u8), @@ -86,17 +77,271 @@ mod tests { } } + #[allow(clippy::unreadable_literal)] + const DURATION_BREAKPOINTS: &[(u32, u8)] = &[ + (1499, 251), + (2499, 250), + (3499, 249), + (4499, 248), + (5499, 247), + (6499, 246), + (7499, 245), + (8499, 244), + (9499, 243), + (10499, 242), + (11499, 241), + (12499, 240), + (13499, 239), + (14499, 238), + (15499, 237), + (16499, 236), + (17499, 235), + (18499, 234), + (19499, 233), + (20499, 232), + (21499, 231), + (22499, 230), + (23499, 229), + (24499, 228), + (25499, 227), + (26499, 226), + (27499, 225), + (28499, 224), + (29499, 223), + (30499, 222), + (31499, 221), + (32499, 220), + (33499, 219), + (34499, 218), + (35499, 217), + (36499, 216), + (37499, 215), + (38499, 214), + (39499, 213), + (40499, 212), + (41499, 211), + (42499, 210), + (43499, 209), + (44499, 208), + (45499, 207), + (46499, 206), + (47499, 205), + (48499, 204), + (49499, 203), + (50499, 202), + (51499, 201), + (52499, 200), + (53499, 199), + (54499, 198), + (55499, 197), + (56499, 196), + (57499, 195), + (58499, 194), + (59499, 193), + (62499, 192), + (67499, 191), + (72499, 190), + (77499, 189), + (82499, 188), + (87499, 187), + (92499, 186), + (97499, 185), + (102499, 184), + (107499, 183), + (112499, 182), + (117499, 181), + (122499, 180), + (127499, 179), + (132499, 178), + (137499, 177), + (142499, 176), + (147499, 175), + (152499, 174), + (157499, 173), + (162499, 172), + (167499, 171), + (172499, 170), + (177499, 169), + (182499, 168), + (187499, 167), + (192499, 166), + (197499, 165), + (202499, 164), + (207499, 163), + (212499, 162), + (217499, 161), + (222499, 160), + (227499, 159), + (232499, 158), + (237499, 157), + (242499, 156), + (247499, 155), + (252499, 154), + (257499, 153), + (262499, 152), + (267499, 151), + (272499, 150), + (277499, 149), + (282499, 148), + (287499, 147), + (294499, 146), + (307499, 145), + (322499, 144), + (337499, 143), + (352499, 142), + (367499, 141), + (382499, 140), + (397499, 139), + (412499, 138), + (427499, 137), + (442499, 136), + (457499, 135), + (472499, 134), + (487499, 133), + (502499, 132), + (517499, 131), + (532499, 130), + (547499, 129), + (562499, 128), + (577499, 127), + (592499, 126), + (607499, 125), + (622499, 124), + (637499, 123), + (652499, 122), + (667499, 121), + (682499, 120), + (697499, 119), + (712499, 118), + (727499, 117), + (742499, 116), + (757499, 115), + (772499, 114), + (787499, 113), + (802499, 112), + (817499, 111), + (832499, 110), + (847499, 109), + (862499, 108), + (884499, 107), + (929499, 106), + (989499, 105), + (1049499, 104), + (1109499, 103), + (1169499, 102), + (1229499, 101), + (1289499, 100), + (1349499, 99), + (1409499, 98), + (1469499, 97), + (1529499, 96), + (1589499, 95), + (1649499, 94), + (1709499, 93), + (1769499, 92), + (1829499, 91), + (1889499, 90), + (1949499, 89), + (2009499, 88), + (2069499, 87), + (2129499, 86), + (2189499, 85), + (2249499, 84), + (2309499, 83), + (2369499, 82), + (2429499, 81), + (2489499, 80), + (2549499, 79), + (2609499, 78), + (2669499, 77), + (2729499, 76), + (2789499, 75), + (2849499, 74), + (2909499, 73), + (2969499, 72), + (3029499, 71), + (3089499, 70), + (3149499, 69), + (3209499, 68), + (3269499, 67), + (3329499, 66), + (3389499, 65), + (3449499, 64), + (3539499, 63), + (3749499, 62), + (4049499, 61), + (4349499, 60), + (4649499, 59), + (4949499, 58), + (5249499, 57), + (5549499, 56), + (5849499, 55), + (6149499, 54), + (6449499, 53), + (6749499, 52), + (7049499, 51), + (7349499, 50), + (7649499, 49), + (7949499, 48), + (8249499, 47), + (8549499, 46), + (8849499, 45), + (9149499, 44), + (9449499, 43), + (9749499, 42), + (10049499, 41), + (10349499, 40), + (10649499, 39), + (10949499, 38), + (11249499, 37), + (11549499, 36), + (11849499, 35), + (12149499, 34), + (12449499, 33), + (12749499, 32), + (13049499, 31), + (13349499, 30), + (13649499, 29), + (13949499, 28), + (14249499, 27), + (14549499, 26), + (14849499, 25), + (15149499, 24), + (15449499, 23), + (15749499, 22), + (16049499, 21), + (16349499, 20), + (16649499, 19), + (16949499, 18), + (17249499, 17), + (17549499, 16), + (17849499, 15), + (18149499, 14), + (18449499, 13), + (18749499, 12), + (19049499, 11), + (19349499, 10), + (19649499, 9), + (19949499, 8), + (20249499, 7), + (20549499, 6), + (20849499, 5), + (21149499, 4), + (21449499, 3), + (21600000, 2), + ]; + #[test] - pub fn check_for_gaps() { - // this test only verifies that there are no gaps when converting from seconds to effect duration - // the steps and resolution might still be wrong - let six_hours = 6 * 60 * 60; - let mut prev = 253; - for seconds in 0..six_hours { - let EffectDuration(next) = EffectDuration::from_seconds(seconds).unwrap(); - if next != prev { - assert_eq!(next, prev - 1, "Skipped at {seconds}s"); - prev = next; + pub fn complete_conformance_test() { + let mut c = 1000; + for (x, y) in DURATION_BREAKPOINTS { + while c <= *x { + assert_eq!( + EffectDuration::from_ms(c).unwrap().0, + *y, + "failed for {c}ms" + ); + c += 1; } } } @@ -106,91 +351,4 @@ mod tests { let seconds = 10 * 60 * 60; // 10h assert!(EffectDuration::from_seconds(seconds).is_err()); } - - #[test] - #[allow(clippy::unreadable_literal)] - #[allow(clippy::cast_possible_truncation)] - #[allow(clippy::cast_sign_loss)] - pub fn timed_effect_zigbee_dump() { - // these values were recorded by request timed_effects to a light - // "timed_effects": { - // "effect": "sunrise", - // "duration": 3539000 - // } - let input: Vec<(u32, u64)> = vec![ - (1500, 0xb000040009fa), - (2500, 0xb000040009f9), - (58000, 0xb000040009c2), - (59000, 0xb000040009c1), - (60000, 0xb000040009c0), - (61000, 0xb000040009c0), - (63000, 0xb000040009bf), - (68000, 0xb000040009be), - (73000, 0xb000040009bd), - (277000, 0xb00004000995), - (278000, 0xb00004000994), - (282000, 0xb00004000994), - (283000, 0xb00004000993), - (287000, 0xb00004000993), - (288000, 0xb00004000992), - (294000, 0xb00004000992), - (295000, 0xb00004000991), - (308000, 0xb00004000990), - (323000, 0xb0000400098f), - (338000, 0xb0000400098e), - (353000, 0xb0000400098d), - (862000, 0xb0000400096c), - (863000, 0xb0000400096b), - (864000, 0xb0000400096b), - (872000, 0xb0000400096b), - (873000, 0xb0000400096b), - (874000, 0xb0000400096b), - (875000, 0xb0000400096b), - (876000, 0xb0000400096b), - (877000, 0xb0000400096b), - (878000, 0xb0000400096b), - (879000, 0xb0000400096b), - (880000, 0xb0000400096b), - (881000, 0xb0000400096b), - (882000, 0xb0000400096b), - (883000, 0xb0000400096b), - (884000, 0xb0000400096b), - (885000, 0xb0000400096a), - (886000, 0xb0000400096a), - (887000, 0xb0000400096a), - (888000, 0xb0000400096a), - (899000, 0xb0000400096a), - (900000, 0xb0000400096a), - (901000, 0xb0000400096a), - (930000, 0xb00004000969), - (990000, 0xb00004000968), - (1050000, 0xb00004000967), - (3390000, 0xb00004000940), - (3450000, 0xb0000400093f), - (3510000, 0xb0000400093f), - (3539000, 0xb0000400093f), - (3540000, 0xb0000400093e), - (3599000, 0xb0000400093e), - (3600000, 0xb0000400093e), - (3601000, 0xb0000400093e), - (3750000, 0xb0000400093d), - (4050000, 0xb0000400093c), - (4350000, 0xb0000400093b), - (20850000, 0xb00004000904), - (21150000, 0xb00004000903), - (21450000, 0xb00004000902), - // max - (21600000, 0xb00004000902), - ]; - - for (input_ms, zigbee_data) in input { - let nearest_second: u32 = (f64::from(input_ms) / 1000.0).round() as u32; - let ed = EffectDuration::from_ms(input_ms).unwrap(); - let zigbee_effect_duration = (zigbee_data & 0xff) as u8; // last byte is effect duration - assert_eq!( - ed.0, zigbee_effect_duration, - "Failed to convert {input_ms}ms ({nearest_second}s) into effect duration {zigbee_effect_duration}" - ); - } - } } From 1112f745c733605a5bc34dee2cc700d466bb52a7 Mon Sep 17 00:00:00 2001 From: Christian Duvholt Date: Mon, 2 Jun 2025 20:20:53 +0200 Subject: [PATCH 07/10] Round to nearest integer division --- crates/hue/src/effect_duration.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/hue/src/effect_duration.rs b/crates/hue/src/effect_duration.rs index 68901a61..a05a3bdd 100644 --- a/crates/hue/src/effect_duration.rs +++ b/crates/hue/src/effect_duration.rs @@ -15,14 +15,14 @@ impl EffectDuration { const RESOLUTION_15S: u32 = 15; // 15s. const RESOLUTION_01M: u32 = 60; // 1min. const RESOLUTION_05M: u32 = 5 * 60; // 5min. - pub fn from_ms(milliseconds: u32) -> HueResult { - let rounded_seconds = (f64::from(milliseconds) / 1000.0).round() as u32; + pub const fn from_ms(milliseconds: u32) -> HueResult { + let rounded_seconds = (milliseconds + 500) / 1000; Self::from_seconds(rounded_seconds) } #[allow(clippy::cast_possible_truncation)] #[allow(clippy::cast_sign_loss)] - pub fn from_seconds(seconds: u32) -> HueResult { + pub const fn from_seconds(seconds: u32) -> HueResult { let (base, resolution) = match seconds { 0..60 => (Self::RESOLUTION_01S_BASE, Self::RESOLUTION_01S), 60..293 => (Self::RESOLUTION_05S_BASE, Self::RESOLUTION_05S), @@ -43,7 +43,7 @@ impl EffectDuration { } }; Ok(Self( - base - ((f64::from(seconds) / f64::from(resolution)).round() as u8), + base - ((seconds + (resolution / 2)) / resolution) as u8, )) } } From 2e782fc70b6290c42888a6d6a156fcc502c5a5f1 Mon Sep 17 00:00:00 2001 From: Christian Duvholt Date: Mon, 2 Jun 2025 20:31:26 +0200 Subject: [PATCH 08/10] Inline base consts --- crates/hue/src/effect_duration.rs | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/crates/hue/src/effect_duration.rs b/crates/hue/src/effect_duration.rs index a05a3bdd..a55c36c7 100644 --- a/crates/hue/src/effect_duration.rs +++ b/crates/hue/src/effect_duration.rs @@ -4,17 +4,12 @@ use crate::error::{HueError, HueResult}; pub struct EffectDuration(pub u8); impl EffectDuration { - const RESOLUTION_01S_BASE: u8 = 0xFC; - const RESOLUTION_05S_BASE: u8 = 0xCC; - const RESOLUTION_15S_BASE: u8 = 0xA5; - const RESOLUTION_01M_BASE: u8 = 0x79; - const RESOLUTION_05M_BASE: u8 = 0x4A; - const RESOLUTION_01S: u32 = 1; // 1s. const RESOLUTION_05S: u32 = 5; // 5s. const RESOLUTION_15S: u32 = 15; // 15s. const RESOLUTION_01M: u32 = 60; // 1min. const RESOLUTION_05M: u32 = 5 * 60; // 5min. + pub const fn from_ms(milliseconds: u32) -> HueResult { let rounded_seconds = (milliseconds + 500) / 1000; Self::from_seconds(rounded_seconds) @@ -24,20 +19,20 @@ impl EffectDuration { #[allow(clippy::cast_sign_loss)] pub const fn from_seconds(seconds: u32) -> HueResult { let (base, resolution) = match seconds { - 0..60 => (Self::RESOLUTION_01S_BASE, Self::RESOLUTION_01S), - 60..293 => (Self::RESOLUTION_05S_BASE, Self::RESOLUTION_05S), + 0..60 => (252, Self::RESOLUTION_01S), + 60..293 => (204, Self::RESOLUTION_05S), 293..295 => { return Ok(Self(146)); } - 295..878 => (Self::RESOLUTION_15S_BASE, Self::RESOLUTION_15S), + 295..878 => (165, Self::RESOLUTION_15S), 878..885 => { return Ok(Self(107)); } - 885..3510 => (Self::RESOLUTION_01M_BASE, Self::RESOLUTION_01M), + 885..3510 => (121, Self::RESOLUTION_01M), 3510..3540 => { return Ok(Self(63)); } - 3540..=21600 => (Self::RESOLUTION_05M_BASE, Self::RESOLUTION_05M), + 3540..=21600 => (74, Self::RESOLUTION_05M), _ => { return Err(HueError::EffectDurationOutOfRange(seconds)); } From e4d20e77be74e65b3923c4ec2348315ad801956a Mon Sep 17 00:00:00 2001 From: Christian Duvholt Date: Mon, 2 Jun 2025 20:31:59 +0200 Subject: [PATCH 09/10] Special case for <1000ms --- crates/hue/src/effect_duration.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/hue/src/effect_duration.rs b/crates/hue/src/effect_duration.rs index a55c36c7..59145cae 100644 --- a/crates/hue/src/effect_duration.rs +++ b/crates/hue/src/effect_duration.rs @@ -19,7 +19,8 @@ impl EffectDuration { #[allow(clippy::cast_sign_loss)] pub const fn from_seconds(seconds: u32) -> HueResult { let (base, resolution) = match seconds { - 0..60 => (252, Self::RESOLUTION_01S), + 0..1 => return Ok(Self(251)), + 1..60 => (252, Self::RESOLUTION_01S), 60..293 => (204, Self::RESOLUTION_05S), 293..295 => { return Ok(Self(146)); @@ -328,7 +329,7 @@ mod tests { #[test] pub fn complete_conformance_test() { - let mut c = 1000; + let mut c = 1; for (x, y) in DURATION_BREAKPOINTS { while c <= *x { assert_eq!( From eb3fca588a4d4cbf775ab14de4dba47932774531 Mon Sep 17 00:00:00 2001 From: Christian Duvholt Date: Mon, 2 Jun 2025 20:43:29 +0200 Subject: [PATCH 10/10] Add helper function to use EffectDuration newtype --- crates/hue/src/zigbee/composite.rs | 6 ++++++ src/backend/z2m/backend_event.rs | 3 +-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/hue/src/zigbee/composite.rs b/crates/hue/src/zigbee/composite.rs index 6b13c85a..3824678f 100644 --- a/crates/hue/src/zigbee/composite.rs +++ b/crates/hue/src/zigbee/composite.rs @@ -6,6 +6,7 @@ use packed_struct::derive::{PackedStruct, PrimitiveEnum_u8}; use packed_struct::{PackedStruct, PackedStructSlice, PrimitiveEnum}; use crate::api::{LightEffect, LightGradientMode, LightTimedEffect}; +use crate::effect_duration::EffectDuration; use crate::error::{HueError, HueResult}; use crate::flags::TakeFlag; use crate::xy::XY; @@ -241,6 +242,11 @@ impl HueZigbeeUpdate { self.effect_speed = Some(effect_speed); self } + + #[must_use] + pub const fn with_effect_duration(self, EffectDuration(effect_speed): EffectDuration) -> Self { + self.with_effect_speed(effect_speed) + } } #[allow(clippy::cast_possible_truncation)] diff --git a/src/backend/z2m/backend_event.rs b/src/backend/z2m/backend_event.rs index 69067769..e7dff886 100644 --- a/src/backend/z2m/backend_event.rs +++ b/src/backend/z2m/backend_event.rs @@ -70,8 +70,7 @@ impl Z2mBackend { } if let Some(duration) = act.duration { - let EffectDuration(effect_duration) = EffectDuration::from_ms(duration)?; - hz = hz.with_effect_speed(effect_duration); + hz = hz.with_effect_duration(EffectDuration::from_ms(duration)?); } }