From ca1b0a2cd5aa26def015253822dfe8efbabe95af Mon Sep 17 00:00:00 2001 From: maziggy Date: Sun, 12 Apr 2026 10:41:57 +0200 Subject: [PATCH] feat(spoolbuddy): auto-wake display on NFC tag scan or scale activity (#945) Display now powers on via wlopm when the daemon detects an NFC tag or weight change while the screen is blanked. Daemon discovers the Wayland session from the shared runtime dir and coexists with swayidle which continues to handle touch-based wake independently. --- CHANGELOG.md | 1 + spoolbuddy/daemon/display_control.py | 63 ++++++++++++++++++++++++++-- 2 files changed, 60 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0dee98595..cd8cf33ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ All notable changes to Bambuddy will be documented in this file. - **LDAP Default Fallback Group** — Settings → Authentication → LDAP → Advanced now has a "Default group" selector. When an LDAP user authenticates but is not listed in any mapped LDAP group, they are automatically assigned to this fallback group instead of being left without permissions. Previously such users could log in successfully but landed on empty pages because every permission check failed. Leave the setting empty to preserve the old behavior. A warning is logged each time the fallback is applied so administrators can spot missing group assignments. ### Changed +- **SpoolBuddy Auto-Wake on NFC/Scale** ([#945](https://github.com/maziggy/bambuddy/issues/945)) — The SpoolBuddy kiosk display now wakes automatically when a spool is placed on the scale or an NFC tag is scanned, without requiring a touch first. The daemon discovers the Wayland session from the shared runtime directory and toggles HDMI power via `wlopm`, coexisting with `swayidle` which continues to handle touch-based wake independently. Gracefully degrades when `wlopm` is not installed or no Wayland session is available. Thanks to @TravisWilder for the suggestion. - **SpoolBuddy Kiosk LCD Now Powers Off on Idle** ([#937](https://github.com/maziggy/bambuddy/issues/937)) — The SpoolBuddy kiosk's "screen blank timeout" setting previously only painted a black CSS overlay over the browser window; the HDMI panel's backlight stayed on indefinitely, wasting power and letting OLED/LED panels burn in. The blanking path is now moved down to the OS layer: the install script installs `swayidle` and `wlopm`, and labwc's autostart launches a new watchdog (`spoolbuddy/install/spoolbuddy-idle.sh`) that queries the backend once on boot for the device's `display_blank_timeout` and hands it to `swayidle`, which powers HDMI off via `wlopm --off HDMI-A-1` after the configured idle period and powers it back on via `wlopm --on` when labwc delivers any input event (touch, keypress). The redundant CSS overlay and its pointer/keyboard listeners have been removed from `SpoolBuddyLayout` — one source of truth now. Screen blanking is opt-in: `display_blank_timeout=0` (the default) skips launching swayidle entirely and the display stays on forever, preserving current behavior for users who didn't pick a timeout. The default for users who newly enable blanking is 300 seconds. Changes made to the timeout in SpoolBuddy Settings → Display take effect on the next kiosk restart — tap Quick Menu → Restart Browser to apply without a full reboot. A new `GET /api/v1/spoolbuddy/devices/{device_id}/display` endpoint (gated on `inventory:update`, same as the existing `PUT` and heartbeat endpoints) is what the kiosk-side watchdog reads, so no new permissions are required on the device's API key. The watchdog also writes a full startup trace (env vars, resolved timeout, the exact `swayidle` command it execs) to `~/.cache/spoolbuddy-idle.log` so any future breakage on a different kiosk setup is trivially diagnosable, and auto-detects `WAYLAND_DISPLAY` from `XDG_RUNTIME_DIR` with a short retry loop in case labwc hasn't finished exporting its env by the time autostart runs. Thanks to @TravisWilder for reporting. ### Fixed diff --git a/spoolbuddy/daemon/display_control.py b/spoolbuddy/daemon/display_control.py index c2b19609a..a6bf7baef 100644 --- a/spoolbuddy/daemon/display_control.py +++ b/spoolbuddy/daemon/display_control.py @@ -2,11 +2,16 @@ Brightness: DSI backlights are controlled via sysfs /sys/class/backlight/*/brightness. HDMI brightness is handled by the frontend via CSS filter. -Blanking: Handled entirely by the frontend (CSS black overlay with touch-to-wake). - The daemon tracks idle state but does not control the physical display. +Blanking: The daemon tracks idle state and controls HDMI power via wlopm when + available. NFC tag scans and scale weight changes wake the display + automatically, and the idle timeout re-blanks it. swayidle handles + touch-based wake/blank independently — both are idempotent via wlopm. """ import logging +import os +import shutil +import subprocess import time from pathlib import Path @@ -22,12 +27,20 @@ class DisplayControl: self._blank_timeout = 0 # seconds, 0 = disabled self._last_activity = time.monotonic() self._blanked = False + self._wlopm_path = shutil.which("wlopm") + self._wayland_env: dict[str, str] | None = None + self._output = os.environ.get("SPOOLBUDDY_DISPLAY_OUTPUT", "HDMI-A-1") if self._backlight_path: logger.info("Backlight found: %s (max=%d)", self._backlight_path, self._max_brightness) else: logger.info("No DSI backlight found, brightness control via frontend CSS") + if self._wlopm_path: + logger.info("wlopm found at %s, HDMI wake/blank enabled", self._wlopm_path) + else: + logger.info("wlopm not found, HDMI wake/blank disabled") + def _find_backlight(self) -> Path | None: if not BACKLIGHT_BASE.exists(): return None @@ -86,10 +99,52 @@ class DisplayControl: if not self._blanked and idle >= self._blank_timeout: self._blank() + def _discover_wayland_env(self) -> dict[str, str] | None: + """Discover WAYLAND_DISPLAY and XDG_RUNTIME_DIR for the kiosk session. + + The daemon runs as a systemd service outside the Wayland session, so + these variables aren't inherited. We probe the same runtime dir that + labwc uses (the daemon and kiosk run as the same user). + """ + xdg = os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}") + runtime = Path(xdg) + if not runtime.is_dir(): + return None + for entry in sorted(runtime.iterdir()): + if entry.name.startswith("wayland-") and not entry.name.endswith(".lock"): + return {"WAYLAND_DISPLAY": entry.name, "XDG_RUNTIME_DIR": xdg} + return None + + def _wlopm(self, on: bool) -> None: + """Toggle HDMI output via wlopm. No-op if wlopm is unavailable.""" + if not self._wlopm_path: + return + # Retry discovery each call until the Wayland socket appears — labwc + # may start after the daemon on boot. + if self._wayland_env is None: + self._wayland_env = self._discover_wayland_env() + if self._wayland_env is None: + logger.debug("No Wayland socket found, cannot control HDMI") + return + logger.info("Wayland session discovered: %s", self._wayland_env.get("WAYLAND_DISPLAY")) + flag = "--on" if on else "--off" + try: + env = {**os.environ, **self._wayland_env} + subprocess.run( + [self._wlopm_path, flag, self._output], + env=env, + timeout=5, + capture_output=True, + ) + except Exception as e: + logger.debug("wlopm %s %s failed: %s", flag, self._output, e) + def _blank(self): self._blanked = True - logger.debug("Screen idle timeout reached (frontend handles blanking)") + self._wlopm(on=False) + logger.debug("Screen idle timeout reached, HDMI off") def _unblank(self): self._blanked = False - logger.debug("Activity detected (frontend handles unblanking)") + self._wlopm(on=True) + logger.debug("Activity detected, HDMI on")