display_control.py 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119
  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.
  9. """
  10. import logging
  11. import os
  12. import stat
  13. import time
  14. from pathlib import Path
  15. logger = logging.getLogger(__name__)
  16. BACKLIGHT_BASE = Path("/sys/class/backlight")
  17. WAKE_FIFO = Path("/tmp/spoolbuddy-wake")
  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. if self._backlight_path:
  26. logger.info("Backlight found: %s (max=%d)", self._backlight_path, self._max_brightness)
  27. else:
  28. logger.info("No DSI backlight found, brightness control via frontend CSS")
  29. def _find_backlight(self) -> Path | None:
  30. if not BACKLIGHT_BASE.exists():
  31. return None
  32. for entry in BACKLIGHT_BASE.iterdir():
  33. brightness_file = entry / "brightness"
  34. if brightness_file.exists():
  35. return entry
  36. return None
  37. def _read_max_brightness(self) -> int:
  38. if not self._backlight_path:
  39. return 100
  40. try:
  41. return int((self._backlight_path / "max_brightness").read_text().strip())
  42. except Exception:
  43. return 255
  44. @property
  45. def has_backlight(self) -> bool:
  46. return self._backlight_path is not None
  47. def set_brightness(self, pct: int):
  48. """Set backlight brightness (0-100%). No-op if no backlight."""
  49. if not self._backlight_path:
  50. return
  51. pct = max(0, min(100, pct))
  52. value = round(self._max_brightness * pct / 100)
  53. try:
  54. (self._backlight_path / "brightness").write_text(str(value))
  55. logger.debug("Brightness set to %d%% (%d/%d)", pct, value, self._max_brightness)
  56. except PermissionError:
  57. logger.warning(
  58. "Permission denied writing to %s/brightness. Ensure spoolbuddy user is in the 'video' group.",
  59. self._backlight_path,
  60. )
  61. except Exception as e:
  62. logger.warning("Failed to set brightness: %s", e)
  63. def set_blank_timeout(self, seconds: int):
  64. """Set screen blank timeout in seconds. 0 = disabled."""
  65. self._blank_timeout = max(0, seconds)
  66. def wake(self):
  67. """Wake screen on activity (NFC tag, scale weight change).
  68. Writes to /tmp/spoolbuddy-wake FIFO which the idle watchdog
  69. (spoolbuddy-idle.sh) monitors inside the Wayland session. The
  70. watchdog calls wlopm --on on our behalf. No-op if the FIFO
  71. doesn't exist (kiosk not running or blanking disabled without FIFO).
  72. """
  73. self._last_activity = time.monotonic()
  74. self._blanked = False
  75. self._signal_wake()
  76. def tick(self):
  77. """Called periodically from heartbeat loop. Tracks idle state internally."""
  78. if self._blank_timeout <= 0:
  79. self._blanked = False
  80. return
  81. idle = time.monotonic() - self._last_activity
  82. if not self._blanked and idle >= self._blank_timeout:
  83. self._blanked = True
  84. logger.debug("Screen idle timeout reached (swayidle manages blanking)")
  85. def _signal_wake(self) -> None:
  86. """Write to the wake FIFO to request display power-on."""
  87. if not WAKE_FIFO.exists():
  88. return
  89. try:
  90. # Verify it's actually a FIFO, not a regular file
  91. if not stat.S_ISFIFO(WAKE_FIFO.stat().st_mode):
  92. return
  93. # Open non-blocking so we don't hang if no reader is attached
  94. fd = os.open(str(WAKE_FIFO), os.O_WRONLY | os.O_NONBLOCK)
  95. try:
  96. os.write(fd, b"wake\n")
  97. logger.info("Wake signal sent via FIFO")
  98. finally:
  99. os.close(fd)
  100. except OSError as e:
  101. # ENXIO = no reader on the FIFO (idle script not running) — expected
  102. if e.errno != 6: # ENXIO
  103. logger.debug("Wake FIFO write failed: %s", e)