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
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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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__":
|
||||||
|
|||||||
Reference in New Issue
Block a user