| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151 |
- """Display brightness and screen blanking control for SpoolBuddy kiosk.
- Brightness: DSI backlights are controlled via sysfs /sys/class/backlight/*/brightness.
- HDMI brightness is handled by the frontend via CSS filter.
- Blanking: swayidle is the sole authority on screen blanking (idle timeout →
- wlopm --off, touch → wlopm --on). The daemon wakes the display by
- writing to a FIFO that the idle watchdog monitors — the watchdog
- runs inside the Wayland session and calls wlopm --on on behalf of
- the daemon. The same FIFO carries "reload-timeout N" lines: when
- the user changes the blank-timeout setting in the UI, the daemon
- picks up the new value over the heartbeat and signals the watchdog
- to kill+restart swayidle with the new timeout, so changes take
- effect live without a kiosk restart.
- """
- import logging
- import os
- import stat
- import time
- from pathlib import Path
- logger = logging.getLogger(__name__)
- BACKLIGHT_BASE = Path("/sys/class/backlight")
- WAKE_FIFO = Path("/tmp/spoolbuddy-wake")
- class DisplayControl:
- def __init__(self):
- self._backlight_path = self._find_backlight()
- self._max_brightness = self._read_max_brightness()
- self._blank_timeout = 0 # seconds, 0 = disabled
- self._timeout_initialized = False
- self._last_activity = time.monotonic()
- self._blanked = False
- if self._backlight_path:
- logger.info("Backlight found: %s (max=%d)", self._backlight_path, self._max_brightness)
- else:
- logger.info("No DSI backlight found, brightness control via frontend CSS")
- def _find_backlight(self) -> Path | None:
- if not BACKLIGHT_BASE.exists():
- return None
- for entry in BACKLIGHT_BASE.iterdir():
- brightness_file = entry / "brightness"
- if brightness_file.exists():
- return entry
- return None
- def _read_max_brightness(self) -> int:
- if not self._backlight_path:
- return 100
- try:
- return int((self._backlight_path / "max_brightness").read_text().strip())
- except Exception:
- return 255
- @property
- def has_backlight(self) -> bool:
- return self._backlight_path is not None
- def set_brightness(self, pct: int):
- """Set backlight brightness (0-100%). No-op if no backlight."""
- if not self._backlight_path:
- return
- pct = max(0, min(100, pct))
- value = round(self._max_brightness * pct / 100)
- try:
- (self._backlight_path / "brightness").write_text(str(value))
- logger.debug("Brightness set to %d%% (%d/%d)", pct, value, self._max_brightness)
- except PermissionError:
- logger.warning(
- "Permission denied writing to %s/brightness. Ensure spoolbuddy user is in the 'video' group.",
- self._backlight_path,
- )
- except Exception as e:
- logger.warning("Failed to set brightness: %s", e)
- def set_blank_timeout(self, seconds: int):
- """Set screen blank timeout in seconds. 0 = disabled.
- On every change after the first call, signals the idle watchdog
- (spoolbuddy-idle.sh) to restart swayidle with the new value.
- Without this, swayidle keeps running with whatever timeout it
- was started with at autostart and UI changes only take effect
- after a kiosk restart.
- The first call (during daemon startup) is suppressed because the
- watchdog already fetched the same value from the backend at its
- own startup; signalling here would just thrash swayidle.
- """
- new_timeout = max(0, seconds)
- changed = new_timeout != self._blank_timeout
- self._blank_timeout = new_timeout
- if changed and self._timeout_initialized:
- self._signal_reload_timeout(new_timeout)
- self._timeout_initialized = True
- def wake(self):
- """Wake screen on activity (NFC tag, scale weight change).
- Writes to /tmp/spoolbuddy-wake FIFO which the idle watchdog
- (spoolbuddy-idle.sh) monitors inside the Wayland session. The
- watchdog calls wlopm --on on our behalf. No-op if the FIFO
- doesn't exist (kiosk not running or blanking disabled without FIFO).
- """
- self._last_activity = time.monotonic()
- self._blanked = False
- self._signal_wake()
- def tick(self):
- """Called periodically from heartbeat loop. Tracks idle state internally."""
- if self._blank_timeout <= 0:
- self._blanked = False
- return
- idle = time.monotonic() - self._last_activity
- if not self._blanked and idle >= self._blank_timeout:
- self._blanked = True
- logger.debug("Screen idle timeout reached (swayidle manages blanking)")
- def _signal_wake(self) -> None:
- """Write to the wake FIFO to request display power-on."""
- if self._write_fifo(b"wake\n"):
- logger.info("Wake signal sent via FIFO")
- def _signal_reload_timeout(self, seconds: int) -> None:
- """Tell the idle watchdog to apply a new timeout to swayidle."""
- if self._write_fifo(f"reload-timeout {seconds}\n".encode()):
- logger.info("Reload-timeout signal sent (timeout=%ds)", seconds)
- def _write_fifo(self, payload: bytes) -> bool:
- """Best-effort write to the wake FIFO. Returns True on success."""
- if not WAKE_FIFO.exists():
- return False
- try:
- # Verify it's actually a FIFO, not a regular file
- if not stat.S_ISFIFO(WAKE_FIFO.stat().st_mode):
- return False
- # Open non-blocking so we don't hang if no reader is attached
- fd = os.open(str(WAKE_FIFO), os.O_WRONLY | os.O_NONBLOCK)
- try:
- os.write(fd, payload)
- return True
- finally:
- os.close(fd)
- except OSError as e:
- # ENXIO = no reader on the FIFO (idle script not running) — expected
- if e.errno != 6: # ENXIO
- logger.debug("FIFO write failed: %s", e)
- return False
|