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
ChargeTypeon the battery device by walking the supplier device link topmi8998-charger— the fuel gauge (qcom-battery) has nocharge_typeat all. Live value reads back over D-Bus,emits-change. - The shell now renders it:
services/ChargeRate.qml→ "Fast charging", "Slow charging", pluspluggedNotChargingso 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_thresholdexists, is writable, and currently reads99. 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 forcharge_type.
What to build
-
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 forcharge_control_{start,end}_threshold, soChargeThresholdSupportedbecomes true andChargeEndThresholdreads 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. -
Charging as evidence, not as a display feed.
device_statealready 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. -
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.mdalready 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).