initial addition of battery display
JS/TS / fmt (pull_request) Successful in 7s
JS/TS / lint (pull_request) Successful in 9s
Python / fmt (pull_request) Successful in 15s
Python / lint (pull_request) Failing after 19s
Python / typecheck (pull_request) Failing after 1m5s
C++ / fmt (pull_request) Successful in 6s
Python / test (pull_request) Successful in 1m20s
Rust / fmt (pull_request) Successful in 1m42s
Python / buildcheck (pull_request) Successful in 2m23s
Rust / clippy (pull_request) Successful in 2m42s
Rust / build (pull_request) Successful in 2m53s
C++ / build (pull_request) Successful in 2m22s
C++ / clang-tidy (pull_request) Successful in 3m17s

This commit is contained in:
2026-07-12 17:47:25 +02:00
parent 93c6ac251e
commit 8789d2d322
6 changed files with 405 additions and 89 deletions
+35 -12
View File
@@ -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<UPowerDevice> 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<var> devices: []
property real updated: 0.0
}
}
}
+84
View File
@@ -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
}
}
+27
View File
@@ -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);
+17 -76
View File
@@ -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 {
+9 -1
View File
@@ -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:
+233
View File
@@ -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()