5264 lines
222 KiB
Rust
5264 lines
222 KiB
Rust
|
|
//! Unified device state machine — the single authority for device power state.
|
|||
|
|
//!
|
|||
|
|
//! Every actor that changes device power (idle timers, proximity sensor,
|
|||
|
|
//! sleep signals, DPMS) routes through this machine. The machine owns the
|
|||
|
|
//! state; actors are inputs, not authorities.
|
|||
|
|
//!
|
|||
|
|
//! The state graph:
|
|||
|
|
//! Active → Dimmed → Locked → {Observed, DozeLight, DozeDeep} → Suspending → Asleep
|
|||
|
|
//! Any locked state → Active (on PAM auth)
|
|||
|
|
//! Any pre-sleep state → Suspending (on PrepareForSleep)
|
|||
|
|
//!
|
|||
|
|
//! Doctrine: SESSION-AUTHORITY-DOCTRINE §9 ("sensor readings are evidence,
|
|||
|
|
//! not fact") and §11 ("the session authority and the binary authority are
|
|||
|
|
//! the same authority"). TASK-08 and TASK-15 define the tiers.
|
|||
|
|
|
|||
|
|
use serde::{Deserialize, Serialize};
|
|||
|
|
use std::fmt;
|
|||
|
|
use std::path::PathBuf;
|
|||
|
|
use std::sync::{Arc, Mutex};
|
|||
|
|
use std::time::{Duration, Instant};
|
|||
|
|
use tracing::{info, warn};
|
|||
|
|
|
|||
|
|
use crate::sessiond::bearer::{Bearer, BearerEvidence};
|
|||
|
|
use crate::sessiond::protocol::{
|
|||
|
|
Button, ButtonEdge, ButtonGesture, InputTrigger, PowerVerb, SensorSource, SensorValue,
|
|||
|
|
TouchGesture, UsbMode,
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
/// How long a locked, lit panel waits for input before it blanks.
|
|||
|
|
///
|
|||
|
|
/// Android blanks the lock screen in about this long; Sailfish's mce keeps a
|
|||
|
|
/// separate blank-from-lockscreen timeout for exactly this case. We had
|
|||
|
|
/// neither: the only backstop was hypridle's 600 s screen-off timer, shared
|
|||
|
|
/// with the desktop case, so glancing at the clock lit the panel for ten
|
|||
|
|
/// minutes.
|
|||
|
|
pub const LOCK_BLANK_AFTER: Duration = Duration::from_secs(15);
|
|||
|
|
|
|||
|
|
/// Same, when evidence says the device is in a hand rather than on a table.
|
|||
|
|
pub const LOCK_BLANK_AFTER_HELD: Duration = Duration::from_secs(25);
|
|||
|
|
|
|||
|
|
/// How long the dimmed warning lasts before the panel goes dark.
|
|||
|
|
///
|
|||
|
|
/// Dimming first is the pre-warning: the screen visibly fades and a tap
|
|||
|
|
/// inside this window cancels the blank. The mechanism already existed and
|
|||
|
|
/// was orphaned — hypridle once carried a dim listener
|
|||
|
|
/// (`brightnessctl -s set 10`, `on-resume = blueline-undim`) which is no
|
|||
|
|
/// longer in the live config, while `blueline-undim` is still installed.
|
|||
|
|
pub const LOCK_DIM_GRACE: Duration = Duration::from_secs(10);
|
|||
|
|
|
|||
|
|
/// Evidence older than this is stale, and stale evidence is *unknown* —
|
|||
|
|
/// neither "user present" nor "user absent".
|
|||
|
|
///
|
|||
|
|
/// This exists because of a measured failure: on 2026-07-25 the SLPI took a
|
|||
|
|
/// CHRE fatal, `blueline-hexagonrpcd-sdsp` exited with status 0 so systemd's
|
|||
|
|
/// `Restart=on-failure` never fired, and every sensor was dead for hours
|
|||
|
|
/// while iio-sensor-proxy still answered `HasProximity: true`. A consumer of
|
|||
|
|
/// a dead sensor looked exactly like a consumer of a quiet one.
|
|||
|
|
pub const EVIDENCE_TTL: Duration = Duration::from_secs(30);
|
|||
|
|
|
|||
|
|
/// How long a source that was previously reporting may say nothing before the
|
|||
|
|
/// machine calls it **down** rather than merely stale.
|
|||
|
|
///
|
|||
|
|
/// The two are different claims and the difference is the whole point.
|
|||
|
|
/// `EVIDENCE_TTL` is about the *reading*: stop believing a 30-second-old "near".
|
|||
|
|
/// This is about the *source*: proximity that sat at `far` all afternoon has an
|
|||
|
|
/// unexpired flag of `false` and looks identical whether the sensor is healthy
|
|||
|
|
/// and the phone is on a table, or the DSP took a CHRE fatal an hour ago. The
|
|||
|
|
/// staleness rule cannot tell those apart, because nothing about a `false` flag
|
|||
|
|
/// going unreported is anomalous.
|
|||
|
|
///
|
|||
|
|
/// It is longer than the TTL on purpose. A source is allowed to be quiet for a
|
|||
|
|
/// while — that is normal. What is not normal is a source that was speaking and
|
|||
|
|
/// then stopped for longer than any legitimate quiet period, which is what this
|
|||
|
|
/// threshold names. Reporters keep this honest by re-reporting their last value
|
|||
|
|
/// periodically, so silence means silence rather than "nothing changed".
|
|||
|
|
pub const SOURCE_DOWN_AFTER: Duration = Duration::from_secs(90);
|
|||
|
|
|
|||
|
|
/// How long after daemon start an *expected* source may stay silent before the
|
|||
|
|
/// machine calls it `Absent`.
|
|||
|
|
///
|
|||
|
|
/// Deliberately much longer than `SOURCE_DOWN_AFTER`. sessiond starts before
|
|||
|
|
/// the session does — that is the whole point of it, it takes the lock before
|
|||
|
|
/// quickshell exists — so its reporters legitimately arrive late. This window
|
|||
|
|
/// has to cover session startup on a cold boot without covering a reporter
|
|||
|
|
/// that is never coming, and it is a policy field so it can be moved off the
|
|||
|
|
/// trail rather than argued about here.
|
|||
|
|
pub const SOURCE_EXPECTED_WITHIN: Duration = Duration::from_secs(300);
|
|||
|
|
|
|||
|
|
/// Which sources a reporter is expected to serve on this device.
|
|||
|
|
///
|
|||
|
|
/// Exactly what `souveraine-sensord` reports (`SOURCES` in its `main`):
|
|||
|
|
/// proximity, light, accelerometer. `Touch` is deliberately absent — nothing
|
|||
|
|
/// reports it, so it must stay `Unknown` and silent, per §10. Adding a source
|
|||
|
|
/// here without a reporter behind it manufactures a permanent false alarm,
|
|||
|
|
/// which is the failure this whole mechanism exists to avoid in the other
|
|||
|
|
/// direction.
|
|||
|
|
pub const EXPECTED_SOURCES: &[SensorSource] = &[
|
|||
|
|
SensorSource::Proximity,
|
|||
|
|
SensorSource::Light,
|
|||
|
|
SensorSource::Accelerometer,
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
/// How long proximity must read `near` before the machine believes it.
|
|||
|
|
///
|
|||
|
|
/// §9.5 specified Android's `DisplayPowerProximityStateController` — 0 ms
|
|||
|
|
/// positive, 250 ms negative — and that is the wrong shape for this device.
|
|||
|
|
/// Measured on the trail 2026-07-26: 44 near-episodes over 2.4 hours, median
|
|||
|
|
/// dwell **1 second**, 15 of them sub-second, and a median 115 s of quiet
|
|||
|
|
/// between them. That is not a sensor bouncing around a threshold; it is
|
|||
|
|
/// isolated one-second blips. A negative debounce delays believing `far`, so it
|
|||
|
|
/// would have turned each 1 s blip into a 1.25 s blip and left all 88
|
|||
|
|
/// transitions in place.
|
|||
|
|
///
|
|||
|
|
/// Android debounces the negative edge because there `near` means *screen off
|
|||
|
|
/// at the ear, immediately* — a positive delay would be felt. That constraint
|
|||
|
|
/// left when proximity stopped actuating: it now only vetoes tap-to-wake, where
|
|||
|
|
/// waiting is imperceptible and a false veto is the more annoying failure.
|
|||
|
|
///
|
|||
|
|
/// Nine of the 44 episodes ran ≥5 s (max 173 s). Those are the real ones —
|
|||
|
|
/// pocket, ear, deliberate cover — and they survive this threshold untouched.
|
|||
|
|
pub const PROXIMITY_NEAR_DEBOUNCE: Duration = Duration::from_millis(700);
|
|||
|
|
|
|||
|
|
/// How long proximity must read `far` before the machine believes it.
|
|||
|
|
///
|
|||
|
|
/// Zero. An uncovered sensor is believed at once: the reading that ends a veto
|
|||
|
|
/// should never be the slow one, and erring toward `far` errs toward letting a
|
|||
|
|
/// 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
|
|||
|
|
/// replaces reacted at NM-event speed and produced ~7 tunnel recycles a
|
|||
|
|
/// minute; a wifi association that comes and goes during a roam settles well
|
|||
|
|
/// inside 20 s, and a genuine bearer change (walking out of range) does not
|
|||
|
|
/// reverse itself within one. The cost of being slow here is 20 s on the wrong
|
|||
|
|
/// link; the cost of being fast was an unusable phone.
|
|||
|
|
pub const BEARER_SETTLE: Duration = Duration::from_secs(20);
|
|||
|
|
|
|||
|
|
/// How long a button must stay down to be a hold rather than a tap.
|
|||
|
|
///
|
|||
|
|
/// **Provisional, and the number is the weakest part of this file.** §9.5's
|
|||
|
|
/// lesson is the standing warning here: the proximity debounce was specified
|
|||
|
|
/// from Android's prior art, and when it came time to build it the trail said
|
|||
|
|
/// the specification was wrong for a device whose constraints had changed. 500
|
|||
|
|
/// ms is AOSP's long-press default and it is a *reference*, not a measurement.
|
|||
|
|
/// The trail now records every gesture with the duration that produced it, so
|
|||
|
|
/// this can be set from real presses the way `PROXIMITY_NEAR_DEBOUNCE` was —
|
|||
|
|
/// do that before defending the value.
|
|||
|
|
pub const BUTTON_HOLD: Duration = Duration::from_millis(500);
|
|||
|
|
|
|||
|
|
/// Held past this, still down. The point where a destructive binding may fire
|
|||
|
|
/// without the user having meant a hold.
|
|||
|
|
pub const BUTTON_LONG_HOLD: Duration = Duration::from_millis(2000);
|
|||
|
|
|
|||
|
|
/// How long after a release to keep waiting for another tap.
|
|||
|
|
///
|
|||
|
|
/// This one is felt directly and in the wrong direction: it is the delay
|
|||
|
|
/// between a single tap and anything happening, because a single cannot fire
|
|||
|
|
/// until the window proves no second tap is coming. Too long and the phone
|
|||
|
|
/// feels broken; too short and a double-tap fires a single first. 300 ms is
|
|||
|
|
/// AOSP's double-tap timeout. Same caveat as above — measure it.
|
|||
|
|
pub const BUTTON_MULTI_TAP_WINDOW: Duration = Duration::from_millis(300);
|
|||
|
|
|
|||
|
|
/// How long a blank waits for the compositor to acknowledge the lock it asked
|
|||
|
|
/// for before going dark anyway.
|
|||
|
|
///
|
|||
|
|
/// `LOCK-DPMS-LESSONS.md` §1 ("Ordering: lock, then off") fixes this at ≤2 s
|
|||
|
|
/// for the power-button path and gives the reason for the fail-open: "if the
|
|||
|
|
/// lock IPC fails, blank anyway — dark-but-unlocked is recoverable,
|
|||
|
|
/// lit-and-unlocked in a pocket is not." Doctrine §8 supplies the other half:
|
|||
|
|
/// the panel may proceed, but the session is never *recorded* as locked. The
|
|||
|
|
/// timeout is an `error-security` in the trail, not a silent success.
|
|||
|
|
pub const LOCK_ACK_BUDGET: Duration = Duration::from_secs(2);
|
|||
|
|
|
|||
|
|
/// Tunable policy for the machine's timed behaviour.
|
|||
|
|
///
|
|||
|
|
/// These are settings, not constants: the shape is iOS's Auto-Lock — a
|
|||
|
|
/// user-chosen timeout with a visible dim shortly before it, and a "never"
|
|||
|
|
/// option for the desk-clock case. The Settings control center (TASK-19) is
|
|||
|
|
/// meant to be a view over this struct, so the values live here rather than
|
|||
|
|
/// being baked into the rule.
|
|||
|
|
/// `serde(default)` at the container level, so a policy file written by an
|
|||
|
|
/// older sessiond loads with the new fields filled from `Default` instead of
|
|||
|
|
/// failing outright. Without it, adding one field here would make every
|
|||
|
|
/// existing device fall back to built-in timers on the next upgrade and lose
|
|||
|
|
/// whatever the user had set — silently, which is the failure mode this whole
|
|||
|
|
/// area keeps producing.
|
|||
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|||
|
|
#[serde(default)]
|
|||
|
|
pub struct DeviceStatePolicy {
|
|||
|
|
/// Locked, panel lit, no input for this long → blank. `None` = never.
|
|||
|
|
pub lock_blank_after: Option<Duration>,
|
|||
|
|
/// Same, when evidence says the device is being held.
|
|||
|
|
pub lock_blank_after_held: Option<Duration>,
|
|||
|
|
/// How long the dimmed warning shows before the blank.
|
|||
|
|
pub dim_grace: Duration,
|
|||
|
|
/// Evidence older than this is unknown.
|
|||
|
|
pub evidence_ttl: Duration,
|
|||
|
|
/// Whether the dim pre-warning is used at all.
|
|||
|
|
pub dim_warning: bool,
|
|||
|
|
/// How long a pending blank waits for the lock it asked for.
|
|||
|
|
pub lock_ack_budget: Duration,
|
|||
|
|
/// Unlocked, panel lit, no input for this long → lock, then blank.
|
|||
|
|
/// `None` = never, and that is the default: the shell's `IdleCoordinator`
|
|||
|
|
/// owns the unlocked idle→lock timer through `ext-idle-notify` (doctrine
|
|||
|
|
/// §5), and a second unlocked timer here would recreate the competing-owner
|
|||
|
|
/// disease this machine exists to end. The field is settable so the
|
|||
|
|
/// capability is real rather than implied.
|
|||
|
|
pub unlocked_blank_after: Option<Duration>,
|
|||
|
|
/// A source silent for this long, having previously reported, is down.
|
|||
|
|
pub source_down_after: Duration,
|
|||
|
|
/// Grace from daemon start before an expected-but-silent source is
|
|||
|
|
/// reported `Absent`. See `SOURCE_EXPECTED_WITHIN`.
|
|||
|
|
pub source_expected_within: Duration,
|
|||
|
|
/// How long proximity must hold `near` before the machine believes it.
|
|||
|
|
pub proximity_near_debounce: Duration,
|
|||
|
|
/// How long proximity must hold `far` before the machine believes it.
|
|||
|
|
pub proximity_far_debounce: Duration,
|
|||
|
|
/// SSIDs that are the home LAN.
|
|||
|
|
///
|
|||
|
|
/// Identity, never an address prefix. The gate this replaces tested
|
|||
|
|
/// `inet 10.10.` and so read the foreign network this phone lives on
|
|||
|
|
/// (`10.10.30.213`) as home, taking the tunnel down as redundant. Empty
|
|||
|
|
/// means "never claim to be home", which is the safe default: an unknown
|
|||
|
|
/// network is not a reason to drop the tunnel.
|
|||
|
|
pub home_ssids: Vec<String>,
|
|||
|
|
/// How long a bearer preference must hold before the machine acts on it.
|
|||
|
|
///
|
|||
|
|
/// This is the entire anti-flap mechanism, and it is a debounce rather
|
|||
|
|
/// than a rate limit on purpose. `wiuf-vpn-gate` recycled the tunnel 652
|
|||
|
|
/// times in 90 minutes because every correction re-entered the controller;
|
|||
|
|
/// a decision that has to survive a settling window cannot oscillate at
|
|||
|
|
/// event speed no matter how the events arrive.
|
|||
|
|
pub bearer_settle: Duration,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
impl DeviceStatePolicy {
|
|||
|
|
/// Where the user's choices live between runs.
|
|||
|
|
///
|
|||
|
|
/// They did not live anywhere until 2026-07-26. `SetPolicy` mutated the
|
|||
|
|
/// in-memory struct and nothing wrote it down, so every lock-screen timer
|
|||
|
|
/// set in Settings survived exactly until the next restart and then
|
|||
|
|
/// silently reverted to the built-in 15 s. The Settings page was honest
|
|||
|
|
/// about reading the daemon — the daemon was the one forgetting.
|
|||
|
|
pub fn path() -> Option<PathBuf> {
|
|||
|
|
let base = std::env::var_os("XDG_CONFIG_HOME")
|
|||
|
|
.map(PathBuf::from)
|
|||
|
|
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))?;
|
|||
|
|
Some(base.join("souveraine").join("device-state-policy.json"))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Load the saved policy, or the defaults.
|
|||
|
|
///
|
|||
|
|
/// A file that exists but cannot be read or parsed is LOUD and then
|
|||
|
|
/// ignored: continuing on built-in timers is the only thing a session
|
|||
|
|
/// authority can do at startup, but doing it quietly would present
|
|||
|
|
/// defaults as if they were the user's settings.
|
|||
|
|
pub fn load() -> Self {
|
|||
|
|
let Some(path) = Self::path() else {
|
|||
|
|
warn!(
|
|||
|
|
"[device-state] no HOME or XDG_CONFIG_HOME — policy cannot be persisted this run"
|
|||
|
|
);
|
|||
|
|
return Self::default();
|
|||
|
|
};
|
|||
|
|
let raw = match std::fs::read_to_string(&path) {
|
|||
|
|
Ok(raw) => raw,
|
|||
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Self::default(),
|
|||
|
|
Err(e) => {
|
|||
|
|
warn!("[device-state] policy at {} is unreadable ({e}) — RUNNING ON DEFAULTS, not on your settings", path.display());
|
|||
|
|
return Self::default();
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
match serde_json::from_str(&raw) {
|
|||
|
|
Ok(p) => {
|
|||
|
|
info!("[device-state] policy loaded from {}", path.display());
|
|||
|
|
p
|
|||
|
|
}
|
|||
|
|
Err(e) => {
|
|||
|
|
warn!("[device-state] policy at {} is malformed ({e}) — RUNNING ON DEFAULTS, not on your settings", path.display());
|
|||
|
|
Self::default()
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Write the policy out. Errors are returned, never swallowed — the caller
|
|||
|
|
/// tells the Settings page, so a control that appears to have taken effect
|
|||
|
|
/// has actually taken effect past the next reboot.
|
|||
|
|
pub fn save(&self) -> Result<(), String> {
|
|||
|
|
let path = Self::path().ok_or_else(|| "no HOME or XDG_CONFIG_HOME".to_string())?;
|
|||
|
|
if let Some(dir) = path.parent() {
|
|||
|
|
std::fs::create_dir_all(dir).map_err(|e| format!("creating {}: {e}", dir.display()))?;
|
|||
|
|
}
|
|||
|
|
let body = serde_json::to_string_pretty(self)
|
|||
|
|
.map_err(|e| format!("serializing the policy: {e}"))?;
|
|||
|
|
// Write-then-rename, so a crash mid-write cannot leave a truncated
|
|||
|
|
// file that the next boot reads as malformed and discards.
|
|||
|
|
let tmp = path.with_extension("json.tmp");
|
|||
|
|
std::fs::write(&tmp, body).map_err(|e| format!("writing {}: {e}", tmp.display()))?;
|
|||
|
|
std::fs::rename(&tmp, &path)
|
|||
|
|
.map_err(|e| format!("renaming into {}: {e}", path.display()))?;
|
|||
|
|
Ok(())
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
impl Default for DeviceStatePolicy {
|
|||
|
|
fn default() -> Self {
|
|||
|
|
Self {
|
|||
|
|
lock_blank_after: Some(LOCK_BLANK_AFTER),
|
|||
|
|
lock_blank_after_held: Some(LOCK_BLANK_AFTER_HELD),
|
|||
|
|
dim_grace: LOCK_DIM_GRACE,
|
|||
|
|
evidence_ttl: EVIDENCE_TTL,
|
|||
|
|
dim_warning: true,
|
|||
|
|
lock_ack_budget: LOCK_ACK_BUDGET,
|
|||
|
|
unlocked_blank_after: None,
|
|||
|
|
source_down_after: SOURCE_DOWN_AFTER,
|
|||
|
|
source_expected_within: SOURCE_EXPECTED_WITHIN,
|
|||
|
|
proximity_near_debounce: PROXIMITY_NEAR_DEBOUNCE,
|
|||
|
|
proximity_far_debounce: PROXIMITY_FAR_DEBOUNCE,
|
|||
|
|
home_ssids: Vec::new(),
|
|||
|
|
bearer_settle: BEARER_SETTLE,
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// What the machine wants done. The machine decides; it never runs a command
|
|||
|
|
/// itself. `tick` returns intent and the daemon executes it, so the decision
|
|||
|
|
/// stays testable without a compositor.
|
|||
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|||
|
|
pub enum Action {
|
|||
|
|
/// Dim the panel as the pre-warning. Saves the current brightness first.
|
|||
|
|
Dim,
|
|||
|
|
/// Restore brightness after a dim. Runs `blueline-undim`, never a bare
|
|||
|
|
/// `brightnessctl -r`: the restore needs the floor, or a save that landed
|
|||
|
|
/// while already dim leaves the panel at 10/255 and every later wake
|
|||
|
|
/// looks like dead glass (Pixel3Arch b9f83f0).
|
|||
|
|
Restore,
|
|||
|
|
/// Blank the panel through the DPMS executor.
|
|||
|
|
Blank,
|
|||
|
|
/// One step of output volume, up or down.
|
|||
|
|
///
|
|||
|
|
/// Under Hyprland these were `wpctl` calls bound to `XF86AudioRaiseVolume`
|
|||
|
|
/// / `LowerVolume` in `hyprland.lua`, with key repeat. viewtop consumes
|
|||
|
|
/// the volume keys as device buttons — they must not be interceptable by a
|
|||
|
|
/// fullscreen client any more than the power button is — so the binding
|
|||
|
|
/// had to come somewhere, and §12 is explicit that the somewhere is an
|
|||
|
|
/// `Action` through this table rather than a small daemon that reads a
|
|||
|
|
/// signal and calls a tool. A second writer to the sink is the eighth
|
|||
|
|
/// blind actor.
|
|||
|
|
///
|
|||
|
|
/// Emitted on the **down edge**, not from a recognised gesture:
|
|||
|
|
/// `BUTTON_MULTI_TAP_WINDOW` is 300 ms, so a tap-driven volume key would
|
|||
|
|
/// lag a third of a second behind the press and feel broken. Volume is one
|
|||
|
|
/// of the two controls whose whole quality is immediacy.
|
|||
|
|
Volume { up: bool },
|
|||
|
|
/// Light the panel through the same executor.
|
|||
|
|
///
|
|||
|
|
/// The machine could turn the screen off and had no way to turn it back
|
|||
|
|
/// on: `apply_gesture` answers a power tap on a dark panel with `Restore`,
|
|||
|
|
/// which is *brightness*, and there was no unblank in this table at all.
|
|||
|
|
/// That worked only because the wake never came through the daemon —
|
|||
|
|
/// `hyprland.lua` bound the physical key straight to
|
|||
|
|
/// `blueline-screen-toggle`, so the compositor woke the panel and sessiond
|
|||
|
|
/// merely heard about it afterwards. Under viewtop the compositor consumes
|
|||
|
|
/// that key (a device button must not be interceptable by whatever is
|
|||
|
|
/// fullscreen), and the gap became a phone that could sleep and never wake.
|
|||
|
|
///
|
|||
|
|
/// So the machine owns both directions now, which is what §1 asks for in
|
|||
|
|
/// the first place: seven blind actors became one authority, and an
|
|||
|
|
/// authority that can only act in one direction is half an authority. It
|
|||
|
|
/// also makes waking something the agent can *reach* — doctrine §13, a
|
|||
|
|
/// verb she cannot reach is a defect — rather than a side effect of a
|
|||
|
|
/// keybinding in a compositor config she has no say over.
|
|||
|
|
///
|
|||
|
|
/// Unlike [`Action::Blank`] this carries no ordering obligation: §1 is
|
|||
|
|
/// about paths to a *dark* panel. Lighting one discloses nothing that the
|
|||
|
|
/// lock surface is not already responsible for covering, and
|
|||
|
|
/// `LOCK-DPMS-LESSONS.md` §7 says it outright — "wake sources move the
|
|||
|
|
/// panel, only sessiond moves the lock".
|
|||
|
|
Unblank,
|
|||
|
|
/// Raise the window action sheet for one window.
|
|||
|
|
///
|
|||
|
|
/// A touch gesture bound the way every other control is: viewtop reports
|
|||
|
|
/// what the fingers did, the machine decides what it means, and the
|
|||
|
|
/// behaviour leaves here through the executor table. §12 is explicit that
|
|||
|
|
/// the alternative — the compositor recognising three fingers and calling
|
|||
|
|
/// `qs ipc` itself — is the eighth blind actor, and `wiuf-vpn-gate` is what
|
|||
|
|
/// that costs: 652 tunnel recycles in ninety minutes with no way to turn it
|
|||
|
|
/// off.
|
|||
|
|
///
|
|||
|
|
/// The compositor has to do the *recognition* because contacts only exist
|
|||
|
|
/// there, which is why this arrives already named rather than as raw
|
|||
|
|
/// touches. What it must not do is decide what a name means.
|
|||
|
|
///
|
|||
|
|
/// This replaced `Overview`, which the same tap used to raise. Two gestures
|
|||
|
|
/// reaching one surface is the one-decider problem in miniature: the rail's
|
|||
|
|
/// swipe already opens the overview, so the tap spent its whole existence
|
|||
|
|
/// duplicating a gesture the thumb already had. `target` is what makes the
|
|||
|
|
/// difference — the sheet is *about a window*, and the tap now knows which.
|
|||
|
|
WindowSheet { target: u64 },
|
|||
|
|
/// Raise the power menu — the device's own verbs, on a hold.
|
|||
|
|
///
|
|||
|
|
/// The counterpart to [`Self::WindowSheet`]: that one is about a window,
|
|||
|
|
/// this one is about the device. Restart, power off, and the USB-C port's
|
|||
|
|
/// state — whether it is host or device, whether it is sourcing or sinking
|
|||
|
|
/// power, and what is on the other end.
|
|||
|
|
///
|
|||
|
|
/// Those last ones are here rather than in a settings page because the port
|
|||
|
|
/// is not a preference; it is what the machine currently *is*, and it
|
|||
|
|
/// changes what every other peripheral means. Casey, 2026-08-05: "that
|
|||
|
|
/// whole power usb state probably belongs in the power switch options".
|
|||
|
|
PowerMenu,
|
|||
|
|
/// End the session's power state: off, restart, or asleep.
|
|||
|
|
///
|
|||
|
|
/// The counterpart to every other row here, on the one transition that
|
|||
|
|
/// cannot be walked back. It arrives as an `Action` for the same reason
|
|||
|
|
/// `Volume` and `WindowSheet` do — §12 forbids the small actor that reads a
|
|||
|
|
/// signal and calls a tool — but the stakes are the argument's strongest
|
|||
|
|
/// case rather than its weakest: a poweroff executed outside this table
|
|||
|
|
/// leaves no entry anywhere, and there is no later moment to notice.
|
|||
|
|
Power(PowerVerb),
|
|||
|
|
/// Change the port's gadget posture through usb-signaller.
|
|||
|
|
///
|
|||
|
|
/// The mode daemon is the mechanism and souveraine-upower is its adjacent
|
|||
|
|
/// power sensor. Neither decides. sessiond emits this action so the mode
|
|||
|
|
/// change is named, audited, and later refusable when a known peer or an
|
|||
|
|
/// active probe lease says the port is occupied.
|
|||
|
|
UsbMode(UsbMode),
|
|||
|
|
/// Request the session lock, because something wants the panel dark and
|
|||
|
|
/// the session is not locked yet.
|
|||
|
|
///
|
|||
|
|
/// This is the machine's half of `LOCK-DPMS-LESSONS.md` §1 — the ordering
|
|||
|
|
/// is lock, *then* off, and it is an invariant the authority enforces
|
|||
|
|
/// rather than a coincidence of two timers (hypridle's 300 s lock landing
|
|||
|
|
/// before its own 600 s blank). The daemon routes this to the shell's lock
|
|||
|
|
/// surface while a live shell owns steady state, and takes the lock itself
|
|||
|
|
/// otherwise; either way the panel does not go dark until it is answered
|
|||
|
|
/// or the ack budget expires.
|
|||
|
|
Lock,
|
|||
|
|
/// Carry ordinary traffic over this bearer.
|
|||
|
|
///
|
|||
|
|
/// Expressed to NetworkManager as a route metric, never written with `ip
|
|||
|
|
/// route`: NM stays the single writer of routes, which is TASK-49's
|
|||
|
|
/// acceptance #6. Emitted only when the decision *changes* and has held
|
|||
|
|
/// for `bearer_settle`, so a link flapping at event speed cannot produce
|
|||
|
|
/// a command per event — the failure that recycled the tunnel 652 times.
|
|||
|
|
PreferLink(Bearer),
|
|||
|
|
/// Pin the WireGuard endpoint's host route to this bearer.
|
|||
|
|
///
|
|||
|
|
/// The tunnel is not a bearer; it rides one. Over clat it was measured
|
|||
|
|
/// sending and never receiving, and pinning the endpoint via wlan0
|
|||
|
|
/// produced a handshake in seconds. This does not turn the tunnel on or
|
|||
|
|
/// off — that switch is the user's and stays the user's.
|
|||
|
|
PinTunnelUnderlay(Bearer),
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// When each evidence source last reported. Kept beside `SensorEvidence`
|
|||
|
|
/// rather than inside it so the weights stay a pure function of the readings.
|
|||
|
|
#[derive(Debug, Clone, Default)]
|
|||
|
|
pub struct EvidenceSeen {
|
|||
|
|
pub proximity: Option<Instant>,
|
|||
|
|
pub accel: Option<Instant>,
|
|||
|
|
pub light: Option<Instant>,
|
|||
|
|
pub touch: Option<Instant>,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
impl EvidenceSeen {
|
|||
|
|
/// Last time this source was heard from, if ever. `None` means never —
|
|||
|
|
/// which is a different claim from "not recently", and the one the
|
|||
|
|
/// `Absent` check is built on.
|
|||
|
|
pub fn get(&self, source: SensorSource) -> Option<Instant> {
|
|||
|
|
match source {
|
|||
|
|
SensorSource::Proximity => self.proximity,
|
|||
|
|
SensorSource::Accelerometer => self.accel,
|
|||
|
|
SensorSource::Light => self.light,
|
|||
|
|
SensorSource::Touch => self.touch,
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Whether an evidence source is *there*, as opposed to what it last said.
|
|||
|
|
///
|
|||
|
|
/// Doctrine §9 says sensor readings are evidence, not fact. This is the
|
|||
|
|
/// sentence underneath that one: the machine must also know whether it is
|
|||
|
|
/// receiving evidence at all. `DEVICE-STATE-MACHINE.md` §0 states the
|
|||
|
|
/// requirement — "'No evidence' and 'evidence says nothing is happening' must
|
|||
|
|
/// not be the same state" — and until now they were.
|
|||
|
|
///
|
|||
|
|
/// Health is deliberately NOT read from `net.hadess.SensorProxy`'s
|
|||
|
|
/// `HasProximity`/`HasAccelerometer` properties, which is the obvious place to
|
|||
|
|
/// look and is wrong. Those lie in both directions, measured: on 2026-07-25 at
|
|||
|
|
/// 12:19 the stack was dead and the proxy answered `HasProximity: true` for
|
|||
|
|
/// hours (`PAF/slpi.md`), and in the 06:04 incident it answered `false` while
|
|||
|
|
/// remoteproc had already recovered SLPI. A property that is wrong both ways is
|
|||
|
|
/// not an authority. What the machine can actually trust is its own experience:
|
|||
|
|
/// whether readings arrive.
|
|||
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
|||
|
|
#[serde(rename_all = "snake_case")]
|
|||
|
|
pub enum SourceHealth {
|
|||
|
|
/// Nothing has ever been heard from this source, and nothing is expected
|
|||
|
|
/// to be. Correct for hardware with no reporter: `Touch` has none, so it
|
|||
|
|
/// sits here forever and says nothing. A signal that fires for a source
|
|||
|
|
/// nobody wired up is a signal nobody reads.
|
|||
|
|
#[default]
|
|||
|
|
Unknown,
|
|||
|
|
/// Reporting within `source_down_after`.
|
|||
|
|
Live,
|
|||
|
|
/// Reported once and has now been silent past the threshold.
|
|||
|
|
Down,
|
|||
|
|
/// A reporter is expected to serve this source and it has never once
|
|||
|
|
/// spoken, past `source_expected_within` from daemon start.
|
|||
|
|
///
|
|||
|
|
/// This variant exists because `Unknown` was silent by design and that
|
|||
|
|
/// silence hid a real outage for a whole boot (2026-07-27). `Down` cannot
|
|||
|
|
/// catch it: `evaluate_source_health` starts from the last-seen stamp, and
|
|||
|
|
/// a source that has never reported has no stamp, so it is skipped and
|
|||
|
|
/// stays `Unknown` forever. `souveraine-sensord` — the one reporter for
|
|||
|
|
/// every iio-sensor-proxy source (§12) — was `enabled` but never started,
|
|||
|
|
/// because its unit hung off `graphical-session.target` and nothing on
|
|||
|
|
/// this device starts that target. The machine ran the entire session with
|
|||
|
|
/// zero evidence, and the trail recorded not one line about it.
|
|||
|
|
///
|
|||
|
|
/// §10 is explicit that "no evidence" and "evidence says nothing is
|
|||
|
|
/// happening" must not be the same state. It made that true for a source
|
|||
|
|
/// that dies mid-session. This makes it true for one that never lived.
|
|||
|
|
Absent,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
impl SourceHealth {
|
|||
|
|
pub fn as_str(self) -> &'static str {
|
|||
|
|
match self {
|
|||
|
|
SourceHealth::Unknown => "unknown",
|
|||
|
|
SourceHealth::Live => "live",
|
|||
|
|
SourceHealth::Down => "down",
|
|||
|
|
SourceHealth::Absent => "absent",
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Per-source health, in the same shape as `EvidenceSeen`.
|
|||
|
|
#[derive(Debug, Clone, Copy, Default)]
|
|||
|
|
pub struct SourceHealthTable {
|
|||
|
|
pub proximity: SourceHealth,
|
|||
|
|
pub accel: SourceHealth,
|
|||
|
|
pub light: SourceHealth,
|
|||
|
|
pub touch: SourceHealth,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
impl SourceHealthTable {
|
|||
|
|
fn get_mut(&mut self, source: SensorSource) -> &mut SourceHealth {
|
|||
|
|
match source {
|
|||
|
|
SensorSource::Proximity => &mut self.proximity,
|
|||
|
|
SensorSource::Accelerometer => &mut self.accel,
|
|||
|
|
SensorSource::Light => &mut self.light,
|
|||
|
|
SensorSource::Touch => &mut self.touch,
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// True when any source the machine should be hearing from is not
|
|||
|
|
/// arriving — whether it died mid-session (`Down`) or never started
|
|||
|
|
/// (`Absent`). This is the single question the rest of the system asks: a
|
|||
|
|
/// surface showing "sensors degraded" does not need to know which one, and
|
|||
|
|
/// a decision taken on absent evidence is no sounder than one taken on
|
|||
|
|
/// evidence that stopped.
|
|||
|
|
pub fn any_down(&self) -> bool {
|
|||
|
|
[self.proximity, self.accel, self.light, self.touch]
|
|||
|
|
.iter()
|
|||
|
|
.any(|h| matches!(h, SourceHealth::Down | SourceHealth::Absent))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn as_json(&self) -> serde_json::Value {
|
|||
|
|
serde_json::json!({
|
|||
|
|
"proximity": self.proximity.as_str(),
|
|||
|
|
"accel": self.accel.as_str(),
|
|||
|
|
"light": self.light.as_str(),
|
|||
|
|
"touch": self.touch.as_str(),
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── Forensic logging ─────────────────────────────────────────────────
|
|||
|
|
// Every decision point emits a ForensicEntry capturing the full state
|
|||
|
|
// at that moment. These are appended to a JSONL file alongside the
|
|||
|
|
// SessionAudit trail. The audit trail says "lock-requested"; the
|
|||
|
|
// forensic trail says "lock-requested because idle timer fired at T,
|
|||
|
|
// state was Active, no inhibitor held, IdleCoordinator was at Dimmed,
|
|||
|
|
// last sensor input was proximity-far at T-30s."
|
|||
|
|
|
|||
|
|
/// How large the live trail may grow before it rotates.
|
|||
|
|
///
|
|||
|
|
/// The trail was unbounded until 2026-07-26 and measured at ~3.6 MB/day, so
|
|||
|
|
/// "unbounded" meant "fills the disk on a device that has no room for it".
|
|||
|
|
/// Bounding it is the other half of making it durable: a file that survives
|
|||
|
|
/// reboot is only an improvement if it also stops growing.
|
|||
|
|
pub const FORENSIC_MAX_BYTES: u64 = 4 * 1024 * 1024;
|
|||
|
|
|
|||
|
|
/// How many rotated generations are kept behind the live file.
|
|||
|
|
///
|
|||
|
|
/// Three files in total, so the ceiling is 12 MiB — about three days at the
|
|||
|
|
/// volume measured before proximity debounce (§9.5) exists, and considerably
|
|||
|
|
/// more once it does. The number is a floor on how far back an incident can be
|
|||
|
|
/// reconstructed, which is the only thing it is for.
|
|||
|
|
pub const FORENSIC_KEEP: usize = 2;
|
|||
|
|
|
|||
|
|
/// A forensic entry — one decision point in the device state machine.
|
|||
|
|
///
|
|||
|
|
/// `hash` is deliberately not a field here. It is computed over this struct's
|
|||
|
|
/// serialization and injected into the written line as the last key, exactly
|
|||
|
|
/// as `SessionAudit.qml` does, so one verifier reads both trails: strip the
|
|||
|
|
/// trailing `,"hash":"<hex>"`, close the object, SHA-256, compare.
|
|||
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|||
|
|
pub struct ForensicEntry {
|
|||
|
|
/// Monotonic sequence (from the audit trail).
|
|||
|
|
pub seq: u64,
|
|||
|
|
/// The hash of the previous entry, empty at the head of a chain.
|
|||
|
|
///
|
|||
|
|
/// §5 called this trail tamper-evident while it carried nothing but a
|
|||
|
|
/// sequence number, which detects a gap and nothing else — any past entry
|
|||
|
|
/// could be edited in place and the file still read as consistent. The
|
|||
|
|
/// chain is what the word was always claiming.
|
|||
|
|
#[serde(default)]
|
|||
|
|
pub prev: String,
|
|||
|
|
/// Wall-clock timestamp (seconds since epoch).
|
|||
|
|
pub ts: u64,
|
|||
|
|
/// What happened.
|
|||
|
|
pub event: ForensicEvent,
|
|||
|
|
/// Full state snapshot at this moment.
|
|||
|
|
pub snapshot: StateSnapshot,
|
|||
|
|
/// Why this decision was made (human-readable).
|
|||
|
|
pub reason: String,
|
|||
|
|
/// The caller's declared intent, when one was given.
|
|||
|
|
///
|
|||
|
|
/// A chain is one decision and several verbs (doctrine §13). Without this
|
|||
|
|
/// the trail records the leaves and loses the thing that produced them —
|
|||
|
|
/// reconstruction sees four unrelated calls and cannot tell a considered
|
|||
|
|
/// sequence from four accidents. Skipped entirely when absent, so entries
|
|||
|
|
/// without an intent serialize byte-for-byte as they did before and the
|
|||
|
|
/// hash contract does not move.
|
|||
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|||
|
|
pub intent: Option<String>,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// What kind of forensic event occurred.
|
|||
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|||
|
|
#[serde(rename_all = "snake_case")]
|
|||
|
|
pub enum ForensicEvent {
|
|||
|
|
/// A state transition happened (or was refused).
|
|||
|
|
Transition {
|
|||
|
|
from: DeviceState,
|
|||
|
|
to: DeviceState,
|
|||
|
|
legal: bool,
|
|||
|
|
},
|
|||
|
|
/// Sensor input was received and evaluated.
|
|||
|
|
SensorInput {
|
|||
|
|
source: SensorSource,
|
|||
|
|
value: SensorValue,
|
|||
|
|
confidence: f32,
|
|||
|
|
},
|
|||
|
|
/// A wake event occurred (screen on, dt2w, power button).
|
|||
|
|
Wake { trigger: WakeTrigger },
|
|||
|
|
/// An error occurred that affected device state.
|
|||
|
|
Error {
|
|||
|
|
component: String,
|
|||
|
|
action: String,
|
|||
|
|
error: String,
|
|||
|
|
},
|
|||
|
|
/// A decision was made (e.g., "suppress DPMS wake" or "promote idle").
|
|||
|
|
Decision {
|
|||
|
|
decision: String,
|
|||
|
|
inputs: serde_json::Value,
|
|||
|
|
},
|
|||
|
|
/// Periodic heartbeat snapshot (for reconstructing timeline gaps).
|
|||
|
|
Heartbeat,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// What triggered a wake event.
|
|||
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|||
|
|
#[serde(rename_all = "snake_case")]
|
|||
|
|
pub enum WakeTrigger {
|
|||
|
|
PowerButton,
|
|||
|
|
DoubleTapToWake,
|
|||
|
|
Squeeze,
|
|||
|
|
ProximityFar,
|
|||
|
|
RtcAlarm,
|
|||
|
|
ModemIrq,
|
|||
|
|
UserInput,
|
|||
|
|
Unknown,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Full state snapshot — everything needed to reconstruct the decision.
|
|||
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|||
|
|
pub struct StateSnapshot {
|
|||
|
|
pub device_state: DeviceState,
|
|||
|
|
pub locked: bool,
|
|||
|
|
pub display_active: bool,
|
|||
|
|
pub phase: String,
|
|||
|
|
pub shell_alive: bool,
|
|||
|
|
pub proximity_near: bool,
|
|||
|
|
pub confidence: f32,
|
|||
|
|
pub suppress_dpms_wake: bool,
|
|||
|
|
pub promote_idle_faster: bool,
|
|||
|
|
pub screen_locked: bool,
|
|||
|
|
pub screen_lock_secure: bool,
|
|||
|
|
pub idle_coordinator_state: String,
|
|||
|
|
pub sleep_inhibitor_held: bool,
|
|||
|
|
/// True when some evidence source that used to report has gone silent.
|
|||
|
|
///
|
|||
|
|
/// On the snapshot rather than only on the health entry because this is
|
|||
|
|
/// what makes the trail diagnostic after the fact: every decision taken
|
|||
|
|
/// during an outage is stamped with the fact that the machine was deciding
|
|||
|
|
/// on absent evidence. Without it, a `panel-off` recorded during four dead
|
|||
|
|
/// hours is indistinguishable from a healthy one, and the log answers
|
|||
|
|
/// "what happened" but not "why was it wrong".
|
|||
|
|
pub sensors_degraded: bool,
|
|||
|
|
/// Where the machine believed the phone was, and how sure it was.
|
|||
|
|
///
|
|||
|
|
/// On every snapshot for the same reason `sensors_degraded` is: a decision
|
|||
|
|
/// is only reconstructable if the trail records what it was deciding on.
|
|||
|
|
/// "Refused a wake" and "refused a wake believing this was a pocket at 0.35"
|
|||
|
|
/// are the same line and different facts.
|
|||
|
|
pub placement: Placement,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Forensic log — accumulates entries for post-hoc analysis.
|
|||
|
|
/// Thread-safe; entries can be added from any thread.
|
|||
|
|
pub struct ForensicLog {
|
|||
|
|
entries: Arc<Mutex<Vec<ForensicEntry>>>,
|
|||
|
|
/// Sequence, chain head, and file health under **one** lock.
|
|||
|
|
///
|
|||
|
|
/// They were two locks and a write that happened after both were dropped,
|
|||
|
|
/// which was survivable while the only content was a sequence number and
|
|||
|
|
/// is not once entries chain: two threads could take the same `prev` and
|
|||
|
|
/// write in either order, and the resulting file would read as tampered.
|
|||
|
|
writer: Arc<Mutex<TrailWriter>>,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// The durable half of the trail: which file, where the chain is, and whether
|
|||
|
|
/// writing is currently working.
|
|||
|
|
struct TrailWriter {
|
|||
|
|
/// `None` means in-memory only — no writable home (LOUD at startup), or a
|
|||
|
|
/// test that has no business touching the real trail.
|
|||
|
|
path: Option<PathBuf>,
|
|||
|
|
next_seq: u64,
|
|||
|
|
/// Hash of the last entry written, which the next entry's `prev` carries.
|
|||
|
|
/// Deliberately **not** reset by rotation: the chain runs across the file
|
|||
|
|
/// boundary, so a rotated set verifies end to end rather than as three
|
|||
|
|
/// unrelated logs.
|
|||
|
|
last_hash: String,
|
|||
|
|
/// Bytes in the live file, tracked rather than stat'd per append.
|
|||
|
|
bytes: u64,
|
|||
|
|
/// The rotation threshold. A field rather than the constant read inline so
|
|||
|
|
/// the rotation rules can be tested at a few hundred bytes instead of by
|
|||
|
|
/// writing 12 MiB of real entries.
|
|||
|
|
max_bytes: u64,
|
|||
|
|
/// The intent the current caller declared, stamped onto every entry
|
|||
|
|
/// produced while it is set. Lives here because `append` already takes
|
|||
|
|
/// this lock — anywhere else would need threading through every call site
|
|||
|
|
/// that records anything.
|
|||
|
|
intent: Option<String>,
|
|||
|
|
/// True while writes are failing, so the warning is one per outage edge
|
|||
|
|
/// instead of one per tick. A trail that cannot write is exactly the kind
|
|||
|
|
/// of failure that must not drown out what it was recording.
|
|||
|
|
failed: bool,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
impl ForensicLog {
|
|||
|
|
/// Open the durable trail at its standard path.
|
|||
|
|
pub fn new() -> Self {
|
|||
|
|
// Tests get an in-memory trail unless they ask for a file. Otherwise
|
|||
|
|
// every `DeviceStateMachine::new()` in the suite would append to the
|
|||
|
|
// developer's real trail and rotate it — the file-backed behaviour is
|
|||
|
|
// covered by tests that name their own path.
|
|||
|
|
#[cfg(test)]
|
|||
|
|
let path = None;
|
|||
|
|
#[cfg(not(test))]
|
|||
|
|
let path = match forensic_log_path() {
|
|||
|
|
Some(p) => Some(p),
|
|||
|
|
None => {
|
|||
|
|
warn!(
|
|||
|
|
"[device-state] no HOME or XDG_STATE_HOME — the forensic trail is MEMORY-ONLY this run and dies with the daemon"
|
|||
|
|
);
|
|||
|
|
None
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
Self::open(path)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Open the trail at an explicit path.
|
|||
|
|
#[cfg(test)]
|
|||
|
|
pub fn with_path(path: PathBuf) -> Self {
|
|||
|
|
Self::open_bounded(Some(path), FORENSIC_MAX_BYTES)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Open the trail with an explicit rotation threshold. Tests only — the
|
|||
|
|
/// live bound is `FORENSIC_MAX_BYTES` and is not a per-caller choice.
|
|||
|
|
#[cfg(test)]
|
|||
|
|
pub fn with_path_bounded(path: PathBuf, max_bytes: u64) -> Self {
|
|||
|
|
Self::open_bounded(Some(path), max_bytes)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn open(path: Option<PathBuf>) -> Self {
|
|||
|
|
Self::open_bounded(path, FORENSIC_MAX_BYTES)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn open_bounded(path: Option<PathBuf>, max_bytes: u64) -> Self {
|
|||
|
|
let writer = match path {
|
|||
|
|
Some(path) => TrailWriter::open(path, max_bytes),
|
|||
|
|
None => TrailWriter::memory_only(),
|
|||
|
|
};
|
|||
|
|
Self {
|
|||
|
|
entries: Arc::new(Mutex::new(Vec::new())),
|
|||
|
|
writer: Arc::new(Mutex::new(writer)),
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Where the trail is being written, or `None` when it is memory-only.
|
|||
|
|
pub fn path(&self) -> Option<PathBuf> {
|
|||
|
|
self.writer
|
|||
|
|
.lock()
|
|||
|
|
.unwrap_or_else(|e| e.into_inner())
|
|||
|
|
.path
|
|||
|
|
.clone()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// How the chain the daemon just opened relates to the one before it.
|
|||
|
|
pub fn chain_state(&self) -> &'static str {
|
|||
|
|
let w = self.writer.lock().unwrap_or_else(|e| e.into_inner());
|
|||
|
|
if w.path.is_none() {
|
|||
|
|
"memory-only"
|
|||
|
|
} else if w.next_seq == 0 {
|
|||
|
|
"new"
|
|||
|
|
} else {
|
|||
|
|
"resumed"
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Append a forensic entry. The seq is auto-incremented and the entry is
|
|||
|
|
/// chained to the one before it, then written to the durable trail.
|
|||
|
|
pub fn append(&self, event: ForensicEvent, snapshot: StateSnapshot, reason: &str) {
|
|||
|
|
let mut w = self.writer.lock().unwrap_or_else(|e| e.into_inner());
|
|||
|
|
let entry = ForensicEntry {
|
|||
|
|
seq: w.next_seq,
|
|||
|
|
prev: w.last_hash.clone(),
|
|||
|
|
ts: std::time::SystemTime::now()
|
|||
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|||
|
|
.unwrap_or_default()
|
|||
|
|
.as_secs(),
|
|||
|
|
event,
|
|||
|
|
snapshot,
|
|||
|
|
reason: reason.to_string(),
|
|||
|
|
intent: w.intent.clone(),
|
|||
|
|
};
|
|||
|
|
w.next_seq += 1;
|
|||
|
|
w.write(&entry);
|
|||
|
|
drop(w);
|
|||
|
|
|
|||
|
|
let mut entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
|
|||
|
|
entries.push(entry);
|
|||
|
|
// Keep last 1000 entries in memory (the JSONL file is the
|
|||
|
|
// durable store; this is for IPC queries).
|
|||
|
|
let len = entries.len();
|
|||
|
|
if len > 1000 {
|
|||
|
|
entries.drain(0..len - 1000);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Declare what the caller is doing. Every entry recorded until this is
|
|||
|
|
/// cleared carries it. Set around one request, not held across them — an
|
|||
|
|
/// intent that outlives its chain mislabels whatever comes next.
|
|||
|
|
pub fn set_intent(&self, intent: Option<String>) {
|
|||
|
|
self.writer.lock().unwrap_or_else(|e| e.into_inner()).intent = intent;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Get recent entries (for IPC queries).
|
|||
|
|
pub fn recent(&self, count: usize) -> Vec<ForensicEntry> {
|
|||
|
|
let entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
|
|||
|
|
let start = entries.len().saturating_sub(count);
|
|||
|
|
entries[start..].to_vec()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Entries recorded after `seq`, oldest first.
|
|||
|
|
///
|
|||
|
|
/// The cursor is the sequence number the trail already mints for its hash
|
|||
|
|
/// chain, so a consumer that reconnects resumes exactly where it stopped
|
|||
|
|
/// and a restarted daemon cannot silently replay — the chain and the
|
|||
|
|
/// stream agree by construction rather than by a second counter kept in
|
|||
|
|
/// step by hand.
|
|||
|
|
pub fn since(&self, seq: u64) -> Vec<ForensicEntry> {
|
|||
|
|
let entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
|
|||
|
|
entries.iter().filter(|e| e.seq > seq).cloned().collect()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// The highest sequence the in-memory buffer holds.
|
|||
|
|
pub fn head_seq(&self) -> u64 {
|
|||
|
|
let entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
|
|||
|
|
entries.last().map(|e| e.seq).unwrap_or(0)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// One button's edges, accumulating into gestures.
|
|||
|
|
///
|
|||
|
|
/// This is the chordotonal principle applied to a button: the thing that turns
|
|||
|
|
/// a run of drivers (down, up, down, up) into one piece of evidence ("double
|
|||
|
|
/// tap") lives below the decision, and only the compressed answer travels
|
|||
|
|
/// upward. A consumer never sees the edges.
|
|||
|
|
///
|
|||
|
|
/// Deliberately per-button and stateless about *meaning*. It recognises; it
|
|||
|
|
/// does not decide what a triple-tap is for. Binding is policy and lives above.
|
|||
|
|
#[derive(Debug, Clone, Default)]
|
|||
|
|
pub struct ButtonRecognizer {
|
|||
|
|
/// When the button went down, while it is down.
|
|||
|
|
down_since: Option<Instant>,
|
|||
|
|
/// Holds already announced for the current press, so `tick` does not
|
|||
|
|
/// re-fire them every second while a finger rests on the button.
|
|||
|
|
hold_fired: bool,
|
|||
|
|
long_hold_fired: bool,
|
|||
|
|
/// Releases that have not yet resolved into a tap gesture, and when the
|
|||
|
|
/// most recent one landed.
|
|||
|
|
pending_taps: u8,
|
|||
|
|
last_release: Option<Instant>,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
impl ButtonRecognizer {
|
|||
|
|
/// A press edge. Returns nothing — a press alone is never a gesture, and
|
|||
|
|
/// pretending otherwise is what makes a double-tap fire a single first.
|
|||
|
|
pub fn down(&mut self, now: Instant) {
|
|||
|
|
self.down_since = Some(now);
|
|||
|
|
self.hold_fired = false;
|
|||
|
|
self.long_hold_fired = false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// A release edge. Returns a gesture only when the release *completes* one
|
|||
|
|
/// immediately — which is never, for taps. A hold that already fired
|
|||
|
|
/// resolves to nothing here: it was announced while the button was down,
|
|||
|
|
/// and announcing it again on release would double-fire every binding.
|
|||
|
|
pub fn up(&mut self, now: Instant) -> Option<ButtonGesture> {
|
|||
|
|
let held = self.down_since.take().map(|t| now.duration_since(t));
|
|||
|
|
if self.hold_fired {
|
|||
|
|
// A hold was already announced. Releasing ends it and starts no
|
|||
|
|
// tap — a long press is not also a tap, and counting it as one is
|
|||
|
|
// how "hold to power off" also toggles your screen.
|
|||
|
|
self.pending_taps = 0;
|
|||
|
|
self.last_release = None;
|
|||
|
|
return None;
|
|||
|
|
}
|
|||
|
|
if held.is_some_and(|d| d >= BUTTON_HOLD) {
|
|||
|
|
// Held long enough, but tick never saw it (a press shorter than one
|
|||
|
|
// tick interval that still crossed the threshold). Announce now.
|
|||
|
|
self.pending_taps = 0;
|
|||
|
|
self.last_release = None;
|
|||
|
|
return Some(ButtonGesture::Hold);
|
|||
|
|
}
|
|||
|
|
self.pending_taps = self.pending_taps.saturating_add(1);
|
|||
|
|
self.last_release = Some(now);
|
|||
|
|
None
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Called on every tick. Fires holds while the button is still down, and
|
|||
|
|
/// resolves pending taps once the multi-tap window has closed.
|
|||
|
|
pub fn tick(&mut self, now: Instant) -> Option<ButtonGesture> {
|
|||
|
|
if let Some(since) = self.down_since {
|
|||
|
|
let held = now.duration_since(since);
|
|||
|
|
if held >= BUTTON_LONG_HOLD && !self.long_hold_fired {
|
|||
|
|
self.long_hold_fired = true;
|
|||
|
|
return Some(ButtonGesture::LongHold);
|
|||
|
|
}
|
|||
|
|
if held >= BUTTON_HOLD && !self.hold_fired {
|
|||
|
|
self.hold_fired = true;
|
|||
|
|
return Some(ButtonGesture::Hold);
|
|||
|
|
}
|
|||
|
|
return None;
|
|||
|
|
}
|
|||
|
|
let last = self.last_release?;
|
|||
|
|
if now.duration_since(last) < BUTTON_MULTI_TAP_WINDOW {
|
|||
|
|
return None;
|
|||
|
|
}
|
|||
|
|
let n = std::mem::take(&mut self.pending_taps);
|
|||
|
|
self.last_release = None;
|
|||
|
|
match n {
|
|||
|
|
0 => None,
|
|||
|
|
1 => Some(ButtonGesture::Tap),
|
|||
|
|
2 => Some(ButtonGesture::DoubleTap),
|
|||
|
|
// Four taps is a triple plus a stray, not a new gesture. Saturating
|
|||
|
|
// here beats inventing a QuadrupleTap nobody asked for.
|
|||
|
|
_ => Some(ButtonGesture::TripleTap),
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Is this entry worth waking a mind for?
|
|||
|
|
///
|
|||
|
|
/// The whole value of the subscribe stream is this predicate. §10 is the
|
|||
|
|
/// argument in miniature: a sensor resting at `far` and a sensor whose stack
|
|||
|
|
/// took a CHRE fatal produce byte-identical silence, so "no evidence" and
|
|||
|
|
/// "evidence says nothing is happening" had to become different states. The
|
|||
|
|
/// same distinction decides what crosses to the agent — an edge means
|
|||
|
|
/// something, a level does not.
|
|||
|
|
///
|
|||
|
|
/// What crosses:
|
|||
|
|
/// - **Transitions**, legal or refused. A refused one is the more interesting:
|
|||
|
|
/// the machine wanted to move and its own guard said no.
|
|||
|
|
/// - **Errors.** Both classes. `error-security` is a violated guarantee;
|
|||
|
|
/// `error-operational` is an actuator that did not do as it was told, which
|
|||
|
|
/// is how a `panel-off` recorded during four dead hours stops reading like a
|
|||
|
|
/// healthy one.
|
|||
|
|
/// - **Decisions**, which is where `source-down` / `source-recovered` and
|
|||
|
|
/// cross-sensor disagreement already land.
|
|||
|
|
///
|
|||
|
|
/// What never crosses: `Heartbeat` (it exists to fill timeline gaps for a
|
|||
|
|
/// reader, and a mind is not a reader), `SensorInput` (a reading is a driver
|
|||
|
|
/// wearing evidence's clothes — 2,900 proximity lines a day saying nothing
|
|||
|
|
/// changed, and the exact stream P1 says an inferring model must not have),
|
|||
|
|
/// and `Wake`, which is already implied by the transition it causes.
|
|||
|
|
pub fn is_notable(event: &ForensicEvent) -> bool {
|
|||
|
|
match event {
|
|||
|
|
ForensicEvent::Transition { .. } => true,
|
|||
|
|
ForensicEvent::Error { .. } => true,
|
|||
|
|
ForensicEvent::Decision { .. } => true,
|
|||
|
|
ForensicEvent::SensorInput { .. } => false,
|
|||
|
|
ForensicEvent::Wake { .. } => false,
|
|||
|
|
ForensicEvent::Heartbeat => false,
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
impl TrailWriter {
|
|||
|
|
fn memory_only() -> Self {
|
|||
|
|
Self {
|
|||
|
|
path: None,
|
|||
|
|
next_seq: 0,
|
|||
|
|
last_hash: String::new(),
|
|||
|
|
bytes: 0,
|
|||
|
|
max_bytes: FORENSIC_MAX_BYTES,
|
|||
|
|
intent: None,
|
|||
|
|
failed: false,
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Open the trail, continuing the existing chain where there is one.
|
|||
|
|
fn open(path: PathBuf, max_bytes: u64) -> Self {
|
|||
|
|
let mut w = Self {
|
|||
|
|
path: Some(path.clone()),
|
|||
|
|
next_seq: 0,
|
|||
|
|
last_hash: String::new(),
|
|||
|
|
bytes: 0,
|
|||
|
|
max_bytes,
|
|||
|
|
intent: None,
|
|||
|
|
failed: false,
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
if let Some(dir) = path.parent() {
|
|||
|
|
if let Err(e) = std::fs::create_dir_all(dir) {
|
|||
|
|
warn!(
|
|||
|
|
"[device-state] cannot create {} ({e}) — the forensic trail is MEMORY-ONLY this run",
|
|||
|
|
dir.display()
|
|||
|
|
);
|
|||
|
|
w.path = None;
|
|||
|
|
return w;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let existing = match std::fs::read_to_string(&path) {
|
|||
|
|
Ok(body) => body,
|
|||
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return w,
|
|||
|
|
Err(e) => {
|
|||
|
|
warn!(
|
|||
|
|
"[device-state] cannot read the existing trail at {} ({e}) — the forensic trail is MEMORY-ONLY this run",
|
|||
|
|
path.display()
|
|||
|
|
);
|
|||
|
|
w.path = None;
|
|||
|
|
return w;
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Walk back to the last line that parses.
|
|||
|
|
//
|
|||
|
|
// A torn *final* line is the expected result of losing power mid-write,
|
|||
|
|
// not evidence of tampering — measured 2026-07-26, when a hard reboot
|
|||
|
|
// left the file at exactly 4096 bytes, a page boundary, with the last
|
|||
|
|
// entry cut in half. Treating that as tampering cost the chain its
|
|||
|
|
// continuity across precisely the event most worth investigating.
|
|||
|
|
//
|
|||
|
|
// So: discard the torn tail, resume from the last good entry, and say
|
|||
|
|
// so. A bad hash in the *middle* is a different claim and this does not
|
|||
|
|
// hide it — the verifier still reads every line, and everything up to
|
|||
|
|
// the tear stays intact and chained.
|
|||
|
|
let mut good_end = 0usize; // byte offset just past the last good line
|
|||
|
|
let mut last_good: Option<serde_json::Value> = None;
|
|||
|
|
for line in existing.split_inclusive('\n') {
|
|||
|
|
let trimmed = line.trim_end_matches('\n');
|
|||
|
|
if trimmed.trim().is_empty() {
|
|||
|
|
good_end += line.len();
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
match serde_json::from_str::<serde_json::Value>(trimmed) {
|
|||
|
|
Ok(v) => {
|
|||
|
|
good_end += line.len();
|
|||
|
|
last_good = Some(v);
|
|||
|
|
}
|
|||
|
|
Err(_) => break, // torn or damaged — everything from here goes
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let discarded = existing.len() - good_end;
|
|||
|
|
if discarded > 0 {
|
|||
|
|
let Some(_) = last_good.as_ref() else {
|
|||
|
|
// Nothing in the file parses at all. That is not a torn tail;
|
|||
|
|
// it is a file we cannot chain onto. Keep it as evidence.
|
|||
|
|
warn!(
|
|||
|
|
"[device-state] no parseable entry in {} — rotating it aside and STARTING A NEW CHAIN; the old file is kept",
|
|||
|
|
path.display()
|
|||
|
|
);
|
|||
|
|
w.rotate();
|
|||
|
|
return w;
|
|||
|
|
};
|
|||
|
|
warn!(
|
|||
|
|
"[device-state] discarding {discarded} torn bytes from the tail of {} — a write that did not reach disk, almost certainly a hard reboot; the chain resumes from the last intact entry",
|
|||
|
|
path.display()
|
|||
|
|
);
|
|||
|
|
if let Err(e) = std::fs::OpenOptions::new()
|
|||
|
|
.write(true)
|
|||
|
|
.open(&path)
|
|||
|
|
.and_then(|f| f.set_len(good_end as u64))
|
|||
|
|
{
|
|||
|
|
warn!(
|
|||
|
|
"[device-state] could not truncate the torn tail of {} ({e}) — the forensic trail is MEMORY-ONLY this run",
|
|||
|
|
path.display()
|
|||
|
|
);
|
|||
|
|
w.path = None;
|
|||
|
|
return w;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let Some(v) = last_good else {
|
|||
|
|
return w; // present but empty: a fresh chain, not a damaged one
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
w.next_seq = v.get("seq").and_then(|s| s.as_u64()).unwrap_or(0) + 1;
|
|||
|
|
w.last_hash = v
|
|||
|
|
.get("hash")
|
|||
|
|
.and_then(|h| h.as_str())
|
|||
|
|
.unwrap_or_default()
|
|||
|
|
.to_string();
|
|||
|
|
w.bytes = good_end as u64;
|
|||
|
|
info!(
|
|||
|
|
"[device-state] forensic trail resumed at {} (seq {}, {} KiB)",
|
|||
|
|
path.display(),
|
|||
|
|
w.next_seq,
|
|||
|
|
w.bytes / 1024
|
|||
|
|
);
|
|||
|
|
w
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Serialize, hash, and write one entry. Every failure is loud once.
|
|||
|
|
fn write(&mut self, entry: &ForensicEntry) {
|
|||
|
|
if self.path.is_none() {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
let body = match serde_json::to_string(entry) {
|
|||
|
|
Ok(b) => b,
|
|||
|
|
Err(e) => {
|
|||
|
|
// Not a file problem: the entry itself will not serialize.
|
|||
|
|
// Never silent — a decision that cannot be recorded is one the
|
|||
|
|
// trail would otherwise imply never happened.
|
|||
|
|
warn!(
|
|||
|
|
"[device-state] forensic entry seq {} will not serialize ({e}) — NOT RECORDED",
|
|||
|
|
entry.seq
|
|||
|
|
);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
let hash = sha256_hex(&body);
|
|||
|
|
// The hash goes in as the last key, so the hashed bytes are the line
|
|||
|
|
// with `,"hash":"<hex>"` removed and the object closed again.
|
|||
|
|
let line = match body.strip_suffix('}') {
|
|||
|
|
Some(open) => format!("{open},\"hash\":\"{hash}\"}}\n"),
|
|||
|
|
None => {
|
|||
|
|
warn!("[device-state] forensic entry seq {} serialized to something that is not an object — NOT RECORDED", entry.seq);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
if self.bytes + line.len() as u64 > self.max_bytes {
|
|||
|
|
self.rotate();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let Some(path) = self.path.clone() else {
|
|||
|
|
return;
|
|||
|
|
};
|
|||
|
|
let written = std::fs::OpenOptions::new()
|
|||
|
|
.create(true)
|
|||
|
|
.append(true)
|
|||
|
|
.open(&path)
|
|||
|
|
.and_then(|mut f| {
|
|||
|
|
use std::io::Write;
|
|||
|
|
f.write_all(line.as_bytes())
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
match written {
|
|||
|
|
Ok(()) => {
|
|||
|
|
self.bytes += line.len() as u64;
|
|||
|
|
if self.failed {
|
|||
|
|
self.failed = false;
|
|||
|
|
info!(
|
|||
|
|
"[device-state] forensic trail at {} is writable again",
|
|||
|
|
path.display()
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
// The chain only advances on a line that actually landed.
|
|||
|
|
// Advancing it on a failed write would leave the next entry
|
|||
|
|
// pointing at a `prev` no file contains, which reads as
|
|||
|
|
// tampering rather than as the outage it is.
|
|||
|
|
self.last_hash = hash;
|
|||
|
|
}
|
|||
|
|
Err(e) => {
|
|||
|
|
if !self.failed {
|
|||
|
|
self.failed = true;
|
|||
|
|
warn!(
|
|||
|
|
"[device-state] CANNOT WRITE the forensic trail at {} ({e}) — decisions are being taken and not recorded",
|
|||
|
|
path.display()
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Shift the generations along and start a new live file.
|
|||
|
|
///
|
|||
|
|
/// `last_hash` survives this on purpose: the first entry of the new file
|
|||
|
|
/// carries the hash of the last entry of the rotated one, so a rotated set
|
|||
|
|
/// verifies as one chain. A verifier reading a single file in isolation
|
|||
|
|
/// finds a non-empty `prev` on line 1, which is the correct answer — its
|
|||
|
|
/// predecessor is the next file along, not nothing.
|
|||
|
|
fn rotate(&mut self) {
|
|||
|
|
let Some(path) = self.path.clone() else {
|
|||
|
|
return;
|
|||
|
|
};
|
|||
|
|
let gen = |n: usize| path.with_extension(format!("jsonl.{n}"));
|
|||
|
|
|
|||
|
|
let _ = std::fs::remove_file(gen(FORENSIC_KEEP));
|
|||
|
|
for n in (1..FORENSIC_KEEP).rev() {
|
|||
|
|
let _ = std::fs::rename(gen(n), gen(n + 1));
|
|||
|
|
}
|
|||
|
|
if let Err(e) = std::fs::rename(&path, gen(1)) {
|
|||
|
|
if e.kind() != std::io::ErrorKind::NotFound {
|
|||
|
|
warn!(
|
|||
|
|
"[device-state] cannot rotate the forensic trail at {} ({e}) — it will keep growing",
|
|||
|
|
path.display()
|
|||
|
|
);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
self.bytes = 0;
|
|||
|
|
info!(
|
|||
|
|
"[device-state] forensic trail rotated at {} bytes, keeping {} generations",
|
|||
|
|
self.max_bytes, FORENSIC_KEEP
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn sha256_hex(body: &str) -> String {
|
|||
|
|
use sha2::{Digest, Sha256};
|
|||
|
|
let mut h = Sha256::new();
|
|||
|
|
h.update(body.as_bytes());
|
|||
|
|
h.finalize().iter().map(|b| format!("{b:02x}")).collect()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Path to the forensic log file.
|
|||
|
|
///
|
|||
|
|
/// `$XDG_STATE_HOME/souveraine/forensic.jsonl`, which is `~/.local/state` in
|
|||
|
|
/// practice and is where the XDG spec puts logs — the same directory as
|
|||
|
|
/// `crashes.log`. It was `$XDG_RUNTIME_DIR` until 2026-07-26, which is tmpfs:
|
|||
|
|
/// RAM on a 3.5 GB phone, erased on every reboot. §5 calls this trail
|
|||
|
|
/// tamper-evident and pairs it with the audit hash chain; a file that
|
|||
|
|
/// evaporates when the device restarts cannot be either. Nothing is migrated
|
|||
|
|
/// from the old location because there is never anything there to migrate.
|
|||
|
|
///
|
|||
|
|
/// `None` when there is no home to write to. There is no `/tmp` fallback: a
|
|||
|
|
/// trail nobody can find later is not a trail, and pretending otherwise is how
|
|||
|
|
/// this one spent a month looking durable.
|
|||
|
|
#[cfg(not(test))]
|
|||
|
|
fn forensic_log_path() -> Option<PathBuf> {
|
|||
|
|
let base = std::env::var_os("XDG_STATE_HOME")
|
|||
|
|
.map(PathBuf::from)
|
|||
|
|
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/state")))?;
|
|||
|
|
Some(base.join("souveraine").join("forensic.jsonl"))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Re-export for use in protocol.rs
|
|||
|
|
pub use self::ForensicEvent as DeviceStateForensicEvent;
|
|||
|
|
pub use self::WakeTrigger as DeviceStateWakeTrigger;
|
|||
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|||
|
|
#[serde(rename_all = "snake_case")]
|
|||
|
|
pub enum DeviceState {
|
|||
|
|
/// Screen on, user present, unlocked or lockable.
|
|||
|
|
Active,
|
|||
|
|
/// Screen dim, user idle, not yet locked.
|
|||
|
|
Dimmed,
|
|||
|
|
/// Screen locked, compositor secure, user may or may not be present.
|
|||
|
|
/// This is the base locked state. Observed/Doze are sub-states.
|
|||
|
|
Locked,
|
|||
|
|
/// Locked + sensor evidence of user presence. EVIDENCE, not FACT.
|
|||
|
|
/// Gates: DPMS wake suppression, idle tier promotion.
|
|||
|
|
/// Never gates: lock/unlock, security tiers, personal data.
|
|||
|
|
Observed,
|
|||
|
|
/// Locked + idle N min. App tier frozen. Wi-Fi power-save.
|
|||
|
|
DozeLight,
|
|||
|
|
/// Locked + idle M min. Network fetchers stopped. RTC wake only.
|
|||
|
|
DozeDeep,
|
|||
|
|
/// PrepareForSleep(true). Inhibitor held. Waiting for lock secure.
|
|||
|
|
Suspending,
|
|||
|
|
/// s2idle. Panel off. Touch in gesture mode. RTC + modem IRQs only.
|
|||
|
|
Asleep,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Transition table — legal (from, to) pairs. Anything not in this list
|
|||
|
|
/// is refused and logged.
|
|||
|
|
const LEGAL_TRANSITIONS: &[(DeviceState, DeviceState)] = &[
|
|||
|
|
// Active → idle dim/lock
|
|||
|
|
(DeviceState::Active, DeviceState::Dimmed),
|
|||
|
|
(DeviceState::Active, DeviceState::Locked),
|
|||
|
|
// Dimmed → user input or idle lock
|
|||
|
|
(DeviceState::Dimmed, DeviceState::Active),
|
|||
|
|
(DeviceState::Dimmed, DeviceState::Locked),
|
|||
|
|
// Locked → user auth, sensor evidence, or idle promotion
|
|||
|
|
(DeviceState::Locked, DeviceState::Active),
|
|||
|
|
(DeviceState::Locked, DeviceState::Observed),
|
|||
|
|
(DeviceState::Locked, DeviceState::DozeLight),
|
|||
|
|
// PAM auth unlocks from ANY locked sub-state, not just the base one.
|
|||
|
|
// Without these, unlocking a phone that was in Observed (proximity had
|
|||
|
|
// fired) or dozing was refused as an illegal transition.
|
|||
|
|
(DeviceState::Observed, DeviceState::Active),
|
|||
|
|
(DeviceState::DozeLight, DeviceState::Active),
|
|||
|
|
(DeviceState::DozeDeep, DeviceState::Active),
|
|||
|
|
// Observed → back to Locked (proximity far) or deeper doze
|
|||
|
|
(DeviceState::Observed, DeviceState::Locked),
|
|||
|
|
(DeviceState::Observed, DeviceState::DozeLight),
|
|||
|
|
// DozeLight → user wake or deeper doze
|
|||
|
|
(DeviceState::DozeLight, DeviceState::Locked),
|
|||
|
|
(DeviceState::DozeLight, DeviceState::DozeDeep),
|
|||
|
|
// DozeDeep → user wake (RTC, modem, input)
|
|||
|
|
(DeviceState::DozeDeep, DeviceState::Locked),
|
|||
|
|
// Any pre-sleep → Suspending (logind is the authority)
|
|||
|
|
(DeviceState::Active, DeviceState::Suspending),
|
|||
|
|
(DeviceState::Dimmed, DeviceState::Suspending),
|
|||
|
|
(DeviceState::Locked, DeviceState::Suspending),
|
|||
|
|
(DeviceState::Observed, DeviceState::Suspending),
|
|||
|
|
(DeviceState::DozeLight, DeviceState::Suspending),
|
|||
|
|
(DeviceState::DozeDeep, DeviceState::Suspending),
|
|||
|
|
// Suspending → Asleep (lock secure + inhibitor released)
|
|||
|
|
(DeviceState::Suspending, DeviceState::Asleep),
|
|||
|
|
// Asleep → Locked (wake, lock persists)
|
|||
|
|
(DeviceState::Asleep, DeviceState::Locked),
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
/// Which states count as "locked" for security purposes.
|
|||
|
|
/// Where the phone believes it is. See [`DeviceStateMachine::placement`].
|
|||
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
|||
|
|
#[serde(rename_all = "snake_case")]
|
|||
|
|
pub enum PlacementBelief {
|
|||
|
|
Hand,
|
|||
|
|
Table,
|
|||
|
|
Pocket,
|
|||
|
|
/// A call is the only thing that makes a covered sensor mean a face, and
|
|||
|
|
/// there is no call-state input yet (§4, "Owed: the call"). Never inferred.
|
|||
|
|
Face,
|
|||
|
|
Unknown,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// A placement belief and the evidence that carried it.
|
|||
|
|
///
|
|||
|
|
/// The facts travel with the answer so a refusal can say what it believed and
|
|||
|
|
/// why, rather than asserting a conclusion the caller cannot argue with.
|
|||
|
|
#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
|
|||
|
|
pub struct Placement {
|
|||
|
|
pub belief: PlacementBelief,
|
|||
|
|
pub confidence: f32,
|
|||
|
|
pub covered: bool,
|
|||
|
|
pub locked: bool,
|
|||
|
|
pub lit: bool,
|
|||
|
|
pub active: bool,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// How sure the machine must be it is in a pocket before it refuses a wake.
|
|||
|
|
///
|
|||
|
|
/// Below what a locked, covered phone earns, so the veto still does the job §4
|
|||
|
|
/// keeps for it — and above what anything else here reaches, so only a pocket
|
|||
|
|
/// reading can fire it. A threshold rather than a hardcoded rule because the
|
|||
|
|
/// call-state input §4 calls owed, or a stillness signal, changes the numbers
|
|||
|
|
/// and not the shape.
|
|||
|
|
const POCKET_VETO_CONFIDENCE: f32 = 0.45;
|
|||
|
|
|
|||
|
|
pub fn is_locked(state: DeviceState) -> bool {
|
|||
|
|
matches!(
|
|||
|
|
state,
|
|||
|
|
DeviceState::Locked
|
|||
|
|
| DeviceState::Observed
|
|||
|
|
| DeviceState::DozeLight
|
|||
|
|
| DeviceState::DozeDeep
|
|||
|
|
| DeviceState::Suspending
|
|||
|
|
| DeviceState::Asleep
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Which states count as "display active" for poller gating.
|
|||
|
|
pub fn is_display_active(state: DeviceState) -> bool {
|
|||
|
|
matches!(state, DeviceState::Active | DeviceState::Dimmed)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// The device state machine. Owns the current state and enforces
|
|||
|
|
/// transition guards.
|
|||
|
|
pub struct DeviceStateMachine {
|
|||
|
|
state: DeviceState,
|
|||
|
|
/// Sensor evidence for the Observed state.
|
|||
|
|
pub sensor_evidence: SensorEvidence,
|
|||
|
|
/// Forensic log — captures every decision point for post-hoc analysis.
|
|||
|
|
pub forensic: ForensicLog,
|
|||
|
|
/// Panel power. A field, not a ninth state: panel-off is orthogonal to
|
|||
|
|
/// the doze tier, and the enum had no cell for "locked, screen dark".
|
|||
|
|
panel_on: bool,
|
|||
|
|
/// When the compositor's quiet period began, or `None` while the user is
|
|||
|
|
/// interacting. This is reported by `ext-idle-notify`, never inferred: a
|
|||
|
|
/// continuous gesture produces no events at all, so a machine that stamped
|
|||
|
|
/// "last input" itself would blank in the middle of a swipe.
|
|||
|
|
idle_since: Option<Instant>,
|
|||
|
|
/// The power verb already in flight, if any.
|
|||
|
|
///
|
|||
|
|
/// Held because the machine cannot observe the outcome: the successful
|
|||
|
|
/// case is the process ending. A second request is refused rather than
|
|||
|
|
/// executed so that "poweroff, then reboot" cannot leave the machine
|
|||
|
|
/// having asked for both.
|
|||
|
|
power_requested: Option<PowerVerb>,
|
|||
|
|
/// Per-source freshness, for the staleness rule.
|
|||
|
|
evidence_seen: EvidenceSeen,
|
|||
|
|
/// Per-source health — whether evidence is arriving at all, which is a
|
|||
|
|
/// different question from what it says. See `SourceHealth`.
|
|||
|
|
pub source_health: SourceHealthTable,
|
|||
|
|
/// When this run of the daemon began. The only thing that can tell an
|
|||
|
|
/// expected source which has not reported *yet* from one that is never
|
|||
|
|
/// going to — see `SOURCE_EXPECTED_WITHIN`.
|
|||
|
|
started_at: Instant,
|
|||
|
|
/// 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
|
|||
|
|
/// answer.** `note_input` fires on that same edge — correctly, a finger on
|
|||
|
|
/// the button is a user present — which takes the device out of `Locked`,
|
|||
|
|
/// and the executor lights the panel and reports it back through
|
|||
|
|
/// [`Self::set_panel`]. By the time the UP edge resolves the tap,
|
|||
|
|
/// `panel_on` is already true, so the press that woke the screen is read as
|
|||
|
|
/// a press to blank it: wake, lock screen, black.
|
|||
|
|
///
|
|||
|
|
/// One physical press, two reports, and the second cannot see what the
|
|||
|
|
/// first did. The level is unreadable by then; only the edge is true. That
|
|||
|
|
/// makes this the sixth edge-versus-level bug here, after `locked_ack`,
|
|||
|
|
/// `ChargeRate`, `bootBloomActive`, `hasLoginctl` and the dormant OR in the
|
|||
|
|
/// compositor's `disclosure_locked`.
|
|||
|
|
press_began_dark: std::collections::HashMap<Button, bool>,
|
|||
|
|
/// True once a blank has been asked for, so we ask exactly once per wake
|
|||
|
|
/// instead of every tick.
|
|||
|
|
blank_requested: bool,
|
|||
|
|
/// True while the pre-warning dim is showing. The machine tracks this so
|
|||
|
|
/// brightness is saved exactly once per dim; the old hypridle `-s/-r`
|
|||
|
|
/// pair had no such memory, which is how it wedged at 10/255.
|
|||
|
|
dimmed: bool,
|
|||
|
|
/// A blank is decided but withheld until the session locks. Carries the
|
|||
|
|
/// deadline after which the panel goes dark regardless — see
|
|||
|
|
/// `LOCK_ACK_BUDGET` for why that fail-open is the documented choice.
|
|||
|
|
pending_blank: Option<Instant>,
|
|||
|
|
/// The exact panel brightness captured immediately before dimming.
|
|||
|
|
///
|
|||
|
|
/// The dim used to be `brightnessctl -s set 10` and the restore a
|
|||
|
|
/// save/restore pair with a floor: anything that came back under 26/255
|
|||
|
|
/// was pushed to 40%. That floor was defending against the old hypridle
|
|||
|
|
/// `-s`/`-r` wedge — a second save while already dim pinned brightness at
|
|||
|
|
/// 10 and every later wake looked like dead glass — but sessiond's
|
|||
|
|
/// `dimmed` field already makes a double save impossible, so the floor was
|
|||
|
|
/// guarding a bug that no longer exists and corrupting a real setting to
|
|||
|
|
/// do it. A phone deliberately run dark came back *brighter* than it
|
|||
|
|
/// started, which is the jump on tap-to-dismiss.
|
|||
|
|
///
|
|||
|
|
/// The machine remembers the number instead of inferring it. `None` means
|
|||
|
|
/// the capture failed, and only then is the floor the right answer.
|
|||
|
|
brightness_before_dim: Option<u32>,
|
|||
|
|
/// The last raw proximity reading, before debounce. `sensor_evidence`
|
|||
|
|
/// carries the believed value; this carries what the sensor actually said.
|
|||
|
|
proximity_raw: bool,
|
|||
|
|
/// When the raw reading last changed. The debounce measures from here.
|
|||
|
|
proximity_since: Option<Instant>,
|
|||
|
|
/// Timed policy. Settable, so Settings can own it.
|
|||
|
|
pub policy: DeviceStatePolicy,
|
|||
|
|
/// Last bearer posture read off the system. Evidence, refreshed by the
|
|||
|
|
/// daemon and never inferred here — the same contract `panel_on` has with
|
|||
|
|
/// the DPMS executor.
|
|||
|
|
pub bearer: BearerEvidence,
|
|||
|
|
/// The bearer the machine has actually acted on, and when the current
|
|||
|
|
/// candidate first differed from it.
|
|||
|
|
///
|
|||
|
|
/// These two fields *are* the anti-flap rule. A preference is only emitted
|
|||
|
|
/// once it has differed from `bearer_applied` continuously for
|
|||
|
|
/// `bearer_settle`; anything that reverses inside that window never
|
|||
|
|
/// produces a command at all. `wiuf-vpn-gate` had no such memory, which is
|
|||
|
|
/// why every NM event it caused became another correction.
|
|||
|
|
bearer_applied: Option<Bearer>,
|
|||
|
|
bearer_candidate_since: Option<(Bearer, Instant)>,
|
|||
|
|
/// Whether the tunnel was deaf last time we looked, so the trail records
|
|||
|
|
/// the edge rather than one entry per tick.
|
|||
|
|
bearer_deaf: bool,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Sensor inputs that feed the Observed state. Each is a reading, not
|
|||
|
|
/// an authority — the machine decides what to do with them.
|
|||
|
|
#[derive(Debug, Clone, Default)]
|
|||
|
|
pub struct SensorEvidence {
|
|||
|
|
/// Proximity: true = near (in-pocket, face-down), false = far.
|
|||
|
|
pub proximity_near: bool,
|
|||
|
|
/// Accelerometer: true = device is moving.
|
|||
|
|
pub accel_moving: bool,
|
|||
|
|
/// Light sensor: true = ambient light is changing.
|
|||
|
|
pub light_changing: bool,
|
|||
|
|
/// Touch: true = recent touch input detected.
|
|||
|
|
pub touch_active: bool,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
impl SensorEvidence {
|
|||
|
|
/// Compute confidence that the user is present. 0.0–1.0.
|
|||
|
|
/// Each sensor contributes weighted evidence. Cross-sensor
|
|||
|
|
/// disagreements reduce confidence (§9: "accelerometer says
|
|||
|
|
/// face-down, light sensor says bright — one of them is lying").
|
|||
|
|
pub fn confidence(&self) -> f32 {
|
|||
|
|
let mut c = 0.0f32;
|
|||
|
|
if self.proximity_near {
|
|||
|
|
c += 0.4;
|
|||
|
|
}
|
|||
|
|
if self.accel_moving {
|
|||
|
|
c += 0.3;
|
|||
|
|
}
|
|||
|
|
if self.light_changing {
|
|||
|
|
c += 0.2;
|
|||
|
|
}
|
|||
|
|
if self.touch_active {
|
|||
|
|
c += 0.1;
|
|||
|
|
}
|
|||
|
|
// Proximity near while the device is moving is genuinely ambiguous —
|
|||
|
|
// a phone walking in a pocket and a phone held to an ear read the
|
|||
|
|
// same. It lowers confidence and nothing more; it does not decide the
|
|||
|
|
// wake, which is a question about the session, not the sensors.
|
|||
|
|
if self.proximity_near && self.accel_moving {
|
|||
|
|
c -= 0.2;
|
|||
|
|
}
|
|||
|
|
c.clamp(0.0, 1.0)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Should idle tier promotion be accelerated?
|
|||
|
|
pub fn should_promote_idle_faster(&self) -> bool {
|
|||
|
|
self.confidence() >= 0.6
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
impl DeviceStateMachine {
|
|||
|
|
pub fn new() -> Self {
|
|||
|
|
let machine = Self {
|
|||
|
|
state: DeviceState::Active,
|
|||
|
|
sensor_evidence: SensorEvidence::default(),
|
|||
|
|
forensic: ForensicLog::new(),
|
|||
|
|
panel_on: true,
|
|||
|
|
idle_since: None,
|
|||
|
|
power_requested: None,
|
|||
|
|
evidence_seen: EvidenceSeen::default(),
|
|||
|
|
source_health: SourceHealthTable::default(),
|
|||
|
|
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: std::collections::HashMap::new(),
|
|||
|
|
dimmed: false,
|
|||
|
|
pending_blank: None,
|
|||
|
|
brightness_before_dim: None,
|
|||
|
|
proximity_raw: false,
|
|||
|
|
proximity_since: None,
|
|||
|
|
// Loaded, not defaulted: the user's Auto-Lock choices are settings,
|
|||
|
|
// and a setting that reverts on reboot is not a setting.
|
|||
|
|
policy: DeviceStatePolicy::load(),
|
|||
|
|
bearer: BearerEvidence::default(),
|
|||
|
|
bearer_applied: None,
|
|||
|
|
bearer_candidate_since: None,
|
|||
|
|
bearer_deaf: false,
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// First entry of the run, and the only one that proves the trail is
|
|||
|
|
// writable before something worth recording needs it. It also marks
|
|||
|
|
// the boot boundary, which the trail could not show while it lived on
|
|||
|
|
// tmpfs — every reboot simply produced an empty file.
|
|||
|
|
machine.record_decision(
|
|||
|
|
"trail-opened",
|
|||
|
|
serde_json::json!({
|
|||
|
|
"path": machine.forensic.path().map(|p| p.display().to_string()),
|
|||
|
|
"chain": machine.forensic.chain_state(),
|
|||
|
|
"max_bytes": FORENSIC_MAX_BYTES,
|
|||
|
|
"generations": FORENSIC_KEEP,
|
|||
|
|
}),
|
|||
|
|
"sessiond started and opened the forensic trail",
|
|||
|
|
);
|
|||
|
|
machine
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// The clock. Called on a fixed interval by the daemon.
|
|||
|
|
///
|
|||
|
|
/// This is the piece the machine did not have. Everything else was
|
|||
|
|
/// event-driven, so the machine could only answer other actors and could
|
|||
|
|
/// never notice that time had passed — which is why it sat in `Active`
|
|||
|
|
/// with an empty forensic log while the phone locked and unlocked around
|
|||
|
|
/// it. Every timed tier (blank, dim, doze promotion, wake windows) hangs
|
|||
|
|
/// off this one function.
|
|||
|
|
pub fn tick(&mut self) -> Vec<Action> {
|
|||
|
|
self.tick_at(Instant::now())
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// `tick` with the clock passed in, so the rules can be tested at any
|
|||
|
|
/// point on the timeline instead of by sleeping.
|
|||
|
|
/// A button edge. Returns any actions the resulting gesture asks for.
|
|||
|
|
///
|
|||
|
|
/// A press is user input regardless of what it turns out to mean, so the
|
|||
|
|
/// idle budget resets on the DOWN edge, not on the gesture. Waiting for
|
|||
|
|
/// recognition would let the multi-tap window count against a user who is
|
|||
|
|
/// visibly touching the device.
|
|||
|
|
pub fn button_edge(&mut self, button: Button, edge: ButtonEdge) -> Vec<Action> {
|
|||
|
|
self.button_edge_at(button, edge, Instant::now())
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn button_edge_at(
|
|||
|
|
&mut self,
|
|||
|
|
button: Button,
|
|||
|
|
edge: ButtonEdge,
|
|||
|
|
now: Instant,
|
|||
|
|
) -> Vec<Action> {
|
|||
|
|
// Before anything below can change it. `note_input` on this same edge
|
|||
|
|
// is what lights the panel, so this is the last moment the answer is
|
|||
|
|
// still about the screen the user actually pressed against.
|
|||
|
|
if edge == ButtonEdge::Down {
|
|||
|
|
// Per button, not one field for all of them.
|
|||
|
|
//
|
|||
|
|
// A tap never resolves on the UP edge — `ButtonRecognizer::up`
|
|||
|
|
// returns `None` and the tap lands when the 300 ms multi-tap window
|
|||
|
|
// closes on a later tick, up to about 1.3 s after the press. A
|
|||
|
|
// single shared latch had to survive that whole gap, and any other
|
|||
|
|
// button's DOWN edge inside it overwrote the answer: power wakes a
|
|||
|
|
// dark phone, the volume rocker beside it gets nudged within the
|
|||
|
|
// second, and the pending power tap resolves as "the panel was lit"
|
|||
|
|
// and blanks the screen. That is the exact regression the latch was
|
|||
|
|
// added to fix, coming back through a second button.
|
|||
|
|
//
|
|||
|
|
// `buttons` is keyed per button for this reason already.
|
|||
|
|
self.press_began_dark.insert(button, !self.panel_on);
|
|||
|
|
}
|
|||
|
|
let rec = self.buttons.entry(button).or_default();
|
|||
|
|
let gesture = match edge {
|
|||
|
|
ButtonEdge::Down => {
|
|||
|
|
rec.down(now);
|
|||
|
|
None
|
|||
|
|
}
|
|||
|
|
ButtonEdge::Up => rec.up(now),
|
|||
|
|
};
|
|||
|
|
let mut actions = Vec::new();
|
|||
|
|
|
|||
|
|
// Volume acts on the press, not on a recognised gesture. See
|
|||
|
|
// `Action::Volume`: the multi-tap window would otherwise put 300 ms
|
|||
|
|
// between the press and the sound changing.
|
|||
|
|
//
|
|||
|
|
// The gesture recogniser still sees these edges and still resolves
|
|||
|
|
// taps and holds from them — that is how a future binding table gets
|
|||
|
|
// volume double-tap or hold-to-ramp without this changing. It simply
|
|||
|
|
// is not what produces the step today.
|
|||
|
|
if edge == ButtonEdge::Down && matches!(button, Button::VolumeUp | Button::VolumeDown) {
|
|||
|
|
actions.push(Action::Volume {
|
|||
|
|
up: button == Button::VolumeUp,
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if edge == ButtonEdge::Down {
|
|||
|
|
// A hardware button is intent — §4's table says of the power button
|
|||
|
|
// "can it lie? no — hardware signal" — so it is never subject to the
|
|||
|
|
// proximity veto that governs tap-to-wake and squeeze. Input is
|
|||
|
|
// noted on the DOWN edge because a finger on the button is a user
|
|||
|
|
// present, whatever the press turns out to mean; making the idle
|
|||
|
|
// budget wait for recognition would count the multi-tap window
|
|||
|
|
// against someone visibly touching the device.
|
|||
|
|
actions.extend(self.note_input(InputTrigger::PowerButton));
|
|||
|
|
}
|
|||
|
|
if let Some(g) = gesture {
|
|||
|
|
actions.extend(self.apply_gesture(button, g, now));
|
|||
|
|
}
|
|||
|
|
actions
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Bind a recognised gesture to behaviour.
|
|||
|
|
///
|
|||
|
|
/// **This match is a placeholder for a binding table.** The end state is
|
|||
|
|
/// Activator-shaped: `(button, gesture) -> action` as editable, persisted
|
|||
|
|
/// data, so a triple-tap can be pointed at something without a rebuild.
|
|||
|
|
/// `DeviceStatePolicy` is already the persisted home for exactly this kind
|
|||
|
|
/// of setting ("a setting that reverts on reboot is not a setting") and is
|
|||
|
|
/// where the table belongs. Recognition is deliberately separate from
|
|||
|
|
/// binding so that change touches only this function.
|
|||
|
|
///
|
|||
|
|
/// Only the power tap is bound today, and it is bound to what the button
|
|||
|
|
/// already did — wake if the panel is dark, otherwise lock-then-blank
|
|||
|
|
/// through `request_blank()` like every other path. Everything else is
|
|||
|
|
/// recognised, recorded, and inert on purpose: a gesture that fires
|
|||
|
|
/// something nobody chose is worse than one that fires nothing.
|
|||
|
|
fn apply_gesture(
|
|||
|
|
&mut self,
|
|||
|
|
button: Button,
|
|||
|
|
gesture: ButtonGesture,
|
|||
|
|
now: Instant,
|
|||
|
|
) -> Vec<Action> {
|
|||
|
|
let bound =
|
|||
|
|
button == Button::Power && matches!(gesture, ButtonGesture::Tap | ButtonGesture::Hold);
|
|||
|
|
self.record_decision(
|
|||
|
|
"button-gesture",
|
|||
|
|
serde_json::json!({
|
|||
|
|
"button": button.as_str(),
|
|||
|
|
"gesture": gesture.as_str(),
|
|||
|
|
"bound": bound,
|
|||
|
|
}),
|
|||
|
|
"hardware button recognised",
|
|||
|
|
);
|
|||
|
|
// Hold raises the power menu — the surface that owns restart, power
|
|||
|
|
// off, and what the USB-C port currently *is* (data role, power
|
|||
|
|
// direction, what is attached). That state is proprioception, not a
|
|||
|
|
// setting, which is why it belongs to this machine and is reached
|
|||
|
|
// through a verb rather than a config page.
|
|||
|
|
//
|
|||
|
|
// Deliberately NOT gated on the panel or the lock. A hold on a dark
|
|||
|
|
// panel is still a request for the menu, and on the lockscreen it must
|
|||
|
|
// still work — powering off is a thing you do to a locked phone, and a
|
|||
|
|
// menu that vanishes when locked would send the user to the hardware
|
|||
|
|
// button they are already holding. Disclosure is unaffected: the menu
|
|||
|
|
// shows device verbs, never session content.
|
|||
|
|
if button == Button::Power && gesture == ButtonGesture::Hold {
|
|||
|
|
self.press_began_dark.remove(&button);
|
|||
|
|
return vec![Action::PowerMenu];
|
|||
|
|
}
|
|||
|
|
if button != Button::Power || gesture != ButtonGesture::Tap {
|
|||
|
|
return Vec::new();
|
|||
|
|
}
|
|||
|
|
// The panel as it was when THIS button's press began, not as it is
|
|||
|
|
// now. See `press_began_dark`: this same press already woke it.
|
|||
|
|
if self
|
|||
|
|
.press_began_dark
|
|||
|
|
.remove(&button)
|
|||
|
|
.unwrap_or(!self.panel_on)
|
|||
|
|
{
|
|||
|
|
// Dark: the tap is a wake, and a power-button wake is never vetoed.
|
|||
|
|
//
|
|||
|
|
// `Unblank` *then* `Restore`, and the order is the whole of it: the
|
|||
|
|
// panel has to come back before the brightness it comes back at
|
|||
|
|
// means anything. `Restore` alone was what this returned, which was
|
|||
|
|
// only ever enough because the compositor woke the panel through a
|
|||
|
|
// keybinding before the daemon was ever consulted — see
|
|||
|
|
// `Action::Unblank`. Under viewtop nothing else does it, so a tap
|
|||
|
|
// set the brightness of a screen that stayed off.
|
|||
|
|
//
|
|||
|
|
// `Restore` puts back the brightness the dim captured rather than a
|
|||
|
|
// guessed floor — see the comment on that capture.
|
|||
|
|
self.blank_requested = false;
|
|||
|
|
// Recorded, because the blank branch below records and this one did
|
|||
|
|
// not: the trail showed `button-gesture … recognised` and then
|
|||
|
|
// silence, so "the press woke it" and "the press did nothing" were
|
|||
|
|
// the same entry. A trail that cannot tell those apart is the one
|
|||
|
|
// question anyone debugging a dark phone is actually asking.
|
|||
|
|
self.record_decision(
|
|||
|
|
"panel-on",
|
|||
|
|
serde_json::json!({ "button": "power", "gesture": "tap" }),
|
|||
|
|
"a tap on a dark panel is a wake",
|
|||
|
|
);
|
|||
|
|
return vec![Action::Unblank, Action::Restore];
|
|||
|
|
}
|
|||
|
|
self.request_blank(
|
|||
|
|
now,
|
|||
|
|
serde_json::json!({ "button": "power", "gesture": "tap" }),
|
|||
|
|
"power button tap",
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Bind a recognised touch gesture to behaviour.
|
|||
|
|
///
|
|||
|
|
/// The same shape as [`Self::apply_gesture`] for buttons, and deliberately
|
|||
|
|
/// as small: three fingers raise the overview, everything else is
|
|||
|
|
/// recognised, recorded and inert. A gesture that fires something nobody
|
|||
|
|
/// chose is worse than one that fires nothing, and the binding table this
|
|||
|
|
/// is a placeholder for is `DeviceStatePolicy`'s to hold — persisted and
|
|||
|
|
/// agent-writable, so a triple-tap can be re-pointed without a rebuild.
|
|||
|
|
pub fn touch_gesture(
|
|||
|
|
&mut self,
|
|||
|
|
fingers: u8,
|
|||
|
|
gesture: TouchGesture,
|
|||
|
|
target: Option<u64>,
|
|||
|
|
) -> Vec<Action> {
|
|||
|
|
// A three-finger tap raises the window action sheet for the window it
|
|||
|
|
// landed on. It used to raise the overview, and that was wrong twice
|
|||
|
|
// over: the rail's swipe already reaches the same surface — a gesture
|
|||
|
|
// that duplicates another gesture is a defect by construction — and
|
|||
|
|
// the tap arrived with no subject at all, because the compositor
|
|||
|
|
// dropped the centroid, so the overview was the only thing it *could*
|
|||
|
|
// name. Now that the target travels with the gesture, the tap can be
|
|||
|
|
// about the window under the fingers, which is what it was always for.
|
|||
|
|
let bound = fingers == 3 && matches!(gesture, TouchGesture::Tap) && target.is_some();
|
|||
|
|
self.record_decision(
|
|||
|
|
"touch-gesture",
|
|||
|
|
serde_json::json!({
|
|||
|
|
"fingers": fingers,
|
|||
|
|
"gesture": gesture.as_str(),
|
|||
|
|
"target": target,
|
|||
|
|
"bound": bound,
|
|||
|
|
}),
|
|||
|
|
"touch gesture recognised",
|
|||
|
|
);
|
|||
|
|
if !bound {
|
|||
|
|
return Vec::new();
|
|||
|
|
}
|
|||
|
|
// Not while the screen is dark: the sheet is content, and putting
|
|||
|
|
// content on an unauthenticated glass is what §4's disclosure rules
|
|||
|
|
// exist to prevent. A tap on a dark panel is a wake, and that is the
|
|||
|
|
// power button's business, not this one's.
|
|||
|
|
if !self.panel_on {
|
|||
|
|
return Vec::new();
|
|||
|
|
}
|
|||
|
|
// `target` is Some — `bound` required it. A tap on the wallpaper falls
|
|||
|
|
// out above with `bound: false` in the trail rather than raising an
|
|||
|
|
// empty sheet, which is the "why did a blank sheet appear" bug the
|
|||
|
|
// no-window case exists to avoid.
|
|||
|
|
vec![Action::WindowSheet {
|
|||
|
|
target: target.unwrap_or_default(),
|
|||
|
|
}]
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn tick_at(&mut self, now: Instant) -> Vec<Action> {
|
|||
|
|
// Buttons first: a hold that crossed its threshold during this tick
|
|||
|
|
// should be announced before the idle rules below decide anything, or
|
|||
|
|
// a hold and a blank can land in the same tick in the wrong order.
|
|||
|
|
let mut button_actions = Vec::new();
|
|||
|
|
let pending: Vec<(Button, ButtonGesture)> = self
|
|||
|
|
.buttons
|
|||
|
|
.iter_mut()
|
|||
|
|
.filter_map(|(b, r)| r.tick(now).map(|g| (*b, g)))
|
|||
|
|
.collect();
|
|||
|
|
for (button, gesture) in pending {
|
|||
|
|
button_actions.extend(self.apply_gesture(button, gesture, now));
|
|||
|
|
}
|
|||
|
|
let mut actions = self.tick_rules_at(now);
|
|||
|
|
button_actions.append(&mut actions);
|
|||
|
|
return button_actions;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn tick_rules_at(&mut self, now: Instant) -> Vec<Action> {
|
|||
|
|
// Expiry first. A source that has gone stale must not be allowed to
|
|||
|
|
// win a pending debounce and then be cleared in the same tick — that
|
|||
|
|
// would put a transition in the trail for a reading nobody confirmed.
|
|||
|
|
self.expire_stale_evidence(now);
|
|||
|
|
self.resolve_proximity_debounce(now);
|
|||
|
|
self.evaluate_source_health(now);
|
|||
|
|
|
|||
|
|
let mut actions = self.bearer_actions(now);
|
|||
|
|
|
|||
|
|
// A blank is already decided and waiting on the lock it asked for.
|
|||
|
|
// Nothing else may run while that is outstanding — the whole point is
|
|||
|
|
// that the panel does not go dark ahead of the lock.
|
|||
|
|
if let Some(deadline) = self.pending_blank {
|
|||
|
|
if is_locked(self.state) {
|
|||
|
|
self.pending_blank = None;
|
|||
|
|
self.blank_requested = true;
|
|||
|
|
info!("[device-state] lock acked — blanking");
|
|||
|
|
self.record_decision(
|
|||
|
|
"panel-off",
|
|||
|
|
serde_json::json!({ "waited_for": "lock-ack" }),
|
|||
|
|
"the lock this blank asked for was acknowledged",
|
|||
|
|
);
|
|||
|
|
actions.push(Action::Blank);
|
|||
|
|
} else if now >= deadline {
|
|||
|
|
// Fail open on the panel, never on the claim. §1: a lit,
|
|||
|
|
// unlocked phone in a pocket is the worse outcome, so the
|
|||
|
|
// blank proceeds — but doctrine §8 forbids pretending the
|
|||
|
|
// session locked, so this is an error in the trail, loudly,
|
|||
|
|
// and the machine's state is left untouched.
|
|||
|
|
self.pending_blank = None;
|
|||
|
|
self.blank_requested = true;
|
|||
|
|
warn!(
|
|||
|
|
"[device-state] lock NOT acked within {}s — blanking an UNLOCKED session",
|
|||
|
|
self.policy.lock_ack_budget.as_secs()
|
|||
|
|
);
|
|||
|
|
self.record_error(
|
|||
|
|
"device-state",
|
|||
|
|
"blank-without-lock",
|
|||
|
|
"lock ack did not arrive within the budget; panel blanked unlocked",
|
|||
|
|
);
|
|||
|
|
actions.push(Action::Blank);
|
|||
|
|
}
|
|||
|
|
return actions;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if !self.panel_on || self.blank_requested {
|
|||
|
|
return actions;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Proximity does not blank the panel. It used to, on any locked
|
|||
|
|
// screen, which is `blueline-proximity-lock`'s old job moved inward
|
|||
|
|
// and kept too powerful: a covered sensor is a pocket, a face, a
|
|||
|
|
// table, or a thumb, and the machine cannot tell which. Turning the
|
|||
|
|
// screen off on that reading is only right during a call — and the
|
|||
|
|
// machine has no call state yet, so for now it is never right.
|
|||
|
|
//
|
|||
|
|
// What remains of proximity is evidence: it drives `Observed`, it is
|
|||
|
|
// in every snapshot, and it gates tap-to-wake (`suppress_wake`). The
|
|||
|
|
// idle budget below blanks a locked screen soon enough anyway.
|
|||
|
|
|
|||
|
|
// The compositor says the user is interacting. Not our business yet —
|
|||
|
|
// and crucially this is what keeps a long swipe from being blanked
|
|||
|
|
// out from under the user's thumb.
|
|||
|
|
let Some(idle_since) = self.idle_since else {
|
|||
|
|
return actions;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
let held = self.sensor_evidence.accel_moving;
|
|||
|
|
let budget = if !is_locked(self.state) {
|
|||
|
|
// An unlocked screen is not this rule's business by default; the
|
|||
|
|
// shell's IdleCoordinator owns that timer. When it IS set, the
|
|||
|
|
// blank still routes through lock-then-off below.
|
|||
|
|
self.policy.unlocked_blank_after
|
|||
|
|
} else if held {
|
|||
|
|
self.policy.lock_blank_after_held
|
|||
|
|
} else {
|
|||
|
|
self.policy.lock_blank_after
|
|||
|
|
};
|
|||
|
|
// "Never" is a legitimate setting (plugged in at the desk).
|
|||
|
|
let Some(budget) = budget else {
|
|||
|
|
return actions;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
let idle_for = now.saturating_duration_since(idle_since);
|
|||
|
|
|
|||
|
|
// Stage two: the grace ran out, go dark.
|
|||
|
|
if idle_for >= budget {
|
|||
|
|
info!(
|
|||
|
|
"[device-state] {}, idle {:.1}s of {}s — blanking",
|
|||
|
|
if is_locked(self.state) {
|
|||
|
|
"locked"
|
|||
|
|
} else {
|
|||
|
|
"unlocked"
|
|||
|
|
},
|
|||
|
|
idle_for.as_secs_f32(),
|
|||
|
|
budget.as_secs()
|
|||
|
|
);
|
|||
|
|
return self.request_blank(
|
|||
|
|
now,
|
|||
|
|
serde_json::json!({
|
|||
|
|
"idle_secs": idle_for.as_secs(),
|
|||
|
|
"budget_secs": budget.as_secs(),
|
|||
|
|
"held": held,
|
|||
|
|
"was_dimmed": self.dimmed,
|
|||
|
|
"confidence": self.sensor_evidence.confidence(),
|
|||
|
|
}),
|
|||
|
|
"panel lit, no input within the blank budget",
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Stage one: the pre-warning. The panel visibly fades and a tap
|
|||
|
|
// inside the grace window cancels the blank — the user gets told
|
|||
|
|
// what is about to happen instead of the screen simply dying.
|
|||
|
|
if self.policy.dim_warning && !self.dimmed {
|
|||
|
|
let dim_at = budget.saturating_sub(self.policy.dim_grace);
|
|||
|
|
if idle_for >= dim_at {
|
|||
|
|
self.dimmed = true;
|
|||
|
|
info!(
|
|||
|
|
"[device-state] locked, idle {:.1}s — dimming, {}s to tap",
|
|||
|
|
idle_for.as_secs_f32(),
|
|||
|
|
self.policy.dim_grace.as_secs()
|
|||
|
|
);
|
|||
|
|
self.record_decision(
|
|||
|
|
"panel-dim",
|
|||
|
|
serde_json::json!({
|
|||
|
|
"idle_secs": idle_for.as_secs(),
|
|||
|
|
"blank_at_secs": budget.as_secs(),
|
|||
|
|
"grace_secs": self.policy.dim_grace.as_secs(),
|
|||
|
|
"held": held,
|
|||
|
|
}),
|
|||
|
|
"pre-warning before blanking; input inside the grace cancels it",
|
|||
|
|
);
|
|||
|
|
actions.push(Action::Dim);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
actions
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Every path to a dark panel goes through here.
|
|||
|
|
///
|
|||
|
|
/// `LOCK-DPMS-LESSONS.md` §1 is "Ordering: lock, then off", and until now
|
|||
|
|
/// that held only because hypridle's 300 s lock listener happened to fire
|
|||
|
|
/// before its own 600 s blank listener. That is an assumption, not an
|
|||
|
|
/// invariant: anything that skipped the lock (an idle inhibitor, the
|
|||
|
|
/// native coordinator disabled, a dead shell) still got blanked, unlocked
|
|||
|
|
/// and silently. Routing every blank through one function makes the
|
|||
|
|
/// ordering a property of the authority instead of a coincidence of two
|
|||
|
|
/// timers owned by a config file.
|
|||
|
|
fn request_blank(
|
|||
|
|
&mut self,
|
|||
|
|
now: Instant,
|
|||
|
|
inputs: serde_json::Value,
|
|||
|
|
reason: &str,
|
|||
|
|
) -> Vec<Action> {
|
|||
|
|
if is_locked(self.state) {
|
|||
|
|
self.blank_requested = true;
|
|||
|
|
self.record_decision("panel-off", inputs, reason);
|
|||
|
|
return vec![Action::Blank];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
info!(
|
|||
|
|
"[device-state] blanking an unlocked session — locking first, {}s to ack",
|
|||
|
|
self.policy.lock_ack_budget.as_secs()
|
|||
|
|
);
|
|||
|
|
self.pending_blank = Some(now + self.policy.lock_ack_budget);
|
|||
|
|
self.record_decision(
|
|||
|
|
"lock-before-blank",
|
|||
|
|
inputs,
|
|||
|
|
"a blank was decided for an unlocked session; the lock goes first",
|
|||
|
|
);
|
|||
|
|
vec![Action::Lock]
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Drop evidence nobody has refreshed. Stale readings are cleared rather
|
|||
|
|
/// than trusted, and clearing errs the safe way: a stale proximity no
|
|||
|
|
/// longer suppresses a wake, and a stale accelerometer no longer buys the
|
|||
|
|
/// longer blank budget. Never the reverse.
|
|||
|
|
fn expire_stale_evidence(&mut self, now: Instant) {
|
|||
|
|
let ttl = self.policy.evidence_ttl;
|
|||
|
|
let expired = |seen: &mut Option<Instant>, flag: &mut bool| -> bool {
|
|||
|
|
let stale = match *seen {
|
|||
|
|
Some(t) => now.saturating_duration_since(t) > ttl,
|
|||
|
|
None => true,
|
|||
|
|
};
|
|||
|
|
if stale && *flag {
|
|||
|
|
*flag = false;
|
|||
|
|
*seen = None;
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
false
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
let mut dropped: Vec<&str> = Vec::new();
|
|||
|
|
if expired(
|
|||
|
|
&mut self.evidence_seen.proximity,
|
|||
|
|
&mut self.sensor_evidence.proximity_near,
|
|||
|
|
) {
|
|||
|
|
// Clear the raw reading and any pending debounce with it. Stale
|
|||
|
|
// means unknown, and a half-resolved edge left behind an expiry
|
|||
|
|
// would let a reading nobody has confirmed in 30 s win the moment
|
|||
|
|
// the next tick ran.
|
|||
|
|
self.proximity_raw = false;
|
|||
|
|
self.proximity_since = None;
|
|||
|
|
dropped.push("proximity");
|
|||
|
|
}
|
|||
|
|
if expired(
|
|||
|
|
&mut self.evidence_seen.accel,
|
|||
|
|
&mut self.sensor_evidence.accel_moving,
|
|||
|
|
) {
|
|||
|
|
dropped.push("accel");
|
|||
|
|
}
|
|||
|
|
if expired(
|
|||
|
|
&mut self.evidence_seen.light,
|
|||
|
|
&mut self.sensor_evidence.light_changing,
|
|||
|
|
) {
|
|||
|
|
dropped.push("light");
|
|||
|
|
}
|
|||
|
|
if expired(
|
|||
|
|
&mut self.evidence_seen.touch,
|
|||
|
|
&mut self.sensor_evidence.touch_active,
|
|||
|
|
) {
|
|||
|
|
dropped.push("touch");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if !dropped.is_empty() {
|
|||
|
|
warn!(
|
|||
|
|
"[device-state] evidence went stale, treating as unknown: {}",
|
|||
|
|
dropped.join(", ")
|
|||
|
|
);
|
|||
|
|
self.record_decision(
|
|||
|
|
"evidence-stale",
|
|||
|
|
serde_json::json!({ "sources": dropped, "ttl_secs": ttl.as_secs() }),
|
|||
|
|
"no reading within the evidence TTL — a dead sensor is not a quiet one",
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Decide whether each evidence source is still there.
|
|||
|
|
///
|
|||
|
|
/// Separate from `expire_stale_evidence` because it answers a different
|
|||
|
|
/// question, and because the staleness rule structurally cannot answer this
|
|||
|
|
/// one: it only acts on a source whose flag is currently `true`. A
|
|||
|
|
/// proximity sensor resting at `far` has a `false` flag, so it expires
|
|||
|
|
/// nothing, reports nothing, and dies in complete silence. That is exactly
|
|||
|
|
/// the 2026-07-25 outage — hours of dead sensors with nothing in any log.
|
|||
|
|
fn evaluate_source_health(&mut self, now: Instant) {
|
|||
|
|
let limit = self.policy.source_down_after;
|
|||
|
|
let sources = [
|
|||
|
|
(SensorSource::Proximity, self.evidence_seen.proximity),
|
|||
|
|
(SensorSource::Accelerometer, self.evidence_seen.accel),
|
|||
|
|
(SensorSource::Light, self.evidence_seen.light),
|
|||
|
|
(SensorSource::Touch, self.evidence_seen.touch),
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
let mut newly_down: Vec<&str> = Vec::new();
|
|||
|
|
for (source, seen) in sources {
|
|||
|
|
let Some(t) = seen else { continue };
|
|||
|
|
if now.saturating_duration_since(t) <= limit {
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
let health = self.source_health.get_mut(source);
|
|||
|
|
if *health == SourceHealth::Down {
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
*health = SourceHealth::Down;
|
|||
|
|
newly_down.push(source.as_str());
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Expected sources that have never spoken at all. `Down` above starts
|
|||
|
|
// from a last-seen stamp and so structurally cannot see these: no
|
|||
|
|
// stamp, no entry, silence forever. That is how a reporter which never
|
|||
|
|
// started produced a whole session of evidence-free decisions with
|
|||
|
|
// nothing in the trail (2026-07-27).
|
|||
|
|
let mut newly_absent: Vec<&str> = Vec::new();
|
|||
|
|
if now.saturating_duration_since(self.started_at) > self.policy.source_expected_within {
|
|||
|
|
for &source in EXPECTED_SOURCES {
|
|||
|
|
if self.evidence_seen.get(source).is_some() {
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
let health = self.source_health.get_mut(source);
|
|||
|
|
if *health == SourceHealth::Absent {
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
*health = SourceHealth::Absent;
|
|||
|
|
newly_absent.push(source.as_str());
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// One entry per edge, not per tick — same contract as source-down.
|
|||
|
|
for source in &newly_absent {
|
|||
|
|
warn!(
|
|||
|
|
"[device-state] evidence source {source} has never reported in {}s since start — its reporter is not running",
|
|||
|
|
self.policy.source_expected_within.as_secs()
|
|||
|
|
);
|
|||
|
|
self.record_error(
|
|||
|
|
"sensor-health",
|
|||
|
|
"source-never-reported",
|
|||
|
|
&format!(
|
|||
|
|
"{source} is expected on this device but has never reported in the {}s since sessiond started; its reporter is not running, so every rule reading it is deciding on absence, not on a negative",
|
|||
|
|
self.policy.source_expected_within.as_secs()
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// One entry per outage edge, not per tick. A source stays Down until
|
|||
|
|
// it speaks again, and a trail that repeated this every second would
|
|||
|
|
// bury the transition that actually diagnoses anything.
|
|||
|
|
for source in &newly_down {
|
|||
|
|
warn!(
|
|||
|
|
"[device-state] evidence source {source} has said nothing for {}s — treating it as DOWN, not quiet",
|
|||
|
|
limit.as_secs()
|
|||
|
|
);
|
|||
|
|
self.record_error(
|
|||
|
|
"sensor-health",
|
|||
|
|
"source-down",
|
|||
|
|
&format!(
|
|||
|
|
"{source} reported before but has been silent for over {}s; its readings are absent, not negative",
|
|||
|
|
limit.as_secs()
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Stamp a source as freshly reported. Called alongside the reading.
|
|||
|
|
/// The bearer actually in force, for the readout. `None` until the first
|
|||
|
|
/// preference settles — which is a real state and not the same as "wifi".
|
|||
|
|
pub fn bearer_applied_str(&self) -> Option<&'static str> {
|
|||
|
|
self.bearer_applied.map(|b| b.as_str())
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Fresh bearer evidence from the daemon's probe.
|
|||
|
|
///
|
|||
|
|
/// Ingress only. The machine never reads the network itself for the same
|
|||
|
|
/// reason it never reads logind itself: a reader that can also act is one
|
|||
|
|
/// refactor away from being a controller, and the controller this replaces
|
|||
|
|
/// is the one that recycled the tunnel 652 times.
|
|||
|
|
pub fn note_bearer(&mut self, evidence: BearerEvidence) {
|
|||
|
|
self.bearer = evidence;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Decide the bearer posture, subject to the settling window.
|
|||
|
|
///
|
|||
|
|
/// Returns at most one `PreferLink` and one `PinTunnelUnderlay`, and only
|
|||
|
|
/// on a change that has held. A steady state produces no actions at all,
|
|||
|
|
/// which is what makes this safe to call every tick.
|
|||
|
|
fn bearer_actions(&mut self, now: Instant) -> Vec<Action> {
|
|||
|
|
let mut actions = Vec::new();
|
|||
|
|
|
|||
|
|
// Report the tunnel going deaf on the edge. This is the one thing that
|
|||
|
|
// is worth saying even when nothing can be done about it: every other
|
|||
|
|
// readout on the device calls a deaf tunnel "connected".
|
|||
|
|
let deaf = self.bearer.tunnel_is_deaf();
|
|||
|
|
if deaf != self.bearer_deaf {
|
|||
|
|
self.bearer_deaf = deaf;
|
|||
|
|
if deaf {
|
|||
|
|
warn!("[device-state] tunnel is up but receiving nothing");
|
|||
|
|
}
|
|||
|
|
self.record_decision(
|
|||
|
|
"bearer-tunnel-health",
|
|||
|
|
serde_json::json!({ "tunnel": self.bearer.tunnel.as_str() }),
|
|||
|
|
if deaf {
|
|||
|
|
"tunnel up but no bytes received — up is not carrying"
|
|||
|
|
} else {
|
|||
|
|
"tunnel handshake healthy"
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let Some(want) = self.bearer.preferred() else {
|
|||
|
|
// Nothing usable. Deliberately not an action: with no link worth
|
|||
|
|
// preferring there is nothing to prefer it over, and issuing a
|
|||
|
|
// command here would be the machine flailing at a dead network.
|
|||
|
|
self.bearer_candidate_since = None;
|
|||
|
|
return actions;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
if Some(want) == self.bearer_applied {
|
|||
|
|
self.bearer_candidate_since = None;
|
|||
|
|
return actions;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// A changed candidate has to survive the settling window. Restarting
|
|||
|
|
// the clock whenever the candidate itself changes is what stops a link
|
|||
|
|
// oscillating between two answers from ever accumulating enough time.
|
|||
|
|
let since = match self.bearer_candidate_since {
|
|||
|
|
Some((b, t)) if b == want => t,
|
|||
|
|
_ => {
|
|||
|
|
self.bearer_candidate_since = Some((want, now));
|
|||
|
|
now
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
if now.duration_since(since) < self.policy.bearer_settle {
|
|||
|
|
return actions;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let previous = self.bearer_applied;
|
|||
|
|
self.bearer_applied = Some(want);
|
|||
|
|
self.bearer_candidate_since = None;
|
|||
|
|
|
|||
|
|
info!(
|
|||
|
|
"[device-state] bearer: {} -> {}",
|
|||
|
|
previous.map(|b| b.as_str()).unwrap_or("none"),
|
|||
|
|
want.as_str()
|
|||
|
|
);
|
|||
|
|
self.record_decision(
|
|||
|
|
"bearer-preferred",
|
|||
|
|
serde_json::json!({
|
|||
|
|
"from": previous.map(|b| b.as_str()),
|
|||
|
|
"to": want.as_str(),
|
|||
|
|
"wifi": self.bearer.wifi.as_str(),
|
|||
|
|
"cellular": self.bearer.cellular.as_str(),
|
|||
|
|
"tunnel": self.bearer.tunnel.as_str(),
|
|||
|
|
"home": self.bearer.home,
|
|||
|
|
"ssid": self.bearer.ssid,
|
|||
|
|
"settled_for_secs": now.duration_since(since).as_secs(),
|
|||
|
|
}),
|
|||
|
|
"bearer preference changed and held for the settling window",
|
|||
|
|
);
|
|||
|
|
actions.push(Action::PreferLink(want));
|
|||
|
|
|
|||
|
|
// The tunnel follows the underlay, and only when it is already up.
|
|||
|
|
if let Some(underlay) = self.bearer.tunnel_underlay() {
|
|||
|
|
actions.push(Action::PinTunnelUnderlay(underlay));
|
|||
|
|
}
|
|||
|
|
actions
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn mark_evidence_seen(&mut self, source: SensorSource) {
|
|||
|
|
self.mark_evidence_seen_at(source, Instant::now())
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// `mark_evidence_seen` with the clock passed in, so health transitions can
|
|||
|
|
/// be tested on a timeline instead of by sleeping for 90 seconds.
|
|||
|
|
pub fn mark_evidence_seen_at(&mut self, source: SensorSource, now: Instant) {
|
|||
|
|
match source {
|
|||
|
|
SensorSource::Proximity => self.evidence_seen.proximity = Some(now),
|
|||
|
|
SensorSource::Accelerometer => self.evidence_seen.accel = Some(now),
|
|||
|
|
SensorSource::Light => self.evidence_seen.light = Some(now),
|
|||
|
|
SensorSource::Touch => self.evidence_seen.touch = Some(now),
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let health = self.source_health.get_mut(source);
|
|||
|
|
let was = *health;
|
|||
|
|
*health = SourceHealth::Live;
|
|||
|
|
// Absent recovers by the same path as Down. Both mean "the machine was
|
|||
|
|
// deciding without this source", and both need the closing entry that
|
|||
|
|
// bounds the window — an outage with no end tells you when evidence
|
|||
|
|
// died and never when it came back.
|
|||
|
|
if matches!(was, SourceHealth::Down | SourceHealth::Absent) {
|
|||
|
|
// Recovery is as diagnostic as the outage: the pair of entries
|
|||
|
|
// bounds the window in which every sensor-driven rule was running
|
|||
|
|
// on nothing, which is what makes the trail usable after the fact.
|
|||
|
|
info!(
|
|||
|
|
"[device-state] evidence source {} is reporting again (was {})",
|
|||
|
|
source.as_str(),
|
|||
|
|
was.as_str()
|
|||
|
|
);
|
|||
|
|
self.record_decision(
|
|||
|
|
"source-recovered",
|
|||
|
|
serde_json::json!({ "source": source.as_str(), "was": was.as_str() }),
|
|||
|
|
"a source the machine had no evidence from has resumed reporting",
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Real user input, with the machine's own veto applied first.
|
|||
|
|
///
|
|||
|
|
/// `note_input` is unconditional by design: a power button is intent and is
|
|||
|
|
/// never refused (§4, "no — hardware signal"). A squeeze is not a button.
|
|||
|
|
/// It is a strain reading, and a phone in a tight pocket is a squeezed
|
|||
|
|
/// chassis — the same failure a covered proximity sensor already vetoes for
|
|||
|
|
/// tap-to-wake. Routing squeeze through here rather than straight to
|
|||
|
|
/// `note_input` is what stops a pocket from resetting the idle budget and
|
|||
|
|
/// opening a verb surface.
|
|||
|
|
///
|
|||
|
|
/// The refusal is recorded, not dropped. §10 spent a section establishing
|
|||
|
|
/// that absence must never be mistaken for a negative; a producer whose
|
|||
|
|
/// reports vanish silently is indistinguishable from a dead one.
|
|||
|
|
pub fn note_input_gated(&mut self, trigger: InputTrigger) -> Option<Vec<Action>> {
|
|||
|
|
if self.suppress_wake(trigger) {
|
|||
|
|
let placement = self.placement();
|
|||
|
|
warn!(
|
|||
|
|
"[device-state] {:?} refused — believed {:?} at {:.2}",
|
|||
|
|
trigger, placement.belief, placement.confidence
|
|||
|
|
);
|
|||
|
|
self.record_decision(
|
|||
|
|
"input-refused",
|
|||
|
|
serde_json::json!({
|
|||
|
|
"trigger": format!("{:?}", trigger),
|
|||
|
|
"placement": placement,
|
|||
|
|
"confidence": self.sensor_evidence.confidence(),
|
|||
|
|
}),
|
|||
|
|
"a placement belief above the veto threshold (DEVICE-STATE-MACHINE §4)",
|
|||
|
|
);
|
|||
|
|
return None;
|
|||
|
|
}
|
|||
|
|
Some(self.note_input(trigger))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Real user input. Resets the blank budget, re-arms the rule, and undoes
|
|||
|
|
/// the pre-warning dim if one is showing — that cancel is the whole point
|
|||
|
|
/// of the grace window, so it returns the action rather than waiting for
|
|||
|
|
/// the next tick to notice.
|
|||
|
|
pub fn note_input(&mut self, trigger: InputTrigger) -> Vec<Action> {
|
|||
|
|
self.idle_since = None;
|
|||
|
|
self.blank_requested = false;
|
|||
|
|
// Input inside the lock-ack window cancels the blank outright. The
|
|||
|
|
// lock request already went out and is not recalled — a lock the user
|
|||
|
|
// interrupted is a lock, and only PAM leaves it.
|
|||
|
|
if self.pending_blank.take().is_some() {
|
|||
|
|
info!("[device-state] input during the lock-ack window — blank cancelled");
|
|||
|
|
}
|
|||
|
|
let mut actions = Vec::new();
|
|||
|
|
if self.dimmed {
|
|||
|
|
self.dimmed = false;
|
|||
|
|
info!("[device-state] input during the dim warning — restoring");
|
|||
|
|
actions.push(Action::Restore);
|
|||
|
|
}
|
|||
|
|
let wake = match trigger {
|
|||
|
|
InputTrigger::PowerButton => WakeTrigger::PowerButton,
|
|||
|
|
InputTrigger::DoubleTapToWake => WakeTrigger::DoubleTapToWake,
|
|||
|
|
InputTrigger::Squeeze => WakeTrigger::Squeeze,
|
|||
|
|
InputTrigger::Touch | InputTrigger::Key => WakeTrigger::UserInput,
|
|||
|
|
InputTrigger::Unknown => WakeTrigger::Unknown,
|
|||
|
|
};
|
|||
|
|
self.record_wake(wake, &format!("input: {:?}", trigger));
|
|||
|
|
|
|||
|
|
// A deliberate wake on a dark panel has to actually light it.
|
|||
|
|
//
|
|||
|
|
// This function recorded the wake and returned `Restore` — which is
|
|||
|
|
// *brightness* — so double-tap-to-wake reported to the machine and the
|
|||
|
|
// screen stayed off. It was invisible for as long as the wake never
|
|||
|
|
// came through the daemon: `hyprland.lua` bound `XF86WakeUp` straight
|
|||
|
|
// to `blueline-screen-toggle on`, so the compositor lit the panel and
|
|||
|
|
// sessiond only heard about it afterwards. viewtop consumes that key,
|
|||
|
|
// and dt2w stopped working.
|
|||
|
|
//
|
|||
|
|
// Only the *deliberate* wakes, and only when the panel is actually
|
|||
|
|
// dark. `Touch`/`Key`/`Unknown` are excluded because they are not wake
|
|||
|
|
// gestures — the touch controller is in gesture mode while the panel is
|
|||
|
|
// off, so a raw touch arriving there is not a request to wake.
|
|||
|
|
//
|
|||
|
|
// `PowerButton` is excluded for a different reason: it already has a
|
|||
|
|
// wake, from `apply_gesture` resolving the tap. Emitting one here too
|
|||
|
|
// would put two unblanks in flight for one press, and the second would
|
|||
|
|
// land against a panel the first had already lit — which is how the
|
|||
|
|
// wake loop happened in the compositor earlier today.
|
|||
|
|
//
|
|||
|
|
// Proximity has already had its say: this is reached through
|
|||
|
|
// `note_input_gated`, whose veto is the pocket check. A power button is
|
|||
|
|
// never vetoed and a double tap always is, because a pocket can produce
|
|||
|
|
// the second and not the first (§4).
|
|||
|
|
if !self.panel_on
|
|||
|
|
&& matches!(
|
|||
|
|
trigger,
|
|||
|
|
InputTrigger::DoubleTapToWake | InputTrigger::Squeeze
|
|||
|
|
)
|
|||
|
|
{
|
|||
|
|
info!("[device-state] {:?} on a dark panel — waking", trigger);
|
|||
|
|
self.blank_requested = false;
|
|||
|
|
// Before any `Restore`: the brightness a panel comes back at means
|
|||
|
|
// nothing until the panel is back.
|
|||
|
|
actions.insert(0, Action::Unblank);
|
|||
|
|
}
|
|||
|
|
actions
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// What the machine currently believes the panel is doing.
|
|||
|
|
///
|
|||
|
|
/// A belief, and named as one: the executor reports it (`set_panel`) and
|
|||
|
|
/// nothing here infers it, so this is the last thing the hardware said
|
|||
|
|
/// rather than an assumption about what it must be doing now.
|
|||
|
|
pub fn panel_on(&self) -> bool {
|
|||
|
|
self.panel_on
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Ask for the panel, from outside the machine's own rules.
|
|||
|
|
///
|
|||
|
|
/// The agent's verb lands here (`{"op":"screen"}`), and so does anything
|
|||
|
|
/// else that wants the glass lit or dark. It is deliberately not a
|
|||
|
|
/// shortcut past anything: **off routes through `request_blank()`** like
|
|||
|
|
/// every other path, so it locks first and waits out `LOCK_ACK_BUDGET`,
|
|||
|
|
/// and **on** is the wake that direction never had.
|
|||
|
|
///
|
|||
|
|
/// Doctrine §13 in one function. Operation is hers — she may turn the
|
|||
|
|
/// screen off — and the ordering invariant is not a permission she is
|
|||
|
|
/// missing, it is a property of the machine that applies to every caller
|
|||
|
|
/// including itself.
|
|||
|
|
pub fn request_screen(&mut self, on: bool, why: &str) -> Vec<Action> {
|
|||
|
|
if on {
|
|||
|
|
if self.panel_on {
|
|||
|
|
return Vec::new();
|
|||
|
|
}
|
|||
|
|
self.blank_requested = false;
|
|||
|
|
let mut actions = vec![Action::Unblank];
|
|||
|
|
// A wake must never come up dim, for the same reason `set_panel`
|
|||
|
|
// restores: a blank that landed while the dim warning was showing
|
|||
|
|
// left the saved brightness low.
|
|||
|
|
if self.dimmed {
|
|||
|
|
self.dimmed = false;
|
|||
|
|
actions.push(Action::Restore);
|
|||
|
|
}
|
|||
|
|
return actions;
|
|||
|
|
}
|
|||
|
|
if !self.panel_on {
|
|||
|
|
return Vec::new();
|
|||
|
|
}
|
|||
|
|
self.request_blank(
|
|||
|
|
Instant::now(),
|
|||
|
|
serde_json::json!({ "verb": "screen", "on": false }),
|
|||
|
|
why,
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Ask for a USB gadget posture.
|
|||
|
|
///
|
|||
|
|
/// The decision is intentionally tiny today: all advertised device-role
|
|||
|
|
/// modes are admissible. The seam matters because attachment identity and
|
|||
|
|
/// probe ownership will make that conditional; when they land, the refusal
|
|||
|
|
/// belongs here rather than in QML or usb-signaller. Every request is
|
|||
|
|
/// written now, so that future rule has a trail to inherit.
|
|||
|
|
pub fn request_usb_mode(&mut self, mode: UsbMode, why: &str) -> Vec<Action> {
|
|||
|
|
self.record_decision(
|
|||
|
|
"usb-mode-request",
|
|||
|
|
serde_json::json!({ "mode": mode.as_str() }),
|
|||
|
|
why,
|
|||
|
|
);
|
|||
|
|
vec![Action::UsbMode(mode)]
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Ask to power the machine off, restart it, or put it to sleep.
|
|||
|
|
///
|
|||
|
|
/// Not gated on the lock. Powering off a locked phone is a thing people do
|
|||
|
|
/// deliberately, and a refusal here would send them to the hardware button
|
|||
|
|
/// they are already holding — the same argument `PowerMenu.qml` makes for
|
|||
|
|
/// showing the sheet while locked.
|
|||
|
|
///
|
|||
|
|
/// No state transition either. `Suspending` is entered from logind's
|
|||
|
|
/// `PrepareForSleep`, which is the event that actually means it; setting it
|
|||
|
|
/// here would be a second writer to the state the protocol owns, and a
|
|||
|
|
/// suspend that logind then refuses would leave the machine believing it
|
|||
|
|
/// had gone to sleep (doctrine §4).
|
|||
|
|
pub fn request_power(&mut self, verb: PowerVerb, why: &str) -> Result<Vec<Action>, String> {
|
|||
|
|
if let Some(in_flight) = self.power_requested {
|
|||
|
|
let refusal = format!("{} is already in flight", in_flight.as_str());
|
|||
|
|
self.record_decision(
|
|||
|
|
"power-request-refused",
|
|||
|
|
serde_json::json!({ "verb": verb.as_str(), "in_flight": in_flight.as_str() }),
|
|||
|
|
&refusal,
|
|||
|
|
);
|
|||
|
|
return Err(refusal);
|
|||
|
|
}
|
|||
|
|
self.power_requested = Some(verb);
|
|||
|
|
self.record_decision(
|
|||
|
|
"power-request",
|
|||
|
|
serde_json::json!({ "verb": verb.as_str() }),
|
|||
|
|
why,
|
|||
|
|
);
|
|||
|
|
Ok(vec![Action::Power(verb)])
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Release the in-flight latch after a power verb failed to happen.
|
|||
|
|
///
|
|||
|
|
/// Only the failure path calls this. Success is the machine going down, so
|
|||
|
|
/// there is nothing to clear and nobody left to clear it — but a refused
|
|||
|
|
/// suspend (lid policy, a blocking inhibitor) returns to a running system,
|
|||
|
|
/// and a latch left set there would refuse every later attempt for the rest
|
|||
|
|
/// of the uptime.
|
|||
|
|
pub fn power_request_failed(&mut self, verb: PowerVerb) {
|
|||
|
|
if self.power_requested == Some(verb) {
|
|||
|
|
self.power_requested = None;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// The executor reports the panel's real state. Turning on counts as
|
|||
|
|
/// input, so a dt2w wake starts the blank budget from the wake itself.
|
|||
|
|
///
|
|||
|
|
/// A *report*, never a request — [`Self::request_screen`] is the asking
|
|||
|
|
/// half. Keeping them apart is what stops a stale report from driving the
|
|||
|
|
/// panel, and stops an ask from quietly editing the machine's idea of what
|
|||
|
|
/// the hardware is doing.
|
|||
|
|
pub fn set_panel(&mut self, on: bool) -> Vec<Action> {
|
|||
|
|
if self.panel_on == on {
|
|||
|
|
return Vec::new();
|
|||
|
|
}
|
|||
|
|
self.panel_on = on;
|
|||
|
|
info!("[device-state] panel {}", if on { "on" } else { "off" });
|
|||
|
|
self.blank_requested = false;
|
|||
|
|
// A blank waiting on a lock ack is moot once the panel is dark by any
|
|||
|
|
// route — the power button, the proximity service, a wake that never
|
|||
|
|
// came. Leaving it armed makes the next tick emit a second `Blank` at
|
|||
|
|
// an already-dark panel, or worse, fire the `blank-without-lock` error
|
|||
|
|
// path for a blank nobody is waiting on.
|
|||
|
|
if !on {
|
|||
|
|
self.pending_blank = None;
|
|||
|
|
}
|
|||
|
|
if on {
|
|||
|
|
self.idle_since = None;
|
|||
|
|
// A wake must never come up dim. If the blank landed while the
|
|||
|
|
// warning was showing, the saved brightness is still low, so the
|
|||
|
|
// restore has to run before the user sees the panel.
|
|||
|
|
if self.dimmed {
|
|||
|
|
self.dimmed = false;
|
|||
|
|
return vec![Action::Restore];
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
Vec::new()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// The compositor reported a quiet period beginning at `since`.
|
|||
|
|
pub fn note_idle_start(&mut self, since: Instant) {
|
|||
|
|
if self.idle_since.is_none() {
|
|||
|
|
self.idle_since = Some(since);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Seconds of compositor-reported quiet, or 0 while the user is active.
|
|||
|
|
pub fn idle_secs(&self) -> u64 {
|
|||
|
|
match self.idle_since {
|
|||
|
|
Some(t) => Instant::now().saturating_duration_since(t).as_secs(),
|
|||
|
|
None => 0,
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Whether the compositor currently reports the user as interacting.
|
|||
|
|
pub fn user_active(&self) -> bool {
|
|||
|
|
self.idle_since.is_none()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Build a forensic snapshot of the current state.
|
|||
|
|
pub fn snapshot(
|
|||
|
|
&self,
|
|||
|
|
phase: &str,
|
|||
|
|
shell_alive: bool,
|
|||
|
|
screen_locked: bool,
|
|||
|
|
screen_lock_secure: bool,
|
|||
|
|
idle_state: &str,
|
|||
|
|
inhibitor_held: bool,
|
|||
|
|
) -> StateSnapshot {
|
|||
|
|
StateSnapshot {
|
|||
|
|
device_state: self.state,
|
|||
|
|
locked: is_locked(self.state),
|
|||
|
|
display_active: is_display_active(self.state),
|
|||
|
|
phase: phase.to_string(),
|
|||
|
|
shell_alive,
|
|||
|
|
proximity_near: self.sensor_evidence.proximity_near,
|
|||
|
|
confidence: self.sensor_evidence.confidence(),
|
|||
|
|
suppress_dpms_wake: self.suppress_wake(InputTrigger::DoubleTapToWake),
|
|||
|
|
promote_idle_faster: self.sensor_evidence.should_promote_idle_faster(),
|
|||
|
|
screen_locked,
|
|||
|
|
screen_lock_secure,
|
|||
|
|
idle_coordinator_state: idle_state.to_string(),
|
|||
|
|
sleep_inhibitor_held: inhibitor_held,
|
|||
|
|
sensors_degraded: self.source_health.any_down(),
|
|||
|
|
placement: self.placement(),
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn state(&self) -> DeviceState {
|
|||
|
|
self.state
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Record the brightness the panel was at when the dim fired. The executor
|
|||
|
|
/// reads the hardware; the machine is what remembers.
|
|||
|
|
pub fn note_brightness_before_dim(&mut self, value: Option<u32>) {
|
|||
|
|
self.brightness_before_dim = value;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// The captured pre-dim brightness, consumed by the restore. Taken rather
|
|||
|
|
/// than read: a stale value surviving into the next dim cycle is exactly
|
|||
|
|
/// the class of bug this replaced.
|
|||
|
|
pub fn take_brightness_before_dim(&mut self) -> Option<u32> {
|
|||
|
|
self.brightness_before_dim.take()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Should this wake be refused because something is over the sensor?
|
|||
|
|
///
|
|||
|
|
/// Tap-to-wake only. A double tap is the one wake source a pocket can
|
|||
|
|
/// produce by itself, so a covered sensor is the right veto for it. A
|
|||
|
|
/// power button press is intent (§4: "no — hardware signal") and is never
|
|||
|
|
/// refused; neither is a wake the machine itself asked for.
|
|||
|
|
///
|
|||
|
|
/// This used to be a confidence question — the pair proximity-near +
|
|||
|
|
/// accel-moving read as 0.5 and suppressed everything. Motion cannot tell
|
|||
|
|
/// a pocket from an ear from a hand, so it was never the instrument.
|
|||
|
|
///
|
|||
|
|
/// **Owed:** a call. Screen dark at the ear is wanted whether or not the
|
|||
|
|
/// session is locked, and it is the only case where proximity should turn
|
|||
|
|
/// 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)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Where the phone believes it is. Interpretation, not a reading.
|
|||
|
|
///
|
|||
|
|
/// `proximity_near` was being read as "pocket" and refusing things on that
|
|||
|
|
/// basis. §4 says it cannot carry that meaning — *"A covered sensor is a
|
|||
|
|
/// pocket, a face, a table or a thumb, and nothing in the machine can tell
|
|||
|
|
/// which"* — and measured 2026-08-05 it was drastically wrong: seven
|
|||
|
|
/// deliberate squeezes refused as pocket-dials while the phone was in a
|
|||
|
|
/// hand, unlocked, screen lit.
|
|||
|
|
///
|
|||
|
|
/// So the four candidates §4 names get separated by the evidence that
|
|||
|
|
/// actually distinguishes them, and each answer carries a confidence,
|
|||
|
|
/// because they are not equally knowable:
|
|||
|
|
///
|
|||
|
|
/// - **Hand** — unlocked with the panel lit. Proximity near is *compatible*
|
|||
|
|
/// with this, not evidence against it: holding a phone is what puts a
|
|||
|
|
/// palm over the sensor.
|
|||
|
|
/// - **Table** — lit and unlocked but nothing has touched it in a while.
|
|||
|
|
/// Casey, 2026-08-05: *"on table is more accurate as I'm not staring it
|
|||
|
|
/// down but if it flashed I'd notice"* — which is the useful part. Table
|
|||
|
|
/// is *attention available*, not absence.
|
|||
|
|
/// - **Pocket** — the only one that needs all of locked, dark and covered,
|
|||
|
|
/// and still tops out **low**. Every one of those three is a state the
|
|||
|
|
/// phone is often in on a desk in a dark room, and the sensor that would
|
|||
|
|
/// settle it is the one §4's table marks as able to lie.
|
|||
|
|
/// - **Face** — needs a call, and there is no call-state input yet. §4 names
|
|||
|
|
/// it as owed; until it exists this is never answered rather than guessed.
|
|||
|
|
///
|
|||
|
|
/// Confidence is deliberately capped below certainty everywhere. This is
|
|||
|
|
/// interpretation consuming evidence, and it gates one thing (tap-to-wake).
|
|||
|
|
/// It is on the snapshot so the trail records what was believed, and it is
|
|||
|
|
/// never an authority.
|
|||
|
|
pub fn placement(&self) -> Placement {
|
|||
|
|
let covered = self.sensor_evidence.proximity_near;
|
|||
|
|
let locked = is_locked(self.state);
|
|||
|
|
let lit = self.panel_on;
|
|||
|
|
// `idle_since` is cleared by real input, so `None` is "something touched
|
|||
|
|
// this recently" without needing a clock passed in.
|
|||
|
|
let active = self.idle_since.is_none();
|
|||
|
|
|
|||
|
|
// Covered is tested first: it is the only pocket-specific evidence
|
|||
|
|
// here, and every other branch describes a phone in the open. Ordering
|
|||
|
|
// it after "unlocked and lit" meant a covered sensor never reached this
|
|||
|
|
// at all, because an unlocked lit phone matched Hand first.
|
|||
|
|
let (belief, confidence) = if covered {
|
|||
|
|
// Lock and darkness *grade* the reading rather than gate it.
|
|||
|
|
// Gating on either disarmed the one thing §4 keeps the veto for —
|
|||
|
|
// the pocket case worth stopping is a double tap about to light the
|
|||
|
|
// screen up, which happens before the panel is dark and can happen
|
|||
|
|
// before the lock hint catches up. Capped below Hand and Table
|
|||
|
|
// throughout, because a face-down phone on a desk produces this
|
|||
|
|
// exact signature and nothing here separates them.
|
|||
|
|
let c = match (locked, lit) {
|
|||
|
|
(true, false) => 0.7,
|
|||
|
|
(true, true) => 0.6,
|
|||
|
|
_ => 0.5,
|
|||
|
|
};
|
|||
|
|
(PlacementBelief::Pocket, c)
|
|||
|
|
} else if !locked && lit && active {
|
|||
|
|
(PlacementBelief::Hand, 0.8)
|
|||
|
|
} else if !locked && lit {
|
|||
|
|
// Unlocked and bright with nobody touching it. Casey's case, and
|
|||
|
|
// the one the old boolean got most wrong.
|
|||
|
|
(PlacementBelief::Table, 0.7)
|
|||
|
|
} else if locked {
|
|||
|
|
(PlacementBelief::Table, 0.5)
|
|||
|
|
} else {
|
|||
|
|
(PlacementBelief::Unknown, 0.0)
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
Placement {
|
|||
|
|
belief,
|
|||
|
|
confidence,
|
|||
|
|
covered,
|
|||
|
|
locked,
|
|||
|
|
lit,
|
|||
|
|
active,
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Tap-to-wake, and nothing else.
|
|||
|
|
///
|
|||
|
|
/// §4 settles the scope in one line — *"It vetoes tap-to-wake, and nothing
|
|||
|
|
/// else"* — and the argument is that a double tap is the one input **a
|
|||
|
|
/// pocket can produce by itself**. Squeeze was in this list and does not
|
|||
|
|
/// meet that test: six strain gauges deflecting past a calibrated baseline
|
|||
|
|
/// is a hand closing on the phone, and holding it in order to squeeze it is
|
|||
|
|
/// exactly what covers the proximity sensor.
|
|||
|
|
///
|
|||
|
|
/// If pocket squeezes turn out to be real, the answer is the producer's
|
|||
|
|
/// threshold — it owns the gauges and their per-device sensitivities — not
|
|||
|
|
/// a veto from the one sensor that cannot tell a pocket from a thumb.
|
|||
|
|
pub fn suppress_wake(&self, trigger: InputTrigger) -> bool {
|
|||
|
|
let p = self.placement();
|
|||
|
|
matches!(trigger, InputTrigger::DoubleTapToWake)
|
|||
|
|
&& p.belief == PlacementBelief::Pocket
|
|||
|
|
&& p.confidence >= POCKET_VETO_CONFIDENCE
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Attempt a state transition. Returns true if the transition was
|
|||
|
|
/// legal and applied, false if refused (logged as a warning).
|
|||
|
|
/// Emits a forensic entry for every attempt (legal or not).
|
|||
|
|
pub fn transition(&mut self, next: DeviceState) -> bool {
|
|||
|
|
if self.state == next {
|
|||
|
|
return true; // idempotent
|
|||
|
|
}
|
|||
|
|
let legal = LEGAL_TRANSITIONS
|
|||
|
|
.iter()
|
|||
|
|
.any(|(from, to)| *from == self.state && *to == next);
|
|||
|
|
let prev = self.state;
|
|||
|
|
if !legal {
|
|||
|
|
warn!(
|
|||
|
|
"[device-state] ILLEGAL transition {:?} → {:?} (refused)",
|
|||
|
|
self.state, next
|
|||
|
|
);
|
|||
|
|
// Forensic: record the refused transition with a minimal snapshot
|
|||
|
|
let snapshot = StateSnapshot {
|
|||
|
|
device_state: self.state,
|
|||
|
|
locked: is_locked(self.state),
|
|||
|
|
display_active: is_display_active(self.state),
|
|||
|
|
phase: String::new(),
|
|||
|
|
shell_alive: false,
|
|||
|
|
proximity_near: self.sensor_evidence.proximity_near,
|
|||
|
|
confidence: self.sensor_evidence.confidence(),
|
|||
|
|
suppress_dpms_wake: self.suppress_wake(InputTrigger::DoubleTapToWake),
|
|||
|
|
promote_idle_faster: self.sensor_evidence.should_promote_idle_faster(),
|
|||
|
|
screen_locked: false,
|
|||
|
|
screen_lock_secure: false,
|
|||
|
|
idle_coordinator_state: String::new(),
|
|||
|
|
sleep_inhibitor_held: false,
|
|||
|
|
sensors_degraded: self.source_health.any_down(),
|
|||
|
|
placement: self.placement(),
|
|||
|
|
};
|
|||
|
|
self.forensic.append(
|
|||
|
|
ForensicEvent::Transition {
|
|||
|
|
from: prev,
|
|||
|
|
to: next,
|
|||
|
|
legal: false,
|
|||
|
|
},
|
|||
|
|
snapshot,
|
|||
|
|
&format!("illegal transition {:?} → {:?} refused", prev, next),
|
|||
|
|
);
|
|||
|
|
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.
|
|||
|
|
info!("[device-state] {:?} → {:?}", prev, next);
|
|||
|
|
// Forensic: record the successful transition
|
|||
|
|
let snapshot = StateSnapshot {
|
|||
|
|
device_state: self.state,
|
|||
|
|
locked: is_locked(self.state),
|
|||
|
|
display_active: is_display_active(self.state),
|
|||
|
|
phase: String::new(),
|
|||
|
|
shell_alive: false,
|
|||
|
|
proximity_near: self.sensor_evidence.proximity_near,
|
|||
|
|
confidence: self.sensor_evidence.confidence(),
|
|||
|
|
suppress_dpms_wake: self.suppress_wake(InputTrigger::DoubleTapToWake),
|
|||
|
|
promote_idle_faster: self.sensor_evidence.should_promote_idle_faster(),
|
|||
|
|
screen_locked: false,
|
|||
|
|
screen_lock_secure: false,
|
|||
|
|
idle_coordinator_state: String::new(),
|
|||
|
|
sleep_inhibitor_held: false,
|
|||
|
|
sensors_degraded: self.source_health.any_down(),
|
|||
|
|
placement: self.placement(),
|
|||
|
|
};
|
|||
|
|
self.forensic.append(
|
|||
|
|
ForensicEvent::Transition {
|
|||
|
|
from: prev,
|
|||
|
|
to: next,
|
|||
|
|
legal: true,
|
|||
|
|
},
|
|||
|
|
snapshot,
|
|||
|
|
&format!("{:?} → {:?}", prev, next),
|
|||
|
|
);
|
|||
|
|
true
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Adopt logind's `LockedHint` as the lock truth.
|
|||
|
|
///
|
|||
|
|
/// This replaces the machine deciding for itself. It had no unlock ingress
|
|||
|
|
/// outside its own fallback surface, so after the first unlock it believed
|
|||
|
|
/// the session was locked forever, and every rule gated on
|
|||
|
|
/// `is_locked(state)` — the blank budget AND the proximity blank — fired
|
|||
|
|
/// against an unlocked, in-use phone. Doctrine §4: never hold state the
|
|||
|
|
/// protocol owns.
|
|||
|
|
///
|
|||
|
|
/// Locking moves the base tier to `Locked`; unlocking returns to `Active`
|
|||
|
|
/// from whichever locked sub-state we were in. Sub-state detail
|
|||
|
|
/// (`Observed`, doze tiers) is ours to track *within* locked, but whether
|
|||
|
|
/// we are locked at all is not.
|
|||
|
|
pub fn set_session_locked(&mut self, locked: bool) -> Vec<Action> {
|
|||
|
|
if locked == is_locked(self.state) {
|
|||
|
|
return Vec::new();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if locked {
|
|||
|
|
self.transition(DeviceState::Locked);
|
|||
|
|
return Vec::new();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Unlocked. Anything the machine decided on the strength of believing
|
|||
|
|
// it was locked is void: a pending blank was only legitimate because
|
|||
|
|
// the screen was a lock screen, and the dim warning belongs to that
|
|||
|
|
// same rule.
|
|||
|
|
//
|
|||
|
|
// But only if the machine can actually leave where it is. There is no
|
|||
|
|
// legal Suspending → Active or Asleep → Active edge (waking goes
|
|||
|
|
// through Locked), so an unlock reported while the device is asleep is
|
|||
|
|
// either a logind race or a genuine impossibility. Voiding lock-screen
|
|||
|
|
// intent while the state stays Asleep would leave the two disagreeing,
|
|||
|
|
// which is the whole class of bug this function exists to end.
|
|||
|
|
if !self.transition(DeviceState::Active) {
|
|||
|
|
warn!(
|
|||
|
|
"[device-state] logind says unlocked but {:?} has no edge to Active — keeping lock-screen intent",
|
|||
|
|
self.state
|
|||
|
|
);
|
|||
|
|
self.record_error(
|
|||
|
|
"device-state",
|
|||
|
|
"unlock-refused",
|
|||
|
|
&format!("no legal transition from {:?} to Active", self.state),
|
|||
|
|
);
|
|||
|
|
return Vec::new();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
info!("[device-state] session unlocked (logind) — clearing lock-screen intent");
|
|||
|
|
self.blank_requested = false;
|
|||
|
|
self.pending_blank = None;
|
|||
|
|
|
|||
|
|
let mut actions = Vec::new();
|
|||
|
|
if self.dimmed {
|
|||
|
|
self.dimmed = false;
|
|||
|
|
actions.push(Action::Restore);
|
|||
|
|
}
|
|||
|
|
actions
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Decide whether to believe a raw proximity reading yet.
|
|||
|
|
///
|
|||
|
|
/// Returns the *believed* value. A raw reading that disagrees with the
|
|||
|
|
/// believed one starts a clock; it only wins once it has held for the
|
|||
|
|
/// direction's threshold. `resolve_proximity_debounce` finishes the job on
|
|||
|
|
/// the tick, for the case where the sensor reports once and goes quiet.
|
|||
|
|
fn debounce_proximity(&mut self, raw: bool, now: Instant) -> bool {
|
|||
|
|
if raw != self.proximity_raw {
|
|||
|
|
self.proximity_raw = raw;
|
|||
|
|
self.proximity_since = Some(now);
|
|||
|
|
}
|
|||
|
|
let believed = self.sensor_evidence.proximity_near;
|
|||
|
|
if raw == believed {
|
|||
|
|
// Nothing pending — the sensor agrees with what we already think.
|
|||
|
|
self.proximity_since = None;
|
|||
|
|
return believed;
|
|||
|
|
}
|
|||
|
|
let held_for = self
|
|||
|
|
.proximity_since
|
|||
|
|
.map(|t| now.saturating_duration_since(t))
|
|||
|
|
.unwrap_or_default();
|
|||
|
|
let threshold = if raw {
|
|||
|
|
self.policy.proximity_near_debounce
|
|||
|
|
} else {
|
|||
|
|
self.policy.proximity_far_debounce
|
|||
|
|
};
|
|||
|
|
if held_for >= threshold {
|
|||
|
|
self.proximity_since = None;
|
|||
|
|
raw
|
|||
|
|
} else {
|
|||
|
|
believed
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Let a held reading win once its threshold passes with no new report.
|
|||
|
|
///
|
|||
|
|
/// Without this the debounce would only resolve when the next reading
|
|||
|
|
/// arrives, and a reporter that heartbeats every 30 s would make a real
|
|||
|
|
/// `near` take up to half a minute to be believed.
|
|||
|
|
fn resolve_proximity_debounce(&mut self, now: Instant) {
|
|||
|
|
if self.proximity_raw == self.sensor_evidence.proximity_near {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
let held_for = self
|
|||
|
|
.proximity_since
|
|||
|
|
.map(|t| now.saturating_duration_since(t))
|
|||
|
|
.unwrap_or_default();
|
|||
|
|
let believed = self.debounce_proximity(self.proximity_raw, now);
|
|||
|
|
if believed != self.sensor_evidence.proximity_near {
|
|||
|
|
self.sensor_evidence.proximity_near = believed;
|
|||
|
|
|
|||
|
|
// Record the edge. Without this the trail would carry a
|
|||
|
|
// Locked → Observed transition with no sensor input behind it —
|
|||
|
|
// the reading was filtered on the way in and believed a tick
|
|||
|
|
// later, so nothing would say why the machine moved. Filtered
|
|||
|
|
// blips stay out of the trail deliberately; the edge that wins
|
|||
|
|
// does not.
|
|||
|
|
let confidence = self.sensor_evidence.confidence();
|
|||
|
|
let snapshot = self.snapshot("", false, false, false, "", false);
|
|||
|
|
self.forensic.append(
|
|||
|
|
ForensicEvent::SensorInput {
|
|||
|
|
source: SensorSource::Proximity,
|
|||
|
|
value: SensorValue::Near(believed),
|
|||
|
|
confidence,
|
|||
|
|
},
|
|||
|
|
snapshot,
|
|||
|
|
&format!(
|
|||
|
|
"proximity={believed} believed after holding {} ms",
|
|||
|
|
held_for.as_millis()
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
let should_be_observed = self.state == DeviceState::Locked && believed;
|
|||
|
|
if should_be_observed {
|
|||
|
|
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);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Proximity-shaped convenience wrapper.
|
|||
|
|
pub fn update_sensors(&mut self, evidence: SensorEvidence) {
|
|||
|
|
self.update_sensors_from(SensorSource::Proximity, evidence)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Update sensor evidence and, if the current state is Locked,
|
|||
|
|
/// potentially transition to/from Observed.
|
|||
|
|
/// Emits forensic entries for every sensor input.
|
|||
|
|
pub fn update_sensors_from(&mut self, source: SensorSource, evidence: SensorEvidence) {
|
|||
|
|
self.update_sensors_from_at(source, evidence, Instant::now())
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// `update_sensors_from` with the clock passed in, so the debounce can be
|
|||
|
|
/// tested on a timeline instead of by sleeping.
|
|||
|
|
pub fn update_sensors_from_at(
|
|||
|
|
&mut self,
|
|||
|
|
source: SensorSource,
|
|||
|
|
mut evidence: SensorEvidence,
|
|||
|
|
now: Instant,
|
|||
|
|
) {
|
|||
|
|
// Proximity is debounced before it is believed. The rest of the
|
|||
|
|
// evidence is taken as reported — none of it flaps the way this one
|
|||
|
|
// does, and none of it has the measurement behind it that would justify
|
|||
|
|
// picking a threshold.
|
|||
|
|
if source == SensorSource::Proximity {
|
|||
|
|
evidence.proximity_near = self.debounce_proximity(evidence.proximity_near, now);
|
|||
|
|
} else {
|
|||
|
|
evidence.proximity_near = self.sensor_evidence.proximity_near;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let was_observed = matches!(self.state, DeviceState::Observed);
|
|||
|
|
let proximity_near = evidence.proximity_near;
|
|||
|
|
let should_be_observed = self.state == DeviceState::Locked && proximity_near;
|
|||
|
|
let confidence = evidence.confidence();
|
|||
|
|
|
|||
|
|
// A reading identical to the one already held is not a decision point.
|
|||
|
|
//
|
|||
|
|
// Reporters heartbeat now — they re-send their last value on a fixed
|
|||
|
|
// interval so that silence means a dead source rather than a quiet one
|
|||
|
|
// (see `SOURCE_DOWN_AFTER`). Those repeats must not each become a trail
|
|||
|
|
// entry: proximity alone would add ~2,900 lines a day saying nothing
|
|||
|
|
// changed, to a log that §0 already records as unbounded and on tmpfs.
|
|||
|
|
// Freshness is still stamped by the caller either way, which is what
|
|||
|
|
// the keepalive is actually for.
|
|||
|
|
let unchanged = self.sensor_evidence.proximity_near == evidence.proximity_near
|
|||
|
|
&& self.sensor_evidence.accel_moving == evidence.accel_moving
|
|||
|
|
&& self.sensor_evidence.light_changing == evidence.light_changing
|
|||
|
|
&& self.sensor_evidence.touch_active == evidence.touch_active;
|
|||
|
|
|
|||
|
|
self.sensor_evidence = evidence;
|
|||
|
|
|
|||
|
|
// Only the trail write is skipped, never the evaluation below. An
|
|||
|
|
// identical reading can still change the answer: proximity held `near`
|
|||
|
|
// across a lock means `should_be_observed` flips from false to true
|
|||
|
|
// with no change in the evidence at all.
|
|||
|
|
if !unchanged {
|
|||
|
|
// Forensic: record the sensor input and its evaluation
|
|||
|
|
let snapshot = StateSnapshot {
|
|||
|
|
device_state: self.state,
|
|||
|
|
locked: is_locked(self.state),
|
|||
|
|
display_active: is_display_active(self.state),
|
|||
|
|
phase: String::new(),
|
|||
|
|
shell_alive: false,
|
|||
|
|
proximity_near: self.sensor_evidence.proximity_near,
|
|||
|
|
confidence,
|
|||
|
|
suppress_dpms_wake: self.suppress_wake(InputTrigger::DoubleTapToWake),
|
|||
|
|
promote_idle_faster: self.sensor_evidence.should_promote_idle_faster(),
|
|||
|
|
screen_locked: false,
|
|||
|
|
screen_lock_secure: false,
|
|||
|
|
idle_coordinator_state: String::new(),
|
|||
|
|
sleep_inhibitor_held: false,
|
|||
|
|
sensors_degraded: self.source_health.any_down(),
|
|||
|
|
placement: self.placement(),
|
|||
|
|
};
|
|||
|
|
self.forensic.append(
|
|||
|
|
ForensicEvent::SensorInput {
|
|||
|
|
// The reporting source, not a hardcoded Proximity. Accel,
|
|||
|
|
// light and touch readings were all being written to the trail
|
|||
|
|
// labelled as proximity, which makes post-hoc reconstruction —
|
|||
|
|
// the entire point of the trail — lie about what was measured.
|
|||
|
|
source,
|
|||
|
|
value: match source {
|
|||
|
|
SensorSource::Proximity => SensorValue::Near(proximity_near),
|
|||
|
|
SensorSource::Accelerometer => {
|
|||
|
|
SensorValue::Moving(self.sensor_evidence.accel_moving)
|
|||
|
|
}
|
|||
|
|
SensorSource::Light => {
|
|||
|
|
SensorValue::Changing(self.sensor_evidence.light_changing)
|
|||
|
|
}
|
|||
|
|
SensorSource::Touch => {
|
|||
|
|
SensorValue::Active(self.sensor_evidence.touch_active)
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
confidence,
|
|||
|
|
},
|
|||
|
|
snapshot,
|
|||
|
|
&format!(
|
|||
|
|
"proximity={}, confidence={:.2}, should_observe={}, was_observed={}",
|
|||
|
|
proximity_near, confidence, should_be_observed, was_observed
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if should_be_observed && !was_observed {
|
|||
|
|
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);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Record a wake event (screen on, dt2w, power button, etc.).
|
|||
|
|
pub fn record_wake(&self, trigger: WakeTrigger, reason: &str) {
|
|||
|
|
let snapshot = StateSnapshot {
|
|||
|
|
device_state: self.state,
|
|||
|
|
locked: is_locked(self.state),
|
|||
|
|
display_active: is_display_active(self.state),
|
|||
|
|
phase: String::new(),
|
|||
|
|
shell_alive: false,
|
|||
|
|
proximity_near: self.sensor_evidence.proximity_near,
|
|||
|
|
confidence: self.sensor_evidence.confidence(),
|
|||
|
|
suppress_dpms_wake: self.suppress_wake(InputTrigger::DoubleTapToWake),
|
|||
|
|
promote_idle_faster: self.sensor_evidence.should_promote_idle_faster(),
|
|||
|
|
screen_locked: false,
|
|||
|
|
screen_lock_secure: false,
|
|||
|
|
idle_coordinator_state: String::new(),
|
|||
|
|
sleep_inhibitor_held: false,
|
|||
|
|
sensors_degraded: self.source_health.any_down(),
|
|||
|
|
placement: self.placement(),
|
|||
|
|
};
|
|||
|
|
self.forensic
|
|||
|
|
.append(ForensicEvent::Wake { trigger }, snapshot, reason);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Record an error that affected device state.
|
|||
|
|
pub fn record_error(&self, component: &str, action: &str, error: &str) {
|
|||
|
|
let snapshot = StateSnapshot {
|
|||
|
|
device_state: self.state,
|
|||
|
|
locked: is_locked(self.state),
|
|||
|
|
display_active: is_display_active(self.state),
|
|||
|
|
phase: String::new(),
|
|||
|
|
shell_alive: false,
|
|||
|
|
proximity_near: self.sensor_evidence.proximity_near,
|
|||
|
|
confidence: self.sensor_evidence.confidence(),
|
|||
|
|
suppress_dpms_wake: self.suppress_wake(InputTrigger::DoubleTapToWake),
|
|||
|
|
promote_idle_faster: self.sensor_evidence.should_promote_idle_faster(),
|
|||
|
|
screen_locked: false,
|
|||
|
|
screen_lock_secure: false,
|
|||
|
|
idle_coordinator_state: String::new(),
|
|||
|
|
sleep_inhibitor_held: false,
|
|||
|
|
sensors_degraded: self.source_health.any_down(),
|
|||
|
|
placement: self.placement(),
|
|||
|
|
};
|
|||
|
|
self.forensic.append(
|
|||
|
|
ForensicEvent::Error {
|
|||
|
|
component: component.to_string(),
|
|||
|
|
action: action.to_string(),
|
|||
|
|
error: error.to_string(),
|
|||
|
|
},
|
|||
|
|
snapshot,
|
|||
|
|
&format!("[{}] {}: {}", component, action, error),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Record a decision (e.g., "suppress DPMS wake", "promote idle").
|
|||
|
|
pub fn record_decision(&self, decision: &str, inputs: serde_json::Value, reason: &str) {
|
|||
|
|
let snapshot = StateSnapshot {
|
|||
|
|
device_state: self.state,
|
|||
|
|
locked: is_locked(self.state),
|
|||
|
|
display_active: is_display_active(self.state),
|
|||
|
|
phase: String::new(),
|
|||
|
|
shell_alive: false,
|
|||
|
|
proximity_near: self.sensor_evidence.proximity_near,
|
|||
|
|
confidence: self.sensor_evidence.confidence(),
|
|||
|
|
suppress_dpms_wake: self.suppress_wake(InputTrigger::DoubleTapToWake),
|
|||
|
|
promote_idle_faster: self.sensor_evidence.should_promote_idle_faster(),
|
|||
|
|
screen_locked: false,
|
|||
|
|
screen_lock_secure: false,
|
|||
|
|
idle_coordinator_state: String::new(),
|
|||
|
|
sleep_inhibitor_held: false,
|
|||
|
|
sensors_degraded: self.source_health.any_down(),
|
|||
|
|
placement: self.placement(),
|
|||
|
|
};
|
|||
|
|
self.forensic.append(
|
|||
|
|
ForensicEvent::Decision {
|
|||
|
|
decision: decision.to_string(),
|
|||
|
|
inputs,
|
|||
|
|
},
|
|||
|
|
snapshot,
|
|||
|
|
reason,
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Serialize the current state for IPC.
|
|||
|
|
pub fn to_ipc_json(&self) -> serde_json::Value {
|
|||
|
|
serde_json::json!({
|
|||
|
|
"device_state": self.state,
|
|||
|
|
"locked": is_locked(self.state),
|
|||
|
|
"display_active": is_display_active(self.state),
|
|||
|
|
"observed": matches!(self.state, DeviceState::Observed),
|
|||
|
|
"observed_confidence": self.sensor_evidence.confidence(),
|
|||
|
|
"suppress_dpms_wake": self.suppress_wake(InputTrigger::DoubleTapToWake),
|
|||
|
|
"panel_on": self.panel_on,
|
|||
|
|
"user_active": self.user_active(),
|
|||
|
|
"idle_secs": self.idle_secs(),
|
|||
|
|
"dimmed": self.dimmed,
|
|||
|
|
"blank_requested": self.blank_requested,
|
|||
|
|
// Freshness means "reported within the TTL", not "has ever been
|
|||
|
|
// heard from". The `is_some()` version read as fresh for the whole
|
|||
|
|
// life of the daemon, because `expire_stale_evidence` only clears
|
|||
|
|
// the timestamp for a source whose flag was set — so a sensor
|
|||
|
|
// resting at `far` reported `fresh: true` indefinitely, including
|
|||
|
|
// through the outage this field exists to make visible.
|
|||
|
|
"evidence_fresh": {
|
|||
|
|
"proximity": self.is_fresh(self.evidence_seen.proximity),
|
|||
|
|
"accel": self.is_fresh(self.evidence_seen.accel),
|
|||
|
|
"light": self.is_fresh(self.evidence_seen.light),
|
|||
|
|
"touch": self.is_fresh(self.evidence_seen.touch),
|
|||
|
|
},
|
|||
|
|
// Health is the other axis: `fresh` says whether the reading may
|
|||
|
|
// be believed, `health` says whether the source is there at all.
|
|||
|
|
"sensor_health": self.source_health.as_json(),
|
|||
|
|
"sensors_degraded": self.source_health.any_down(),
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn is_fresh(&self, seen: Option<Instant>) -> bool {
|
|||
|
|
seen.is_some_and(|t| {
|
|||
|
|
Instant::now().saturating_duration_since(t) <= self.policy.evidence_ttl
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
impl fmt::Display for DeviceState {
|
|||
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|||
|
|
match self {
|
|||
|
|
DeviceState::Active => write!(f, "active"),
|
|||
|
|
DeviceState::Dimmed => write!(f, "dimmed"),
|
|||
|
|
DeviceState::Locked => write!(f, "locked"),
|
|||
|
|
DeviceState::Observed => write!(f, "observed"),
|
|||
|
|
DeviceState::DozeLight => write!(f, "doze_light"),
|
|||
|
|
DeviceState::DozeDeep => write!(f, "doze_deep"),
|
|||
|
|
DeviceState::Suspending => write!(f, "suspending"),
|
|||
|
|
DeviceState::Asleep => write!(f, "asleep"),
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[cfg(test)]
|
|||
|
|
mod tests {
|
|||
|
|
use super::*;
|
|||
|
|
use crate::sessiond::bearer::{LinkHealth, TunnelHealth};
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn initial_state_is_active() {
|
|||
|
|
let sm = DeviceStateMachine::new();
|
|||
|
|
assert_eq!(sm.state(), DeviceState::Active);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// A locked machine with a lit panel, where the compositor has just
|
|||
|
|
/// reported that input stopped at `t0`.
|
|||
|
|
fn locked_and_lit() -> (DeviceStateMachine, Instant) {
|
|||
|
|
let mut sm = DeviceStateMachine::new();
|
|||
|
|
sm.transition(DeviceState::Locked);
|
|||
|
|
assert!(sm.panel_on);
|
|||
|
|
let t0 = Instant::now();
|
|||
|
|
sm.note_idle_start(t0);
|
|||
|
|
(sm, t0)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Unlocked, panel lit, an explicit unlocked blank budget set. This is the
|
|||
|
|
/// configuration `unlocked_blank_after` exists for; the default is None.
|
|||
|
|
fn unlocked_and_lit() -> (DeviceStateMachine, Instant) {
|
|||
|
|
let mut sm = DeviceStateMachine::new();
|
|||
|
|
assert!(!is_locked(sm.state));
|
|||
|
|
assert!(sm.panel_on);
|
|||
|
|
sm.policy.unlocked_blank_after = Some(Duration::from_secs(30));
|
|||
|
|
sm.policy.dim_warning = false;
|
|||
|
|
let t0 = Instant::now();
|
|||
|
|
sm.note_idle_start(t0);
|
|||
|
|
(sm, t0)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn an_unlock_reported_while_asleep_is_refused_not_half_applied() {
|
|||
|
|
// There is no Asleep → Active edge. Voiding lock-screen intent while
|
|||
|
|
// the state stays Asleep would leave the two disagreeing.
|
|||
|
|
let mut sm = DeviceStateMachine::new();
|
|||
|
|
sm.transition(DeviceState::Locked);
|
|||
|
|
sm.transition(DeviceState::Suspending);
|
|||
|
|
sm.transition(DeviceState::Asleep);
|
|||
|
|
sm.dimmed = true;
|
|||
|
|
sm.blank_requested = true;
|
|||
|
|
|
|||
|
|
assert!(sm.set_session_locked(false).is_empty());
|
|||
|
|
assert_eq!(sm.state, DeviceState::Asleep, "state must not move");
|
|||
|
|
assert!(sm.dimmed, "intent must not be voided by a refused unlock");
|
|||
|
|
assert!(sm.blank_requested);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn logind_unlock_releases_the_machine_from_locked() {
|
|||
|
|
// The bug this closes, measured on hardware 2026-07-26: the machine
|
|||
|
|
// sat in Locked for 40 minutes while LockedHint said no, because
|
|||
|
|
// locked_ack was the only lock ingress and there was no unlock one.
|
|||
|
|
let (mut sm, _t0) = locked_and_lit();
|
|||
|
|
assert!(is_locked(sm.state));
|
|||
|
|
|
|||
|
|
sm.set_session_locked(false);
|
|||
|
|
assert_eq!(sm.state, DeviceState::Active);
|
|||
|
|
assert!(!is_locked(sm.state));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn unlocking_cancels_a_pending_blank_and_restores_the_dim() {
|
|||
|
|
let (mut sm, t0) = locked_and_lit();
|
|||
|
|
// Get into the dim pre-warning.
|
|||
|
|
let dim_at = LOCK_BLANK_AFTER - LOCK_DIM_GRACE;
|
|||
|
|
assert_eq!(
|
|||
|
|
sm.tick_at(t0 + dim_at + Duration::from_millis(10)),
|
|||
|
|
vec![Action::Dim]
|
|||
|
|
);
|
|||
|
|
assert!(sm.dimmed);
|
|||
|
|
|
|||
|
|
// Unlocking voids lock-screen intent and undims.
|
|||
|
|
assert_eq!(sm.set_session_locked(false), vec![Action::Restore]);
|
|||
|
|
assert!(!sm.dimmed);
|
|||
|
|
assert!(sm.pending_blank.is_none());
|
|||
|
|
assert!(!sm.blank_requested);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn relocking_puts_the_machine_back() {
|
|||
|
|
let (mut sm, _t0) = locked_and_lit();
|
|||
|
|
sm.set_session_locked(false);
|
|||
|
|
assert_eq!(sm.state, DeviceState::Active);
|
|||
|
|
sm.set_session_locked(true);
|
|||
|
|
assert!(is_locked(sm.state));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn an_unlocked_blank_asks_for_the_lock_first() {
|
|||
|
|
let (mut sm, t0) = unlocked_and_lit();
|
|||
|
|
|
|||
|
|
// The budget expires: the machine wants the panel dark, but the
|
|||
|
|
// session is unlocked, so the lock goes first — LOCK-DPMS §1.
|
|||
|
|
assert_eq!(sm.tick_at(t0 + Duration::from_secs(31)), vec![Action::Lock]);
|
|||
|
|
assert!(sm.pending_blank.is_some());
|
|||
|
|
assert!(!sm.blank_requested, "the panel must not be dark yet");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn the_agents_screen_verb_cannot_blank_an_unlocked_session() {
|
|||
|
|
// Doctrine §13 in one assertion. Operation is hers, so she may ask for
|
|||
|
|
// the screen off — and the ordering invariant is not a permission she
|
|||
|
|
// lacks, it is a property of the machine that applies to every caller.
|
|||
|
|
// She gets `Lock` first, exactly as the idle timer does.
|
|||
|
|
let (mut sm, _t0) = unlocked_and_lit();
|
|||
|
|
assert_eq!(sm.request_screen(false, "agent"), vec![Action::Lock]);
|
|||
|
|
assert!(sm.pending_blank.is_some());
|
|||
|
|
assert!(!sm.blank_requested, "the panel must not be dark yet");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn double_tap_to_wake_actually_wakes_the_panel() {
|
|||
|
|
// The regression that made viewtop feel worse than Hyprland. This
|
|||
|
|
// function recorded the wake and returned `Restore`, which is
|
|||
|
|
// brightness — so dt2w reported to the machine and the screen stayed
|
|||
|
|
// dark. It only ever worked because hyprland.lua bound XF86WakeUp
|
|||
|
|
// straight to the DPMS toggle and the daemon was never consulted.
|
|||
|
|
let (mut sm, _t0) = unlocked_and_lit();
|
|||
|
|
sm.set_panel(false);
|
|||
|
|
let actions = sm.note_input(InputTrigger::DoubleTapToWake);
|
|||
|
|
assert!(
|
|||
|
|
actions.contains(&Action::Unblank),
|
|||
|
|
"a double tap on a dark panel must light it: {actions:?}"
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn a_wake_on_a_lit_panel_does_not_unblank_it() {
|
|||
|
|
let (mut sm, _t0) = unlocked_and_lit();
|
|||
|
|
assert!(sm.panel_on());
|
|||
|
|
let actions = sm.note_input(InputTrigger::DoubleTapToWake);
|
|||
|
|
assert!(!actions.contains(&Action::Unblank));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn the_power_button_does_not_unblank_twice() {
|
|||
|
|
// `apply_gesture` already wakes on a resolved tap. If `note_input`
|
|||
|
|
// also woke on the down edge there would be two unblanks in flight for
|
|||
|
|
// one press, and the second would land against a panel the first had
|
|||
|
|
// lit — which is exactly how the wake loop happened in the compositor.
|
|||
|
|
let (mut sm, _t0) = unlocked_and_lit();
|
|||
|
|
sm.set_panel(false);
|
|||
|
|
let actions = sm.note_input(InputTrigger::PowerButton);
|
|||
|
|
assert!(
|
|||
|
|
!actions.contains(&Action::Unblank),
|
|||
|
|
"the power button's wake belongs to apply_gesture: {actions:?}"
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn a_volume_press_changes_volume_immediately() {
|
|||
|
|
// On the DOWN edge, not on a resolved gesture: BUTTON_MULTI_TAP_WINDOW
|
|||
|
|
// is 300ms and a volume key that lags a third of a second behind the
|
|||
|
|
// press feels broken.
|
|||
|
|
let (mut sm, t0) = unlocked_and_lit();
|
|||
|
|
let up = sm.button_edge_at(Button::VolumeUp, ButtonEdge::Down, t0);
|
|||
|
|
assert!(up.contains(&Action::Volume { up: true }), "{up:?}");
|
|||
|
|
let down = sm.button_edge_at(Button::VolumeDown, ButtonEdge::Down, t0);
|
|||
|
|
assert!(down.contains(&Action::Volume { up: false }), "{down:?}");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn releasing_a_volume_key_does_not_change_volume_again() {
|
|||
|
|
let (mut sm, t0) = unlocked_and_lit();
|
|||
|
|
sm.button_edge_at(Button::VolumeUp, ButtonEdge::Down, t0);
|
|||
|
|
let up = sm.button_edge_at(
|
|||
|
|
Button::VolumeUp,
|
|||
|
|
ButtonEdge::Up,
|
|||
|
|
t0 + Duration::from_millis(60),
|
|||
|
|
);
|
|||
|
|
assert!(
|
|||
|
|
!up.iter().any(|a| matches!(a, Action::Volume { .. })),
|
|||
|
|
"one press is one step: {up:?}"
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn the_screen_verb_wakes_a_dark_panel() {
|
|||
|
|
// The direction the machine did not have. Before `Action::Unblank`
|
|||
|
|
// there was no way out of a blank at all from inside the daemon: a
|
|||
|
|
// power tap on a dark panel answered `Restore`, which is brightness,
|
|||
|
|
// and the panel stayed off. It only ever worked because Hyprland's
|
|||
|
|
// keybinding woke the panel before sessiond was consulted.
|
|||
|
|
let (mut sm, _t0) = unlocked_and_lit();
|
|||
|
|
sm.set_panel(false);
|
|||
|
|
assert!(!sm.panel_on());
|
|||
|
|
assert_eq!(sm.request_screen(true, "agent"), vec![Action::Unblank]);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn a_power_tap_on_a_dark_panel_unblanks_before_it_restores() {
|
|||
|
|
// Order is the whole of it: the brightness a panel comes back at means
|
|||
|
|
// nothing until the panel is back. `Restore` alone was what this
|
|||
|
|
// returned, and under viewtop that set the brightness of a screen that
|
|||
|
|
// stayed off.
|
|||
|
|
let (mut sm, t0) = unlocked_and_lit();
|
|||
|
|
sm.set_panel(false);
|
|||
|
|
sm.button_edge_at(Button::Power, ButtonEdge::Down, t0);
|
|||
|
|
let actions = sm.button_edge_at(
|
|||
|
|
Button::Power,
|
|||
|
|
ButtonEdge::Up,
|
|||
|
|
t0 + Duration::from_millis(80),
|
|||
|
|
);
|
|||
|
|
let actions = if actions.is_empty() {
|
|||
|
|
// Taps resolve on the multi-tap window expiring, not on release.
|
|||
|
|
sm.tick_at(t0 + Duration::from_secs(2))
|
|||
|
|
} else {
|
|||
|
|
actions
|
|||
|
|
};
|
|||
|
|
let unblank = actions.iter().position(|a| *a == Action::Unblank);
|
|||
|
|
let restore = actions.iter().position(|a| *a == Action::Restore);
|
|||
|
|
assert!(
|
|||
|
|
unblank.is_some(),
|
|||
|
|
"a tap on a dark panel must wake it: {actions:?}"
|
|||
|
|
);
|
|||
|
|
if let (Some(u), Some(r)) = (unblank, restore) {
|
|||
|
|
assert!(u < r, "the panel must come back before its brightness does");
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn three_fingers_raise_the_sheet_for_the_window_they_landed_on() {
|
|||
|
|
let (mut sm, _t0) = unlocked_and_lit();
|
|||
|
|
let actions = sm.touch_gesture(3, TouchGesture::Tap, Some(7));
|
|||
|
|
assert_eq!(actions, vec![Action::WindowSheet { target: 7 }]);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn holding_power_raises_the_power_menu() {
|
|||
|
|
let (mut sm, t0) = unlocked_and_lit();
|
|||
|
|
let actions = sm.apply_gesture(Button::Power, ButtonGesture::Hold, t0);
|
|||
|
|
assert_eq!(actions, vec![Action::PowerMenu]);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn the_power_menu_still_opens_on_a_dark_panel() {
|
|||
|
|
// Not gated on the panel or the lock: powering off is a thing you do
|
|||
|
|
// to a locked phone, and a menu that vanished when locked would send
|
|||
|
|
// the user back to the button they are already holding.
|
|||
|
|
let (mut sm, t0) = unlocked_and_lit();
|
|||
|
|
sm.set_panel(false);
|
|||
|
|
let actions = sm.apply_gesture(Button::Power, ButtonGesture::Hold, t0);
|
|||
|
|
assert_eq!(actions, vec![Action::PowerMenu]);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn a_three_finger_tap_on_the_wallpaper_raises_nothing() {
|
|||
|
|
// The no-window case, decided rather than discovered on device: a tap
|
|||
|
|
// with no subject is inert. The alternative is a sheet with nothing to
|
|||
|
|
// act on, which is the "why did a blank sheet appear" bug.
|
|||
|
|
let (mut sm, _t0) = unlocked_and_lit();
|
|||
|
|
assert!(sm.touch_gesture(3, TouchGesture::Tap, None).is_empty());
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn every_other_touch_gesture_is_recognised_and_inert() {
|
|||
|
|
// Deliberate. A gesture that fires something nobody chose is worse
|
|||
|
|
// than one that fires nothing, and the binding table this is a
|
|||
|
|
// placeholder for is DeviceStatePolicy's to hold.
|
|||
|
|
let (mut sm, _t0) = unlocked_and_lit();
|
|||
|
|
for g in [
|
|||
|
|
TouchGesture::SwipeUp,
|
|||
|
|
TouchGesture::SwipeDown,
|
|||
|
|
TouchGesture::SwipeLeft,
|
|||
|
|
TouchGesture::SwipeRight,
|
|||
|
|
] {
|
|||
|
|
assert!(sm.touch_gesture(3, g, Some(1)).is_empty(), "{g:?}");
|
|||
|
|
}
|
|||
|
|
for fingers in [1u8, 2, 4, 5] {
|
|||
|
|
assert!(
|
|||
|
|
sm.touch_gesture(fingers, TouchGesture::Tap, Some(1))
|
|||
|
|
.is_empty(),
|
|||
|
|
"{fingers} fingers"
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn the_sheet_does_not_open_on_a_dark_panel() {
|
|||
|
|
// The sheet is content, and content on an unauthenticated glass is
|
|||
|
|
// what the disclosure rules exist to prevent. A tap on a dark panel is
|
|||
|
|
// a wake, and that is the power button's business.
|
|||
|
|
let (mut sm, _t0) = unlocked_and_lit();
|
|||
|
|
sm.set_panel(false);
|
|||
|
|
assert!(sm.touch_gesture(3, TouchGesture::Tap, Some(1)).is_empty());
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn a_press_that_woke_the_panel_does_not_then_blank_it() {
|
|||
|
|
// The regression Casey hit: press to wake, the lock screen appears, and
|
|||
|
|
// the screen goes black again. One press, two reports.
|
|||
|
|
//
|
|||
|
|
// `note_input` on the DOWN edge takes the device out of Locked and the
|
|||
|
|
// executor lights the panel, reporting it back through `set_panel`
|
|||
|
|
// before the finger is even off the button. The test above never
|
|||
|
|
// simulated that report, which is why it passed while the phone failed.
|
|||
|
|
let (mut sm, t0) = unlocked_and_lit();
|
|||
|
|
sm.set_panel(false);
|
|||
|
|
sm.button_edge_at(Button::Power, ButtonEdge::Down, t0);
|
|||
|
|
// The wake this very press caused, landing between the edges.
|
|||
|
|
sm.set_panel(true);
|
|||
|
|
|
|||
|
|
let mut actions = sm.button_edge_at(
|
|||
|
|
Button::Power,
|
|||
|
|
ButtonEdge::Up,
|
|||
|
|
t0 + Duration::from_millis(80),
|
|||
|
|
);
|
|||
|
|
if actions.is_empty() {
|
|||
|
|
actions = sm.tick_at(t0 + Duration::from_secs(2));
|
|||
|
|
}
|
|||
|
|
assert!(
|
|||
|
|
!actions.iter().any(|a| matches!(a, Action::Blank)),
|
|||
|
|
"the press that woke the panel must not also blank it: {actions:?}"
|
|||
|
|
);
|
|||
|
|
assert!(
|
|||
|
|
!actions.iter().any(|a| matches!(a, Action::Lock)),
|
|||
|
|
"and it must not lock in order to blank: {actions:?}"
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn a_volume_nudge_does_not_make_the_power_tap_blank_the_screen() {
|
|||
|
|
// The latch used to be one field for every button. A tap resolves on
|
|||
|
|
// the multi-tap window closing, up to ~1.3 s after the press, so any
|
|||
|
|
// other button's DOWN edge inside that gap overwrote it — and on a
|
|||
|
|
// Pixel 3 the volume rocker is under the same grip as power.
|
|||
|
|
let (mut sm, t0) = unlocked_and_lit();
|
|||
|
|
sm.set_panel(false);
|
|||
|
|
sm.button_edge_at(Button::Power, ButtonEdge::Down, t0);
|
|||
|
|
sm.set_panel(true); // the wake this press caused
|
|||
|
|
sm.button_edge_at(
|
|||
|
|
Button::Power,
|
|||
|
|
ButtonEdge::Up,
|
|||
|
|
t0 + Duration::from_millis(80),
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
// The rocker, well inside the multi-tap window.
|
|||
|
|
sm.button_edge_at(
|
|||
|
|
Button::VolumeUp,
|
|||
|
|
ButtonEdge::Down,
|
|||
|
|
t0 + Duration::from_millis(120),
|
|||
|
|
);
|
|||
|
|
sm.button_edge_at(
|
|||
|
|
Button::VolumeUp,
|
|||
|
|
ButtonEdge::Up,
|
|||
|
|
t0 + Duration::from_millis(200),
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
let actions = sm.tick_at(t0 + Duration::from_secs(2));
|
|||
|
|
assert!(
|
|||
|
|
!actions
|
|||
|
|
.iter()
|
|||
|
|
.any(|a| matches!(a, Action::Blank | Action::Lock)),
|
|||
|
|
"the volume press must not decide what the power press meant: {actions:?}"
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn a_press_against_a_lit_panel_still_blanks_it() {
|
|||
|
|
// The other half — without this the fix would simply disable the power
|
|||
|
|
// button's only binding.
|
|||
|
|
let (mut sm, t0) = unlocked_and_lit();
|
|||
|
|
sm.button_edge_at(Button::Power, ButtonEdge::Down, t0);
|
|||
|
|
let mut actions = sm.button_edge_at(
|
|||
|
|
Button::Power,
|
|||
|
|
ButtonEdge::Up,
|
|||
|
|
t0 + Duration::from_millis(80),
|
|||
|
|
);
|
|||
|
|
if actions.is_empty() {
|
|||
|
|
actions = sm.tick_at(t0 + Duration::from_secs(2));
|
|||
|
|
}
|
|||
|
|
assert!(
|
|||
|
|
actions
|
|||
|
|
.iter()
|
|||
|
|
.any(|a| matches!(a, Action::Lock | Action::Blank)),
|
|||
|
|
"a tap on a lit panel is still lock-then-blank: {actions:?}"
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn asking_for_the_state_the_panel_is_already_in_does_nothing() {
|
|||
|
|
let (mut sm, _t0) = unlocked_and_lit();
|
|||
|
|
assert!(sm.panel_on());
|
|||
|
|
assert!(sm.request_screen(true, "agent").is_empty());
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn a_pending_blank_fires_once_the_lock_is_acked() {
|
|||
|
|
let (mut sm, t0) = unlocked_and_lit();
|
|||
|
|
let t1 = t0 + Duration::from_secs(31);
|
|||
|
|
assert_eq!(sm.tick_at(t1), vec![Action::Lock]);
|
|||
|
|
|
|||
|
|
// Nothing while the lock is still outstanding, well inside the budget.
|
|||
|
|
assert!(sm.tick_at(t1 + Duration::from_millis(500)).is_empty());
|
|||
|
|
|
|||
|
|
// The lock lands. Now — and only now — the panel goes dark.
|
|||
|
|
sm.transition(DeviceState::Locked);
|
|||
|
|
assert_eq!(
|
|||
|
|
sm.tick_at(t1 + Duration::from_millis(600)),
|
|||
|
|
vec![Action::Blank]
|
|||
|
|
);
|
|||
|
|
assert!(sm.pending_blank.is_none());
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn a_lock_that_never_acks_blanks_anyway_and_says_so() {
|
|||
|
|
let (mut sm, t0) = unlocked_and_lit();
|
|||
|
|
let t1 = t0 + Duration::from_secs(31);
|
|||
|
|
assert_eq!(sm.tick_at(t1), vec![Action::Lock]);
|
|||
|
|
|
|||
|
|
let before = sm.forensic.recent(200).len();
|
|||
|
|
|
|||
|
|
// Past the ack budget with no lock. §1: dark-but-unlocked beats
|
|||
|
|
// lit-and-unlocked in a pocket, so the blank proceeds.
|
|||
|
|
assert_eq!(
|
|||
|
|
sm.tick_at(t1 + LOCK_ACK_BUDGET + Duration::from_millis(10)),
|
|||
|
|
vec![Action::Blank]
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
// But doctrine §8 forbids pretending it locked: the state is untouched
|
|||
|
|
// and the trail carries an error, not a decision.
|
|||
|
|
assert!(!is_locked(sm.state));
|
|||
|
|
let entries = sm.forensic.recent(200);
|
|||
|
|
assert!(entries.len() > before);
|
|||
|
|
assert!(
|
|||
|
|
entries.iter().any(|e| matches!(
|
|||
|
|
&e.event,
|
|||
|
|
ForensicEvent::Error { action, .. } if action == "blank-without-lock"
|
|||
|
|
)),
|
|||
|
|
"the unlocked blank must be a loud error in the trail"
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn input_inside_the_ack_window_cancels_the_blank() {
|
|||
|
|
let (mut sm, t0) = unlocked_and_lit();
|
|||
|
|
let t1 = t0 + Duration::from_secs(31);
|
|||
|
|
assert_eq!(sm.tick_at(t1), vec![Action::Lock]);
|
|||
|
|
|
|||
|
|
// The user came back within the second-or-so window.
|
|||
|
|
sm.note_input(InputTrigger::Touch);
|
|||
|
|
assert!(sm.pending_blank.is_none());
|
|||
|
|
|
|||
|
|
// And the panel stays lit past where the deadline would have been.
|
|||
|
|
assert!(sm
|
|||
|
|
.tick_at(t1 + LOCK_ACK_BUDGET + Duration::from_secs(1))
|
|||
|
|
.is_empty());
|
|||
|
|
assert!(!sm.blank_requested);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn a_locked_blank_still_goes_straight_to_dark() {
|
|||
|
|
// The ordering rule must not add a lock round-trip to the case that
|
|||
|
|
// was already correct: locked screens blank immediately.
|
|||
|
|
let (mut sm, t0) = locked_and_lit();
|
|||
|
|
assert_eq!(
|
|||
|
|
sm.tick_at(t0 + LOCK_BLANK_AFTER + Duration::from_millis(10)),
|
|||
|
|
vec![Action::Blank]
|
|||
|
|
);
|
|||
|
|
assert!(sm.pending_blank.is_none());
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn lockscreen_dims_as_a_warning_then_blanks() {
|
|||
|
|
let (mut sm, t0) = locked_and_lit();
|
|||
|
|
|
|||
|
|
// Nothing at all early on.
|
|||
|
|
assert!(sm.tick_at(t0 + Duration::from_secs(1)).is_empty());
|
|||
|
|
|
|||
|
|
// Dim lands one grace-window before the blank, not at it.
|
|||
|
|
let dim_at = LOCK_BLANK_AFTER - LOCK_DIM_GRACE;
|
|||
|
|
assert_eq!(
|
|||
|
|
sm.tick_at(t0 + dim_at + Duration::from_millis(10)),
|
|||
|
|
vec![Action::Dim]
|
|||
|
|
);
|
|||
|
|
// The warning is asked for once, not on every tick.
|
|||
|
|
assert!(sm.tick_at(t0 + dim_at + Duration::from_secs(1)).is_empty());
|
|||
|
|
|
|||
|
|
// Then the panel goes dark when the budget runs out.
|
|||
|
|
assert_eq!(
|
|||
|
|
sm.tick_at(t0 + LOCK_BLANK_AFTER + Duration::from_millis(10)),
|
|||
|
|
vec![Action::Blank]
|
|||
|
|
);
|
|||
|
|
// And that is asked for once too.
|
|||
|
|
assert!(sm
|
|||
|
|
.tick_at(t0 + LOCK_BLANK_AFTER + Duration::from_secs(5))
|
|||
|
|
.is_empty());
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn tap_inside_the_grace_window_cancels_the_blank() {
|
|||
|
|
let (mut sm, t0) = locked_and_lit();
|
|||
|
|
let dim_at = LOCK_BLANK_AFTER - LOCK_DIM_GRACE;
|
|||
|
|
assert_eq!(
|
|||
|
|
sm.tick_at(t0 + dim_at + Duration::from_millis(10)),
|
|||
|
|
vec![Action::Dim]
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
// The user taps: brightness comes back and the budget restarts.
|
|||
|
|
assert_eq!(sm.note_input(InputTrigger::Touch), vec![Action::Restore]);
|
|||
|
|
assert!(sm.user_active(), "a tap means the user is here");
|
|||
|
|
|
|||
|
|
// While the compositor reports the user as active, nothing happens at
|
|||
|
|
// all — this is the case a self-stamped clock gets wrong.
|
|||
|
|
assert!(sm.tick_at(t0 + LOCK_BLANK_AFTER * 10).is_empty());
|
|||
|
|
|
|||
|
|
// Quiet resumes later; the budget runs from there, not from before.
|
|||
|
|
let tap = t0 + LOCK_BLANK_AFTER * 10;
|
|||
|
|
sm.note_idle_start(tap);
|
|||
|
|
let at_old_deadline = tap + LOCK_BLANK_AFTER - Duration::from_secs(1);
|
|||
|
|
assert!(!sm.tick_at(at_old_deadline).contains(&Action::Blank));
|
|||
|
|
|
|||
|
|
// And it does eventually blank, one full budget after the tap.
|
|||
|
|
assert_eq!(
|
|||
|
|
sm.tick_at(tap + LOCK_BLANK_AFTER + Duration::from_millis(10)),
|
|||
|
|
vec![Action::Blank]
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn proximity_alone_never_blanks_the_panel() {
|
|||
|
|
// A covered sensor is a pocket, a face, a table or a thumb, and the
|
|||
|
|
// machine cannot tell which. The only reading that justifies turning
|
|||
|
|
// the screen off is a call, and the machine has no call state yet.
|
|||
|
|
for locked in [true, false] {
|
|||
|
|
let (mut sm, t0) = locked_and_lit();
|
|||
|
|
if !locked {
|
|||
|
|
sm.set_session_locked(false);
|
|||
|
|
}
|
|||
|
|
sm.sensor_evidence.proximity_near = true;
|
|||
|
|
sm.mark_evidence_seen(SensorSource::Proximity);
|
|||
|
|
assert!(
|
|||
|
|
sm.tick_at(t0 + Duration::from_millis(10)).is_empty(),
|
|||
|
|
"locked={locked}: proximity is evidence, not an actuator"
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn an_unlocked_screen_is_never_blanked_by_this_rule() {
|
|||
|
|
let mut sm = DeviceStateMachine::new();
|
|||
|
|
let t0 = Instant::now();
|
|||
|
|
sm.note_idle_start(t0);
|
|||
|
|
assert_eq!(sm.state(), DeviceState::Active);
|
|||
|
|
// Hours idle and unlocked: not this rule's business.
|
|||
|
|
assert!(sm.tick_at(t0 + Duration::from_secs(3600)).is_empty());
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn never_is_a_real_setting() {
|
|||
|
|
let (mut sm, t0) = locked_and_lit();
|
|||
|
|
sm.policy.lock_blank_after = None;
|
|||
|
|
assert!(sm.tick_at(t0 + Duration::from_secs(3600)).is_empty());
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn a_held_device_gets_the_longer_budget() {
|
|||
|
|
let (mut sm, t0) = locked_and_lit();
|
|||
|
|
sm.sensor_evidence.accel_moving = true;
|
|||
|
|
sm.mark_evidence_seen(SensorSource::Accelerometer);
|
|||
|
|
|
|||
|
|
// Past the table budget, but a hand is on it: the panel has not gone
|
|||
|
|
// dark. (A dim warning is due here — the held budget's grace window
|
|||
|
|
// starts exactly where the table budget would have blanked.)
|
|||
|
|
assert!(!sm
|
|||
|
|
.tick_at(t0 + LOCK_BLANK_AFTER + Duration::from_secs(1))
|
|||
|
|
.contains(&Action::Blank));
|
|||
|
|
// The longer budget still ends.
|
|||
|
|
assert_eq!(
|
|||
|
|
sm.tick_at(t0 + LOCK_BLANK_AFTER_HELD + Duration::from_millis(10)),
|
|||
|
|
vec![Action::Blank]
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn stale_evidence_stops_buying_the_longer_budget() {
|
|||
|
|
let (mut sm, t0) = locked_and_lit();
|
|||
|
|
sm.sensor_evidence.accel_moving = true;
|
|||
|
|
sm.mark_evidence_seen(SensorSource::Accelerometer);
|
|||
|
|
|
|||
|
|
// The sensor dies: no further readings. Once the TTL passes the
|
|||
|
|
// reading is unknown, and unknown must not extend the budget —
|
|||
|
|
// erring toward saving the panel, never toward burning it.
|
|||
|
|
let late = t0 + EVIDENCE_TTL + LOCK_BLANK_AFTER;
|
|||
|
|
let actions = sm.tick_at(late);
|
|||
|
|
assert!(!sm.sensor_evidence.accel_moving, "stale reading must clear");
|
|||
|
|
assert_eq!(actions, vec![Action::Blank]);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn a_squeeze_is_never_vetoed_by_proximity_and_neither_is_a_button() {
|
|||
|
|
let (mut sm, _t0) = locked_and_lit();
|
|||
|
|
sm.sensor_evidence.proximity_near = true;
|
|||
|
|
sm.mark_evidence_seen(SensorSource::Proximity);
|
|||
|
|
|
|||
|
|
// A squeeze is NOT vetoed by a covered sensor, and this test used to
|
|||
|
|
// assert the opposite. Measured 2026-08-05: seven deliberate squeezes
|
|||
|
|
// at deflection 2509-3320 were all refused while the phone was in a
|
|||
|
|
// hand — holding it to squeeze it is what covers the sensor. Six strain
|
|||
|
|
// gauges past a calibrated baseline is the stronger signal of the two,
|
|||
|
|
// and §4 scopes the veto to tap-to-wake alone.
|
|||
|
|
assert!(!sm.suppress_wake(InputTrigger::Squeeze));
|
|||
|
|
assert!(sm.note_input_gated(InputTrigger::Squeeze).is_some());
|
|||
|
|
|
|||
|
|
// Intent from a hardware button is never refused (§4).
|
|||
|
|
assert!(!sm.suppress_wake(InputTrigger::PowerButton));
|
|||
|
|
assert!(sm.note_input_gated(InputTrigger::PowerButton).is_some());
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn an_uncovered_squeeze_is_real_input() {
|
|||
|
|
let (mut sm, _t0) = locked_and_lit();
|
|||
|
|
sm.sensor_evidence.proximity_near = false;
|
|||
|
|
|
|||
|
|
assert!(!sm.suppress_wake(InputTrigger::Squeeze));
|
|||
|
|
assert!(sm.note_input_gated(InputTrigger::Squeeze).is_some());
|
|||
|
|
// It resets the idle budget like any other real input.
|
|||
|
|
assert!(sm.idle_since.is_none());
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn stale_proximity_stops_suppressing_wake() {
|
|||
|
|
let (mut sm, t0) = locked_and_lit();
|
|||
|
|
sm.sensor_evidence.proximity_near = true;
|
|||
|
|
sm.mark_evidence_seen(SensorSource::Proximity);
|
|||
|
|
assert!(sm.suppress_wake(InputTrigger::DoubleTapToWake));
|
|||
|
|
|
|||
|
|
// A dead proximity sensor must not keep vetoing wakes forever.
|
|||
|
|
sm.tick_at(t0 + EVIDENCE_TTL + Duration::from_secs(1));
|
|||
|
|
assert!(!sm.suppress_wake(InputTrigger::DoubleTapToWake));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn waking_from_a_dimmed_blank_restores_brightness_first() {
|
|||
|
|
let (mut sm, t0) = locked_and_lit();
|
|||
|
|
let dim_at = LOCK_BLANK_AFTER - LOCK_DIM_GRACE;
|
|||
|
|
sm.tick_at(t0 + dim_at + Duration::from_millis(10));
|
|||
|
|
sm.tick_at(t0 + LOCK_BLANK_AFTER + Duration::from_millis(10));
|
|||
|
|
sm.set_panel(false);
|
|||
|
|
|
|||
|
|
// dt2w wake: the saved brightness is still the dim value, so the
|
|||
|
|
// restore has to run or the panel comes up looking dead.
|
|||
|
|
assert_eq!(sm.set_panel(true), vec![Action::Restore]);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn pam_unlock_is_legal_from_every_locked_substate() {
|
|||
|
|
for from in [
|
|||
|
|
DeviceState::Locked,
|
|||
|
|
DeviceState::Observed,
|
|||
|
|
DeviceState::DozeLight,
|
|||
|
|
DeviceState::DozeDeep,
|
|||
|
|
] {
|
|||
|
|
let mut sm = DeviceStateMachine::new();
|
|||
|
|
sm.transition(DeviceState::Locked);
|
|||
|
|
// Walk the real graph to get there; doze tiers promote in order.
|
|||
|
|
match from {
|
|||
|
|
DeviceState::Locked => {}
|
|||
|
|
DeviceState::Observed => assert!(sm.transition(DeviceState::Observed)),
|
|||
|
|
DeviceState::DozeLight => assert!(sm.transition(DeviceState::DozeLight)),
|
|||
|
|
DeviceState::DozeDeep => {
|
|||
|
|
assert!(sm.transition(DeviceState::DozeLight));
|
|||
|
|
assert!(sm.transition(DeviceState::DozeDeep));
|
|||
|
|
}
|
|||
|
|
other => panic!("unexpected setup state {other:?}"),
|
|||
|
|
}
|
|||
|
|
assert!(
|
|||
|
|
sm.transition(DeviceState::Active),
|
|||
|
|
"unlock from {from:?} must be legal"
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn legal_transitions_work() {
|
|||
|
|
let mut sm = DeviceStateMachine::new();
|
|||
|
|
assert!(sm.transition(DeviceState::Dimmed));
|
|||
|
|
assert_eq!(sm.state(), DeviceState::Dimmed);
|
|||
|
|
assert!(sm.transition(DeviceState::Locked));
|
|||
|
|
assert_eq!(sm.state(), DeviceState::Locked);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn illegal_transitions_refused() {
|
|||
|
|
let mut sm = DeviceStateMachine::new();
|
|||
|
|
// Active → DozeLight is not legal (must go through Locked first)
|
|||
|
|
assert!(!sm.transition(DeviceState::DozeLight));
|
|||
|
|
assert_eq!(sm.state(), DeviceState::Active); // unchanged
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn idempotent_transition() {
|
|||
|
|
let mut sm = DeviceStateMachine::new();
|
|||
|
|
assert!(sm.transition(DeviceState::Active)); // same state
|
|||
|
|
assert_eq!(sm.state(), DeviceState::Active);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn active_to_suspending_allowed() {
|
|||
|
|
let mut sm = DeviceStateMachine::new();
|
|||
|
|
// Any pre-sleep state → Suspending is legal (logind authority)
|
|||
|
|
assert!(sm.transition(DeviceState::Suspending));
|
|||
|
|
assert_eq!(sm.state(), DeviceState::Suspending);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn asleep_to_locked() {
|
|||
|
|
let mut sm = DeviceStateMachine::new();
|
|||
|
|
sm.transition(DeviceState::Dimmed);
|
|||
|
|
sm.transition(DeviceState::Locked);
|
|||
|
|
sm.transition(DeviceState::Suspending);
|
|||
|
|
sm.transition(DeviceState::Asleep);
|
|||
|
|
assert!(sm.transition(DeviceState::Locked));
|
|||
|
|
assert_eq!(sm.state(), DeviceState::Locked);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn sensor_evidence_confidence() {
|
|||
|
|
let e = SensorEvidence {
|
|||
|
|
proximity_near: true,
|
|||
|
|
accel_moving: false,
|
|||
|
|
light_changing: false,
|
|||
|
|
touch_active: false,
|
|||
|
|
};
|
|||
|
|
assert!((e.confidence() - 0.4).abs() < 0.01);
|
|||
|
|
|
|||
|
|
let e2 = SensorEvidence {
|
|||
|
|
proximity_near: true,
|
|||
|
|
accel_moving: true,
|
|||
|
|
light_changing: true,
|
|||
|
|
touch_active: true,
|
|||
|
|
};
|
|||
|
|
// 0.4 + 0.3 + 0.2 + 0.1 - 0.2 (disagreement) = 0.8
|
|||
|
|
assert!((e2.confidence() - 0.8).abs() < 0.01);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn sensor_disagreement_reduces_confidence() {
|
|||
|
|
// Proximity near + accel moving: a pocket, an ear and a hand all read
|
|||
|
|
// this way. It lowers confidence and decides nothing.
|
|||
|
|
let e = SensorEvidence {
|
|||
|
|
proximity_near: true,
|
|||
|
|
accel_moving: true,
|
|||
|
|
light_changing: false,
|
|||
|
|
touch_active: false,
|
|||
|
|
};
|
|||
|
|
// 0.4 + 0.3 - 0.2 = 0.5
|
|||
|
|
assert!((e.confidence() - 0.5).abs() < 0.01);
|
|||
|
|
// Idle promotion not accelerated (0.5 < 0.6).
|
|||
|
|
assert!(!e.should_promote_idle_faster());
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── Proximity debounce ────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
/// A locked machine and a clock, for driving proximity on a timeline.
|
|||
|
|
fn locked_for_proximity() -> (DeviceStateMachine, Instant) {
|
|||
|
|
let mut sm = DeviceStateMachine::new();
|
|||
|
|
sm.transition(DeviceState::Locked);
|
|||
|
|
(sm, Instant::now())
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn prox(near: bool) -> SensorEvidence {
|
|||
|
|
SensorEvidence {
|
|||
|
|
proximity_near: near,
|
|||
|
|
..Default::default()
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Report proximity the way `server.rs` does — the reading and the
|
|||
|
|
/// freshness stamp together. A test that only does the first half has a
|
|||
|
|
/// source that is instantly stale.
|
|||
|
|
fn report_prox(sm: &mut DeviceStateMachine, near: bool, at: Instant) {
|
|||
|
|
sm.mark_evidence_seen_at(SensorSource::Proximity, at);
|
|||
|
|
sm.update_sensors_from_at(SensorSource::Proximity, prox(near), at);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Report `near` and let it hold long enough to be believed.
|
|||
|
|
fn hold_prox_near(sm: &mut DeviceStateMachine, at: Instant) -> Instant {
|
|||
|
|
report_prox(sm, true, at);
|
|||
|
|
let settled = at + PROXIMITY_NEAR_DEBOUNCE + Duration::from_millis(1);
|
|||
|
|
sm.mark_evidence_seen_at(SensorSource::Proximity, settled);
|
|||
|
|
sm.tick_at(settled);
|
|||
|
|
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
|
|||
|
|
// dwell 1 s, 15 of them sub-second. Each one produced two transitions
|
|||
|
|
// and two trail entries for a reading that meant nothing.
|
|||
|
|
let (mut sm, t0) = locked_for_proximity();
|
|||
|
|
|
|||
|
|
report_prox(&mut sm, true, t0);
|
|||
|
|
assert_eq!(sm.state, DeviceState::Locked, "near is not believed yet");
|
|||
|
|
assert!(!sm.sensor_evidence.proximity_near);
|
|||
|
|
|
|||
|
|
report_prox(&mut sm, false, t0 + Duration::from_millis(400));
|
|||
|
|
sm.tick_at(t0 + Duration::from_millis(500));
|
|||
|
|
assert_eq!(
|
|||
|
|
sm.state,
|
|||
|
|
DeviceState::Locked,
|
|||
|
|
"the blip must leave no transition behind"
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn a_held_near_is_believed_once_it_has_held() {
|
|||
|
|
// The nine real episodes ran 5 s to 173 s. They must survive untouched.
|
|||
|
|
let (mut sm, t0) = locked_for_proximity();
|
|||
|
|
report_prox(&mut sm, true, t0);
|
|||
|
|
assert_eq!(sm.state, DeviceState::Locked);
|
|||
|
|
|
|||
|
|
// No further reading arrives — the tick has to finish the job, or a
|
|||
|
|
// reporter that heartbeats every 30 s would delay a real near by half
|
|||
|
|
// a minute.
|
|||
|
|
hold_prox_near(&mut sm, t0);
|
|||
|
|
assert!(sm.sensor_evidence.proximity_near);
|
|||
|
|
assert_eq!(sm.state, DeviceState::Observed);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn far_is_believed_at_once() {
|
|||
|
|
// The reading that ends a veto is never the slow one.
|
|||
|
|
let (mut sm, t0) = locked_for_proximity();
|
|||
|
|
hold_prox_near(&mut sm, t0);
|
|||
|
|
assert_eq!(sm.state, DeviceState::Observed);
|
|||
|
|
|
|||
|
|
let t1 = t0 + Duration::from_secs(10);
|
|||
|
|
report_prox(&mut sm, false, t1);
|
|||
|
|
assert!(!sm.sensor_evidence.proximity_near);
|
|||
|
|
assert_eq!(sm.state, DeviceState::Locked);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn a_flapping_sensor_that_never_settles_is_never_believed() {
|
|||
|
|
// Alternating faster than the threshold: the near edge keeps restarting
|
|||
|
|
// its clock, so nothing is ever believed and the trail stays quiet.
|
|||
|
|
let (mut sm, t0) = locked_for_proximity();
|
|||
|
|
let mut t = t0;
|
|||
|
|
for _ in 0..20 {
|
|||
|
|
report_prox(&mut sm, true, t);
|
|||
|
|
t += Duration::from_millis(200);
|
|||
|
|
report_prox(&mut sm, false, t);
|
|||
|
|
t += Duration::from_millis(200);
|
|||
|
|
}
|
|||
|
|
assert_eq!(sm.state, DeviceState::Locked);
|
|||
|
|
assert!(!sm.sensor_evidence.proximity_near);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn a_heartbeat_repeat_does_not_restart_the_debounce() {
|
|||
|
|
// Reporters re-send their last value every 30 s (§10). A repeat is the
|
|||
|
|
// same edge continuing, not a new one — if it reset the clock, a held
|
|||
|
|
// near would never be believed.
|
|||
|
|
let (mut sm, t0) = locked_for_proximity();
|
|||
|
|
report_prox(&mut sm, true, t0);
|
|||
|
|
report_prox(&mut sm, true, t0 + Duration::from_millis(500));
|
|||
|
|
report_prox(
|
|||
|
|
&mut sm,
|
|||
|
|
true,
|
|||
|
|
t0 + PROXIMITY_NEAR_DEBOUNCE + Duration::from_millis(1),
|
|||
|
|
);
|
|||
|
|
assert!(sm.sensor_evidence.proximity_near);
|
|||
|
|
assert_eq!(sm.state, DeviceState::Observed);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn a_non_proximity_reading_does_not_disturb_the_debounce() {
|
|||
|
|
// accel/light/touch share the evidence struct; updating one must not
|
|||
|
|
// silently overwrite a proximity value mid-debounce.
|
|||
|
|
let (mut sm, t0) = locked_for_proximity();
|
|||
|
|
report_prox(&mut sm, true, t0);
|
|||
|
|
|
|||
|
|
let accel = SensorEvidence {
|
|||
|
|
proximity_near: false, // the caller's stale copy — must be ignored
|
|||
|
|
accel_moving: true,
|
|||
|
|
..Default::default()
|
|||
|
|
};
|
|||
|
|
sm.mark_evidence_seen_at(SensorSource::Accelerometer, t0 + Duration::from_millis(100));
|
|||
|
|
sm.update_sensors_from_at(
|
|||
|
|
SensorSource::Accelerometer,
|
|||
|
|
accel,
|
|||
|
|
t0 + Duration::from_millis(100),
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
sm.mark_evidence_seen_at(
|
|||
|
|
SensorSource::Proximity,
|
|||
|
|
t0 + PROXIMITY_NEAR_DEBOUNCE + Duration::from_millis(1),
|
|||
|
|
);
|
|||
|
|
sm.tick_at(t0 + PROXIMITY_NEAR_DEBOUNCE + Duration::from_millis(1));
|
|||
|
|
assert!(sm.sensor_evidence.accel_moving);
|
|||
|
|
assert!(
|
|||
|
|
sm.sensor_evidence.proximity_near,
|
|||
|
|
"the proximity edge must still resolve on its own clock"
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn the_pre_dim_brightness_is_remembered_and_consumed_once() {
|
|||
|
|
// Casey's report: the panel dims, and a tap to dismiss brings it back
|
|||
|
|
// at a different level. Cause was `blueline-undim`'s floor — anything
|
|||
|
|
// restoring under 26/255 was pushed to 40%, so a phone deliberately
|
|||
|
|
// run dark came back brighter than it started. The machine remembers
|
|||
|
|
// the number now instead of the executor guessing at it.
|
|||
|
|
let mut sm = DeviceStateMachine::new();
|
|||
|
|
assert_eq!(
|
|||
|
|
sm.take_brightness_before_dim(),
|
|||
|
|
None,
|
|||
|
|
"nothing captured yet"
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
sm.note_brightness_before_dim(Some(18));
|
|||
|
|
assert_eq!(
|
|||
|
|
sm.take_brightness_before_dim(),
|
|||
|
|
Some(18),
|
|||
|
|
"a value below the old floor must survive the round trip unchanged"
|
|||
|
|
);
|
|||
|
|
assert_eq!(
|
|||
|
|
sm.take_brightness_before_dim(),
|
|||
|
|
None,
|
|||
|
|
"taken, not read — a stale capture must not survive into the next dim"
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn a_failed_brightness_read_falls_through_to_the_floor() {
|
|||
|
|
// The one case the floor is right for: nothing knows what the panel
|
|||
|
|
// was at, so coming back dark is worse than coming back wrong.
|
|||
|
|
let mut sm = DeviceStateMachine::new();
|
|||
|
|
sm.note_brightness_before_dim(None);
|
|||
|
|
assert_eq!(sm.take_brightness_before_dim(), None);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn only_tap_to_wake_is_vetoed_by_a_covered_sensor() {
|
|||
|
|
// A double tap is the one wake a pocket can produce by itself. The
|
|||
|
|
// power button is intent and is never refused, whatever the sensors
|
|||
|
|
// say, locked or not.
|
|||
|
|
let mut sm = DeviceStateMachine::new();
|
|||
|
|
sm.sensor_evidence.proximity_near = true;
|
|||
|
|
sm.sensor_evidence.accel_moving = true;
|
|||
|
|
|
|||
|
|
for state in [DeviceState::Active, DeviceState::Locked] {
|
|||
|
|
sm.transition(state);
|
|||
|
|
assert!(sm.suppress_wake(InputTrigger::DoubleTapToWake));
|
|||
|
|
assert!(!sm.suppress_wake(InputTrigger::PowerButton));
|
|||
|
|
assert!(!sm.suppress_wake(InputTrigger::Touch));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn proximity_triggers_observed() {
|
|||
|
|
let mut sm = DeviceStateMachine::new();
|
|||
|
|
sm.transition(DeviceState::Dimmed);
|
|||
|
|
sm.transition(DeviceState::Locked);
|
|||
|
|
|
|||
|
|
// Held, not blipped — near is debounced now, so a reading that does not
|
|||
|
|
// last is a reading the machine never believed.
|
|||
|
|
hold_prox_near(&mut sm, Instant::now());
|
|||
|
|
assert_eq!(sm.state(), DeviceState::Observed);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn proximity_far_returns_to_locked() {
|
|||
|
|
let mut sm = DeviceStateMachine::new();
|
|||
|
|
sm.transition(DeviceState::Dimmed);
|
|||
|
|
sm.transition(DeviceState::Locked);
|
|||
|
|
|
|||
|
|
// Enter observed
|
|||
|
|
let settled = hold_prox_near(&mut sm, Instant::now());
|
|||
|
|
assert_eq!(sm.state(), DeviceState::Observed);
|
|||
|
|
|
|||
|
|
// 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);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn is_locked_covers_all_locked_states() {
|
|||
|
|
assert!(!is_locked(DeviceState::Active));
|
|||
|
|
assert!(!is_locked(DeviceState::Dimmed));
|
|||
|
|
assert!(is_locked(DeviceState::Locked));
|
|||
|
|
assert!(is_locked(DeviceState::Observed));
|
|||
|
|
assert!(is_locked(DeviceState::DozeLight));
|
|||
|
|
assert!(is_locked(DeviceState::DozeDeep));
|
|||
|
|
assert!(is_locked(DeviceState::Suspending));
|
|||
|
|
assert!(is_locked(DeviceState::Asleep));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn display_active_only_active_and_dimmed() {
|
|||
|
|
assert!(is_display_active(DeviceState::Active));
|
|||
|
|
assert!(is_display_active(DeviceState::Dimmed));
|
|||
|
|
assert!(!is_display_active(DeviceState::Locked));
|
|||
|
|
assert!(!is_display_active(DeviceState::Asleep));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn full_lifecycle_with_forensic_log() {
|
|||
|
|
// Exercise the entire state machine: a phone's day in 30 seconds.
|
|||
|
|
let mut sm = DeviceStateMachine::new();
|
|||
|
|
|
|||
|
|
// 1. User picks up the phone — Active
|
|||
|
|
assert_eq!(sm.state(), DeviceState::Active);
|
|||
|
|
sm.record_wake(WakeTrigger::UserInput, "user picked up phone");
|
|||
|
|
|
|||
|
|
// 2. User stops touching — screen dims after 120s
|
|||
|
|
sm.transition(DeviceState::Dimmed);
|
|||
|
|
sm.record_decision(
|
|||
|
|
"dim screen",
|
|||
|
|
serde_json::json!({"idle_seconds": 120, "inhibitor": false}),
|
|||
|
|
"native IdleMonitor fired, no inhibitor held",
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
// 3. User still idle — lock after 300s
|
|||
|
|
sm.transition(DeviceState::Locked);
|
|||
|
|
sm.record_decision(
|
|||
|
|
"lock session",
|
|||
|
|
serde_json::json!({"idle_seconds": 300, "inhibitor": false}),
|
|||
|
|
"native IdleMonitor fired, requesting lock",
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
// 4. Phone goes into pocket — proximity near, held. A pocket is not a
|
|||
|
|
// one-second blip; the debounce is what tells them apart.
|
|||
|
|
hold_prox_near(&mut sm, Instant::now());
|
|||
|
|
sm.update_sensors(SensorEvidence {
|
|||
|
|
proximity_near: true,
|
|||
|
|
accel_moving: false,
|
|||
|
|
light_changing: false,
|
|||
|
|
touch_active: false,
|
|||
|
|
});
|
|||
|
|
assert_eq!(sm.state(), DeviceState::Observed);
|
|||
|
|
sm.record_decision(
|
|||
|
|
"suppress DPMS wake",
|
|||
|
|
serde_json::json!({"confidence": 0.4, "proximity": "near"}),
|
|||
|
|
"proximity near, confidence 0.4 >= 0.3 threshold",
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
// 5. Phone stays in pocket — promote to DozeLight faster
|
|||
|
|
sm.transition(DeviceState::DozeLight);
|
|||
|
|
sm.record_decision(
|
|||
|
|
"freeze app tier",
|
|||
|
|
serde_json::json!({"idle_minutes": 5, "promoted_faster": true}),
|
|||
|
|
"observed state accelerated promotion, freezing apps.slice",
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
// 6. Phone still idle — promote to DozeDeep
|
|||
|
|
sm.transition(DeviceState::DozeDeep);
|
|||
|
|
sm.record_decision(
|
|||
|
|
"stop network fetchers",
|
|||
|
|
serde_json::json!({"idle_minutes": 15}),
|
|||
|
|
"deep doze, only RTC + modem IRQs active",
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
// 7. User presses power button — wake to lock screen
|
|||
|
|
sm.transition(DeviceState::Locked);
|
|||
|
|
sm.record_wake(WakeTrigger::PowerButton, "user pressed power button");
|
|||
|
|
|
|||
|
|
// 8. Phone goes back in pocket. Held — "briefly" is precisely what the
|
|||
|
|
// debounce now refuses to believe, which is the point of it.
|
|||
|
|
let pocketed = hold_prox_near(&mut sm, Instant::now());
|
|||
|
|
assert_eq!(sm.state(), DeviceState::Observed);
|
|||
|
|
|
|||
|
|
// 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
|
|||
|
|
sm.transition(DeviceState::Suspending);
|
|||
|
|
sm.transition(DeviceState::Asleep);
|
|||
|
|
|
|||
|
|
// 11. RTC alarm wakes the phone
|
|||
|
|
sm.transition(DeviceState::Locked);
|
|||
|
|
sm.record_wake(WakeTrigger::RtcAlarm, "RTC alarm for notification check");
|
|||
|
|
|
|||
|
|
// 12. User unlocks with PAM
|
|||
|
|
sm.transition(DeviceState::Active);
|
|||
|
|
|
|||
|
|
// Verify the full lifecycle completed
|
|||
|
|
assert_eq!(sm.state(), DeviceState::Active);
|
|||
|
|
|
|||
|
|
// Dump the forensic log
|
|||
|
|
let entries = sm.forensic.recent(100);
|
|||
|
|
println!("\n=== FORENSIC LOG ({} entries) ===", entries.len());
|
|||
|
|
for entry in &entries {
|
|||
|
|
let event_name = match &entry.event {
|
|||
|
|
ForensicEvent::Transition { from, to, legal } => {
|
|||
|
|
format!("transition {:?} → {:?} (legal={})", from, to, legal)
|
|||
|
|
}
|
|||
|
|
ForensicEvent::SensorInput {
|
|||
|
|
source, confidence, ..
|
|||
|
|
} => {
|
|||
|
|
format!("sensor {:?} conf={:.2}", source, confidence)
|
|||
|
|
}
|
|||
|
|
ForensicEvent::Wake { trigger } => {
|
|||
|
|
format!("wake {:?}", trigger)
|
|||
|
|
}
|
|||
|
|
ForensicEvent::Error {
|
|||
|
|
component, action, ..
|
|||
|
|
} => {
|
|||
|
|
format!("error [{}] {}", component, action)
|
|||
|
|
}
|
|||
|
|
ForensicEvent::Decision { decision, .. } => {
|
|||
|
|
format!("decision: {}", decision)
|
|||
|
|
}
|
|||
|
|
ForensicEvent::Heartbeat => "heartbeat".to_string(),
|
|||
|
|
};
|
|||
|
|
println!(
|
|||
|
|
" seq={:3} ts={} {:30} state={:12} locked={} conf={:.2} reason={}",
|
|||
|
|
entry.seq,
|
|||
|
|
entry.ts,
|
|||
|
|
event_name,
|
|||
|
|
format!("{:?}", entry.snapshot.device_state),
|
|||
|
|
entry.snapshot.locked,
|
|||
|
|
entry.snapshot.confidence,
|
|||
|
|
entry.reason,
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
println!("=== END FORENSIC LOG ===\n");
|
|||
|
|
|
|||
|
|
// Verify we captured the key events
|
|||
|
|
assert!(
|
|||
|
|
entries.len() >= 12,
|
|||
|
|
"expected at least 12 forensic entries, got {}",
|
|||
|
|
entries.len()
|
|||
|
|
);
|
|||
|
|
// Verify transitions were captured
|
|||
|
|
let transitions: Vec<_> = entries
|
|||
|
|
.iter()
|
|||
|
|
.filter(|e| matches!(e.event, ForensicEvent::Transition { .. }))
|
|||
|
|
.collect();
|
|||
|
|
assert!(
|
|||
|
|
transitions.len() >= 8,
|
|||
|
|
"expected at least 8 transitions, got {}",
|
|||
|
|
transitions.len()
|
|||
|
|
);
|
|||
|
|
// Verify sensor inputs were captured
|
|||
|
|
let sensors: Vec<_> = entries
|
|||
|
|
.iter()
|
|||
|
|
.filter(|e| matches!(e.event, ForensicEvent::SensorInput { .. }))
|
|||
|
|
.collect();
|
|||
|
|
assert!(
|
|||
|
|
sensors.len() >= 2,
|
|||
|
|
"expected at least 2 sensor inputs, got {}",
|
|||
|
|
sensors.len()
|
|||
|
|
);
|
|||
|
|
// Verify wake events were captured
|
|||
|
|
let wakes: Vec<_> = entries
|
|||
|
|
.iter()
|
|||
|
|
.filter(|e| matches!(e.event, ForensicEvent::Wake { .. }))
|
|||
|
|
.collect();
|
|||
|
|
assert!(
|
|||
|
|
wakes.len() >= 2,
|
|||
|
|
"expected at least 2 wake events, got {}",
|
|||
|
|
wakes.len()
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn illegal_transition_is_forensically_logged() {
|
|||
|
|
let mut sm = DeviceStateMachine::new();
|
|||
|
|
// Try an illegal transition: Active → DozeLight (must go through Locked)
|
|||
|
|
assert!(!sm.transition(DeviceState::DozeLight));
|
|||
|
|
let entries = sm.forensic.recent(10);
|
|||
|
|
let refused: Vec<_> = entries
|
|||
|
|
.iter()
|
|||
|
|
.filter(|e| match &e.event {
|
|||
|
|
ForensicEvent::Transition { legal, .. } => !legal,
|
|||
|
|
_ => false,
|
|||
|
|
})
|
|||
|
|
.collect();
|
|||
|
|
assert_eq!(
|
|||
|
|
refused.len(),
|
|||
|
|
1,
|
|||
|
|
"expected 1 refused transition in forensic log"
|
|||
|
|
);
|
|||
|
|
match &refused[0].event {
|
|||
|
|
ForensicEvent::Transition { from, to, legal } => {
|
|||
|
|
assert_eq!(*from, DeviceState::Active);
|
|||
|
|
assert_eq!(*to, DeviceState::DozeLight);
|
|||
|
|
assert!(!legal);
|
|||
|
|
}
|
|||
|
|
_ => unreachable!(),
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn cross_sensor_disagreement_logged() {
|
|||
|
|
let mut sm = DeviceStateMachine::new();
|
|||
|
|
sm.transition(DeviceState::Dimmed);
|
|||
|
|
sm.transition(DeviceState::Locked);
|
|||
|
|
// Proximity near + accel moving = walking with phone. Near is
|
|||
|
|
// debounced, so it has to be held before the pair is on the record.
|
|||
|
|
let t0 = Instant::now();
|
|||
|
|
hold_prox_near(&mut sm, t0);
|
|||
|
|
sm.update_sensors_from_at(
|
|||
|
|
SensorSource::Accelerometer,
|
|||
|
|
SensorEvidence {
|
|||
|
|
proximity_near: true,
|
|||
|
|
accel_moving: true,
|
|||
|
|
light_changing: false,
|
|||
|
|
touch_active: false,
|
|||
|
|
},
|
|||
|
|
t0 + PROXIMITY_NEAR_DEBOUNCE + Duration::from_millis(2),
|
|||
|
|
);
|
|||
|
|
let entries = sm.forensic.recent(10);
|
|||
|
|
let sensor_entries: Vec<_> = entries
|
|||
|
|
.iter()
|
|||
|
|
.filter(|e| matches!(e.event, ForensicEvent::SensorInput { .. }))
|
|||
|
|
.collect();
|
|||
|
|
assert!(!sensor_entries.is_empty());
|
|||
|
|
// The confidence should reflect the disagreement (0.4 + 0.3 - 0.2 = 0.5)
|
|||
|
|
match &sensor_entries[sensor_entries.len() - 1].event {
|
|||
|
|
ForensicEvent::SensorInput { confidence, .. } => {
|
|||
|
|
assert!(
|
|||
|
|
(confidence - 0.5).abs() < 0.01,
|
|||
|
|
"expected 0.5 confidence, got {}",
|
|||
|
|
confidence
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
_ => unreachable!(),
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── Evidence-source health ───────────────────────────────────────
|
|||
|
|
// The distinction these cover is DEVICE-STATE-MACHINE.md §0's: "'No
|
|||
|
|
// evidence' and 'evidence says nothing is happening' must not be the same
|
|||
|
|
// state." The 2026-07-25 outage ran four hours with nothing in any log.
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn a_source_that_never_reported_is_unknown_not_down() {
|
|||
|
|
// A light sensor with no reporter installed is silent, correctly,
|
|||
|
|
// forever. Calling that an outage would make the signal worthless.
|
|||
|
|
let mut sm = DeviceStateMachine::new();
|
|||
|
|
let t0 = Instant::now();
|
|||
|
|
sm.tick_at(t0 + SOURCE_DOWN_AFTER + Duration::from_secs(60));
|
|||
|
|
assert_eq!(sm.source_health.light, SourceHealth::Unknown);
|
|||
|
|
assert_eq!(sm.source_health.proximity, SourceHealth::Unknown);
|
|||
|
|
assert!(!sm.source_health.any_down());
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn a_source_that_reported_and_went_silent_is_down_and_loud() {
|
|||
|
|
let mut sm = DeviceStateMachine::new();
|
|||
|
|
let t0 = Instant::now();
|
|||
|
|
sm.mark_evidence_seen_at(SensorSource::Proximity, t0);
|
|||
|
|
|
|||
|
|
// Still within the window: quiet is allowed.
|
|||
|
|
sm.tick_at(t0 + SOURCE_DOWN_AFTER - Duration::from_secs(1));
|
|||
|
|
assert_eq!(sm.source_health.proximity, SourceHealth::Live);
|
|||
|
|
|
|||
|
|
sm.tick_at(t0 + SOURCE_DOWN_AFTER + Duration::from_secs(1));
|
|||
|
|
assert_eq!(sm.source_health.proximity, SourceHealth::Down);
|
|||
|
|
assert!(sm.source_health.any_down());
|
|||
|
|
|
|||
|
|
// The outage is an error in the trail, not just a state field —
|
|||
|
|
// otherwise nothing after the fact can find the window.
|
|||
|
|
let errors: Vec<_> = sm
|
|||
|
|
.forensic
|
|||
|
|
.recent(50)
|
|||
|
|
.into_iter()
|
|||
|
|
.filter(|e| matches!(&e.event, ForensicEvent::Error { action, .. } if action == "source-down"))
|
|||
|
|
.collect();
|
|||
|
|
assert_eq!(errors.len(), 1, "expected exactly one source-down entry");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn an_expected_source_that_never_reports_is_absent_and_loud() {
|
|||
|
|
// The regression this exists for: souveraine-sensord was `enabled` but
|
|||
|
|
// never started (its unit hung off a target nothing activates), so
|
|||
|
|
// proximity/light/accel had no stamp at all. `Down` starts from a
|
|||
|
|
// stamp, so it could not see this, and the machine ran a whole session
|
|||
|
|
// on no evidence with nothing in the trail.
|
|||
|
|
let mut sm = DeviceStateMachine::new();
|
|||
|
|
let t0 = sm.started_at;
|
|||
|
|
|
|||
|
|
// Inside the grace window: a reporter is allowed to arrive late.
|
|||
|
|
sm.tick_at(t0 + SOURCE_EXPECTED_WITHIN - Duration::from_secs(1));
|
|||
|
|
assert_eq!(sm.source_health.proximity, SourceHealth::Unknown);
|
|||
|
|
assert!(!sm.source_health.any_down());
|
|||
|
|
|
|||
|
|
sm.tick_at(t0 + SOURCE_EXPECTED_WITHIN + Duration::from_secs(1));
|
|||
|
|
assert_eq!(sm.source_health.proximity, SourceHealth::Absent);
|
|||
|
|
assert_eq!(sm.source_health.light, SourceHealth::Absent);
|
|||
|
|
assert_eq!(sm.source_health.accel, SourceHealth::Absent);
|
|||
|
|
assert!(
|
|||
|
|
sm.source_health.any_down(),
|
|||
|
|
"absent evidence is degraded evidence"
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
// Touch has no reporter on this device, so it must stay silent — a
|
|||
|
|
// permanent false alarm is the same defect in the other direction.
|
|||
|
|
assert_eq!(sm.source_health.touch, SourceHealth::Unknown);
|
|||
|
|
|
|||
|
|
let errors: Vec<_> = sm
|
|||
|
|
.forensic
|
|||
|
|
.recent(50)
|
|||
|
|
.into_iter()
|
|||
|
|
.filter(|e| matches!(&e.event, ForensicEvent::Error { action, .. } if action == "source-never-reported"))
|
|||
|
|
.collect();
|
|||
|
|
assert_eq!(errors.len(), 3, "one entry per expected source, once");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn an_absent_source_is_recorded_once_not_every_tick() {
|
|||
|
|
let mut sm = DeviceStateMachine::new();
|
|||
|
|
let t0 = sm.started_at;
|
|||
|
|
for i in 0..10 {
|
|||
|
|
sm.tick_at(t0 + SOURCE_EXPECTED_WITHIN + Duration::from_secs(1 + i));
|
|||
|
|
}
|
|||
|
|
let errors = sm
|
|||
|
|
.forensic
|
|||
|
|
.recent(50)
|
|||
|
|
.into_iter()
|
|||
|
|
.filter(|e| matches!(&e.event, ForensicEvent::Error { action, .. } if action == "source-never-reported"))
|
|||
|
|
.count();
|
|||
|
|
assert_eq!(errors, 3, "three expected sources, one entry each");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn a_late_reporter_clears_absent_and_bounds_the_gap() {
|
|||
|
|
let mut sm = DeviceStateMachine::new();
|
|||
|
|
let t0 = sm.started_at;
|
|||
|
|
sm.tick_at(t0 + SOURCE_EXPECTED_WITHIN + Duration::from_secs(1));
|
|||
|
|
assert_eq!(sm.source_health.proximity, SourceHealth::Absent);
|
|||
|
|
|
|||
|
|
// The reporter finally starts. Absent must close like Down does, or
|
|||
|
|
// the trail says when evidence went missing and never when it returned.
|
|||
|
|
sm.mark_evidence_seen_at(
|
|||
|
|
SensorSource::Proximity,
|
|||
|
|
t0 + SOURCE_EXPECTED_WITHIN + Duration::from_secs(5),
|
|||
|
|
);
|
|||
|
|
assert_eq!(sm.source_health.proximity, SourceHealth::Live);
|
|||
|
|
|
|||
|
|
let recovered = sm
|
|||
|
|
.forensic
|
|||
|
|
.recent(50)
|
|||
|
|
.into_iter()
|
|||
|
|
.filter(|e| matches!(&e.event, ForensicEvent::Decision { decision, .. } if decision == "source-recovered"))
|
|||
|
|
.count();
|
|||
|
|
assert_eq!(recovered, 1);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn a_down_source_is_recorded_once_not_every_tick() {
|
|||
|
|
// A tick loop that re-logged this every second would bury the
|
|||
|
|
// transition under 3600 identical lines an hour.
|
|||
|
|
let mut sm = DeviceStateMachine::new();
|
|||
|
|
let t0 = Instant::now();
|
|||
|
|
sm.mark_evidence_seen_at(SensorSource::Proximity, t0);
|
|||
|
|
for i in 0..10 {
|
|||
|
|
sm.tick_at(t0 + SOURCE_DOWN_AFTER + Duration::from_secs(1 + i));
|
|||
|
|
}
|
|||
|
|
let errors = sm
|
|||
|
|
.forensic
|
|||
|
|
.recent(50)
|
|||
|
|
.into_iter()
|
|||
|
|
.filter(|e| matches!(&e.event, ForensicEvent::Error { action, .. } if action == "source-down"))
|
|||
|
|
.count();
|
|||
|
|
assert_eq!(errors, 1);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn a_recovered_source_goes_live_and_bounds_the_outage() {
|
|||
|
|
let mut sm = DeviceStateMachine::new();
|
|||
|
|
let t0 = Instant::now();
|
|||
|
|
sm.mark_evidence_seen_at(SensorSource::Proximity, t0);
|
|||
|
|
sm.tick_at(t0 + SOURCE_DOWN_AFTER + Duration::from_secs(1));
|
|||
|
|
assert_eq!(sm.source_health.proximity, SourceHealth::Down);
|
|||
|
|
|
|||
|
|
sm.mark_evidence_seen_at(SensorSource::Proximity, t0 + Duration::from_secs(200));
|
|||
|
|
assert_eq!(sm.source_health.proximity, SourceHealth::Live);
|
|||
|
|
assert!(!sm.source_health.any_down());
|
|||
|
|
|
|||
|
|
// The recovery entry is what closes the interval. Without it the trail
|
|||
|
|
// says when the sensors died and never says when they came back.
|
|||
|
|
let recovered = sm
|
|||
|
|
.forensic
|
|||
|
|
.recent(50)
|
|||
|
|
.into_iter()
|
|||
|
|
.filter(|e| matches!(&e.event, ForensicEvent::Decision { decision, .. } if decision == "source-recovered"))
|
|||
|
|
.count();
|
|||
|
|
assert_eq!(recovered, 1);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn decisions_taken_during_an_outage_are_stamped_degraded() {
|
|||
|
|
// The point of the whole feature: a panel-off recorded during four
|
|||
|
|
// dead hours must not read like a healthy one.
|
|||
|
|
let mut sm = DeviceStateMachine::new();
|
|||
|
|
let t0 = Instant::now();
|
|||
|
|
sm.mark_evidence_seen_at(SensorSource::Proximity, t0);
|
|||
|
|
sm.tick_at(t0 + SOURCE_DOWN_AFTER + Duration::from_secs(1));
|
|||
|
|
|
|||
|
|
sm.record_decision("panel-off", serde_json::json!({}), "test");
|
|||
|
|
let entry = sm
|
|||
|
|
.forensic
|
|||
|
|
.recent(1)
|
|||
|
|
.into_iter()
|
|||
|
|
.next()
|
|||
|
|
.expect("a decision was just recorded");
|
|||
|
|
assert!(
|
|||
|
|
entry.snapshot.sensors_degraded,
|
|||
|
|
"a decision taken while a source is down must say so"
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn freshness_expires_rather_than_meaning_ever_seen() {
|
|||
|
|
// `evidence_fresh` used to be `is_some()`, which read true for the
|
|||
|
|
// life of the daemon and stayed true straight through an outage.
|
|||
|
|
let mut sm = DeviceStateMachine::new();
|
|||
|
|
sm.mark_evidence_seen_at(
|
|||
|
|
SensorSource::Proximity,
|
|||
|
|
Instant::now() - (EVIDENCE_TTL + Duration::from_secs(5)),
|
|||
|
|
);
|
|||
|
|
let json = sm.to_ipc_json();
|
|||
|
|
assert_eq!(
|
|||
|
|
json["evidence_fresh"]["proximity"],
|
|||
|
|
serde_json::json!(false)
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
sm.mark_evidence_seen(SensorSource::Proximity);
|
|||
|
|
let json = sm.to_ipc_json();
|
|||
|
|
assert_eq!(json["evidence_fresh"]["proximity"], serde_json::json!(true));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn a_repeated_reading_is_not_a_trail_entry() {
|
|||
|
|
// Reporters heartbeat every 30s so silence is meaningful. If each
|
|||
|
|
// repeat were logged, proximity alone would add ~2,900 lines a day to
|
|||
|
|
// an unbounded tmpfs file, all of them saying nothing happened.
|
|||
|
|
let mut sm = DeviceStateMachine::new();
|
|||
|
|
sm.transition(DeviceState::Locked);
|
|||
|
|
let near = SensorEvidence {
|
|||
|
|
proximity_near: true,
|
|||
|
|
..Default::default()
|
|||
|
|
};
|
|||
|
|
sm.update_sensors(near.clone());
|
|||
|
|
let after_first = sm.forensic.recent(100).len();
|
|||
|
|
|
|||
|
|
for _ in 0..5 {
|
|||
|
|
sm.update_sensors(near.clone());
|
|||
|
|
}
|
|||
|
|
assert_eq!(
|
|||
|
|
sm.forensic.recent(100).len(),
|
|||
|
|
after_first,
|
|||
|
|
"identical readings must not each append to the trail"
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn a_repeated_reading_still_reevaluates_observed() {
|
|||
|
|
// The dedupe skips the trail write, never the decision. Proximity held
|
|||
|
|
// `near` across a lock flips should_be_observed with no change in the
|
|||
|
|
// evidence at all — an early return here would strand the machine.
|
|||
|
|
let mut sm = DeviceStateMachine::new();
|
|||
|
|
let t0 = Instant::now();
|
|||
|
|
let settled = hold_prox_near(&mut sm, t0);
|
|||
|
|
assert_eq!(sm.state, DeviceState::Active);
|
|||
|
|
assert!(sm.sensor_evidence.proximity_near, "near is believed by now");
|
|||
|
|
|
|||
|
|
sm.transition(DeviceState::Locked);
|
|||
|
|
report_prox(&mut sm, true, settled + Duration::from_secs(1));
|
|||
|
|
assert_eq!(
|
|||
|
|
sm.state,
|
|||
|
|
DeviceState::Observed,
|
|||
|
|
"an unchanged reading must still be re-evaluated against the new state"
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn a_policy_round_trips_through_json() {
|
|||
|
|
// The bug this closes: SetPolicy mutated memory and nothing wrote it
|
|||
|
|
// down, so every timer set in Settings reverted on the next restart.
|
|||
|
|
let mut p = DeviceStatePolicy::default();
|
|||
|
|
p.lock_blank_after = Some(Duration::from_secs(300));
|
|||
|
|
p.dim_warning = false;
|
|||
|
|
let raw = serde_json::to_string(&p).expect("policy serializes");
|
|||
|
|
let back: DeviceStatePolicy = serde_json::from_str(&raw).expect("policy parses");
|
|||
|
|
assert_eq!(back.lock_blank_after, Some(Duration::from_secs(300)));
|
|||
|
|
assert!(!back.dim_warning);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn a_policy_file_missing_new_fields_still_loads() {
|
|||
|
|
// An older sessiond's file must not make the device fall back to
|
|||
|
|
// built-in timers on upgrade. `serde(default)` at the container level
|
|||
|
|
// is what guarantees it; this is the test that keeps it there.
|
|||
|
|
let raw = r#"{"lock_blank_after":{"secs":300,"nanos":0},"dim_warning":true}"#;
|
|||
|
|
let p: DeviceStatePolicy = serde_json::from_str(raw).expect("partial policy parses");
|
|||
|
|
assert_eq!(p.lock_blank_after, Some(Duration::from_secs(300)));
|
|||
|
|
// Filled from Default, not left at zero.
|
|||
|
|
assert_eq!(p.lock_ack_budget, LOCK_ACK_BUDGET);
|
|||
|
|
assert_eq!(p.source_down_after, SOURCE_DOWN_AFTER);
|
|||
|
|
assert_eq!(p.evidence_ttl, EVIDENCE_TTL);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── The trail itself: durable, bounded, chained ───────────────────
|
|||
|
|
|
|||
|
|
fn a_snapshot() -> StateSnapshot {
|
|||
|
|
DeviceStateMachine::new().snapshot("test", false, false, false, "", false)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn write_n(log: &ForensicLog, n: usize) {
|
|||
|
|
let snap = a_snapshot();
|
|||
|
|
for i in 0..n {
|
|||
|
|
log.append(
|
|||
|
|
ForensicEvent::Heartbeat,
|
|||
|
|
snap.clone(),
|
|||
|
|
&format!("entry {i}"),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Recompute a line's hash the way an external verifier must: strip the
|
|||
|
|
/// trailing `hash` key, close the object, SHA-256 what is left.
|
|||
|
|
fn rehash(line: &str) -> String {
|
|||
|
|
let cut = line.rfind(",\"hash\":").expect("the hash is the last key");
|
|||
|
|
sha256_hex(&format!("{}}}", &line[..cut]))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn chain_of(path: &std::path::Path) -> Vec<(u64, String, String)> {
|
|||
|
|
std::fs::read_to_string(path)
|
|||
|
|
.unwrap_or_default()
|
|||
|
|
.lines()
|
|||
|
|
.filter(|l| !l.trim().is_empty())
|
|||
|
|
.map(|line| {
|
|||
|
|
let v: serde_json::Value = serde_json::from_str(line).expect("a parseable entry");
|
|||
|
|
let hash = v["hash"].as_str().expect("every entry carries its hash");
|
|||
|
|
assert_eq!(rehash(line), hash, "the hash must cover the entry body");
|
|||
|
|
(
|
|||
|
|
v["seq"].as_u64().expect("seq"),
|
|||
|
|
v["prev"].as_str().unwrap_or_default().to_string(),
|
|||
|
|
hash.to_string(),
|
|||
|
|
)
|
|||
|
|
})
|
|||
|
|
.collect()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn every_entry_chains_to_the_one_before_it() {
|
|||
|
|
// §5 called the trail tamper-evident while it carried only a seq,
|
|||
|
|
// which detects a deleted line and nothing else. This is the claim.
|
|||
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|||
|
|
let path = dir.path().join("forensic.jsonl");
|
|||
|
|
write_n(&ForensicLog::with_path(path.clone()), 4);
|
|||
|
|
|
|||
|
|
let chain = chain_of(&path);
|
|||
|
|
assert_eq!(chain.len(), 4);
|
|||
|
|
assert_eq!(chain[0].1, "", "the head of a new chain has no predecessor");
|
|||
|
|
for pair in chain.windows(2) {
|
|||
|
|
assert_eq!(pair[1].0, pair[0].0 + 1, "seq is contiguous");
|
|||
|
|
assert_eq!(pair[1].1, pair[0].2, "prev is the previous entry's hash");
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn the_trail_survives_a_reopen_and_keeps_one_chain() {
|
|||
|
|
// The whole point of leaving tmpfs: a reboot must not erase what the
|
|||
|
|
// machine decided, and the chain must not restart at zero either.
|
|||
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|||
|
|
let path = dir.path().join("forensic.jsonl");
|
|||
|
|
write_n(&ForensicLog::with_path(path.clone()), 3);
|
|||
|
|
write_n(&ForensicLog::with_path(path.clone()), 3);
|
|||
|
|
|
|||
|
|
let chain = chain_of(&path);
|
|||
|
|
assert_eq!(chain.len(), 6, "the earlier run is still there");
|
|||
|
|
assert_eq!(chain[3].0, 3, "seq continues across the restart");
|
|||
|
|
assert_eq!(
|
|||
|
|
chain[3].1, chain[2].2,
|
|||
|
|
"the new run chains onto the old one rather than starting over"
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn the_trail_rotates_and_the_set_stays_bounded() {
|
|||
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|||
|
|
let path = dir.path().join("forensic.jsonl");
|
|||
|
|
// Small enough that a handful of entries fills a generation.
|
|||
|
|
write_n(&ForensicLog::with_path_bounded(path.clone(), 2048), 400);
|
|||
|
|
|
|||
|
|
let live = std::fs::metadata(&path).expect("a live file").len();
|
|||
|
|
assert!(live <= 2048, "the live file is bounded: {live}");
|
|||
|
|
|
|||
|
|
let mut total = live;
|
|||
|
|
for n in 1..=FORENSIC_KEEP {
|
|||
|
|
let g = path.with_extension(format!("jsonl.{n}"));
|
|||
|
|
total += std::fs::metadata(&g).map(|m| m.len()).unwrap_or(0);
|
|||
|
|
}
|
|||
|
|
assert!(
|
|||
|
|
total <= 2048 * (FORENSIC_KEEP as u64 + 1),
|
|||
|
|
"the whole set is bounded: {total}"
|
|||
|
|
);
|
|||
|
|
// And nothing beyond the kept generations survives.
|
|||
|
|
assert!(
|
|||
|
|
!path
|
|||
|
|
.with_extension(format!("jsonl.{}", FORENSIC_KEEP + 1))
|
|||
|
|
.exists(),
|
|||
|
|
"generations past the keep count are deleted, not accumulated"
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn the_chain_runs_across_a_rotation() {
|
|||
|
|
// A rotated set must verify as one chain, or bounding the trail would
|
|||
|
|
// have quietly cost the property that durability was for.
|
|||
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|||
|
|
let path = dir.path().join("forensic.jsonl");
|
|||
|
|
write_n(&ForensicLog::with_path_bounded(path.clone(), 2048), 12);
|
|||
|
|
|
|||
|
|
let rotated = chain_of(&path.with_extension("jsonl.1"));
|
|||
|
|
let live = chain_of(&path);
|
|||
|
|
assert!(
|
|||
|
|
!rotated.is_empty() && !live.is_empty(),
|
|||
|
|
"a rotation happened"
|
|||
|
|
);
|
|||
|
|
let last_rotated = rotated.last().expect("rotated entries");
|
|||
|
|
assert_eq!(
|
|||
|
|
live[0].1, last_rotated.2,
|
|||
|
|
"the first live entry chains onto the last rotated one"
|
|||
|
|
);
|
|||
|
|
assert_eq!(live[0].0, last_rotated.0 + 1, "seq does not restart");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn a_torn_tail_is_discarded_and_the_chain_resumes() {
|
|||
|
|
// Measured on hardware 2026-07-26: a hard reboot left the trail at
|
|||
|
|
// exactly 4096 bytes — a page boundary — with the final entry cut in
|
|||
|
|
// half. The first version treated that as tampering and rotated the
|
|||
|
|
// whole file aside, so every hard reboot started a new chain at the
|
|||
|
|
// one moment continuity is worth most.
|
|||
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|||
|
|
let path = dir.path().join("forensic.jsonl");
|
|||
|
|
write_n(&ForensicLog::with_path(path.clone()), 3);
|
|||
|
|
|
|||
|
|
// Tear the last write the way losing power does: a partial line, no
|
|||
|
|
// newline, mid-object.
|
|||
|
|
{
|
|||
|
|
use std::io::Write;
|
|||
|
|
let mut f = std::fs::OpenOptions::new()
|
|||
|
|
.append(true)
|
|||
|
|
.open(&path)
|
|||
|
|
.expect("open");
|
|||
|
|
write!(f, "{{\"seq\":3,\"prev\":\"abc\",\"ts\":17850").expect("tear it");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
write_n(&ForensicLog::with_path(path.clone()), 1);
|
|||
|
|
|
|||
|
|
assert!(
|
|||
|
|
!path.with_extension("jsonl.1").exists(),
|
|||
|
|
"a torn tail is not a rotation event"
|
|||
|
|
);
|
|||
|
|
let chain = chain_of(&path);
|
|||
|
|
assert_eq!(chain.len(), 4, "three intact entries plus the new one");
|
|||
|
|
assert_eq!(chain[3].0, 3, "seq continues rather than restarting");
|
|||
|
|
assert_eq!(
|
|||
|
|
chain[3].1, chain[2].2,
|
|||
|
|
"the entry after the tear chains onto the last intact one"
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn a_file_with_nothing_parseable_is_still_rotated_aside() {
|
|||
|
|
// Appending onto a line we cannot parse would produce a chain that
|
|||
|
|
// fails verification forever after, which reads as tampering. The
|
|||
|
|
// damaged file is evidence, so it is kept, not deleted.
|
|||
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|||
|
|
let path = dir.path().join("forensic.jsonl");
|
|||
|
|
std::fs::write(&path, "this is not JSON at all\nnor is this\n").expect("write junk");
|
|||
|
|
|
|||
|
|
write_n(&ForensicLog::with_path(path.clone()), 1);
|
|||
|
|
|
|||
|
|
let kept = path.with_extension("jsonl.1");
|
|||
|
|
assert!(kept.exists(), "the unusable trail is kept as evidence");
|
|||
|
|
assert_eq!(
|
|||
|
|
std::fs::read_to_string(&kept).unwrap().lines().count(),
|
|||
|
|
2,
|
|||
|
|
"kept whole, not truncated"
|
|||
|
|
);
|
|||
|
|
let fresh = chain_of(&path);
|
|||
|
|
assert_eq!(fresh.len(), 1);
|
|||
|
|
assert_eq!(fresh[0].0, 0, "the new chain starts clean");
|
|||
|
|
assert_eq!(
|
|||
|
|
fresh[0].1, "",
|
|||
|
|
"and does not claim a predecessor it cannot verify"
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn an_intent_stamps_every_entry_it_covers_and_no_others() {
|
|||
|
|
// The point of the field: a chain is one decision and several verbs,
|
|||
|
|
// and a trail that records only the leaves cannot tell a considered
|
|||
|
|
// sequence from four accidents.
|
|||
|
|
let log = ForensicLog::new();
|
|||
|
|
let snap = a_snapshot();
|
|||
|
|
|
|||
|
|
log.append(ForensicEvent::Heartbeat, snap.clone(), "before");
|
|||
|
|
log.set_intent(Some("quiet the room".into()));
|
|||
|
|
log.append(ForensicEvent::Heartbeat, snap.clone(), "during");
|
|||
|
|
log.append(ForensicEvent::Heartbeat, snap.clone(), "still during");
|
|||
|
|
log.set_intent(None);
|
|||
|
|
log.append(ForensicEvent::Heartbeat, snap, "after");
|
|||
|
|
|
|||
|
|
let got: Vec<_> = log
|
|||
|
|
.recent(10)
|
|||
|
|
.into_iter()
|
|||
|
|
.map(|e| (e.reason, e.intent))
|
|||
|
|
.collect();
|
|||
|
|
assert_eq!(got[0].1, None, "entries before the intent are untouched");
|
|||
|
|
assert_eq!(got[1].1.as_deref(), Some("quiet the room"));
|
|||
|
|
assert_eq!(got[2].1.as_deref(), Some("quiet the room"));
|
|||
|
|
assert_eq!(
|
|||
|
|
got[3].1, None,
|
|||
|
|
"an intent that outlived its request would mislabel whatever came next"
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn an_entry_without_an_intent_serializes_exactly_as_it_did_before() {
|
|||
|
|
// `skip_serializing_if` is what keeps the hash contract from moving:
|
|||
|
|
// an entry with no intent must not gain an `"intent":null`, or every
|
|||
|
|
// line written before today would fail verification against the code
|
|||
|
|
// that wrote it.
|
|||
|
|
let entry = ForensicEntry {
|
|||
|
|
seq: 0,
|
|||
|
|
prev: String::new(),
|
|||
|
|
ts: 0,
|
|||
|
|
event: ForensicEvent::Heartbeat,
|
|||
|
|
snapshot: a_snapshot(),
|
|||
|
|
reason: "x".into(),
|
|||
|
|
intent: None,
|
|||
|
|
};
|
|||
|
|
let json = serde_json::to_string(&entry).expect("serializes");
|
|||
|
|
assert!(!json.contains("intent"), "absent must mean absent: {json}");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn a_trail_with_nowhere_to_write_still_answers_ipc() {
|
|||
|
|
// Memory-only is the honest answer when there is no home; it must not
|
|||
|
|
// take the in-memory buffer down with it, because the IPC query is how
|
|||
|
|
// the shell reads the trail.
|
|||
|
|
let log = ForensicLog::new();
|
|||
|
|
assert_eq!(log.chain_state(), "memory-only");
|
|||
|
|
write_n(&log, 3);
|
|||
|
|
assert_eq!(log.recent(10).len(), 3);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---- bearer: the anti-flap rule is the whole point ----
|
|||
|
|
|
|||
|
|
fn machine_with_bearer(wifi: LinkHealth, cellular: LinkHealth) -> DeviceStateMachine {
|
|||
|
|
let mut m = DeviceStateMachine::new();
|
|||
|
|
m.note_bearer(BearerEvidence {
|
|||
|
|
wifi,
|
|||
|
|
cellular,
|
|||
|
|
..Default::default()
|
|||
|
|
});
|
|||
|
|
m
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn a_bearer_change_waits_for_the_settling_window() {
|
|||
|
|
let mut m = machine_with_bearer(LinkHealth::Carrying, LinkHealth::Associated);
|
|||
|
|
let t0 = Instant::now();
|
|||
|
|
|
|||
|
|
// First sight of a preference is not enough. This is the property the
|
|||
|
|
// 652-recycle gate did not have: it acted on every event it saw.
|
|||
|
|
let a = m.tick_at(t0);
|
|||
|
|
assert!(
|
|||
|
|
!a.iter().any(|x| matches!(x, Action::PreferLink(_))),
|
|||
|
|
"acted before the window: {a:?}"
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
let early = t0 + BEARER_SETTLE - Duration::from_secs(1);
|
|||
|
|
let a = m.tick_at(early);
|
|||
|
|
assert!(!a.iter().any(|x| matches!(x, Action::PreferLink(_))));
|
|||
|
|
|
|||
|
|
let late = t0 + BEARER_SETTLE + Duration::from_secs(1);
|
|||
|
|
let a = m.tick_at(late);
|
|||
|
|
assert!(
|
|||
|
|
a.contains(&Action::PreferLink(Bearer::Wifi)),
|
|||
|
|
"should have settled on wifi: {a:?}"
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn a_preference_that_reverses_inside_the_window_never_acts() {
|
|||
|
|
// The flap case, stated exactly: wifi appears, then goes away again
|
|||
|
|
// before it settled. No command may be produced by that at all.
|
|||
|
|
let mut m = machine_with_bearer(LinkHealth::Carrying, LinkHealth::Carrying);
|
|||
|
|
let t0 = Instant::now();
|
|||
|
|
let _ = m.tick_at(t0);
|
|||
|
|
|
|||
|
|
m.note_bearer(BearerEvidence {
|
|||
|
|
wifi: LinkHealth::Down,
|
|||
|
|
cellular: LinkHealth::Carrying,
|
|||
|
|
..Default::default()
|
|||
|
|
});
|
|||
|
|
let mid = t0 + BEARER_SETTLE / 2;
|
|||
|
|
assert!(!m
|
|||
|
|
.tick_at(mid)
|
|||
|
|
.iter()
|
|||
|
|
.any(|x| matches!(x, Action::PreferLink(_))));
|
|||
|
|
|
|||
|
|
// Back to wifi before either candidate matured.
|
|||
|
|
m.note_bearer(BearerEvidence {
|
|||
|
|
wifi: LinkHealth::Carrying,
|
|||
|
|
cellular: LinkHealth::Carrying,
|
|||
|
|
..Default::default()
|
|||
|
|
});
|
|||
|
|
let later = t0 + BEARER_SETTLE + Duration::from_secs(1);
|
|||
|
|
let a = m.tick_at(later);
|
|||
|
|
assert!(
|
|||
|
|
!a.iter().any(|x| matches!(x, Action::PreferLink(_))),
|
|||
|
|
"the clock must restart when the candidate changes, not accumulate: {a:?}"
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn a_settled_preference_is_not_re_emitted_every_tick() {
|
|||
|
|
// Idempotence at the decision, not at the executor. A command per tick
|
|||
|
|
// would be the same disease as a command per event.
|
|||
|
|
let mut m = machine_with_bearer(LinkHealth::Carrying, LinkHealth::Absent);
|
|||
|
|
let t0 = Instant::now();
|
|||
|
|
let _ = m.tick_at(t0);
|
|||
|
|
let settled = t0 + BEARER_SETTLE + Duration::from_secs(1);
|
|||
|
|
assert!(m
|
|||
|
|
.tick_at(settled)
|
|||
|
|
.contains(&Action::PreferLink(Bearer::Wifi)));
|
|||
|
|
|
|||
|
|
for i in 1..5 {
|
|||
|
|
let a = m.tick_at(settled + Duration::from_secs(i));
|
|||
|
|
assert!(
|
|||
|
|
!a.iter().any(|x| matches!(x, Action::PreferLink(_))),
|
|||
|
|
"re-emitted on a steady state at +{i}s: {a:?}"
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn no_usable_link_produces_no_action() {
|
|||
|
|
let mut m = machine_with_bearer(LinkHealth::Down, LinkHealth::Absent);
|
|||
|
|
let t0 = Instant::now();
|
|||
|
|
let _ = m.tick_at(t0);
|
|||
|
|
let a = m.tick_at(t0 + BEARER_SETTLE + Duration::from_secs(1));
|
|||
|
|
assert!(
|
|||
|
|
!a.iter().any(|x| matches!(x, Action::PreferLink(_))),
|
|||
|
|
"flailed at a dead network: {a:?}"
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn the_tunnel_is_pinned_with_the_link_but_never_switched_on() {
|
|||
|
|
let mut m = DeviceStateMachine::new();
|
|||
|
|
m.note_bearer(BearerEvidence {
|
|||
|
|
wifi: LinkHealth::Carrying,
|
|||
|
|
cellular: LinkHealth::Associated,
|
|||
|
|
tunnel: TunnelHealth::Handshaking,
|
|||
|
|
..Default::default()
|
|||
|
|
});
|
|||
|
|
let t0 = Instant::now();
|
|||
|
|
let _ = m.tick_at(t0);
|
|||
|
|
let a = m.tick_at(t0 + BEARER_SETTLE + Duration::from_secs(1));
|
|||
|
|
assert!(a.contains(&Action::PinTunnelUnderlay(Bearer::Wifi)));
|
|||
|
|
|
|||
|
|
// Tunnel off: the preference still lands, the pin does not. Deciding
|
|||
|
|
// to bring a tunnel up is the user's call, not this machine's.
|
|||
|
|
let mut m = DeviceStateMachine::new();
|
|||
|
|
m.note_bearer(BearerEvidence {
|
|||
|
|
wifi: LinkHealth::Carrying,
|
|||
|
|
tunnel: TunnelHealth::Off,
|
|||
|
|
..Default::default()
|
|||
|
|
});
|
|||
|
|
let _ = m.tick_at(t0);
|
|||
|
|
let a = m.tick_at(t0 + BEARER_SETTLE + Duration::from_secs(1));
|
|||
|
|
assert!(a.contains(&Action::PreferLink(Bearer::Wifi)));
|
|||
|
|
assert!(
|
|||
|
|
!a.iter().any(|x| matches!(x, Action::PinTunnelUnderlay(_))),
|
|||
|
|
"pinned an underlay for a tunnel the user switched off: {a:?}"
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|