display_control.py 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
  1. """Display brightness and screen blanking control for SpoolBuddy kiosk.
  2. Brightness: DSI backlights are controlled via sysfs /sys/class/backlight/*/brightness.
  3. HDMI brightness is handled by the frontend via CSS filter.
  4. Blanking: swayidle is the sole authority on screen blanking (idle timeout →
  5. wlopm --off, touch → wlopm --on). The daemon only *wakes* the
  6. display via wlopm --on when NFC/scale activity is detected — it
  7. never blanks. wlopm --on is idempotent so both paths coexist
  8. safely.
  9. """
  10. import logging
  11. import os
  12. import shutil
  13. import subprocess
  14. import time
  15. from pathlib import Path
  16. logger = logging.getLogger(__name__)
  17. BACKLIGHT_BASE = Path("/sys/class/backlight")
  18. class DisplayControl:
  19. def __init__(self):
  20. self._backlight_path = self._find_backlight()
  21. self._max_brightness = self._read_max_brightness()
  22. self._blank_timeout = 0 # seconds, 0 = disabled
  23. self._last_activity = time.monotonic()
  24. self._blanked = False
  25. self._wlopm_path = shutil.which("wlopm")
  26. self._wayland_env: dict[str, str] | None = None
  27. self._output = os.environ.get("SPOOLBUDDY_DISPLAY_OUTPUT", "HDMI-A-1")
  28. if self._backlight_path:
  29. logger.info("Backlight found: %s (max=%d)", self._backlight_path, self._max_brightness)
  30. else:
  31. logger.info("No DSI backlight found, brightness control via frontend CSS")
  32. if self._wlopm_path:
  33. logger.info("wlopm found at %s, HDMI wake/blank enabled", self._wlopm_path)
  34. else:
  35. logger.info("wlopm not found, HDMI wake/blank disabled")
  36. def _find_backlight(self) -> Path | None:
  37. if not BACKLIGHT_BASE.exists():
  38. return None
  39. for entry in BACKLIGHT_BASE.iterdir():
  40. brightness_file = entry / "brightness"
  41. if brightness_file.exists():
  42. return entry
  43. return None
  44. def _read_max_brightness(self) -> int:
  45. if not self._backlight_path:
  46. return 100
  47. try:
  48. return int((self._backlight_path / "max_brightness").read_text().strip())
  49. except Exception:
  50. return 255
  51. @property
  52. def has_backlight(self) -> bool:
  53. return self._backlight_path is not None
  54. def set_brightness(self, pct: int):
  55. """Set backlight brightness (0-100%). No-op if no backlight."""
  56. if not self._backlight_path:
  57. return
  58. pct = max(0, min(100, pct))
  59. value = round(self._max_brightness * pct / 100)
  60. try:
  61. (self._backlight_path / "brightness").write_text(str(value))
  62. logger.debug("Brightness set to %d%% (%d/%d)", pct, value, self._max_brightness)
  63. except PermissionError:
  64. logger.warning(
  65. "Permission denied writing to %s/brightness. Ensure spoolbuddy user is in the 'video' group.",
  66. self._backlight_path,
  67. )
  68. except Exception as e:
  69. logger.warning("Failed to set brightness: %s", e)
  70. def set_blank_timeout(self, seconds: int):
  71. """Set screen blank timeout in seconds. 0 = disabled."""
  72. self._blank_timeout = max(0, seconds)
  73. def wake(self):
  74. """Wake screen on activity (NFC tag, scale weight change).
  75. Always calls wlopm --on regardless of the daemon's internal blanked
  76. state, because swayidle may have blanked the screen independently and
  77. the daemon has no way to know. wlopm --on is idempotent so calling it
  78. while the screen is already on is harmless.
  79. """
  80. self._last_activity = time.monotonic()
  81. self._blanked = False
  82. self._wlopm(on=True)
  83. def tick(self):
  84. """Called periodically from heartbeat loop. Tracks idle state internally."""
  85. if self._blank_timeout <= 0:
  86. self._blanked = False
  87. return
  88. idle = time.monotonic() - self._last_activity
  89. if not self._blanked and idle >= self._blank_timeout:
  90. self._blanked = True
  91. logger.debug("Screen idle timeout reached (swayidle manages blanking)")
  92. def _discover_wayland_env(self) -> dict[str, str] | None:
  93. """Discover WAYLAND_DISPLAY and XDG_RUNTIME_DIR for the kiosk session.
  94. The daemon runs as a systemd service outside the Wayland session, so
  95. these variables aren't inherited. First try our own runtime dir, then
  96. scan all /run/user/*/ dirs — on a kiosk there's exactly one Wayland
  97. session and the daemon may run under a system uid that logind doesn't
  98. create a runtime dir for.
  99. """
  100. candidates: list[Path] = []
  101. # Prefer our own runtime dir if set
  102. own_xdg = os.environ.get("XDG_RUNTIME_DIR", "")
  103. if own_xdg:
  104. candidates.append(Path(own_xdg))
  105. candidates.append(Path(f"/run/user/{os.getuid()}"))
  106. # Fall back to scanning all user runtime dirs
  107. run_user = Path("/run/user")
  108. if run_user.is_dir():
  109. for uid_dir in sorted(run_user.iterdir()):
  110. if uid_dir.is_dir() and uid_dir not in candidates:
  111. candidates.append(uid_dir)
  112. for runtime in candidates:
  113. if not runtime.is_dir():
  114. continue
  115. try:
  116. for entry in sorted(runtime.iterdir()):
  117. if entry.name.startswith("wayland-") and not entry.name.endswith(".lock"):
  118. return {"WAYLAND_DISPLAY": entry.name, "XDG_RUNTIME_DIR": str(runtime)}
  119. except PermissionError:
  120. continue
  121. return None
  122. def _wlopm(self, on: bool) -> None:
  123. """Toggle HDMI output via wlopm. No-op if wlopm is unavailable."""
  124. if not self._wlopm_path:
  125. logger.warning("wlopm not available, cannot control HDMI")
  126. return
  127. # Retry discovery each call until the Wayland socket appears — labwc
  128. # may start after the daemon on boot.
  129. if self._wayland_env is None:
  130. self._wayland_env = self._discover_wayland_env()
  131. if self._wayland_env is None:
  132. logger.warning("No Wayland socket found in /run/user/ (uid=%d), cannot control HDMI", os.getuid())
  133. return
  134. logger.info("Wayland session discovered: %s", self._wayland_env.get("WAYLAND_DISPLAY"))
  135. flag = "--on" if on else "--off"
  136. try:
  137. env = {**os.environ, **self._wayland_env}
  138. result = subprocess.run(
  139. [self._wlopm_path, flag, self._output],
  140. env=env,
  141. timeout=5,
  142. capture_output=True,
  143. text=True,
  144. )
  145. if result.returncode != 0:
  146. logger.warning(
  147. "wlopm %s %s exit=%d: %s",
  148. flag,
  149. self._output,
  150. result.returncode,
  151. (result.stderr or result.stdout).strip(),
  152. )
  153. else:
  154. logger.info("wlopm %s %s OK", flag, self._output)
  155. except Exception as e:
  156. logger.warning("wlopm %s %s failed: %s", flag, self._output, e)