969 lines
47 KiB
Markdown
969 lines
47 KiB
Markdown
# Device state machine — unified design
|
||
|
||
**Status:** actuating on hardware since 2026-07-25. **Created:** 2026-07-24.
|
||
|
||
`b4b30b1` landed the shape: the §2 enum (all 8 variants), a 20-pair
|
||
`LEGAL_TRANSITIONS` guard, §4's confidence arithmetic with exactly the weights
|
||
below, and the forensic log. It was **entirely inert** for a day — no caller
|
||
for `transition()` outside `update_sensors`, no sensor feeder, no clock, so
|
||
the daemon never left `Active` and the forensic log stayed at `count: 0`.
|
||
|
||
`aa30278` gave it the missing half and it now drives the device:
|
||
|
||
- a 1 s tick loop (`tick_at()` takes the clock, so rules test without sleeping)
|
||
- real actuators — `Dim`/`Restore`/`Blank` through brightnessctl,
|
||
`blueline-undim`, and the DPMS executor
|
||
- input truth from `ext-idle-notify` (`idle.rs`), not inferred, which is what
|
||
keeps a long swipe from being blanked mid-gesture
|
||
- `panel` as a *field*, not a ninth state: the enum had no cell for "locked,
|
||
screen dark", and DozeLight is not a dark glance
|
||
|
||
Verified on the phone: `panel-dim` then `panel-off` in `forensic.jsonl`,
|
||
including an `error-operational` entry when `brightnessctl` exited 1 — the
|
||
§5 contract working end to end.
|
||
|
||
**Ordering is now an invariant, not a coincidence** (2026-07-25, second pass).
|
||
Every path to a dark panel routes through one function that locks first, waits
|
||
`LOCK_ACK_BUDGET` (2 s) for the compositor ack, and blanks anyway on timeout
|
||
while recording an `error-security` — the panel fails open, the *claim* never
|
||
does (LOCK-DPMS-LESSONS §1 + doctrine §8). hypridle's 300 s lock and 600 s
|
||
screen-off listeners are **deleted**, not disabled: they were the competing
|
||
writer that made §1 hold only because 300 < 600.
|
||
|
||
Still open: doze tiers (TASK-15, blocked on slice separation), and both
|
||
confidence gates are still computed and logged without being branched on.
|
||
Source health landed 2026-07-26 (§10) — the machine now knows when a sensor is
|
||
absent rather than merely quiet.
|
||
|
||
## 0. Audit, 2026-07-26 — what the running machine actually did
|
||
|
||
Measured on hardware, 40 minutes after a cold boot. Kept here rather than in a
|
||
new task file: this section IS the audit.
|
||
|
||
**FIXED — the machine kept a shadow copy of lock state, and it drifted.**
|
||
`device_state: locked, locked: true, panel_on: false` while
|
||
`loginctl show-session 1 -p LockedHint` said `no`. The screen was dark on an
|
||
unlocked session. `locked_ack` was the only lock ingress and there was no
|
||
unlock ingress outside sessiond's own fallback PIN surface, so after the first
|
||
unlock the machine believed it was locked forever. Every `is_locked()` rule
|
||
then ran against an in-use phone: proximity blanked it (that rule's
|
||
`is_locked` guard was correct — the *state* was lying), and blanks never asked
|
||
for a lock first because one was "already held". This is doctrine §4 verbatim:
|
||
"never a hand-tracked bool. A shadow copy can disagree with logind." Fixed by
|
||
`lockhint.rs` — logind is read, not mirrored.
|
||
|
||
**FIXED 2026-07-26 — proximity is debounced, and not the way §9.5 said.** 69
|
||
transitions in 40 minutes, `Locked → Observed → Locked` on a sensor with no
|
||
hysteresis. The fix was specified here on 07-24 as Android's 0 ms positive /
|
||
250 ms negative and was never built. When it came time to build it, the trail
|
||
said the specification was wrong — see §9.5.
|
||
|
||
**FIXED 2026-07-26 — the trail was on tmpfs, unbounded, and chained to
|
||
nothing.** `$XDG_RUNTIME_DIR/souveraine/forensic.jsonl` was RAM on a 3.5 GB
|
||
phone, erased every reboot, growing without limit — 104 KB in this audit's
|
||
first 40 minutes. §5 called it tamper-evident; it carried a sequence number,
|
||
which catches a deleted line and not an edited one. Both decisions the audit
|
||
owed are taken, and the third it implied. See §11.
|
||
|
||
One divergence left: the `Locked → Observed` transition keys on **raw
|
||
`proximity_near`**, not on the §4 threshold table.
|
||
|
||
The other is settled. §4 called near+moving hand-held and refused to suppress
|
||
the wake; the code computed 0.5 ≥ 0.3 and suppressed. Neither was right —
|
||
motion cannot tell a pocket from an ear from a hand. The session can, and now
|
||
does. See §4.
|
||
|
||
**FIXED 2026-07-26 — evidence-source health is device state.** On 2026-07-25
|
||
SLPI took a CHRE fatal, remoteproc recovered it, `blueline-hexagonrpcd-sdsp`
|
||
exited "successfully" and was never restarted — every sensor was dead for hours
|
||
and nothing noticed, because a consumer of a dead sensor looks identical to one
|
||
whose sensor is quiet. See `PAF/slpi.md`. "No evidence" and "evidence
|
||
says nothing is happening" must not be the same state. They no longer are —
|
||
see §10.
|
||
|
||
**Supersedes:** the scattered state machines in IdleCoordinator.qml (7 states),
|
||
sessiond server.rs Phase (4 states), blueline-screen-toggle (2 states), and
|
||
blueline-proximity-lock (2 states). Those are projections of this model, not
|
||
separate systems.
|
||
|
||
---
|
||
|
||
## 1. Why one machine, not seven
|
||
|
||
The phone is in one state at any moment. It is either in your hand, in your
|
||
pocket, on a table, or asleep. Each of the seven actors we built sees one facet
|
||
and acts on it blindly:
|
||
|
||
- IdleCoordinator sees idle timers → locks at 300s
|
||
- sessiond sees heartbeat → retakes lock on EOF
|
||
- blueline-screen-toggle sees DPMS commands → serializes panel power
|
||
- blueline-proximity-lock sees proximity sensor → blanks locked screen
|
||
- SessionEvents sees PrepareForSleep → lock-then-release inhibitor
|
||
- hypridle sees idle timeout → dispatches lock/DPMS
|
||
- GlobalStates is a flat bag of booleans with no transition logic
|
||
|
||
None of them can see the others. Three of them can turn the screen off. Two of
|
||
them can request a lock. None of them knows whether the phone is in a pocket or
|
||
on a table — they just know "the user stopped touching it."
|
||
|
||
The doctrine already says this (SESSION-AUTHORITY-DOCTRINE §9): "Sensor readings
|
||
are evidence, not fact." The tier vocabulary needs a row the current matrix has
|
||
no cell for — `observed`. This doc defines that row.
|
||
|
||
---
|
||
|
||
## 2. The unified state
|
||
|
||
One enum. The device is in exactly one state at any time.
|
||
|
||
```
|
||
enum DeviceState {
|
||
Active, // Screen on, user present, unlocked or lockable
|
||
Dimmed, // Screen dim, user idle, not yet locked
|
||
Locked, // Screen locked, compositor secure, user may/may not be present
|
||
Observed, // Sensor says user present (proximity, accel, light) —
|
||
// EVIDENCE, not FACT. The shell acts on it but never
|
||
// trusts it for lock/unlock decisions.
|
||
DozeLight, // Locked + idle N min. App tier frozen. Wi-Fi power-save.
|
||
DozeDeep, // Locked + idle M min. Network fetchers stopped. RTC wake only.
|
||
Suspending, // PrepareForSleep(true). Inhibitor held. Waiting for lock secure.
|
||
Asleep, // s2idle. RTC + modem IRQs only.
|
||
}
|
||
```
|
||
|
||
### What each state means
|
||
|
||
**Active** — the user is interacting with the device. Screen is on. The session
|
||
may be unlocked (personal tier) or locked (the user is looking at the lock
|
||
surface). `displayActive = true`. All pollers run at full rate.
|
||
|
||
**Dimmed** — the user stopped touching the device but hasn't been idle long
|
||
enough to lock. Screen brightness lowered. The native IdleMonitor fired at
|
||
`dimAfterSeconds`. `displayActive = false` — pollers that produce stale data
|
||
should back off.
|
||
|
||
**Locked** — the compositor has acknowledged the lock surface (`screenLockSecure`).
|
||
The session is secure. The user may or may not be physically present — the lock
|
||
persists across pocketing, table-placing, and suspend/resume. Only PAM auth
|
||
unlocks.
|
||
|
||
**Observed** — one or more sensors report user presence, but this is EVIDENCE,
|
||
not FACT. The device is locked (Observed is a sub-state of Locked), and the
|
||
sensors are saying "someone is here." This state gates:
|
||
- DPMS wake (proximity-near suppresses wake to prevent pocket-dial)
|
||
- Touch input suppression (face-down on table shouldn't accept accidental touches)
|
||
- Idle tier promotion (proximity-near + locked = promote to Doze faster)
|
||
|
||
Observed NEVER gates:
|
||
- Lock/unlock decisions (only PAM does that)
|
||
- Security tier transitions (only sessiond does that)
|
||
- Personal data disclosure (only screenLockSecure does that)
|
||
|
||
**DozeLight** — locked and idle for N minutes. The app tier is frozen
|
||
(`systemctl --user freeze app-*.scope`). Wi-Fi drops to power-save. UPower
|
||
polling coasts. The lockscreen glance data (media, agent, notify cards) still
|
||
updates on wake — cards read on unlock, they don't need background freshness.
|
||
|
||
**DozeDeep** — locked and idle for M minutes. Network fetchers stopped. Only
|
||
RTC wakeups and modem IRQs can bring the device out. The coordinator wakes
|
||
every K minutes to check for notifications, then re-suspends.
|
||
|
||
**Suspending** — logind's `PrepareForSleep(true)` arrived. The delay inhibitor
|
||
is held. The lock is being requested (if not already secure). This state is
|
||
transient — the device moves to Asleep once the inhibitor is released.
|
||
|
||
**Asleep** — s2idle. The panel is off. The touch controller is in gesture mode
|
||
(dt2w). Only hardware wake sources work: power button, RTC alarm, modem IRQ.
|
||
|
||
---
|
||
|
||
## 3. Transitions
|
||
|
||
Every transition is guarded. Illegal transitions are refused and logged.
|
||
|
||
```
|
||
┌─────────┐ idle timeout ┌────────┐
|
||
│ Active │ ───────────────→ │ Dimmed │
|
||
└────┬─────┘ └───┬────┘
|
||
│ │
|
||
│ idle lock timer │ idle lock timer
|
||
│ external lock signal │ external lock signal
|
||
│ │
|
||
▼ ▼
|
||
┌─────────────────────────────────────┐
|
||
│ Locked │
|
||
│ (compositor secure, PAM required │
|
||
│ to leave) │
|
||
└────┬──────────┬──────────┬──────────┘
|
||
│ │ │
|
||
│ proximity │ idle N │ PrepareForSleep(true)
|
||
│ near │ min │
|
||
▼ ▼ │
|
||
┌──────────┐ ┌───────────┐ │
|
||
│ Observed │ │ DozeLight │ │
|
||
│ (sensor │ │ (frozen, │ │
|
||
│ evidence)│ │ Wi-Fi │ │
|
||
└────┬─────┘ │ save) │ │
|
||
│ └─────┬─────┘ │
|
||
│ │ │
|
||
│ proximity │ idle M │
|
||
│ far │ min │
|
||
│ ▼ │
|
||
│ ┌───────────┐ │
|
||
│ │ DozeDeep │ │
|
||
│ │ (network │ │
|
||
│ │ off, RTC │ │
|
||
│ │ wake) │ │
|
||
│ └─────┬─────┘ │
|
||
│ │ │
|
||
└─────────────┘ │
|
||
(prox far → back │
|
||
to Locked) ▼
|
||
┌───────────┐
|
||
│Suspending │
|
||
│(inhibitor │
|
||
│ held) │
|
||
└─────┬─────┘
|
||
│ lock secure
|
||
│ inhibitor released
|
||
▼
|
||
┌───────────┐
|
||
│ Asleep │
|
||
│(s2idle) │
|
||
└─────┬─────┘
|
||
│ PrepareForSleep(false)
|
||
│ power button / dt2w / RTC
|
||
▼
|
||
┌───────────┐
|
||
│ Locked │
|
||
│(wake, lock│
|
||
│ persists) │
|
||
└───────────┘
|
||
```
|
||
|
||
### Transition table
|
||
|
||
| From | To | Trigger | Guard | Action |
|
||
|---|---|---|---|---|
|
||
| Active | Dimmed | native IdleMonitor (dimAfterSeconds) | no inhibitor, no lock requested | lower brightness |
|
||
| Active | Locked | native IdleMonitor (lockAfterSeconds) | no inhibitor | request lock |
|
||
| Active | Locked | external lock signal (logind Lock) | — | request lock |
|
||
| Dimmed | Active | user input (touch, key) | not locked | restore brightness |
|
||
| Dimmed | Locked | native IdleMonitor (lockAfterSeconds) | no inhibitor | request lock |
|
||
| Locked | Active | PAM auth success | stepUp tier | unlock session |
|
||
| Locked | Observed | proximity-near detected | screen locked | suppress DPMS wake |
|
||
| Locked | DozeLight | idle N min while locked | screen locked | freeze app tier, Wi-Fi save |
|
||
| Observed | Locked | proximity-far detected | screen locked | allow DPMS wake |
|
||
| Observed | DozeLight | idle N min while locked | screen locked | freeze app tier |
|
||
| DozeLight | Locked | user input (dt2w, power button) | — | thaw app tier |
|
||
| DozeLight | DozeDeep | idle M min while locked | screen locked | stop network fetchers |
|
||
| DozeDeep | Locked | RTC wakeup, modem IRQ, user input | — | resume network |
|
||
| Any pre-sleep | Suspending | PrepareForSleep(true) | — | request lock, hold inhibitor |
|
||
| Suspending | Asleep | lock secure + inhibitor released | screenLockSecure | logind proceeds |
|
||
| Asleep | Locked | PrepareForSleep(false) | — | reacquire inhibitor |
|
||
|
||
### The Observed transition in detail
|
||
|
||
Observed is not a destination — it's a modifier on Locked. When the device is
|
||
Locked and proximity goes near, the device enters "Locked + Observed" — the
|
||
lock persists, but DPMS wake is suppressed and the idle tier promotion path
|
||
activates.
|
||
|
||
When proximity goes far, the device returns to "Locked" — DPMS wake is
|
||
re-enabled, and the idle timer resumes from where it left off.
|
||
|
||
This means Observed is really a boolean flag on the Locked state, not a
|
||
separate state in the enum. The enum above lists it separately for clarity,
|
||
but the implementation should model it as:
|
||
|
||
```
|
||
struct DeviceState {
|
||
state: DeviceStateEnum, // Active, Dimmed, Locked, DozeLight, DozeDeep, Suspending, Asleep
|
||
observed: bool, // sensor evidence of user presence
|
||
observed_confidence: f32, // 0.0–1.0, cross-sensor verified
|
||
observed_sources: Vec<SensorSource>, // which sensors contributed
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 4. Sensor evidence model
|
||
|
||
The doctrine §9 says: "Sensor readings are evidence, not fact." This section
|
||
defines how sensor inputs feed the state machine without becoming authorities.
|
||
|
||
### Sensor sources
|
||
|
||
| Sensor | What it reports | Confidence | Can it lie? |
|
||
|---|---|---|---|
|
||
| Proximity (iio-sensor-proxy) | near/far | binary, high | yes — a finger over the sensor |
|
||
| Accelerometer (iio-sensor-proxy) | device orientation + motion | continuous, medium | yes — vibration, vehicle |
|
||
| Light sensor (iio-sensor-proxy) | ambient light level | continuous, medium | yes — flashlight, shadow |
|
||
| Touch (FTS controller) | user input | binary, high | yes — pocket touch |
|
||
| Power button | user intent | binary, absolute | no — hardware signal |
|
||
| dt2w (FTS gesture mode) | user wake intent | binary, high | yes — false positive |
|
||
|
||
### Evidence aggregation
|
||
|
||
A single sensor saying "user present" is weak evidence. Two sensors agreeing
|
||
is stronger. The `observed_confidence` field aggregates:
|
||
|
||
```
|
||
confidence = 0.0
|
||
if proximity.near: confidence += 0.4
|
||
if accel.moving: confidence += 0.3
|
||
if light.changing: confidence += 0.2
|
||
if touch.active: confidence += 0.1
|
||
// Cap at 1.0
|
||
```
|
||
|
||
The thresholds for action:
|
||
|
||
| Confidence | Action allowed |
|
||
|---|---|
|
||
| < 0.3 | No sensor-driven action. Device stays in current state. |
|
||
| 0.3–0.6 | Suppress DPMS wake (prevent pocket-dial). Do not promote idle tier. |
|
||
| 0.6–0.8 | Suppress DPMS wake. Promote to DozeLight faster (halve the idle timer). |
|
||
| > 0.8 | Suppress DPMS wake. Promote to DozeLight/DozeDeep at normal rate. |
|
||
|
||
### Cross-sensor verification
|
||
|
||
If sensors disagree, the system logs the disagreement and uses the lower
|
||
confidence:
|
||
|
||
- Accelerometer says face-down, light sensor says bright → one is lying.
|
||
Log: `[sensor] cross-sensor disagreement: accel=face-down light=bright`.
|
||
Confidence drops to 0.3 (no action).
|
||
- Proximity says near, accelerometer says moving → ambiguous, and it stays
|
||
that way. A pocket, an ear and a hand all read like this; motion does not
|
||
separate them. Confidence drops 0.2 and no rule branches on the pair.
|
||
|
||
### Proximity is evidence, not an actuator (settled 2026-07-26)
|
||
|
||
This section used to read near+moving as "hand-held, so let the wake through"
|
||
while the code read it as 0.5 confidence and suppressed. Both used the wrong
|
||
instrument, and the argument they were having was too small. Casey's call:
|
||
proximity should barely control anything.
|
||
|
||
A covered sensor is a pocket, a face, a table or a thumb, and nothing in the
|
||
machine can tell which. So:
|
||
|
||
- **It does not blank the panel.** The rule that blanked any locked screen on
|
||
proximity-near is gone — that was `blueline-proximity-lock`'s old job moved
|
||
inward and kept too powerful. The idle budget blanks a locked screen soon
|
||
enough anyway.
|
||
- **It vetoes tap-to-wake, and nothing else.** A double tap is the one wake a
|
||
pocket can produce by itself, so a covered sensor is the right veto for it.
|
||
A power button is intent (§4: "no — hardware signal") and is never refused.
|
||
`DeviceStateMachine::suppress_wake(trigger)` is that rule.
|
||
- **It stays evidence.** It drives `Observed`, it is on every snapshot, and it
|
||
is context the agent can read (doctrine §13).
|
||
|
||
**Owed: the call.** A call is the one case where proximity *should* turn a
|
||
panel off, and it should do so whether or not the session is locked — an
|
||
unlocked in-call phone still wakes against a cheek. That needs a call-state
|
||
input (ModemManager / callaudiod) as another source: a factor, never an
|
||
authority.
|
||
|
||
### What sensors NEVER do
|
||
|
||
- Unlock the session (only PAM does that)
|
||
- Change screenLocked or screenLockSecure (only sessiond does that)
|
||
- Serve as a factor in authentication
|
||
- Override a manual lock (user pressed power button to lock)
|
||
|
||
---
|
||
|
||
## 5. Error and audit contract
|
||
|
||
### One trail, not two
|
||
|
||
Today, SessionAudit tracks state transitions (lock, unlock, sleep, wake) and
|
||
QML console.log tracks everything else. They never meet. This means:
|
||
|
||
- A brightnessctl failure is invisible to the audit trail
|
||
- A PAM timeout is logged to console but not to the tamper-evident log
|
||
- A D-Bus parse error in SessionEvents is a console.log line that vanishes
|
||
|
||
The unified model says: **every event that affects device state goes to one
|
||
trail.** State transitions, sensor inputs that triggered transitions, errors
|
||
that prevented transitions, and errors that degraded guarantees.
|
||
|
||
### Event categories
|
||
|
||
| Category | Example | Severity | Trail? |
|
||
|---|---|---|---|
|
||
| State transition | Active → Dimmed | info | yes |
|
||
| Sensor input | proximity-near, confidence=0.7 | debug | yes (if triggered transition) |
|
||
| Error — operational | brightnessctl restore failed | warn | yes |
|
||
| Error — security | PAM timeout, lock not secure before suspend | error | yes |
|
||
| Error — system | D-Bus parse error, gdbus monitor exited | error | yes |
|
||
| Error — degraded | sleep inhibitor exited unexpectedly | warn | yes |
|
||
| Audit — hash chain | chain resumed, chain reset | info | yes |
|
||
|
||
### Error severity definitions
|
||
|
||
**debug** — information useful for development. Not shown to user. Logged to
|
||
trail only if it triggered a state transition.
|
||
|
||
**info** — normal operation. State transitions, successful auth, successful
|
||
inhibitor acquire/release. Always logged to trail.
|
||
|
||
**warn** — degraded operation. The system is working but a guarantee is weaker
|
||
than intended. Examples: brightnessctl restore failed (screen stays dim), sleep
|
||
inhibitor exited unexpectedly (suspend will proceed without delay). Logged to
|
||
trail AND to console.warn.
|
||
|
||
**error** — broken operation. A guarantee is violated. Examples: PAM timeout
|
||
(lock not secure before suspend deadline), D-Bus monitor exited (PrepareForSleep
|
||
signals will not be received). Logged to trail AND to console.error AND surfaced
|
||
to the user if security-relevant.
|
||
|
||
### The trail format
|
||
|
||
The existing SessionAudit hash-chained JSONL format is correct. Extend it with
|
||
new event types:
|
||
|
||
```jsonl
|
||
{"seq":42,"prev":"sha256...","ts":1234567890,"event":"state-transition","data":{"from":"active","to":"dimmed","trigger":"idle-monitor","confidence":null}}
|
||
{"seq":43,"prev":"sha256...","ts":1234567891,"event":"sensor-input","data":{"source":"proximity","value":"near","confidence":0.7,"triggered":true}}
|
||
{"seq":44,"prev":"sha256...","ts":1234567892,"event":"error-operational","data":{"component":"idle-coordinator","action":"brightness-restore","error":"exit code 1"}}
|
||
{"seq":45,"prev":"sha256...","ts":1234567893,"event":"error-security","data":{"component":"session-events","action":"lock-for-suspend","error":"WlSessionLock.secure not received before deadline"}}
|
||
```
|
||
|
||
---
|
||
|
||
## 6. Mapping to existing code
|
||
|
||
### What stays
|
||
|
||
| Component | Role in unified model |
|
||
|---|---|
|
||
| IdleCoordinator.qml | Projection: owns the QML-side state enum, publishes displayActive, drives brightness. Becomes a consumer of the unified state, not the source. |
|
||
| sessiond server.rs Phase | Projection: owns the lock handoff. Phase maps to DeviceState.Locked (Holding/Released are sub-states of Locked). |
|
||
| blueline-screen-toggle | Executor: serializes DPMS transitions. Receives commands from the unified state machine, not from independent actors. |
|
||
| blueline-proximity-lock | Sensor input: feeds proximity evidence into the unified state machine. Stops making DPMS decisions directly. |
|
||
| SessionEvents.qml | Ingress: feeds PrepareForSleep and external lock signals into the unified state machine. |
|
||
| SessionAudit.qml | Trail: receives all events from the unified state machine. |
|
||
| GlobalStates.qml | Projection: flat booleans consumed by QML surfaces. Driven by the unified state machine. |
|
||
|
||
### What changes
|
||
|
||
1. **The unified state machine is the authority.** It lives in Rust (sessiond or
|
||
a sibling daemon) because it needs to be alive before the shell starts and
|
||
survive shell crashes. QML projects it; Rust owns it.
|
||
|
||
2. **Sensors become inputs, not actors.** blueline-proximity-lock stops calling
|
||
blueline-screen-toggle directly. It reports proximity-near to the unified
|
||
state machine, which decides whether to suppress DPMS wake based on
|
||
confidence and current state.
|
||
|
||
3. **DPMS becomes an executor, not an authority.** blueline-screen-toggle keeps
|
||
its flock mutex and state file, but it receives commands from the unified
|
||
state machine instead of from three independent callers.
|
||
|
||
4. **The audit trail is unified.** SessionAudit receives all events —
|
||
transitions, sensor inputs, errors. The hash chain covers everything.
|
||
|
||
5. **Error handling is standardized.** QML uses console.error for errors,
|
||
console.warn for warnings, console.log for info. Each log line has a
|
||
canonical tag. Errors that affect device state go to the audit trail.
|
||
|
||
---
|
||
|
||
## 7. Comparison targets
|
||
|
||
This design should be compared to:
|
||
|
||
- **AOSP PowerManagerService** — the Android power state machine
|
||
(Interactive → Dream → Asleep). How does it handle sensor fusion? Does it
|
||
have an "observed" state? How does it handle drop detection?
|
||
- **LineageOS doze** — any modifications to AOSP's doze implementation
|
||
- **SailfishOS mce** — the mode control entity (libwakelock.c + datapipe.c
|
||
pub/sub). Cited in TASK-08 as prior art.
|
||
- **FuriLabs batman** — dual-source idle: logind IdleHint + wlr-output-management
|
||
- **DankMaterialShell** — independent idle monitors for dim/DPMS/lock/suspend,
|
||
AC-vs-battery thresholds
|
||
|
||
Research complete — findings in §9 below.
|
||
|
||
---
|
||
|
||
## 8. Open questions
|
||
|
||
1. **Where does the unified state machine live?**
|
||
**Answer: sessiond.** Doctrine §11 names it as the natural home. It already
|
||
owns lock state, survives shell crashes, and the boot-order doc (Phase C)
|
||
says "sessiond is then the natural home for the rest of the authority
|
||
surface." TASK-15 agrees: "likely a sessiond sibling or sessiond itself."
|
||
|
||
2. **How does the shell read the unified state?**
|
||
**Answer: extend `session.state()` IPC.** The current response
|
||
`{locked, shell_alive, phase}` expands to include the device state enum,
|
||
observed confidence, and doze tier. GlobalStates stays as a QML projection
|
||
for backward compatibility.
|
||
|
||
3. **What is the freeze granularity?**
|
||
**Answer: freeze `apps.slice`, allowlist `apps-wake.slice`.** TASK-15:
|
||
"A slice freeze is one command and no bookkeeping; per-app lets Chatty stay
|
||
live for SMS-adjacent work. Likely: freeze `apps.slice`, keep an allowlist
|
||
slice for the few that may run locked."
|
||
|
||
4. **How does drop detection fit?**
|
||
**Dropped.** No existing OS does it. Novel but not needed for v1.
|
||
|
||
5. **What is the RTC wakeup schedule?**
|
||
**Answer: M is a config value.** TASK-15: "wake every M min → thaw the
|
||
network fetchers for a bounded window." The coordinator needs a "fetch
|
||
complete" signal, not a fixed sleep. The wake window is bounded by the
|
||
fetch, not by a timer. Charging can skip tiers 2–3 (desk-clock case).
|
||
|
||
---
|
||
|
||
## 9. Prior art — what others do (research findings, 2026-07-24)
|
||
|
||
### AOSP PowerManagerService
|
||
|
||
Android's top-level power state machine has four states:
|
||
|
||
```
|
||
WAKEFULNESS_ASLEEP (0) ↔ WAKEFULNESS_AWAKE (1) ↔ WAKEFULNESS_DREAMING (2) ↔ WAKEFULNESS_DOZING (3)
|
||
```
|
||
|
||
Managed per `PowerGroup` (one per display group). Transitions are dirty-flag
|
||
driven — `updatePowerStateLocked()` is called whenever wakefulness, user
|
||
activity, display power, wake locks, proximity, battery, or settings change.
|
||
Guards are eventTime ordering (`wakeUpLocked` requires `eventTime >= lastSleepTime`).
|
||
|
||
The **Sandman** (dream manager) asynchronously starts/stops dreams and doze.
|
||
`canDreamLocked()` checks battery, charging, dream settings. `canDozeLocked()`
|
||
checks doze enabled, screen-off timeout, ambient suppression.
|
||
|
||
### DozeMachine (SystemUI)
|
||
|
||
A separate 12-state machine inside the DOZING umbrella, managing the ambient
|
||
display (AOD) experience:
|
||
|
||
```
|
||
UNINITIALIZED → INITIALIZED → {DOZE, DOZE_AOD, DOZE_AOD_DOCKED}
|
||
DOZE ↔ DOZE_AOD ↔ DOZE_AOD_PAUSING ↔ DOZE_AOD_PAUSED
|
||
{DOZE, DOZE_AOD} → DOZE_REQUEST_PULSE → DOZE_PULSING → DOZE_PULSE_DONE → {DOZE, DOZE_AOD}
|
||
```
|
||
|
||
Proximity is the key guard. Before any pulse, `proximityCheckThenCall()` checks
|
||
if the device is in-pocket — if proximity reports NEAR, the pulse is dropped.
|
||
In DOZE_AOD, proximity NEAR transitions to PAUSING then PAUSED (AOD off).
|
||
Proximity FAR transitions back to DOZE_AOD.
|
||
|
||
### DozeTriggers — sensor bridge
|
||
|
||
Registers 9 sensor types for doze interactions:
|
||
|
||
| Sensor | Trigger | Gated by |
|
||
|---|---|---|
|
||
| Significant Motion | pulse on movement | proximity check |
|
||
| Pick-up Gesture | gentle wake on pickup | proximity check |
|
||
| Double Tap | pulse on double-tap | proximity check |
|
||
| Tap | pulse on single-tap | proximity check |
|
||
| Long Press | pulse on long-press | proximity check |
|
||
| UDFPS Long Press | fingerprint long-press | proximity check |
|
||
| Wake Display | presence detection to wake AOD | proximity check |
|
||
| Wake Lock Screen | reach detection to wake lock | proximity check |
|
||
| Quick Pickup | fast pickup for UDFPS devices | proximity check |
|
||
|
||
All gated by proximity. Sensors are selectively registered based on doze state.
|
||
|
||
### AttentionDetector — camera-based "Smart Stay"
|
||
|
||
Uses the front-facing camera to detect if the user is looking at the screen.
|
||
Extends the screen timeout (starts 2 seconds before dim). Has a maximum
|
||
extension limit to prevent security issues (device never locking). Tracks
|
||
consecutive timeout extensions.
|
||
|
||
**This is the closest thing to our "observed" state** — but it only extends
|
||
screen timeout, never affects lock/unlock. No confidence scoring, no
|
||
cross-sensor verification, no fusion with proximity or accelerometer.
|
||
|
||
### DisplayPowerProximityStateController
|
||
|
||
Low-level proximity-display controller:
|
||
- States: PROXIMITY_UNKNOWN (-1), PROXIMITY_NEGATIVE (0), PROXIMITY_POSITIVE (1)
|
||
- Positive debounce: 0ms (immediate)
|
||
- Negative debounce: 250ms
|
||
- Threshold: 5.0cm
|
||
- When positive, display can be blanked to prevent accidental touches
|
||
|
||
### Drop detection — does NOT exist in AOSP or LineageOS
|
||
|
||
**No stock Android or LineageOS has drop detection as a state machine input.**
|
||
|
||
Vendor-specific implementations found:
|
||
|
||
| Vendor | Implementation | Purpose |
|
||
|---|---|---|
|
||
| Vivo (OPPO/OnePlus parent) | `TYPE_DROP_DET` sensor (type ID 66548) in `PopupCameraManagerService` | Retract popup camera on drop. Hardware protection only. Not a power state input. |
|
||
| HarmonyOS (Huawei) | `SENSOR_TYPE_DROP_DETECTION = 7` in `CategoryMotion` | First-class sensor type in their API. Purpose: hardware protection. |
|
||
| Apple Watch | `CMFallDetectionEventUserResolution` in CoreMotion | Fall detection for health/safety. Not phone power management. |
|
||
|
||
Open-source research implementation (`sense-android-library` FallDetector):
|
||
- Phase 1 (free fall): acceleration < 0.6g for > 60ms
|
||
- Phase 2 (impact): acceleration > 2.0g within 200ms after free fall
|
||
- Phase 3 (inactivity): acceleration < 1.3g for > 2000ms within 3500ms after impact
|
||
|
||
This is a health app algorithm, not an OS integration. No mobile OS treats
|
||
drop detection as a power state machine input.
|
||
|
||
### What this means for our design
|
||
|
||
1. **Our "observed" state is novel.** No existing OS combines proximity +
|
||
accelerometer + light into a confidence score for device state decisions.
|
||
Android uses proximity as a binary gate (suppress/allow), never as evidence.
|
||
|
||
2. **Our sensor fusion model is novel.** Android's DozeTriggers gates every
|
||
sensor through proximity, but never cross-validates sensors against each
|
||
other. Our "accelerometer says face-down, light says bright — one is lying"
|
||
pattern doesn't exist anywhere.
|
||
|
||
3. **Drop detection as a state machine input is novel.** The three-phase
|
||
free-fall/impact/inactivity algorithm exists in research, but no OS
|
||
integrates it into power management. Our design can add it as a sensor
|
||
source with high confidence (free-fall is hard to fake) that triggers:
|
||
- Suppress touch input (prevent accidental taps during fall)
|
||
- Lock immediately (if screen was on)
|
||
- Log to audit trail (drop event with timestamp + sensor data)
|
||
|
||
4. **The two-machine pattern (PowerManager + DozeMachine) validates our
|
||
approach.** Android separates the top-level wakefulness from the doze UI
|
||
layer. Our design separates the unified device state from the shell
|
||
projections (IdleCoordinator, GlobalStates). Same shape, better coverage.
|
||
|
||
5. **Proximity debounce matters — and the direction is ours, not Android's.**
|
||
`DisplayPowerProximityStateController` uses 0 ms positive and 250 ms
|
||
negative: believe `near` at once, make `far` wait. This section told us to
|
||
copy that. **Measured on the trail 2026-07-26, it would have done nothing.**
|
||
|
||
44 near-episodes over 2.4 hours: median dwell **1 second**, 15 of them
|
||
sub-second, median 115 s of quiet between them. Not a sensor bouncing around
|
||
a threshold — isolated one-second blips. A negative debounce delays believing
|
||
`far`, so each 1 s blip would have become a 1.25 s blip and all 88
|
||
transitions would have stayed.
|
||
|
||
The mirror image is what this data asks for: debounce the **positive** edge.
|
||
Android debounces the negative one because there `near` means *screen off at
|
||
the ear, now*, and a positive delay is felt. That constraint left the moment
|
||
proximity stopped actuating (§4) — it now only vetoes tap-to-wake, where
|
||
waiting is imperceptible and a false veto is the worse failure.
|
||
|
||
Built as `PROXIMITY_NEAR_DEBOUNCE` 700 ms / `PROXIMITY_FAR_DEBOUNCE` 0, both
|
||
policy fields. Nine of the 44 episodes ran ≥5 s (max 173 s) — pocket, ear,
|
||
deliberate cover — and pass through untouched. Resolution happens on the
|
||
reading *and* on the tick, so a reporter that heartbeats every 30 s cannot
|
||
delay a real `near` by half a minute. The edge that wins is written to the
|
||
trail with how long it held; the blips that lose are not written at all,
|
||
which is most of the log volume §0 was complaining about.
|
||
|
||
**The lesson is the method, not the number.** The prior art was read
|
||
correctly and applied to a device whose constraints had changed underneath
|
||
it. Measure the thing before adopting the answer.
|
||
|
||
---
|
||
|
||
## 10. Evidence-source health (built 2026-07-26)
|
||
|
||
§4 says what a reading is worth. This section says whether there is a reading
|
||
at all, which §4 assumed and never checked.
|
||
|
||
### Why staleness was not enough
|
||
|
||
`EVIDENCE_TTL` (30 s) already expired old readings, and its comment already
|
||
named the SLPI outage. It still could not have caught it. `expire_stale_evidence`
|
||
only acts on a source whose flag is currently `true` — it exists to stop a
|
||
30-second-old "near" from suppressing a wake. A proximity sensor resting at
|
||
`far` has a flag of `false`: nothing to expire, nothing anomalous about the
|
||
silence, no entry in any log. That is the shape of the outage. Four hours of
|
||
dead sensors produced exactly zero lines.
|
||
|
||
The two questions are different and both are needed:
|
||
|
||
| | question | threshold | what it protects |
|
||
|---|---|---|---|
|
||
| `evidence_fresh` | may this *reading* be believed? | `EVIDENCE_TTL` 30 s | a rule acting on a stale value |
|
||
| `sensor_health` | is this *source* there? | `SOURCE_DOWN_AFTER` 90 s | a rule acting on absence as if it were a negative |
|
||
|
||
### Three states, and why `Unknown` is not a failure
|
||
|
||
`Unknown` → never heard from. `Live` → reported within the window. `Down` →
|
||
reported once and then stopped.
|
||
|
||
Only a source that spoke and then went silent has failed. Accel, light and touch
|
||
have no reporters on this device today, so they sit at `Unknown` forever, which
|
||
is correct: a signal that fires for hardware nobody wired up is a signal nobody
|
||
reads. Down is rare by construction, and therefore worth reading.
|
||
|
||
### Health is not read from `net.hadess.SensorProxy`
|
||
|
||
`HasProximity` / `HasAccelerometer` is the obvious source of truth and it is
|
||
wrong. It lies in both directions, both measured (`PAF/slpi.md`):
|
||
|
||
- 2026-07-25 12:19 — stack dead, proxy answered `HasProximity: true` for hours,
|
||
and the first client to claim a sensor segfaulted it.
|
||
- 06:04 incident — proxy answered `false` after remoteproc had already
|
||
recovered SLPI.
|
||
|
||
A property that is wrong in both directions is not an authority. What the
|
||
machine can actually trust is its own experience: whether readings arrive. This
|
||
is the same move as `lockhint.rs` making logind the lock truth — read the thing
|
||
that cannot be wrong about itself, not the thing that summarises it.
|
||
|
||
### Silence had to be made meaningful
|
||
|
||
`monitor-sensor` emits only on change, so a phone on a table is silent for hours
|
||
and is byte-for-byte indistinguishable from a CHRE fatal. Without fixing that,
|
||
the only options were never detecting a dead stack or crying wolf every quiet
|
||
afternoon.
|
||
|
||
So reporters heartbeat: `blueline-proximity-lock` re-sends its last reading
|
||
every 30 s, well inside the 90 s threshold, and seeds that value from the
|
||
startup probe banner so a stack that was dead from boot is still visible. As
|
||
long as the reporter and the sensor behind it live, sessiond hears from
|
||
proximity every 30 s. If it stops hearing, something is actually wrong.
|
||
|
||
The repeats are idempotent — the machine re-derives identical evidence — and
|
||
they do **not** append to the trail, or proximity alone would add ~2,900 lines a
|
||
day saying nothing changed to a log that §0 records as unbounded and on tmpfs.
|
||
The dedupe skips the trail write only, never the evaluation: an unchanged
|
||
reading can still change the answer, because proximity held `near` across a lock
|
||
flips `should_be_observed` with no change in the evidence at all.
|
||
|
||
### What the trail can now answer
|
||
|
||
- `error-security` `source-down` on the outage edge, once, not per tick.
|
||
- `source-recovered` on the way back, which is what *bounds* the window. An
|
||
outage with no closing entry tells you when the sensors died and never when
|
||
they returned.
|
||
- `sensors_degraded` on **every** forensic snapshot. This is the part that makes
|
||
the log diagnostic rather than merely complete: a `panel-off` recorded during
|
||
four dead hours no longer reads like a healthy one. Reconstruction after the
|
||
fact can ask "what was the machine deciding on?" and not just "what did it do?"
|
||
|
||
### Delivery order matters, and it is TASK-28's case exactly
|
||
|
||
The two halves ship by different routes — sessiond in the `souveraine` package,
|
||
the reporter in `rootfs-overlay/`. New sessiond with the old reporter would
|
||
declare proximity down every 90 s, forever, because nothing heartbeats. The
|
||
reporter went to the device first, deliberately. That asymmetry is the argument
|
||
TASK-28 is made of.
|
||
|
||
### Still not covered
|
||
|
||
Health answers "is the source there". It does not answer "is the source telling
|
||
the truth" — a proximity sensor stuck reporting `near` heartbeats perfectly and
|
||
reads as `Live`. §4's cross-sensor disagreement machinery is where that would
|
||
live, and it is still computed and logged without being branched on.
|
||
|
||
---
|
||
|
||
## 11. The trail itself (built 2026-07-26)
|
||
|
||
§5 said what goes in the trail. §10 made the entries diagnostic. This is about
|
||
the file.
|
||
|
||
**Where.** `$XDG_STATE_HOME/souveraine/forensic.jsonl` — `~/.local/state`, next
|
||
to `crashes.log`. Durable, and inside the encrypted home. No fallback path: with
|
||
no home the trail is memory-only and says so at startup. The old code fell
|
||
through to `/tmp`, which nobody would look in. Nothing is migrated; there was
|
||
never anything in the tmpfs file to migrate.
|
||
|
||
**Bounds.** 4 MiB a file, two rotated generations, 12 MiB in all. At §0's
|
||
3.6 MB/day that is three days; at the 0.5 MB/day measured since §10's heartbeat
|
||
dedupe, three weeks. Proximity debounce (§9.5) cuts it again — the flap is most
|
||
of what is left.
|
||
|
||
**The chain.** Each entry carries `prev`, the SHA-256 of the one before it, and
|
||
each written line ends with its own `hash`. Same contract as
|
||
`SessionAudit.qml`, so one verifier reads both: strip the trailing
|
||
`,"hash":"<hex>"`, close the object, hash it, compare.
|
||
|
||
Three properties, each a test:
|
||
|
||
- Rotation does not break it. The new file's first entry carries the old file's
|
||
last hash. A file read alone shows a non-empty `prev` on line 1 — correct, its
|
||
predecessor is the next file along.
|
||
- Restart does not break it. The daemon reads the tail and continues the seq and
|
||
the hash. Every run opens with `trail-opened`, which names the path, says
|
||
whether the chain resumed, and proves the file is writable before anything
|
||
needs it to be.
|
||
- **A torn tail is a hard reboot, and the chain resumes past it.** Written
|
||
first as "rotate the whole file aside"; hardware corrected it the same day.
|
||
Casey's cold boot left the trail at exactly 4096 bytes — a page boundary —
|
||
with the final entry cut in half, because the last write never reached disk.
|
||
Rotating that aside restarted the chain at seq 0, losing continuity at the
|
||
one event most worth reconstructing. Now the torn bytes are discarded, the
|
||
file is truncated to the last intact entry, and the chain continues from it,
|
||
loudly. A file with *nothing* parseable is a different claim and is still
|
||
rotated aside and kept. A bad hash in the middle is a third claim, and
|
||
nothing here hides it — the verifier reads every line.
|
||
|
||
Seq, chain head and write now sit under one lock. Two locks and a later write
|
||
was survivable for a counter; two threads taking the same `prev` verifies as
|
||
tampered.
|
||
|
||
**Failed writes are loud.** Every file call was `let _ = ...`. Now the first
|
||
failure warns with path and error, and recovery says so — one line per edge, as
|
||
§10 does for source-down. The chain head advances only on a line that landed.
|
||
Advance it on a failed write and the next entry points at a `prev` no file
|
||
holds, which looks exactly like deletion.
|
||
|
||
**The verifier exists:** `souveraine-verify-trail` (Pixel3Arch
|
||
`rootfs-overlay/usr/bin/`). Checks that every entry's hash covers its own body
|
||
and that each `prev` is the previous hash; takes generations oldest-first to
|
||
verify across a rotation. Proven on the device 2026-07-26 — 14 entries verify,
|
||
and editing one byte of entry 2 reports `seq 1 hash does not cover its body`
|
||
and exits 1. The same contract reads `SessionAudit.qml`'s chain, which has
|
||
never been checked either.
|
||
|
||
---
|
||
|
||
## 12. Ambient light — where it belongs in the chain (2026-07-26)
|
||
|
||
Light is already half-modelled: `SensorSource::Light` exists, `light_changing`
|
||
carries +0.2 in §4's arithmetic, and §10 tracks its health. It has no reporter,
|
||
so it sits at `Unknown` forever. Adding auto-brightness means answering two
|
||
separate questions, and conflating them is how this goes wrong.
|
||
|
||
### Two consumers, one input
|
||
|
||
| | question | already exists |
|
||
|---|---|---|
|
||
| evidence | is the light *changing*? (someone is here) | yes — §4, unfed |
|
||
| actuation | how bright *should* the panel be? | no |
|
||
|
||
A change-detector cannot drive brightness and an absolute lux reading is weak
|
||
evidence of presence. Both come off the same sensor; neither substitutes.
|
||
|
||
### It must be an Action, not a daemon
|
||
|
||
The tempting shape is a small auto-brightness service reading lux and calling
|
||
`brightnessctl`. That is an **eighth blind actor** and §1 already says what
|
||
happens: sessiond owns the panel's brightness today — `Dim` captures the
|
||
current value, `Restore` puts it back, and the code comment on that capture
|
||
records what it cost to get right. A second writer makes the captured value a
|
||
lie the moment it writes, and the failure looks exactly like the one already
|
||
recorded there: "a tap-to-dismiss came back at a different level than it
|
||
started."
|
||
|
||
So: lux enters through `sensor_input` like proximity, and auto-brightness
|
||
leaves as an `Action` computed in `tick()`, executed by the same executor
|
||
table that owns `Dim`/`Restore`. One writer, one trail.
|
||
|
||
### The interaction that has to be stated
|
||
|
||
`Dim` is a deliberate departure from the correct brightness, so auto-brightness
|
||
must be **suspended between `Dim` and `Restore`**, not merely rate-limited.
|
||
Otherwise the dim is fought by the ambient controller, and `Restore` puts back
|
||
a value auto-brightness has since superseded.
|
||
|
||
The rule: auto-brightness proposes only while the panel is undimmed and the
|
||
device is not `Locked`. On `Restore` it re-evaluates from current lux rather
|
||
than replaying the captured number — the capture exists to undo *our* dim, not
|
||
to pin the panel to a stale room.
|
||
|
||
### Hysteresis, for the same reason proximity has it
|
||
|
||
§9.5's lesson applies directly: an unsmoothed sensor driving an actuator
|
||
produces exactly the flapping proximity produced. Lux is continuous and noisy —
|
||
a hand passing over the sensor is a 1-second event. Ramp, do not step, and
|
||
require a sustained delta before moving at all. The numbers should come off the
|
||
trail the way `PROXIMITY_NEAR_DEBOUNCE` did, not out of this document.
|
||
|
||
### The reporter is the same open question as proximity's
|
||
|
||
`blueline-proximity-lock` is a shell script on a `sleep 30` loop feeding
|
||
`sensor_input`, and §10's heartbeat requirement is implemented inside it. A
|
||
light reporter needs the identical contract — heartbeat inside
|
||
`SOURCE_DOWN_AFTER`, seed from a startup probe — which is the second copy of
|
||
that logic and the argument for one reporter process serving every
|
||
iio-sensor-proxy source instead of one script per sensor. Decide that before
|
||
writing the second script, not after the third.
|
||
|
||
### Manual override: anchors, not a bias (settled 2026-07-27)
|
||
|
||
This section's open question — "Android uses the manual value as a bias on the
|
||
curve; that is probably right here too" — has a better answer, and it is the one
|
||
iOS uses. From Apple's brightness-control patents: the system does not apply a
|
||
global offset. It keeps a **piecewise curve anchored at 0%, 100%, and the points
|
||
the user has actually set**. A manual correction at a given ambient level
|
||
becomes an anchor at *that* level, and the controller interpolates between
|
||
anchors.
|
||
|
||
The difference is not cosmetic. A bias is global: nudge the screen up in a dim
|
||
room and you have also brightened every sunlit reading. Anchors are local, which
|
||
matches what the person meant — they made a statement about *this* light, not
|
||
about all light. It also explains the behaviour everyone recognises from iOS:
|
||
correcting brightness indoors does not wreck it outdoors.
|
||
|
||
Two consequences worth stating:
|
||
|
||
- **The ceiling is honoured.** Pinned at manual maximum, further ambient rise
|
||
moves nothing. There is no headroom left to grant and pretending otherwise is
|
||
how a controller feels like it is fighting you.
|
||
- **Two to four anchors is the whole model.** This is a handful of `(lux,
|
||
brightness)` pairs, not a learned curve. It persists next to the other
|
||
settings, and a user who wants their old behaviour back deletes them.
|
||
|
||
### Response shape
|
||
|
||
Three separable pieces, and conflating them is how this goes wrong:
|
||
|
||
1. **Smooth the input.** A hand passing the sensor is a one-second event. Same
|
||
argument as §9.5, same fix.
|
||
2. **Ramp the output, asymmetrically.** Animate toward the target rather than
|
||
stepping, and do not use the same rate in both directions — walking into
|
||
sun and walking into a dark room are not the same experience. **Which
|
||
direction gets the faster ramp is a measurement, not a quote.** §9.5 is the
|
||
standing warning here: the prior art was read correctly and applied to a
|
||
device whose constraints had changed underneath it. Take the numbers off the
|
||
trail.
|
||
3. **Fit the curve** from the anchors above.
|
||
|
||
### Verified 2026-07-27: the input is already lux
|
||
|
||
A real concern with ambient sensors is that the raw part reports RGBC channels
|
||
and `intensity_clear` is not illuminance. **Not our problem here** — measured on
|
||
the device, iio-sensor-proxy hands us lux directly:
|
||
|
||
```
|
||
=== Has ambient light sensor (value: 0.000000, unit: lux)
|
||
Light changed: 0.665771 (lux)
|
||
```
|
||
|
||
No conversion step. The same run also proves the ALS itself is healthy — which
|
||
matters, because the reason nothing has ever consumed it is that the *reporter*
|
||
was dead (see `SourceHealth::Absent`), not the sensor.
|
||
|
||
### It is still not a QML singleton, and that is the whole point
|
||
|
||
The natural-looking shape — a Quickshell singleton subscribing to
|
||
`net.hadess.SensorProxy` and writing brightness — is the one thing this section
|
||
already forbids, and it is worth restating because it is what any outside
|
||
adviser will propose. sessiond owns the panel's brightness: `Dim` captures the
|
||
current value and `Restore` puts it back. A second writer makes that captured
|
||
value a lie the instant it writes, and the failure mode is already recorded in
|
||
the code comment on that capture — "a tap-to-dismiss came back at a different
|
||
level than it started."
|
||
|
||
Reference daemons in this space (`yabd`, `lightsd`) are worth reading for the
|
||
ramp and clamp mechanics and worth copying nothing from architecturally: both
|
||
are the standalone-daemon shape, because neither has a device state machine to
|
||
belong to. We do. Lux enters as `sensor_input` like every other source;
|
||
brightness leaves as an `Action` from `tick()`, through the executor table that
|
||
already owns `Dim`/`Restore`. One writer, one trail.
|
||
|
||
### Open
|
||
|
||
- Whether lux belongs in the confidence table at all once it drives an
|
||
actuator, or whether `light_changing` should be derived from the same
|
||
smoothed signal auto-brightness uses rather than a second raw one.
|
||
- How long a manual anchor lives. Anchors that never expire eventually encode a
|
||
room the user left a year ago; anchors that expire silently undo a stated
|
||
preference. Neither is obviously right and the trail cannot answer it yet.
|