PAF becomes saf/device (history kept), STATE.md dissolves into saf/state.md with the dated era archived, the substrate SAF moves up from souveraine, and every agreement points at saf/INDEX.md and nowhere else. one map, nothing to remember
188 lines
11 KiB
Markdown
188 lines
11 KiB
Markdown
# TASK 08 — Device state manager / power profiles
|
||
|
||
**Status:** in progress. The shell-side state projection and reason-tracked
|
||
inhibitors exist; the durable manager and device tiers remain open. Largest
|
||
of the tasks; sub-divide before starting.
|
||
|
||
## Goal
|
||
|
||
One owner for device power states instead of scattered timeout
|
||
listeners. Android is the reference — it has *tiers* of idle (screen-off,
|
||
doze-light, doze-deep) with promotion rules. We have exactly one blunt
|
||
s2idle and it wedges touch (TASK-09).
|
||
|
||
Wants (from handoff doc item 12):
|
||
- **(a) Idle tiers with device-aware gating.** The souveraine settings
|
||
device flag should gate which states a device may enter. blueline: no
|
||
suspend until TASK-09 closes. SailfishOS mce (libwakelock.c +
|
||
datapipe.c pub/sub) and FuriLabs batman (dual-source idle: logind
|
||
IdleHint + wlr-output-management) are the steal-this prior art (both
|
||
researched 2026-07-17 with full findings).
|
||
- **(b) Charge-aware profiles. UNBLOCKED 2026-07-24 — the kernel work is
|
||
done and running.** The old note here (no charge-threshold sysfs on
|
||
mainline, needs software current-pacing) is obsolete: `qcom_smbx.c` now
|
||
exposes read/write charge control, live on the device since kernel
|
||
`7.1.1-sdm845-g58892902459c`:
|
||
|
||
/sys/class/power_supply/pmi8998-charger/
|
||
charge_control_end_threshold 99 (percent, ABI-correct)
|
||
charge_type N/A (Trickle/Fast/Taper/…)
|
||
voltage_max 4395000 (CV target)
|
||
constant_charge_current_max 1950000 (capped at 1C = 2.97A)
|
||
|
||
upower exposes `ChargeType` + `PercentageTrusted` over D-Bus (the
|
||
Souveraine fork, `souveraine/charge-type`). **Both properties existed but
|
||
reported defaults until 2026-07-25 — do not trust a D-Bus property merely
|
||
because it is present.** `ChargeType` read `unknown` and
|
||
`PercentageTrusted` read `no` on hardware for days. Three fixes were
|
||
needed, and neither of the first two moved the symptom on its own:
|
||
`628283f` (kernel spells NONE as `N/A`), `718e921` (SDM845 splits fuel
|
||
gauge from charger — `qcom-battery` has **no** `charge_type`; walk the
|
||
kernel's device links to `pmi8998-charger`), and `f974714` — the one that
|
||
actually did it: `up-enumerator-udev.c` tries
|
||
`UP_TYPE_DEVICE_SUPPLY_BATTERY` first for every power_supply and only falls
|
||
back to `UP_TYPE_DEVICE_SUPPLY`, so a real battery never ran the refresh
|
||
where the fork set these. `beb94dc` fixes `PercentageTrusted`, whose
|
||
heuristic tested `charge_full` — absent here, and not what upower reads the
|
||
percentage from anyway (it uses `capacity`).
|
||
**Verified on device 2026-07-25: `charge-type: fast` against the charger's
|
||
`Fast`.** Note `charge_type` (active regime) is a different attribute from
|
||
`charge_types` (the settable profile list `up-device-supply-battery.c`
|
||
already uses for thresholds) — easy to conflate when wiring (b).
|
||
Hardware gaps that are not bugs: `charge_full`, `charge_now`, `energy_*`,
|
||
`cycle_count` and model/serial are all absent, so `energy-full` falls back
|
||
to design (13.068 Wh), health reads a meaningless 100%, and charge-cycles
|
||
is N/A. Also seen: `temp` = 570 → **57 °C while charging** — verify whether
|
||
that is real before building thermal policy on it.
|
||
pop-os/system76-power
|
||
`charge_thresholds.rs` is still the capability-probing pattern to copy —
|
||
probe, do not assume, since the attrs exist only on this kernel.
|
||
Cosmetic: 4.4V encodes to 4.395V so the threshold getter reads 99, not
|
||
100. Errs low, safe. Full design + review history:
|
||
`saf/device/evidence/power/HANDOFF-upower-charge-control.md`.
|
||
- **(c) USB role awareness.** The USB-C role/mode plumbing installed
|
||
earlier has NO UI toggle. Shell should show current mode
|
||
(gadget/host/charge-only) and switch it. abrauchli/usbeehive (Rust MIT,
|
||
typec.rs/pd.rs sysfs) is liftable as a dependency. No existing OSS GUI
|
||
toggle to fork — build fresh.
|
||
- **(d) Wifi handoff gating.** Radio transitions belong to the same
|
||
state model, not ad-hoc.
|
||
- **(e) Bounded maintenance/update leases.** A deploy or system update may
|
||
inhibit *automatic* idle lock while it mutates live session components,
|
||
but never bypass an existing lock or disable manual lock. The lease has an
|
||
owner/reason/deadline, survives a shell reload, expires on deploy failure,
|
||
disconnect or timeout, and restores the exact prior idle state.
|
||
|
||
## Context
|
||
|
||
- Idle coordinator already exists: `souveraine b18fa33`, native path
|
||
ENABLED on phone (dim 120s / lock 300s). See handoff doc "Idle manager,
|
||
first real pass" for the hard-won Wayland protocol-error lesson
|
||
(inhibit checked INSIDE handlers, monitor objects never destroyed).
|
||
- hypridle is the redundant fallback (300s lock, 600s screen-off, suspend
|
||
DISABLED per TASK-09 interim).
|
||
- `Session.qml` already implements `state()`, reason-tracked `inhibit()` /
|
||
`uninhibit()`, and the `Idle.inhibit` projection. A 2026-07-21 audit found
|
||
that no `IpcHandler` actually exposed those methods despite comments saying
|
||
it did; a worktree fix adds the missing local `session` IPC seam.
|
||
- **Measured maintenance failure (2026-07-21 19:29):** the phone auto-locked
|
||
during a live shell deploy. Quickshell reloaded while the compositor lock
|
||
was secure, hit `FATAL: Tried to show lockscreen surfaces without active
|
||
lock`, crashed, and sessiond correctly fail-closed. Repeated shell owners
|
||
then contended for notifications/polkit until cleanup. A deploy must never
|
||
race the idle lock again.
|
||
- The conclusions want to land in `power-profiles-daemon` as the hint layer.
|
||
|
||
## Two things the machine knows and nothing says (open, 2026-07-26)
|
||
|
||
**(f) `sensors_degraded` reaches no surface.** Source health landed
|
||
(`DEVICE-STATE-MACHINE.md` §10): a source that reported and went silent for 90 s
|
||
is `down`, not quiet, and every forensic snapshot carries `sensors_degraded`.
|
||
The trail knows. No screen does. The crash reporter structurally cannot help —
|
||
all three of its sources key on something *failing*, and the SLPI outage exited
|
||
status 0 (archive/05). This is the surfacing half: an ambient indicator when a
|
||
source that used to work has stopped, legible on the lock surface, saying which
|
||
and since when. `device_state` already ships `sensor_health` over IPC.
|
||
|
||
**(g) The confidence gates are computed, logged, and never branched on.** §4's
|
||
threshold table (<0.3 / 0.3–0.6 / 0.6–0.8 / >0.8) and the cross-sensor
|
||
disagreement rules are written to the trail and ignored. **This needs a decision
|
||
before code**, and the decision moved on 2026-07-26: the table was written when
|
||
proximity suppressed DPMS wake and blanked locked screens, and proximity now
|
||
does neither — it vetoes tap-to-wake and is otherwise evidence. So the question
|
||
is no longer "what should confidence gate" but "is anything left for it to
|
||
gate". The honest candidate is the doze promotion rate and possibly nothing
|
||
else. Answer that, then wire it or delete the table and record why.
|
||
|
||
## (h) Thermal policy — the zones are there and the policy is empty (2026-08-04)
|
||
|
||
Measured on the device, so this is inventory rather than plan:
|
||
|
||
- **23 thermal zones**, all on the **`step_wise`** governor. `power_allocator`
|
||
(IPA — the PID that allocates a power budget across cooling devices) is
|
||
*available* on every zone and used by none.
|
||
- **Cooling devices:** `cpufreq-cpu0` (17 states), `cpufreq-cpu4` (23),
|
||
`devfreq-5000000.gpu` (6), plus `cx` and `ebi`.
|
||
- **Trip points:** passive at CPU 90/95 °C, GPU 85 °C, PMIC 105 °C; `hot` 90,
|
||
`critical` 110. **Nothing below 85 °C.** So nothing throttles until the die is
|
||
already hot, and there is no gentle first response at all.
|
||
- **cpuidle:** two states (WFI, little-rail power collapse), governor `menu`,
|
||
and `menu` is the only one built. Android downstream had far deeper cluster
|
||
and rail collapse; upstream `teo` is not available to compare against.
|
||
- **GPU:** `simple_ondemand` devfreq, idling at 257 MHz.
|
||
- Battery reads 41 °C at 22% while idle — and note the existing warning above
|
||
about `temp` = 570 while charging. Verify the sensor before building policy on
|
||
it; a skin model fed a lying thermistor is worse than no model.
|
||
|
||
**The Android prior art, and which parts transfer.** The HAL types zones
|
||
(`CPU/GPU/SKIN/BATTERY/USB_PORT/POWER_AMPLIFIER`) and maps each to a severity
|
||
ladder — `NONE → LIGHT → MODERATE → SEVERE → CRITICAL → EMERGENCY → SHUTDOWN`.
|
||
That ladder is the interesting part and it is *ours to define*, because it is
|
||
policy, not hardware. `PowerManager.getThermalHeadroom()` is its app-facing half:
|
||
a normalized 0–1 forecast some seconds ahead, so software degrades itself before
|
||
the kernel throttles it. **Skin temperature is a virtual sensor** — a weighted
|
||
model over die zones, because there is no thermistor on the glass; on SDM845
|
||
those coefficients lived in Qualcomm's per-device `thermal-engine.conf`, which
|
||
is the one piece of blueline-specific prior art worth going and reading.
|
||
|
||
**How it lands here, and the trap to avoid.** §12's rule is not optional and an
|
||
outside adviser will propose breaking it: **a thermal daemon that reads a zone
|
||
and calls a governor is the eighth blind actor.** Temperature enters through
|
||
`sensor_input` like proximity and lux; the response leaves as an `Action` from
|
||
`tick()`, through the executor table that already owns `Dim`/`Restore`/`Blank`.
|
||
One writer, one trail. The severity ladder is then a `DeviceStatePolicy` field
|
||
the agent can read and rewrite, which is what makes "the phone is getting warm"
|
||
something she can act on instead of something that happens to her.
|
||
|
||
**Do the cheap thing first.** TASK-15's 2026-08-04 measurement found ~36% of a
|
||
core burning at idle in the sensor stack, with the SoC nowhere near any trip.
|
||
No thermal policy would have helped that. Fix the spinning before modelling the
|
||
heat.
|
||
|
||
## Sub-tasks (do separately)
|
||
|
||
1. State machine + pub/sub (mce/datapipe shape), device-flag gating.
|
||
2. Charge-aware profile (needs the PMIC investigation first).
|
||
3. USB role UI toggle.
|
||
4. Wifi handoff in the state model.
|
||
5. Maintenance/update lease in the durable manager. Interim `deploy.sh` may
|
||
use a systemd-timer-backed hypridle lease, but it must first refuse an
|
||
already locked session and schedule automatic recovery *before* stopping
|
||
idle handling.
|
||
|
||
Start with (1) — it's the spine the others hang off.
|
||
|
||
## Acceptance
|
||
|
||
One daemon owns device power state. `session state` IPC reports the
|
||
current tier. A plugged-in-at-100% phone doesn't float-charge blindly.
|
||
USB mode is visible and switchable from the shell.
|
||
|
||
An update lease is visible in the same state projection with owner, reason and
|
||
deadline. Manual lock during a lease wins immediately. Kill the deploy/SSH
|
||
mid-run: automatic idle handling returns within the bounded timeout.
|
||
|
||
## Connects to
|
||
|
||
TASK-09 (suspend must be safe before it's a tier), idle coordinator
|
||
(`b18fa33`), session-authority layer (handoff item 4).
|