add support for all UPower peripherals
C++ / fmt (pull_request) Successful in 3s
JS/TS / fmt (pull_request) Successful in 17s
JS/TS / lint (pull_request) Successful in 16s
Python / fmt (pull_request) Successful in 34s
Python / lint (pull_request) Successful in 35s
Python / typecheck (pull_request) Failing after 1m5s
C++ / build (pull_request) Successful in 1m36s
Python / test (pull_request) Successful in 1m26s
Rust / fmt (pull_request) Successful in 48s
Rust / build (pull_request) Successful in 1m41s
Rust / clippy (pull_request) Successful in 1m37s
Python / buildcheck (pull_request) Successful in 2m41s
C++ / clang-tidy (pull_request) Successful in 3m32s

This commit is contained in:
2026-07-13 00:48:05 +02:00
parent 8789d2d322
commit 4ee9bb3f12
5 changed files with 86 additions and 55 deletions
+6
View File
@@ -10,11 +10,13 @@ import qs.Helpers
Singleton { Singleton {
id: root id: root
readonly property list<var> allPeripherals: [...logiDevices, ...upowerDevices.filter(d => !Battery.logiDevices.some(l => l.nativePath === d.nativePath))]
readonly property real currentPerc: UPower.displayDevice.percentage readonly property real currentPerc: UPower.displayDevice.percentage
readonly property var deviceState: UPower.displayDevice.state readonly property var deviceState: UPower.displayDevice.state
readonly property string deviceStateString: UPowerDeviceState.toString(deviceState) readonly property string deviceStateString: UPowerDeviceState.toString(deviceState)
readonly property bool isLaptop: UPower.displayDevice.isLaptopBattery readonly property bool isLaptop: UPower.displayDevice.isLaptopBattery
readonly property alias logiDevices: adapter.devices readonly property alias logiDevices: adapter.devices
readonly property var lowestPeripheral: allPeripherals.reduce((lowest, current) => current.percentage < lowest.percentage ? current : lowest)
readonly property bool onBattery: UPower.onBattery readonly property bool onBattery: UPower.onBattery
readonly property bool ready: UPower.displayDevice.ready readonly property bool ready: UPower.displayDevice.ready
readonly property real timeToEmpty: UPower.displayDevice.timeToEmpty readonly property real timeToEmpty: UPower.displayDevice.timeToEmpty
@@ -39,6 +41,10 @@ Singleton {
}; };
} }
function startDaemon(): void {
Quickshell.execDetached(["zshell-cli", "battery", "daemon"]);
}
FileView { FileView {
id: fileView id: fileView
+2 -2
View File
@@ -24,7 +24,7 @@ Column {
} }
Repeater { Repeater {
model: [...Battery.logiDevices] // ...Battery.upowerDevices] model: Battery.allPeripherals
RowLayout { RowLayout {
id: layout id: layout
@@ -38,7 +38,7 @@ Column {
CustomText { CustomText {
Layout.fillWidth: true Layout.fillWidth: true
text: layout.modelData.name text: layout.modelData.model
} }
BatteryIcon { BatteryIcon {
+5 -3
View File
@@ -37,6 +37,8 @@ Item {
} }
} }
Component.onCompleted: Battery.startDaemon()
Loader { Loader {
id: batteryIconLoader id: batteryIconLoader
@@ -52,12 +54,12 @@ Item {
Loader { Loader {
id: peripheralIconLoader id: peripheralIconLoader
active: Battery.logiDevices.some(d => d.isPresent) && !batteryIconLoader.active active: Battery.lowestPeripheral.isPresent && !batteryIconLoader.active
anchors.centerIn: parent anchors.centerIn: parent
sourceComponent: BatteryIcon { sourceComponent: BatteryIcon {
devState: Battery.logiDevices[0].state devState: Battery.lowestPeripheral.state
percentage: Battery.logiDevices[0].percentage percentage: Battery.lowestPeripheral.percentage
} }
} }
+1 -1
View File
@@ -9,12 +9,12 @@ from typer._completion_classes import completion_init
from typer._completion_shared import _get_shell_name, install from typer._completion_shared import _get_shell_name, install
from zshell.subcommands import ( from zshell.subcommands import (
battery,
record, record,
scheme, scheme,
screenshot, screenshot,
shell, shell,
wallpaper, wallpaper,
battery,
) )
app = typer.Typer(name="zshell-cli", add_completion=False) app = typer.Typer(name="zshell-cli", add_completion=False)
+72 -49
View File
@@ -1,9 +1,11 @@
from __future__ import annotations from __future__ import annotations
import contextlib import contextlib
import fcntl
import json import json
import logging import logging
import os import os
import re
import signal import signal
import tempfile import tempfile
import time import time
@@ -17,14 +19,31 @@ app = typer.Typer(
) )
logger = logging.getLogger("zshell.battery") logger = logging.getLogger("zshell.battery")
DEFAULT_OUTPUT = Path.home() / ".cache" / "zshell" / "battery.json" DEFAULT_OUTPUT = Path.home() / ".cache" / "zshell" / "battery.json"
_RUNTIME_DIR = Path(
os.environ.get("XDG_RUNTIME_DIR") or (Path.home() / ".cache" / "zshell")
)
LOCK = _RUNTIME_DIR / "zshell-battery-daemon.lock"
def _acquire_singleton_lock(path: Path) -> int | None:
path.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(path, os.O_CREAT | os.O_RDWR, 0o644)
try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
os.close(fd)
return None
os.ftruncate(fd, 0)
os.write(fd, str(os.getpid()).encode())
return fd
@dataclass @dataclass
class DeviceBattery: class DeviceBattery:
name: str model: str
nativePath: str | None nativePath: str | None
serial: str | None
type: str | None type: str | None
wired: bool wired: bool
percentage: float | None percentage: float | None
@@ -33,6 +52,30 @@ class DeviceBattery:
isPresent: bool isPresent: bool
_SYSFS_POWER_SUPPLY = Path("/sys/class/power_supply")
def _normalize_serial(s: str) -> str:
return re.sub(r"[^0-9A-Za-z]", "", s).upper()
def _find_upower_native_path(serial: str | None) -> str | None:
if not serial or not _SYSFS_POWER_SUPPLY.is_dir():
return None
target = _normalize_serial(serial)
for entry in _SYSFS_POWER_SUPPLY.glob("hidpp_battery_*"):
try:
raw = (entry / "serial_number").read_text().strip()
except OSError:
logger.debug(
"couldn't read serial_number under %s", entry, exc_info=True
)
continue
if _normalize_serial(raw) == target:
return entry.name
return None
def _clean_json(path: Path) -> None: def _clean_json(path: Path) -> None:
_write_json_atomic(path, []) _write_json_atomic(path, [])
@@ -45,6 +88,7 @@ def _iter_open_devices():
"logitech_receiver isn't importable -- install it with `pip install solaar`." "logitech_receiver isn't importable -- install it with `pip install solaar`."
) from e ) from e
except ValueError as e: except ValueError as e:
# gi.require_version() raises ValueError if the GTK3 typelib isn't installed.
raise RuntimeError( raise RuntimeError(
"logitech_receiver failed to import because a GTK3 typelib is missing " "logitech_receiver failed to import because a GTK3 typelib is missing "
f"({e}). Install the same GTK3 + PyGObject packages Solaar's GUI needs " f"({e}). Install the same GTK3 + PyGObject packages Solaar's GUI needs "
@@ -59,7 +103,7 @@ def _iter_open_devices():
else: else:
d = receiver.create_receiver(base, dev_info) d = receiver.create_receiver(base, dev_info)
except OSError as e: except OSError as e:
if e.errno == 13: if e.errno == 13: # EACCES
logger.error( logger.error(
"permission denied opening %s -- check the Solaar udev rule " "permission denied opening %s -- check the Solaar udev rule "
"(rules.d/42-logitech-unify-permissions.rules) is installed " "(rules.d/42-logitech-unify-permissions.rules) is installed "
@@ -79,7 +123,7 @@ def _iter_open_devices():
if d.isDevice: if d.isDevice:
yield d yield d
else: else:
yield from d yield from d # a receiver: walk its currently paired devices
def _read_battery(dev) -> DeviceBattery | None: def _read_battery(dev) -> DeviceBattery | None:
@@ -100,30 +144,23 @@ def _read_battery(dev) -> DeviceBattery | None:
return None return None
percentage = battery.level / 100 if isinstance(battery.level, int) else None percentage = battery.level / 100 if isinstance(battery.level, int) else None
status = ( state = battery.status.name.lower() if battery.status is not None else None
"charging" serial = getattr(dev, "serial", None)
if battery.status.name.lower() == "recharging"
else "fullycharged"
if battery.status.name.lower() == "full"
else battery.status.name.lower()
if battery.status is not None
else None
)
return DeviceBattery( return DeviceBattery(
name=dev.name or dev.codename or "Unknown device", model=dev.name or dev.codename or "Unknown device",
nativePath=getattr(dev, "serial", None), nativePath=_find_upower_native_path(serial),
serial=serial,
type=str(dev.kind) if dev.kind is not None else None, type=str(dev.kind) if dev.kind is not None else None,
wired=dev.receiver is None, wired=dev.receiver is None,
percentage=percentage, percentage=percentage,
state=status, state=state,
charging=battery.charging(), charging=battery.charging(),
isPresent=battery.ok(), isPresent=battery.ok(),
) )
def poll_once() -> list[DeviceBattery]: def poll_once() -> list[DeviceBattery]:
"""Open every attached Logitech device once and read its battery. One-shot, synchronous."""
results = [] results = []
for dev in _iter_open_devices(): for dev in _iter_open_devices():
try: try:
@@ -154,37 +191,6 @@ def _write_json_atomic(path: Path, results: list[DeviceBattery]) -> None:
raise raise
@app.command()
def show(
as_json: bool = typer.Option(
False, "--json", help="Print raw JSON instead of a table."
),
):
try:
results = poll_once()
except RuntimeError as e:
typer.echo(f"error: {e}", err=True)
raise typer.Exit(code=1) from None
if not results:
typer.echo("No Logitech HID++ devices responded.", err=True)
raise typer.Exit(code=1)
if as_json:
typer.echo(json.dumps([asdict(r) for r in results], indent=2))
return
for r in results:
charge_str = (
f"{r.percentage}%" if r.percentage is not None else "unknown"
)
flags = [
f for f, on in (("charging", r.charging), ("wired", r.wired)) if on
]
flag_str = f" ({', '.join(flags)})" if flags else ""
typer.echo(f"{r.name}: {charge_str}{flag_str}")
@app.command() @app.command()
def daemon( def daemon(
interval: float = typer.Option( interval: float = typer.Option(
@@ -196,7 +202,22 @@ def daemon(
"-o", "-o",
help="Where to write the JSON status file.", help="Where to write the JSON status file.",
), ),
lock: Path = typer.Option(
LOCK, "--lock-file", help="Path to daemon lock file"
),
): ):
lock_fd = _acquire_singleton_lock(lock)
if lock_fd is None:
try:
holder_pid = lock.read_text().strip()
except OSError:
holder_pid = "unknown"
typer.echo(
f"error: a battery daemon is already running (pid: {holder_pid}, lock: {lock})",
err=True,
)
raise typer.Exit(code=1)
running = True running = True
def _stop(signum, frame): def _stop(signum, frame):
@@ -205,7 +226,9 @@ def daemon(
signal.signal(signal.SIGINT, _stop) signal.signal(signal.SIGINT, _stop)
signal.signal(signal.SIGTERM, _stop) signal.signal(signal.SIGTERM, _stop)
typer.echo(f"Polling every {interval}s, writing to {out}") typer.echo(f"Polling every {interval}s, writing to {out}")
try: try:
while running: while running:
try: try:
@@ -226,7 +249,7 @@ def daemon(
with contextlib.suppress(Exception): with contextlib.suppress(Exception):
_clean_json(out) _clean_json(out)
typer.echo("Stopped.") typer.echo("Stopped.")
if __name__ == "__main__": if __name__ == "__main__":