diff --git a/crates/z2m/src/api.rs b/crates/z2m/src/api.rs index 8818f565..20931189 100644 --- a/crates/z2m/src/api.rs +++ b/crates/z2m/src/api.rs @@ -121,6 +121,52 @@ pub struct DeviceRemove { pub id: String, } +#[derive(Clone, Debug, Default, Serialize)] +pub struct DeviceRead { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub state: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub color: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub brightness: Option, +} + +impl DeviceRead { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + fn to_field_value(changed: bool) -> Option { + // https://www.zigbee2mqtt.io/guide/usage/mqtt_topics_and_messages.html#zigbee2mqtt-friendly-name-get + if changed { Some("".to_string()) } else { None } + } + + #[must_use] + pub fn with_state(self, on_changed: bool) -> Self { + Self { + state: Self::to_field_value(on_changed), + ..self + } + } + + #[must_use] + pub fn with_color(self, color_changed: bool) -> Self { + Self { + color: Self::to_field_value(color_changed), + ..self + } + } + + #[must_use] + pub fn with_brightness(self, brightness_changed: bool) -> Self { + Self { + brightness: Self::to_field_value(brightness_changed), + ..self + } + } +} + #[derive(Clone, Debug, Serialize, Deserialize)] pub struct DeviceRemoveResponse { pub id: String, diff --git a/crates/z2m/src/request.rs b/crates/z2m/src/request.rs index def0bfdb..9995fb7b 100644 --- a/crates/z2m/src/request.rs +++ b/crates/z2m/src/request.rs @@ -1,7 +1,7 @@ use serde::Serialize; use serde_json::Value; -use crate::api::{DeviceRemove, GroupMemberChange, PermitJoin}; +use crate::api::{DeviceRead, DeviceRemove, GroupMemberChange, PermitJoin}; use crate::update::DeviceUpdate; #[derive(Clone, Debug, Serialize)] @@ -48,6 +48,9 @@ pub enum Z2mRequest<'a> { #[serde(untagged)] Update(&'a DeviceUpdate), + #[serde(untagged)] + DeviceRead(&'a DeviceRead), + // same as Z2mRequest::Raw, but allows us to suppress logging for these #[serde(untagged)] EntertainmentFrame(Value), diff --git a/src/backend/z2m/backend_event.rs b/src/backend/z2m/backend_event.rs index e7dff886..4b68eed2 100644 --- a/src/backend/z2m/backend_event.rs +++ b/src/backend/z2m/backend_event.rs @@ -9,13 +9,14 @@ use uuid::Uuid; use bifrost_api::backend::BackendRequest; use hue::api::{ - Entertainment, EntertainmentConfiguration, GroupedLight, GroupedLightUpdate, Light, - LightEffectsV2Update, LightGradientMode, LightUpdate, RType, Resource, ResourceLink, Room, - RoomUpdate, Scene, SceneActive, SceneStatus, SceneStatusEnum, SceneUpdate, - ZigbeeDeviceDiscoveryUpdate, + ColorTemperatureUpdate, Entertainment, EntertainmentConfiguration, GroupedLight, + GroupedLightUpdate, Light, LightEffectsV2Update, LightGradientMode, LightUpdate, RType, + Resource, ResourceLink, Room, RoomUpdate, Scene, SceneActive, SceneStatus, SceneStatusEnum, + SceneUpdate, ZigbeeDeviceDiscoveryUpdate, }; use hue::error::HueError; use hue::stream::HueStreamLightsV2; +use z2m::api::DeviceRead; use z2m::update::{DeviceEffect, DeviceUpdate}; use crate::backend::z2m::Z2mBackend; @@ -29,6 +30,22 @@ impl Z2mBackend { fn make_hue_specific_update(upd: &LightUpdate) -> ApiResult { let mut hz = HueZigbeeUpdate::new(); + if let Some(on) = &upd.on { + hz = hz.with_on_off(on.on); + } + + if let Some(br) = &upd.dimming { + hz = hz.with_brightness((br.brightness / 100.0).unit_to_u8_clamped_light()); + } + + if let Some(ColorTemperatureUpdate { mirek: Some(mirek) }) = upd.color_temperature { + hz = hz.with_color_mirek(mirek); + } + + if let Some(xy) = &upd.color { + hz = hz.with_color_xy(xy.xy); + } + if let Some(grad) = &upd.gradient { hz = hz.with_gradient_colors( grad.mode.map_or(GradientStyle::Linear, Into::into), @@ -102,36 +119,17 @@ impl Z2mBackend { let hue_effects = lock.get::(link)?.effects.is_some(); drop(lock); - /* step 1: send generic light update */ - let transition = upd - .dynamics - .as_ref() - .and_then(|d| d.duration.map(|duration| f64::from(duration) / 1000.0)) - .or_else(|| { - if upd.dimming.is_some() || upd.color_temperature.is_some() || upd.color.is_some() { - Some(0.4) - } else { - None - } - }); - let mut payload = DeviceUpdate::default() - .with_state(upd.on.map(|on| on.on)) - .with_brightness(upd.dimming.map(|dim| dim.brightness / 100.0 * 254.0)) - .with_color_temp(upd.color_temperature.and_then(|ct| ct.mirek)) - .with_color_xy(upd.color.map(|col| col.xy)) - .with_transition(transition); - - // We don't want to send gradient updates twice, but if hue - // effects are not supported for this light, this is the best - // (and only) way to do it - if !hue_effects { - payload = payload.with_gradient(upd.gradient.clone()); - } + let mut payload: Option = None; // handle "identify" request (light breathing) if upd.identify.is_some() { - // update immediate payload with breathe effect - payload = payload.with_effect(DeviceEffect::Breathe); + // handle "identify" request (light breathing) + + payload = Some( + payload + .unwrap_or_default() + .with_effect(DeviceEffect::Breathe), + ); let tx = self.message_tx.clone(); let topic = topic.clone(); @@ -145,17 +143,63 @@ impl Z2mBackend { }); } - z2mws.send_update(topic, &payload).await?; + if !hue_effects { + /* send generic light update */ + let transition = upd + .dynamics + .as_ref() + .and_then(|d| d.duration.map(|duration| f64::from(duration) / 1000.0)) + .or_else(|| { + if upd.dimming.is_some() + || upd.color_temperature.is_some() + || upd.color.is_some() + { + Some(0.4) + } else { + None + } + }); + payload = Some( + payload + .unwrap_or_default() + .with_state(upd.on.map(|on| on.on)) + .with_brightness(upd.dimming.map(|dim| dim.brightness / 100.0 * 254.0)) + .with_color_temp(upd.color_temperature.and_then(|ct| ct.mirek)) + .with_color_xy(upd.color.map(|col| col.xy)) + .with_transition(transition) + // We don't want to send gradient updates twice, but if hue + // effects are not supported for this light, this is the best + // (and only) way to do it + .with_gradient(upd.gradient.clone()), + ); + }; - /* step 2: if supported (and needed) send hue-specific effects update */ + if let Some(payload) = payload { + z2mws.send_update(topic, &payload).await?; + } + /* if supported send hue-specific effects update */ if hue_effects { let mut hz = Self::make_hue_specific_update(upd)?; if !hz.is_empty() { hz = hz.with_fade_speed(0x0001); + let read_payload = DeviceRead::default() + .with_state( + hz.onoff.is_some() || hz.brightness.is_some() || hz.effect_type.is_some(), + ) + .with_color( + hz.color_mirek.is_some() + || hz.color_xy.is_some() + || hz.effect_type.is_some(), + ) + .with_brightness(hz.brightness.is_some() || hz.effect_type.is_some()); + z2mws.send_hue_effects(topic, hz).await?; + + // Do an explicit attribute read since Hue specific updates do not automatically update z2m state + z2mws.send_read(&topic, &read_payload).await?; } } diff --git a/src/backend/z2m/websocket.rs b/src/backend/z2m/websocket.rs index e8449c17..6431826e 100644 --- a/src/backend/z2m/websocket.rs +++ b/src/backend/z2m/websocket.rs @@ -6,7 +6,7 @@ use hue::zigbee::{HueZigbeeUpdate, ZigbeeMessage}; use tokio::net::TcpStream; use tokio_tungstenite::tungstenite::{self, Message}; use tokio_tungstenite::{MaybeTlsStream, WebSocketStream}; -use z2m::api::{DeviceRemove, GroupMemberChange, PermitJoin}; +use z2m::api::{DeviceRead, DeviceRemove, GroupMemberChange, PermitJoin}; use z2m::request::Z2mPayload; use z2m::update::DeviceUpdate; use z2m::{api::RawMessage, request::Z2mRequest}; @@ -55,6 +55,10 @@ impl Z2mWebSocket { topic: "bridge/request/device/remove".into(), payload: serde_json::to_value(dev)?, }, + Z2mRequest::DeviceRead(read) => RawMessage { + topic: format!("{topic}/get"), + payload: serde_json::to_value(read)?, + }, _ => RawMessage { topic: format!("{topic}/set"), payload: serde_json::to_value(payload)?, @@ -96,6 +100,12 @@ impl Z2mWebSocket { self.send(topic, &z2mreq).await } + pub async fn send_read(&mut self, topic: &str, payload: &DeviceRead) -> ApiResult<()> { + let z2mreq = Z2mRequest::DeviceRead(payload); + + self.send(topic, &z2mreq).await + } + pub async fn send_group_member_add( &mut self, topic: &str,