| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176 |
- """Display brightness and screen blanking control for SpoolBuddy kiosk.
- 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.
- """
- import logging
- import os
- import shutil
- import subprocess
- import time
- from pathlib import Path
- logger = logging.getLogger(__name__)
- BACKLIGHT_BASE = Path("/sys/class/backlight")
- class DisplayControl:
- def __init__(self):
- self._backlight_path = self._find_backlight()
- self._max_brightness = self._read_max_brightness()
- 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
- for entry in BACKLIGHT_BASE.iterdir():
- brightness_file = entry / "brightness"
- if brightness_file.exists():
- return entry
- return None
- def _read_max_brightness(self) -> int:
- if not self._backlight_path:
- return 100
- try:
- return int((self._backlight_path / "max_brightness").read_text().strip())
- except Exception:
- return 255
- @property
- def has_backlight(self) -> bool:
- return self._backlight_path is not None
- def set_brightness(self, pct: int):
- """Set backlight brightness (0-100%). No-op if no backlight."""
- if not self._backlight_path:
- return
- pct = max(0, min(100, pct))
- value = round(self._max_brightness * pct / 100)
- try:
- (self._backlight_path / "brightness").write_text(str(value))
- logger.debug("Brightness set to %d%% (%d/%d)", pct, value, self._max_brightness)
- except PermissionError:
- logger.warning(
- "Permission denied writing to %s/brightness. Ensure spoolbuddy user is in the 'video' group.",
- self._backlight_path,
- )
- except Exception as e:
- logger.warning("Failed to set brightness: %s", e)
- def set_blank_timeout(self, seconds: int):
- """Set screen blank timeout in seconds. 0 = disabled."""
- self._blank_timeout = max(0, seconds)
- 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.
- """
- self._last_activity = time.monotonic()
- self._blanked = False
- self._wlopm(on=True)
- def tick(self):
- """Called periodically from heartbeat loop. Tracks idle state internally."""
- if self._blank_timeout <= 0:
- self._blanked = False
- return
- idle = time.monotonic() - self._last_activity
- if not self._blanked and idle >= self._blank_timeout:
- self._blanked = True
- logger.debug("Screen idle timeout reached (swayidle manages blanking)")
- 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. First try our own runtime dir, then
- scan all /run/user/*/ dirs — on a kiosk there's exactly one Wayland
- session and the daemon may run under a system uid that logind doesn't
- create a runtime dir for.
- """
- candidates: list[Path] = []
- # Prefer our own runtime dir if set
- own_xdg = os.environ.get("XDG_RUNTIME_DIR", "")
- if own_xdg:
- candidates.append(Path(own_xdg))
- candidates.append(Path(f"/run/user/{os.getuid()}"))
- # Fall back to scanning all user runtime dirs
- run_user = Path("/run/user")
- if run_user.is_dir():
- for uid_dir in sorted(run_user.iterdir()):
- if uid_dir.is_dir() and uid_dir not in candidates:
- candidates.append(uid_dir)
- for runtime in candidates:
- if not runtime.is_dir():
- continue
- try:
- 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": str(runtime)}
- except PermissionError:
- continue
- 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")
- 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)
|