Skip to content

Mirror the Mac battery into the guest as a real power_supply device - #220

Open
NimbleAINinja wants to merge 17 commits into
omacom:mainfrom
NimbleAINinja:host-battery-bridge
Open

NimbleAINinja wants to merge 17 commits into
omacom:mainfrom
NimbleAINinja:host-battery-bridge

Conversation

@NimbleAINinja

Copy link
Copy Markdown

What this does

Mirrors the Mac's battery into the Omarchy guest as a real power_supply
device, so the Quattro bar and every other UPower consumer show it with no
configuration. The guest behaves like the laptop it is running on.

Omarchy Quattro's bar is Quickshell, which reads UPower, which reads
/sys/class/power_supply. The VM exposes no power supply, so the widget is
simply absent today.

How

A fifth virtio-serial port carries newline-delimited JSON, host to guest only.
The guest may send exactly one request line ({"type":"refresh"}) on start,
because a guest opening the port is not observable on the host's socket
chardev; nothing else it sends can influence Mac power state.

IOKit power sources -> NativeBatteryBridge (Swift, in the helper)
                          |  dev.tryomarchy.battery (virtserialport, nr=5)
                          v
                       omarchy-native-battery-bridge (root system service)
                          |  one whole-snapshot line per write()
                          v
                       try_omarchy_battery.ko (DKMS)
                          v
                       /sys/class/power_supply/{BAT0,ADP0}  ->  UPower -> bar

Each snapshot is complete rather than a delta, so a restarted agent is never
half-informed, and one write() is one consistent state: consumers cannot
observe a new percentage beside a stale charging flag.

Design decisions worth reviewing

  • A purpose-built DKMS module, not upstream's test_power. The pinned
    linux-aarch64 ships no test_power.ko, and test_power names its devices
    test_battery/test_ac, carries a USB supply and wakeup timer we do not
    want, and its per-parameter writes allow torn state on every update. DKMS is
    already proven in this image by v4l2loopback.
  • BAT0 / ADP0, the conventional names, so tools that special-case them
    (acpi, fastfetch, status scripts) work. Honesty lives in the properties
    instead: manufacturer Apple, model Mac Battery.
  • Warn only. The UPower drop-in sets CriticalPowerAction=Ignore and
    AllowRiskyCriticalPowerAction=true. Both are required: upower 1.91.4
    classifies Ignore as risky and silently falls back to
    HybridSleep -> Hibernate -> PowerOff without the second key, which would
    suspend the VM on a low Mac battery. The Mac's own power management stays the
    only authority. Please do not "simplify" that file.
  • Always on, no start-menu surface. It needs no macOS permission and
    exposes nothing sensitive. On a Mac with no internal battery the guest gets
    only mains, so the bar shows nothing — correct for a desktop.
  • Root-only port (MODE="0600", no GROUP), following the authentication
    rule's posture rather than the clipboard's, since no user process needs it.

Existing guests

App updates retain persistent guest disks, so an existing VM does not receive
the module from an app update — though it does receive the port immediately,
since QEMU's command line comes from the host. No factory reset is needed:
the factory image already carries dkms, gcc, make, kmod and matching
headers, so guest/scripts/install-battery-into-existing-guest.sh builds the
module in place from files staged through the shared Mac folder. No network
fetch. Because it goes through DKMS, the pacman hook rebuilds it on a guest
kernel upgrade.

Verification

make test and guest/test pass. Beyond that, exercised on real hardware on
two machines, both with guest kernel 7.2.2-2:

  • Retrofit installed via the documented procedure; module built by DKMS;
    BAT0/ADP0 present; agent service active.
  • Live tracking confirmed: unplug propagated to sysfs and UPower's
    DisplayDevice within 5 seconds, replug likewise.
  • On a genuinely discharging battery (22%), icon-name became
    battery-low-symbolic and warning-level was correctly none (UPower's
    threshold is 10%).
  • Clean teardown: on ACPI powerdown the bridge exited 0, the supervisor made
    its one restart attempt, and the restart correctly refused to attach to a
    non-QEMU pid — identical behaviour to the camera and authentication bridges.
  • The module was also compiled against the pinned linux-aarch64-headers 7.2.6-1 in an ARM64 container, and the register script was run end to end
    against a staged root.

Tests added: guest agent unit tests, a host-testable harness driving the real
tob_parse source (the cross-language seam, where two token tables must
agree), Swift tests for snapshot mapping and the exact wire contract, a
nr=5 assertion in the launcher contract test, and verify.py checks for the
unit, the root-only rule, both UPower keys, and the DKMS build line.

Known limitation: no time remaining in the bar

The time estimates reach sysfs correctly and live — measured at
time_to_empty=3180, then 3600 at 22% — but upower 1.91.4 does not read
time_to_empty_avg, and a capacity-only device gives it no energy or power
values to derive from, so it reports 0 Wh / 0 W and prints no time field.
The bar therefore shows level, charge state and AC presence only; anything
reading sysfs directly does see the estimates. The docs state this plainly.

The fix I would recommend is publishing real energy figures read from IOKit's
AppleSmartBattery (mAh x mV = uWh, mA x mV = uW), so UPower derives a
true time from true inputs, rather than synthesizing a fake capacity scale.
That is deliberately not in this PR.

Note for anyone building this

make guest currently fails on main for reasons unrelated to this branch:
Arch Linux ARM has moved hyprland to 0.56.2-3, which needs
libaquamarine.so=14-64, while the ABI-pinned aquamarine 0.14.0-2 provides
so=13-64. A coordinated hyprland/aquamarine pin bump is a prerequisite for
building an image containing this feature, and the feature itself is
unaffected — the failure occurs before its registration step runs.

🤖 Generated with Claude Code

NimbleAINinja and others added 17 commits September 15, 2026 22:57
…-battery

tob_exit now removes the state attribute (draining in-flight writers)
before tearing down the power supplies and platform device, closing a
use-after-free/leak window a concurrent state_store could hit during
module unload. state_store now rolls tob_state.present back to false
when BAT0 registration fails, so a failed write does not leave state
claiming a phantom battery until the next differing snapshot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V11NHWxRgQdLPoyjxwVULA
Package the in-repo try-omarchy-battery DKMS sources as a reproducible
pacman package (try-omarchy-battery-dkms 1.0.0-1) and register it in the
factory build so DKMS compiles try_omarchy_battery.ko against the pinned
kernel and stages the archive in the local repository (archive count 6 -> 7).

Also updates macos/run-qemu-gpu.sh's exact supplyChain key set so VM launch
validation accepts the new tryOmarchyBattery pin, and fixes
guest/tests/verify.py's existing archive-count/name assertion that the new
seventh archive would otherwise break.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V11NHWxRgQdLPoyjxwVULA
…ript

register-native-battery-module.sh canonicalized --root/--work with pwd -P but
only checked the unsafe-root case statement before resolution, letting a
symlinked --root that resolves to /usr, /etc, etc. slip through. Mirror
register-patched-hyprland.sh: re-run the unsafe-root case after
canonicalization, reject a --work inside the staged root, and reject
newlines in either path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V11NHWxRgQdLPoyjxwVULA
…ttery wire contract

Review round 1: the refresh path now dispatches onto stateQueue
synchronously (via a new sendSync/sendOnQueue split) so a guest flooding
refresh requests without draining its side is throttled by the blocking
socket write instead of queuing unbounded work; the heartbeat still calls
sendOnQueue directly to avoid a self-deadlock. An oversized guest line is
now dropped by a new GuestLineReader instead of throwing and terminating
the bridge, matching the "ignore everything but a well-formed refresh"
wire contract. Also pins the encoded snapshot's key set in a test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V11NHWxRgQdLPoyjxwVULA
Existing guest disks keep their old rootfs across app updates, so they
never receive the DKMS battery module from a factory image rebuild —
only the virtio port arrives immediately via the host's QEMU launch
command. This script lets a user install the eight staged battery
files, build the module with the guest's already-present dkms/gcc/
kernel-headers toolchain, and enable the bridge service without a
factory reset or any network fetch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V11NHWxRgQdLPoyjxwVULA
The prior check only proved three literal strings existed somewhere in
the retrofit script; it would still pass if a destination path pointed
at the wrong file, or if the service were enabled before the DKMS
module was built. Since this script runs as root and modifies system
paths, it can never run in CI, so this static check is the only
automated guard it gets. Add assertions that all eight real
destination paths appear in the script, that the DKMS install precedes
the systemctl enable --now, and that set -euo pipefail is present so a
mid-script failure cannot continue into a half-installed state.

Also hardcode the three module-source destination paths under
/usr/src/try-omarchy-battery-1.0.0/ instead of building them through
variable interpolation, guarded by an assertion that the literal
matches $version — consistent with the existing dkms install literal
and needed so the new check can see the destination paths as text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V11NHWxRgQdLPoyjxwVULA
Adds docs/host-battery.md covering the JSON snapshot protocol, the
sysfs state-line grammar, why the UPower critical-battery drop-in needs
both CriticalPowerAction=Ignore and AllowRiskyCriticalPowerAction=true,
and the no-reset retrofit procedure for existing guests. Cross-links it
from a new architecture.md paragraph and adds a README highlight line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V11NHWxRgQdLPoyjxwVULA
DKMS always passes KERNELRELEASE on its make command line, which selects the
Makefile's kbuild branch — the branch that defines only obj-m and has no
`modules` target. Both `dkms install` paths, factory image and retrofit, failed
with "No rule to make target 'modules'". Drive kbuild directly from MAKE[0]
instead, and drop the CLEAN line dkms 3.4.3 reports as deprecated and which
carried the identical trap.

Reproduced in an ARM64 container with the pinned dkms 3.4.3-2 and
linux-aarch64-headers 7.2.6-1 running the real register script against a
staged root: failing before, try_omarchy_battery.ko built after.

Also correct the Makefile comment, which stated the opposite of what DKMS
does, and mark the manual targets .PHONY.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V11NHWxRgQdLPoyjxwVULA
pacstrap tears down its devtmpfs before build.sh reaches the register scripts,
and this is the first transaction whose hooks actually run programs in the
staged root. With $root/dev empty, the hooks' >/dev/null created a 39-byte
regular file at $root/dev/null — unowned by pacman, content dependent on hook
output, shipped in the rootfs — and mkinitcpio aborted with
"/proc must be mounted!".

Mount proc, sysfs, devtmpfs and a run tmpfs around the single pacman -U, the
way arch-chroot does for every other chroot invocation in the build, and
unmount them before the verification queries. $root/dev is empty again
afterwards and the initcpio hook completes.

Also add the pacman -Qkk integrity check the three sibling register scripts
run; this script hand-generates its .MTREE, so it is exactly where that check
pays.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V11NHWxRgQdLPoyjxwVULA
Restart=always with RestartSec=1 and StartLimitIntervalSec=0 meant a guest
that took the app update but not the module retrofit respawned Python once a
second forever, writing a journal line each time, because the agent exits 1
immediately when the sysfs state attribute is missing.

Condition the unit on that attribute alongside the virtio port. systemd
re-evaluates conditions on every start attempt, so a later retrofit still
brings the unit up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V11NHWxRgQdLPoyjxwVULA
The time estimates are plumbed end to end into sysfs as time_to_empty_avg and
time_to_full_avg, but the pinned upower 1.91.4 reads neither property, and the
capacity-only device gives it no energy, charge or power values to derive an
estimate from. Percentage, charge state and AC presence do reach the bar;
tools reading sysfs directly see the times. Say so in the README highlight,
docs/host-battery.md and the spec's fidelity decision.

Also update the "module absent" failure mode in both tables to the unit
condition that now covers it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V11NHWxRgQdLPoyjxwVULA
Nothing in the suite executed anything under guest/native-module/, and
tob_parse plus the agent's format_state_line carry two independently written
copies of the status-token table. Add a userspace harness that slices
struct tob_state, the status table and tob_parse verbatim out of the shipped
module source, compiles them against shims for the six kernel helpers the
parser uses, and drives them from guest/tests/test_tob_parse.py: both line
forms the agent emits, the disconnect line, every status token, an unknown
key, a missing '=', each missing required key, out-of-range capacity, and -1
times. The extraction fails loudly rather than testing a stale copy.

Pin MAKE[0]'s shape in verify.py so the DKMS build defect cannot recur, pin
the unit's new module condition, add the "Is Present": false case the Swift
snapshot predicate needs, and drop two pieces of dead test code: an
unconditionally empty byte slice and a re-assertion of two strings an earlier
check already covers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V11NHWxRgQdLPoyjxwVULA
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant