The r506 build had ChargeEvidence reading sysfs from sessiond's clock with conclusion() inside the driver type — the rule violated four times in one object. Recorded here: the ruling (§4), the task reconciliation, and the bearer audit under the same rule. Also the corrected cutover answer: the laptop is the test bed, the chooser is phone-shaped, mechanism undecided.
5.8 KiB
TASK 33 — Battery and charging belong to the device state machine
Status: (2) shape landed 2026-08-15 — charge is SensorSource::Charge,
reported by souveraine-sensord, interpreted by the machine
(conclude_charge, one decision). (1) and (3) remain open. 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. Landed 2026-08-15. The first build (
ChargeEvidencereading sysfs from sessiond's clock, withconclusion()inside the driver type) was reviewed out: the decider must not probe, and a driver must not interpret. Charge is a sensor source like any other —SensorSource::Chargeenters throughsensor_input, getsEvidenceSeen/SourceHealth/last-seen from the gate for free, and the machine'sconclude_charge()makes the one decision ("Charged", "Charging slowly", "the cable is in but nothing is happening and that is now unusual") once.souveraine-sensordreports it on a 30-second poll, change-driven; the 5-second sysfs probe on sessiond's clock is gone.DEVICE-STATE-MACHINE.md§4 carries the full ruling, including the bearer audit (same shape, same correction owed). -
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).