diff --git a/spoolbuddy/daemon/display_control.py b/spoolbuddy/daemon/display_control.py index 6d67eba5a..ad2cca12d 100644 --- a/spoolbuddy/daemon/display_control.py +++ b/spoolbuddy/daemon/display_control.py @@ -3,22 +3,22 @@ Brightness: DSI backlights are controlled via sysfs /sys/class/backlight/*/brightness. HDMI brightness is handled by the frontend via CSS filter. Blanking: swayidle is the sole authority on screen blanking (idle timeout → - wlopm --off, touch → wlopm --on). The daemon only *wakes* the - display via wlopm --on when NFC/scale activity is detected — it - never blanks. wlopm --on is idempotent so both paths coexist - safely. + wlopm --off, touch → wlopm --on). The daemon wakes the display by + writing to a FIFO that the idle watchdog monitors — the watchdog + runs inside the Wayland session and calls wlopm --on on behalf of + the daemon. """ import logging import os -import shutil -import subprocess +import stat import time from pathlib import Path logger = logging.getLogger(__name__) BACKLIGHT_BASE = Path("/sys/class/backlight") +WAKE_FIFO = Path("/tmp/spoolbuddy-wake") class DisplayControl: @@ -28,20 +28,12 @@ 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 @@ -87,14 +79,14 @@ class DisplayControl: def wake(self): """Wake screen on activity (NFC tag, scale weight change). - Always calls wlopm --on regardless of the daemon's internal blanked - state, because swayidle may have blanked the screen independently and - the daemon has no way to know. wlopm --on is idempotent so calling it - while the screen is already on is harmless. + Writes to /tmp/spoolbuddy-wake FIFO which the idle watchdog + (spoolbuddy-idle.sh) monitors inside the Wayland session. The + watchdog calls wlopm --on on our behalf. No-op if the FIFO + doesn't exist (kiosk not running or blanking disabled without FIFO). """ self._last_activity = time.monotonic() self._blanked = False - self._wlopm(on=True) + self._signal_wake() def tick(self): """Called periodically from heartbeat loop. Tracks idle state internally.""" @@ -106,64 +98,22 @@ class DisplayControl: self._blanked = True logger.debug("Screen idle timeout reached (swayidle manages blanking)") - _WAYLAND_ENV_FILE = Path("/tmp/spoolbuddy-wayland-env") - - 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. The kiosk idle watchdog - (spoolbuddy-idle.sh) writes them to /tmp/spoolbuddy-wayland-env on - startup — read that file. - """ - if not self._WAYLAND_ENV_FILE.exists(): - return None - try: - env: dict[str, str] = {} - for line in self._WAYLAND_ENV_FILE.read_text().strip().splitlines(): - if "=" in line: - key, value = line.split("=", 1) - env[key] = value - wayland_display = env.get("WAYLAND_DISPLAY", "") - xdg_runtime_dir = env.get("XDG_RUNTIME_DIR", "") - if wayland_display and xdg_runtime_dir: - return {"WAYLAND_DISPLAY": wayland_display, "XDG_RUNTIME_DIR": xdg_runtime_dir} - except Exception as e: - logger.warning("Failed to read %s: %s", self._WAYLAND_ENV_FILE, e) - return None - - def _wlopm(self, on: bool) -> None: - """Toggle HDMI output via wlopm. No-op if wlopm is unavailable.""" - if not self._wlopm_path: - logger.warning("wlopm not available, cannot control HDMI") + def _signal_wake(self) -> None: + """Write to the wake FIFO to request display power-on.""" + if not WAKE_FIFO.exists(): 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.warning("No Wayland socket found in /run/user/ (uid=%d), cannot control HDMI", os.getuid()) - 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} - result = subprocess.run( - [self._wlopm_path, flag, self._output], - env=env, - timeout=5, - capture_output=True, - text=True, - ) - if result.returncode != 0: - logger.warning( - "wlopm %s %s exit=%d: %s", - flag, - self._output, - result.returncode, - (result.stderr or result.stdout).strip(), - ) - else: - logger.info("wlopm %s %s OK", flag, self._output) - except Exception as e: - logger.warning("wlopm %s %s failed: %s", flag, self._output, e) + # Verify it's actually a FIFO, not a regular file + if not stat.S_ISFIFO(WAKE_FIFO.stat().st_mode): + return + # Open non-blocking so we don't hang if no reader is attached + fd = os.open(str(WAKE_FIFO), os.O_WRONLY | os.O_NONBLOCK) + try: + os.write(fd, b"wake\n") + logger.info("Wake signal sent via FIFO") + finally: + os.close(fd) + except OSError as e: + # ENXIO = no reader on the FIFO (idle script not running) — expected + if e.errno != 6: # ENXIO + logger.debug("Wake FIFO write failed: %s", e) diff --git a/spoolbuddy/install/spoolbuddy-idle.sh b/spoolbuddy/install/spoolbuddy-idle.sh index b661de3da..2890aa5b2 100755 --- a/spoolbuddy/install/spoolbuddy-idle.sh +++ b/spoolbuddy/install/spoolbuddy-idle.sh @@ -85,22 +85,38 @@ if [ -n "$BACKEND_URL" ] && [ -n "$API_KEY" ] && [ -n "$DEVICE_ID" ]; then fi fi -# Write Wayland env to a known file so the SpoolBuddy daemon (which runs as a -# systemd service outside the compositor) can discover the socket and call wlopm -# to wake the display on NFC/scale events. -WAYLAND_ENV_FILE="/tmp/spoolbuddy-wayland-env" -printf 'WAYLAND_DISPLAY=%s\nXDG_RUNTIME_DIR=%s\n' \ - "${WAYLAND_DISPLAY:-}" "${XDG_RUNTIME_DIR:-}" > "$WAYLAND_ENV_FILE" -chmod 644 "$WAYLAND_ENV_FILE" -echo "wrote Wayland env to $WAYLAND_ENV_FILE" +# FIFO for the SpoolBuddy daemon to request display wake from outside the +# Wayland session (NFC tag scan, scale weight change). The daemon writes +# "wake\n" to this pipe; the monitor loop below calls wlopm --on. +WAKE_FIFO="/tmp/spoolbuddy-wake" +rm -f "$WAKE_FIFO" +mkfifo -m 622 "$WAKE_FIFO" +echo "wake FIFO created at $WAKE_FIFO" if [ "$TIMEOUT" -le 0 ]; then - # Blanking explicitly disabled — don't launch swayidle at all. - echo "timeout<=0, sleeping forever" - exec sleep infinity + # Blanking explicitly disabled — just monitor the wake FIFO so NFC/scale + # wake still works even without swayidle. + echo "timeout<=0, monitoring wake FIFO only (no swayidle)" + while read -r _ < "$WAKE_FIFO"; do + wlopm --on "$OUTPUT" 2>/dev/null || true + done + exit 0 fi -echo "execing swayidle with timeout=$TIMEOUT output=$OUTPUT" -exec swayidle -w \ +echo "starting swayidle with timeout=$TIMEOUT output=$OUTPUT" +swayidle -w \ timeout "$TIMEOUT" "wlopm --off $OUTPUT" \ - resume "wlopm --on $OUTPUT" + resume "wlopm --on $OUTPUT" & +SWAYIDLE_PID=$! + +# Monitor wake FIFO — when the daemon writes to it, turn the display on. +while read -r _ < "$WAKE_FIFO"; do + wlopm --on "$OUTPUT" 2>/dev/null || true +done & +FIFO_PID=$! + +# If either process exits, clean up and exit. +wait -n "$SWAYIDLE_PID" "$FIFO_PID" 2>/dev/null +echo "child exited, cleaning up" +kill "$SWAYIDLE_PID" "$FIFO_PID" 2>/dev/null || true +rm -f "$WAKE_FIFO"