Watch
1
0
Fork
You've already forked SouveraineOS
0
SouveraineOS/docs/tasks/33-battery-as-device-state.md

5.3 KiB

TASK 33 — Battery and charging belong to the device state machine

Status: open, raised 2026-07-26. Size: one session for (1), one for (2); (3) is a policy decision before it is code. Repos: ~/Projects/souveraine (packaging/upower-souveraine, surfaces/quickshell, src/sessiond).

Policy decision — 2026-08-07

This is a charge ceiling + resume floor, not “trickle charging.” Charge type is evidence about what the charger is doing; thresholds are control over when it may do it. Keep those as different fields and different verbs.

The live phone has only pmi8998-charger/charge_control_end_threshold=99; there is no charge_control_start_threshold. The resulting narrow hardware behaviour is the observed 99→stop, 98→start twitch. The wanted policy is a wide, flexible hysteresis pair: stay below 100%, and permit a bounded agent decision to hold charging until a useful floor (30% was Casey's example) before re-enabling it.

Authority is settled: a human defines the safe envelope; an agent may propose or choose inside it only through step-up, and every move is recorded. No agent or QML surface writes charger sysfs directly. Until both thresholds and that authority path exist, the power sheet shows this as a draft rather than a switch that lies.

Why this exists

Charging has no owner. Every surface reads UPower directly and renders whatever it says, so the phone shows things that are true field-by-field and wrong as a sentence: it reaches 100%, the charger terminates, and the lock screen counts down from 94% with the cable still plugged in. Nothing is broken — that is charge-termination hysteresis, the charger resting between top-ups — but nothing in the stack knows enough to say so, because nothing owns the question.

Every other device signal was moved behind the state machine for exactly this reason (DEVICE-STATE-MACHINE.md): a sensor reports, the machine decides, one surface renders the decision. Battery is the last raw feed still going straight to the glass.

What is already true (verified live 2026-07-26, blueline)

  • The UPower fork already publishes ChargeType on the battery device by walking the supplier device link to pmi8998-charger — the fuel gauge (qcom-battery) has no charge_type at all. Live value reads back over D-Bus, emits-change.
  • The shell now renders it: services/ChargeRate.qml → "Fast charging", "Slow charging", plus pluggedNotCharging so the lock surface says "Charged 99%" instead of a countdown. That is the display fix. It is not the logic fix, and it is not in the state machine.
  • /sys/class/power_supply/pmi8998-charger/charge_control_end_threshold exists, is writable, and currently reads 99. A charge ceiling is available on this hardware today with no kernel work.
  • UPower nonetheless reports ChargeThresholdSupported: false, ChargeEndThreshold: 0. Upstream looks for the threshold attributes on the battery device; here they live on the charger — the identical supplier-walk bug the fork already fixed for charge_type.

What to build

  1. Threshold support in the fork, the same way charge_type was done. Reuse up_device_supply_get_supplier_charge_type_str's device-link walk for charge_control_{start,end}_threshold, so ChargeThresholdSupported becomes true and ChargeEndThreshold reads and writes through the standard UPower API. No new interface — the one upstream already defines, finally reporting the truth on this device. Write path needs a polkit action; the shell is the agent for it.

  2. Charging as evidence, not as a display feed. device_state already carries sensor evidence and health (§10). Charging state belongs there: plugged/unplugged, charge type, whether the charger is resting or working, and — the part no surface can currently derive — what the machine concludes, so "Charged", "Charging slowly", and "the cable is in but nothing is happening and that is now unusual" are one decision made once rather than three surfaces guessing. sensors_degraded (TASK-08 f) is the precedent for the shape.

  3. A charge policy, and who is allowed to set it. Casey's framing: the agent might one day decide to let the pack run to 30% first, or hold it at 80% overnight. The mechanism now exists (1). The decision that does not exist is authority: a ceiling that the device sets for battery health is not the same as a ceiling an agent chose, and neither is the same as one the user asked for out loud. SETTINGS-AUTHORITY.md already has the risk tiers and propose-vs-set split; charge policy is a natural step-up-gated setting under it. Decide the tier before writing the verb — a programmable ceiling that silently strands the phone at 30% before a trip is a worse failure than never having built it.

Do not

  • Add a fourth surface that reads UPower directly. That is the problem.
  • Ship a writable ceiling before (3) has an answer.
  • Treat "discharging while plugged in" as a fault to alarm about. It is the normal resting state of a full pack.

Connects to

DEVICE-STATE-MACHINE.md (§10 health, the evidence model), TASK-08 (the state manager and its two unsurfaced findings), SETTINGS-AUTHORITY.md (risk tiers), TASK-30 (whatever verbs this exposes carry the same five refusal codes), upower-charge-type-sdm845 memory (the supplier-walk fix this reuses).