From 8789d2d32279dd09c59d0e6311a19985fe3083aa Mon Sep 17 00:00:00 2001 From: zach Date: Sun, 12 Jul 2026 17:47:25 +0200 Subject: [PATCH] initial addition of battery display --- Helpers/Battery.qml | 47 +++-- Modules/SysTray/Common/BatteryIcon.qml | 84 ++++++++ Modules/SysTray/Popouts/UPowerPopout.qml | 27 +++ Modules/SysTray/Widgets/UPowerWidget.qml | 93 ++------- cli/src/zshell/__init__.py | 10 +- cli/src/zshell/subcommands/battery.py | 233 +++++++++++++++++++++++ 6 files changed, 405 insertions(+), 89 deletions(-) create mode 100644 Modules/SysTray/Common/BatteryIcon.qml create mode 100644 cli/src/zshell/subcommands/battery.py diff --git a/Helpers/Battery.qml b/Helpers/Battery.qml index 1ffb61f..21ac558 100644 --- a/Helpers/Battery.qml +++ b/Helpers/Battery.qml @@ -1,34 +1,57 @@ pragma Singleton import Quickshell +import Quickshell.Io import Quickshell.Services.UPower import qs.Config +import qs.Paths +import qs.Helpers Singleton { id: root - readonly property var colors: { - if (deviceState === UPowerDeviceState.Charging || deviceState === UPowerDeviceState.FullyCharged) + readonly property real currentPerc: UPower.displayDevice.percentage + readonly property var deviceState: UPower.displayDevice.state + readonly property string deviceStateString: UPowerDeviceState.toString(deviceState) + readonly property bool isLaptop: UPower.displayDevice.isLaptopBattery + readonly property alias logiDevices: adapter.devices + readonly property bool onBattery: UPower.onBattery + readonly property bool ready: UPower.displayDevice.ready + readonly property real timeToEmpty: UPower.displayDevice.timeToEmpty + readonly property real timeToFull: UPower.displayDevice.timeToFull + readonly property list upowerDevices: UPower.devices.values + + function getColors(percentage: real, state: string): var { + if (state === "charging" || state === "full" || state === "recharging") return { fg: DynamicColors.swapRG(DynamicColors.palette.m3error), bg: DynamicColors.swapRG(DynamicColors.palette.m3onError) }; - else if (currentPerc <= 0.2) + else if (percentage <= 0.2) return { fg: DynamicColors.palette.m3error, bg: DynamicColors.palette.m3onError }; else return { - fg: DynamicColors.palette.m3onSurface, - bg: DynamicColors.palette.m3surface + fg: DynamicColors.palette.m3tertiary, + bg: DynamicColors.palette.m3onTertiary }; } - readonly property real currentPerc: UPower.displayDevice.percentage - readonly property var deviceState: UPower.displayDevice.state - readonly property bool isLaptop: UPower.displayDevice.isLaptopBattery - readonly property bool onBattery: UPower.onBattery - readonly property bool ready: UPower.displayDevice.ready - readonly property real timeToEmpty: UPower.displayDevice.timeToEmpty - readonly property real timeToFull: UPower.displayDevice.timeToFull + + FileView { + id: fileView + + path: `${Paths.cache}/battery.json` + watchChanges: true + + onFileChanged: reload() + + JsonAdapter { + id: adapter + + property list devices: [] + property real updated: 0.0 + } + } } diff --git a/Modules/SysTray/Common/BatteryIcon.qml b/Modules/SysTray/Common/BatteryIcon.qml new file mode 100644 index 0000000..24c53b7 --- /dev/null +++ b/Modules/SysTray/Common/BatteryIcon.qml @@ -0,0 +1,84 @@ +import QtQuick +import qs.Config +import qs.Components +import qs.Helpers + +Row { + id: root + + property real batHeight: Appearance.padding.smaller * 2 + property real batWidth: Appearance.padding.larger * 2 + required property string devState + property real nubHeight: Appearance.padding.small + property real nubWidth: 2 + required property real percentage + property real radius: Appearance.rounding.smallest / 2 + + spacing: 1 + + CustomRect { + id: track + + anchors.verticalCenter: parent.verticalCenter + color: Battery.getColors(root.percentage, root.devState).bg + height: root.batHeight + radius: root.radius + width: root.batWidth + + CustomText { + color: Battery.getColors(root.percentage, root.devState).fg + font.pointSize: Appearance.font.size.larger / 1.5 + font.weight: 800 + height: track.height + horizontalAlignment: Text.AlignHCenter + text: Math.round(root.percentage * 100) + verticalAlignment: Text.AlignVCenter + width: track.width + } + + Item { + clip: true + width: parent.width * root.percentage + + anchors { + bottom: parent.bottom + left: parent.left + top: parent.top + } + + CustomRect { + id: fill + + color: Battery.getColors(root.percentage, root.devState).fg + height: track.height + radius: track.radius + width: track.width + + CustomText { + id: batteryLabel + + clip: true + color: Battery.getColors(root.percentage, root.devState).bg + font.pointSize: Appearance.font.size.larger / 1.5 + font.weight: 800 + height: track.height + horizontalAlignment: Text.AlignHCenter + text: Math.round(root.percentage * 100) + verticalAlignment: Text.AlignVCenter + width: track.width + } + } + } + } + + CustomRect { + id: nub + + anchors.verticalCenter: parent.verticalCenter + bottomRightRadius: 20 + color: root.percentage < 0.99 ? track.color : fill.color + height: root.nubHeight + topRightRadius: 20 + width: root.nubWidth + } +} diff --git a/Modules/SysTray/Popouts/UPowerPopout.qml b/Modules/SysTray/Popouts/UPowerPopout.qml index 4ed3c7c..2ce8187 100644 --- a/Modules/SysTray/Popouts/UPowerPopout.qml +++ b/Modules/SysTray/Popouts/UPowerPopout.qml @@ -1,7 +1,9 @@ pragma ComponentBehavior: Bound import QtQuick +import QtQuick.Layouts import Quickshell.Services.UPower +import qs.Modules.SysTray.Common import qs.Config import qs.Helpers import qs.Components @@ -21,6 +23,31 @@ Column { } } + Repeater { + model: [...Battery.logiDevices] // ...Battery.upowerDevices] + + RowLayout { + id: layout + + required property var modelData + + anchors.left: parent.left + anchors.leftMargin: Appearance.padding.normal + anchors.right: parent.right + anchors.rightMargin: Appearance.padding.normal + + CustomText { + Layout.fillWidth: true + text: layout.modelData.name + } + + BatteryIcon { + devState: layout.modelData.state + percentage: layout.modelData.percentage + } + } + } + CustomText { function formatSeconds(s: int, fallback: string): string { const day = Math.floor(s / 86400); diff --git a/Modules/SysTray/Widgets/UPowerWidget.qml b/Modules/SysTray/Widgets/UPowerWidget.qml index 0c4b4c8..8f45c6c 100644 --- a/Modules/SysTray/Widgets/UPowerWidget.qml +++ b/Modules/SysTray/Widgets/UPowerWidget.qml @@ -1,6 +1,7 @@ import Quickshell.Services.UPower import QtQuick import QtQuick.Layouts +import qs.Modules.SysTray.Common import qs.Components import qs.Config import qs.Helpers @@ -8,12 +9,13 @@ import qs.Helpers Item { id: root + readonly property Item currentItem: batteryIconLoader?.item ?? peripheralIconLoader?.item ?? upowerIconLoader.item readonly property bool shouldBeActive: Config.bar.tray.showPower Layout.preferredHeight: shouldBeActive ? implicitHeight : 0 Layout.preferredWidth: shouldBeActive ? implicitWidth : 0 - implicitHeight: Battery.isLaptop ? batteryIconLoader.item.implicitHeight : upowerIconLoader.item.implicitHeight - implicitWidth: Battery.isLaptop ? batteryIconLoader.item.implicitWidth : upowerIconLoader.item.implicitWidth + implicitHeight: currentItem?.implicitHeight ?? 0 + implicitWidth: currentItem?.implicitWidth ?? 0 opacity: shouldBeActive ? 1 : 0 scale: shouldBeActive ? 1 : 0 visible: opacity > 0 @@ -41,89 +43,28 @@ Item { active: Battery.isLaptop anchors.centerIn: parent - sourceComponent: Row { - id: batteryIcon + sourceComponent: BatteryIcon { + devState: Battery.deviceStateString + percentage: Battery.currentPerc + } + } - property real batHeight: 16 - property real batWidth: 30 - property real nubHeight: 6 - property real nubWidth: 2 - property real radius: Appearance.rounding.smallest / 2 + Loader { + id: peripheralIconLoader - spacing: 1 + active: Battery.logiDevices.some(d => d.isPresent) && !batteryIconLoader.active + anchors.centerIn: parent - CustomRect { - id: track - - anchors.verticalCenter: parent.verticalCenter - color: Battery.colors.bg - height: batteryIcon.batHeight - radius: batteryIcon.radius - width: batteryIcon.batWidth - - CustomText { - color: Battery.colors.fg - font.pointSize: Appearance.font.size.larger / 1.5 - font.weight: 800 - height: track.height - horizontalAlignment: Text.AlignHCenter - text: Math.round(Battery.currentPerc * 100) - verticalAlignment: Text.AlignVCenter - width: track.width - } - - Item { - clip: true - width: parent.width * Battery.currentPerc - - anchors { - bottom: parent.bottom - left: parent.left - top: parent.top - } - - CustomRect { - id: fill - - color: Battery.colors.fg - height: track.height - radius: track.radius - width: track.width - - CustomText { - id: batteryLabel - - clip: true - color: Battery.colors.bg - font.pointSize: 7.5 - font.weight: 800 - height: track.height - horizontalAlignment: Text.AlignHCenter - text: Math.round(Battery.currentPerc * 100) - verticalAlignment: Text.AlignVCenter - width: track.width - } - } - } - } - - CustomRect { - id: nub - - anchors.verticalCenter: parent.verticalCenter - bottomRightRadius: 20 - color: Battery.currentPerc < 0.99 ? track.color : fill.color - height: batteryIcon.nubHeight - topRightRadius: 20 - width: batteryIcon.nubWidth - } + sourceComponent: BatteryIcon { + devState: Battery.logiDevices[0].state + percentage: Battery.logiDevices[0].percentage } } Loader { id: upowerIconLoader - active: !Battery.isLaptop + active: !batteryIconLoader.active && !peripheralIconLoader.active anchors.centerIn: parent sourceComponent: MaterialIcon { diff --git a/cli/src/zshell/__init__.py b/cli/src/zshell/__init__.py index 613ee3e..17e2ac5 100644 --- a/cli/src/zshell/__init__.py +++ b/cli/src/zshell/__init__.py @@ -8,7 +8,14 @@ import typer from typer._completion_classes import completion_init from typer._completion_shared import _get_shell_name, install -from zshell.subcommands import record, scheme, screenshot, shell, wallpaper +from zshell.subcommands import ( + record, + scheme, + screenshot, + shell, + wallpaper, + battery, +) app = typer.Typer(name="zshell-cli", add_completion=False) @@ -17,6 +24,7 @@ app.add_typer(scheme.app, name="scheme") app.add_typer(screenshot.app, name="screenshot") app.add_typer(wallpaper.app, name="wallpaper") app.add_typer(record.app, name="record") +app.add_typer(battery.app, name="battery") def _completion_installed() -> bool: diff --git a/cli/src/zshell/subcommands/battery.py b/cli/src/zshell/subcommands/battery.py new file mode 100644 index 0000000..ddde1cb --- /dev/null +++ b/cli/src/zshell/subcommands/battery.py @@ -0,0 +1,233 @@ +from __future__ import annotations + +import contextlib +import json +import logging +import os +import signal +import tempfile +import time +from dataclasses import asdict, dataclass +from pathlib import Path + +import typer + +app = typer.Typer( + help="Read live battery status from Logitech HID++ peripherals." +) + +logger = logging.getLogger("zshell.battery") + +DEFAULT_OUTPUT = Path.home() / ".cache" / "zshell" / "battery.json" + + +@dataclass +class DeviceBattery: + name: str + nativePath: str | None + type: str | None + wired: bool + percentage: float | None + state: str | None + charging: bool + isPresent: bool + + +def _clean_json(path: Path) -> None: + _write_json_atomic(path, []) + + +def _iter_open_devices(): + try: + from logitech_receiver import base, device, receiver + except ImportError as e: + raise RuntimeError( + "logitech_receiver isn't importable -- install it with `pip install solaar`." + ) from e + except ValueError as e: + raise RuntimeError( + "logitech_receiver failed to import because a GTK3 typelib is missing " + f"({e}). Install the same GTK3 + PyGObject packages Solaar's GUI needs " + "(e.g. python3-gi + gir1.2-gtk-3.0 on Debian/Ubuntu, or the gtk3/" + "python3-gobject equivalents on your distro)." + ) from e + + for dev_info in base.receivers_and_devices(): + try: + if dev_info.isDevice: + d = device.create_device(base, dev_info) + else: + d = receiver.create_receiver(base, dev_info) + except OSError as e: + if e.errno == 13: + logger.error( + "permission denied opening %s -- check the Solaar udev rule " + "(rules.d/42-logitech-unify-permissions.rules) is installed " + "and you're in the right group", + dev_info.path, + ) + else: + logger.exception("failed opening %s", dev_info) + continue + except Exception: + logger.exception("failed opening %s", dev_info) + continue + + if d is None: + continue + + if d.isDevice: + yield d + else: + yield from d + + +def _read_battery(dev) -> DeviceBattery | None: + try: + if not dev.ping(): + return None + except Exception: + logger.debug("ping failed for %s", dev, exc_info=True) + return None + + try: + battery = dev.battery() + except Exception: + logger.debug("battery() failed for %s", dev, exc_info=True) + return None + + if battery is None: + return None + + percentage = battery.level / 100 if isinstance(battery.level, int) else None + status = ( + "charging" + 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( + name=dev.name or dev.codename or "Unknown device", + nativePath=getattr(dev, "serial", None), + type=str(dev.kind) if dev.kind is not None else None, + wired=dev.receiver is None, + percentage=percentage, + state=status, + charging=battery.charging(), + isPresent=battery.ok(), + ) + + +def poll_once() -> list[DeviceBattery]: + """Open every attached Logitech device once and read its battery. One-shot, synchronous.""" + results = [] + for dev in _iter_open_devices(): + try: + info = _read_battery(dev) + if info is not None: + results.append(info) + finally: + with contextlib.suppress(Exception): + dev.close() + return results + + +def _write_json_atomic(path: Path, results: list[DeviceBattery]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + payload = { + "updated": time.time(), + "devices": [asdict(r) for r in results], + } + fd, tmp_path = tempfile.mkstemp( + dir=path.parent, prefix=".battery-", suffix=".tmp" + ) + try: + with os.fdopen(fd, "w") as f: + json.dump(payload, f, indent=2) + os.replace(tmp_path, path) # atomic rename on the same filesystem + except Exception: + Path(tmp_path).unlink(missing_ok=True) + 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() +def daemon( + interval: float = typer.Option( + 15.0, "--interval", "-i", help="Seconds between polls." + ), + out: Path = typer.Option( + DEFAULT_OUTPUT, + "--out", + "-o", + help="Where to write the JSON status file.", + ), +): + running = True + + def _stop(signum, frame): + nonlocal running + running = False + + signal.signal(signal.SIGINT, _stop) + signal.signal(signal.SIGTERM, _stop) + typer.echo(f"Polling every {interval}s, writing to {out}") + try: + while running: + try: + results = poll_once() + _write_json_atomic(out, results) + except RuntimeError as e: + typer.echo(f"error: {e}", err=True) + raise typer.Exit(code=1) from None + except Exception: + logger.exception("poll cycle failed") + + remaining = interval + while running and remaining > 0: + step = min(0.2, remaining) + time.sleep(step) + remaining -= step + finally: + with contextlib.suppress(Exception): + _clean_json(out) + + typer.echo("Stopped.") + + +if __name__ == "__main__": + app()