Watch
1
0
Fork
You've already forked souveraine
0
souveraine/docs/tasks/power-indication.md
Fimeg 63a39ab5d0 upower fork: submodule, prebuilt packaging, CI build, lock-surface charge state
- packaging/upower-souveraine submodule pinned at fork head (f18b1a4,
  souveraine/charge-type): ChargeType + PercentageTrusted on Device
- PKGBUILD.upower.prebuilt: prebuilt meson install tree, provides/
  conflicts upower, replaces stock (IgnorePkg=upower on the phone)
- ci.yml: build the fork per-arch against the aarch64 sysroot, assert
  binary arch, fold into the souveraine-{arch} pacman db
- LockSurfaceHost: charge line driven by device state, not onBattery —
  no more eternal 'Charging N%' on a topped-off pack
- docs/tasks/power-indication.md force-added past the docs/ gitignore
2026-07-17 16:33:37 -04:00

22 KiB
Raw Blame History

task_id title status priority phase created references
souveraine-surface-004 Power indication — charging state, full detection, and charge-aware idle policy scoped medium delivery 2026-07-17
surfaces/quickshell/modules/souveraine/lock/LockSurfaceHost.qml
surfaces/quickshell/services/IdleCoordinator.qml
surfaces/quickshell/modules/settings/IdleConfig.qml
surfaces/quickshell/services/StepUpAuth.qml
src/api/auth.rs
docs/tasks/sensor-driven-lockscreen.md
docs/Thoughts.md
/home/casey/Projects/upower (fork — based on v1.91.3, branch souveraine/charge-type)
gitea.wiuf.net/Fimeg/upower (fork mirror)
packaging/upower-souveraine/ (git submodule in this repo)
packaging/arch/PKGBUILD.upower.prebuilt (CI package recipe)
.gitea/workflows/ci.yml (aarch64-artifact builds + folds into pacman DB)
/home/casey/Projects/PostMarketOS-Blueline/references/sdm845-mainline-7.1-dev-PMAPORTS-PINNED-UPSTREAM/Documentation/power/power_supply_class.rst
/home/casey/Projects/PostMarketOS-Blueline/references/sdm845-mainline-7.1-dev-PMAPORTS-PINNED-UPSTREAM/include/linux/power_supply.h
/home/casey/Projects/PostMarketOS-Blueline/references/msm-google-crosshatch/drivers/power/supply/google_charger.c
/home/casey/Projects/PostMarketOS-Blueline/references/msm-google-crosshatch/drivers/power/supply/qcom/qpnp-qg.c

Power Indication

Problem

The lock surface showed "Charging 1%" indefinitely on the Pixel 3. Two failures compounded:

  1. Wrong signal. LockSurfaceHost.qml decided between "Battery" and "Charging" using UPower.onBattery alone. onBattery is a boolean that is false for anything that isn't discharging — fully-charged, pending-charge, and unknown all read as false, so a topped-off pack printed "Charging N%" forever.
  2. Wrong percentage. The pack reported ~1% because the battery driver had just changed and UPower was computing percentage = energy_now / energy_full with a junk energy_full. A healthy-looking battery read near zero.

The display fix (read UPowerDevice.state, not onBattery) is landed. This task scopes the rest of the power story: detecting end-of-charge honestly, exposing it to the surface, and letting the session idle graph react to charging state.

What the substrate actually exposes

There are three layers; each models "trickle" differently.

Layer 1 — kernel power_supply class (sysfs, authoritative)

The kernel contract is Documentation/power/power_supply_class.rst. Two attributes carry distinct information:

status (POWER_SUPPLY_STATUS_*, in include/linux/power_supply.h):

Value Meaning
UNKNOWN (0) Driver couldn't report.
CHARGING (1) Plugged in, taking energy.
DISCHARGING (2) On battery.
NOT_CHARGING (3) Plugged in but not adding energy (threshold, fault, full-and-held).
FULL (4) Topped off.

charge_type (POWER_SUPPLY_CHARGE_TYPE_*) — this is where the kernel models trickle as a first-class value, distinct from status:

Value Meaning
UNKNOWN / NONE Not applicable (discharging, or no charger).
TRICKLE Slow speed.
FAST Fast speed.
STANDARD / ADAPTIVE / CUSTOM / LONGLIFE / BYPASS Other regimes.

So: a phone in the constant-voltage tail is status=CHARGING, charge_type=TRICKLE (or FAST early). The kernel does distinguish them. The charge/energy unit trap lives here too: the class doc spells out that CHARGE_* is µAh and ENERGY_* is µWh and CAPACITY is the derived 0100 percent — and warns these are easy to confuse. That confusion is the substrate cause of the 1% bug.

Read raw, no daemon involved:

cat /sys/class/power_supply/battery/status        # Charging|Full|...
cat /sys/class/power_supply/battery/charge_type   # Trickle|Fast|...
cat /sys/class/power_supply/battery/capacity      # 0..100
cat /sys/class/power_supply/battery/charge_full   # µAh
cat /sys/class/power_supply/battery/charge_full_design
cat /sys/class/power_supply/battery/current_now   # signed µA (rate)

Layer 2 — UPower (D-Bus, what quickshell reads)

Stock UPower collapses layer 1. Its state (org.freedesktop.UPower.Device) maps status but throws away charge_type — the entire trickle/fast distinction is invisible:

UPower state Meaning
Unknown (0) status=UNKNOWN.
Charging (1) status=CHARGINGregardless of charge_type.
Discharging (2) status=DISCHARGING.
Empty (3) Critically low.
FullyCharged (4) status=FULL.
PendingCharge (5) Plugged in, not yet charging (≈ NOT_CHARGING + expecting to).
PendingDischarge (6) Unplugged, not yet discharging.

Quickshell mirrors this as UPowerDeviceState on UPower.displayDevice.state, with percentage, energy, energyCapacity, changeRate (signed W), timeToFull, timeToEmpty, healthPercentage, iconName, isPresent.

This gap is fixed by the fork (see "The UPower fork" below), not worked around. Stock UPower exposes neither charge_type nor honest percentage-trust; rather than read sysfs in the shell, we fork UPower so it becomes the single authority for everything the kernel exposes.

Layer 3 — the Pixel 3 driver (Qualcomm QG + google_charger)

The actual drivers (from the pinned msm-google-crosshatch 4.9 tree and the sdm845 mainline 7.1 port):

  • drivers/power/supply/google_charger.c — Pixel's charger shim. Owns the taper algorithm (POWER_SUPPLY_PROP_TAPER_CONTROL, POWER_SUPPLY_PROP_CHARGER_STATUS_FAST, struct taper_wa_struct) and a battery-droop recharge scheme (google,bd-recharge-voltage, google,bd-recharge-soc, google,bd-trigger-voltage). This is the real "trickle / top-off / recharge" logic: it terminates fast charge, holds at full, then recharges when the pack droops below bd_recharge_voltage / bd_recharge_soc. Those thresholds are device-tree properties, not sysfs knobs the shell can set.
  • drivers/power/supply/qcom/qpnp-qg.c + qg-soc.c — the Qualcomm Gauge fuel gauge (QG). Owns SoC computation and capacity reporting.
  • drivers/power/supply/qcom/step-chg-jeita.h — JEITA step charging (temperature-conditioned current/voltage steps).

What this means for detection: on the Pixel 3 the kernel really does move through FAST → TRICKLE/taper → FULL → NOT_CHARGING → (recharge). The status + charge_type pair in sysfs is a faithful view of it. UPower can only see status, which is why "Charging N% forever" was so easy to write — UPower said Charging the whole taper + hold tail.

The percentage trap (substrate)

capacity (and UPower's percentage) is derived, not measured: energy_now / energy_full (µWh) or charge_now / charge_full (µAh), scaled to 100. Any of these make it lie:

  • Unit confusion after a driver change (the 1% bug). A driver that reports ENERGY_* where the class expects CHARGE_* (or vice versa) makes charge_full read near zero → capacity collapses to ~1%. The kernel doc's charge-vs-energy warning is exactly this.
  • *_full < *_full_design on aged packs. A healthy pack settles at 95% and reports FULL at 95%. This is correct — display "Full 95%", not "Charging 95%."
  • Stale charge_full. QG relearns full capacity over full discharge/charge cycles; until then capacity drifts.

If charge_full ≪ charge_full_design and status=FULL, the pack is aged or threshold-clamped — surface as Full. If charge_full ≈ 0, the driver is reporting wrong units and capacity is meaningless until the driver is fixed (do not display a percentage at all).

The UPower fork

Stock UPower doesn't expose charge_type and trusts energy_full blindly. We fork it at ~/Projects/upower (based on v1.91.3, the exact installed version) rather than read sysfs in the shell. UPower becomes the single authority for power truth; the shell reads one well-typed D-Bus surface instead of poking sysfs from QML.

Fork changes:

  • libupower-glib/up-types.h — new UpDeviceChargeType enum (UNKNOWN/NONE/TRICKLE/FAST/STANDARD/ADAPTIVE/CUSTOM/LONGLIFE/BYPASS), mirroring kernel POWER_SUPPLY_CHARGE_TYPE_*.
  • libupower-glib/up-device.{c,h} — install a charge-type GObject property, accessor, and refresh wiring.
  • dbus/org.freedesktop.UPower.Device.xml — new <property name="ChargeType" type="u" access="read"> so D-Bus clients (quickshell) see it and PropertiesChanged emits on it.
  • src/linux/up-device-supply.cup_device_supply_get_charge_type() reading sysfs charge_type, called next to get_state().
  • Percentage-trust guard. When charge_full (or energy_full) reads absurdly low vs *_design, mark the derived percentage untrusted and expose a PercentageTrusted boolean so consumers suppress it.
  • Raw sysfs passthrough. Expose current_now (as EnergyRate already does), charge_full, and charge_full_design so consumers never need to touch sysfs for anything.

The fork lives at ~/Projects/upower (branch souveraine/charge-type, based on v1.91.3), mirrored to gitea.wiuf.net/Fimeg/upower and consumed by SouveraineOS as a git submodule at packaging/upower-souveraine/. It is packaged by the same gitea CI that builds the souveraine agent substrate:

  • .gitea/workflows/ci.yml aarch64-artifact job builds the fork per-arch (native x86_64 + cross-aarch64 against the agent substrate's sysroot) into install trees, then makepkgs packaging/arch/PKGBUILD.upower.prebuilt and folds the resulting upower-souveraine-*.pkg.tar.zst into the same per-arch pacman database as souveraine (souveraine-aarch64.db / souveraine-x86_64.db). One edge release archive serves both.
  • The phone installs it from its existing [souveraine-__ARCH__] repo: pacman -S upower-souveraine.
  • provides=("upower=1.91.3") + conflicts=('upower') make it replace stock upower. IgnorePkg = upower must be set in pacman.conf so a later stock upower from the distro can't clobber the fork (IgnorePkg only skips automatic upgrades; an explicit install still wins).

The fork compiles clean and passes its self-test on the laptop. The cross-aarch64 meson build in CI is the path most likely to need a first-run adjustment on archdev — it shares the agent substrate's ~/aarch64-sysroot, but meson's cross-file vs cargo's env-var approach to that sysroot differ slightly; validate on the first green run.

Read vs write authority (IPC contract)

The forked daemon is a read-only status authority. It reports truth; it does not gate actions. The trust boundary for acting on power state stays in Souveraine, exactly where it already lives:

  • Reading status (the lock glance, the agent seeing the power line, PowerService.state, charge_type, percentage) is ambient tier, no auth. The IPC call (session.state() today; power.state() to come) works read-only for any caller, agent included. This is the same ambient tier as clock and date on the lock — see Thoughts.md.
  • Changing settings (toggling inhibitSleepWhileCharging, wakeOnCharge, setting charge thresholds) is a settings changestep-up required, routed through StepUpAuth (PamContext). The agent may propose a change but may not apply it without a freshly minted step-up token. This is the souveraine-capability-tiers ambient/personal/stepUp split applied to power.

The daemon never mints tokens and never knows about capabilities. Token minting is the existing per-agent bearer-token system (src/api/auth.rs, souv_<uuid>) for the memfs write path, and the existing step-up flow (StepUpAuth.qml) for session-tier elevation. Power inherits both unchanged — it just reads from a daemon that now tells the truth.

Display policy (partially landed)

LockSurfaceHost.qml now maps state to a label:

state Label
FullyCharged Full N%
Charging Charging N%
Discharging Battery N%
Unknown / Empty / Pending* N% (no lying verb)

This kills "Charging 1% forever." Remaining display work:

  • Low-battery styling. state == Empty or percentage under a threshold should color/warn, not just drop the verb. The TouchLockSurface.qml icon path already has Battery.isLow; align the text path to the same threshold.
  • Charge-rate hint. When Charging and changeRate > 0, a short +N W or timeToFull ("full in 40m") is more honest than a frozen percentage. changeRate is signed; show magnitude only while charging.
  • Health. healthPercentage (capacity vs design) is available and worth surfacing somewhere low-priority (settings, not the lock). It is the honest version of "why does Full say 95%."

Charge-aware idle policy

The session idle graph today (IdleCoordinator, Active → Dimmed → LockRequested → LockSecure → Suspending → Asleep → Waking) is purely time- and activity-driven. It has no input from power state. Three behaviors are worth adding, all opt-in and gated on config so they never override an explicit user inhibitor:

1. Don't suspend while charging (unless asked)

The most defensible charge-aware rule. A plugged-in phone is usually near the user and near power; suspending aggressively serves no purpose. When state in {Charging, FullyCharged, PendingCharge}:

  • Raise a block-style sleep inhibitor (logind sleep inhibit, held via systemd-inhibit fd — the same mechanism SessionEvents uses for the suspend-before-lock protocol). Release it on Discharging.
  • This only prevents auto-suspend. An explicit poweroff/reboot from the lock menu still proceeds — the inhibitor is block, and the user action calls Session.poweroff() directly.

Config: lock.idle.inhibitSleepWhileCharging: bool (default false on laptop form factors, candidate default true on phone).

2. Wake-on-charge / charge-event wake

On the Discharging → Charging edge (charger plugged), optionally fire a surface wake so the screen comes up to show "Charging." This is the involuntary-glance counterpart to lift-to-wake in sensor-driven-lockscreen. Implemented by watching UPower.displayDevice.state for the edge and requesting the same wake the idle coordinator's Waking state uses.

Risk: a flaky charger connection oscillates the edge and strobes the screen. Debounce on a ~2s stable window before waking.

Config: lock.idle.wakeOnCharge: bool.

3. Trickle / taper awareness

UPower hides it (still Charging), but the kernel exposes it via charge_type (sysfs) — so this is read, not derived:

  • Honest full detection. Display reaches "Full" exactly on state == FullyCharged (kernel status=FULL). Do not infer it from percentage == 100 (which may never happen) or from onBattery == false (the original bug).
  • Trickle/taper hint. Read /sys/class/power_supply/battery/charge_type directly. When it reads Trickle (kernel POWER_SUPPLY_CHARGE_TYPE_TRICKLE) while status=Charging, the pack is in the constant-voltage tail — display "Almost full" or a +N W with current_now. This is the real trickle signal, not a heuristic on changeRate. PowerService reads it via a small file watch; UPower never sees it.
  • Taper control. The Pixel google_charger driver also exposes POWER_SUPPLY_PROP_TAPER_CONTROL to the kernel, and its recharge thresholds are device-tree (google,bd-recharge-*). Those are not shell-tunable; they're documented here so the behavior is understandable, not so we change it.

Charge-threshold / "stop at 80%" tuning (charge_control_end_threshold) is out of scope — a driver concern, not a shell concern. If it lands it shows up as status=NOT_CHARGING (UPower PendingCharge) at the threshold, which the label map already handles as a verb-less N%.

Integration points

  • IdleCoordinator — the only consumer of charging state for policy. Add a Connections on a power service (see below) that raises/releases the sleep inhibitor and fires wake on the charge edge. Keep all policy behind config flags; the coordinator's existing Idle.inhibit user-toggle must always win.
  • LockSurfaceHost.qml — display only; already reads state.
  • SessionEvents.qml — owns the logind sleep-inhibitor fd pattern (suspend-before-lock). Reuse its mechanism for the charge-driven inhibitor rather than inventing a second fd path.
  • sensor-driven-lockscreen peek/login modes — orthogonal. Charge state is ambient-tier and shows in peek; charge wake transitions peek→login only if combined with explicit user intent (it shouldn't dump the PIN pad on screen just because you plugged in — surface the glance, not the credential gate).

A small power service

UPower.onBattery / displayDevice is fine for the lock glance, but charge-aware policy wants derived edges and debouncing that don't belong inline in IdleCoordinator. Pattern matches SessionEvents.qml (a thin singleton that owns a signal source and exposes derived state):

services/PowerService.qml:

  • Bind UPower.displayDevice properties.
  • Expose: isCharging, isFull, onBattery (re-derived from state, never from the buggy UPower.onBattery), changeRateW, percentage, healthPercent, plus a debounced beganCharging / stoppedCharging signal for edge consumers.
  • No policy here — just the honest projection, like the sensor service in sensor-driven-lockscreen is a thin D-Bus bridge.

This also gives one place to fix the percentage-trap: clamp/guard percentage when energy_full reads as junk, and expose a percentageTrusted: bool so the surface can fall back to a verb-less display when the number is meaningless.

Work breakdown

Fork (~/Projects/upower, built on archdev):

  1. Type enum + property. UpDeviceChargeType in up-types.h; charge-type GObject property + accessor in up-device.{c,h}; ChargeType (type u) in dbus/org.freedesktop.UPower.Device.xml.
  2. Sysfs read. up_device_supply_get_charge_type() in src/linux/up-device-supply.c, called from the refresh path next to up_device_supply_get_state(). Map the kernel strings (Unknown/Trickle/Fast/Standard/Adaptive/Custom/Long life /Bypass/empty) to the enum.
  3. Percentage-trust guard. In the supply refresh, compare charge_full to charge_full_design; when absurdly low, set a new boolean percentage-trusted = false and expose it as D-Bus PercentageTrusted (type b).
  4. Raw passthrough. Ensure current_now, charge_full, charge_full_design are exposed (some already are as EnergyRate/ EnergyFull/EnergyFullDesign; expose any gap).
  5. Package via Souveraine gitea CI. Fork mirrored to gitea.wiuf.net/Fimeg/upower, consumed as submodule packaging/upower-souveraine/. ci.yml aarch64-artifact builds it per-arch and folds upower-souveraine into the per-arch pacman DB on the edge release. Validate the cross-aarch64 meson build on first run. IgnorePkg = upower in the phone's pacman.conf.

Souveraine (this repo):

  1. Verify on Pixel 3. While fast-charging / near full / at full / held-after-full, confirm via the forked upower -d that the driver reports charge_type moving to Trickle and state reaching FullyCharged. Pins down which hints ship on this driver.
  2. PowerService.qml. Thin projection from UPower.displayDevice (now including chargeType and percentageTrusted — no sysfs reads needed). Debounced charge edges (beganCharging/stoppedCharging).
  3. LockSurfaceHost.qmlPowerService. Replace direct UPower.* reads with service properties; add low-battery styling and the +N W / time-to-full / "Almost full" hint from chargeType == Trickle.
  4. Charge-aware idle policy in IdleCoordinator. Sleep-inhibit while charging and wake-on-charge, config-gated, reusing the SessionEvents inhibitor fd pattern. User Idle.inhibit always wins.
  5. Settings (step-up gated). IdleConfig.qml toggles for inhibitSleepWhileCharging and wakeOnCharge. Changing them is a settings change → StepUpAuth required; the IPC write call carries a freshly minted step-up token. Reading current values stays ambient.

Acceptance

  • Lock surface never prints "Charging N%" on a FullyCharged pack, regardless of what percentage reads.
  • When the driver reports junk energy_full, the surface shows a verb-less N% (or suppresses the line) rather than "Charging 1%."
  • PowerService is the single source of power truth for surfaces and the idle coordinator.
  • Auto-suspend is held off while charging when configured, and a flaky charger connection does not strobe the screen.
  • No charge-derived policy overrides an explicit user inhibitor.

Risk

  • Driver-dependent signals. charge_type=Trickle, current_now, and a reliable status=Full all depend on the (recently changed) Pixel 3 battery driver reporting them correctly. The label map and percentageTrusted guard degrade gracefully when they're absent — if charge_type is unreadable or reports None, PowerService simply omits the trickle hint and the surface falls back to the state-based label. Don't ship the trickle hint as a guaranteed feature; ship it as "shown when the kernel exposes it."
  • Sleep inhibitor semantics. A block inhibitor holds suspend system-wide. If PowerService fails to release it on unplug (crash, race), the phone stops auto-suspending. Release must be fail-safe — release-on-unplug, release-on-shell-exit, release on any Discharging transition.
  • Edge storms. Debounce every charge-state edge.