Skip to content

Commit 8ff6a4e

Browse files
authored
Merge pull request #946 from eetu/hue-motion-toggle
Toggle Hue motion sensors from UI
2 parents 9e4fe3f + 45a4e1e commit 8ff6a4e

10 files changed

Lines changed: 370 additions & 103 deletions

File tree

‎backend/src/cache.rs‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,13 @@ impl<T: Clone> Cache<T> {
5050
});
5151
}
5252

53+
pub async fn invalidate(&self, key: &str) {
54+
let mut guard = self.inner.write().await;
55+
if guard.as_ref().is_some_and(|entry| entry.key == key) {
56+
*guard = None;
57+
}
58+
}
59+
5360
pub async fn has_data(&self) -> bool {
5461
self.inner.read().await.is_some()
5562
}

‎backend/src/hue/data.rs‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,15 +63,15 @@ async fn fetch_from_bridge(state: &Arc<AppState>) -> Result<HueResponse, HueErro
6363
.collect();
6464

6565
// device ID → motion data
66-
let motion_by_device: HashMap<&str, (bool, Option<&str>)> = motions
66+
let motion_by_device: HashMap<&str, (bool, Option<&str>, bool)> = motions
6767
.data
6868
.iter()
6969
.map(|m| {
7070
let (motion, updated_at) = match &m.motion.motion_report {
7171
Some(report) => (report.motion, Some(report.changed.as_str())),
7272
None => (m.motion.motion.unwrap_or(false), None),
7373
};
74-
(m.owner.rid.as_str(), (motion, updated_at))
74+
(m.owner.rid.as_str(), (motion, updated_at, m.enabled))
7575
})
7676
.collect();
7777

@@ -126,8 +126,9 @@ async fn fetch_from_bridge(state: &Arc<AppState>) -> Result<HueResponse, HueErro
126126
room_type,
127127
enabled: temp.enabled,
128128
battery,
129-
motion: motion_data.map(|(m, _)| *m),
130-
motion_updated_at: motion_data.and_then(|(_, u)| u.map(String::from)),
129+
motion: motion_data.map(|(m, _, _)| *m),
130+
motion_updated_at: motion_data.and_then(|(_, u, _)| u.map(String::from)),
131+
motion_enabled: motion_data.map(|(_, _, e)| *e),
131132
connected: connected_by_device
132133
.get(temp.owner.rid.as_str())
133134
.copied()

‎backend/src/hue/events.rs‎

Lines changed: 99 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ pub enum HueLiveEvent {
3232
#[serde(rename = "updatedAt")]
3333
updated_at: String,
3434
},
35+
MotionEnabled {
36+
#[serde(rename = "deviceId")]
37+
device_id: String,
38+
enabled: bool,
39+
},
3540
Connectivity {
3641
#[serde(rename = "deviceId")]
3742
device_id: String,
@@ -100,7 +105,7 @@ async fn stream_events(state: &Arc<AppState>) -> Result<(), Box<dyn std::error::
100105
continue;
101106
}
102107
for resource in envelope.data {
103-
if let Some(event) = parse_resource_update(&resource) {
108+
for event in parse_resource_update(&resource) {
104109
let _ = state.hue_events_tx.send(event);
105110
}
106111
}
@@ -117,52 +122,103 @@ struct EventEnvelope {
117122
data: Vec<serde_json::Value>,
118123
}
119124

120-
fn parse_resource_update(resource: &serde_json::Value) -> Option<HueLiveEvent> {
121-
let rtype = resource.get("type")?.as_str()?;
125+
fn parse_resource_update(resource: &serde_json::Value) -> Vec<HueLiveEvent> {
126+
let Some(rtype) = resource.get("type").and_then(|v| v.as_str()) else {
127+
return Vec::new();
128+
};
122129
match rtype {
123-
"grouped_light" => {
124-
let on = resource.get("on")?.get("on")?.as_bool()?;
125-
let id = resource.get("id")?.as_str()?.to_string();
126-
Some(HueLiveEvent::GroupedLight { id, on })
127-
}
128-
"temperature" => {
129-
let temp_obj = resource.get("temperature")?;
130-
let temperature = temp_obj
131-
.get("temperature_report")
132-
.and_then(|r| r.get("temperature"))
133-
.or_else(|| temp_obj.get("temperature"))?
134-
.as_f64()?;
135-
let id = resource.get("id")?.as_str()?.to_string();
136-
Some(HueLiveEvent::Temperature { id, temperature })
137-
}
138-
"device_power" => {
139-
let battery = resource
140-
.get("power_state")?
141-
.get("battery_level")?
142-
.as_u64()? as u8;
143-
let device_id = resource.get("owner")?.get("rid")?.as_str()?.to_string();
144-
Some(HueLiveEvent::DevicePower { device_id, battery })
145-
}
146-
"motion" => {
147-
let report = resource.get("motion")?.get("motion_report")?;
148-
let motion = report.get("motion")?.as_bool()?;
149-
let updated_at = report.get("changed")?.as_str()?.to_string();
150-
let device_id = resource.get("owner")?.get("rid")?.as_str()?.to_string();
151-
Some(HueLiveEvent::Motion {
152-
device_id,
153-
motion,
154-
updated_at,
130+
"grouped_light" => resource
131+
.get("on")
132+
.and_then(|o| o.get("on"))
133+
.and_then(|o| o.as_bool())
134+
.zip(resource.get("id").and_then(|v| v.as_str()))
135+
.map(|(on, id)| {
136+
vec![HueLiveEvent::GroupedLight {
137+
id: id.to_string(),
138+
on,
139+
}]
155140
})
156-
}
157-
"zigbee_connectivity" => {
158-
let status = resource.get("status")?.as_str()?;
159-
let device_id = resource.get("owner")?.get("rid")?.as_str()?.to_string();
160-
Some(HueLiveEvent::Connectivity {
161-
device_id,
162-
connected: status == "connected",
141+
.unwrap_or_default(),
142+
"temperature" => resource
143+
.get("temperature")
144+
.and_then(|temp_obj| {
145+
temp_obj
146+
.get("temperature_report")
147+
.and_then(|r| r.get("temperature"))
148+
.or_else(|| temp_obj.get("temperature"))
149+
.and_then(|t| t.as_f64())
163150
})
151+
.zip(resource.get("id").and_then(|v| v.as_str()))
152+
.map(|(temperature, id)| {
153+
vec![HueLiveEvent::Temperature {
154+
id: id.to_string(),
155+
temperature,
156+
}]
157+
})
158+
.unwrap_or_default(),
159+
"device_power" => resource
160+
.get("power_state")
161+
.and_then(|s| s.get("battery_level"))
162+
.and_then(|b| b.as_u64())
163+
.zip(
164+
resource
165+
.get("owner")
166+
.and_then(|o| o.get("rid"))
167+
.and_then(|r| r.as_str()),
168+
)
169+
.map(|(battery, device_id)| {
170+
vec![HueLiveEvent::DevicePower {
171+
device_id: device_id.to_string(),
172+
battery: battery as u8,
173+
}]
174+
})
175+
.unwrap_or_default(),
176+
"motion" => {
177+
let Some(device_id) = resource
178+
.get("owner")
179+
.and_then(|o| o.get("rid"))
180+
.and_then(|r| r.as_str())
181+
else {
182+
return Vec::new();
183+
};
184+
let mut events = Vec::new();
185+
if let Some(enabled) = resource.get("enabled").and_then(|e| e.as_bool()) {
186+
events.push(HueLiveEvent::MotionEnabled {
187+
device_id: device_id.to_string(),
188+
enabled,
189+
});
190+
}
191+
if let Some(report) = resource.get("motion").and_then(|m| m.get("motion_report")) {
192+
if let (Some(motion), Some(updated_at)) = (
193+
report.get("motion").and_then(|m| m.as_bool()),
194+
report.get("changed").and_then(|c| c.as_str()),
195+
) {
196+
events.push(HueLiveEvent::Motion {
197+
device_id: device_id.to_string(),
198+
motion,
199+
updated_at: updated_at.to_string(),
200+
});
201+
}
202+
}
203+
events
164204
}
165-
_ => None,
205+
"zigbee_connectivity" => resource
206+
.get("status")
207+
.and_then(|s| s.as_str())
208+
.zip(
209+
resource
210+
.get("owner")
211+
.and_then(|o| o.get("rid"))
212+
.and_then(|r| r.as_str()),
213+
)
214+
.map(|(status, device_id)| {
215+
vec![HueLiveEvent::Connectivity {
216+
device_id: device_id.to_string(),
217+
connected: status == "connected",
218+
}]
219+
})
220+
.unwrap_or_default(),
221+
_ => Vec::new(),
166222
}
167223
}
168224

‎backend/src/hue/handlers.rs‎

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ use crate::AppState;
1212
use super::client::{hue_fetch, hue_put};
1313
use super::data::fetch_hue_data;
1414
use super::events::subscribe;
15-
use super::models::GroupedLightResource;
15+
use super::models::{GroupedLightResource, MotionResource};
1616

1717
// ---- GET /api/hue ----
1818

@@ -203,3 +203,61 @@ pub async fn toggle_group(
203203
}
204204
}
205205
}
206+
207+
// ---- POST /api/hue/toggleMotion/{deviceId} ----
208+
209+
#[utoipa::path(
210+
post,
211+
path = "/api/hue/toggleMotion/{deviceId}",
212+
params(("deviceId" = String, Path, description = "Hue device ID owning the motion service")),
213+
responses(
214+
(status = 200, description = "Motion sensor toggled"),
215+
(status = 404, description = "Motion service not found for device"),
216+
(status = 502, description = "Failed to toggle motion sensor")
217+
)
218+
)]
219+
pub async fn toggle_motion(
220+
state: web::Data<Arc<AppState>>,
221+
path: web::Path<String>,
222+
) -> HttpResponse {
223+
let device_id = path.into_inner();
224+
225+
let motions = match hue_fetch::<MotionResource>(&state, "/clip/v2/resource/motion").await {
226+
Ok(res) => res,
227+
Err(e) => {
228+
tracing::error!("Failed to list motion services: {e}");
229+
return HttpResponse::BadGateway().json(serde_json::json!({"error": e.to_string()}));
230+
}
231+
};
232+
233+
let Some(service) = motions.data.iter().find(|m| m.owner.rid == device_id) else {
234+
return HttpResponse::NotFound()
235+
.json(serde_json::json!({"error": "Motion service not found for device"}));
236+
};
237+
238+
let new_enabled = !service.enabled;
239+
let body = serde_json::json!({"enabled": new_enabled});
240+
241+
match hue_put(
242+
&state,
243+
&format!("/clip/v2/resource/motion/{}", service.id),
244+
&body,
245+
)
246+
.await
247+
{
248+
Ok(()) => {
249+
state.hue_cache.invalidate("hue").await;
250+
let _ = state
251+
.hue_events_tx
252+
.send(super::events::HueLiveEvent::MotionEnabled {
253+
device_id,
254+
enabled: new_enabled,
255+
});
256+
HttpResponse::Ok().json(serde_json::json!({"enabled": new_enabled}))
257+
}
258+
Err(e) => {
259+
tracing::error!("Failed to toggle motion {}: {e}", service.id);
260+
HttpResponse::BadGateway().json(serde_json::json!({"error": e.to_string()}))
261+
}
262+
}
263+
}

‎backend/src/hue/models.rs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ pub struct Sensor {
2626
pub motion: Option<bool>,
2727
#[serde(skip_serializing_if = "Option::is_none")]
2828
pub motion_updated_at: Option<String>,
29+
#[serde(skip_serializing_if = "Option::is_none")]
30+
pub motion_enabled: Option<bool>,
2931
pub connected: bool,
3032
}
3133

@@ -136,7 +138,9 @@ pub struct PowerState {
136138

137139
#[derive(Debug, Deserialize)]
138140
pub struct MotionResource {
141+
pub id: String,
139142
pub owner: Owner,
143+
pub enabled: bool,
140144
pub motion: MotionData,
141145
}
142146

‎backend/src/lib.rs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ pub struct AppState {
4040
hue::handlers::events_sse,
4141
hue::handlers::pair,
4242
hue::handlers::toggle_group,
43+
hue::handlers::toggle_motion,
4344
solis::handlers::get_data,
4445
pv::handlers::get_forecast,
4546
pv::handlers::post_forecast,
@@ -188,6 +189,10 @@ pub fn create_app(
188189
.route(
189190
"/toggleGroup/{id}",
190191
web::post().to(hue::handlers::toggle_group),
192+
)
193+
.route(
194+
"/toggleMotion/{deviceId}",
195+
web::post().to(hue::handlers::toggle_motion),
191196
),
192197
)
193198
.service(

0 commit comments

Comments
 (0)