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 new file mode 100644 index 000000000..ae168a6cd --- /dev/null +++ b/crates/hue/src/api/behavior.rs @@ -0,0 +1,218 @@ +use std::ops::AddAssign; + +use serde::{Deserialize, Deserializer, Serialize}; +use serde_json::Value; +use uuid::{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, +} + +impl BehaviorScript { + pub const WAKE_UP_ID: Uuid = uuid!("ff8957e3-2eb9-4699-a0c8-ad2cb3ede704"); + + #[must_use] + pub fn wake_up() -> Self { + Self { + configuration_schema: DollarRef { + dref: Some("basic_wake_up_config.json#".to_string()), + }, + description: + "Get your body in the mood to wake up by fading on the lights in the morning." + .to_string(), + max_number_instances: None, + metadata: BehaviorScriptMetadata { + name: "Basic wake up routine".to_string(), + category: "automation".to_string(), + }, + state_schema: DollarRef { dref: None }, + supported_features: vec!["style_sunrise".to_string(), "intensity".to_string()], + trigger_schema: DollarRef { + dref: Some("trigger.json#".to_string()), + }, + version: "0.0.1".to_string(), + } + } +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct BehaviorScriptMetadata { + pub name: String, + pub category: String, +} + +fn deserialize_optional_field<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + Ok(Some(Value::deserialize(deserializer)?)) +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct BehaviorInstance { + #[serde(default)] + pub dependees: Vec, + 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( + 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, PartialEq)] +#[serde(untagged)] +pub enum BehaviorInstanceConfiguration { + Wakeup(WakeupConfiguration), +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct WakeupConfiguration { + pub end_brightness: f64, + pub fade_in_duration: configuration::Duration, + #[serde(skip_serializing_if = "Option::is_none")] + pub turn_lights_off_after: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub style: Option, + pub when: configuration::When, + #[serde(rename = "where")] + pub where_field: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum WakeupStyle { + Sunrise, + Basic, +} + +pub mod configuration { + use std::time::Duration as StdDuration; + + use chrono::Weekday; + use serde::{Deserialize, Serialize}; + + use crate::api::ResourceLink; + + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + pub struct Duration { + pub seconds: u32, + } + + impl Duration { + pub fn to_std(&self) -> StdDuration { + StdDuration::from_secs(self.seconds.into()) + } + } + + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + pub struct When { + pub recurrence_days: Option>, + pub time_point: TimePoint, + } + + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + #[serde(tag = "type", rename_all = "snake_case")] + pub enum TimePoint { + Time { time: Time }, + } + + impl TimePoint { + pub const fn time(&self) -> &Time { + match self { + Self::Time { time } => time, + } + } + } + + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + pub struct Time { + pub hour: u32, + pub minute: u32, + } + + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + pub struct Where { + pub group: ResourceLink, + pub items: Option>, + } +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +pub struct BehaviorInstanceMetadata { + pub name: String, +} + +#[derive(Debug, Default, Serialize, Deserialize, Clone)] +pub struct BehaviorInstanceUpdate { + pub configuration: Option, + pub enabled: Option, + pub metadata: Option, +} + +impl BehaviorInstanceUpdate { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + #[must_use] + pub fn with_metadata(self, metadata: BehaviorInstanceMetadata) -> Self { + Self { + metadata: Some(metadata), + ..self + } + } + + #[must_use] + pub fn with_enabled(self, enabled: bool) -> Self { + Self { + enabled: Some(enabled), + ..self + } + } + + #[must_use] + pub fn with_configuration(self, configuration: BehaviorInstanceConfiguration) -> 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/grouped_light.rs b/crates/hue/src/api/grouped_light.rs index 6d94846e4..2a4f46491 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,12 @@ impl GroupedLightUpdate { ..self } } + + #[must_use] + 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..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 { @@ -579,6 +580,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 +641,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/crates/hue/src/api/mod.rs b/crates/hue/src/api/mod.rs index 42798326f..edf7dc81f 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,11 @@ mod stream; mod stubs; 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}; pub use entertainment_config::{ @@ -43,12 +49,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/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/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/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/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..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); } @@ -883,6 +884,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); @@ -957,6 +959,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/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 f9f37b066..0002fdc31 100644 --- a/src/resource.rs +++ b/src/resource.rs @@ -10,13 +10,14 @@ use tokio::sync::Notify; use uuid::Uuid; use hue::api::{ - 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, + 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; @@ -54,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<()> { @@ -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())), } } @@ -197,7 +208,7 @@ impl Resources { .hue_event(EventBlock::update(id, id_v1, delta)?); } - self.state_updates.notify_one(); + self.state_updates.notify_waiters(); Ok(()) } @@ -247,7 +258,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)?)?); @@ -262,7 +273,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)?; @@ -371,6 +382,16 @@ impl Resources { Ok(()) } + pub fn add_behavior_scripts(&mut self) -> ApiResult<()> { + let wake_up_link = ResourceLink::new(BehaviorScript::WAKE_UP_ID, RType::BehaviorScript); + self.add( + &wake_up_link, + Resource::BehaviorScript(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..10af661c5 --- /dev/null +++ b/src/routes/clip/behavior_instance.rs @@ -0,0 +1,44 @@ +use axum::extract::{Path, State}; +use axum::routing::{get, put}; +use axum::{Json, Router}; +use serde_json::Value; +use uuid::Uuid; + +use hue::api::BehaviorInstance; +use hue::api::{BehaviorInstanceUpdate, RType}; + +use crate::routes::clip::{generic, 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) +} + +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}", get(get_resource_id)) + .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()) } 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..e7bb6c746 --- /dev/null +++ b/src/server/scheduler.rs @@ -0,0 +1,409 @@ +use std::{iter, sync::Arc, time::Duration}; + +use chrono::{DateTime, Days, Local, NaiveTime, Timelike, Weekday}; +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, WakeupStyle, +}; +use hue::clamp::Clamp; +use hue::effect_duration::EffectDuration; + +use crate::backend::BackendRequest; +use crate::error::ApiResult; +use crate::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(|ScheduleBehaviorInstance(_, bi)| bi.enabled) + .flat_map(|ScheduleBehaviorInstance(id, bi)| match &bi.configuration { + BehaviorInstanceConfiguration::Wakeup(wakeup_configuration) => { + wakeup(self.res.clone(), id, wakeup_configuration) + } + }) + .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(ScheduleBehaviorInstance(r.id, behavior_instance)) + } + _ => None, + }) + .collect() + } +} + +fn wakeup( + res: Arc>, + id: &Uuid, + wakeup_configuration: &WakeupConfiguration, +) -> Vec> { + let jobs = create_wake_up_jobs(id, wakeup_configuration); + jobs.into_iter() + .map(move |job| spawn(job.create(res.clone()))) + .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(Debug)] +enum ScheduleType { + Recurring(Weekday), + Once(), +} + +pub struct WakeupJob { + resource_id: Uuid, + schedule_type: ScheduleType, + configuration: WakeupConfiguration, +} + +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 => { + return Err("with time"); + } + }; + let wakeup_datetime = if next < now { + next.checked_add_days(Days::new(1)).ok_or("add day")? + } else { + next + }; + Ok(wakeup_datetime) + } + + fn start_time(&self) -> Result { + 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 + // 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) + } + + async fn create(self, res: Arc>) { + log::debug!( + "Created new behavior instance job: {:?}", + self.configuration + ); + let now = Local::now(); + let result = match &self.schedule_type { + 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); + } + } + + 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 { + 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.iter().copied().map(ScheduleType::Recurring)), + ); + 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>) { + log::debug!("Running scheduled behavior instance:, {:#?}", config); + #[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] + } + }); + + 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) => { + log::warn!("Failed to get resource: {}", err); + None + } + } + }) + .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 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); + } + } + + // 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(duration.to_std()).await; + + for request in &requests { + if let Err(err) = request.off(res.clone()).await { + log::warn!("Failed to turn off wake up light: {}", err); + } + } + } +} + +enum WakeupRequest { + Light(ResourceLink), + Group(ResourceLink), +} + +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(WakeupStyle::Sunrise); + + 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; + + // 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(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(0.0)) + .with_color_temperature(Some(WAKEUP_FADE_MIREK)); + BackendRequest::GroupedLightUpdate(*resource_link, payload) + } + }; + res.lock().await.backend_request(reset_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)?; + 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)); + 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(effect_duration.0)), + }, + }), + }); + res.lock() + .await + .backend_request(BackendRequest::LightUpdate(*resource_link, payload))?; + } + Self::Group(_resource_link) => {} + }; + Ok(()) + } + + 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) + } + }; + + res.lock().await.backend_request(backend_request) + } +}