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