148 lines
4.9 KiB
Rust
148 lines
4.9 KiB
Rust
//! Bed-exit (overnight) primitive (§3.12.1 row 8).
|
|
//!
|
|
//! Edge-triggered event: fires once when "someone sleeping" transitions
|
|
//! to "no presence in any bed-tagged zone" between 22:00 and 06:00
|
|
//! local time.
|
|
//!
|
|
//! Inputs:
|
|
//! - `sleeping` from upstream (the someone_sleeping primitive — wired
|
|
//! into the bus output so we don't re-derive it here)
|
|
//! - `active_zones` — list of zones currently reporting presence
|
|
//! - `bed_zones` — config list of zones tagged as bed-areas
|
|
//! - `local_seconds_since_midnight` — local-time of day
|
|
//!
|
|
//! For v1 we don't have direct cross-primitive wiring, so we
|
|
//! approximate "sleeping" with: was-presence-in-bed-zone, then
|
|
//! exited-bed-zone. Refine in v2 when the bus exposes `sleeping`
|
|
//! state to other primitives.
|
|
|
|
use super::common::{in_window, PrimitiveConfig, PrimitiveState, RawSnapshot, Reason};
|
|
|
|
#[derive(Debug, Default, Clone)]
|
|
pub struct BedExit {
|
|
in_bed: bool,
|
|
}
|
|
|
|
impl BedExit {
|
|
pub fn new() -> Self { Self::default() }
|
|
|
|
fn in_bed_zone(snap: &RawSnapshot) -> bool {
|
|
!snap.bed_zones.is_empty()
|
|
&& snap.active_zones.iter().any(|z| snap.bed_zones.contains(z))
|
|
}
|
|
|
|
pub fn tick(&mut self, snap: &RawSnapshot, cfg: &PrimitiveConfig) -> PrimitiveState {
|
|
if snap.since_start < cfg.warmup {
|
|
return PrimitiveState::Idle;
|
|
}
|
|
let now_in_bed = snap.presence && Self::in_bed_zone(snap);
|
|
let was_in_bed = self.in_bed;
|
|
self.in_bed = now_in_bed;
|
|
|
|
if was_in_bed && !now_in_bed {
|
|
// Only fire during overnight window.
|
|
let (start, end) = cfg.bed_exit_window;
|
|
if in_window(snap.local_seconds_since_midnight, start, end) {
|
|
return PrimitiveState::Event {
|
|
event_type: "bed_exit",
|
|
reason: Reason::new(&[
|
|
"left_bed_zone",
|
|
"overnight_window",
|
|
]),
|
|
};
|
|
}
|
|
}
|
|
PrimitiveState::Idle
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::time::Duration;
|
|
|
|
fn cfg() -> PrimitiveConfig { PrimitiveConfig::default() }
|
|
|
|
fn in_bed_overnight(t: u64) -> RawSnapshot {
|
|
RawSnapshot {
|
|
since_start: Duration::from_secs(120 + t),
|
|
presence: true,
|
|
active_zones: vec!["bedroom".into()],
|
|
bed_zones: vec!["bedroom".into()],
|
|
local_seconds_since_midnight: 2 * 3600, // 02:00
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn out_of_bed_overnight(t: u64) -> RawSnapshot {
|
|
RawSnapshot {
|
|
since_start: Duration::from_secs(120 + t),
|
|
presence: true,
|
|
active_zones: vec!["hall".into()],
|
|
bed_zones: vec!["bedroom".into()],
|
|
local_seconds_since_midnight: 2 * 3600,
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn fires_on_bed_to_non_bed_overnight() {
|
|
let mut p = BedExit::new();
|
|
let _ = p.tick(&in_bed_overnight(10), &cfg());
|
|
let state = p.tick(&out_of_bed_overnight(20), &cfg());
|
|
assert!(matches!(state, PrimitiveState::Event { event_type: "bed_exit", .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn does_not_fire_during_day() {
|
|
let mut p = BedExit::new();
|
|
let mut s_in = in_bed_overnight(10);
|
|
s_in.local_seconds_since_midnight = 14 * 3600; // 14:00
|
|
let _ = p.tick(&s_in, &cfg());
|
|
let mut s_out = out_of_bed_overnight(20);
|
|
s_out.local_seconds_since_midnight = 14 * 3600;
|
|
let state = p.tick(&s_out, &cfg());
|
|
assert!(matches!(state, PrimitiveState::Idle));
|
|
}
|
|
|
|
#[test]
|
|
fn does_not_fire_without_prior_in_bed() {
|
|
let mut p = BedExit::new();
|
|
// Person never was in bed.
|
|
let state = p.tick(&out_of_bed_overnight(20), &cfg());
|
|
assert!(matches!(state, PrimitiveState::Idle));
|
|
}
|
|
|
|
#[test]
|
|
fn warmup_blocks_initial_transitions() {
|
|
let mut p = BedExit::new();
|
|
let mut s_in = in_bed_overnight(0);
|
|
s_in.since_start = Duration::from_secs(30);
|
|
assert!(matches!(p.tick(&s_in, &cfg()), PrimitiveState::Idle));
|
|
}
|
|
|
|
#[test]
|
|
fn does_not_fire_when_bed_zones_unconfigured() {
|
|
let mut p = BedExit::new();
|
|
let mut s_in = in_bed_overnight(10);
|
|
s_in.bed_zones.clear();
|
|
let _ = p.tick(&s_in, &cfg());
|
|
let mut s_out = out_of_bed_overnight(20);
|
|
s_out.bed_zones.clear();
|
|
let state = p.tick(&s_out, &cfg());
|
|
assert!(matches!(state, PrimitiveState::Idle));
|
|
}
|
|
|
|
#[test]
|
|
fn fires_just_after_midnight_window_start() {
|
|
let mut p = BedExit::new();
|
|
let mut s_in = in_bed_overnight(10);
|
|
s_in.local_seconds_since_midnight = 22 * 3600 + 5; // 22:00:05
|
|
let _ = p.tick(&s_in, &cfg());
|
|
let mut s_out = out_of_bed_overnight(20);
|
|
s_out.local_seconds_since_midnight = 22 * 3600 + 10;
|
|
let state = p.tick(&s_out, &cfg());
|
|
assert!(matches!(state, PrimitiveState::Event { .. }));
|
|
}
|
|
}
|