Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Stage 6a — UPower Battery Service Adapter

Why this stage exists

docs/status-services.md already noted that command/sysfs polling in the demo was “intentionally a test fixture, not the toolkit’s service architecture,” and that “optional reusable provider crates may be designed later from demonstrated consumer needs.” This stage makes that real for one service: battery.

It also settles how service adapters are packaged going forward. Rather than feature-gating D-Bus support inside the core patin crate, Patin became a Cargo workspace: each adapter is its own crate under crates/, so a consumer that wants only the toolkit never compiles zbus or any other adapter dependency.

Workspace conversion

The root Cargo.toml gained [workspace] and [workspace.package] sections alongside its existing [package] table; the root package remains an implicit workspace member, so src/, examples/, and Cargo.lock did not move. edition, rust-version, license, and publish are now inherited via .workspace = true on both crates to avoid drift.

cargo build/test/clippy only operate on the current directory’s package unless --workspace is passed, so .github/workflows/ci.yml and the README’s verification commands now pass --workspace to test and clippy (fmt --all already covered every member).

Core crate: patin::service

src/service.rs adds one trait, exported from src/lib.rs:

#![allow(unused)]
fn main() {
pub trait Provider {
    type Snapshot: Clone + PartialEq;
    fn poll(&mut self) -> Self::Snapshot;
}
}

Construction is deliberately left out of the trait: opening a D-Bus connection (or whatever a future adapter needs) can fail in ways specific to that adapter, so each one exposes its own fallible new().

New crate: patin-service-upower

crates/patin-service-upower implements Provider for BatteryProvider against UPower’s synthetic DisplayDevice — the aggregate device UPower maintains specifically for status bars and shells, which avoids reimplementing the sysfs version’s “pick the best battery” logic. It reads the Percentage and State D-Bus properties over zbus’s blocking API on the existing once-per-second Shell::update tick; no calloop or platform.rs changes were needed for this poll-based adapter.

zbus = "=5.18.0" is used with default-features = false and an explicit feature list (async-executor, async-fs, async-io, async-lock, async-process, async-task, blocking, blocking-api) — resolved by trial build against the crate’s actual feature graph, since blocking-api alone does not transitively pull the executor it needs. This set excludes tokio; zbus implements the D-Bus wire protocol itself, so no libdbus system package is required.

A missing system bus, missing UPower service, or missing device all degrade to None via .ok()? short-circuiting, the same failure philosophy as the demo’s sysfs and wpctl/pactl fixtures. This also means unit tests (run without a real system bus reachable, or with one reachable but no UPower registered on it) see None deterministically rather than failing.

Demo integration

examples/demo_bar/services.rs’s SystemStatus now holds a BatteryProvider field instead of a power_supply_root path; the sysfs read_battery function and its formatting are gone, replaced by format_battery over BatterySnapshot. SystemStatus::poll became &mut self (both call sites were already in &mut self contexts). Volume and brightness are unchanged. Root Cargo.toml depends on patin-service-upower under [dev-dependencies], since only the example uses it.

Verification

Verified on 29 July 2026:

$ cargo fmt --all -- --check
(no output)

$ cargo test --workspace --all-targets
running 5 tests (patin)
... all ok
running 4 tests (examples/demo_bar)
... all ok
running 1 test (patin-service-upower)
test tests::poll_without_a_system_bus_returns_none ... ok

$ cargo clippy --workspace --all-targets --all-features -- -D warnings
Finished `dev` profile [unoptimized + debuginfo] target(s), no warnings

$ git diff --check
(no output, exit 0)

cargo run --example demo_bar (timeout 5s) in this sandbox, which has a Wayland compositor and a session/system D-Bus socket but no upowerd registered:

demo_bar: status providers: battery=unavailable, volume=VOL MUTE, brightness=BRI 71%
patin: connected; waiting for the compositor to configure the bar

Confirmed independently with dbus-send --system ... org.freedesktop.UPower ... that the service is genuinely ServiceUnknown here, so unavailable is the adapter degrading correctly, not a bug.

$ mdbook build
 INFO Book building has started
 INFO Running the html backend
 INFO HTML book written to `/home/vdzee/proj/patin/book`

FP5 end-to-end confirmation

Verified on the FP5 the same day (postmarketOS edge, aarch64, upowerd genuinely active). The working tree was copied over with tar piped over SSH (no rsync on-device), built natively with cargo build --release --locked --example demo_bar (~4 minutes cold, fetching zbus and its transitive dependencies), then installed and launched via the existing scripts/install-demo-user.sh:

demo_bar: status providers: battery=BAT 69%, volume=VOL 8%, brightness=BRI 70%
patin: connected; waiting for the compositor to configure the bar

A real percentage from D-Bus/UPower, not unavailable — the adapter works end to end against the target device’s actual UPower.

Backgrounding it as a plain setsid nohup ... & SSH command was not enough to keep it alive: the phone’s PAM session tears down its whole cgroup on SSH disconnect regardless of setsid. It stayed running only once launched as a transient systemd user unit instead:

systemd-run --user --unit=patin-demo --collect \
  --setenv=XDG_RUNTIME_DIR=/run/user/10000 \
  --setenv=WAYLAND_DISPLAY=wayland-0 \
  -- /home/sn3rt/.local/bin/patin