- 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
22 KiB
| 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 |
|
Power Indication
Problem
The lock surface showed "Charging 1%" indefinitely on the Pixel 3. Two failures compounded:
- Wrong signal.
LockSurfaceHost.qmldecided between "Battery" and "Charging" usingUPower.onBatteryalone.onBatteryis a boolean that isfalsefor anything that isn't discharging — fully-charged, pending-charge, and unknown all read asfalse, so a topped-off pack printed "Charging N%" forever. - Wrong percentage. The pack reported ~1% because the battery
driver had just changed and UPower was computing
percentage = energy_now / energy_fullwith a junkenergy_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 0–100 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=CHARGING — regardless 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 belowbd_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 expectsCHARGE_*(or vice versa) makescharge_fullread near zero → capacity collapses to ~1%. The kernel doc's charge-vs-energy warning is exactly this. *_full<*_full_designon aged packs. A healthy pack settles at 95% and reportsFULLat 95%. This is correct — display "Full 95%", not "Charging 95%."- Stale
charge_full. QG relearns full capacity over full discharge/charge cycles; until thencapacitydrifts.
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— newUpDeviceChargeTypeenum (UNKNOWN/NONE/TRICKLE/FAST/STANDARD/ADAPTIVE/CUSTOM/LONGLIFE/BYPASS), mirroring kernelPOWER_SUPPLY_CHARGE_TYPE_*.libupower-glib/up-device.{c,h}— install acharge-typeGObject 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.c—up_device_supply_get_charge_type()reading sysfscharge_type, called next toget_state().- Percentage-trust guard. When
charge_full(orenergy_full) reads absurdly low vs*_design, mark the derivedpercentageuntrusted and expose aPercentageTrustedboolean so consumers suppress it. - Raw sysfs passthrough. Expose
current_now(asEnergyRatealready does),charge_full, andcharge_full_designso 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.ymlaarch64-artifactjob builds the fork per-arch (native x86_64 + cross-aarch64 against the agent substrate's sysroot) into install trees, thenmakepkgspackaging/arch/PKGBUILD.upower.prebuiltand folds the resultingupower-souveraine-*.pkg.tar.zstinto the same per-arch pacman database assouveraine(souveraine-aarch64.db/souveraine-x86_64.db). Oneedgerelease 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 = upowermust be set inpacman.confso 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 — seeThoughts.md. - Changing settings (toggling
inhibitSleepWhileCharging,wakeOnCharge, setting charge thresholds) is a settings change → step-up required, routed throughStepUpAuth(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 == Emptyorpercentageunder a threshold should color/warn, not just drop the verb. TheTouchLockSurface.qmlicon path already hasBattery.isLow; align the text path to the same threshold. - Charge-rate hint. When
ChargingandchangeRate > 0, a short+N WortimeToFull("full in 40m") is more honest than a frozen percentage.changeRateis 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 (logindsleepinhibit, held viasystemd-inhibitfd — the same mechanismSessionEventsuses for the suspend-before-lock protocol). Release it onDischarging. - This only prevents auto-suspend. An explicit poweroff/reboot from
the lock menu still proceeds — the inhibitor is
block, and the user action callsSession.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(kernelstatus=FULL). Do not infer it frompercentage == 100(which may never happen) or fromonBattery == false(the original bug). - Trickle/taper hint. Read
/sys/class/power_supply/battery/charge_typedirectly. When it readsTrickle(kernelPOWER_SUPPLY_CHARGE_TYPE_TRICKLE) whilestatus=Charging, the pack is in the constant-voltage tail — display "Almost full" or a+N Wwithcurrent_now. This is the real trickle signal, not a heuristic onchangeRate.PowerServicereads it via a small file watch; UPower never sees it. - Taper control. The Pixel
google_chargerdriver also exposesPOWER_SUPPLY_PROP_TAPER_CONTROLto 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 aConnectionson 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 existingIdle.inhibituser-toggle must always win.LockSurfaceHost.qml— display only; already readsstate.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-lockscreenpeek/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.displayDeviceproperties. - Expose:
isCharging,isFull,onBattery(re-derived fromstate, never from the buggyUPower.onBattery),changeRateW,percentage,healthPercent, plus a debouncedbeganCharging/stoppedChargingsignal 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):
- Type enum + property.
UpDeviceChargeTypeinup-types.h;charge-typeGObject property + accessor inup-device.{c,h};ChargeType(typeu) indbus/org.freedesktop.UPower.Device.xml. - Sysfs read.
up_device_supply_get_charge_type()insrc/linux/up-device-supply.c, called from the refresh path next toup_device_supply_get_state(). Map the kernel strings (Unknown/Trickle/Fast/Standard/Adaptive/Custom/Long life/Bypass/empty) to the enum. - Percentage-trust guard. In the supply refresh, compare
charge_fulltocharge_full_design; when absurdly low, set a new booleanpercentage-trusted= false and expose it as D-BusPercentageTrusted(typeb). - Raw passthrough. Ensure
current_now,charge_full,charge_full_designare exposed (some already are asEnergyRate/EnergyFull/EnergyFullDesign; expose any gap). - Package via Souveraine gitea CI. Fork mirrored to
gitea.wiuf.net/Fimeg/upower, consumed as submodulepackaging/upower-souveraine/.ci.ymlaarch64-artifactbuilds it per-arch and foldsupower-souveraineinto the per-arch pacman DB on theedgerelease. Validate the cross-aarch64 meson build on first run.IgnorePkg = upowerin the phone'spacman.conf.
Souveraine (this repo):
- Verify on Pixel 3. While fast-charging / near full / at full /
held-after-full, confirm via the forked
upower -dthat the driver reportscharge_typemoving toTrickleandstatereachingFullyCharged. Pins down which hints ship on this driver. PowerService.qml. Thin projection fromUPower.displayDevice(now includingchargeTypeandpercentageTrusted— no sysfs reads needed). Debounced charge edges (beganCharging/stoppedCharging).LockSurfaceHost.qml→PowerService. Replace directUPower.*reads with service properties; add low-battery styling and the+N W/ time-to-full / "Almost full" hint fromchargeType == Trickle.- Charge-aware idle policy in
IdleCoordinator. Sleep-inhibit while charging and wake-on-charge, config-gated, reusing theSessionEventsinhibitor fd pattern. UserIdle.inhibitalways wins. - Settings (step-up gated).
IdleConfig.qmltoggles forinhibitSleepWhileChargingandwakeOnCharge. Changing them is a settings change →StepUpAuthrequired; 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
FullyChargedpack, regardless of whatpercentagereads. - When the driver reports junk
energy_full, the surface shows a verb-lessN%(or suppresses the line) rather than "Charging 1%." PowerServiceis 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 reliablestatus=Fullall depend on the (recently changed) Pixel 3 battery driver reporting them correctly. The label map andpercentageTrustedguard degrade gracefully when they're absent — ifcharge_typeis unreadable or reportsNone,PowerServicesimply omits the trickle hint and the surface falls back to thestate-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
blockinhibitor holds suspend system-wide. IfPowerServicefails 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 anyDischargingtransition. - Edge storms. Debounce every charge-state edge.