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
This commit is contained in:
parent
c91c3a7796
commit
63a39ab5d0
7 changed files with 602 additions and 2 deletions
|
|
@ -118,6 +118,71 @@ jobs:
|
||||||
cp "$BIN" souveraine-x86_64
|
cp "$BIN" souveraine-x86_64
|
||||||
sha256sum souveraine-aarch64 souveraine-x86_64 > souveraine-binaries.sha256
|
sha256sum souveraine-aarch64 souveraine-x86_64 > souveraine-binaries.sha256
|
||||||
|
|
||||||
|
- name: Build UPower fork (per-arch install trees)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$GITHUB_WORKSPACE/src/Souveraine"
|
||||||
|
# The fork is a git submodule of Souveraine. The runner checkout
|
||||||
|
# above is not recursive; clone it explicitly with the same LAN
|
||||||
|
# token used for tuie, pinned to the submodule's recorded commit.
|
||||||
|
UPOWER_URL=$(git config --file .gitmodules submodule."packaging/upower-souveraine".url)
|
||||||
|
UPOWER_SHA=$(git ls-tree HEAD packaging/upower-souveraine | awk '{print $3}')
|
||||||
|
git -c http.extraheader="Authorization: token ${{ secrets.LOCAL_GITEA_TOKEN }}" \
|
||||||
|
clone "$UPOWER_URL" "$GITHUB_WORKSPACE/src/upower"
|
||||||
|
git -C "$GITHUB_WORKSPACE/src/upower" checkout "$UPOWER_SHA"
|
||||||
|
|
||||||
|
SYSROOT="$HOME/aarch64-sysroot"
|
||||||
|
|
||||||
|
# --- x86_64: native build on the archdev host ---
|
||||||
|
(
|
||||||
|
cd "$GITHUB_WORKSPACE/src/upower"
|
||||||
|
meson setup build-x86_64 \
|
||||||
|
--prefix=/usr --sysconfdir=/etc --localstatedir=/var \
|
||||||
|
-Dsystemdsystemunitdir=/usr/lib/systemd/system
|
||||||
|
meson compile -C build-x86_64
|
||||||
|
DESTDIR="$GITHUB_WORKSPACE/src/upower/dest-x86_64" meson install -C build-x86_64
|
||||||
|
)
|
||||||
|
tar -C "$GITHUB_WORKSPACE/src/upower/dest-x86_64" \
|
||||||
|
-cf "$GITHUB_WORKSPACE/src/Souveraine/upower-tree-x86_64.tar" .
|
||||||
|
|
||||||
|
# --- aarch64: cross build against the same sysroot souveraine uses ---
|
||||||
|
CROSS="$GITHUB_WORKSPACE/src/upower/aarch64-cross.ini"
|
||||||
|
cat > "$CROSS" <<EOF
|
||||||
|
[binaries]
|
||||||
|
c = 'aarch64-linux-gnu-gcc'
|
||||||
|
cpp = 'aarch64-linux-gnu-g++'
|
||||||
|
ar = 'aarch64-linux-gnu-ar'
|
||||||
|
strip = 'aarch64-linux-gnu-strip'
|
||||||
|
pkgconfig = '$PWD/.cargo/aarch64-pkg-config'
|
||||||
|
[built-in options]
|
||||||
|
c_args = ['--sysroot=$SYSROOT']
|
||||||
|
cpp_args = ['--sysroot=$SYSROOT']
|
||||||
|
c_link_args = ['--sysroot=$SYSROOT']
|
||||||
|
cpp_link_args = ['--sysroot=$SYSROOT']
|
||||||
|
[host_machine]
|
||||||
|
system = 'linux'
|
||||||
|
cpu_family = 'aarch64'
|
||||||
|
cpu = 'aarch64'
|
||||||
|
endian = 'little'
|
||||||
|
EOF
|
||||||
|
(
|
||||||
|
cd "$GITHUB_WORKSPACE/src/upower"
|
||||||
|
PKG_CONFIG_ALLOW_CROSS=1 \
|
||||||
|
PKG_CONFIG_LIBDIR="$SYSROOT/usr/lib/pkgconfig" \
|
||||||
|
PKG_CONFIG_SYSROOT_DIR="$SYSROOT" \
|
||||||
|
meson setup build-aarch64 \
|
||||||
|
--prefix=/usr --sysconfdir=/etc --localstatedir=/var \
|
||||||
|
-Dsystemdsystemunitdir=/usr/lib/systemd/system \
|
||||||
|
--cross-file "$CROSS"
|
||||||
|
meson compile -C build-aarch64
|
||||||
|
DESTDIR="$GITHUB_WORKSPACE/src/upower/dest-aarch64" meson install -C build-aarch64
|
||||||
|
)
|
||||||
|
# Sanity: the aarch64 upowerd must actually be aarch64.
|
||||||
|
file "$GITHUB_WORKSPACE/src/upower/dest-aarch64/usr/lib/upowerd" \
|
||||||
|
| grep -q aarch64 || { echo "aarch64 upowerd is not aarch64" >&2; exit 1; }
|
||||||
|
tar -C "$GITHUB_WORKSPACE/src/upower/dest-aarch64" \
|
||||||
|
-cf "$GITHUB_WORKSPACE/src/Souveraine/upower-tree-aarch64.tar" .
|
||||||
|
|
||||||
- name: Package and sign pacman repository
|
- name: Package and sign pacman repository
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
@ -149,6 +214,33 @@ jobs:
|
||||||
gpg --batch --yes --local-user "$ARCHIVE_KEY" \
|
gpg --batch --yes --local-user "$ARCHIVE_KEY" \
|
||||||
--detach-sign "$ARCH_REPO/$(basename "$PKG")"
|
--detach-sign "$ARCH_REPO/$(basename "$PKG")"
|
||||||
|
|
||||||
|
# UPower fork package — built from packaging/upower-souveraine
|
||||||
|
# (git submodule of the Souveraine fork of upstream upower). Built
|
||||||
|
# natively per-arch by the "Build UPower fork" step above into
|
||||||
|
# upower-tree-$ARCH.tar, then folded into the SAME per-arch pacman
|
||||||
|
# database so the phone installs upower-souveraine from the same
|
||||||
|
# edge archive it already uses for souveraine.
|
||||||
|
UPOWER_WORK="$WORK/upower-$ARCH"
|
||||||
|
mkdir -p "$UPOWER_WORK"
|
||||||
|
cp "upower-tree-$ARCH.tar" "$UPOWER_WORK/upower-tree.tar"
|
||||||
|
cp packaging/upower-souveraine/dbus/org.freedesktop.UPower.Device.xml \
|
||||||
|
"$UPOWER_WORK/" 2>/dev/null \
|
||||||
|
|| cp "$GITHUB_WORKSPACE/src/upower/dbus/org.freedesktop.UPower.Device.xml" \
|
||||||
|
"$UPOWER_WORK/"
|
||||||
|
cp packaging/arch/PKGBUILD.upower.prebuilt "$UPOWER_WORK/PKGBUILD"
|
||||||
|
(
|
||||||
|
cd "$UPOWER_WORK"
|
||||||
|
export SOUVERAINE_PKGVER="$PKGVER"
|
||||||
|
export SOUVERAINE_PKGARCH="$ARCH"
|
||||||
|
CARCH="$ARCH" makepkg --nodeps --noconfirm --cleanbuild
|
||||||
|
)
|
||||||
|
UPKG=$(find "$UPOWER_WORK" -maxdepth 1 -name 'upower-souveraine-*.pkg.tar.zst' -print -quit)
|
||||||
|
test -n "$UPKG"
|
||||||
|
bsdtar -tf "$UPKG" | grep -qx 'usr/lib/upowerd'
|
||||||
|
cp "$UPKG" "$ARCH_REPO/"
|
||||||
|
gpg --batch --yes --local-user "$ARCHIVE_KEY" \
|
||||||
|
--detach-sign "$ARCH_REPO/$(basename "$UPKG")"
|
||||||
|
|
||||||
# Pacman databases are architecture-specific: one database cannot
|
# Pacman databases are architecture-specific: one database cannot
|
||||||
# retain two same-named, same-version packages for different
|
# retain two same-named, same-version packages for different
|
||||||
# architectures. Keep each database and its assets distinct.
|
# architectures. Keep each database and its assets distinct.
|
||||||
|
|
|
||||||
4
.gitmodules
vendored
Normal file
4
.gitmodules
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
[submodule "packaging/upower-souveraine"]
|
||||||
|
path = packaging/upower-souveraine
|
||||||
|
url = https://gitea.wiuf.net/Fimeg/upower.git
|
||||||
|
branch = souveraine/charge-type
|
||||||
|
|
@ -99,6 +99,7 @@
|
||||||
| `souveraine-subconscious-naming.md` | 2 | pending | Clarify naming: Ani's subconscious = Aster, Souveraine agents' subconscious = Souveraine-subconscious. |
|
| `souveraine-subconscious-naming.md` | 2 | pending | Clarify naming: Ani's subconscious = Aster, Souveraine agents' subconscious = Souveraine-subconscious. |
|
||||||
| `quickshell-surface-package.md` | delivery | **in progress** | Split core from QuickShell surface package; safe preview/adopt installer, no automatic ii overwrite. |
|
| `quickshell-surface-package.md` | delivery | **in progress** | Split core from QuickShell surface package; safe preview/adopt installer, no automatic ii overwrite. |
|
||||||
| `device-specific-quickshell-profiles.md` | delivery | scoped | Phone and laptop share the agent channel but have distinct QuickShell bodies, manifests, and packages. |
|
| `device-specific-quickshell-profiles.md` | delivery | scoped | Phone and laptop share the agent channel but have distinct QuickShell bodies, manifests, and packages. |
|
||||||
|
| `power-indication.md` | delivery | scoped | Charging state, honest full detection (state not onBattery — kills "Charging 1% forever"), a PowerService singleton, and charge-aware idle policy (sleep-inhibit while charging, wake-on-charge). |
|
||||||
| `footer-cleanup.md` | 3.0 | **landed** | Removed S-Ret indicator, scroll hint, posture label. Footer: Esc menu · Tab · tool hint · conv ID · ctx%. |
|
| `footer-cleanup.md` | 3.0 | **landed** | Removed S-Ret indicator, scroll hint, posture label. Footer: Esc menu · Tab · tool hint · conv ID · ctx%. |
|
||||||
| `ui-styling-cleanup.md` | 3.0 | pending | UI styling polish for cockpit/subconscious/thinking boxes — sentences run together, needs formatting and visual cleanup. |
|
| `ui-styling-cleanup.md` | 3.0 | pending | UI styling polish for cockpit/subconscious/thinking boxes — sentences run together, needs formatting and visual cleanup. |
|
||||||
| `resume-timestamp-indicator.md` | 3.0 | pending | Show "User resumed conversation at HH:MM" when returning after hours of absence. Also show duration ("4 hours, 12 minutes later"). Ambient sense rendered as separate timestamp marker, not inline. |
|
| `resume-timestamp-indicator.md` | 3.0 | pending | Show "User resumed conversation at HH:MM" when returning after hours of absence. Also show duration ("4 hours, 12 minutes later"). Ambient sense rendered as separate timestamp marker, not inline. |
|
||||||
|
|
|
||||||
448
docs/tasks/power-indication.md
Normal file
448
docs/tasks/power-indication.md
Normal file
|
|
@ -0,0 +1,448 @@
|
||||||
|
---
|
||||||
|
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.
|
||||||
35
packaging/arch/PKGBUILD.upower.prebuilt
Normal file
35
packaging/arch/PKGBUILD.upower.prebuilt
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
# CI package recipe for the Souveraine UPower fork.
|
||||||
|
#
|
||||||
|
# The archdev runner builds upower-souveraine natively per arch (meson +
|
||||||
|
# ninja), then makepkg assembles this package from the prebuilt tree. The
|
||||||
|
# package is folded into the same souveraine-{arch} pacman database as the
|
||||||
|
# souveraine agent substrate, so the phone installs it from the same edge
|
||||||
|
# release archive it already consumes.
|
||||||
|
#
|
||||||
|
# Provides/conflicts upower so installing this package replaces the stock
|
||||||
|
# distro upower. /etc/pacman.conf must list `upower` under IgnorePkg so a
|
||||||
|
# later stock upower from the distro can't clobber the fork. (IgnorePkg only
|
||||||
|
# skips automatic upgrades; an explicit -S upower-souveraine still wins.)
|
||||||
|
pkgname=upower-souveraine
|
||||||
|
pkgver="${SOUVERAINE_PKGVER:?CI must set SOUVERAINE_PKGVER}"
|
||||||
|
pkgrel=1
|
||||||
|
pkgdesc="UPower fork (Souveraine) — exposes ChargeType and PercentageTrusted over D-Bus"
|
||||||
|
arch=("${SOUVERAINE_PKGARCH:?CI must set SOUVERAINE_PKGARCH}")
|
||||||
|
url="https://gitea.wiuf.net/Fimeg/upower"
|
||||||
|
license=('GPL-2.0-or-later')
|
||||||
|
provides=("upower=1.91.3" 'libupower-glib.so=3-64')
|
||||||
|
conflicts=('upower')
|
||||||
|
depends=('glib2' 'glibc' 'libgcc' 'libgudev' 'libimobiledevice' 'libplist' 'polkit')
|
||||||
|
options=('!strip' '!libtool')
|
||||||
|
# Prebuilt install tree produced by `meson install --destdir` in CI, plus the
|
||||||
|
# introspection XML (so quickshell's UPower module can introspect the new
|
||||||
|
# ChargeType/PercentageTrusted properties).
|
||||||
|
source=('upower-tree.tar' 'org.freedesktop.UPower.Device.xml')
|
||||||
|
sha256sums=('SKIP' 'SKIP')
|
||||||
|
|
||||||
|
package() {
|
||||||
|
bsdtar -xf "$srcdir/upower-tree.tar" -C "$pkgdir"
|
||||||
|
# Keep the canonical interface XML discoverable for introspection.
|
||||||
|
install -Dm644 "$srcdir/org.freedesktop.UPower.Device.xml" \
|
||||||
|
"$pkgdir/usr/share/dbus-1/interfaces/org.freedesktop.UPower.Device.xml"
|
||||||
|
}
|
||||||
1
packaging/upower-souveraine
Submodule
1
packaging/upower-souveraine
Submodule
|
|
@ -0,0 +1 @@
|
||||||
|
Subproject commit f18b1a476c7946bce44b989d7d040d810bd49d50
|
||||||
|
|
@ -53,8 +53,27 @@ Item {
|
||||||
Layout.alignment: Qt.AlignHCenter
|
Layout.alignment: Qt.AlignHCenter
|
||||||
visible: LockContentPolicy.batteryVisible && UPower.displayDevice?.isPresent
|
visible: LockContentPolicy.batteryVisible && UPower.displayDevice?.isPresent
|
||||||
text: {
|
text: {
|
||||||
const pct = Math.round(UPower.displayDevice?.percentage ?? 0);
|
const dev = UPower.displayDevice;
|
||||||
return (UPower.onBattery ? "Battery " : "Charging ") + pct + "%";
|
const pct = Math.round(dev?.percentage ?? 0);
|
||||||
|
// State is authoritative — don't infer "Charging" from
|
||||||
|
// onBattery alone. onBattery is false for fully-charged,
|
||||||
|
// pending-charge, and unknown, which is what made the line
|
||||||
|
// read "Charging N%" forever on a topped-off pack. The
|
||||||
|
// recent driver change also leaves energy-full unreliable,
|
||||||
|
// so a stale percentage can read ~0%; never call a
|
||||||
|
// fully-charged pack "1%".
|
||||||
|
switch (dev?.state ?? UPowerDeviceState.Unknown) {
|
||||||
|
case UPowerDeviceState.FullyCharged:
|
||||||
|
return "Full " + pct + "%";
|
||||||
|
case UPowerDeviceState.Charging:
|
||||||
|
return "Charging " + pct + "%";
|
||||||
|
case UPowerDeviceState.Discharging:
|
||||||
|
return "Battery " + pct + "%";
|
||||||
|
default:
|
||||||
|
// Unknown / Empty / Pending* — show the number
|
||||||
|
// without a misleading verb.
|
||||||
|
return pct + "%";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
color: "#d9ffffff"
|
color: "#d9ffffff"
|
||||||
font.pixelSize: 14
|
font.pixelSize: 14
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue