Skip to content

Commit 3ff73f5

Browse files
committed
feat(motion): mark the current light level on the daylight slider
The reading is the one the automation compares: a sensor light_level, or a group grouped_light_level.
1 parent 809aead commit 3ff73f5

9 files changed

Lines changed: 265 additions & 42 deletions

File tree

‎backend/src/hue/data.rs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ async fn fetch_from_bridge(state: &Arc<AppState>) -> Result<HueResponse, HueErro
7272
let grouped_motions: Vec<GroupedMotionResource> = resources.take("grouped_motion")?;
7373
let service_groups: Vec<ServiceGroupResource> = resources.take("service_group")?;
7474
let automations: Vec<BehaviorInstanceResource> = resources.take("behavior_instance")?;
75+
let mut light_levels: Vec<LightLevelResource> = resources.take("light_level")?;
76+
light_levels.extend(resources.take::<LightLevelResource>("grouped_light_level")?);
7577

7678
// device ID → battery level
7779
let battery_by_device: HashMap<&str, u8> = device_powers
@@ -182,6 +184,7 @@ async fn fetch_from_bridge(state: &Arc<AppState>) -> Result<HueResponse, HueErro
182184
grouped: &grouped_motions,
183185
service_groups: &service_groups,
184186
automations: &automations,
187+
light_levels: &light_levels,
185188
device_names: &device_name_by_id,
186189
});
187190

‎backend/src/hue/events.rs‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@ pub enum HueLiveEvent {
4646
id: String,
4747
sensitivity: u8,
4848
},
49+
/// `id` is a `light_level` or `grouped_light_level` service.
50+
LightLevel {
51+
id: String,
52+
level: u32,
53+
},
4954
Daylight {
5055
#[serde(rename = "automationId")]
5156
automation_id: String,
@@ -237,6 +242,24 @@ fn parse_resource_update(resource: &serde_json::Value) -> Vec<HueLiveEvent> {
237242
}
238243
events
239244
}
245+
"light_level" | "grouped_light_level" => resource
246+
.get("light")
247+
.and_then(|light| {
248+
light
249+
.get("light_level_report")
250+
.and_then(|r| r.get("light_level"))
251+
.or_else(|| light.get("light_level"))
252+
})
253+
.and_then(|l| l.as_u64())
254+
.and_then(|l| u32::try_from(l).ok())
255+
.zip(resource.get("id").and_then(|v| v.as_str()))
256+
.map(|(level, id)| {
257+
vec![HueLiveEvent::LightLevel {
258+
id: id.to_string(),
259+
level,
260+
}]
261+
})
262+
.unwrap_or_default(),
240263
"behavior_instance" => resource
241264
.get("configuration")
242265
.and_then(dark_threshold)
@@ -318,6 +341,19 @@ mod tests {
318341
);
319342
}
320343

344+
#[test]
345+
fn light_level_update_carries_the_reading() {
346+
let events = events(json!({
347+
"id": "gl-1", "type": "grouped_light_level",
348+
"light": {"light_level_report": {"changed": "2026-10-04T09:00:00Z", "light_level": 17723}},
349+
}));
350+
351+
assert_eq!(
352+
events,
353+
[json!({"type": "light_level", "id": "gl-1", "level": 17723})]
354+
);
355+
}
356+
321357
#[test]
322358
fn automation_update_carries_daylight_null_when_ignored() {
323359
let configuration = |t: u32| {

‎backend/src/hue/models.rs‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,13 @@ pub struct Daylight {
116116
/// Hue light level (10000·log10(lux) + 1) below which motion counts as
117117
/// dark. `null` when the automation ignores daylight.
118118
pub dark_threshold: Option<u32>,
119+
/// The `light_level` or `grouped_light_level` the automation compares
120+
/// against. Live `light_level` events carry this ID.
121+
#[serde(skip_serializing_if = "Option::is_none")]
122+
pub light_level_service_id: Option<String>,
123+
/// That service's current reading, on the same scale as the threshold.
124+
#[serde(skip_serializing_if = "Option::is_none")]
125+
pub light_level: Option<u32>,
119126
}
120127

121128
// ---- CLIP v2 resource shapes (for deserialization from bridge) ----
@@ -257,6 +264,35 @@ pub struct BehaviorInstanceResource {
257264
pub configuration: serde_json::Value,
258265
}
259266

267+
/// `light_level` and `grouped_light_level` share this shape.
268+
#[derive(Debug, Deserialize)]
269+
pub struct LightLevelResource {
270+
pub id: String,
271+
#[serde(default)]
272+
pub light: LightData,
273+
}
274+
275+
#[derive(Debug, Default, Deserialize)]
276+
pub struct LightData {
277+
/// Absent on a `grouped_light_level`; only the report is emitted there.
278+
pub light_level: Option<u32>,
279+
pub light_level_report: Option<LightLevelReport>,
280+
}
281+
282+
impl LightData {
283+
pub fn current(&self) -> Option<u32> {
284+
self.light_level_report
285+
.as_ref()
286+
.map(|r| r.light_level)
287+
.or(self.light_level)
288+
}
289+
}
290+
291+
#[derive(Debug, Deserialize)]
292+
pub struct LightLevelReport {
293+
pub light_level: u32,
294+
}
295+
260296
#[derive(Debug, Deserialize)]
261297
pub struct SensitivityData {
262298
pub sensitivity: Option<u8>,

‎backend/src/hue/motion.rs‎

Lines changed: 59 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ const MOTION_SERVICE_RID_POINTER: &str = "/motion/motion_service/rid";
1919
pub const DARK_THRESHOLD_POINTER: &str =
2020
"/light_level/daylight/daylight_sensitivity/settings/dark_threshold";
2121

22+
/// Where a motion automation names the light reading it compares against.
23+
const LIGHT_LEVEL_SERVICE_RID_POINTER: &str =
24+
"/light_level/daylight/daylight_sensitivity/light_level_service/rid";
25+
2226
/// The `dark_threshold` of an automation that ignores daylight: the top of the
2327
/// light level scale, so it is always dark.
2428
pub const DAYLIGHT_OFF: u32 = 65534;
@@ -30,6 +34,8 @@ pub struct MotionInputs<'a> {
3034
pub grouped: &'a [GroupedMotionResource],
3135
pub service_groups: &'a [ServiceGroupResource],
3236
pub automations: &'a [BehaviorInstanceResource],
37+
/// Both `light_level` and `grouped_light_level` resources.
38+
pub light_levels: &'a [LightLevelResource],
3339
/// device ID → name
3440
pub device_names: &'a HashMap<&'a str, &'a str>,
3541
}
@@ -79,7 +85,7 @@ pub fn build_motion_units(inputs: &MotionInputs) -> Vec<MotionUnit> {
7985
}
8086
}));
8187

82-
let daylight_by_service = daylight_by_service(inputs.automations);
88+
let daylight_by_service = daylight_by_service(inputs.automations, inputs.light_levels);
8389
let grouped_by_id: HashMap<&str, &GroupedMotionResource> =
8490
inputs.grouped.iter().map(|g| (g.id.as_str(), g)).collect();
8591

@@ -160,7 +166,14 @@ pub fn set_dark_threshold(configuration: &mut Value, threshold: Option<u32>) ->
160166
}
161167

162168
/// motion service ID → the daylight setting of the first automation on it.
163-
fn daylight_by_service(automations: &[BehaviorInstanceResource]) -> HashMap<&str, Daylight> {
169+
fn daylight_by_service<'a>(
170+
automations: &'a [BehaviorInstanceResource],
171+
light_levels: &[LightLevelResource],
172+
) -> HashMap<&'a str, Daylight> {
173+
let level_by_service: HashMap<&str, Option<u32>> = light_levels
174+
.iter()
175+
.map(|l| (l.id.as_str(), l.light.current()))
176+
.collect();
164177
let mut map = HashMap::new();
165178
for automation in automations {
166179
let Some(rid) = automation
@@ -173,9 +186,17 @@ fn daylight_by_service(automations: &[BehaviorInstanceResource]) -> HashMap<&str
173186
let Some(threshold) = dark_threshold(&automation.configuration) else {
174187
continue;
175188
};
189+
let light_level_service = automation
190+
.configuration
191+
.pointer(LIGHT_LEVEL_SERVICE_RID_POINTER)
192+
.and_then(Value::as_str);
176193
map.entry(rid).or_insert_with(|| Daylight {
177194
automation_id: automation.id.clone(),
178195
dark_threshold: threshold,
196+
light_level_service_id: light_level_service.map(str::to_owned),
197+
light_level: light_level_service
198+
.and_then(|id| level_by_service.get(id).copied())
199+
.flatten(),
179200
});
180201
}
181202
map
@@ -208,14 +229,14 @@ mod tests {
208229
json!({"motion_report": {"changed": "2026-10-04T09:00:00Z", "motion": motion}})
209230
}
210231

211-
fn automation(id: &str, service: &str, threshold: Option<u32>) -> Value {
232+
fn automation(id: &str, service: &str, light: &str, threshold: Option<u32>) -> Value {
212233
let mut configuration = json!({
213234
"motion": {"motion_service": {"rid": service, "rtype": "motion"}},
214235
"source": {"rid": "device-x", "rtype": "device"},
215236
});
216237
if let Some(t) = threshold {
217238
configuration["light_level"] = json!({"daylight": {"daylight_sensitivity": {
218-
"light_level_service": {"rid": "ll", "rtype": "light_level"},
239+
"light_level_service": {"rid": light, "rtype": "light_level"},
219240
"settings": {"dark_threshold": t, "offset": 7000},
220241
}}});
221242
}
@@ -255,11 +276,17 @@ mod tests {
255276
]},
256277
]));
257278
let automations = parse::<BehaviorInstanceResource>(json!([
258-
automation("b-kitchen", "g-kitchen", Some(14477)),
259-
automation("b-hall", "m-hall", Some(DAYLIGHT_OFF)),
260-
automation("b-hall-2", "m-hall", Some(100)),
279+
automation("b-kitchen", "g-kitchen", "gl-kitchen", Some(14477)),
280+
automation("b-hall", "m-hall", "ll-hall", Some(DAYLIGHT_OFF)),
281+
automation("b-hall-2", "m-hall", "ll-hall", Some(100)),
261282
{"id": "b-other", "configuration": {"when": {}}},
262283
]));
284+
// The hall sensor has not reported yet.
285+
let light_levels = parse::<LightLevelResource>(json!([
286+
{"id": "gl-kitchen", "light": {"light_level_report":
287+
{"changed": "2026-10-04T09:00:00Z", "light_level": 17723}}},
288+
{"id": "ll-kitchen", "light": {"light_level": 17000}},
289+
]));
263290
let device_names = HashMap::from([("d-kitchen", "Keittiön anturi"), ("d-hall", "Käytävä")]);
264291

265292
build_motion_units(&MotionInputs {
@@ -269,6 +296,7 @@ mod tests {
269296
grouped: &grouped,
270297
service_groups: &service_groups,
271298
automations: &automations,
299+
light_levels: &light_levels,
272300
device_names: &device_names,
273301
})
274302
}
@@ -349,9 +377,31 @@ mod tests {
349377
assert_eq!(hall_daylight.dark_threshold, None, "65534 ignores daylight");
350378
}
351379

380+
#[test]
381+
fn daylight_carries_the_reading_the_automation_compares() {
382+
let units = units();
383+
let kitchen = units.iter().find(|u| u.id == "sg-kitchen").unwrap();
384+
let hall = units.iter().find(|u| u.id == "m-hall").unwrap();
385+
386+
let kitchen_daylight = kitchen.daylight.as_ref().unwrap();
387+
assert_eq!(
388+
kitchen_daylight.light_level_service_id.as_deref(),
389+
Some("gl-kitchen"),
390+
"the group's reading, not its sensor's"
391+
);
392+
assert_eq!(kitchen_daylight.light_level, Some(17723));
393+
394+
let hall_daylight = hall.daylight.as_ref().unwrap();
395+
assert_eq!(
396+
hall_daylight.light_level_service_id.as_deref(),
397+
Some("ll-hall")
398+
);
399+
assert_eq!(hall_daylight.light_level, None);
400+
}
401+
352402
#[test]
353403
fn set_dark_threshold_rewrites_only_the_threshold() {
354-
let mut configuration = automation("b", "m", Some(14477))["configuration"].clone();
404+
let mut configuration = automation("b", "m", "ll", Some(14477))["configuration"].clone();
355405
let expected = {
356406
let mut c = configuration.clone();
357407
c["light_level"]["daylight"]["daylight_sensitivity"]["settings"]["dark_threshold"] =
@@ -368,7 +418,7 @@ mod tests {
368418

369419
#[test]
370420
fn set_dark_threshold_refuses_automation_without_daylight() {
371-
let mut configuration = automation("b", "m", None)["configuration"].clone();
421+
let mut configuration = automation("b", "m", "ll", None)["configuration"].clone();
372422
let before = configuration.clone();
373423

374424
assert!(!set_dark_threshold(&mut configuration, Some(20000)));

‎frontend/src/App.tsx‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,15 @@ const applyEvent = (data: Response, event: HueLiveEvent): Response => {
9595
m.sensitivity ? { ...m, sensitivity: { ...m.sensitivity, value: event.sensitivity } } : m,
9696
),
9797
};
98+
case "light_level":
99+
return {
100+
...data,
101+
motionUnits: data.motionUnits.map((u) =>
102+
u.daylight?.lightLevelServiceId === event.id
103+
? { ...u, daylight: { ...u.daylight, lightLevel: event.level } }
104+
: u,
105+
),
106+
};
98107
case "daylight":
99108
return {
100109
...data,

0 commit comments

Comments
 (0)