1282 lines
52 KiB
Python
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,
|
|
),
|
|
)
|