sessiond: a one-second proximity blip no longer flaps the state
The phone's trail was almost entirely Locked -> Observed -> Locked. The snapshots said why: prox=true on the way in, 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 episode is long enough to enter and its end is immediate. The debounce cannot be where this is fixed. suppress_wake reads the debounced value, so slowing `far` there would keep vetoing tap-to-wake after the sensor was uncovered — which is exactly 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 is on the state and the veto keeps its instant edge, with a test pinning that separation. 3 s, bounded by data already in this file rather than by feel: the measured blips ran ~1 s, and of the 44 recorded episodes the nine real ones all ran >= 5 s. It sits above the noise and below every genuine episode. It reduces the flapping rather than abolishing it — a sensor that keeps blipping still enters Observed on each 700 ms near. Raising the entry bar needs the same split applied to the near edge, which is a second change with its own justification to earn. Two existing tests asserted the instant exit and now advance past the dwell. The property they are named for is unchanged: far ends Observed.
This commit is contained in:
parent
6cb245492e
commit
1f028c129e
1 changed files with 171 additions and 16 deletions
|
|
@ -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<Button, ButtonRecognizer>,
|
||||
/// When `Observed` was entered, for [`OBSERVED_MIN_DWELL`].
|
||||
observed_since: Option<Instant>,
|
||||
/// 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
|
||||
|
|
|
|||
Loading…
Reference in a new issue