diff --git a/crates/hue/src/api/behavior.rs b/crates/hue/src/api/behavior.rs new file mode 100644 index 00000000..5e6fe618 --- /dev/null +++ b/crates/hue/src/api/behavior.rs @@ -0,0 +1,231 @@ +use std::ops::AddAssign; + +use serde::{Deserialize, Deserializer, Serialize}; +use serde_json::Value; +use uuid::{Uuid, uuid}; + +use super::{DollarRef, ResourceLink}; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct BehaviorScript { + pub configuration_schema: DollarRef, + pub description: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_number_instances: Option, + pub metadata: BehaviorScriptMetadata, + pub state_schema: DollarRef, + pub supported_features: Vec, + pub trigger_schema: DollarRef, + pub version: String, +} + +impl BehaviorScript { + pub const WAKE_UP_ID: Uuid = uuid!("ff8957e3-2eb9-4699-a0c8-ad2cb3ede704"); + + #[must_use] + pub fn wake_up() -> Self { + Self { + configuration_schema: DollarRef { + dref: Some("basic_wake_up_config.json#".to_string()), + }, + description: + "Get your body in the mood to wake up by fading on the lights in the morning." + .to_string(), + max_number_instances: None, + metadata: BehaviorScriptMetadata { + name: "Basic wake up routine".to_string(), + category: "automation".to_string(), + }, + state_schema: DollarRef { dref: None }, + supported_features: vec!["style_sunrise".to_string(), "intensity".to_string()], + trigger_schema: DollarRef { + dref: Some("trigger.json#".to_string()), + }, + version: "0.0.1".to_string(), + } + } +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct BehaviorScriptMetadata { + pub name: String, + pub category: String, +} + +fn deserialize_optional_field<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + Ok(Some(Value::deserialize(deserializer)?)) +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +pub struct BehaviorInstance { + #[serde(default)] + pub dependees: Vec, + pub enabled: bool, + pub last_error: Option, + pub metadata: BehaviorInstanceMetadata, + pub script_id: Uuid, + pub status: Option, + #[serde( + default, + deserialize_with = "deserialize_optional_field", + skip_serializing_if = "Option::is_none" + )] + pub state: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub migrated_from: Option, + pub configuration: Value, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub enum BehaviorInstanceConfiguration { + Wakeup(WakeupConfiguration), +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct WakeupConfiguration { + pub end_brightness: f64, + pub fade_in_duration: configuration::Duration, + #[serde(skip_serializing_if = "Option::is_none")] + pub turn_lights_off_after: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub style: Option, + pub when: configuration::When, + #[serde(rename = "where")] + pub where_field: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum WakeupStyle { + Sunrise, + Basic, +} + +pub mod configuration { + use std::time::Duration as StdDuration; + + use chrono::Weekday; + use serde::{Deserialize, Serialize}; + + use crate::api::ResourceLink; + + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + pub struct Duration { + pub seconds: u32, + } + + impl Duration { + pub fn to_std(&self) -> StdDuration { + StdDuration::from_secs(self.seconds.into()) + } + } + + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + pub struct When { + pub recurrence_days: Option>, + pub time_point: TimePoint, + } + + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + #[serde(tag = "type", rename_all = "snake_case")] + pub enum TimePoint { + Time { time: Time }, + } + + impl TimePoint { + pub const fn time(&self) -> &Time { + match self { + Self::Time { time } => time, + } + } + } + + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + pub struct Time { + pub hour: u32, + pub minute: u32, + } + + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + pub struct Where { + pub group: ResourceLink, + pub items: Option>, + } +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +pub struct BehaviorInstanceDependee { + #[serde(rename = "type")] + pub type_field: Option, + pub target: ResourceLink, + pub level: BehaviorInstanceDependeeLevel, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum BehaviorInstanceDependeeLevel { + Critical, + NonCritical, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +pub struct BehaviorInstanceMetadata { + pub name: String, +} + +#[derive(Debug, Default, Serialize, Deserialize, Clone)] +pub struct BehaviorInstanceUpdate { + pub configuration: Option, + pub enabled: Option, + pub metadata: Option, +} + +impl BehaviorInstanceUpdate { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + #[must_use] + pub fn with_metadata(self, metadata: BehaviorInstanceMetadata) -> Self { + Self { + metadata: Some(metadata), + ..self + } + } + + #[must_use] + pub fn with_enabled(self, enabled: bool) -> Self { + Self { + enabled: Some(enabled), + ..self + } + } + + #[must_use] + pub fn with_configuration(self, configuration: Value) -> Self { + Self { + configuration: Some(configuration), + ..self + } + } +} + +impl AddAssign for BehaviorInstance { + fn add_assign(&mut self, upd: BehaviorInstanceUpdate) { + if let Some(md) = upd.metadata { + self.metadata = md; + } + + if let Some(enabled) = upd.enabled { + self.enabled = enabled; + } + + if let Some(configuration) = upd.configuration { + self.configuration = configuration; + } + } +} diff --git a/crates/hue/src/api/light.rs b/crates/hue/src/api/light.rs index e4371512..2ccdb691 100644 --- a/crates/hue/src/api/light.rs +++ b/crates/hue/src/api/light.rs @@ -490,6 +490,7 @@ pub enum LightEffect { Cosmos, Sunbeam, Enchant, + Sunrise, } impl LightEffect { diff --git a/crates/hue/src/api/mod.rs b/crates/hue/src/api/mod.rs index a84ee7b9..7edf737a 100644 --- a/crates/hue/src/api/mod.rs +++ b/crates/hue/src/api/mod.rs @@ -1,3 +1,4 @@ +mod behavior; mod device; mod entertainment; mod entertainment_config; @@ -11,6 +12,11 @@ mod stubs; mod update; mod zigbee_device_discovery; +pub use behavior::{ + BehaviorInstance, BehaviorInstanceConfiguration, BehaviorInstanceMetadata, + BehaviorInstanceUpdate, BehaviorScript, BehaviorScriptMetadata, WakeupConfiguration, + WakeupStyle, +}; pub use device::{Device, DeviceArchetype, DeviceProductData, DeviceUpdate, Identify}; pub use entertainment::{Entertainment, EntertainmentSegment, EntertainmentSegments}; pub use entertainment_config::{ @@ -44,11 +50,11 @@ pub use scene::{ use serde::ser::SerializeMap; pub use stream::HueStreamKey; pub use stubs::{ - BehaviorInstance, BehaviorInstanceMetadata, BehaviorScript, Bridge, BridgeHome, Button, - ButtonData, ButtonMetadata, ButtonReport, DevicePower, DeviceSoftwareUpdate, DollarRef, - GeofenceClient, Geolocation, GroupedLightLevel, GroupedMotion, Homekit, LightLevel, Matter, - Metadata, MetadataUpdate, Motion, PrivateGroup, PublicImage, RelativeRotary, SmartScene, - Taurus, Temperature, TimeZone, ZigbeeConnectivity, ZigbeeConnectivityStatus, Zone, + Bridge, BridgeHome, Button, ButtonData, ButtonMetadata, ButtonReport, DevicePower, + DeviceSoftwareUpdate, DollarRef, GeofenceClient, Geolocation, GroupedLightLevel, GroupedMotion, + Homekit, LightLevel, Matter, Metadata, MetadataUpdate, Motion, PrivateGroup, PublicImage, + RelativeRotary, SmartScene, Taurus, Temperature, TimeZone, ZigbeeConnectivity, + ZigbeeConnectivityStatus, Zone, }; pub use update::Update; pub use zigbee_device_discovery::{ diff --git a/crates/hue/src/api/stubs.rs b/crates/hue/src/api/stubs.rs index 354bd65d..092d6b68 100644 --- a/crates/hue/src/api/stubs.rs +++ b/crates/hue/src/api/stubs.rs @@ -1,9 +1,8 @@ use std::collections::BTreeSet; use chrono::{DateTime, Utc}; -use serde::{Deserialize, Deserializer, Serialize}; +use serde::{Deserialize, Serialize}; use serde_json::Value; -use uuid::Uuid; use crate::api::{DeviceArchetype, LightFunction, ResourceLink, SceneMetadata}; use crate::{best_guess_timezone, date_format}; @@ -71,51 +70,6 @@ pub struct DeviceSoftwareUpdate { pub problems: Vec, } -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct BehaviorScript { - pub configuration_schema: DollarRef, - pub description: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub max_number_instances: Option, - pub metadata: Value, - pub state_schema: DollarRef, - pub supported_features: Vec, - pub trigger_schema: DollarRef, - pub version: String, -} - -fn deserialize_optional_field<'de, D>(deserializer: D) -> Result, D::Error> -where - D: Deserializer<'de>, -{ - Ok(Some(Value::deserialize(deserializer)?)) -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct BehaviorInstance { - pub configuration: Value, - #[serde(default)] - pub dependees: Vec, - pub enabled: bool, - pub last_error: Option, - pub metadata: BehaviorInstanceMetadata, - pub script_id: Uuid, - pub status: Option, - #[serde( - default, - deserialize_with = "deserialize_optional_field", - skip_serializing_if = "Option::is_none" - )] - pub state: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub migrated_from: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct BehaviorInstanceMetadata { - pub name: String, -} - #[derive(Debug, Serialize, Deserialize, Clone)] pub struct GeofenceClient { pub name: String, diff --git a/crates/hue/src/api/update.rs b/crates/hue/src/api/update.rs index ccd2f176..b0b1ee50 100644 --- a/crates/hue/src/api/update.rs +++ b/crates/hue/src/api/update.rs @@ -2,14 +2,13 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use crate::api::{ - DeviceUpdate, EntertainmentConfigurationUpdate, GroupedLightUpdate, LightUpdate, RType, - RoomUpdate, SceneUpdate, + BehaviorInstanceUpdate, DeviceUpdate, EntertainmentConfigurationUpdate, GroupedLightUpdate, + LightUpdate, RType, RoomUpdate, SceneUpdate, }; type BridgeUpdate = Value; type BridgeHomeUpdate = Value; type ZigbeeDeviceDiscoveryUpdate = Value; -type BehaviorInstanceUpdate = Value; type SmartSceneUpdate = Value; type ZoneUpdate = Value; type GeolocationUpdate = Value; diff --git a/crates/hue/src/effect_duration.rs b/crates/hue/src/effect_duration.rs new file mode 100644 index 00000000..9c013b9c --- /dev/null +++ b/crates/hue/src/effect_duration.rs @@ -0,0 +1,94 @@ +use crate::error::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_60M_BASE: u8 = 0x3F; + +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. + +impl EffectDuration { + #[allow(clippy::cast_possible_truncation)] + pub const fn from_seconds(seconds: u32) -> HueResult { + let (base, resolution) = if seconds < RESOLUTION_01S_LIMIT { + (RESOLUTION_01S_BASE, RESOLUTION_01S) + } else if seconds < RESOLUTION_05S_LIMIT { + (RESOLUTION_05S_BASE, RESOLUTION_05S) + } else if seconds < RESOLUTION_15S_LIMIT { + (RESOLUTION_15S_BASE, RESOLUTION_15S) + } else if seconds < RESOLUTION_01M_LIMIT { + (RESOLUTION_01M_BASE, RESOLUTION_01M) + } else if seconds < RESOLUTION_60M_LIMIT { + (RESOLUTION_60M_BASE, RESOLUTION_60M) + } else { + return Err(crate::error::HueError::EffectDurationOutOfRange(seconds)); + }; + Ok(Self(base - ((seconds / resolution) as u8))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + pub fn seconds_to_effect_duration() { + // sniffed from the real Hue hub + let values = vec![ + (5, 145), + (10, 125), + (15, 106), + (20, 101), + (25, 96), + (30, 91), + (35, 86), + (40, 81), + (45, 76), + (50, 71), + (55, 66), + (60, 62), + ]; + for (input, output) in values { + assert_eq!( + EffectDuration::from_seconds(input * 60).unwrap(), + EffectDuration(output) + ); + } + } + + #[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; + } + } + } + + #[test] + pub fn out_of_range() { + let seconds = 10 * 60 * 60; // 10h + assert!(EffectDuration::from_seconds(seconds).is_err()); + } +} diff --git a/crates/hue/src/error.rs b/crates/hue/src/error.rs index 29ce322a..5ef491f1 100644 --- a/crates/hue/src/error.rs +++ b/crates/hue/src/error.rs @@ -55,6 +55,9 @@ pub enum HueError { #[error("Cannot merge json difference between non-map object")] Unmergable, + + #[error("Effect duration out of range: {0}")] + EffectDurationOutOfRange(u32), } /// Error types for Hue Bridge v1 API diff --git a/crates/hue/src/lib.rs b/crates/hue/src/lib.rs index 87009a5a..c089776f 100644 --- a/crates/hue/src/lib.rs +++ b/crates/hue/src/lib.rs @@ -8,6 +8,7 @@ pub mod colortemp; pub mod date_format; pub mod devicedb; pub mod diff; +pub mod effect_duration; pub mod error; pub mod flags; pub mod gamma; diff --git a/crates/hue/src/zigbee/composite.rs b/crates/hue/src/zigbee/composite.rs index 3508c536..342c2d6b 100644 --- a/crates/hue/src/zigbee/composite.rs +++ b/crates/hue/src/zigbee/composite.rs @@ -41,6 +41,7 @@ impl From for EffectType { LightEffect::Cosmos => Self::Cosmos, LightEffect::Sunbeam => Self::Sunbeam, LightEffect::Enchant => Self::Enchant, + LightEffect::Sunrise => Self::Sunrise, } } } diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 77dc4822..e3470779 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -126,6 +126,7 @@ impl IntoResponse for ApiError { | HueError::PackedStructError(_) | HueError::UuidError(_) | HueError::HueEntertainmentBadHeader + | HueError::EffectDurationOutOfRange(_) | HueError::HueZigbeeUnknownFlags(_) => StatusCode::BAD_REQUEST, HueError::NotFound(_) | HueError::V1NotFound(_) | HueError::WrongType(_, _) => {