diff --git a/src/sessiond/device_state.rs b/src/sessiond/device_state.rs index 94382ca..d075307 100644 --- a/src/sessiond/device_state.rs +++ b/src/sessiond/device_state.rs @@ -126,6 +126,36 @@ pub const PROXIMITY_NEAR_DEBOUNCE: Duration = Duration::from_millis(700); /// wake through, which is the recoverable direction. pub const PROXIMITY_FAR_DEBOUNCE: Duration = Duration::ZERO; +/// How long `Observed` must be held before a `far` reading may end it. +/// +/// Measured on the phone 2026-08-03, which is what this is for. The trail was +/// almost entirely `Locked → Observed → Locked`, and reading the snapshots +/// showed why: `prox=true` on the way in and `prox=false` **one second later** +/// on the way out, over and over. `near` must hold 700 ms to be believed and +/// `far` is believed instantly, so a one-second blip is long enough to enter +/// and its end is immediate — the asymmetry that protects the wake veto is the +/// same asymmetry that makes this state chatter. +/// +/// The debounce itself cannot be the place to fix it. [`suppress_wake`] reads +/// the *debounced* value, so slowing `far` there would keep vetoing tap-to-wake +/// after the sensor was uncovered, which is precisely what +/// [`PROXIMITY_FAR_DEBOUNCE`]'s zero exists to prevent. One reading, two +/// consumers, opposite needs: the veto wants `far` fast, the state wants it +/// stable. So the hysteresis goes here, on the state, and the veto keeps its +/// instant edge. +/// +/// 3 s, and the bound comes from data already in this file rather than from +/// feel: the measured blips ran ~1 s, and of the 44 recorded proximity episodes +/// the nine real ones — pocket, ear, deliberate cover — all ran **≥5 s** +/// (max 173 s). 3 s sits above the noise and below every genuine episode, so it +/// suppresses the chatter without shortening a single real one. +/// +/// This reduces the flapping rather than abolishing it: a sensor that keeps +/// blipping still enters `Observed` on each ≥700 ms `near`. Raising the *entry* +/// bar would need the same split applied to the near edge, and that is a second +/// change with its own justification to earn. +pub const OBSERVED_MIN_DWELL: Duration = Duration::from_secs(3); + /// How long a changed bearer preference must hold before the machine acts. /// /// 20 s, chosen against the failure rather than against a feel. The gate this @@ -1337,6 +1367,8 @@ pub struct DeviceStateMachine { /// Per-button gesture recognition. Keyed rather than a field per button so /// adding volume costs nothing and binds nothing. buttons: std::collections::HashMap, + /// When `Observed` was entered, for [`OBSERVED_MIN_DWELL`]. + observed_since: Option, /// Whether the panel was dark when the current press began. /// /// Latched on the DOWN edge because **the press itself changes the @@ -1467,6 +1499,7 @@ impl DeviceStateMachine { started_at: Instant::now(), buttons: std::collections::HashMap::new(), blank_requested: false, + observed_since: None, // Boot comes up lit, so no press is in flight and the latch would // only ever be read after a real DOWN edge has set it. press_began_dark: false, @@ -2396,6 +2429,22 @@ impl DeviceStateMachine { /// a panel *off* rather than decline to turn one on. That needs a /// call-state input (ModemManager / callaudiod) — a factor, never an /// authority. + /// May a `far` reading end `Observed` yet? See [`OBSERVED_MIN_DWELL`]. + /// + /// Anything other than `Observed` answers yes: this gates one edge, and a + /// state we are not in has no dwell to serve. `None` also answers yes — + /// an `Observed` entered before this was tracked (a daemon that restarted + /// into it) must still be able to leave, or the phone would sit in a state + /// nothing could clear. + fn observed_dwell_elapsed(&self, now: Instant) -> bool { + if !matches!(self.state, DeviceState::Observed) { + return true; + } + self.observed_since + .map(|t| now.saturating_duration_since(t) >= OBSERVED_MIN_DWELL) + .unwrap_or(true) + } + pub fn suppress_wake(&self, trigger: InputTrigger) -> bool { matches!( trigger, @@ -2448,6 +2497,14 @@ impl DeviceStateMachine { return false; } self.state = next; + // Cleared here, on any path out, so a later entry cannot inherit an old + // timestamp and believe its dwell is already served. The *stamp* is set + // by the caller instead — `Instant::now()` here would read the real + // clock while every test drives an injected one, and the dwell would + // then be untestable. + if !matches!(next, DeviceState::Observed) { + self.observed_since = None; + } // Log the pair the right way round. This read // "Locked → Locked (from Active)" on the first live run, which looks // like a refused self-transition rather than the real Active → Locked. @@ -2614,8 +2671,13 @@ impl DeviceStateMachine { let should_be_observed = self.state == DeviceState::Locked && believed; if should_be_observed { - self.transition(DeviceState::Observed); - } else if matches!(self.state, DeviceState::Observed) && !believed { + if self.transition(DeviceState::Observed) { + self.observed_since = Some(now); + } + } else if matches!(self.state, DeviceState::Observed) + && !believed + && self.observed_dwell_elapsed(now) + { self.transition(DeviceState::Locked); } } @@ -2724,8 +2786,10 @@ impl DeviceStateMachine { } if should_be_observed && !was_observed { - self.transition(DeviceState::Observed); - } else if was_observed && !proximity_near { + if self.transition(DeviceState::Observed) { + self.observed_since = Some(now); + } + } else if was_observed && !proximity_near && self.observed_dwell_elapsed(now) { self.transition(DeviceState::Locked); } } @@ -3533,6 +3597,86 @@ mod tests { settled } + #[test] + fn a_believed_near_followed_by_far_a_second_later_does_not_flap() { + // The measured case, 2026-08-03. The phone's trail was almost entirely + // Locked -> Observed -> Locked, and the snapshots said why: prox=true + // on the way in, prox=false one second later on the way out. `near` + // holds 700 ms and is believed; `far` is believed instantly; the round + // trip costs two transitions and two trail entries for a reading that + // never meant anything. + let (mut sm, t0) = locked_for_proximity(); + + let settled = hold_prox_near(&mut sm, t0); + assert_eq!(sm.state, DeviceState::Observed, "a held near is believed"); + + // Far, one second after entering — inside the dwell. + let blip_ends = settled + Duration::from_secs(1); + report_prox(&mut sm, false, blip_ends); + sm.tick_at(blip_ends); + assert_eq!( + sm.state, + DeviceState::Observed, + "a one-second episode must not bounce the state back" + ); + + // Past the dwell, `far` still ends it — this must not become a state + // the phone cannot leave. + let after = settled + OBSERVED_MIN_DWELL + Duration::from_millis(1); + report_prox(&mut sm, false, after); + sm.tick_at(after); + assert_eq!( + sm.state, + DeviceState::Locked, + "the dwell delays the exit, it does not remove it" + ); + } + + #[test] + fn a_real_proximity_episode_still_ends_when_it_ends() { + // The nine real episodes of the 44 measured all ran >= 5 s. The dwell + // is 3 s precisely so it sits under every one of them: covering a + // sensor for a real interval must behave exactly as it did before. + let (mut sm, t0) = locked_for_proximity(); + let settled = hold_prox_near(&mut sm, t0); + assert_eq!(sm.state, DeviceState::Observed); + + let uncovered = settled + Duration::from_secs(5); + report_prox(&mut sm, false, uncovered); + sm.tick_at(uncovered); + assert_eq!( + sm.state, + DeviceState::Locked, + "a five-second episode ends on the reading that ends it" + ); + } + + #[test] + fn the_wake_veto_still_lifts_the_instant_the_sensor_clears() { + // The dwell must not leak into `suppress_wake`. That reads the + // debounced evidence, and PROXIMITY_FAR_DEBOUNCE is zero exactly so a + // wake is never refused after the phone is out of the pocket. Holding + // the *state* longer must not hold the *veto* longer. + let (mut sm, t0) = locked_for_proximity(); + let settled = hold_prox_near(&mut sm, t0); + assert!( + sm.suppress_wake(InputTrigger::DoubleTapToWake), + "covered: a pocket double-tap is vetoed" + ); + + let clear = settled + Duration::from_millis(200); + report_prox(&mut sm, false, clear); + assert!( + !sm.suppress_wake(InputTrigger::DoubleTapToWake), + "uncovered: the veto lifts at once, dwell or no dwell" + ); + assert_eq!( + sm.state, + DeviceState::Observed, + "and the state is still holding its dwell, which is the whole point" + ); + } + #[test] fn a_sub_second_near_blip_is_never_believed() { // The measured case, 2026-07-26: 44 near-episodes in 2.4 hours, median @@ -3715,13 +3859,19 @@ mod tests { // Enter observed let settled = hold_prox_near(&mut sm, Instant::now()); assert_eq!(sm.state(), DeviceState::Observed); - let _ = settled; - // Leave observed - sm.update_sensors(SensorEvidence { - proximity_near: false, - ..Default::default() - }); + // Leave observed. Past OBSERVED_MIN_DWELL, because a `far` inside the + // dwell is now deliberately ignored — see that constant. The property + // this test is named for is unchanged: far ends Observed. + let after = settled + OBSERVED_MIN_DWELL + Duration::from_millis(1); + sm.update_sensors_from_at( + SensorSource::Proximity, + SensorEvidence { + proximity_near: false, + ..Default::default() + }, + after, + ); assert_eq!(sm.state(), DeviceState::Locked); } @@ -3808,14 +3958,19 @@ mod tests { // 8. Phone goes back in pocket. Held — "briefly" is precisely what the // debounce now refuses to believe, which is the point of it. - hold_prox_near(&mut sm, Instant::now()); + let pocketed = hold_prox_near(&mut sm, Instant::now()); assert_eq!(sm.state(), DeviceState::Observed); - // 9. Phone comes back out - sm.update_sensors(SensorEvidence { - proximity_near: false, - ..Default::default() - }); + // 9. Phone comes back out — after OBSERVED_MIN_DWELL, since a pocket + // that lasted less than that is a blip rather than a pocket. + sm.update_sensors_from_at( + SensorSource::Proximity, + SensorEvidence { + proximity_near: false, + ..Default::default() + }, + pocketed + OBSERVED_MIN_DWELL + Duration::from_millis(1), + ); assert_eq!(sm.state(), DeviceState::Locked); // 10. System suspends