Files
bambuddy/spoolbuddy/daemon/display_control.py
T
maziggy 48b492f619 Spoolbuddy
1. Device tab — repo URL unlinked

  Replaced the <a> tag with a <span> so the GitHub URL is displayed as plain text (no clickable link in kiosk mode).

  2. Display controls — moved to frontend

  Root cause: The daemon runs as a system user without Wayland access, and the kiosk uses an HDMI display (no sysfs backlight). Neither wlopm, vcgencmd, nor sysfs writes
  can work in this setup.

  Fix — frontend-controlled display in SpoolBuddyLayout.tsx:
  - Brightness: CSS filter: brightness(X) applied to the layout root. Immediate visual dimming that works on any display type.
  - Screen blanking: Full-screen black overlay (z-[9999]) shown after the configured inactivity timeout. Touch anywhere to wake.
  - Activity tracking: Resets on pointerdown/keydown events AND on WebSocket-driven NFC/scale changes (weight update, tag scan).
  - Queries device data every 15s to read the current brightness and blank timeout values.

  Daemon display_control.py — Simplified:
  - Removed vcgencmd/wlopm/subprocess usage entirely
  - _blank/_unblank are now state-tracking no-ops (log only)
  - DSI backlight brightness via sysfs is preserved as a bonus for DSI displays
  - Updated docstring to explain the architecture

  3. Scale tab — redesigned with step indicator

  Idle state: Same as before — weight card with tare/calibrate buttons.

  Calibration wizard: Two-column layout:
  - Left column (~64px): Vertical step indicator with numbered circles, checkmark for completed steps, and a connecting line
  - Right column: Live weight bar (with stable/settling indicator), step-specific content (instructions or numpad), and action buttons pinned to bottom via mt-auto

  The numpad buttons are min-h-[52px] with text-lg — large enough for touch on 1024x600. The weight info card is hidden during the weight entry step to maximize vertical
  space.
2026-03-02 11:58:10 +01:00

96 lines
3.4 KiB
Python

"""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: Handled entirely by the frontend (CSS black overlay with touch-to-wake).
The daemon tracks idle state but does not control the physical display.
"""
import logging
import time
from pathlib import Path
logger = logging.getLogger(__name__)
BACKLIGHT_BASE = Path("/sys/class/backlight")
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._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."""
self._blank_timeout = max(0, seconds)
def wake(self):
"""Wake screen on activity (NFC tag, scale weight change)."""
self._last_activity = time.monotonic()
if self._blanked:
self._unblank()
def tick(self):
"""Called periodically from heartbeat loop. Blanks screen if idle."""
if self._blank_timeout <= 0:
if self._blanked:
self._unblank()
return
idle = time.monotonic() - self._last_activity
if not self._blanked and idle >= self._blank_timeout:
self._blank()
def _blank(self):
self._blanked = True
logger.debug("Screen idle timeout reached (frontend handles blanking)")
def _unblank(self):
self._blanked = False
logger.debug("Activity detected (frontend handles unblanking)")