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

448 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
task_id: souveraine-surface-004
title: Power indication — charging state, full detection, and charge-aware idle policy
status: scoped
priority: medium
phase: delivery
created: 2026-07-17
references:
- 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=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 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.c`** — `up_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 `makepkg`s
`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 change**
**step-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):**
6. **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.
7. **`PowerService.qml`.** Thin projection from
`UPower.displayDevice` (now including `chargeType` and
`percentageTrusted` — no sysfs reads needed). Debounced charge edges
(`beganCharging`/`stoppedCharging`).
8. **`LockSurfaceHost.qml``PowerService`.** Replace direct
`UPower.*` reads with service properties; add low-battery styling
and the `+N W` / time-to-full / "Almost full" hint from
`chargeType == Trickle`.
9. **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.
10. **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.