display_control.py 3.4 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495
  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: Handled entirely by the frontend (CSS black overlay with touch-to-wake).
  5. The daemon tracks idle state but does not control the physical display.
  6. """
  7. import logging
  8. import time
  9. from pathlib import Path
  10. logger = logging.getLogger(__name__)
  11. BACKLIGHT_BASE = Path("/sys/class/backlight")
  12. class DisplayControl:
  13. def __init__(self):
  14. self._backlight_path = self._find_backlight()
  15. self._max_brightness = self._read_max_brightness()
  16. self._blank_timeout = 0 # seconds, 0 = disabled
  17. self._last_activity = time.monotonic()
  18. self._blanked = False
  19. if self._backlight_path:
  20. logger.info("Backlight found: %s (max=%d)", self._backlight_path, self._max_brightness)
  21. else:
  22. logger.info("No DSI backlight found, brightness control via frontend CSS")
  23. def _find_backlight(self) -> Path | None:
  24. if not BACKLIGHT_BASE.exists():
  25. return None
  26. for entry in BACKLIGHT_BASE.iterdir():
  27. brightness_file = entry / "brightness"
  28. if brightness_file.exists():
  29. return entry
  30. return None
  31. def _read_max_brightness(self) -> int:
  32. if not self._backlight_path:
  33. return 100
  34. try:
  35. return int((self._backlight_path / "max_brightness").read_text().strip())
  36. except Exception:
  37. return 255
  38. @property
  39. def has_backlight(self) -> bool:
  40. return self._backlight_path is not None
  41. def set_brightness(self, pct: int):
  42. """Set backlight brightness (0-100%). No-op if no backlight."""
  43. if not self._backlight_path:
  44. return
  45. pct = max(0, min(100, pct))
  46. value = round(self._max_brightness * pct / 100)
  47. try:
  48. (self._backlight_path / "brightness").write_text(str(value))
  49. logger.debug("Brightness set to %d%% (%d/%d)", pct, value, self._max_brightness)
  50. except PermissionError:
  51. logger.warning(
  52. "Permission denied writing to %s/brightness. Ensure spoolbuddy user is in the 'video' group.",
  53. self._backlight_path,
  54. )
  55. except Exception as e:
  56. logger.warning("Failed to set brightness: %s", e)
  57. def set_blank_timeout(self, seconds: int):
  58. """Set screen blank timeout in seconds. 0 = disabled."""
  59. self._blank_timeout = max(0, seconds)
  60. def wake(self):
  61. """Wake screen on activity (NFC tag, scale weight change)."""
  62. self._last_activity = time.monotonic()
  63. if self._blanked:
  64. self._unblank()
  65. def tick(self):
  66. """Called periodically from heartbeat loop. Blanks screen if idle."""
  67. if self._blank_timeout <= 0:
  68. if self._blanked:
  69. self._unblank()
  70. return
  71. idle = time.monotonic() - self._last_activity
  72. if not self._blanked and idle >= self._blank_timeout:
  73. self._blank()
  74. def _blank(self):
  75. self._blanked = True
  76. logger.debug("Screen idle timeout reached (frontend handles blanking)")
  77. def _unblank(self):
  78. self._blanked = False
  79. logger.debug("Activity detected (frontend handles unblanking)")