From ecacb5bcdec8f483a6b81e098fb5de33dd00ac7c Mon Sep 17 00:00:00 2001 From: Alex Kinnane <17098249+akinnane@users.noreply.github.co> Date: Sun, 29 Mar 2026 17:17:47 +0100 Subject: [PATCH] Fix events. Add lightlevels. Add Motion devices. Fix events stream - Previously it would discard events due to the `.pop`. It now correctly collects all events in the message. Add Light Levels support Add Motion device support Clippy&Fix tests --- Cargo.toml | 2 +- src/bin/hue_register_user.rs | 2 +- src/bridge.rs | 299 ++++++++++++++++++++++++++++++----- src/lib.rs | 11 +- 4 files changed, 273 insertions(+), 41 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 9d3dc40..cf9cc10 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ edition = "2021" [dependencies] thiserror = "2.0.6" regex = "1.3" -reqwest = { version = "0.12.9", features = [ "json", "rustls-tls" ], default-features = false} +reqwest = { version = "0.12.9", features = [ "json", "rustls-tls", "http2" ], default-features = false} reqwest-eventsource = "0.6.0" tokio = { version = "1.42.0", features = ["rt", "rt-multi-thread", "macros"] } serde = { version = "1", features = ["derive"]} diff --git a/src/bin/hue_register_user.rs b/src/bin/hue_register_user.rs index c25a234..ca8b0d0 100644 --- a/src/bin/hue_register_user.rs +++ b/src/bin/hue_register_user.rs @@ -20,7 +20,7 @@ async fn main() { match r { Ok(r) => { eprint!("done: "); - println!("{}", r.application_key); + println!("application_key(username):{}\n", r.application_key,); break; } Err(HueError::BridgeError { code: 101, .. }) => { diff --git a/src/bridge.rs b/src/bridge.rs index 5675441..5a0d570 100644 --- a/src/bridge.rs +++ b/src/bridge.rs @@ -4,6 +4,9 @@ use reqwest::Method; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::HashMap; +use std::time::Duration; + +use crate::HueError; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ResourceIdentifier { @@ -16,6 +19,11 @@ pub struct Device { pub id: String, pub id_v1: Option, pub services: Vec, + pub metadata: DeviceMetadata, + pub product_data: DeviceProductData, + pub identify: Option, + pub usertest: Option, + pub device_mode: Option, } impl Device { @@ -31,6 +39,33 @@ impl Device { } } +/// Additional metadata including a user given name +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeviceMetadata { + /// Human readable name of a resource + pub name: String, + /// By default archetype given by manufacturer. Can be changed by user. + pub archetype: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeviceProductData { + /// unique identification of device model + pub model_id: String, + /// Name of device manufacturer + pub manufacturer_name: String, + /// Name of the product + pub product_name: String, + /// Archetype of the product + pub product_archetype: String, + /// This device is Hue certified + pub certified: bool, + /// Software version of the product + pub software_version: String, + /// Hardware type; identified by Manufacturer code and ImageType + pub hardware_platform_type: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LightMetadata { pub name: String, @@ -39,10 +74,6 @@ pub struct LightMetadata { pub function: String, } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct On { - pub on: bool, -} #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Dimming { @@ -63,9 +94,12 @@ pub struct ColorTemperature { pub mirek_schema: MirekSchema, } -#[derive(Debug, Clone, Serialize, Deserialize)] +/// CIE XY gamut position +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, PartialOrd)] pub struct XY { + // X position in color gamut pub x: f32, + // Y position in color gamut pub y: f32, } @@ -83,14 +117,26 @@ pub struct Color { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Light { + /// Unique identifier representing a specific resource instance pub id: String, + /// Clip v1 resource identifier pub id_v1: Option, + /// additional metadata including a user given name pub metadata: LightMetadata, + /// Service identification number. 0 indicates service of a single instance pub service_id: u32, pub on: On, pub dimming: Option, pub color_temperature: Option, pub color: Option, + pub owner: ResourceIdentifier, + pub r#type: String, + pub product_data: Option, + pub identify: Value, + pub dynamics: LightDynamics, + pub effects_v2: Option, + /// Deprecated: use effects_v2 + pub effects: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -99,6 +145,54 @@ pub struct Metadata { pub archetype: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LightDynamics { + /// Current status of the lamp with dynamics. + pub status: String, + /// Statuses in which a lamp could be when playing dynamics. + pub status_values: Vec, + /// speed of dynamic palette. The speed is only valid if the status is dynamic_palette. + pub speed: Value, + /// Indicates whether the value presented in speed is valid + pub speed_valid: bool, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct LightLevelGet { + /// Unique identifier representing a specific resource instance + pub id: String, + /// Clip v1 resource identifier + pub id_v1: Option, + /// Owner of the service, in case the owner service is deleted, the service also gets deleted + pub owner: ResourceIdentifier, + /// true when sensor is activated, false when deactivated + pub enabled: bool, + pub light: LightLevelLight, + /// Type of the supported resources + pub r#type: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct LightLevelLight { + /// Deprecated. Moved to light_level_report/light_level + pub light_level: usize, + /// Deprecated. Indication whether the value presented in light_level is valid + pub light_level_valid: bool, + pub light_level_report: LightLevelLightReport, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct LightLevelLightReport { + /// last time the value of this property is changed. + pub changed: String, + /// Light level in 10000*log10(lux) +1 measured by + /// sensor. Logarithmic scale used because the human eye adjusts + /// to light levels and small changes at low lux levels are more + /// noticeable than at high lux levels. This allows use of linear + /// scale configuration sliders. + pub light_level: usize, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Room { pub id: String, @@ -116,6 +210,55 @@ pub struct ResolvedRoom { pub children: Vec, pub services: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MotionGet { + /// Unique identifier representing a specific resource instance + pub id: String, + /// Clip v1 resource identifier + pub id_v1: Option, + /// Owner of the service, in case the owner service is deleted, the service also gets deleted + pub owner: ResourceIdentifier, + /// true when sensor is activated, false when deactivated + pub enabled: bool, + pub motion: Motion, + pub sensitivity: MotionSensitivity, + /// Type of the supported resources + pub r#type: Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Motion { + // #[deprecated] + // /// Deprecated. Moved to motion_report/motion. + // pub motion: Option, + // #[deprecated] + // /// Deprecated. Motion is valid when motion_report property is present, invalid when absent. + // pub motion_valid: Option, + pub motion_report: MotionReport, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MotionReport { + /// last time the value of this property is changed. + pub changed: String, + /// true if motion is detected + pub motion: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MotionSensitivity { + pub status: Value, + /// Sensitivity of the sensor. Value in the range 0 to sensitivity_max. + pub sensitivity: i32, + /// Maximum value of the sensitivity configuration attribute. + pub sensitivity_max: i32, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, PartialOrd)] +pub struct On { + /// On/Off state of the light on=true, off=false + pub on: bool, +} #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Zone { @@ -157,29 +300,35 @@ pub struct CommandScene { recall: SceneRecall, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, PartialOrd)] pub struct CommandLightDimming { pub brightness: f32, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, PartialOrd)] pub struct CommandLightColorTemperature { pub mirek: u16, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, PartialOrd)] pub struct CommandLightColor { pub xy: XY, } -#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, PartialOrd)] pub struct CommandLightDynamics { #[serde(skip_serializing_if = "Option::is_none")] duration: Option, #[serde(skip_serializing_if = "Option::is_none")] speed: Option, } -#[derive(Debug, Clone, Serialize, Deserialize, Default)] + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, PartialOrd)] +pub struct CommandLightEffectsV2Action { + pub effect: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, PartialOrd)] pub struct CommandLight { #[serde(skip_serializing_if = "Option::is_none")] pub on: Option, @@ -191,6 +340,8 @@ pub struct CommandLight { pub color: Option, #[serde(skip_serializing_if = "Option::is_none")] pub dynamics: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub effects_v2: Option, } impl CommandLight { @@ -237,6 +388,13 @@ impl CommandLight { ..self } } + + pub fn with_effect_v2(self, effect: String) -> Self { + Self { + effects_v2: Some(CommandLightEffectsV2Action { effect }), + ..self + } + } } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -245,14 +403,31 @@ pub struct EventColorTemperature { pub mirek_valid: bool, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EventContactReport { + pub changed: String, + pub state: EventContactReportState, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum EventContactReportState { + Contact, + NoContact, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Event { + pub r#type: String, pub id: String, pub id_v1: Option, pub on: Option, pub dimming: Option, pub color_temperature: Option, pub color: Option, + pub motion: Option, + pub owner: Option, + pub contact_report: Option, } /// An unauthenticated bridge is a bridge that has not @@ -304,21 +479,23 @@ impl UnauthBridge { devicetype: name.to_string(), }; let url = format!("https://{}/api", self.ip); - let resp: BridgeResponse> = self + let resp = self .client .post(&url) .json(&obtain) .send() .await? - .json() + .text() .await?; - let resp = resp.get()?; - let username = resp.success.username; + let resp = + serde_json::from_str::>>(&resp)?.get()?; + + let application_key = resp.success.username; Ok(Bridge { ip: self.ip, - client: create_reqwest_client(Some(&username)), - application_key: username, + client: create_reqwest_client(Some(&application_key)), + application_key, }) } } @@ -336,6 +513,9 @@ pub struct Bridge { fn create_reqwest_client(application_key: Option<&str>) -> reqwest::Client { reqwest::Client::builder() + .http2_prior_knowledge() + .http2_keep_alive_interval(Duration::from_secs(5)) + .http2_keep_alive_while_idle(true) // see https://developers.meethue.com/develop/application-design-guidance/using-https/ .add_root_certificate( reqwest::Certificate::from_pem( @@ -455,15 +635,19 @@ impl Bridge { devicetype: name.to_string(), }; let url = format!("https://{}/api", self.ip); - let resp: BridgeResponse> = self + let resp = self .client .post(&url) .json(&obtain) .send() .await? - .json() + .text() .await?; - let resp = resp.get()?; + + dbg!(&resp); + + let resp = + serde_json::from_str::>>(&resp)?.get()?; Ok(Bridge { ip: self.ip, @@ -514,6 +698,12 @@ impl Bridge { /// } /// # }) /// ``` + pub async fn get_light(&self, id: &str) -> crate::Result { + let url = format!("https://{}/clip/v2/resource/light/{}", self.ip, id); + let resp: Light = self.client.get(&url).send().await?.json().await?; + Ok(resp) + } + pub async fn get_all_lights(&self) -> crate::Result> { let url = format!("https://{}/clip/v2/resource/light", self.ip); let resp: BridgeResponseV2 = self.client.get(&url).send().await?.json().await?; @@ -552,6 +742,14 @@ impl Bridge { Ok(groups) } + pub async fn get_all_motion(&self) -> crate::Result> { + let url = format!("https://{}/clip/v2/resource/motion", self.ip); + let resp: BridgeResponseV2 = self.client.get(&url).send().await?.json().await?; + let mut motions = resp.get()?; + motions.sort_by(|a, b| a.id.cmp(&b.id)); + Ok(motions) + } + pub async fn resolve_all_rooms(&self) -> crate::Result> { let rooms = self.get_all_rooms().await?; @@ -680,16 +878,26 @@ impl Bridge { pub async fn set_light_state(&self, light: &str, command: &CommandLight) -> crate::Result<()> { let url = format!("https://{}/clip/v2/resource/light/{}", self.ip, light); - let resp: BridgeResponseV2 = self - .client - .put(&url) - .json(&command) - .send() - .await? - .json() - .await?; - resp.get()?; - Ok(()) + let resp = self.client.put(&url).json(&command).send().await?; + if resp.status().is_success() { + let resp: BridgeResponseV2 = resp.json().await?; + resp.get()?; + Ok(()) + } else { + match resp.error_for_status() { + Ok(_) => todo!(), + Err(err) => Err(HueError::Reqwest(err)), + } + } + } + + pub async fn get_all_light_levels(&self) -> crate::Result> { + let url = format!("https://{}/clip/v2/resource/light_level", self.ip); + let resp: BridgeResponseV2 = + self.client.get(&url).send().await?.json().await?; + let mut light_levels = resp.get()?; + light_levels.sort_by(|a, b| a.id.cmp(&b.id)); + Ok(light_levels) } pub fn events(&self) -> crate::Result> { @@ -704,14 +912,33 @@ impl Bridge { Ok(reqwest_eventsource::Event::Message(msg)) => { log::debug!("message {:?}", msg.data); match serde_json::from_str::>(&msg.data) { - Ok(mut event) => Some(HueEvent::Event { - data: event.pop().unwrap().data, - }), - Err(e) => Some(HueEvent::Error(format!("{:?}", e))), + Ok(events) => { + log::debug!("events: {:?}", events); + let data = events + .into_iter() + .flat_map(|event_envelope| event_envelope.data.into_iter()) + .collect(); + Some(HueEvent::Event { data }) + } + Err(e) => { + let err = format!( + "serde_json::from_str::>() error: {:?}\n{}", + e, &msg.data + ); + log::error!("{}", err); + Some(HueEvent::Error(err)) + } } } - Ok(reqwest_eventsource::Event::Open) => None, - Err(e) => Some(HueEvent::Error(format!("{:?}", e))), + Ok(reqwest_eventsource::Event::Open) => { + log::info!("reqwest_eventsource opened"); + None + } + Err(e) => { + let err = format!("reqwest_eventsource error{:?}", e); + log::error!("{}", err); + Some(HueEvent::Error(err)) + } } }), ) @@ -763,7 +990,7 @@ struct BridgeErrorV2 { } #[derive(Debug, serde::Deserialize)] -struct BridgeResponseV2 { +pub struct BridgeResponseV2 { errors: Vec, data: Vec, } diff --git a/src/lib.rs b/src/lib.rs index 12e13af..ff91c28 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,7 @@ //! ```no_run //! # tokio_test::block_on(async { //! let bridge = hueclient::Bridge::discover_required() +//! .await //! .register_application("mycomputer") // Press the bridge before running this //! .await //! .unwrap(); @@ -14,16 +15,20 @@ //! ``` //! ### Second run //! ```no_run +//! # tokio_test::block_on(async { //! const USERNAME: &str = "the username that was generated in the previous example"; //! let bridge = hueclient::Bridge::discover_required() -//! .with_user(USERNAME); +//! .await +//! .with_user(USERNAME); +//! # }) //! ``` //! ### Good night //! ```no_run //! # tokio_test::block_on(async { //! # const USERNAME: &str = "the username that was generated in the previous example"; -//! # let bridge = hueclient::Bridge::discover_required() -//! # .with_user(USERNAME); +//! # let bridge = hueclient::Bridge::discover_required() +//! # .await +//! # .with_user(USERNAME); //! let cmd = hueclient::CommandLight::default().off(); //! for light in &bridge.get_all_lights().await.unwrap() { //! bridge.set_light_state(&light.id, &cmd).await.unwrap();