Watch
1
0
Fork
You've already forked SouveraineOS
0
SouveraineOS/docs/tasks/33-battery-as-device-state.md
Fimeg 3cdd36ff2f state: charge is a sensor source, and the laptop is moving to viewtop
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.
2026-08-15 11:45:28 -04:00

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 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. Landed 2026-08-15. The first build (ChargeEvidence reading sysfs from sessiond's clock, with conclusion() 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::Charge enters through sensor_input, gets EvidenceSeen/SourceHealth/last-seen from the gate for free, and the machine's conclude_charge() makes the one decision ("Charged", "Charging slowly", "the cable is in but nothing is happening and that is now unusual") once. souveraine-sensord reports 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).

  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).