Files
matrix-screen-controller/核桃派软件源代码/app/display/service.py
T

1282 lines
52 KiB
Python

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,
),
)