Measured: 44 near-episodes in 2.4h, median dwell 1s, 15 sub-second. The negative debounce §9.5 specified would have delayed each blip, not removed it.
3218 lines
130 KiB
Rust
3218 lines
130 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::protocol::{InputTrigger, SensorSource, SensorValue};
|
||
|
||
/// 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 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 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,
|
||
/// 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,
|
||
}
|
||
|
||
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,
|
||
proximity_near_debounce: PROXIMITY_NEAR_DEBOUNCE,
|
||
proximity_far_debounce: PROXIMITY_FAR_DEBOUNCE,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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,
|
||
/// 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,
|
||
}
|
||
|
||
/// 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>,
|
||
}
|
||
|
||
/// 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. Not an error — a light
|
||
/// sensor with no reporter installed is silent, correctly, forever. Only a
|
||
/// source that spoke and then stopped has failed.
|
||
#[default]
|
||
Unknown,
|
||
/// Reporting within `source_down_after`.
|
||
Live,
|
||
/// Reported once and has now been silent past the threshold.
|
||
Down,
|
||
}
|
||
|
||
impl SourceHealth {
|
||
pub fn as_str(self) -> &'static str {
|
||
match self {
|
||
SourceHealth::Unknown => "unknown",
|
||
SourceHealth::Live => "live",
|
||
SourceHealth::Down => "down",
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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 that once worked has gone silent. This is the
|
||
/// single question the rest of the system asks — a surface showing
|
||
/// "sensors degraded" does not need to know which one.
|
||
pub fn any_down(&self) -> bool {
|
||
[self.proximity, self.accel, self.light, self.touch]
|
||
.iter()
|
||
.any(|h| *h == SourceHealth::Down)
|
||
}
|
||
|
||
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,
|
||
}
|
||
|
||
/// 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,
|
||
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,
|
||
}
|
||
|
||
/// 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,
|
||
/// 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(),
|
||
};
|
||
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);
|
||
}
|
||
}
|
||
|
||
/// 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()
|
||
}
|
||
}
|
||
|
||
impl TrailWriter {
|
||
fn memory_only() -> Self {
|
||
Self {
|
||
path: None,
|
||
next_seq: 0,
|
||
last_hash: String::new(),
|
||
bytes: 0,
|
||
max_bytes: FORENSIC_MAX_BYTES,
|
||
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,
|
||
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;
|
||
}
|
||
};
|
||
|
||
let Some(last) = existing.lines().rev().find(|l| !l.trim().is_empty()) else {
|
||
return w; // present but empty: a fresh chain, not a damaged one
|
||
};
|
||
|
||
match serde_json::from_str::<serde_json::Value>(last) {
|
||
Ok(v) => {
|
||
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 = existing.len() as u64;
|
||
info!(
|
||
"[device-state] forensic trail resumed at {} (seq {}, {} KiB)",
|
||
path.display(),
|
||
w.next_seq,
|
||
w.bytes / 1024
|
||
);
|
||
}
|
||
Err(e) => {
|
||
// Do not append to a trail we cannot chain onto, and do not
|
||
// delete it either — a truncated tail is evidence of something.
|
||
// It is rotated aside and a clean chain starts, LOUDLY.
|
||
warn!(
|
||
"[device-state] the tail of {} is not parseable ({e}) — rotating it aside and STARTING A NEW CHAIN; the old file is kept",
|
||
path.display()
|
||
);
|
||
w.rotate();
|
||
}
|
||
}
|
||
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.
|
||
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>,
|
||
/// 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,
|
||
/// 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 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,
|
||
}
|
||
|
||
/// 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,
|
||
evidence_seen: EvidenceSeen::default(),
|
||
source_health: SourceHealthTable::default(),
|
||
blank_requested: false,
|
||
dimmed: false,
|
||
pending_blank: 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(),
|
||
};
|
||
|
||
// 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.
|
||
pub fn tick_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 = Vec::new();
|
||
|
||
// 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());
|
||
}
|
||
|
||
// 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.
|
||
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;
|
||
if was == SourceHealth::Down {
|
||
// 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",
|
||
source.as_str()
|
||
);
|
||
self.record_decision(
|
||
"source-recovered",
|
||
serde_json::json!({ "source": source.as_str() }),
|
||
"a source previously recorded as down has resumed reporting",
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 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::Touch | InputTrigger::Key => WakeTrigger::UserInput,
|
||
InputTrigger::Unknown => WakeTrigger::Unknown,
|
||
};
|
||
self.record_wake(wake, &format!("input: {:?}", trigger));
|
||
actions
|
||
}
|
||
|
||
/// 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.
|
||
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(),
|
||
}
|
||
}
|
||
|
||
pub fn state(&self) -> DeviceState {
|
||
self.state
|
||
}
|
||
|
||
/// 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.
|
||
pub fn suppress_wake(&self, trigger: InputTrigger) -> bool {
|
||
matches!(trigger, InputTrigger::DoubleTapToWake) && self.sensor_evidence.proximity_near
|
||
}
|
||
|
||
/// 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(),
|
||
};
|
||
self.forensic.append(
|
||
ForensicEvent::Transition {
|
||
from: prev,
|
||
to: next,
|
||
legal: false,
|
||
},
|
||
snapshot,
|
||
&format!("illegal transition {:?} → {:?} refused", prev, next),
|
||
);
|
||
return false;
|
||
}
|
||
self.state = next;
|
||
// 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(),
|
||
};
|
||
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 {
|
||
self.transition(DeviceState::Observed);
|
||
} else if matches!(self.state, DeviceState::Observed) && !believed {
|
||
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(),
|
||
};
|
||
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 {
|
||
self.transition(DeviceState::Observed);
|
||
} else if was_observed && !proximity_near {
|
||
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(),
|
||
};
|
||
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(),
|
||
};
|
||
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(),
|
||
};
|
||
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::*;
|
||
|
||
#[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 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 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_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 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);
|
||
let _ = settled;
|
||
|
||
// Leave observed
|
||
sm.update_sensors(SensorEvidence {
|
||
proximity_near: false,
|
||
..Default::default()
|
||
});
|
||
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.
|
||
hold_prox_near(&mut sm, Instant::now());
|
||
assert_eq!(sm.state(), DeviceState::Observed);
|
||
|
||
// 9. Phone comes back out
|
||
sm.update_sensors(SensorEvidence {
|
||
proximity_near: false,
|
||
..Default::default()
|
||
});
|
||
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 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_damaged_tail_is_rotated_aside_not_appended_to() {
|
||
// 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");
|
||
write_n(&ForensicLog::with_path(path.clone()), 2);
|
||
{
|
||
use std::io::Write;
|
||
let mut f = std::fs::OpenOptions::new()
|
||
.append(true)
|
||
.open(&path)
|
||
.expect("open");
|
||
writeln!(f, "{{\"seq\":2,\"trunca").expect("write a torn line");
|
||
}
|
||
|
||
write_n(&ForensicLog::with_path(path.clone()), 1);
|
||
|
||
let kept = path.with_extension("jsonl.1");
|
||
assert!(kept.exists(), "the damaged trail is kept");
|
||
assert_eq!(
|
||
std::fs::read_to_string(&kept).unwrap().lines().count(),
|
||
3,
|
||
"including the torn line"
|
||
);
|
||
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 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);
|
||
}
|
||
}
|