From 3fcdc1776517107ee484a1da7d668a378ad144b9 Mon Sep 17 00:00:00 2001 From: Christian Duvholt Date: Sat, 1 Feb 2025 19:15:34 +0100 Subject: [PATCH 01/26] Add CRUD support for setting wake up routine --- crates/hue/src/api/behavior.rs | 102 +++++++++++++++++++++++++++ crates/hue/src/api/mod.rs | 16 +++-- crates/hue/src/api/update.rs | 6 +- src/resource.rs | 48 +++++++++++-- src/routes/clip/behavior_instance.rs | 40 +++++++++++ src/routes/clip/mod.rs | 2 + 6 files changed, 203 insertions(+), 11 deletions(-) create mode 100644 crates/hue/src/api/behavior.rs create mode 100644 src/routes/clip/behavior_instance.rs diff --git a/crates/hue/src/api/behavior.rs b/crates/hue/src/api/behavior.rs new file mode 100644 index 000000000..43b1eb0d3 --- /dev/null +++ b/crates/hue/src/api/behavior.rs @@ -0,0 +1,102 @@ +use std::ops::AddAssign; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use uuid::Uuid; + +use super::DollarRef; + +#[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, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct BehaviorScriptMetadata { + pub name: String, + pub category: String, +} + +#[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(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, Default, Serialize, Deserialize, Clone)] +pub struct BehaviorInstanceUpdate { + pub configuration: Option, + pub enabled: Option, + pub metadata: Option, + // trigger +} + +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/mod.rs b/crates/hue/src/api/mod.rs index 42798326f..a61fdad30 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; @@ -10,6 +11,10 @@ mod stream; mod stubs; mod update; +pub use behavior::{ + BehaviorInstance, BehaviorInstanceMetadata, BehaviorInstanceUpdate, BehaviorScript, + BehaviorScriptMetadata, +}; pub use device::{Device, DeviceArchetype, DeviceProductData, DeviceUpdate, Identify}; pub use entertainment::{Entertainment, EntertainmentSegment, EntertainmentSegments}; pub use entertainment_config::{ @@ -43,12 +48,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, - ZigbeeDeviceDiscovery, 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, ZigbeeDeviceDiscovery, Zone, }; pub use update::{Update, UpdateRecord}; diff --git a/crates/hue/src/api/update.rs b/crates/hue/src/api/update.rs index 0d5413974..578b5d6fa 100644 --- a/crates/hue/src/api/update.rs +++ b/crates/hue/src/api/update.rs @@ -6,11 +6,13 @@ use crate::api::{ RoomUpdate, SceneUpdate, }; +use super::BehaviorInstanceUpdate; + #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(tag = "type", rename_all = "snake_case")] pub enum Update { /* BehaviorScript(BehaviorScriptUpdate), */ - /* BehaviorInstance(BehaviorInstanceUpdate), */ + BehaviorInstance(BehaviorInstanceUpdate), /* Bridge(BridgeUpdate), */ /* BridgeHome(BridgeHomeUpdate), */ Device(DeviceUpdate), @@ -41,6 +43,7 @@ impl Update { Self::Light(_) => RType::Light, Self::Room(_) => RType::Room, Self::Scene(_) => RType::Scene, + Self::BehaviorInstance(_) => RType::BehaviorInstance, } } @@ -53,6 +56,7 @@ impl Update { Self::Device(_) => Some(format!("/device/{id}")), Self::Light(_) => Some(format!("/lights/{id}")), Self::Scene(_) => Some(format!("/scenes/{uuid}")), + Self::BehaviorInstance(_) => Some(format!("/behavior_instance/{uuid}")), } } } diff --git a/src/resource.rs b/src/resource.rs index f9f37b066..ae227d1c0 100644 --- a/src/resource.rs +++ b/src/resource.rs @@ -7,11 +7,12 @@ use maplit::btreeset; use serde_json::{json, Value}; use tokio::sync::broadcast::{Receiver, Sender}; use tokio::sync::Notify; -use uuid::Uuid; +use uuid::{uuid, Uuid}; use hue::api::{ - Bridge, BridgeHome, Device, DeviceArchetype, DeviceProductData, DeviceUpdate, DimmingUpdate, - Entertainment, EntertainmentConfiguration, EntertainmentConfigurationLocationsUpdate, + BehaviorInstanceUpdate, BehaviorScript, BehaviorScriptMetadata, Bridge, BridgeHome, Device, + DeviceArchetype, DeviceProductData, DeviceUpdate, DimmingUpdate, DollarRef, Entertainment, + EntertainmentConfiguration, EntertainmentConfigurationLocationsUpdate, EntertainmentConfigurationStatus, EntertainmentConfigurationStreamProxyMode, EntertainmentConfigurationStreamProxyUpdate, EntertainmentConfigurationUpdate, GroupedLight, GroupedLightUpdate, Light, LightMode, LightUpdate, Metadata, On, RType, Resource, ResourceLink, @@ -98,7 +99,9 @@ impl Resources { } pub fn init(&mut self, bridge_id: &str) -> ApiResult<()> { - self.add_bridge(bridge_id.to_owned()) + self.add_bridge(bridge_id.to_owned())?; + self.add_behavior_scripts()?; + Ok(()) } pub fn aux_get(&self, link: &ResourceLink) -> HueResult<&AuxData> { @@ -175,6 +178,14 @@ impl Resources { Ok(Some(Update::EntertainmentConfiguration(upd))) } + Resource::BehaviorInstance(behavior_instance) => { + let upd = BehaviorInstanceUpdate::new() + .with_metadata(behavior_instance.metadata.clone()) + .with_enabled(behavior_instance.enabled) + .with_configuration(behavior_instance.configuration.clone()); + + Ok(Some(Update::BehaviorInstance(upd))) + } obj => Err(HueError::UpdateUnsupported(obj.rtype())), } } @@ -371,6 +382,35 @@ impl Resources { Ok(()) } + pub fn add_behavior_scripts(&mut self) -> ApiResult<()> { + let wake_up_link = ResourceLink::new( + uuid!("ff8957e3-2eb9-4699-a0c8-ad2cb3ede704"), + RType::BehaviorScript, + ); + let wake_up = BehaviorScript { + 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(), + }; + self.add(&wake_up_link, Resource::BehaviorScript(wake_up))?; + + Ok(()) + } + pub fn get_next_scene_id(&self, room: &ResourceLink) -> HueResult { let mut set: HashSet = HashSet::new(); diff --git a/src/routes/clip/behavior_instance.rs b/src/routes/clip/behavior_instance.rs new file mode 100644 index 000000000..55355911a --- /dev/null +++ b/src/routes/clip/behavior_instance.rs @@ -0,0 +1,40 @@ +use axum::{ + extract::{Path, State}, + routing::put, + Json, Router, +}; +use serde_json::Value; +use uuid::Uuid; + +use hue::api::BehaviorInstance; +use hue::api::{BehaviorInstanceUpdate, RType}; + +use crate::routes::clip::{ApiV2Result, V2Reply}; +use crate::server::appstate::AppState; + +async fn put_behavior_instance( + State(state): State, + Path(id): Path, + Json(put): Json, +) -> ApiV2Result { + log::info!("PUT behavior_instance/{id}"); + log::debug!("json data\n{}", serde_json::to_string_pretty(&put)?); + + let rlink = RType::BehaviorInstance.link_to(id); + + log::info!("PUT behavior_instance/{id}: updating"); + + let upd: BehaviorInstanceUpdate = serde_json::from_value(put)?; + + state + .res + .lock() + .await + .update::(&id, |bi| *bi += upd)?; + + V2Reply::ok(rlink) +} + +pub fn router() -> Router { + Router::new().route("/:id", put(put_behavior_instance)) +} diff --git a/src/routes/clip/mod.rs b/src/routes/clip/mod.rs index 17992cc10..972c39daa 100644 --- a/src/routes/clip/mod.rs +++ b/src/routes/clip/mod.rs @@ -1,3 +1,4 @@ +pub mod behavior_instance; pub mod device; pub mod entertainment; pub mod entertainment_configuration; @@ -54,5 +55,6 @@ pub fn router() -> Router { entertainment_configuration::router(), ) .nest("/entertainment/", entertainment::router()) + .nest("/behavior_instance", behavior_instance::router()) .merge(generic::router()) } From dc87fd8ebb6942b9a07cb35e6724016b14113cd9 Mon Sep 17 00:00:00 2001 From: Christian Duvholt Date: Sat, 1 Feb 2025 23:02:28 +0100 Subject: [PATCH 02/26] Proof of concept implementation of wake up automation --- Cargo.lock | 13 ++++ Cargo.toml | 1 + crates/hue/src/api/behavior.rs | 89 +++++++++++++++++++++++-- crates/hue/src/api/mod.rs | 4 +- crates/hue/src/api/stubs.rs | 48 +------------- src/main.rs | 3 + src/resource.rs | 13 ++-- src/routes/clip/behavior_instance.rs | 2 +- src/server/mod.rs | 29 +++++++++ src/server/scheduler.rs | 97 ++++++++++++++++++++++++++++ 10 files changed, 236 insertions(+), 63 deletions(-) create mode 100644 src/server/scheduler.rs diff --git a/Cargo.lock b/Cargo.lock index 80284b273..5a68f2f34 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -314,6 +314,7 @@ dependencies = [ "tokio-stream", "tokio-tungstenite", "tokio-util", + "tokio_schedule", "tower 0.5.2", "tower-http", "tracing", @@ -408,8 +409,10 @@ checksum = "7e36cc9d416881d2e24f9a963be5fb1cd90966419ac844274161d10488b3e825" dependencies = [ "android-tzdata", "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-targets", ] @@ -2603,6 +2606,16 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio_schedule" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c291c554da3518d6ef69c76ea35aabc78f736185a16b6017f6d1c224dac2e0" +dependencies = [ + "chrono", + "tokio", +] + [[package]] name = "tower" version = "0.4.13" diff --git a/Cargo.toml b/Cargo.toml index 402fcdc5f..f4ab0e17b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -137,6 +137,7 @@ zcl = { path = "crates/zcl" } openssl = { version = "0.10.72", optional = true } tokio-util = { version = "0.7.13", features = ["net"] } tokio-openssl = "0.6.5" +tokio_schedule = "0.3.2" udp-stream = "0.0.12" maplit = "1.0.2" svc = { version = "0.1.0", path = "crates/svc" } diff --git a/crates/hue/src/api/behavior.rs b/crates/hue/src/api/behavior.rs index 43b1eb0d3..83d679dcb 100644 --- a/crates/hue/src/api/behavior.rs +++ b/crates/hue/src/api/behavior.rs @@ -1,8 +1,8 @@ use std::ops::AddAssign; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; use serde_json::Value; -use uuid::Uuid; +use uuid::{uuid, Uuid}; use super::DollarRef; @@ -25,9 +25,15 @@ pub struct BehaviorScriptMetadata { pub category: String, } -#[derive(Debug, Serialize, Deserialize, Clone)] +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)] pub struct BehaviorInstance { - pub configuration: Value, #[serde(default)] pub dependees: Vec, pub enabled: bool, @@ -35,13 +41,76 @@ pub struct BehaviorInstance { pub metadata: BehaviorInstanceMetadata, pub script_id: Uuid, pub status: Option, - #[serde(skip_serializing_if = "Option::is_none")] + #[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: BehaviorInstanceConfiguration, } -#[derive(Debug, Serialize, Deserialize, Clone)] +// TODO: refer to const in one place +const WAKEUP: Uuid = uuid!("ff8957e3-2eb9-4699-a0c8-ad2cb3ede704"); + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(untagged)] +pub enum BehaviorInstanceConfiguration { + #[serde(rename = "ff8957e3-2eb9-4699-a0c8-ad2cb3ede704")] + Wakeup(WakeupConfiguration), +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct WakeupConfiguration { + pub end_brightness: f64, + pub fade_in_duration: configuration::FadeInDuration, + pub style: String, + pub when: configuration::When, + #[serde(rename = "where")] + pub where_field: Vec, +} + +pub mod configuration { + use serde::{Deserialize, Serialize}; + + use crate::api::ResourceLink; + + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + pub struct FadeInDuration { + pub seconds: i64, + } + + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + pub struct When { + #[serde(rename = "recurrence_days")] + pub recurrence_days: Vec, + #[serde(rename = "time_point")] + pub time_point: TimePoint, + } + + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + pub struct TimePoint { + pub time: Time, + #[serde(rename = "type")] + // time + pub type_field: String, + } + + #[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, + } +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub struct BehaviorInstanceMetadata { pub name: String, } @@ -96,7 +165,13 @@ impl AddAssign for BehaviorInstance { } if let Some(configuration) = upd.configuration { - self.configuration = configuration; + if self.script_id == WAKEUP { + if let Ok(parsed) = serde_json::from_value(configuration) { + self.configuration = parsed; + } else { + // todo: log + } + } } } } diff --git a/crates/hue/src/api/mod.rs b/crates/hue/src/api/mod.rs index a61fdad30..bfebdacec 100644 --- a/crates/hue/src/api/mod.rs +++ b/crates/hue/src/api/mod.rs @@ -12,8 +12,8 @@ mod stubs; mod update; pub use behavior::{ - BehaviorInstance, BehaviorInstanceMetadata, BehaviorInstanceUpdate, BehaviorScript, - BehaviorScriptMetadata, + BehaviorInstance, BehaviorInstanceConfiguration, BehaviorInstanceMetadata, + BehaviorInstanceUpdate, BehaviorScript, BehaviorScriptMetadata, WakeupConfiguration, }; pub use device::{Device, DeviceArchetype, DeviceProductData, DeviceUpdate, Identify}; pub use entertainment::{Entertainment, EntertainmentSegment, EntertainmentSegments}; diff --git a/crates/hue/src/api/stubs.rs b/crates/hue/src/api/stubs.rs index a379423a1..96b2334d6 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, 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/src/main.rs b/src/main.rs index b9b37dd96..62f779b4d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -117,6 +117,9 @@ async fn build_tasks(appstate: &AppState) -> ApiResult<()> { )?; mgr.register_service("entertainment", svc).await?; + let svc = server::scheduler(appstate.res.clone()); + mgr.register_function("scheduler", svc).await?; + // register all z2m backends as services for (name, server) in &appstate.config().z2m.servers { let client = Z2mBackend::new( diff --git a/src/resource.rs b/src/resource.rs index ae227d1c0..1625bb137 100644 --- a/src/resource.rs +++ b/src/resource.rs @@ -55,7 +55,7 @@ impl Resources { pub fn update_bridge_version(&mut self, version: SwVersion) { self.version = version; self.state.patch_bridge_version(&self.version); - self.state_updates.notify_one(); + self.state_updates.notify_waiters(); } pub fn reset_all_streaming(&mut self) -> ApiResult<()> { @@ -181,8 +181,9 @@ impl Resources { Resource::BehaviorInstance(behavior_instance) => { let upd = BehaviorInstanceUpdate::new() .with_metadata(behavior_instance.metadata.clone()) - .with_enabled(behavior_instance.enabled) - .with_configuration(behavior_instance.configuration.clone()); + .with_enabled(behavior_instance.enabled); + // todo: fix + // .with_configuration(behavior_instance.configuration.clone()); Ok(Some(Update::BehaviorInstance(upd))) } @@ -208,7 +209,7 @@ impl Resources { .hue_event(EventBlock::update(id, id_v1, delta)?); } - self.state_updates.notify_one(); + self.state_updates.notify_waiters(); Ok(()) } @@ -258,7 +259,7 @@ impl Resources { self.state.insert(link.rid, obj); - self.state_updates.notify_one(); + self.state_updates.notify_waiters(); let evt = EventBlock::add(serde_json::to_value(self.get_resource_by_id(&link.rid)?)?); @@ -273,7 +274,7 @@ impl Resources { log::info!("Deleting {link:?}.."); self.state.remove(&link.rid)?; - self.state_updates.notify_one(); + self.state_updates.notify_waiters(); let evt = EventBlock::delete(link)?; diff --git a/src/routes/clip/behavior_instance.rs b/src/routes/clip/behavior_instance.rs index 55355911a..545d7f8b9 100644 --- a/src/routes/clip/behavior_instance.rs +++ b/src/routes/clip/behavior_instance.rs @@ -36,5 +36,5 @@ async fn put_behavior_instance( } pub fn router() -> Router { - Router::new().route("/:id", put(put_behavior_instance)) + Router::new().route("/{id}", put(put_behavior_instance)) } diff --git a/src/server/mod.rs b/src/server/mod.rs index 2674f725d..1bbb131ed 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -4,6 +4,7 @@ pub mod certificate; pub mod entertainment; pub mod http; pub mod hueevents; +pub mod scheduler; pub mod updater; use std::fs::File; @@ -18,6 +19,7 @@ use axum::routing::IntoMakeService; use axum::{Router, ServiceExt}; use camino::Utf8PathBuf; +use scheduler::Scheduler; use tokio::select; use tokio::sync::Mutex; use tokio::time::{sleep_until, MissedTickBehavior}; @@ -129,3 +131,30 @@ pub async fn version_updater( } } } + +pub async fn scheduler(res: Arc>) -> ApiResult<()> { + const STABILIZE_TIME: Duration = Duration::from_secs(1); + + let rx = res.lock().await.state_channel(); + let mut scheduler = Scheduler::new(res); + + scheduler.update().await; + + loop { + /* Wait for change notification */ + rx.notified().await; + + /* Updates often happen in burst, and we don't want to write the state + * file over and over, so ignore repeated update notifications within + * STABILIZE_TIME */ + let deadline = tokio::time::Instant::now() + STABILIZE_TIME; + loop { + select! { + () = rx.notified() => {}, + () = sleep_until(deadline) => break, + } + } + + scheduler.update().await; + } +} diff --git a/src/server/scheduler.rs b/src/server/scheduler.rs new file mode 100644 index 000000000..938319bf1 --- /dev/null +++ b/src/server/scheduler.rs @@ -0,0 +1,97 @@ +use std::sync::Arc; + +use chrono::Utc; +use tokio::{spawn, sync::Mutex, task::JoinHandle}; +use tokio_schedule::{every, Job}; + +use hue::api::{ + BehaviorInstance, BehaviorInstanceConfiguration, GroupedLightUpdate, On, RType, Resource, + WakeupConfiguration, +}; + +use crate::{backend::BackendRequest, resource::Resources}; + +#[derive(Debug)] +pub struct Scheduler { + jobs: Vec>, + res: Arc>, + behavior_instances: Vec, +} + +impl Scheduler { + pub const fn new(res: Arc>) -> Self { + Self { + jobs: vec![], + behavior_instances: vec![], + res, + } + } + + pub async fn update(&mut self) { + let new_behavior_instances = self.get_behavior_instances().await; + if new_behavior_instances != self.behavior_instances { + self.behavior_instances = new_behavior_instances; + self.update_jobs(); + } + } + + fn update_jobs(&mut self) { + for job in &self.jobs { + job.abort(); + } + self.jobs = self + .behavior_instances + .iter() + .filter(|bi| bi.enabled) + .map(|bi| match &bi.configuration { + BehaviorInstanceConfiguration::Wakeup(wakeup_configuration) => { + // todo: weekday + // todo: timezone + // todo: everything + let time = &wakeup_configuration.when.time_point.time; + let config = wakeup_configuration.clone(); + let res = self.res.clone(); + let schedule = every(1) + .day() + .at(time.hour, time.minute, 00) + .in_timezone(&Utc); + log::debug!("Created new behavior instance schedule: {:#?}", schedule); + spawn(schedule.perform(move || run_wake_up(config.clone(), res.clone()))) + } + }) + .collect(); + } + + async fn get_behavior_instances(&self) -> Vec { + self.res + .lock() + .await + .get_resources_by_type(RType::BehaviorInstance) + .into_iter() + .filter_map(|r| match r.obj { + Resource::BehaviorInstance(behavior_instance) => Some(behavior_instance), + _ => None, + }) + .collect() + } +} + +async fn run_wake_up(config: WakeupConfiguration, res: Arc>) { + log::debug!("Running scheduled behavior instance:, {:#?}", config); + let lock = res.lock().await; + let group = &config.where_field[0].group; + if let Ok(resource) = lock.get_resource_by_id(&group.rid) { + log::debug!("Turning on {:#?}", resource.obj); + if let Resource::Room(room) = resource.obj { + if let Some(grouped_light) = room.grouped_light_service() { + let payload = GroupedLightUpdate::default().with_on(Some(On::new(true))); + + if let Err(err) = lock + .backend_request(BackendRequest::GroupedLightUpdate(*grouped_light, payload)) + { + log::error!("Failed to execute group update: {:#?}", err); + } + } + } + } +} From ab023a5387db0dd8d224a70271291fb124596b95 Mon Sep 17 00:00:00 2001 From: Christian Duvholt Date: Sun, 2 Feb 2025 19:03:42 +0100 Subject: [PATCH 03/26] Support wake up weekday selection --- crates/hue/src/api/behavior.rs | 10 ++++++++ src/server/scheduler.rs | 44 ++++++++++++++++++++++------------ 2 files changed, 39 insertions(+), 15 deletions(-) diff --git a/crates/hue/src/api/behavior.rs b/crates/hue/src/api/behavior.rs index 83d679dcb..af6e09846 100644 --- a/crates/hue/src/api/behavior.rs +++ b/crates/hue/src/api/behavior.rs @@ -73,6 +73,7 @@ pub struct WakeupConfiguration { } pub mod configuration { + use chrono::Weekday; use serde::{Deserialize, Serialize}; use crate::api::ResourceLink; @@ -90,6 +91,15 @@ pub mod configuration { pub time_point: TimePoint, } + impl When { + pub fn weekdays(&self) -> Vec { + self.recurrence_days + .iter() + .filter_map(|w| w.parse().ok()) + .collect() + } + } + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct TimePoint { pub time: Time, diff --git a/src/server/scheduler.rs b/src/server/scheduler.rs index 938319bf1..8570c8848 100644 --- a/src/server/scheduler.rs +++ b/src/server/scheduler.rs @@ -1,8 +1,8 @@ use std::sync::Arc; -use chrono::Utc; +use chrono::Local; use tokio::{spawn, sync::Mutex, task::JoinHandle}; -use tokio_schedule::{every, Job}; +use tokio_schedule::{every, EveryWeekDay, Job}; use hue::api::{ BehaviorInstance, BehaviorInstanceConfiguration, GroupedLightUpdate, On, RType, Resource, @@ -43,20 +43,15 @@ impl Scheduler { .behavior_instances .iter() .filter(|bi| bi.enabled) - .map(|bi| match &bi.configuration { + .flat_map(|bi| match &bi.configuration { BehaviorInstanceConfiguration::Wakeup(wakeup_configuration) => { - // todo: weekday - // todo: timezone - // todo: everything - let time = &wakeup_configuration.when.time_point.time; - let config = wakeup_configuration.clone(); - let res = self.res.clone(); - let schedule = every(1) - .day() - .at(time.hour, time.minute, 00) - .in_timezone(&Utc); - log::debug!("Created new behavior instance schedule: {:#?}", schedule); - spawn(schedule.perform(move || run_wake_up(config.clone(), res.clone()))) + let jobs = create_wake_up_jobs(wakeup_configuration); + jobs.into_iter().map(|job| { + log::debug!("Created new behavior instance job: {:#?}", job); + let res = self.res.clone(); + let config = wakeup_configuration.clone(); + spawn(job.perform(move || run_wake_up(config.clone(), res.clone()))) + }) } }) .collect(); @@ -76,6 +71,25 @@ impl Scheduler { } } +fn create_wake_up_jobs(configuration: &WakeupConfiguration) -> Vec> { + // todo: + // timezone + // non repeating + // specific lights + // style + // brightness + // turn lights off + // fade duration + + let time = &configuration.when.time_point.time; + configuration + .when + .weekdays() + .into_iter() + .map(|weekday| every(1).week().on(weekday).at(time.hour, time.minute, 0)) + .collect() +} + async fn run_wake_up(config: WakeupConfiguration, res: Arc>) { log::debug!("Running scheduled behavior instance:, {:#?}", config); let lock = res.lock().await; From c99342aae096f69c0bddc32cd34f0a3e834fc803 Mon Sep 17 00:00:00 2001 From: Christian Duvholt Date: Sun, 2 Feb 2025 19:36:01 +0100 Subject: [PATCH 04/26] Implement wake up brightness and transition --- crates/hue/src/api/behavior.rs | 2 +- crates/hue/src/api/grouped_light.rs | 8 ++++++++ crates/z2m/src/update.rs | 5 +++++ src/backend/z2m/mod.rs | 1 + src/server/scheduler.rs | 7 ++++--- 5 files changed, 19 insertions(+), 4 deletions(-) diff --git a/crates/hue/src/api/behavior.rs b/crates/hue/src/api/behavior.rs index af6e09846..f2287c865 100644 --- a/crates/hue/src/api/behavior.rs +++ b/crates/hue/src/api/behavior.rs @@ -80,7 +80,7 @@ pub mod configuration { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct FadeInDuration { - pub seconds: i64, + pub seconds: u32, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/hue/src/api/grouped_light.rs b/crates/hue/src/api/grouped_light.rs index 6d94846e4..092b2f8ac 100644 --- a/crates/hue/src/api/grouped_light.rs +++ b/crates/hue/src/api/grouped_light.rs @@ -56,6 +56,9 @@ pub struct GroupedLightUpdate { pub color: Option, #[serde(skip_serializing_if = "Option::is_none")] pub color_temperature: Option, + + #[serde(skip)] + pub transition: Option, } impl GroupedLightUpdate { @@ -100,4 +103,9 @@ impl GroupedLightUpdate { ..self } } + + #[must_use] + pub const fn with_transition(self, transition: Option) -> Self { + Self { transition, ..self } + } } diff --git a/crates/z2m/src/update.rs b/crates/z2m/src/update.rs index e6829827d..f2d5cd5f0 100644 --- a/crates/z2m/src/update.rs +++ b/crates/z2m/src/update.rs @@ -112,6 +112,11 @@ impl DeviceUpdate { ..self } } + + #[must_use] + pub fn with_transition(self, transition: Option) -> Self { + Self { transition, ..self } + } } #[derive(Copy, Debug, Serialize, Deserialize, Clone)] diff --git a/src/backend/z2m/mod.rs b/src/backend/z2m/mod.rs index ef712810e..93a559e4f 100644 --- a/src/backend/z2m/mod.rs +++ b/src/backend/z2m/mod.rs @@ -957,6 +957,7 @@ impl Z2mBackend { let payload = DeviceUpdate::default() .with_state(upd.on.map(|on| on.on)) .with_brightness(upd.dimming.map(|dim| dim.brightness / 100.0 * 254.0)) + .with_transition(upd.transition) .with_color_temp(upd.color_temperature.map(|ct| ct.mirek)) .with_color_xy(upd.color.map(|col| col.xy)); diff --git a/src/server/scheduler.rs b/src/server/scheduler.rs index 8570c8848..f8fd877bf 100644 --- a/src/server/scheduler.rs +++ b/src/server/scheduler.rs @@ -77,9 +77,7 @@ fn create_wake_up_jobs(configuration: &WakeupConfiguration) -> Vec>) { log::debug!("Turning on {:#?}", resource.obj); if let Resource::Room(room) = resource.obj { if let Some(grouped_light) = room.grouped_light_service() { - let payload = GroupedLightUpdate::default().with_on(Some(On::new(true))); + let payload = GroupedLightUpdate::default() + .with_on(Some(On::new(true))) + .with_brightness(Some(config.end_brightness)) + .with_transition(Some(f64::from(config.fade_in_duration.seconds))); if let Err(err) = lock .backend_request(BackendRequest::GroupedLightUpdate(*grouped_light, payload)) From 0ee02bf7bb365d4752f77faa49596999447a9166 Mon Sep 17 00:00:00 2001 From: Christian Duvholt Date: Sun, 2 Feb 2025 21:47:56 +0100 Subject: [PATCH 05/26] Properly implement choosing devices for wakeup automation --- crates/hue/src/api/behavior.rs | 1 + crates/hue/src/api/grouped_light.rs | 7 ++- crates/hue/src/api/light.rs | 11 +++++ src/backend/z2m/mod.rs | 1 + src/server/scheduler.rs | 73 ++++++++++++++++++++++++----- 5 files changed, 78 insertions(+), 15 deletions(-) diff --git a/crates/hue/src/api/behavior.rs b/crates/hue/src/api/behavior.rs index f2287c865..b00d89747 100644 --- a/crates/hue/src/api/behavior.rs +++ b/crates/hue/src/api/behavior.rs @@ -117,6 +117,7 @@ pub mod configuration { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Where { pub group: ResourceLink, + pub items: Option>, } } diff --git a/crates/hue/src/api/grouped_light.rs b/crates/hue/src/api/grouped_light.rs index 092b2f8ac..2a4f46491 100644 --- a/crates/hue/src/api/grouped_light.rs +++ b/crates/hue/src/api/grouped_light.rs @@ -105,7 +105,10 @@ impl GroupedLightUpdate { } #[must_use] - pub const fn with_transition(self, transition: Option) -> Self { - Self { transition, ..self } + pub fn with_transition(self, transition: Option>) -> Self { + Self { + transition: transition.map(Into::into), + ..self + } } } diff --git a/crates/hue/src/api/light.rs b/crates/hue/src/api/light.rs index d63b99739..da51f8ffe 100644 --- a/crates/hue/src/api/light.rs +++ b/crates/hue/src/api/light.rs @@ -579,6 +579,9 @@ pub struct LightUpdate { pub gradient: Option, #[serde(skip_serializing_if = "Option::is_none")] pub effects_v2: Option, + + #[serde(skip)] + pub transition: Option, } impl LightUpdate { @@ -637,6 +640,14 @@ impl LightUpdate { ..self } } + + #[must_use] + pub fn with_transition(self, transition: Option>) -> Self { + Self { + transition: transition.map(Into::into), + ..self + } + } } #[derive(Debug, Serialize, Deserialize, Clone, Copy)] diff --git a/src/backend/z2m/mod.rs b/src/backend/z2m/mod.rs index 93a559e4f..5be09d0b7 100644 --- a/src/backend/z2m/mod.rs +++ b/src/backend/z2m/mod.rs @@ -883,6 +883,7 @@ impl Z2mBackend { let payload = DeviceUpdate::default() .with_state(upd.on.map(|on| on.on)) .with_brightness(upd.dimming.map(|dim| dim.brightness / 100.0 * 254.0)) + .with_transition(upd.transition) .with_color_temp(upd.color_temperature.map(|ct| ct.mirek)) .with_color_xy(upd.color.map(|col| col.xy)) .with_gradient(upd.gradient); diff --git a/src/server/scheduler.rs b/src/server/scheduler.rs index f8fd877bf..b94aa2429 100644 --- a/src/server/scheduler.rs +++ b/src/server/scheduler.rs @@ -5,8 +5,8 @@ use tokio::{spawn, sync::Mutex, task::JoinHandle}; use tokio_schedule::{every, EveryWeekDay, Job}; use hue::api::{ - BehaviorInstance, BehaviorInstanceConfiguration, GroupedLightUpdate, On, RType, Resource, - WakeupConfiguration, + BehaviorInstance, BehaviorInstanceConfiguration, GroupedLightUpdate, LightUpdate, On, RType, + Resource, Room, WakeupConfiguration, }; use crate::{backend::BackendRequest, resource::Resources}; @@ -75,7 +75,6 @@ fn create_wake_up_jobs(configuration: &WakeupConfiguration) -> Vec Vec>) { log::debug!("Running scheduled behavior instance:, {:#?}", config); - let lock = res.lock().await; - let group = &config.where_field[0].group; - if let Ok(resource) = lock.get_resource_by_id(&group.rid) { + #[allow(clippy::option_if_let_else)] + let resource_links = config.where_field.iter().flat_map(|room| { + if let Some(items) = &room.items { + items.clone() + } else { + vec![room.group] + } + }); + + for resource_link in resource_links { + let resource = res.lock().await.get_resource_by_id(&resource_link.rid); + let resource = match resource { + Ok(resource) => resource, + Err(err) => { + log::warn!("Failed to get resource: {}", err); + continue; + } + }; log::debug!("Turning on {:#?}", resource.obj); - if let Resource::Room(room) = resource.obj { - if let Some(grouped_light) = room.grouped_light_service() { - let payload = GroupedLightUpdate::default() + match resource.obj { + Resource::Room(room) => { + wakeup_room(room, res.clone(), config.clone()).await; + } + Resource::Light(_light) => { + let payload = LightUpdate::default() .with_on(Some(On::new(true))) .with_brightness(Some(config.end_brightness)) - .with_transition(Some(f64::from(config.fade_in_duration.seconds))); + .with_transition(Some(config.fade_in_duration.seconds)); - if let Err(err) = lock - .backend_request(BackendRequest::GroupedLightUpdate(*grouped_light, payload)) - { + let upd = res + .lock() + .await + .backend_request(BackendRequest::LightUpdate(resource_link, payload)); + if let Err(err) = upd { log::error!("Failed to execute group update: {:#?}", err); } } + Resource::BridgeHome(_bridge_home) => { + let rooms = res.lock().await.get_resources_by_type(RType::Room); + for room_resource in rooms { + if let Resource::Room(room) = room_resource.obj { + wakeup_room(room, res.clone(), config.clone()).await; + } + } + } + _ => (), } } } + +async fn wakeup_room(room: Room, res: Arc>, config: WakeupConfiguration) { + let Some(grouped_light) = room.grouped_light_service() else { + log::error!("Failed to get grouped light service for room"); + return; + }; + let payload = GroupedLightUpdate::default() + .with_on(Some(On::new(true))) + .with_brightness(Some(config.end_brightness)) + .with_transition(Some(config.fade_in_duration.seconds)); + + let upd = res + .lock() + .await + .backend_request(BackendRequest::GroupedLightUpdate(*grouped_light, payload)); + if let Err(err) = upd { + log::error!("Failed to execute group update: {:#?}", err); + } +} From eddd9476b0e7a4ee3969a99031ba9741c35c83d7 Mon Sep 17 00:00:00 2001 From: Christian Duvholt Date: Mon, 3 Feb 2025 19:53:30 +0100 Subject: [PATCH 06/26] Disable unimplemented wake up sunrise style --- src/resource.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/resource.rs b/src/resource.rs index 1625bb137..a8f382c26 100644 --- a/src/resource.rs +++ b/src/resource.rs @@ -401,7 +401,10 @@ impl Resources { category: "automation".to_string(), }, state_schema: DollarRef { dref: None }, - supported_features: vec!["style_sunrise".to_string(), "intensity".to_string()], + supported_features: vec![ + // "style_sunrise".to_string(), + "intensity".to_string(), + ], trigger_schema: DollarRef { dref: Some("trigger.json#".to_string()), }, From fee447a64c2b1b4b92efe88334f935fe04461d92 Mon Sep 17 00:00:00 2001 From: Christian Duvholt Date: Mon, 3 Feb 2025 22:15:01 +0100 Subject: [PATCH 07/26] Implement non repeating wakeup --- crates/hue/src/api/behavior.rs | 11 ++- src/server/scheduler.rs | 144 ++++++++++++++++++++++++++------- 2 files changed, 121 insertions(+), 34 deletions(-) diff --git a/crates/hue/src/api/behavior.rs b/crates/hue/src/api/behavior.rs index b00d89747..c49c9d59b 100644 --- a/crates/hue/src/api/behavior.rs +++ b/crates/hue/src/api/behavior.rs @@ -66,7 +66,7 @@ pub enum BehaviorInstanceConfiguration { pub struct WakeupConfiguration { pub end_brightness: f64, pub fade_in_duration: configuration::FadeInDuration, - pub style: String, + pub style: Option, pub when: configuration::When, #[serde(rename = "where")] pub where_field: Vec, @@ -86,17 +86,16 @@ pub mod configuration { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct When { #[serde(rename = "recurrence_days")] - pub recurrence_days: Vec, + pub recurrence_days: Option>, #[serde(rename = "time_point")] pub time_point: TimePoint, } impl When { - pub fn weekdays(&self) -> Vec { + pub fn weekdays(&self) -> Option> { self.recurrence_days - .iter() - .filter_map(|w| w.parse().ok()) - .collect() + .as_ref() + .map(|days| days.iter().filter_map(|w| w.parse().ok()).collect()) } } diff --git a/src/server/scheduler.rs b/src/server/scheduler.rs index b94aa2429..8b92811a3 100644 --- a/src/server/scheduler.rs +++ b/src/server/scheduler.rs @@ -1,12 +1,13 @@ -use std::sync::Arc; +use std::{sync::Arc, time::Duration}; -use chrono::Local; -use tokio::{spawn, sync::Mutex, task::JoinHandle}; -use tokio_schedule::{every, EveryWeekDay, Job}; +use chrono::{Days, Local, NaiveTime, Weekday}; +use tokio::{spawn, sync::Mutex, task::JoinHandle, time::sleep}; +use tokio_schedule::{every, Job}; +use uuid::Uuid; use hue::api::{ - BehaviorInstance, BehaviorInstanceConfiguration, GroupedLightUpdate, LightUpdate, On, RType, - Resource, Room, WakeupConfiguration, + BehaviorInstance, BehaviorInstanceConfiguration, BehaviorInstanceUpdate, GroupedLightUpdate, + LightUpdate, On, RType, Resource, Room, WakeupConfiguration, }; use crate::{backend::BackendRequest, resource::Resources}; @@ -15,7 +16,7 @@ use crate::{backend::BackendRequest, resource::Resources}; pub struct Scheduler { jobs: Vec>, res: Arc>, - behavior_instances: Vec, + behavior_instances: Vec, } impl Scheduler { @@ -42,49 +43,136 @@ impl Scheduler { self.jobs = self .behavior_instances .iter() - .filter(|bi| bi.enabled) - .flat_map(|bi| match &bi.configuration { + .filter(|ScheduleBehaviorInstance(_, bi)| bi.enabled) + .flat_map(|ScheduleBehaviorInstance(id, bi)| match &bi.configuration { BehaviorInstanceConfiguration::Wakeup(wakeup_configuration) => { - let jobs = create_wake_up_jobs(wakeup_configuration); - jobs.into_iter().map(|job| { - log::debug!("Created new behavior instance job: {:#?}", job); - let res = self.res.clone(); - let config = wakeup_configuration.clone(); - spawn(job.perform(move || run_wake_up(config.clone(), res.clone()))) - }) + wakeup(self.res.clone(), *id, wakeup_configuration.clone()) } }) .collect(); } - async fn get_behavior_instances(&self) -> Vec { + async fn get_behavior_instances(&self) -> Vec { self.res .lock() .await .get_resources_by_type(RType::BehaviorInstance) .into_iter() .filter_map(|r| match r.obj { - Resource::BehaviorInstance(behavior_instance) => Some(behavior_instance), + Resource::BehaviorInstance(behavior_instance) => { + Some(ScheduleBehaviorInstance(r.id, behavior_instance)) + } _ => None, }) .collect() } } -fn create_wake_up_jobs(configuration: &WakeupConfiguration) -> Vec> { +fn wakeup( + res: Arc>, + id: Uuid, + wakeup_configuration: WakeupConfiguration, +) -> Vec> { + let jobs = create_wake_up_jobs(&wakeup_configuration); + jobs.into_iter() + .map(move |job| { + log::debug!("Created new behavior instance job: {:?}", job); + let res = res.clone(); + let config = wakeup_configuration.clone(); + let fut = match job { + ScheduleJob::Recurring(weekday, time) => every(1) + .week() + .on(weekday) + .at(time.hour, time.minute, 0) + .perform(move || run_wake_up(config.clone(), res.clone())), + ScheduleJob::Once(time) => Box::pin(async move { + match time.get_sleep_duration() { + Ok(sleep_duration) => { + sleep(sleep_duration).await; + run_wake_up(config.clone(), res.clone()).await; + disable_behavior_instance(id, res).await; + } + Err(err) => { + log::error!( + "Failed to get sleep duration for time {:?}: {}", + time, + err + ); + } + } + }), + }; + spawn(fut) + }) + .collect() +} + +async fn disable_behavior_instance(id: Uuid, res: Arc>) { + let upd = BehaviorInstanceUpdate::default().with_enabled(false); + let upd_result = res + .lock() + .await + .update::(&id, |bi| *bi += upd); + if let Err(err) = upd_result { + log::error!("Failed to disable behavior instance {:?}", err); + } +} + +#[derive(Debug, PartialEq)] +struct ScheduleBehaviorInstance(Uuid, BehaviorInstance); + +#[derive(Clone, Debug)] +struct Time { + pub hour: u32, + pub minute: u32, +} + +impl Time { + fn get_sleep_duration(&self) -> Result { + let now = Local::now(); + let naive_time = NaiveTime::from_hms_opt(self.hour, self.minute, 0).ok_or("naive time")?; + let next = match now.with_time(naive_time) { + chrono::offset::LocalResult::Single(time) => time, + chrono::offset::LocalResult::Ambiguous(_, latest) => latest, + chrono::offset::LocalResult::None => { + return Err("with time"); + } + }; + let wakeup_datetime = if next < now { + next.checked_add_days(Days::new(1)).ok_or("add day")? + } else { + next + }; + let sleep_duration = (wakeup_datetime - now).to_std().ok().ok_or("duration")?; + Ok(sleep_duration) + } +} + +#[derive(Debug)] +enum ScheduleJob { + Recurring(Weekday, Time), + Once(Time), +} + +fn create_wake_up_jobs(configuration: &WakeupConfiguration) -> Vec { // todo: // timezone - // non repeating - // style // turn lights off - let time = &configuration.when.time_point.time; - configuration - .when - .weekdays() - .into_iter() - .map(|weekday| every(1).week().on(weekday).at(time.hour, time.minute, 0)) - .collect() + let time = Time { + hour: configuration.when.time_point.time.hour, + minute: configuration.when.time_point.time.minute, + }; + let weekdays = configuration.when.weekdays(); + + if let Some(weekdays) = weekdays { + weekdays + .into_iter() + .map(|weekday| ScheduleJob::Recurring(weekday, time.clone())) + .collect() + } else { + vec![ScheduleJob::Once(time)] + } } async fn run_wake_up(config: WakeupConfiguration, res: Arc>) { From da671729bea9ab2f60c0e82ee5249975883a5e27 Mon Sep 17 00:00:00 2001 From: Christian Duvholt Date: Wed, 5 Feb 2025 19:43:35 +0100 Subject: [PATCH 08/26] Implement turn off lights after wake up --- crates/hue/src/api/behavior.rs | 15 ++++++- src/server/scheduler.rs | 82 ++++++++++++++++++++++++++++------ 2 files changed, 82 insertions(+), 15 deletions(-) diff --git a/crates/hue/src/api/behavior.rs b/crates/hue/src/api/behavior.rs index c49c9d59b..65aba5310 100644 --- a/crates/hue/src/api/behavior.rs +++ b/crates/hue/src/api/behavior.rs @@ -65,7 +65,10 @@ pub enum BehaviorInstanceConfiguration { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct WakeupConfiguration { pub end_brightness: f64, - pub fade_in_duration: configuration::FadeInDuration, + 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")] @@ -73,16 +76,24 @@ pub struct WakeupConfiguration { } 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 FadeInDuration { + 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 { #[serde(rename = "recurrence_days")] diff --git a/src/server/scheduler.rs b/src/server/scheduler.rs index 8b92811a3..79e4ebbcd 100644 --- a/src/server/scheduler.rs +++ b/src/server/scheduler.rs @@ -1,6 +1,7 @@ use std::{sync::Arc, time::Duration}; use chrono::{Days, Local, NaiveTime, Weekday}; +use futures::{stream, StreamExt}; use tokio::{spawn, sync::Mutex, task::JoinHandle, time::sleep}; use tokio_schedule::{every, Job}; use uuid::Uuid; @@ -157,7 +158,6 @@ enum ScheduleJob { fn create_wake_up_jobs(configuration: &WakeupConfiguration) -> Vec { // todo: // timezone - // turn lights off let time = Time { hour: configuration.when.time_point.time.hour, @@ -186,17 +186,26 @@ async fn run_wake_up(config: WakeupConfiguration, res: Arc>) { } }); - for resource_link in resource_links { - let resource = res.lock().await.get_resource_by_id(&resource_link.rid); - let resource = match resource { - Ok(resource) => resource, - Err(err) => { - log::warn!("Failed to get resource: {}", err); - continue; + let resources = stream::iter(resource_links.into_iter()) + .filter_map(|resource_link| { + let res = res.clone(); + async move { + let resource = res.lock().await.get_resource_by_id(&resource_link.rid); + match resource { + Ok(resource) => Some((resource_link, resource)), + Err(err) => { + log::warn!("Failed to get resource: {}", err); + None + } + } } - }; + }) + .collect::>() + .await; + + for (resource_link, resource) in &resources { log::debug!("Turning on {:#?}", resource.obj); - match resource.obj { + match &resource.obj { Resource::Room(room) => { wakeup_room(room, res.clone(), config.clone()).await; } @@ -209,7 +218,7 @@ async fn run_wake_up(config: WakeupConfiguration, res: Arc>) { let upd = res .lock() .await - .backend_request(BackendRequest::LightUpdate(resource_link, payload)); + .backend_request(BackendRequest::LightUpdate(*resource_link, payload)); if let Err(err) = upd { log::error!("Failed to execute group update: {:#?}", err); } @@ -218,16 +227,47 @@ async fn run_wake_up(config: WakeupConfiguration, res: Arc>) { let rooms = res.lock().await.get_resources_by_type(RType::Room); for room_resource in rooms { if let Resource::Room(room) = room_resource.obj { - wakeup_room(room, res.clone(), config.clone()).await; + wakeup_room(&room, res.clone(), config.clone()).await; } } } _ => (), } } + + if let Some(duration) = config.turn_lights_off_after { + sleep(duration.to_std()).await; + for (resource_link, resource) in resources { + match resource.obj { + Resource::Room(room) => { + turn_off_room(&room, res.clone()).await; + } + Resource::Light(_light) => { + let payload = LightUpdate::default().with_on(Some(On::new(false))); + + let upd = res + .lock() + .await + .backend_request(BackendRequest::LightUpdate(resource_link, payload)); + if let Err(err) = upd { + log::error!("Failed to execute group update: {:#?}", err); + } + } + Resource::BridgeHome(_bridge_home) => { + let rooms = res.lock().await.get_resources_by_type(RType::Room); + for room_resource in rooms { + if let Resource::Room(room) = room_resource.obj { + turn_off_room(&room, res.clone()).await; + } + } + } + _ => (), + } + } + } } -async fn wakeup_room(room: Room, res: Arc>, config: WakeupConfiguration) { +async fn wakeup_room(room: &Room, res: Arc>, config: WakeupConfiguration) { let Some(grouped_light) = room.grouped_light_service() else { log::error!("Failed to get grouped light service for room"); return; @@ -245,3 +285,19 @@ async fn wakeup_room(room: Room, res: Arc>, config: WakeupConfi log::error!("Failed to execute group update: {:#?}", err); } } + +async fn turn_off_room(room: &Room, res: Arc>) { + let Some(grouped_light) = room.grouped_light_service() else { + log::error!("Failed to get grouped light service for room"); + return; + }; + let payload = GroupedLightUpdate::default().with_on(Some(On::new(false))); + + let upd = res + .lock() + .await + .backend_request(BackendRequest::GroupedLightUpdate(*grouped_light, payload)); + if let Err(err) = upd { + log::error!("Failed to execute group update: {:#?}", err); + } +} From 4b548c44cf479643e8acb79e75af4b5157c0a2aa Mon Sep 17 00:00:00 2001 From: Christian Duvholt Date: Wed, 5 Feb 2025 21:09:58 +0100 Subject: [PATCH 09/26] Start wake up time when fade in should start It previously started fade in when the fade in when the light should've been at full brightness --- src/server/scheduler.rs | 93 +++++++++++++++++++++++++---------------- 1 file changed, 58 insertions(+), 35 deletions(-) diff --git a/src/server/scheduler.rs b/src/server/scheduler.rs index 79e4ebbcd..4b8bb2ede 100644 --- a/src/server/scheduler.rs +++ b/src/server/scheduler.rs @@ -1,6 +1,6 @@ use std::{sync::Arc, time::Duration}; -use chrono::{Days, Local, NaiveTime, Weekday}; +use chrono::{Days, Local, NaiveTime, Timelike, Weekday}; use futures::{stream, StreamExt}; use tokio::{spawn, sync::Mutex, task::JoinHandle, time::sleep}; use tokio_schedule::{every, Job}; @@ -76,35 +76,7 @@ fn wakeup( ) -> Vec> { let jobs = create_wake_up_jobs(&wakeup_configuration); jobs.into_iter() - .map(move |job| { - log::debug!("Created new behavior instance job: {:?}", job); - let res = res.clone(); - let config = wakeup_configuration.clone(); - let fut = match job { - ScheduleJob::Recurring(weekday, time) => every(1) - .week() - .on(weekday) - .at(time.hour, time.minute, 0) - .perform(move || run_wake_up(config.clone(), res.clone())), - ScheduleJob::Once(time) => Box::pin(async move { - match time.get_sleep_duration() { - Ok(sleep_duration) => { - sleep(sleep_duration).await; - run_wake_up(config.clone(), res.clone()).await; - disable_behavior_instance(id, res).await; - } - Err(err) => { - log::error!( - "Failed to get sleep duration for time {:?}: {}", - time, - err - ); - } - } - }), - }; - spawn(fut) - }) + .map(move |job| spawn(job.run(id, wakeup_configuration.clone(), res.clone()))) .collect() } @@ -129,8 +101,16 @@ struct Time { } impl Time { - fn get_sleep_duration(&self) -> Result { - let now = Local::now(); + fn get_sleep_duration(&self, now: chrono::DateTime) -> Result { + let wakeup_datetime = self.next_datetime(now)?; + let sleep_duration = (wakeup_datetime - now).to_std().ok().ok_or("duration")?; + Ok(sleep_duration) + } + + fn next_datetime( + &self, + now: chrono::DateTime, + ) -> Result, &'static str> { let naive_time = NaiveTime::from_hms_opt(self.hour, self.minute, 0).ok_or("naive time")?; let next = match now.with_time(naive_time) { chrono::offset::LocalResult::Single(time) => time, @@ -144,8 +124,7 @@ impl Time { } else { next }; - let sleep_duration = (wakeup_datetime - now).to_std().ok().ok_or("duration")?; - Ok(sleep_duration) + Ok(wakeup_datetime) } } @@ -155,6 +134,50 @@ enum ScheduleJob { Once(Time), } +impl ScheduleJob { + async fn run( + self, + id: Uuid, + wakeup_configuration: WakeupConfiguration, + res: Arc>, + ) { + log::debug!("Created new behavior instance job: {:?}", self); + let now = Local::now(); + match self { + Self::Recurring(weekday, time) => match time.next_datetime(now) { + Ok(datetime) => { + let fade_in_start = datetime - wakeup_configuration.fade_in_duration.to_std(); + every(1) + .week() + .on(weekday) + .at( + fade_in_start.hour(), + fade_in_start.minute(), + fade_in_start.second(), + ) + .perform(move || run_wake_up(wakeup_configuration.clone(), res.clone())) + .await; + } + Err(err) => { + log::error!("Failed to get next datetime {:?}: {}", time, err); + } + }, + Self::Once(time) => match time.get_sleep_duration(now) { + Ok(time_until_wakeup) => { + let fade_in_start = + time_until_wakeup - wakeup_configuration.fade_in_duration.to_std(); + sleep(fade_in_start).await; + run_wake_up(wakeup_configuration.clone(), res.clone()).await; + disable_behavior_instance(id, res).await; + } + Err(err) => { + log::error!("Failed to get sleep duration for time {:?}: {}", time, err); + } + }, + } + } +} + fn create_wake_up_jobs(configuration: &WakeupConfiguration) -> Vec { // todo: // timezone @@ -236,7 +259,7 @@ async fn run_wake_up(config: WakeupConfiguration, res: Arc>) { } if let Some(duration) = config.turn_lights_off_after { - sleep(duration.to_std()).await; + sleep(config.fade_in_duration.to_std() + duration.to_std()).await; for (resource_link, resource) in resources { match resource.obj { Resource::Room(room) => { From 8926811b12adf62ab4ff7dd940369d51ed018d53 Mon Sep 17 00:00:00 2001 From: Christian Duvholt Date: Fri, 7 Feb 2025 21:56:37 +0100 Subject: [PATCH 10/26] Spawn actual wake up task in the background to mimic Hue When the wake up schedule first begins it shouldn't be cancelled by doing changes --- src/server/scheduler.rs | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/server/scheduler.rs b/src/server/scheduler.rs index 4b8bb2ede..b57f44425 100644 --- a/src/server/scheduler.rs +++ b/src/server/scheduler.rs @@ -155,7 +155,13 @@ impl ScheduleJob { fade_in_start.minute(), fade_in_start.second(), ) - .perform(move || run_wake_up(wakeup_configuration.clone(), res.clone())) + .perform(move || { + let wakeup_configuration = wakeup_configuration.clone(); + let res = res.clone(); + async move { + spawn(run_wake_up(wakeup_configuration.clone(), res.clone())); + } + }) .await; } Err(err) => { @@ -164,11 +170,13 @@ impl ScheduleJob { }, Self::Once(time) => match time.get_sleep_duration(now) { Ok(time_until_wakeup) => { - let fade_in_start = - time_until_wakeup - wakeup_configuration.fade_in_duration.to_std(); - sleep(fade_in_start).await; - run_wake_up(wakeup_configuration.clone(), res.clone()).await; - disable_behavior_instance(id, res).await; + spawn(async move { + let fade_in_duration = wakeup_configuration.fade_in_duration.to_std(); + let fade_in_start = time_until_wakeup - fade_in_duration; + sleep(fade_in_start).await; + run_wake_up(wakeup_configuration.clone(), res.clone()).await; + disable_behavior_instance(id, res).await; + }); } Err(err) => { log::error!("Failed to get sleep duration for time {:?}: {}", time, err); From 318fab2eb28478ec1914fa10d4dda41fad2da5bd Mon Sep 17 00:00:00 2001 From: Christian Duvholt Date: Fri, 7 Feb 2025 22:36:36 +0100 Subject: [PATCH 11/26] Introduce WakeupJob and refactor --- src/server/scheduler.rs | 77 +++++++++++++++++++++++------------------ 1 file changed, 43 insertions(+), 34 deletions(-) diff --git a/src/server/scheduler.rs b/src/server/scheduler.rs index b57f44425..3e87a5186 100644 --- a/src/server/scheduler.rs +++ b/src/server/scheduler.rs @@ -1,4 +1,4 @@ -use std::{sync::Arc, time::Duration}; +use std::{iter, sync::Arc, time::Duration}; use chrono::{Days, Local, NaiveTime, Timelike, Weekday}; use futures::{stream, StreamExt}; @@ -47,7 +47,7 @@ impl Scheduler { .filter(|ScheduleBehaviorInstance(_, bi)| bi.enabled) .flat_map(|ScheduleBehaviorInstance(id, bi)| match &bi.configuration { BehaviorInstanceConfiguration::Wakeup(wakeup_configuration) => { - wakeup(self.res.clone(), *id, wakeup_configuration.clone()) + wakeup(self.res.clone(), id, wakeup_configuration) } }) .collect(); @@ -71,12 +71,12 @@ impl Scheduler { fn wakeup( res: Arc>, - id: Uuid, - wakeup_configuration: WakeupConfiguration, + id: &Uuid, + wakeup_configuration: &WakeupConfiguration, ) -> Vec> { - let jobs = create_wake_up_jobs(&wakeup_configuration); + let jobs = create_wake_up_jobs(id, wakeup_configuration); jobs.into_iter() - .map(move |job| spawn(job.run(id, wakeup_configuration.clone(), res.clone()))) + .map(move |job| spawn(job.run(res.clone()))) .collect() } @@ -129,24 +129,28 @@ impl Time { } #[derive(Debug)] -enum ScheduleJob { +enum ScheduleType { Recurring(Weekday, Time), Once(Time), } -impl ScheduleJob { - async fn run( - self, - id: Uuid, - wakeup_configuration: WakeupConfiguration, - res: Arc>, - ) { - log::debug!("Created new behavior instance job: {:?}", self); +pub struct WakeupJob { + resource_id: Uuid, + schedule_type: ScheduleType, + configuration: WakeupConfiguration, +} + +impl WakeupJob { + async fn run(self, res: Arc>) { + log::debug!( + "Created new behavior instance job: {:?}", + self.configuration + ); let now = Local::now(); - match self { - Self::Recurring(weekday, time) => match time.next_datetime(now) { + match self.schedule_type { + ScheduleType::Recurring(weekday, time) => match time.next_datetime(now) { Ok(datetime) => { - let fade_in_start = datetime - wakeup_configuration.fade_in_duration.to_std(); + let fade_in_start = datetime - self.configuration.fade_in_duration.to_std(); every(1) .week() .on(weekday) @@ -156,7 +160,7 @@ impl ScheduleJob { fade_in_start.second(), ) .perform(move || { - let wakeup_configuration = wakeup_configuration.clone(); + let wakeup_configuration = self.configuration.clone(); let res = res.clone(); async move { spawn(run_wake_up(wakeup_configuration.clone(), res.clone())); @@ -168,14 +172,14 @@ impl ScheduleJob { log::error!("Failed to get next datetime {:?}: {}", time, err); } }, - Self::Once(time) => match time.get_sleep_duration(now) { + ScheduleType::Once(time) => match time.get_sleep_duration(now) { Ok(time_until_wakeup) => { spawn(async move { - let fade_in_duration = wakeup_configuration.fade_in_duration.to_std(); + let fade_in_duration = self.configuration.fade_in_duration.to_std(); let fade_in_start = time_until_wakeup - fade_in_duration; sleep(fade_in_start).await; - run_wake_up(wakeup_configuration.clone(), res.clone()).await; - disable_behavior_instance(id, res).await; + run_wake_up(self.configuration.clone(), res.clone()).await; + disable_behavior_instance(self.resource_id, res).await; }); } Err(err) => { @@ -186,24 +190,29 @@ impl ScheduleJob { } } -fn create_wake_up_jobs(configuration: &WakeupConfiguration) -> Vec { - // todo: - // timezone - +fn create_wake_up_jobs(resource_id: &Uuid, configuration: &WakeupConfiguration) -> Vec { let time = Time { hour: configuration.when.time_point.time.hour, minute: configuration.when.time_point.time.minute, }; let weekdays = configuration.when.weekdays(); - if let Some(weekdays) = weekdays { - weekdays - .into_iter() - .map(|weekday| ScheduleJob::Recurring(weekday, time.clone())) - .collect() + let schedule_types: Box> = if let Some(weekdays) = weekdays { + Box::new( + weekdays + .into_iter() + .map(|weekday| ScheduleType::Recurring(weekday, time.clone())), + ) } else { - vec![ScheduleJob::Once(time)] - } + Box::new(iter::once(ScheduleType::Once(time))) + }; + schedule_types + .map(|schedule_type| WakeupJob { + resource_id: *resource_id, + schedule_type, + configuration: configuration.clone(), + }) + .collect() } async fn run_wake_up(config: WakeupConfiguration, res: Arc>) { From 9d1344bae14e0701ee4f1bd8ce0cd5bce4ad0059 Mon Sep 17 00:00:00 2001 From: Christian Duvholt Date: Fri, 7 Feb 2025 23:05:18 +0100 Subject: [PATCH 12/26] Properly implement start time calculation The previous method was buggy and brittle --- src/server/scheduler.rs | 83 +++++++++++++++++++++++------------------ 1 file changed, 46 insertions(+), 37 deletions(-) diff --git a/src/server/scheduler.rs b/src/server/scheduler.rs index 3e87a5186..3113b163b 100644 --- a/src/server/scheduler.rs +++ b/src/server/scheduler.rs @@ -1,6 +1,6 @@ -use std::{iter, sync::Arc, time::Duration}; +use std::{iter, sync::Arc}; -use chrono::{Days, Local, NaiveTime, Timelike, Weekday}; +use chrono::{DateTime, Days, Local, NaiveTime, Timelike, Weekday}; use futures::{stream, StreamExt}; use tokio::{spawn, sync::Mutex, task::JoinHandle, time::sleep}; use tokio_schedule::{every, Job}; @@ -100,19 +100,22 @@ struct Time { pub minute: u32, } -impl Time { - fn get_sleep_duration(&self, now: chrono::DateTime) -> Result { - let wakeup_datetime = self.next_datetime(now)?; - let sleep_duration = (wakeup_datetime - now).to_std().ok().ok_or("duration")?; - Ok(sleep_duration) - } +#[derive(Debug)] +enum ScheduleType { + Recurring(Weekday, Time), + Once(Time), +} + +pub struct WakeupJob { + resource_id: Uuid, + schedule_type: ScheduleType, + configuration: WakeupConfiguration, +} - fn next_datetime( - &self, - now: chrono::DateTime, - ) -> Result, &'static str> { - let naive_time = NaiveTime::from_hms_opt(self.hour, self.minute, 0).ok_or("naive time")?; - let next = match now.with_time(naive_time) { +impl WakeupJob { + fn start_datetime(&self, now: DateTime) -> Result, &'static str> { + let start_time = self.start_time()?; + let next = match now.with_time(start_time) { chrono::offset::LocalResult::Single(time) => time, chrono::offset::LocalResult::Ambiguous(_, latest) => latest, chrono::offset::LocalResult::None => { @@ -126,34 +129,32 @@ impl Time { }; Ok(wakeup_datetime) } -} -#[derive(Debug)] -enum ScheduleType { - Recurring(Weekday, Time), - Once(Time), -} - -pub struct WakeupJob { - resource_id: Uuid, - schedule_type: ScheduleType, - configuration: WakeupConfiguration, -} + fn start_time(&self) -> Result { + let job_time: &Time = match &self.schedule_type { + ScheduleType::Recurring(_weekday, time) => time, + ScheduleType::Once(time) => time, + }; + let scheduled_wakeup_time = + NaiveTime::from_hms_opt(job_time.hour, job_time.minute, 0).ok_or("naive time")?; + // although the scheduled time in the Hue app is the time when lights are at full brightness + // the job start time is considered to be when the fade in effects starts + let fade_in_duration = self.configuration.fade_in_duration.to_std(); + Ok(scheduled_wakeup_time - fade_in_duration) + } -impl WakeupJob { async fn run(self, res: Arc>) { log::debug!( "Created new behavior instance job: {:?}", self.configuration ); let now = Local::now(); - match self.schedule_type { - ScheduleType::Recurring(weekday, time) => match time.next_datetime(now) { - Ok(datetime) => { - let fade_in_start = datetime - self.configuration.fade_in_duration.to_std(); + match &self.schedule_type { + ScheduleType::Recurring(weekday, time) => match self.start_time() { + Ok(fade_in_start) => { every(1) .week() - .on(weekday) + .on(*weekday) .at( fade_in_start.hour(), fade_in_start.minute(), @@ -172,12 +173,20 @@ impl WakeupJob { log::error!("Failed to get next datetime {:?}: {}", time, err); } }, - ScheduleType::Once(time) => match time.get_sleep_duration(now) { - Ok(time_until_wakeup) => { + ScheduleType::Once(time) => match self.start_datetime(now) { + Ok(fade_in_datetime) => { spawn(async move { - let fade_in_duration = self.configuration.fade_in_duration.to_std(); - let fade_in_start = time_until_wakeup - fade_in_duration; - sleep(fade_in_start).await; + let Ok(time_until_fade_in) = + (fade_in_datetime - now).to_std().ok().ok_or("duration") + else { + log::error!( + "Failed to get sleep duration: datetime {}, now {}", + fade_in_datetime, + now + ); + return; + }; + sleep(time_until_fade_in).await; run_wake_up(self.configuration.clone(), res.clone()).await; disable_behavior_instance(self.resource_id, res).await; }); From 86087f0f3f1bbda34895827481f9ca3ab6f8f57f Mon Sep 17 00:00:00 2001 From: Christian Duvholt Date: Fri, 7 Feb 2025 23:18:28 +0100 Subject: [PATCH 13/26] Refactor wake up job error handling --- src/server/scheduler.rs | 97 +++++++++++++++++++++-------------------- 1 file changed, 49 insertions(+), 48 deletions(-) diff --git a/src/server/scheduler.rs b/src/server/scheduler.rs index 3113b163b..e9ebcb60a 100644 --- a/src/server/scheduler.rs +++ b/src/server/scheduler.rs @@ -76,7 +76,7 @@ fn wakeup( ) -> Vec> { let jobs = create_wake_up_jobs(id, wakeup_configuration); jobs.into_iter() - .map(move |job| spawn(job.run(res.clone()))) + .map(move |job| spawn(job.create(res.clone()))) .collect() } @@ -143,60 +143,61 @@ impl WakeupJob { Ok(scheduled_wakeup_time - fade_in_duration) } - async fn run(self, res: Arc>) { + async fn create(self, res: Arc>) { log::debug!( "Created new behavior instance job: {:?}", self.configuration ); let now = Local::now(); - match &self.schedule_type { - ScheduleType::Recurring(weekday, time) => match self.start_time() { - Ok(fade_in_start) => { - every(1) - .week() - .on(*weekday) - .at( - fade_in_start.hour(), - fade_in_start.minute(), - fade_in_start.second(), - ) - .perform(move || { - let wakeup_configuration = self.configuration.clone(); - let res = res.clone(); - async move { - spawn(run_wake_up(wakeup_configuration.clone(), res.clone())); - } - }) - .await; - } - Err(err) => { - log::error!("Failed to get next datetime {:?}: {}", time, err); - } - }, - ScheduleType::Once(time) => match self.start_datetime(now) { - Ok(fade_in_datetime) => { - spawn(async move { - let Ok(time_until_fade_in) = - (fade_in_datetime - now).to_std().ok().ok_or("duration") - else { - log::error!( - "Failed to get sleep duration: datetime {}, now {}", - fade_in_datetime, - now - ); - return; - }; - sleep(time_until_fade_in).await; - run_wake_up(self.configuration.clone(), res.clone()).await; - disable_behavior_instance(self.resource_id, res).await; - }); - } - Err(err) => { - log::error!("Failed to get sleep duration for time {:?}: {}", time, err); - } - }, + let result = match &self.schedule_type { + ScheduleType::Recurring(weekday, _time) => self.create_recurring(*weekday, res).await, + ScheduleType::Once(_time) => self.run_once(now, res), + }; + if let Err(err) = result { + log::error!("Failed to create wake up job: {}", err); } } + + async fn create_recurring( + &self, + weekday: Weekday, + res: Arc>, + ) -> Result<(), &'static str> { + let fade_in_start = self.start_time()?; + every(1) + .week() + .on(weekday) + .at( + fade_in_start.hour(), + fade_in_start.minute(), + fade_in_start.second(), + ) + .perform(move || { + let wakeup_configuration = self.configuration.clone(); + let res = res.clone(); + async move { + spawn(run_wake_up(wakeup_configuration.clone(), res.clone())); + } + }) + .await; + Ok(()) + } + + fn run_once( + self, + now: DateTime, + res: Arc>, + ) -> Result<(), &'static str> { + let fade_in_datetime = self.start_datetime(now)?; + let time_until_fade_in = (fade_in_datetime - now).to_std().ok().ok_or("duration")?; + spawn(async move { + sleep(time_until_fade_in).await; + run_wake_up(self.configuration.clone(), res.clone()).await; + disable_behavior_instance(self.resource_id, res).await; + }); + + Ok(()) + } } fn create_wake_up_jobs(resource_id: &Uuid, configuration: &WakeupConfiguration) -> Vec { From ace0fdb9a489601826917a83467b2c202ece1698 Mon Sep 17 00:00:00 2001 From: Christian Duvholt Date: Fri, 7 Feb 2025 23:26:17 +0100 Subject: [PATCH 14/26] Get rid of Time struct --- src/server/scheduler.rs | 36 +++++++++--------------------------- 1 file changed, 9 insertions(+), 27 deletions(-) diff --git a/src/server/scheduler.rs b/src/server/scheduler.rs index e9ebcb60a..7da58107c 100644 --- a/src/server/scheduler.rs +++ b/src/server/scheduler.rs @@ -94,16 +94,10 @@ async fn disable_behavior_instance(id: Uuid, res: Arc>) { #[derive(Debug, PartialEq)] struct ScheduleBehaviorInstance(Uuid, BehaviorInstance); -#[derive(Clone, Debug)] -struct Time { - pub hour: u32, - pub minute: u32, -} - #[derive(Debug)] enum ScheduleType { - Recurring(Weekday, Time), - Once(Time), + Recurring(Weekday), + Once(), } pub struct WakeupJob { @@ -131,10 +125,7 @@ impl WakeupJob { } fn start_time(&self) -> Result { - let job_time: &Time = match &self.schedule_type { - ScheduleType::Recurring(_weekday, time) => time, - ScheduleType::Once(time) => time, - }; + let job_time = &self.configuration.when.time_point.time; let scheduled_wakeup_time = NaiveTime::from_hms_opt(job_time.hour, job_time.minute, 0).ok_or("naive time")?; // although the scheduled time in the Hue app is the time when lights are at full brightness @@ -150,8 +141,8 @@ impl WakeupJob { ); let now = Local::now(); let result = match &self.schedule_type { - ScheduleType::Recurring(weekday, _time) => self.create_recurring(*weekday, res).await, - ScheduleType::Once(_time) => self.run_once(now, res), + ScheduleType::Recurring(weekday) => self.create_recurring(*weekday, res).await, + ScheduleType::Once() => self.run_once(now, res), }; if let Err(err) = result { log::error!("Failed to create wake up job: {}", err); @@ -201,21 +192,12 @@ impl WakeupJob { } fn create_wake_up_jobs(resource_id: &Uuid, configuration: &WakeupConfiguration) -> Vec { - let time = Time { - hour: configuration.when.time_point.time.hour, - minute: configuration.when.time_point.time.minute, - }; let weekdays = configuration.when.weekdays(); - let schedule_types: Box> = if let Some(weekdays) = weekdays { - Box::new( - weekdays - .into_iter() - .map(|weekday| ScheduleType::Recurring(weekday, time.clone())), - ) - } else { - Box::new(iter::once(ScheduleType::Once(time))) - }; + let schedule_types: Box> = weekdays.map_or_else( + || Box::new(iter::once(ScheduleType::Once())) as Box>, + |weekdays| Box::new(weekdays.into_iter().map(ScheduleType::Recurring)), + ); schedule_types .map(|schedule_type| WakeupJob { resource_id: *resource_id, From 33263af8aea405cca6417ef7771a5a392cc3cf98 Mon Sep 17 00:00:00 2001 From: Christian Duvholt Date: Sat, 8 Feb 2025 12:26:10 +0100 Subject: [PATCH 15/26] Set wake up color temperature --- src/server/scheduler.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/server/scheduler.rs b/src/server/scheduler.rs index 7da58107c..65ec8e5e7 100644 --- a/src/server/scheduler.rs +++ b/src/server/scheduler.rs @@ -207,6 +207,9 @@ fn create_wake_up_jobs(resource_id: &Uuid, configuration: &WakeupConfiguration) .collect() } +// As reported by the Hue bridge +const WAKEUP_FADE_MIREK: u16 = 447; + async fn run_wake_up(config: WakeupConfiguration, res: Arc>) { log::debug!("Running scheduled behavior instance:, {:#?}", config); #[allow(clippy::option_if_let_else)] @@ -245,7 +248,8 @@ async fn run_wake_up(config: WakeupConfiguration, res: Arc>) { let payload = LightUpdate::default() .with_on(Some(On::new(true))) .with_brightness(Some(config.end_brightness)) - .with_transition(Some(config.fade_in_duration.seconds)); + .with_transition(Some(config.fade_in_duration.seconds)) + .with_color_temperature(Some(WAKEUP_FADE_MIREK)); let upd = res .lock() @@ -307,7 +311,8 @@ async fn wakeup_room(room: &Room, res: Arc>, config: WakeupConf let payload = GroupedLightUpdate::default() .with_on(Some(On::new(true))) .with_brightness(Some(config.end_brightness)) - .with_transition(Some(config.fade_in_duration.seconds)); + .with_transition(Some(config.fade_in_duration.seconds)) + .with_color_temperature(Some(WAKEUP_FADE_MIREK)); let upd = res .lock() From 0c7079a896874204ce59b1c03c67af19a5249ac8 Mon Sep 17 00:00:00 2001 From: Christian Duvholt Date: Sat, 8 Feb 2025 13:15:36 +0100 Subject: [PATCH 16/26] Add WakeupRequest abstraction so that backend requests are better grouped --- src/server/scheduler.rs | 181 ++++++++++++++++++---------------------- 1 file changed, 83 insertions(+), 98 deletions(-) diff --git a/src/server/scheduler.rs b/src/server/scheduler.rs index 65ec8e5e7..b3ae86cbd 100644 --- a/src/server/scheduler.rs +++ b/src/server/scheduler.rs @@ -1,17 +1,16 @@ use std::{iter, sync::Arc}; use chrono::{DateTime, Days, Local, NaiveTime, Timelike, Weekday}; -use futures::{stream, StreamExt}; use tokio::{spawn, sync::Mutex, task::JoinHandle, time::sleep}; use tokio_schedule::{every, Job}; use uuid::Uuid; use hue::api::{ BehaviorInstance, BehaviorInstanceConfiguration, BehaviorInstanceUpdate, GroupedLightUpdate, - LightUpdate, On, RType, Resource, Room, WakeupConfiguration, + LightUpdate, On, RType, Resource, ResourceLink, WakeupConfiguration, }; -use crate::{backend::BackendRequest, resource::Resources}; +use crate::{backend::BackendRequest, error::ApiResult, resource::Resources}; #[derive(Debug)] pub struct Scheduler { @@ -207,9 +206,6 @@ fn create_wake_up_jobs(resource_id: &Uuid, configuration: &WakeupConfiguration) .collect() } -// As reported by the Hue bridge -const WAKEUP_FADE_MIREK: u16 = 447; - async fn run_wake_up(config: WakeupConfiguration, res: Arc>) { log::debug!("Running scheduled behavior instance:, {:#?}", config); #[allow(clippy::option_if_let_else)] @@ -221,11 +217,12 @@ async fn run_wake_up(config: WakeupConfiguration, res: Arc>) { } }); - let resources = stream::iter(resource_links.into_iter()) - .filter_map(|resource_link| { - let res = res.clone(); - async move { - let resource = res.lock().await.get_resource_by_id(&resource_link.rid); + let requests = { + let lock = res.lock().await; + resource_links + .into_iter() + .filter_map(|resource_link| { + let resource = lock.get_resource_by_id(&resource_link.rid); match resource { Ok(resource) => Some((resource_link, resource)), Err(err) => { @@ -233,108 +230,96 @@ async fn run_wake_up(config: WakeupConfiguration, res: Arc>) { None } } - } - }) - .collect::>() - .await; - - for (resource_link, resource) in &resources { - log::debug!("Turning on {:#?}", resource.obj); - match &resource.obj { - Resource::Room(room) => { - wakeup_room(room, res.clone(), config.clone()).await; - } - Resource::Light(_light) => { - let payload = LightUpdate::default() - .with_on(Some(On::new(true))) - .with_brightness(Some(config.end_brightness)) - .with_transition(Some(config.fade_in_duration.seconds)) - .with_color_temperature(Some(WAKEUP_FADE_MIREK)); - - let upd = res - .lock() - .await - .backend_request(BackendRequest::LightUpdate(*resource_link, payload)); - if let Err(err) = upd { - log::error!("Failed to execute group update: {:#?}", err); + }) + .flat_map(|(resource_link, resource)| match resource.obj { + Resource::Room(room) => room + .grouped_light_service() + .map_or_else(Vec::new, |grouped_light| { + vec![WakeupRequest::Group(*grouped_light)] + }), + Resource::Light(_light) => { + vec![WakeupRequest::Light(resource_link)] } - } - Resource::BridgeHome(_bridge_home) => { - let rooms = res.lock().await.get_resources_by_type(RType::Room); - for room_resource in rooms { - if let Resource::Room(room) = room_resource.obj { - wakeup_room(&room, res.clone(), config.clone()).await; - } + Resource::BridgeHome(_bridge_home) => { + let all_rooms = lock.get_resources_by_type(RType::Room); + all_rooms + .into_iter() + .filter_map(|room_resource| match room_resource.obj { + Resource::Room(room) => { + let grouped_light = room.grouped_light_service()?; + Some(WakeupRequest::Group(*grouped_light)) + } + _ => None, + }) + .collect() } - } - _ => (), + _ => Vec::new(), + }) + .collect::>() + }; + + for request in &requests { + if let Err(err) = request.on(res.clone(), config.clone()).await { + log::warn!("Failed to turn on wake up light: {}", err); } } if let Some(duration) = config.turn_lights_off_after { sleep(config.fade_in_duration.to_std() + duration.to_std()).await; - for (resource_link, resource) in resources { - match resource.obj { - Resource::Room(room) => { - turn_off_room(&room, res.clone()).await; - } - Resource::Light(_light) => { - let payload = LightUpdate::default().with_on(Some(On::new(false))); - - let upd = res - .lock() - .await - .backend_request(BackendRequest::LightUpdate(resource_link, payload)); - if let Err(err) = upd { - log::error!("Failed to execute group update: {:#?}", err); - } - } - Resource::BridgeHome(_bridge_home) => { - let rooms = res.lock().await.get_resources_by_type(RType::Room); - for room_resource in rooms { - if let Resource::Room(room) = room_resource.obj { - turn_off_room(&room, res.clone()).await; - } - } - } - _ => (), + + for request in &requests { + if let Err(err) = request.off(res.clone()).await { + log::warn!("Failed to turn off wake up light: {}", err); } } } } -async fn wakeup_room(room: &Room, res: Arc>, config: WakeupConfiguration) { - let Some(grouped_light) = room.grouped_light_service() else { - log::error!("Failed to get grouped light service for room"); - return; - }; - let payload = GroupedLightUpdate::default() - .with_on(Some(On::new(true))) - .with_brightness(Some(config.end_brightness)) - .with_transition(Some(config.fade_in_duration.seconds)) - .with_color_temperature(Some(WAKEUP_FADE_MIREK)); +enum WakeupRequest { + Light(ResourceLink), + Group(ResourceLink), +} - let upd = res - .lock() - .await - .backend_request(BackendRequest::GroupedLightUpdate(*grouped_light, payload)); - if let Err(err) = upd { - log::error!("Failed to execute group update: {:#?}", err); +impl WakeupRequest { + async fn on(&self, res: Arc>, config: WakeupConfiguration) -> ApiResult<()> { + // As reported by the Hue bridge + const WAKEUP_FADE_MIREK: u16 = 447; + + let backend_request = match self { + Self::Light(resource_link) => { + let payload = LightUpdate::default() + .with_on(Some(On::new(true))) + .with_brightness(Some(config.end_brightness)) + .with_transition(Some(config.fade_in_duration.seconds)) + .with_color_temperature(Some(WAKEUP_FADE_MIREK)); + BackendRequest::LightUpdate(*resource_link, payload) + } + Self::Group(resource_link) => { + let payload = GroupedLightUpdate::default() + .with_on(Some(On::new(true))) + .with_brightness(Some(config.end_brightness)) + .with_transition(Some(config.fade_in_duration.seconds)) + .with_color_temperature(Some(WAKEUP_FADE_MIREK)); + + BackendRequest::GroupedLightUpdate(*resource_link, payload) + } + }; + + res.lock().await.backend_request(backend_request) } -} -async fn turn_off_room(room: &Room, res: Arc>) { - let Some(grouped_light) = room.grouped_light_service() else { - log::error!("Failed to get grouped light service for room"); - return; - }; - let payload = GroupedLightUpdate::default().with_on(Some(On::new(false))); + async fn off(&self, res: Arc>) -> ApiResult<()> { + let backend_request = match self { + Self::Light(resource_link) => { + let payload = LightUpdate::default().with_on(Some(On::new(false))); + BackendRequest::LightUpdate(*resource_link, payload) + } + Self::Group(resource_link) => { + let payload = GroupedLightUpdate::default().with_on(Some(On::new(false))); + BackendRequest::GroupedLightUpdate(*resource_link, payload) + } + }; - let upd = res - .lock() - .await - .backend_request(BackendRequest::GroupedLightUpdate(*grouped_light, payload)); - if let Err(err) = upd { - log::error!("Failed to execute group update: {:#?}", err); + res.lock().await.backend_request(backend_request) } } From 342385bdebf8b81533a612ad7cf8727bc5e93db9 Mon Sep 17 00:00:00 2001 From: Christian Duvholt Date: Sat, 8 Feb 2025 14:13:38 +0100 Subject: [PATCH 17/26] Reset wake up lights before fade in This should prevent to light from using the previous configuration when starting up --- src/server/scheduler.rs | 32 ++++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/src/server/scheduler.rs b/src/server/scheduler.rs index b3ae86cbd..5a8779709 100644 --- a/src/server/scheduler.rs +++ b/src/server/scheduler.rs @@ -1,4 +1,4 @@ -use std::{iter, sync::Arc}; +use std::{iter, sync::Arc, time::Duration}; use chrono::{DateTime, Days, Local, NaiveTime, Timelike, Weekday}; use tokio::{spawn, sync::Mutex, task::JoinHandle, time::sleep}; @@ -285,27 +285,43 @@ impl WakeupRequest { // As reported by the Hue bridge const WAKEUP_FADE_MIREK: u16 = 447; - let backend_request = match self { + // Reset brightness and set color temperature + let reset_backend_request = match self { Self::Light(resource_link) => { let payload = LightUpdate::default() .with_on(Some(On::new(true))) - .with_brightness(Some(config.end_brightness)) - .with_transition(Some(config.fade_in_duration.seconds)) + .with_brightness(Some(0.0)) .with_color_temperature(Some(WAKEUP_FADE_MIREK)); BackendRequest::LightUpdate(*resource_link, payload) } Self::Group(resource_link) => { let payload = GroupedLightUpdate::default() .with_on(Some(On::new(true))) - .with_brightness(Some(config.end_brightness)) - .with_transition(Some(config.fade_in_duration.seconds)) + .with_brightness(Some(0.0)) .with_color_temperature(Some(WAKEUP_FADE_MIREK)); - BackendRequest::GroupedLightUpdate(*resource_link, payload) } }; + res.lock().await.backend_request(reset_backend_request)?; - res.lock().await.backend_request(backend_request) + sleep(Duration::from_secs(1)).await; + + // Start fade in to set brightness + let on_backend_request = match self { + Self::Light(resource_link) => { + let payload = LightUpdate::default() + .with_brightness(Some(config.end_brightness)) + .with_transition(Some(config.fade_in_duration.seconds)); + BackendRequest::LightUpdate(*resource_link, payload) + } + Self::Group(resource_link) => { + let payload = GroupedLightUpdate::default() + .with_brightness(Some(config.end_brightness)) + .with_transition(Some(config.fade_in_duration.seconds)); + BackendRequest::GroupedLightUpdate(*resource_link, payload) + } + }; + res.lock().await.backend_request(on_backend_request) } async fn off(&self, res: Arc>) -> ApiResult<()> { From 7de4da574d402d37f01b39431abf8f130e2e645e Mon Sep 17 00:00:00 2001 From: Christian Duvholt Date: Sat, 8 Feb 2025 14:36:30 +0100 Subject: [PATCH 18/26] Fix bug where wake up alarm was disabled before it completed --- src/server/scheduler.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/server/scheduler.rs b/src/server/scheduler.rs index 5a8779709..d9b629938 100644 --- a/src/server/scheduler.rs +++ b/src/server/scheduler.rs @@ -264,8 +264,12 @@ async fn run_wake_up(config: WakeupConfiguration, res: Arc>) { } } + // wait until fade in has completed + // otherwise the behavior instance can be disabled before it has actually finished + sleep(config.fade_in_duration.to_std()).await; + if let Some(duration) = config.turn_lights_off_after { - sleep(config.fade_in_duration.to_std() + duration.to_std()).await; + sleep(duration.to_std()).await; for request in &requests { if let Err(err) = request.off(res.clone()).await { From 156d8ff712a68056b1188fa1bfc522ebaa030e15 Mon Sep 17 00:00:00 2001 From: Christian Duvholt Date: Sat, 8 Feb 2025 14:54:24 +0100 Subject: [PATCH 19/26] Clean up behavior config parsing --- crates/hue/src/api/behavior.rs | 19 +++++-------------- src/resource.rs | 5 ++--- 2 files changed, 7 insertions(+), 17 deletions(-) diff --git a/crates/hue/src/api/behavior.rs b/crates/hue/src/api/behavior.rs index 65aba5310..5ef06b1f9 100644 --- a/crates/hue/src/api/behavior.rs +++ b/crates/hue/src/api/behavior.rs @@ -2,7 +2,7 @@ use std::ops::AddAssign; use serde::{Deserialize, Deserializer, Serialize}; use serde_json::Value; -use uuid::{uuid, Uuid}; +use uuid::Uuid; use super::DollarRef; @@ -39,6 +39,7 @@ pub struct BehaviorInstance { pub enabled: bool, pub last_error: Option, pub metadata: BehaviorInstanceMetadata, + // Wake up: ff8957e3-2eb9-4699-a0c8-ad2cb3ede704 pub script_id: Uuid, pub status: Option, #[serde( @@ -52,13 +53,9 @@ pub struct BehaviorInstance { pub configuration: BehaviorInstanceConfiguration, } -// TODO: refer to const in one place -const WAKEUP: Uuid = uuid!("ff8957e3-2eb9-4699-a0c8-ad2cb3ede704"); - #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(untagged)] pub enum BehaviorInstanceConfiguration { - #[serde(rename = "ff8957e3-2eb9-4699-a0c8-ad2cb3ede704")] Wakeup(WakeupConfiguration), } @@ -138,7 +135,7 @@ pub struct BehaviorInstanceMetadata { #[derive(Debug, Default, Serialize, Deserialize, Clone)] pub struct BehaviorInstanceUpdate { - pub configuration: Option, + pub configuration: Option, pub enabled: Option, pub metadata: Option, // trigger @@ -167,7 +164,7 @@ impl BehaviorInstanceUpdate { } #[must_use] - pub fn with_configuration(self, configuration: Value) -> Self { + pub fn with_configuration(self, configuration: BehaviorInstanceConfiguration) -> Self { Self { configuration: Some(configuration), ..self @@ -186,13 +183,7 @@ impl AddAssign for BehaviorInstance { } if let Some(configuration) = upd.configuration { - if self.script_id == WAKEUP { - if let Ok(parsed) = serde_json::from_value(configuration) { - self.configuration = parsed; - } else { - // todo: log - } - } + self.configuration = configuration; } } } diff --git a/src/resource.rs b/src/resource.rs index a8f382c26..cd2374bfb 100644 --- a/src/resource.rs +++ b/src/resource.rs @@ -181,9 +181,8 @@ impl Resources { Resource::BehaviorInstance(behavior_instance) => { let upd = BehaviorInstanceUpdate::new() .with_metadata(behavior_instance.metadata.clone()) - .with_enabled(behavior_instance.enabled); - // todo: fix - // .with_configuration(behavior_instance.configuration.clone()); + .with_enabled(behavior_instance.enabled) + .with_configuration(behavior_instance.configuration.clone()); Ok(Some(Update::BehaviorInstance(upd))) } From be5dd0c7f0fcd167519ae29b673fe4bfadfe6bb5 Mon Sep 17 00:00:00 2001 From: Christian Duvholt Date: Sun, 20 Apr 2025 19:34:40 +0200 Subject: [PATCH 20/26] Initial support for sunrise effect with hardcoded transition time --- crates/hue/src/api/light.rs | 1 + src/backend/z2m/mod.rs | 1 + src/resource.rs | 5 +-- src/server/scheduler.rs | 67 ++++++++++++++++++++++++++++++++++--- 4 files changed, 66 insertions(+), 8 deletions(-) diff --git a/crates/hue/src/api/light.rs b/crates/hue/src/api/light.rs index da51f8ffe..5d3904bfc 100644 --- a/crates/hue/src/api/light.rs +++ b/crates/hue/src/api/light.rs @@ -461,6 +461,7 @@ pub enum LightEffect { Cosmos, Sunbeam, Enchant, + Sunrise, } impl LightEffect { diff --git a/src/backend/z2m/mod.rs b/src/backend/z2m/mod.rs index 5be09d0b7..1915fc81a 100644 --- a/src/backend/z2m/mod.rs +++ b/src/backend/z2m/mod.rs @@ -843,6 +843,7 @@ impl Z2mBackend { LightEffect::Cosmos => EffectType::Cosmos, LightEffect::Sunbeam => EffectType::Sunbeam, LightEffect::Enchant => EffectType::Enchant, + LightEffect::Sunrise => EffectType::Sunrise, }; hz = hz.with_effect_type(et); } diff --git a/src/resource.rs b/src/resource.rs index cd2374bfb..f977c2443 100644 --- a/src/resource.rs +++ b/src/resource.rs @@ -400,10 +400,7 @@ impl Resources { category: "automation".to_string(), }, state_schema: DollarRef { dref: None }, - supported_features: vec![ - // "style_sunrise".to_string(), - "intensity".to_string(), - ], + supported_features: vec!["style_sunrise".to_string(), "intensity".to_string()], trigger_schema: DollarRef { dref: Some("trigger.json#".to_string()), }, diff --git a/src/server/scheduler.rs b/src/server/scheduler.rs index d9b629938..ea1946b86 100644 --- a/src/server/scheduler.rs +++ b/src/server/scheduler.rs @@ -5,9 +5,13 @@ use tokio::{spawn, sync::Mutex, task::JoinHandle, time::sleep}; use tokio_schedule::{every, Job}; use uuid::Uuid; -use hue::api::{ - BehaviorInstance, BehaviorInstanceConfiguration, BehaviorInstanceUpdate, GroupedLightUpdate, - LightUpdate, On, RType, Resource, ResourceLink, WakeupConfiguration, +use hue::{ + api::{ + BehaviorInstance, BehaviorInstanceConfiguration, BehaviorInstanceUpdate, + GroupedLightUpdate, Light, LightEffectActionUpdate, LightEffectsV2Update, LightUpdate, On, + RType, Resource, ResourceLink, WakeupConfiguration, + }, + clamp::Clamp, }; use crate::{backend::BackendRequest, error::ApiResult, resource::Resources}; @@ -286,6 +290,31 @@ enum WakeupRequest { impl WakeupRequest { async fn on(&self, res: Arc>, config: WakeupConfiguration) -> ApiResult<()> { + let light_supports_effects = match self { + Self::Light(resource_link) => res + .lock() + .await + .get::(resource_link)? + .effects + .is_some(), + Self::Group(_) => false, // todo: implement when grouped light support effects + }; + let use_sunrise_effect = + light_supports_effects && config.style == Some("sunrise".to_string()); + + if use_sunrise_effect { + self.sunrise_on(&res, &config).await?; + } else { + self.transition_to_bright_on(res, &config).await?; + } + Ok(()) + } + + async fn transition_to_bright_on( + &self, + res: Arc>, + config: &WakeupConfiguration, + ) -> Result<(), crate::error::ApiError> { // As reported by the Hue bridge const WAKEUP_FADE_MIREK: u16 = 447; @@ -325,7 +354,37 @@ impl WakeupRequest { BackendRequest::GroupedLightUpdate(*resource_link, payload) } }; - res.lock().await.backend_request(on_backend_request) + res.lock().await.backend_request(on_backend_request)?; + Ok(()) + } + + async fn sunrise_on( + &self, + res: &Arc>, + config: &WakeupConfiguration, + ) -> Result<(), crate::error::ApiError> { + match self { + Self::Light(resource_link) => { + let mut payload = LightUpdate::default() + .with_on(Some(On::new(true))) + .with_brightness(Some(config.end_brightness)); + payload.effects_v2 = Some(LightEffectsV2Update { + action: Some(LightEffectActionUpdate { + effect: Some(hue::api::LightEffect::Sunrise), + parameters: hue::api::LightEffectParameters { + color: None, + color_temperature: None, + speed: Some(Clamp::unit_from_u8(145)), + }, + }), + }); + res.lock() + .await + .backend_request(BackendRequest::LightUpdate(*resource_link, payload))?; + } + Self::Group(_resource_link) => {} + }; + Ok(()) } async fn off(&self, res: Arc>) -> ApiResult<()> { From db04497ec5e9caf249034f9208ec3cad98e45e3b Mon Sep 17 00:00:00 2001 From: Christian Duvholt Date: Sun, 20 Apr 2025 20:13:50 +0200 Subject: [PATCH 21/26] Calculate effect duration for sunrise effect --- crates/hue/src/effect_duration.rs | 71 +++++++++++++++++++++++++++++++ crates/hue/src/lib.rs | 1 + src/server/scheduler.rs | 4 +- 3 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 crates/hue/src/effect_duration.rs diff --git a/crates/hue/src/effect_duration.rs b/crates/hue/src/effect_duration.rs new file mode 100644 index 000000000..2f9203feb --- /dev/null +++ b/crates/hue/src/effect_duration.rs @@ -0,0 +1,71 @@ +#[derive(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 = 0x3F; + +const RESOLUTION_01S: u32 = 1; // 01s. +const RESOLUTION_05S: u32 = 5; // 05s. +const RESOLUTION_15S: u32 = 15; // 15s. +const RESOLUTION_01M: u32 = 60; // 01min. +const RESOLUTION_05M: u32 = 5 * 600; // 05min. + +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_05M_LIMIT: u32 = 6 * 60 * 60; // 06hrs. + +impl EffectDuration { + #[must_use] + #[allow(clippy::cast_possible_truncation)] + pub const fn from_seconds(seconds: u32) -> Self { + 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_05M_LIMIT { + (RESOLUTION_05M_BASE, RESOLUTION_05M) + } else { + return Self(0); + }; + 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), + EffectDuration(output) + ); + } + } +} diff --git a/crates/hue/src/lib.rs b/crates/hue/src/lib.rs index c207e0a3e..33c842f65 100644 --- a/crates/hue/src/lib.rs +++ b/crates/hue/src/lib.rs @@ -5,6 +5,7 @@ pub mod clamp; pub mod colorspace; pub mod date_format; pub mod devicedb; +pub mod effect_duration; pub mod error; pub mod event; pub mod flags; diff --git a/src/server/scheduler.rs b/src/server/scheduler.rs index ea1946b86..fd8bb830d 100644 --- a/src/server/scheduler.rs +++ b/src/server/scheduler.rs @@ -12,6 +12,7 @@ use hue::{ RType, Resource, ResourceLink, WakeupConfiguration, }, clamp::Clamp, + effect_duration::EffectDuration, }; use crate::{backend::BackendRequest, error::ApiResult, resource::Resources}; @@ -368,13 +369,14 @@ impl WakeupRequest { let mut payload = LightUpdate::default() .with_on(Some(On::new(true))) .with_brightness(Some(config.end_brightness)); + let effect_duration = EffectDuration::from_seconds(config.fade_in_duration.seconds); payload.effects_v2 = Some(LightEffectsV2Update { action: Some(LightEffectActionUpdate { effect: Some(hue::api::LightEffect::Sunrise), parameters: hue::api::LightEffectParameters { color: None, color_temperature: None, - speed: Some(Clamp::unit_from_u8(145)), + speed: Some(Clamp::unit_from_u8(effect_duration.0)), }, }), }); From a6621c4509fce0d169d8eede1791a8035fc57560 Mon Sep 17 00:00:00 2001 From: Christian Duvholt Date: Mon, 21 Apr 2025 20:06:02 +0200 Subject: [PATCH 22/26] Move behavior script details to hue crate --- crates/hue/src/api/behavior.rs | 29 ++++++++++++++++++++- src/resource.rs | 47 ++++++++++------------------------ 2 files changed, 42 insertions(+), 34 deletions(-) diff --git a/crates/hue/src/api/behavior.rs b/crates/hue/src/api/behavior.rs index 5ef06b1f9..1330c7345 100644 --- a/crates/hue/src/api/behavior.rs +++ b/crates/hue/src/api/behavior.rs @@ -2,7 +2,7 @@ use std::ops::AddAssign; use serde::{Deserialize, Deserializer, Serialize}; use serde_json::Value; -use uuid::Uuid; +use uuid::{uuid, Uuid}; use super::DollarRef; @@ -19,6 +19,33 @@ pub struct BehaviorScript { 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, diff --git a/src/resource.rs b/src/resource.rs index f977c2443..0002fdc31 100644 --- a/src/resource.rs +++ b/src/resource.rs @@ -7,17 +7,17 @@ use maplit::btreeset; use serde_json::{json, Value}; use tokio::sync::broadcast::{Receiver, Sender}; use tokio::sync::Notify; -use uuid::{uuid, Uuid}; +use uuid::Uuid; use hue::api::{ - BehaviorInstanceUpdate, BehaviorScript, BehaviorScriptMetadata, Bridge, BridgeHome, Device, - DeviceArchetype, DeviceProductData, DeviceUpdate, DimmingUpdate, DollarRef, Entertainment, - EntertainmentConfiguration, EntertainmentConfigurationLocationsUpdate, - EntertainmentConfigurationStatus, EntertainmentConfigurationStreamProxyMode, - EntertainmentConfigurationStreamProxyUpdate, EntertainmentConfigurationUpdate, GroupedLight, - GroupedLightUpdate, Light, LightMode, LightUpdate, Metadata, On, RType, Resource, ResourceLink, - ResourceRecord, RoomUpdate, SceneUpdate, Stub, TimeZone, Update, ZigbeeConnectivity, - ZigbeeConnectivityStatus, ZigbeeDeviceDiscovery, + BehaviorInstanceUpdate, BehaviorScript, Bridge, BridgeHome, Device, DeviceArchetype, + DeviceProductData, DeviceUpdate, DimmingUpdate, Entertainment, EntertainmentConfiguration, + EntertainmentConfigurationLocationsUpdate, EntertainmentConfigurationStatus, + EntertainmentConfigurationStreamProxyMode, EntertainmentConfigurationStreamProxyUpdate, + EntertainmentConfigurationUpdate, GroupedLight, GroupedLightUpdate, Light, LightMode, + LightUpdate, Metadata, On, RType, Resource, ResourceLink, ResourceRecord, RoomUpdate, + SceneUpdate, Stub, TimeZone, Update, ZigbeeConnectivity, ZigbeeConnectivityStatus, + ZigbeeDeviceDiscovery, }; use hue::event::EventBlock; use hue::version::SwVersion; @@ -383,30 +383,11 @@ impl Resources { } pub fn add_behavior_scripts(&mut self) -> ApiResult<()> { - let wake_up_link = ResourceLink::new( - uuid!("ff8957e3-2eb9-4699-a0c8-ad2cb3ede704"), - RType::BehaviorScript, - ); - let wake_up = BehaviorScript { - 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(), - }; - self.add(&wake_up_link, Resource::BehaviorScript(wake_up))?; + let wake_up_link = ResourceLink::new(BehaviorScript::WAKE_UP_ID, RType::BehaviorScript); + self.add( + &wake_up_link, + Resource::BehaviorScript(BehaviorScript::wake_up()), + )?; Ok(()) } From 48dbbea84c685e836d671cc28c76b96273d6c439 Mon Sep 17 00:00:00 2001 From: Christian Duvholt Date: Mon, 21 Apr 2025 20:16:26 +0200 Subject: [PATCH 23/26] Add behavior_instance/{id} GET endpoint --- src/routes/clip/behavior_instance.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/routes/clip/behavior_instance.rs b/src/routes/clip/behavior_instance.rs index 545d7f8b9..788f1123e 100644 --- a/src/routes/clip/behavior_instance.rs +++ b/src/routes/clip/behavior_instance.rs @@ -1,6 +1,6 @@ use axum::{ extract::{Path, State}, - routing::put, + routing::{get, put}, Json, Router, }; use serde_json::Value; @@ -9,7 +9,7 @@ use uuid::Uuid; use hue::api::BehaviorInstance; use hue::api::{BehaviorInstanceUpdate, RType}; -use crate::routes::clip::{ApiV2Result, V2Reply}; +use crate::routes::clip::{generic, ApiV2Result, V2Reply}; use crate::server::appstate::AppState; async fn put_behavior_instance( @@ -35,6 +35,12 @@ async fn put_behavior_instance( V2Reply::ok(rlink) } +async fn get_resource_id(state: State, Path(id): Path) -> ApiV2Result { + generic::get_resource_id(state, Path((RType::BehaviorInstance, id))).await +} + pub fn router() -> Router { - Router::new().route("/{id}", put(put_behavior_instance)) + Router::new() + .route("/{id}", get(get_resource_id)) + .route("/{id}", put(put_behavior_instance)) } From 23aa411207380638daa7a807c81c214559d70d2c Mon Sep 17 00:00:00 2001 From: Christian Duvholt Date: Mon, 21 Apr 2025 20:32:35 +0200 Subject: [PATCH 24/26] Unmerge some imports --- src/routes/clip/behavior_instance.rs | 8 +++----- src/server/scheduler.rs | 23 +++++++++++++---------- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/src/routes/clip/behavior_instance.rs b/src/routes/clip/behavior_instance.rs index 788f1123e..10af661c5 100644 --- a/src/routes/clip/behavior_instance.rs +++ b/src/routes/clip/behavior_instance.rs @@ -1,8 +1,6 @@ -use axum::{ - extract::{Path, State}, - routing::{get, put}, - Json, Router, -}; +use axum::extract::{Path, State}; +use axum::routing::{get, put}; +use axum::{Json, Router}; use serde_json::Value; use uuid::Uuid; diff --git a/src/server/scheduler.rs b/src/server/scheduler.rs index fd8bb830d..8a804065e 100644 --- a/src/server/scheduler.rs +++ b/src/server/scheduler.rs @@ -1,21 +1,24 @@ use std::{iter, sync::Arc, time::Duration}; use chrono::{DateTime, Days, Local, NaiveTime, Timelike, Weekday}; -use tokio::{spawn, sync::Mutex, task::JoinHandle, time::sleep}; +use tokio::spawn; +use tokio::sync::Mutex; +use tokio::task::JoinHandle; +use tokio::time::sleep; use tokio_schedule::{every, Job}; use uuid::Uuid; -use hue::{ - api::{ - BehaviorInstance, BehaviorInstanceConfiguration, BehaviorInstanceUpdate, - GroupedLightUpdate, Light, LightEffectActionUpdate, LightEffectsV2Update, LightUpdate, On, - RType, Resource, ResourceLink, WakeupConfiguration, - }, - clamp::Clamp, - effect_duration::EffectDuration, +use hue::api::{ + BehaviorInstance, BehaviorInstanceConfiguration, BehaviorInstanceUpdate, GroupedLightUpdate, + Light, LightEffectActionUpdate, LightEffectsV2Update, LightUpdate, On, RType, Resource, + ResourceLink, WakeupConfiguration, }; +use hue::clamp::Clamp; +use hue::effect_duration::EffectDuration; -use crate::{backend::BackendRequest, error::ApiResult, resource::Resources}; +use crate::backend::BackendRequest; +use crate::error::ApiResult; +use crate::resource::Resources; #[derive(Debug)] pub struct Scheduler { From cda0c1e4f01b635be1000a3494ae798b95b93093 Mon Sep 17 00:00:00 2001 From: Christian Duvholt Date: Mon, 21 Apr 2025 20:49:33 +0200 Subject: [PATCH 25/26] Improve behavior instance serde models --- crates/hue/src/api/behavior.rs | 27 +++++++++++---------------- src/server/scheduler.rs | 6 +++--- 2 files changed, 14 insertions(+), 19 deletions(-) diff --git a/crates/hue/src/api/behavior.rs b/crates/hue/src/api/behavior.rs index 1330c7345..091c9f8d0 100644 --- a/crates/hue/src/api/behavior.rs +++ b/crates/hue/src/api/behavior.rs @@ -120,26 +120,22 @@ pub mod configuration { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct When { - #[serde(rename = "recurrence_days")] - pub recurrence_days: Option>, - #[serde(rename = "time_point")] + pub recurrence_days: Option>, pub time_point: TimePoint, } - impl When { - pub fn weekdays(&self) -> Option> { - self.recurrence_days - .as_ref() - .map(|days| days.iter().filter_map(|w| w.parse().ok()).collect()) - } + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + #[serde(tag = "type", rename_all = "snake_case")] + pub enum TimePoint { + Time { time: Time }, } - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] - pub struct TimePoint { - pub time: Time, - #[serde(rename = "type")] - // time - pub type_field: String, + impl TimePoint { + pub const fn time(&self) -> &Time { + match self { + Self::Time { time } => time, + } + } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -165,7 +161,6 @@ pub struct BehaviorInstanceUpdate { pub configuration: Option, pub enabled: Option, pub metadata: Option, - // trigger } impl BehaviorInstanceUpdate { diff --git a/src/server/scheduler.rs b/src/server/scheduler.rs index 8a804065e..428fec9ca 100644 --- a/src/server/scheduler.rs +++ b/src/server/scheduler.rs @@ -132,7 +132,7 @@ impl WakeupJob { } fn start_time(&self) -> Result { - let job_time = &self.configuration.when.time_point.time; + let job_time = self.configuration.when.time_point.time(); let scheduled_wakeup_time = NaiveTime::from_hms_opt(job_time.hour, job_time.minute, 0).ok_or("naive time")?; // although the scheduled time in the Hue app is the time when lights are at full brightness @@ -199,11 +199,11 @@ impl WakeupJob { } fn create_wake_up_jobs(resource_id: &Uuid, configuration: &WakeupConfiguration) -> Vec { - let weekdays = configuration.when.weekdays(); + let weekdays = configuration.when.recurrence_days.as_ref(); let schedule_types: Box> = weekdays.map_or_else( || Box::new(iter::once(ScheduleType::Once())) as Box>, - |weekdays| Box::new(weekdays.into_iter().map(ScheduleType::Recurring)), + |weekdays| Box::new(weekdays.iter().copied().map(ScheduleType::Recurring)), ); schedule_types .map(|schedule_type| WakeupJob { From 5132724e112cf07922a9503a6e7373523ff92e9a Mon Sep 17 00:00:00 2001 From: Christian Duvholt Date: Mon, 21 Apr 2025 20:59:36 +0200 Subject: [PATCH 26/26] Add wake-up style enum --- crates/hue/src/api/behavior.rs | 9 ++++++++- crates/hue/src/api/mod.rs | 1 + src/server/scheduler.rs | 4 ++-- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/crates/hue/src/api/behavior.rs b/crates/hue/src/api/behavior.rs index 091c9f8d0..ae168a6cd 100644 --- a/crates/hue/src/api/behavior.rs +++ b/crates/hue/src/api/behavior.rs @@ -93,12 +93,19 @@ pub struct WakeupConfiguration { #[serde(skip_serializing_if = "Option::is_none")] pub turn_lights_off_after: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub style: Option, + 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; diff --git a/crates/hue/src/api/mod.rs b/crates/hue/src/api/mod.rs index bfebdacec..edf7dc81f 100644 --- a/crates/hue/src/api/mod.rs +++ b/crates/hue/src/api/mod.rs @@ -14,6 +14,7 @@ mod update; 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}; diff --git a/src/server/scheduler.rs b/src/server/scheduler.rs index 428fec9ca..e7bb6c746 100644 --- a/src/server/scheduler.rs +++ b/src/server/scheduler.rs @@ -11,7 +11,7 @@ use uuid::Uuid; use hue::api::{ BehaviorInstance, BehaviorInstanceConfiguration, BehaviorInstanceUpdate, GroupedLightUpdate, Light, LightEffectActionUpdate, LightEffectsV2Update, LightUpdate, On, RType, Resource, - ResourceLink, WakeupConfiguration, + ResourceLink, WakeupConfiguration, WakeupStyle, }; use hue::clamp::Clamp; use hue::effect_duration::EffectDuration; @@ -304,7 +304,7 @@ impl WakeupRequest { Self::Group(_) => false, // todo: implement when grouped light support effects }; let use_sunrise_effect = - light_supports_effects && config.style == Some("sunrise".to_string()); + light_supports_effects && config.style == Some(WakeupStyle::Sunrise); if use_sunrise_effect { self.sunrise_on(&res, &config).await?;