display_control.py 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161
  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: The daemon tracks idle state and controls HDMI power via wlopm when
  5. available. NFC tag scans and scale weight changes wake the display
  6. automatically, and the idle timeout re-blanks it. swayidle handles
  7. touch-based wake/blank independently — both are idempotent via wlopm.
  8. """
  9. import logging
  10. import os
  11. import shutil
  12. import subprocess
  13. import time
  14. from pathlib import Path
  15. logger = logging.getLogger(__name__)
  16. BACKLIGHT_BASE = Path("/sys/class/backlight")
  17. class DisplayControl:
  18. def __init__(self):
  19. self._backlight_path = self._find_backlight()
  20. self._max_brightness = self._read_max_brightness()
  21. self._blank_timeout = 0 # seconds, 0 = disabled
  22. self._last_activity = time.monotonic()
  23. self._blanked = False
  24. self._daemon_woke = False # True when the daemon woke the display (NFC/scale)
  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. self._last_activity = time.monotonic()
  76. if self._blanked:
  77. self._unblank()
  78. def tick(self):
  79. """Called periodically from heartbeat loop. Blanks screen if idle."""
  80. if self._blank_timeout <= 0:
  81. if self._blanked:
  82. self._unblank()
  83. return
  84. idle = time.monotonic() - self._last_activity
  85. if not self._blanked and idle >= self._blank_timeout:
  86. self._blank()
  87. def _discover_wayland_env(self) -> dict[str, str] | None:
  88. """Discover WAYLAND_DISPLAY and XDG_RUNTIME_DIR for the kiosk session.
  89. The daemon runs as a systemd service outside the Wayland session, so
  90. these variables aren't inherited. We probe the same runtime dir that
  91. labwc uses (the daemon and kiosk run as the same user).
  92. """
  93. xdg = os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")
  94. runtime = Path(xdg)
  95. if not runtime.is_dir():
  96. return None
  97. for entry in sorted(runtime.iterdir()):
  98. if entry.name.startswith("wayland-") and not entry.name.endswith(".lock"):
  99. return {"WAYLAND_DISPLAY": entry.name, "XDG_RUNTIME_DIR": xdg}
  100. return None
  101. def _wlopm(self, on: bool) -> None:
  102. """Toggle HDMI output via wlopm. No-op if wlopm is unavailable."""
  103. if not self._wlopm_path:
  104. return
  105. # Retry discovery each call until the Wayland socket appears — labwc
  106. # may start after the daemon on boot.
  107. if self._wayland_env is None:
  108. self._wayland_env = self._discover_wayland_env()
  109. if self._wayland_env is None:
  110. logger.debug("No Wayland socket found, cannot control HDMI")
  111. return
  112. logger.info("Wayland session discovered: %s", self._wayland_env.get("WAYLAND_DISPLAY"))
  113. flag = "--on" if on else "--off"
  114. try:
  115. env = {**os.environ, **self._wayland_env}
  116. subprocess.run(
  117. [self._wlopm_path, flag, self._output],
  118. env=env,
  119. timeout=5,
  120. capture_output=True,
  121. )
  122. except Exception as e:
  123. logger.debug("wlopm %s %s failed: %s", flag, self._output, e)
  124. def _blank(self):
  125. self._blanked = True
  126. # Only power off HDMI if the daemon was responsible for the last wake.
  127. # Touch-based wake/blank is managed entirely by swayidle — if we called
  128. # wlopm --off here unconditionally, we'd fight swayidle because the
  129. # daemon never sees touch events and its idle timer would expire while
  130. # the user is still interacting via the touchscreen.
  131. if self._daemon_woke:
  132. self._daemon_woke = False
  133. self._wlopm(on=False)
  134. logger.debug("Daemon wake idle timeout reached, HDMI off")
  135. else:
  136. logger.debug("Screen idle timeout reached (swayidle manages HDMI)")
  137. def _unblank(self):
  138. self._blanked = False
  139. self._daemon_woke = True
  140. self._wlopm(on=True)
  141. logger.debug("Activity detected (NFC/scale), HDMI on")