display_control.py 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151
  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 wakes the display by
  6. writing to a FIFO that the idle watchdog monitors — the watchdog
  7. runs inside the Wayland session and calls wlopm --on on behalf of
  8. the daemon. The same FIFO carries "reload-timeout N" lines: when
  9. the user changes the blank-timeout setting in the UI, the daemon
  10. picks up the new value over the heartbeat and signals the watchdog
  11. to kill+restart swayidle with the new timeout, so changes take
  12. effect live without a kiosk restart.
  13. """
  14. import logging
  15. import os
  16. import stat
  17. import time
  18. from pathlib import Path
  19. logger = logging.getLogger(__name__)
  20. BACKLIGHT_BASE = Path("/sys/class/backlight")
  21. WAKE_FIFO = Path("/tmp/spoolbuddy-wake")
  22. class DisplayControl:
  23. def __init__(self):
  24. self._backlight_path = self._find_backlight()
  25. self._max_brightness = self._read_max_brightness()
  26. self._blank_timeout = 0 # seconds, 0 = disabled
  27. self._timeout_initialized = False
  28. self._last_activity = time.monotonic()
  29. self._blanked = False
  30. if self._backlight_path:
  31. logger.info("Backlight found: %s (max=%d)", self._backlight_path, self._max_brightness)
  32. else:
  33. logger.info("No DSI backlight found, brightness control via frontend CSS")
  34. def _find_backlight(self) -> Path | None:
  35. if not BACKLIGHT_BASE.exists():
  36. return None
  37. for entry in BACKLIGHT_BASE.iterdir():
  38. brightness_file = entry / "brightness"
  39. if brightness_file.exists():
  40. return entry
  41. return None
  42. def _read_max_brightness(self) -> int:
  43. if not self._backlight_path:
  44. return 100
  45. try:
  46. return int((self._backlight_path / "max_brightness").read_text().strip())
  47. except Exception:
  48. return 255
  49. @property
  50. def has_backlight(self) -> bool:
  51. return self._backlight_path is not None
  52. def set_brightness(self, pct: int):
  53. """Set backlight brightness (0-100%). No-op if no backlight."""
  54. if not self._backlight_path:
  55. return
  56. pct = max(0, min(100, pct))
  57. value = round(self._max_brightness * pct / 100)
  58. try:
  59. (self._backlight_path / "brightness").write_text(str(value))
  60. logger.debug("Brightness set to %d%% (%d/%d)", pct, value, self._max_brightness)
  61. except PermissionError:
  62. logger.warning(
  63. "Permission denied writing to %s/brightness. Ensure spoolbuddy user is in the 'video' group.",
  64. self._backlight_path,
  65. )
  66. except Exception as e:
  67. logger.warning("Failed to set brightness: %s", e)
  68. def set_blank_timeout(self, seconds: int):
  69. """Set screen blank timeout in seconds. 0 = disabled.
  70. On every change after the first call, signals the idle watchdog
  71. (spoolbuddy-idle.sh) to restart swayidle with the new value.
  72. Without this, swayidle keeps running with whatever timeout it
  73. was started with at autostart and UI changes only take effect
  74. after a kiosk restart.
  75. The first call (during daemon startup) is suppressed because the
  76. watchdog already fetched the same value from the backend at its
  77. own startup; signalling here would just thrash swayidle.
  78. """
  79. new_timeout = max(0, seconds)
  80. changed = new_timeout != self._blank_timeout
  81. self._blank_timeout = new_timeout
  82. if changed and self._timeout_initialized:
  83. self._signal_reload_timeout(new_timeout)
  84. self._timeout_initialized = True
  85. def wake(self):
  86. """Wake screen on activity (NFC tag, scale weight change).
  87. Writes to /tmp/spoolbuddy-wake FIFO which the idle watchdog
  88. (spoolbuddy-idle.sh) monitors inside the Wayland session. The
  89. watchdog calls wlopm --on on our behalf. No-op if the FIFO
  90. doesn't exist (kiosk not running or blanking disabled without FIFO).
  91. """
  92. self._last_activity = time.monotonic()
  93. self._blanked = False
  94. self._signal_wake()
  95. def tick(self):
  96. """Called periodically from heartbeat loop. Tracks idle state internally."""
  97. if self._blank_timeout <= 0:
  98. self._blanked = False
  99. return
  100. idle = time.monotonic() - self._last_activity
  101. if not self._blanked and idle >= self._blank_timeout:
  102. self._blanked = True
  103. logger.debug("Screen idle timeout reached (swayidle manages blanking)")
  104. def _signal_wake(self) -> None:
  105. """Write to the wake FIFO to request display power-on."""
  106. if self._write_fifo(b"wake\n"):
  107. logger.info("Wake signal sent via FIFO")
  108. def _signal_reload_timeout(self, seconds: int) -> None:
  109. """Tell the idle watchdog to apply a new timeout to swayidle."""
  110. if self._write_fifo(f"reload-timeout {seconds}\n".encode()):
  111. logger.info("Reload-timeout signal sent (timeout=%ds)", seconds)
  112. def _write_fifo(self, payload: bytes) -> bool:
  113. """Best-effort write to the wake FIFO. Returns True on success."""
  114. if not WAKE_FIFO.exists():
  115. return False
  116. try:
  117. # Verify it's actually a FIFO, not a regular file
  118. if not stat.S_ISFIFO(WAKE_FIFO.stat().st_mode):
  119. return False
  120. # Open non-blocking so we don't hang if no reader is attached
  121. fd = os.open(str(WAKE_FIFO), os.O_WRONLY | os.O_NONBLOCK)
  122. try:
  123. os.write(fd, payload)
  124. return True
  125. finally:
  126. os.close(fd)
  127. except OSError as e:
  128. # ENXIO = no reader on the FIFO (idle script not running) — expected
  129. if e.errno != 6: # ENXIO
  130. logger.debug("FIFO write failed: %s", e)
  131. return False