from __future__ import annotations import logging import os import json from bisect import bisect_right import threading import time from uuid import uuid4 from dataclasses import dataclass from pathlib import Path from typing import Callable from PIL import Image from app.animations.constants import MAX_DURATION_MS, MIN_DURATION_MS from app.config.store import ConfigStore, MATRIX_REFRESH_RATE_LIMITS from app.power.protection import ( LOW_VOLTAGE_CRITICAL_BRIGHTNESS_PERCENT, LowVoltageProtectionDirective, ) from .colors import RGB, validate_rgb from .diagnostics import render_diagnostic from .low_voltage_indicator import render_low_voltage_indicator from .matrix_driver import MatrixDisplayDriver, get_default_driver_options from .mock_driver import MockDisplayDriver from .ota_indicator import OTA_INDICATOR_BRIGHTNESS, render_ota_indicator from .startup_indicator import ( CLOSED_EYE_SECONDS, OPEN_EYE_SECONDS, STARTUP_BRIGHTNESS, StartupSessionStore, render_startup_smile, ) from .text_renderer import FontResolver, TextOptions, render_text from .transforms import apply_orientation, apply_row_bit_order, get_row_bit_order from .wifi_indicator import ( WIFI_INDICATOR_BRIGHTNESS, WIFI_SCROLL_FPS, WIFI_SCROLL_GAP_PIXELS, WIFI_SCROLL_PIXELS_PER_SECOND, render_wifi_indicator, text_width, ) logger = logging.getLogger(__name__) _PROTECTION_MODES = {"disabled", "unavailable", "inactive", "limiting", "critical"} DEFAULT_DISPLAY_TEST_BRIGHTNESS = 50 UNSAVED_CONTENT = {"category": "unsaved", "id": None, "name": "未保存内容"} ANIMATION_PLAYBACK_SPEEDS = (0.5, 1.0, 1.5, 2.0) class DisplayTestInactiveError(RuntimeError): """Raised when a temporary test-only control has no active session.""" class AnimationPlaybackConflictError(RuntimeError): """Raised when a playback command does not target the active session.""" @dataclass(frozen=True) class _SelectedOutput: frame: Image.Image brightness: int mode: str class DisplayService: width = 64 height = 64 frame_bytes = width * height * 3 def __init__( self, config_store: ConfigStore, driver_kind: str | None = None, startup_boot_id: str | None = None, font_resolver: FontResolver | None = None, ) -> None: self.config_store = config_store self._font_resolver = font_resolver self._lock = threading.RLock() self._state_revision = 0 self._orientation = int(self.config_store.config["orientation"]) self._brightness = int(self.config_store.config["brightness"]) self._current_frame = Image.new("RGB", (self.width, self.height), (0, 0, 0)) self._current_content = dict(UNSAVED_CONTENT) self._row_bit_order = get_row_bit_order() self._startup_session = StartupSessionStore( self.config_store.runtime_dir, boot_id=startup_boot_id, ) self._startup_active = False self._startup_frame = render_startup_smile() self._startup_stop = threading.Event() self._startup_thread: threading.Thread | None = None self._wifi_active = False self._wifi_connected = False self._wifi_message = "" self._wifi_frame = Image.new("RGB", (self.width, self.height), (0, 0, 0)) self._wifi_stop = threading.Event() self._wifi_thread: threading.Thread | None = None self._wifi_generation = 0 self._activity_listener: Callable[[], None] | None = None self._animation_active = False self._animation_id: str | None = None self._animation_revision: str | None = None self._animation_frames: tuple[dict, ...] = () self._animation_index = 0 self._animation_generation = 0 self._animation_session_id: str | None = None self._animation_boundaries_ms: tuple[int, ...] = () self._animation_total_duration_ms = 0 self._animation_position_anchor_ms = 0.0 self._animation_anchor_monotonic = time.monotonic() self._animation_paused = False self._animation_speed = 1.0 self._animation_condition = threading.Condition(self._lock) self._animation_thread: threading.Thread | None = None self._display_test_active = False self._display_test_color: RGB | None = None self._display_test_frame = Image.new("RGB", (self.width, self.height), (0, 0, 0)) self._display_test_brightness = DEFAULT_DISPLAY_TEST_BRIGHTNESS self._ota_active = False self._ota_percent = 0 self._ota_stage = "准备更新" self._ota_frame = render_ota_indicator() self._ota_status_path: Path | None = None self._ota_stop = threading.Event() self._ota_thread: threading.Thread | None = None self._low_voltage_frame = render_low_voltage_indicator() self._protection_sequence = -1 self._protection_enforced_sequence = -1 self._protection_enabled = False self._protection_mode = "disabled" self._protection_brightness_limit: int | None = None self._protection_reading_stale = False self._effective_brightness = self._brightness self._visible_frame = self._current_frame.copy() self._visible_orientation = self._orientation self._output_dirty = False self._driver_kind = ( driver_kind or os.environ.get("MATRIX_DRIVER") or "auto" ).lower() self.driver = self._create_driver( int(self.config_store.config["matrix_refresh_rate_limit_hz"]) ) def set_activity_listener(self, listener: Callable[[], None] | None) -> None: self._activity_listener = listener def _notify_user_activity(self) -> None: listener = self._activity_listener if listener is None: return try: listener() except Exception: logger.exception("Failed to notify the user-activity listener") def start_startup_indicator(self) -> bool: with self._lock: if self._startup_active or not self._startup_session.should_show(): return False frame = render_startup_smile() target = self._select_output_locked( startup_active=True, startup_frame=frame, ) try: self._submit_output_locked(target.frame, target.brightness) except Exception: logger.exception("Failed to start the startup indicator") self._try_reconcile_output_locked( "Failed to restore the display after startup indicator error" ) return False self._startup_frame = frame self._startup_active = True self._startup_stop.clear() self._startup_thread = threading.Thread( target=self._animate_startup_indicator, name="startup-smile", daemon=True, ) self._startup_thread.start() logger.info( "Started the startup smile at %s%% effective brightness", target.brightness, ) return True def clear(self) -> None: image = Image.new("RGB", (self.width, self.height), (0, 0, 0)) self.show_image(image, mode="clear") def fill(self, color: RGB) -> None: color = validate_rgb(color) image = Image.new("RGB", (self.width, self.height), color) self.show_image(image, mode="fill") def show_image( self, image: Image.Image, mode: str = "image", *, content_source: dict | None = None, notify_activity: bool = True, ) -> None: rgb_image = image.convert("RGB") if rgb_image.size != (self.width, self.height): raise ValueError("image size must be 64x64") with self._lock: startup_was_active = self._startup_active wifi_was_active = self._wifi_active target = self._select_output_locked( current_frame=rgb_image, startup_active=False, wifi_active=False, animation_active=False, display_test_active=False, ) try: self._submit_output_locked(target.frame, target.brightness) self.config_store.set_last_mode(mode) except Exception: self._try_reconcile_output_locked( "Failed to restore the display after image update error" ) raise self._current_frame = rgb_image.copy() self._current_content = self._checked_content_source(content_source) self._display_test_active = False self._display_test_color = None self._stop_animation_locked() if startup_was_active: self._finish_startup_indicator_locked() if wifi_was_active: self._finish_wifi_indicator_locked() self._state_revision += 1 if notify_activity: self._notify_user_activity() def start_animation( self, animation_id: str, revision: str, frames: list[dict], *, content_source: dict | None = None, notify_activity: bool = True, ) -> None: if not frames: raise ValueError("animation must contain at least one frame") checked: list[dict] = [] boundaries: list[int] = [] total_duration_ms = 0 for item in frames: image = item.get("image") path = item.get("path") duration_ms = item.get("duration_ms") if image is not None: if not isinstance(image, Image.Image) or image.size != (self.width, self.height): raise ValueError("animation frame must be a 64x64 image") descriptor = {"image": image.convert("RGB").copy()} elif path is not None: checked_path = Path(path) if not checked_path.is_file(): raise ValueError("animation frame cache is missing") descriptor = {"path": checked_path} else: raise ValueError("animation frame must provide image or path") if type(duration_ms) is not int or not MIN_DURATION_MS <= duration_ms <= MAX_DURATION_MS: raise ValueError( f"animation duration_ms must be in {MIN_DURATION_MS}..{MAX_DURATION_MS}" ) checked.append({"id": str(item["id"]), **descriptor, "duration_ms": duration_ms}) total_duration_ms += duration_ms boundaries.append(total_duration_ms) with self._lock: first = checked[0] first_image = self._animation_frame_image(first) startup_was_active = self._startup_active wifi_was_active = self._wifi_active target = self._select_output_locked( startup_active=False, wifi_active=False, animation_active=True, animation_frame=first_image, display_test_active=False, ) try: self._submit_output_locked(target.frame, target.brightness, synchronized=True) except Exception: self._try_reconcile_output_locked("Failed to restore display after animation start error") raise self._stop_animation_locked() self._display_test_active = False self._display_test_color = None self._animation_active = True self._animation_id = str(animation_id) self._animation_revision = str(revision) self._animation_frames = tuple(checked) self._animation_session_id = str(uuid4()) self._animation_boundaries_ms = tuple(boundaries) self._animation_total_duration_ms = total_duration_ms self._animation_position_anchor_ms = 0.0 self._animation_anchor_monotonic = time.monotonic() self._animation_paused = False self._animation_speed = 1.0 self._current_content = self._checked_content_source( content_source or { "category": "animation", "id": str(animation_id), "name": "动图", } ) self._animation_index = 0 self._animation_generation += 1 generation = self._animation_generation self._animation_condition.notify_all() if startup_was_active: self._finish_startup_indicator_locked() if wifi_was_active: self._finish_wifi_indicator_locked() self._state_revision += 1 self._animation_thread = threading.Thread( target=self._animate_user_animation, args=(generation,), name="user-animation", daemon=True, ) self._animation_thread.start() if notify_activity: self._notify_user_activity() def is_animation_active(self, animation_id: str | None = None) -> bool: with self._lock: return self._animation_active and (animation_id is None or self._animation_id == animation_id) def control_animation_playback( self, session_id: str, *, position_ms: int | None = None, paused: bool | None = None, speed: float | None = None, ) -> dict: if position_ms is None and paused is None and speed is None: raise ValueError("at least one animation playback control is required") if not isinstance(session_id, str) or not session_id: raise ValueError("session_id must not be empty") if position_ms is not None and (isinstance(position_ms, bool) or not isinstance(position_ms, int)): raise ValueError("position_ms must be an integer") if paused is not None and type(paused) is not bool: raise ValueError("paused must be a boolean") if speed is not None: if isinstance(speed, bool) or not isinstance(speed, (int, float)): raise ValueError("speed must be a supported number") speed = float(speed) if speed not in ANIMATION_PLAYBACK_SPEEDS: raise ValueError("speed must be one of 0.5, 1, 1.5 or 2") with self._lock: if not self._animation_active or session_id != self._animation_session_id: raise AnimationPlaybackConflictError("animation playback session is no longer active") if position_ms is not None and not 0 <= position_ms < self._animation_total_duration_ms: raise ValueError( f"position_ms must be in 0..{self._animation_total_duration_ms - 1}" ) now = time.monotonic() current_position = self._animation_position_locked(now) next_position = float(position_ms) if position_ms is not None else current_position next_paused = self._animation_paused if paused is None else paused next_speed = self._animation_speed if speed is None else speed next_index = self._animation_index_for_position_locked(next_position) if position_ms is not None or next_index != self._animation_index: frame = self._animation_frame_image(self._animation_frames[next_index]) target = self._select_output_locked(animation_frame=frame) self._submit_output_locked(target.frame, target.brightness, synchronized=True) self._animation_position_anchor_ms = next_position self._animation_anchor_monotonic = now self._animation_paused = next_paused self._animation_speed = next_speed self._animation_index = next_index self._state_revision += 1 self._animation_condition.notify_all() return self._animation_playback_status_locked(now) def show_rgb_bytes( self, data: bytes, mode: str = "canvas", *, content_source: dict | None = None, ) -> None: if len(data) != self.frame_bytes: raise ValueError("frame size must be 64x64 RGB888") image = Image.frombytes("RGB", (self.width, self.height), data) self.show_image(image, mode=mode, content_source=content_source) def show_text(self, options: TextOptions) -> None: image = render_text( options, size=(self.width, self.height), font_resolver=self._font_resolver, ) self.show_image(image, mode="text") def show_diagnostic(self, diagnostic_mode: str) -> None: image = render_diagnostic(diagnostic_mode, size=(self.width, self.height)) self.show_image(image, mode=f"diagnostic:{diagnostic_mode}") def start_display_test_fill(self, color: RGB) -> dict: checked = validate_rgb(color) frame = Image.new("RGB", (self.width, self.height), checked) with self._lock: brightness = ( self._display_test_brightness if self._display_test_active else DEFAULT_DISPLAY_TEST_BRIGHTNESS ) target = self._select_output_locked( display_test_active=True, display_test_frame=frame, display_test_brightness=brightness, ) self._submit_output_locked(target.frame, target.brightness) self._display_test_active = True self._display_test_color = checked self._display_test_frame = frame self._display_test_brightness = brightness self._state_revision += 1 result = self._display_test_status_locked() self._notify_user_activity() return result def set_display_test_brightness(self, value: int) -> dict: if isinstance(value, bool) or not isinstance(value, int) or not 1 <= value <= 100: raise ValueError("brightness must be in 1..100") with self._lock: if not self._display_test_active: raise DisplayTestInactiveError("display test is not active") target = self._select_output_locked(display_test_brightness=value) self._submit_output_locked(target.frame, target.brightness) self._display_test_brightness = value self._state_revision += 1 return self._display_test_status_locked() def stop_display_test(self) -> dict: with self._lock: if not self._display_test_active: return self._display_test_status_locked() target = self._select_output_locked(display_test_active=False) self._submit_output_locked( target.frame, target.brightness, synchronized=self._animation_active, ) self._display_test_active = False self._display_test_color = None self._state_revision += 1 return self._display_test_status_locked() def start_ota_indicator(self, status_path: Path) -> None: path = Path(status_path) percent, stage = self._read_ota_progress(path) frame = render_ota_indicator(percent, stage) with self._lock: target = self._select_output_locked( ota_active=True, ota_frame=frame, ) self._submit_output_locked(target.frame, target.brightness) self._ota_active = True self._ota_percent = percent self._ota_stage = stage self._ota_frame = frame self._ota_status_path = path if self._ota_thread is None or not self._ota_thread.is_alive(): self._ota_stop.clear() self._ota_thread = threading.Thread( target=self._animate_ota_indicator, name="ota-indicator", daemon=True, ) self._ota_thread.start() self._state_revision += 1 def stop_ota_indicator(self) -> None: thread = None with self._lock: if not self._ota_active: return target = self._select_output_locked(ota_active=False) self._submit_output_locked( target.frame, target.brightness, synchronized=self._animation_active, ) self._ota_active = False self._ota_status_path = None self._ota_stop.set() thread = self._ota_thread self._state_revision += 1 if thread is not None and thread is not threading.current_thread(): thread.join(timeout=2.0) @staticmethod def _read_ota_progress(path: Path) -> tuple[int, str]: try: document = json.loads(path.read_text(encoding="utf-8")) job = document.get("job") if isinstance(document, dict) else None percent = int(job.get("percent", 0)) if isinstance(job, dict) else 0 stage = str(job.get("message") or job.get("phase") or "准备更新") if isinstance(job, dict) else "准备更新" return max(0, min(100, percent)), stage[:80] except (OSError, UnicodeError, json.JSONDecodeError, TypeError, ValueError): return 0, "准备更新" def _animate_ota_indicator(self) -> None: pulse = 0 while not self._ota_stop.wait(0.25): with self._lock: if not self._ota_active or self._ota_status_path is None: return status_path = self._ota_status_path percent, stage = self._read_ota_progress(status_path) pulse = (pulse + 1) % 4 frame = render_ota_indicator(percent, f"{stage}:{pulse}") with self._lock: if not self._ota_active: return target = self._select_output_locked(ota_frame=frame) try: self._submit_output_locked(target.frame, target.brightness) self._ota_percent = percent self._ota_stage = stage self._ota_frame = frame except Exception: logger.exception("Failed to submit an OTA indicator frame") def set_orientation(self, value: int) -> None: if value not in {0, 90, 180, 270}: raise ValueError("orientation must be one of 0, 90, 180, 270") with self._lock: startup_was_active = self._startup_active wifi_was_active = self._wifi_active if value == self._orientation and not startup_was_active and not wifi_was_active: if self._output_dirty: self._reconcile_output_locked() return previous = self._orientation target = self._select_output_locked(startup_active=False, wifi_active=False) try: self._submit_output_locked( target.frame, target.brightness, orientation=value, ) if value != previous: self.config_store.update({"orientation": value}) except Exception: self._try_reconcile_output_locked( "Failed to restore the display after orientation update error" ) raise self._orientation = value if startup_was_active: self._finish_startup_indicator_locked() if wifi_was_active: self._finish_wifi_indicator_locked() self._state_revision += 1 self._notify_user_activity() def set_brightness(self, value: int) -> None: if value < 1 or value > 100: raise ValueError("brightness must be in 1..100") with self._lock: startup_was_active = self._startup_active wifi_was_active = self._wifi_active if value == self._brightness and not startup_was_active and not wifi_was_active: if self._output_dirty: self._reconcile_output_locked() return previous = self._brightness target = self._select_output_locked( brightness=value, startup_active=False, wifi_active=False, ) try: self._submit_output_locked(target.frame, target.brightness) if value != previous: self.config_store.update({"brightness": value}) except Exception: self._try_reconcile_output_locked( "Failed to restore display after brightness update error" ) raise self._brightness = value if startup_was_active: self._finish_startup_indicator_locked() if wifi_was_active: self._finish_wifi_indicator_locked() self._state_revision += 1 self._notify_user_activity() def show_wifi_indicator(self, *, connected: bool, message: str) -> None: if not isinstance(message, str) or not message: raise ValueError("WiFi indicator message must not be empty") with self._lock: if ( self._wifi_active and self._wifi_connected == connected and self._wifi_message == message ): return frame = render_wifi_indicator(message, offset=0.0, connected=connected) target = self._select_output_locked( wifi_active=True, wifi_frame=frame, startup_active=False, ) self._submit_output_locked(target.frame, target.brightness) if self._startup_active: self._finish_startup_indicator_locked() self._wifi_active = True self._wifi_connected = connected self._wifi_message = message self._wifi_frame = frame self._wifi_generation += 1 generation = self._wifi_generation self._wifi_stop.clear() self._wifi_thread = threading.Thread( target=self._animate_wifi_indicator, args=(generation,), name="wifi-indicator", daemon=True, ) self._wifi_thread.start() def dismiss_wifi_indicator(self) -> bool: with self._lock: if not self._wifi_active: return False self._finish_wifi_indicator_locked() self._reconcile_output_locked() return True def set_refresh_rate_limit(self, value: int) -> None: if type(value) is not int or value not in MATRIX_REFRESH_RATE_LIMITS: allowed = ", ".join(str(item) for item in sorted(MATRIX_REFRESH_RATE_LIMITS)) raise ValueError(f"refresh rate limit must be one of {allowed}") with self._lock: previous = int( self.config_store.config["matrix_refresh_rate_limit_hz"] ) if value == previous: return try: self.driver.set_refresh_rate_limit(value) self.config_store.update( {"matrix_refresh_rate_limit_hz": value} ) except Exception as exc: try: self.driver.set_refresh_rate_limit(previous) except Exception as rollback_exc: logger.exception( "Failed to restore native refresh rate after update error" ) raise RuntimeError( "Refresh-rate update failed and native rollback also failed: " f"{rollback_exc}" ) from exc raise RuntimeError( f"Failed to apply refresh-rate limit {value}Hz; " "the saved setting was not changed" ) from exc self._state_revision += 1 def apply_power_protection(self, directive: LowVoltageProtectionDirective) -> None: """Apply the newest voltage-protection output overlay. The directive becomes the desired state before driver I/O starts. If enforcement partially fails, later user activity and an equal-sequence monitor retry both continue toward that same safe target. """ sequence = directive.sequence enabled = directive.enabled mode = directive.mode brightness_limit = directive.brightness_limit_percent reading_stale = directive.reading_stale if isinstance(sequence, bool) or not isinstance(sequence, int) or sequence < 0: raise ValueError("protection sequence must be a non-negative integer") if not isinstance(enabled, bool): raise ValueError("protection enabled must be a boolean") if mode not in _PROTECTION_MODES: raise ValueError(f"unsupported protection mode: {mode}") if brightness_limit is not None and ( isinstance(brightness_limit, bool) or not isinstance(brightness_limit, int) or brightness_limit < 1 or brightness_limit > 100 ): raise ValueError("protection brightness limit must be in 1..100 or null") if not isinstance(reading_stale, bool): raise ValueError("protection reading_stale must be a boolean") incoming = (enabled, mode, brightness_limit, reading_stale) with self._lock: current = ( self._protection_enabled, self._protection_mode, self._protection_brightness_limit, self._protection_reading_stale, ) if sequence < self._protection_sequence: return if sequence == self._protection_sequence and incoming != current: logger.warning( "Ignoring conflicting low-voltage directive sequence %s", sequence, ) return if ( sequence == self._protection_enforced_sequence and not self._output_dirty ): return if sequence > self._protection_sequence: self._protection_sequence = sequence self._protection_enabled = enabled self._protection_mode = mode self._protection_brightness_limit = brightness_limit self._protection_reading_stale = reading_stale self._reconcile_output_locked() self._protection_enforced_sequence = sequence def get_current_frame(self) -> Image.Image: with self._lock: return self._visible_frame.copy() def get_state_revision(self) -> int: with self._lock: return self._state_revision def get_status(self) -> dict: with self._lock: config = self.config_store.config driver_status = self.driver.get_status() selected = self._select_output_locked() playback_status = self._animation_playback_status_locked(time.monotonic()) return { "ok": True, "screen": { "width": self.width, "height": self.height, "driver": driver_status["driver"], "driver_available": driver_status["driver_available"], "driver_error": driver_status.get("driver_error"), "hardware_mapping": driver_status["hardware_mapping"], "driver_options": driver_status.get("driver_options", {}), "driver_status": driver_status.get("driver_status", {}), }, "state": { "mode": selected.mode, "orientation": config["orientation"], "brightness": config["brightness"], "effective_brightness": self._effective_brightness, "startup_indicator_active": self._startup_active, "wifi_indicator_active": self._wifi_active, "wifi_indicator_connected": self._wifi_connected if self._wifi_active else None, "animation_active": self._animation_active, "animation_id": self._animation_id if self._animation_active else None, "animation_revision": self._animation_revision if self._animation_active else None, "animation_frame_id": self._animation_frames[self._animation_index]["id"] if self._animation_active else None, "animation_frame_index": self._animation_index + 1 if self._animation_active else None, "animation_playback": playback_status, "current_content": self._current_content_status_locked(selected), "display_test": self._display_test_status_locked(), "ota_update": { "active": self._ota_active, "percent": self._ota_percent if self._ota_active else None, "stage": self._ota_stage if self._ota_active else None, }, "revision": self._state_revision, }, } def close(self) -> None: thread = None wifi_thread = None animation_thread = None ota_thread = None with self._lock: self._startup_active = False self._startup_stop.set() thread = self._startup_thread self._wifi_active = False self._wifi_stop.set() self._wifi_generation += 1 wifi_thread = self._wifi_thread self._stop_animation_locked() self._display_test_active = False self._display_test_color = None self._ota_active = False self._ota_stop.set() ota_thread = self._ota_thread animation_thread = self._animation_thread if thread is not None and thread is not threading.current_thread(): thread.join(timeout=2.0) if wifi_thread is not None and wifi_thread is not threading.current_thread(): wifi_thread.join(timeout=2.0) if animation_thread is not None and animation_thread is not threading.current_thread(): animation_thread.join(timeout=2.0) if ota_thread is not None and ota_thread is not threading.current_thread(): ota_thread.join(timeout=2.0) with self._lock: self.driver.close() def _transform(self, image: Image.Image, orientation: int | None = None) -> Image.Image: oriented = apply_orientation( image, self._orientation if orientation is None else orientation, ) return apply_row_bit_order(oriented, self._row_bit_order) def _animate_startup_indicator(self) -> None: eyes_closed = False delay = OPEN_EYE_SECONDS while not self._startup_stop.wait(delay): with self._lock: if not self._startup_active: return eyes_closed = not eyes_closed frame = render_startup_smile(eyes_closed=eyes_closed) if self._display_test_active: self._startup_frame = frame delay = CLOSED_EYE_SECONDS if eyes_closed else OPEN_EYE_SECONDS continue target = self._select_output_locked(startup_frame=frame) try: self._submit_output_locked(target.frame, target.brightness) self._startup_frame = frame except Exception: logger.exception("Failed to submit a startup smile frame") delay = CLOSED_EYE_SECONDS if eyes_closed else OPEN_EYE_SECONDS def _animation_frame_image(self, frame: dict) -> Image.Image: image = frame.get("image") if isinstance(image, Image.Image): return image path = frame.get("path") try: with Image.open(path) as cached: image = cached.convert("RGB").copy() except (OSError, ValueError, TypeError) as exc: raise RuntimeError("animation frame cache is unreadable") from exc if image.size != (self.width, self.height): raise RuntimeError("animation frame cache must be 64x64") return image def _animation_position_locked(self, now: float | None = None) -> float: if not self._animation_active or self._animation_total_duration_ms <= 0: return 0.0 position = self._animation_position_anchor_ms if not self._animation_paused: sampled_at = time.monotonic() if now is None else now position += max(0.0, sampled_at - self._animation_anchor_monotonic) * 1000 * self._animation_speed return position % self._animation_total_duration_ms def _animation_index_for_position_locked(self, position_ms: float) -> int: if not self._animation_boundaries_ms: return 0 normalized = position_ms % self._animation_total_duration_ms return min( len(self._animation_boundaries_ms) - 1, bisect_right(self._animation_boundaries_ms, normalized), ) def _animation_playback_status_locked(self, now: float | None = None) -> dict: if not self._animation_active: return { "active": False, "session_id": None, "animation_id": None, "animation_revision": None, "frame_id": None, "frame_index": None, "frame_count": None, "position_ms": None, "total_duration_ms": None, "paused": None, "speed": None, "supported_speeds": list(ANIMATION_PLAYBACK_SPEEDS), } position = self._animation_position_locked(now) return { "active": True, "session_id": self._animation_session_id, "animation_id": self._animation_id, "animation_revision": self._animation_revision, "frame_id": self._animation_frames[self._animation_index]["id"], "frame_index": self._animation_index + 1, "frame_count": len(self._animation_frames), "position_ms": min( self._animation_total_duration_ms - 1, max(0, int(position)), ), "total_duration_ms": self._animation_total_duration_ms, "paused": self._animation_paused, "speed": self._animation_speed, "supported_speeds": list(ANIMATION_PLAYBACK_SPEEDS), } def _animate_user_animation(self, generation: int) -> None: while True: with self._animation_condition: if not self._animation_active or generation != self._animation_generation: return if self._animation_paused: self._animation_condition.wait() continue now = time.monotonic() position = self._animation_position_locked(now) next_index = self._animation_index_for_position_locked(position) if next_index != self._animation_index: try: frame = self._animation_frame_image(self._animation_frames[next_index]) if not self._display_test_active: target = self._select_output_locked(animation_frame=frame) self._submit_output_locked(target.frame, target.brightness, synchronized=True) self._animation_index = next_index except Exception: logger.exception("Failed to submit an animation frame") frame_end_ms = self._animation_boundaries_ms[next_index] remaining_ms = frame_end_ms - position if remaining_ms <= 0: remaining_ms = 1 self._animation_condition.wait( timeout=max(0.001, remaining_ms / (1000 * self._animation_speed)) ) def _animate_wifi_indicator(self, generation: int) -> None: offset = 0.0 revision_message = None frame_seconds = 1.0 / WIFI_SCROLL_FPS step = WIFI_SCROLL_PIXELS_PER_SECOND / WIFI_SCROLL_FPS while not self._wifi_stop.wait(frame_seconds): with self._lock: if not self._wifi_active or generation != self._wifi_generation: return if revision_message != self._wifi_message: revision_message = self._wifi_message offset = 0.0 offset += step width = text_width(self._wifi_message) if offset > width + self.width + WIFI_SCROLL_GAP_PIXELS: offset = 0.0 frame = render_wifi_indicator( self._wifi_message, offset=offset, connected=self._wifi_connected, ) if self._display_test_active: self._wifi_frame = frame continue target = self._select_output_locked(wifi_frame=frame) try: self._submit_output_locked(target.frame, target.brightness) self._wifi_frame = frame except Exception: logger.exception("Failed to submit a WiFi indicator frame") def _stop_animation_locked(self) -> None: if not self._animation_active: return self._animation_active = False self._animation_generation += 1 self._animation_session_id = None self._animation_id = None self._animation_revision = None self._animation_frames = () self._animation_boundaries_ms = () self._animation_total_duration_ms = 0 self._animation_position_anchor_ms = 0.0 self._animation_anchor_monotonic = time.monotonic() self._animation_paused = False self._animation_speed = 1.0 self._animation_index = 0 self._animation_condition.notify_all() def _finish_startup_indicator_locked(self) -> None: self._startup_active = False self._startup_stop.set() try: self._startup_session.mark_dismissed() except Exception: logger.exception("Failed to persist startup indicator dismissal") logger.info("Dismissed the startup smile until the next system boot") def _finish_wifi_indicator_locked(self) -> None: self._wifi_active = False self._wifi_stop.set() self._wifi_generation += 1 self._wifi_message = "" def _select_output_locked( self, *, current_frame: Image.Image | None = None, startup_active: bool | None = None, startup_frame: Image.Image | None = None, wifi_active: bool | None = None, wifi_frame: Image.Image | None = None, animation_active: bool | None = None, animation_frame: Image.Image | None = None, brightness: int | None = None, display_test_active: bool | None = None, display_test_frame: Image.Image | None = None, display_test_brightness: int | None = None, ota_active: bool | None = None, ota_frame: Image.Image | None = None, ) -> _SelectedOutput: user_frame = self._current_frame if current_frame is None else current_frame user_brightness = self._brightness if brightness is None else brightness show_startup = self._startup_active if startup_active is None else startup_active smile = self._startup_frame if startup_frame is None else startup_frame show_wifi = self._wifi_active if wifi_active is None else wifi_active wifi = self._wifi_frame if wifi_frame is None else wifi_frame show_animation = self._animation_active if animation_active is None else animation_active if animation_frame is None and self._animation_frames: animation_frame = self._animation_frame_image(self._animation_frames[self._animation_index]) show_display_test = ( self._display_test_active if display_test_active is None else display_test_active ) test_frame = self._display_test_frame if display_test_frame is None else display_test_frame test_brightness = ( self._display_test_brightness if display_test_brightness is None else display_test_brightness ) show_ota = self._ota_active if ota_active is None else ota_active update_frame = self._ota_frame if ota_frame is None else ota_frame if self._protection_enabled and self._protection_mode == "critical": return _SelectedOutput( frame=self._low_voltage_frame, brightness=LOW_VOLTAGE_CRITICAL_BRIGHTNESS_PERCENT, mode="low_voltage_indicator", ) cap = 100 if ( self._protection_enabled and self._protection_mode == "limiting" and self._protection_brightness_limit is not None ): cap = self._protection_brightness_limit if show_display_test: return _SelectedOutput( frame=test_frame, brightness=min(test_brightness, cap), mode="display_test:fill", ) if show_ota: return _SelectedOutput( frame=update_frame, brightness=min(OTA_INDICATOR_BRIGHTNESS, cap), mode="ota_update_indicator", ) if show_wifi: return _SelectedOutput( frame=wifi, brightness=min(WIFI_INDICATOR_BRIGHTNESS, cap), mode="wifi_connected_indicator" if self._wifi_connected else "wifi_disconnected_indicator", ) if show_startup: return _SelectedOutput( frame=smile, brightness=min(STARTUP_BRIGHTNESS, cap), mode="startup_indicator", ) if show_animation and animation_frame is not None: return _SelectedOutput( frame=animation_frame, brightness=min(user_brightness, cap), mode="animation", ) return _SelectedOutput( frame=user_frame, brightness=min(user_brightness, cap), mode=self.config_store.config["last_mode"], ) def _display_test_status_locked(self) -> dict: color = None if self._display_test_color is not None: color = "#{:02X}{:02X}{:02X}".format(*self._display_test_color) return { "active": self._display_test_active, "color": color, "brightness": self._display_test_brightness if self._display_test_active else None, } @staticmethod def _checked_content_source(value: dict | None) -> dict: if value is None: return dict(UNSAVED_CONTENT) if not isinstance(value, dict) or set(value) != {"category", "id", "name"}: raise ValueError("content source must contain category, id and name") category = value.get("category") content_id = value.get("id") name = value.get("name") if category not in {"template", "animation"}: raise ValueError("content source category must be template or animation") if not isinstance(content_id, str) or not content_id: raise ValueError("content source id must not be empty") if not isinstance(name, str) or not name.strip(): raise ValueError("content source name must not be empty") return {"category": category, "id": content_id, "name": name.strip()} def _current_content_status_locked(self, selected: _SelectedOutput) -> dict: system_names = { "low_voltage_indicator": "低电压提示", "wifi_connected_indicator": "WiFi 提示", "wifi_disconnected_indicator": "WiFi 提示", "startup_indicator": "开机提示", "display_test:fill": "纯色测试", "ota_update_indicator": "软件更新", } system_name = system_names.get(selected.mode) if system_name is not None: return {"category": "system", "id": None, "name": system_name} return dict(self._current_content) def _reconcile_output_locked(self) -> None: target = self._select_output_locked() self._submit_output_locked(target.frame, target.brightness) def _try_reconcile_output_locked(self, message: str) -> None: try: self._reconcile_output_locked() except Exception: logger.exception(message) def _submit_output_locked( self, frame: Image.Image, brightness: int, *, orientation: int | None = None, synchronized: bool = False, ) -> None: """Submit one selected output while avoiding unsafe bright transients.""" if brightness < 1 or brightness > 100: raise ValueError("effective brightness must be in 1..100") target_orientation = self._orientation if orientation is None else orientation frame_changed = ( target_orientation != self._visible_orientation or frame.tobytes() != self._visible_frame.tobytes() ) if ( not self._output_dirty and not frame_changed and brightness == self._effective_brightness ): return transformed = self._transform(frame, orientation=target_orientation) def submit_frame() -> None: try: if synchronized: self.driver.set_image(transformed, synchronized=True) else: self.driver.set_image(transformed) except Exception: self._output_dirty = True raise self._visible_frame = frame.copy() self._visible_orientation = target_orientation self._output_dirty = False if brightness < self._effective_brightness: try: self.driver.set_brightness(brightness) except Exception: self._output_dirty = True raise self._effective_brightness = brightness submit_frame() return if brightness > self._effective_brightness: # Put the target frame on the panel while it is still dim, then # raise brightness and submit it once more at the final setting. submit_frame() try: self.driver.set_brightness(brightness) except Exception: self._output_dirty = True raise self._effective_brightness = brightness try: submit_frame() except Exception: # The correct target frame was already accepted at the safer # brightness. Keep that successful work and force a retry. self._output_dirty = True raise return submit_frame() def _create_driver( self, limit_refresh_rate_hz: int, *, allow_fallback: bool = True, ): if self._driver_kind == "mock": return MockDisplayDriver( self.config_store.runtime_dir, brightness=self._brightness, limit_refresh_rate_hz=limit_refresh_rate_hz, ) try: return MatrixDisplayDriver( brightness=self._brightness, limit_refresh_rate_hz=limit_refresh_rate_hz, ) except Exception as exc: if not allow_fallback: raise logger.exception("Falling back to mock display driver") return MockDisplayDriver( self.config_store.runtime_dir, brightness=self._brightness, reason=str(exc), driver_options=get_default_driver_options( self._brightness, limit_refresh_rate_hz, ), )