from __future__ import annotations import base64 import hashlib import json import logging import os from io import BytesIO from pathlib import Path from typing import Any, Callable, Literal from uuid import UUID, uuid4 from fastapi import BackgroundTasks, FastAPI, Header, HTTPException, Request, WebSocket, WebSocketDisconnect from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response from fastapi.staticfiles import StaticFiles from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictInt, StrictStr, field_validator, model_validator from app.control import DeviceControl from app.mobile.control import MobileControl from app.mobile.session import SessionManager from app.mobile.bluez import BluezRuntime from app.config.store import ( COLOR_PALETTE_LIMIT, DEFAULT_DISPLAY, ConfigError, ConfigStore, normalize_default_display, validate_config, ) from app.animations.store import AnimationStore, DEFAULT_DURATION_MS from app.display.colors import parse_color from app.display.diagnostics import render_diagnostic from app.display.service import ( ANIMATION_PLAYBACK_SPEEDS, AnimationPlaybackConflictError, DisplayService, DisplayTestInactiveError, ) from app.display.text_renderer import TextOptions, render_text, render_text_layer from app.fonts.store import ( MAX_FONT_UPLOAD_BYTES, FontCatalog, FontCatalogError, FontStorageFullError, FontTooLargeError, FontTypeError, FontValidationError, ) from app.frp import ( FrpConflictError, FrpController, FrpError, FrpValidationError, MockFrpBackend, SystemdFrpBackend, ) from app.frp.manager import MAX_FRP_CONFIG_BYTES from app.demo_library import ( DEMO_ANIMATION_ID, DEMO_RESTRICTED_MESSAGE, DEMO_STATIC_ID, demo_animation, demo_animation_frame, demo_template, demo_thumbnail, ) from app.monitoring.resources import ResourceMonitor from app.monitoring.storage import DeviceStorageMonitor from app.library_order import ( LibraryOrderConflictError, LibraryOrderError, LibraryOrderStorageFullError, LibraryOrderStore, LibraryOrderValidationError, ) from app.media import MediaImportError, MediaImportManager from app.network.manager import MockNetworkManager, NetworkBackend, NetworkManagerError, NmcliNetworkManager from app.network.diagnostics import MockNetworkDiagnostics, NetworkDiagnostics from app.network.service import WifiNetworkService from app.network.store import WifiConfigError, WifiConfigStore from app.ota.manager import OtaManager, OtaUploadError from app.ota.versioning import read_feature_updated_at, read_software_version from app.system.performance import PerformanceModeError, PerformanceModeManager from app.power.voltage import ( CalibrationConflictError, CalibrationError, CalibrationUnavailableError, VoltageMonitor, ) from app.templates.store import ( TemplateConflictError, TemplateError, TemplateNotFoundError, TemplateStorageFullError, TemplateStore, TemplateValidationError, render_scene_image, ) from app.ui_copy_editor.settings import UI_COPY_EDITOR_BUILD_ENABLED from app.ui_copy_editor.store import ( UiCopyDraftConflictError, UiCopyDraftStore, UiCopyDraftValidationError, ) logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s", ) logger = logging.getLogger(__name__) def static_content_version(static_dir: Path) -> str: digest = hashlib.sha256() for path in sorted(item for item in static_dir.rglob("*") if item.is_file()): digest.update(path.relative_to(static_dir).as_posix().encode("utf-8")) digest.update(b"\0") digest.update(path.read_bytes()) digest.update(b"\0") return digest.hexdigest()[:16] def normalize_if_match(value: str | None) -> str: if value is None: raise HTTPException(status_code=428, detail="If-Match is required") candidate = value.strip() if candidate.startswith('W/'): candidate = candidate[2:].strip() if len(candidate) >= 2 and candidate[0] == candidate[-1] == '"': candidate = candidate[1:-1] if not candidate or "," in candidate or candidate == "*": raise HTTPException(status_code=428, detail="If-Match must contain one record revision") return candidate class ConfigUpdate(BaseModel): orientation: int | None = None brightness: int | None = None low_voltage_protection_enabled: StrictBool | None = None default_font: str | None = None default_text_size: int | None = None preview_refresh_interval_ms: StrictInt | None = None matrix_refresh_rate_limit_hz: StrictInt | None = None custom_test_color: str | None = None workspace_order: list[StrictStr] | None = None performance_mode_enabled: StrictBool | None = None animation_preview_max_concurrent: StrictInt | None = None @field_validator("low_voltage_protection_enabled", mode="before") @classmethod def reject_null_low_voltage_protection_enabled(cls, value: Any) -> Any: if value is None: raise ValueError("low_voltage_protection_enabled must be a boolean") return value @field_validator("performance_mode_enabled", mode="before") @classmethod def reject_null_performance_mode_enabled(cls, value: Any) -> Any: if value is None: raise ValueError("performance_mode_enabled must be a boolean") return value @field_validator("animation_preview_max_concurrent", mode="before") @classmethod def reject_null_animation_preview_max_concurrent(cls, value: Any) -> Any: if value is None: raise ValueError("animation_preview_max_concurrent must be an integer") return value @field_validator("custom_test_color", mode="before") @classmethod def reject_null_custom_test_color(cls, value: Any) -> Any: if value is None: raise ValueError("custom_test_color must be #RRGGBB") return value @field_validator("workspace_order", mode="before") @classmethod def reject_null_workspace_order(cls, value: Any) -> Any: if value is None: raise ValueError("workspace_order must be an array") return value class WifiUpdate(BaseModel): ssid: str password: str | None = None ipv4_mode: Literal["dhcp", "manual"] = "dhcp" address: str | None = None gateway: str | None = None prefix: StrictInt | None = None dns_servers: list[str] = Field(default_factory=list) prompt_delay_seconds: StrictInt | None = None activation: Literal["immediate", "next_boot"] class WifiPromptDelayUpdate(BaseModel): prompt_delay_seconds: StrictInt class DefaultContentRequest(BaseModel): model_config = ConfigDict(extra="forbid") type: Literal["template", "animation"] id: str class FillRequest(BaseModel): color: Any class DisplayTestBrightnessRequest(BaseModel): brightness: StrictInt class AnimationPlaybackUpdateRequest(BaseModel): model_config = ConfigDict(extra="forbid") session_id: StrictStr position_ms: StrictInt | None = None paused: StrictBool | None = None speed: float | None = None @field_validator("position_ms", "paused", "speed", mode="before") @classmethod def reject_null_playback_controls(cls, value: Any) -> Any: if value is None: raise ValueError("animation playback controls must not be null") return value @field_validator("speed", mode="before") @classmethod def validate_playback_speed(cls, value: Any) -> float: if isinstance(value, bool) or type(value) not in {int, float}: raise ValueError("speed must be a supported number") checked = float(value) if checked not in ANIMATION_PLAYBACK_SPEEDS: raise ValueError("speed must be one of 0.5, 1, 1.5 or 2") return checked @model_validator(mode="after") def require_playback_control(self) -> "AnimationPlaybackUpdateRequest": if not self.model_fields_set.intersection({"position_ms", "paused", "speed"}): raise ValueError("at least one animation playback control is required") return self class PaletteColorRequest(BaseModel): color: str class TemplateCreateRequest(BaseModel): name: str scene: dict[str, Any] class TemplateSceneRequest(BaseModel): scene: dict[str, Any] class TemplateNameRequest(BaseModel): name: str class AnimationCreateRequest(BaseModel): name: str class AnimationFrameCreateRequest(BaseModel): scene: dict[str, Any] name: str | None = None duration_ms: StrictInt = DEFAULT_DURATION_MS class AnimationFramePutRequest(BaseModel): scene: dict[str, Any] class AnimationFramePatchRequest(BaseModel): name: str | None = None duration_ms: StrictInt | None = None class AnimationOrderRequest(BaseModel): frame_ids: list[str] class AnimationMoveRequest(BaseModel): frame_ids: list[str] before_frame_id: str | None = None class AnimationDurationsRequest(BaseModel): duration_ms: StrictInt frame_ids: list[str] | None = None class AnimationFramesCopyRequest(BaseModel): frame_ids: list[str] destination_animation_id: str destination_revision: str insert_after_frame_id: str | None = None class AnimationFramesDeleteRequest(BaseModel): frame_ids: list[str] class LibraryCopyRequest(BaseModel): source_type: Literal["template", "animation_frame"] source_id: str source_animation_id: str | None = None source_revision: str | None = None destination_type: Literal["static", "animation"] destination_animation_id: str | None = None destination_revision: str | None = None class LibraryOrderItemRequest(BaseModel): model_config = ConfigDict(extra="forbid") type: Literal["template", "animation"] id: str class LibraryOrderRequest(BaseModel): model_config = ConfigDict(extra="forbid") items: list[LibraryOrderItemRequest] class MediaSettingsRequest(BaseModel): model_config = ConfigDict(extra="forbid") name: str | None = None fit_mode: Literal["crop", "contain", "stretch"] | None = None center_x: float | None = None center_y: float | None = None zoom: float | None = None transparency_color: str | None = None padding_color: str | None = None class TextRequest(BaseModel): text: str = Field(min_length=0, max_length=512) font: str = "default" size: int = 12 x: int = 0 y: int = 0 align: str = "left" color: Any = "#FFFFFF" background: Any = "#000000" class TextLayerRequest(BaseModel): text: str = Field(min_length=0, max_length=512) font: str = "default" size: int = 12 x: int = 0 y: int = 0 align: str = "left" color: Any = "#FFFFFF" class DiagnosticRequest(BaseModel): mode: Literal[ "corners_lines", "row_bands", "address_check", "address_bit_0", "address_bit_1", "address_bit_2", "address_bit_3", "address_bit_4", "text_ok123", "clear", ] class VoltageCalibrationPreviewRequest(BaseModel): reference_volts: float class VoltageCalibrationConfirmRequest(BaseModel): proposal_id: str = Field(min_length=1, max_length=128) def text_options_from_request(request: TextRequest) -> TextOptions: return TextOptions( text=request.text, font=request.font, size=request.size, x=request.x, y=request.y, align=request.align, color=parse_color(request.color), background=parse_color(request.background), ) def text_layer_options_from_request(request: TextLayerRequest) -> TextOptions: return TextOptions( text=request.text, font=request.font, size=request.size, x=request.x, y=request.y, align=request.align, color=parse_color(request.color), ) def image_png_response(image) -> Response: output = BytesIO() image.save(output, format="PNG") return Response(content=output.getvalue(), media_type="image/png") def create_app( project_root: Path | None = None, driver_kind: str | None = None, startup_boot_id: str | None = None, voltage_monitor_factory: Callable[[ConfigStore], VoltageMonitor] | None = None, resource_monitor_factory: Callable[[], ResourceMonitor] | None = None, storage_monitor_factory: Callable[[Path, Path], DeviceStorageMonitor] | None = None, performance_manager_factory: Callable[[Path], PerformanceModeManager] | None = None, network_backend: NetworkBackend | None = None, frp_backend: Any | None = None, network_diagnostics: Any | None = None, ui_copy_editor_build_enabled: bool = False, maintenance_black: bool | None = None, ota_worker_starter: Callable[[], None] | None = None, ota_staging_root: Path | None = None, ) -> FastAPI: if maintenance_black is None: maintenance_black = os.environ.get("MATRIX_MAINTENANCE_BLACK") == "1" code_root = Path(__file__).resolve().parents[1] software_version = read_software_version(code_root) feature_updated_at = read_feature_updated_at(code_root) static_dir = code_root / "app" / "static" editor_static_dir = code_root / "app" / "ui_copy_editor" / "static" base_static_version = static_content_version(static_dir) editor_asset_version = None if ui_copy_editor_build_enabled: editor_asset_version = static_content_version(editor_static_dir) app_version = hashlib.sha256( f"{base_static_version}\0ui-copy-editor\0{editor_asset_version}".encode("ascii") ).hexdigest()[:16] else: app_version = base_static_version static_version_prefix = f"/static/{app_version}" index_html = (static_dir / "index.html").read_text(encoding="utf-8") index_html = index_html.replace( "", f'<meta name="app-version" content="{app_version}">\n <title>', 1, ) index_html = index_html.replace('/static/style.css', f'{static_version_prefix}/style.css') index_html = index_html.replace('/static/app.js', f'{static_version_prefix}/app.js') store = ConfigStore(project_root=project_root) configured_driver = (driver_kind or os.environ.get("MATRIX_DRIVER") or "auto").lower() performance_mode = ( performance_manager_factory(store.runtime_dir) if performance_manager_factory else PerformanceModeManager( store.runtime_dir, # Mock/fake-register runs must never inspect or change the host's # real cpufreq policies. Tests that exercise governors inject an # explicit fake sysfs through performance_manager_factory. sysfs_root=(store.runtime_dir / "mock-cpufreq") if configured_driver == "mock" else None, ) ) ui_copy_store = ( UiCopyDraftStore(store.data_dir, static_dir / "ui-copy.json") if ui_copy_editor_build_enabled else None ) editor_asset_prefix = ( f"/__ui-copy-editor/{editor_asset_version}" if editor_asset_version is not None else None ) if ui_copy_store is not None and editor_asset_prefix is not None: bootstrap = json.dumps( { "api_url": "/api/dev/ui-copy", "export_url": "/api/dev/ui-copy/export", "catalog_url": f"{static_version_prefix}/ui-copy.json", "runtime_url": f"{static_version_prefix}/ui-copy-runtime.js", "app_version": app_version, "initial_draft": ui_copy_store.status_document(), }, ensure_ascii=False, separators=(",", ":"), ).replace("<", "\\u003c") index_html = index_html.replace( "</head>", f' <link rel="stylesheet" href="{editor_asset_prefix}/editor.css">\n </head>', 1, ) index_html = index_html.replace( f'<script type="module" src="{static_version_prefix}/app.js"></script>', " <script>globalThis.__UI_COPY_EDITOR_BUILD__ = " f"Object.freeze({bootstrap});</script>\n" f' <script type="module" src="{static_version_prefix}/app.js"></script>', 1, ) index_html = index_html.replace( "</body>", f' <script type="module" src="{editor_asset_prefix}/editor.js"></script>\n' " </body>", 1, ) fonts = FontCatalog(store.data_dir) templates = TemplateStore(store.data_dir, font_resolver=fonts.resolve) animations = AnimationStore(store.data_dir, font_resolver=fonts.resolve) media_imports = MediaImportManager(store.data_dir, templates, animations) library_order = LibraryOrderStore( store.data_dir, forbidden_ids={DEMO_STATIC_ID, DEMO_ANIMATION_ID}, ) display = DisplayService( store, driver_kind=driver_kind, startup_boot_id=startup_boot_id, font_resolver=fonts.resolve, ) power_monitor = voltage_monitor_factory(store) if voltage_monitor_factory else VoltageMonitor(store) power_monitor.set_protection_listener(display.apply_power_protection) resource_monitor = resource_monitor_factory() if resource_monitor_factory else ResourceMonitor() storage_monitor = ( storage_monitor_factory(code_root, store.data_dir) if storage_monitor_factory else DeviceStorageMonitor(code_root, store.data_dir) ) wifi_store = WifiConfigStore( store.data_dir, store.runtime_dir, boot_id=startup_boot_id, ) if network_backend is None: network_backend = MockNetworkManager() if configured_driver == "mock" else NmcliNetworkManager() network = WifiNetworkService(wifi_store, network_backend, display) if frp_backend is None: frp_backend = ( MockFrpBackend() if configured_driver == "mock" else SystemdFrpBackend(store.data_dir) ) frp = FrpController(store.data_dir, frp_backend) if network_diagnostics is None: network_diagnostics = ( MockNetworkDiagnostics() if configured_driver == "mock" else NetworkDiagnostics(lambda: network.get_status()) ) display.set_activity_listener(network.dismiss_for_boot) ota = OtaManager( code_root=code_root, data_root=store.data_dir, runtime_root=store.runtime_dir, software_version=software_version, display=display, worker_starter=ota_worker_starter, staging_root=ota_staging_root, ) control = DeviceControl(store, display, power_monitor, performance_mode) def resolve_content(reference: dict[str, str]) -> dict[str, Any]: checked = normalize_default_display(reference) content_type = checked["type"] content_id = checked["id"] if content_type == "template": detail = ( demo_template(include_scene=True) if content_id == DEMO_STATIC_ID else templates.get(content_id) ) return { "type": content_type, "id": content_id, "name": detail["name"], "revision": detail["revision"], "is_demo": content_id == DEMO_STATIC_ID, "image": render_scene_image(detail["scene"], font_resolver=fonts.resolve), } detail = ( demo_animation(include_scenes=True) if content_id == DEMO_ANIMATION_ID else animations.playback_snapshot(content_id) ) if not detail["frames"]: raise TemplateConflictError("animation has no frames") frames = detail["frames"] if content_id != DEMO_ANIMATION_ID else [ { "id": frame["id"], "duration_ms": frame["duration_ms"], "image": render_scene_image(frame["scene"], font_resolver=fonts.resolve), } for frame in detail["frames"] ] return { "type": content_type, "id": content_id, "name": detail["name"], "revision": detail["revision"], "is_demo": content_id == DEMO_ANIMATION_ID, "frames": frames, } def content_metadata(resolved: dict[str, Any]) -> dict[str, Any]: return { key: resolved[key] for key in ("type", "id", "name", "revision", "is_demo") } def apply_resolved_content( resolved: dict[str, Any], *, notify_activity: bool, ) -> None: source = { "category": resolved["type"], "id": resolved["id"], "name": resolved["name"], } if resolved["type"] == "template": display.show_image( resolved["image"], mode="template", content_source=source, notify_activity=notify_activity, ) return display.start_animation( resolved["id"], resolved["revision"], resolved["frames"], content_source=source, notify_activity=notify_activity, ) def effective_default_content(*, repair: bool = True) -> dict[str, Any]: reference = store.config["default_display"] try: return resolve_content(reference) except (TemplateError, ConfigError, ValueError): if not repair or reference == DEFAULT_DISPLAY: raise logger.warning("Default display reference is invalid; falling back to demo animation") store.update({"default_display": DEFAULT_DISPLAY}) return resolve_content(DEFAULT_DISPLAY) def set_default_content( reference: dict[str, str], *, notify_activity: bool = True, ) -> dict[str, Any]: checked = normalize_default_display(reference) resolved = resolve_content(checked) previous = store.config["default_display"] store.update({"default_display": checked}) try: apply_resolved_content(resolved, notify_activity=notify_activity) except Exception: try: store.update({"default_display": previous}) except Exception: logger.exception("Failed to roll back the default display reference") raise return content_metadata(resolved) def activate_default_content() -> dict[str, Any]: try: resolved = effective_default_content() apply_resolved_content(resolved, notify_activity=False) return content_metadata(resolved) except Exception: if store.config["default_display"] == DEFAULT_DISPLAY: raise logger.exception("Failed to activate configured default content; using demo animation") return set_default_content(DEFAULT_DISPLAY, notify_activity=False) def default_matches(content_type: str, content_id: str) -> bool: reference = store.config["default_display"] return reference["type"] == content_type and reference["id"] == content_id def switch_default_before_delete(content_type: str, content_id: str) -> dict[str, str] | None: if not default_matches(content_type, content_id): return None previous = store.config["default_display"] set_default_content(DEFAULT_DISPLAY) return previous def restore_default_after_failed_delete(previous: dict[str, str] | None) -> None: if previous is None: return try: set_default_content(previous) except Exception: logger.exception("Failed to restore the default display after a delete error") app = FastAPI(title="Matrix Screen Controller") app.state.config_store = store app.state.font_catalog = fonts app.state.template_store = templates app.state.animation_store = animations app.state.media_import_manager = media_imports app.state.library_order_store = library_order app.state.display_service = display app.state.voltage_monitor = power_monitor app.state.resource_monitor = resource_monitor app.state.storage_monitor = storage_monitor app.state.wifi_store = wifi_store app.state.network_service = network app.state.frp_controller = frp app.state.network_diagnostics = network_diagnostics app.state.ui_copy_store = ui_copy_store app.state.ui_copy_editor_build_enabled = ui_copy_editor_build_enabled app.state.maintenance_black = maintenance_black app.state.app_version = app_version app.state.software_version = str(software_version) app.state.feature_updated_at = feature_updated_at app.state.ota_manager = ota app.state.performance_mode = performance_mode app.state.instance_id = str(uuid4()) app.mount(static_version_prefix, StaticFiles(directory=static_dir), name="versioned-static") app.mount("/static", StaticFiles(directory=static_dir), name="static") if editor_asset_prefix is not None: app.mount( editor_asset_prefix, StaticFiles(directory=editor_static_dir), name="ui-copy-editor-assets", ) @app.middleware("http") async def cache_policy(request, call_next): if ( request.method in {"POST", "PUT", "PATCH", "DELETE"} and request.url.path != "/api/ota/update" and ota.is_active() ): return JSONResponse( status_code=423, content={"detail": "software update is active; device writes are temporarily locked"}, headers={"Cache-Control": "no-store"}, ) response = await call_next(request) path = request.url.path if ( request.method in {"POST", "PUT", "PATCH", "DELETE"} and response.status_code < 400 and not path.startswith("/api/display/test") and path.startswith(( "/api/config", "/api/network/wifi", "/api/frp", "/api/power/voltage/calibration", "/api/colors/palette", "/api/library", "/api/templates", "/api/animations", "/api/fonts", "/api/display", )) ): storage_monitor.invalidate() is_digest_thumbnail = ( path.startswith("/api/templates/") and path.endswith("/thumbnail") and bool(request.query_params.get("v")) ) or ( path.startswith("/api/animations/") and path.endswith("/thumbnail") and bool(request.query_params.get("v")) ) if is_digest_thumbnail: pass elif path == "/" or path.startswith("/api/"): response.headers["Cache-Control"] = "no-store" elif path.startswith(f"{static_version_prefix}/"): response.headers["Cache-Control"] = "public, max-age=31536000, immutable" elif editor_asset_prefix is not None and path.startswith(f"{editor_asset_prefix}/"): response.headers["Cache-Control"] = "public, max-age=31536000, immutable" elif path.startswith("/static/"): response.headers["Cache-Control"] = "no-store" return response @app.get("/") def index() -> HTMLResponse: network.dismiss_for_boot() return HTMLResponse(index_html) if ui_copy_store is not None: @app.get("/api/dev/ui-copy") def get_ui_copy_draft() -> Response: document = ui_copy_store.status_document() return Response( content=json.dumps(document, ensure_ascii=False, separators=(",", ":")), media_type="application/json", headers={"ETag": f'"{document["revision"]}"'}, ) @app.put("/api/dev/ui-copy") def put_ui_copy_draft( document: dict[str, Any], if_match: str | None = Header(default=None, alias="If-Match"), ) -> Response: revision_text = normalize_if_match(if_match) try: expected_revision = int(revision_text) except ValueError as exc: raise HTTPException(status_code=428, detail="If-Match must be an integer revision") from exc try: saved = ui_copy_store.replace(document, expected_revision=expected_revision) except UiCopyDraftConflictError as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc except UiCopyDraftValidationError as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc return Response( content=json.dumps(saved, ensure_ascii=False, separators=(",", ":")), media_type="application/json", headers={"ETag": f'"{saved["revision"]}"'}, ) @app.get("/api/dev/ui-copy/export") def export_ui_copy_draft() -> Response: document = ui_copy_store.status_document() return Response( content=json.dumps(document, ensure_ascii=False, indent=2, sort_keys=True) + "\n", media_type="application/json", headers={ "Content-Disposition": 'attachment; filename="ui-copy-draft.json"', "ETag": f'"{document["revision"]}"', }, ) mobile_runtime = None def read_device_status() -> dict: status = display.get_status() status["service"] = { "app_version": app.state.app_version, "software_version": app.state.software_version, "feature_updated_at": app.state.feature_updated_at, "instance_id": app.state.instance_id, } status["power"] = { "screen_input_voltage": power_monitor.get_status(), "low_voltage_protection": power_monitor.get_protection_status(), } status["resources"] = resource_monitor.get_status() status["network"] = network.get_cached_status() status["frp"] = frp.status() status["ui"] = { "preview_refresh_interval_ms": store.config["preview_refresh_interval_ms"], } status["system"] = { "performance_mode": performance_mode.status( store.config["performance_mode_enabled"] ), } status["mobile"] = mobile_runtime.status() if mobile_runtime else {"available": False, "connected": False, "reason": "initialization_failed"} return status try: mobile = MobileControl(control, network, templates, animations, resolve_content, apply_resolved_content, effective_default_content, set_default_content, read_device_status, storage_monitor.get_status, library_order=library_order) mobile_sessions = SessionManager(mobile.dispatch, mobile.identity) mobile_runtime = BluezRuntime(mobile_sessions, enabled=os.environ.get("MATRIX_BLE_ENABLED") == "1") app.state.mobile_control = mobile app.state.mobile_sessions = mobile_sessions except Exception: logger.exception("Mobile control initialization failed; display remains available") app.state.mobile_runtime = mobile_runtime @app.get("/api/status") def get_status() -> dict: return read_device_status() @app.get("/api/ota/status") def get_ota_status() -> dict: return { **ota.status(), "feature_updated_at": app.state.feature_updated_at, } @app.get("/api/ota/failure-log") def get_ota_failure_log() -> Response: content = ota.failure_log() if content is None: raise HTTPException(status_code=404, detail="no OTA failure log is available") return Response( content=content, media_type="text/plain; charset=utf-8", headers={"Cache-Control": "no-store"}, ) @app.post("/api/ota/update", status_code=202) async def upload_ota_update(request: Request, filename: str = "") -> dict: content_type = request.headers.get("content-type", "").split(";", 1)[0].strip().casefold() if content_type != "application/octet-stream": raise HTTPException(status_code=415, detail="OTA upload Content-Type must be application/octet-stream") content_length = request.headers.get("content-length") parsed_length: int | None = None if content_length is not None: try: parsed_length = int(content_length) except ValueError as exc: raise HTTPException(status_code=400, detail="Content-Length must be an integer") from exc try: return await ota.accept_upload( filename=filename, chunks=request.stream(), content_length=parsed_length, orientation=int(store.config["orientation"]), ) except OtaUploadError as exc: raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc @app.get("/api/display/current-frame") def get_current_display_frame() -> Response: return image_png_response(display.get_current_frame()) @app.patch("/api/display/animation-playback") def patch_animation_playback(request: AnimationPlaybackUpdateRequest) -> dict: try: playback = display.control_animation_playback( request.session_id, position_ms=request.position_ms, paused=request.paused, speed=request.speed, ) return { "ok": True, "animation_playback": playback, "state_revision": display.get_state_revision(), } except AnimationPlaybackConflictError as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc except ValueError as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc @app.get("/api/display/default-content") def get_default_content() -> dict: try: return content_metadata(effective_default_content()) except (TemplateError, ConfigError, ValueError) as exc: raise template_error(exc) if isinstance(exc, TemplateError) else HTTPException(status_code=400, detail=str(exc)) @app.put("/api/display/default-content") def put_default_content(request: DefaultContentRequest) -> dict: try: return set_default_content(request.model_dump()) except TemplateError as exc: raise template_error(exc) from exc except (ConfigError, ValueError) as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc except Exception as exc: logger.exception("Failed to save and display default content") raise HTTPException(status_code=500, detail="default content could not be applied") from exc @app.get("/api/config") def get_config() -> dict: return store.config @app.get("/api/fonts") def get_fonts() -> dict: return fonts.document() @app.post("/api/fonts/import") async def import_font(request: Request, response: Response, filename: str = "") -> dict: content_type = request.headers.get("content-type", "").split(";", 1)[0].strip().casefold() if content_type != "application/octet-stream": raise HTTPException( status_code=415, detail="font upload Content-Type must be application/octet-stream", ) content_length = request.headers.get("content-length") if content_length is not None: try: if int(content_length) > MAX_FONT_UPLOAD_BYTES: raise HTTPException(status_code=413, detail="font file exceeds the 32 MiB limit") except ValueError as exc: raise HTTPException(status_code=400, detail="Content-Length must be an integer") from exc try: created, imported = await fonts.import_font(filename, request.stream()) response.status_code = 201 if created else 200 return {"created": created, "fonts": imported} except FontTooLargeError as exc: raise HTTPException(status_code=413, detail=str(exc)) from exc except FontTypeError as exc: raise HTTPException(status_code=415, detail=str(exc)) from exc except FontValidationError as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc except FontStorageFullError as exc: raise HTTPException(status_code=507, detail=str(exc)) from exc except FontCatalogError as exc: logger.exception("Font import failed") raise HTTPException(status_code=500, detail="font could not be imported") from exc @app.get("/api/device-storage") def get_device_storage() -> dict: try: return storage_monitor.get_status() except OSError as exc: logger.exception("Device storage scan failed") raise HTTPException(status_code=500, detail="device storage could not be read") from exc @app.get("/api/network/wifi") def get_wifi_config() -> dict: return network.get_status(include_secret=True) @app.post("/api/network/diagnostics") def run_network_diagnostics() -> dict: return network_diagnostics.run() def frp_http_error(exc: FrpError) -> HTTPException: if isinstance(exc, FrpConflictError): return HTTPException(status_code=409, detail=str(exc)) if isinstance(exc, FrpValidationError): return HTTPException(status_code=422, detail=str(exc)) return HTTPException(status_code=503, detail=str(exc)) async def read_frp_body(request: Request) -> bytes: content_type = request.headers.get("content-type", "").split(";", 1)[0].strip().casefold() if content_type not in {"application/octet-stream", "text/plain"}: raise HTTPException(status_code=415, detail="FRP config Content-Type must be text/plain or application/octet-stream") content_length = request.headers.get("content-length") if content_length is not None: try: if int(content_length) > MAX_FRP_CONFIG_BYTES: raise HTTPException(status_code=413, detail="FRP config exceeds the 1 MiB limit") except ValueError as exc: raise HTTPException(status_code=400, detail="Content-Length must be an integer") from exc body = await request.body() if len(body) > MAX_FRP_CONFIG_BYTES: raise HTTPException(status_code=413, detail="FRP config exceeds the 1 MiB limit") return body @app.get("/api/frp") def get_frp_status() -> dict: return frp.status() @app.post("/api/frp/configs", status_code=201) async def create_frp_config(request: Request, filename: str = "", name: str = "") -> dict: try: profile = frp.catalog.create( filename=filename, name=name, content=await read_frp_body(request), ) return {"ok": True, "profile": profile, "frp": frp.status()} except FrpError as exc: raise frp_http_error(exc) from exc @app.get("/api/frp/configs/{profile_id}") def get_frp_config(profile_id: str) -> Response: try: profile, content = frp.content(profile_id) return Response( content=content, media_type="text/plain; charset=utf-8", headers={ "X-Frp-Config-Name": profile["name"].encode("ascii", "ignore").decode("ascii"), "Cache-Control": "no-store", }, ) except FrpError as exc: raise frp_http_error(exc) from exc @app.put("/api/frp/configs/{profile_id}") async def update_frp_config(profile_id: str, request: Request, name: str = "") -> dict: try: profile = frp.catalog.update( profile_id, name=name, content=await read_frp_body(request), ) return {"ok": True, "profile": profile, "frp": frp.status()} except FrpError as exc: raise frp_http_error(exc) from exc @app.delete("/api/frp/configs/{profile_id}") def delete_frp_config(profile_id: str) -> dict: try: frp.catalog.delete(profile_id) return {"ok": True, "frp": frp.status()} except FrpError as exc: raise frp_http_error(exc) from exc @app.post("/api/frp/configs/{profile_id}/select") def select_frp_config(profile_id: str) -> dict: try: frp.catalog.select(profile_id) return {"ok": True, "frp": frp.status()} except FrpError as exc: raise frp_http_error(exc) from exc @app.post("/api/frp/service/start") def start_frp_service() -> dict: try: return {"ok": True, "frp": frp.start()} except FrpError as exc: raise frp_http_error(exc) from exc @app.post("/api/frp/service/stop") def stop_frp_service() -> dict: try: return {"ok": True, "frp": frp.stop()} except FrpError as exc: raise frp_http_error(exc) from exc @app.put("/api/network/wifi") def put_wifi_config( update: WifiUpdate, background_tasks: BackgroundTasks, response: Response, ) -> dict: try: result = network.save_settings(update.model_dump()) if result["activation"] == "immediate": response.status_code = 202 background_tasks.add_task(network.activate_saved, result["operation_id"]) return {"ok": True, **result, "network": network.get_status()} except (WifiConfigError, NetworkManagerError, ValueError) as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc except Exception as exc: logger.exception("Failed to save WiFi configuration") raise HTTPException(status_code=500, detail="WiFi configuration could not be saved") from exc @app.put("/api/network/wifi/prompt-delay") def put_wifi_prompt_delay(update: WifiPromptDelayUpdate) -> dict: try: result = network.save_prompt_delay(update.prompt_delay_seconds) return {"ok": True, **result} except (WifiConfigError, ValueError) as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc except Exception as exc: logger.exception("Failed to save WiFi prompt delay") raise HTTPException(status_code=500, detail="WiFi prompt delay could not be saved") from exc @app.put("/api/config") def put_config(update: ConfigUpdate) -> dict: values = update.model_dump(exclude_none=True) try: return control.update_config(values) except (ConfigError, PerformanceModeError, ValueError) as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc except Exception as exc: logger.exception("Failed to save config") raise HTTPException(status_code=500, detail=str(exc)) from exc @app.post("/api/power/voltage/calibration/preview") def preview_voltage_calibration(request: VoltageCalibrationPreviewRequest) -> dict: try: return power_monitor.preview_calibration(request.reference_volts) except CalibrationUnavailableError as exc: raise HTTPException(status_code=503, detail=str(exc)) from exc except CalibrationError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @app.post("/api/power/voltage/calibration/confirm") def confirm_voltage_calibration(request: VoltageCalibrationConfirmRequest) -> dict: try: status = power_monitor.confirm_calibration(request.proposal_id) return { "ok": True, "screen_input_voltage": status, "config": store.config, } except CalibrationConflictError as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc except CalibrationUnavailableError as exc: raise HTTPException(status_code=503, detail=str(exc)) from exc @app.post("/api/power/voltage/calibration/reset") def reset_voltage_calibration() -> dict: status = power_monitor.reset_calibration() return { "ok": True, "screen_input_voltage": status, "config": store.config, } def palette_response() -> dict: return { "colors": store.config["color_palette"], "limit": COLOR_PALETTE_LIMIT, } @app.get("/api/colors/palette") def get_color_palette() -> dict: return palette_response() @app.post("/api/colors/palette") def add_color_palette(request: PaletteColorRequest) -> dict: try: store.add_palette_color(request.color) return palette_response() except ConfigError as exc: status_code = 409 if "limited" in str(exc) else 400 raise HTTPException(status_code=status_code, detail=str(exc)) from exc @app.delete("/api/colors/palette/{hex_color}") def delete_color_palette(hex_color: str) -> dict: try: store.remove_palette_color(f"#{hex_color}") return palette_response() except ConfigError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc def template_error(exc: TemplateError) -> HTTPException: if isinstance(exc, TemplateValidationError): status_code = 422 elif isinstance(exc, TemplateConflictError): status_code = 409 elif isinstance(exc, TemplateNotFoundError): status_code = 404 elif isinstance(exc, TemplateStorageFullError): status_code = 507 else: status_code = 500 logger.exception("Template operation failed", exc_info=exc) return HTTPException(status_code=status_code, detail=str(exc)) def reject_demo_template(template_id: str) -> None: if template_id == DEMO_STATIC_ID: raise TemplateConflictError(DEMO_RESTRICTED_MESSAGE) def reject_demo_animation(animation_id: str) -> None: if animation_id == DEMO_ANIMATION_ID: raise TemplateConflictError(DEMO_RESTRICTED_MESSAGE) def current_library_items() -> list[dict[str, str]]: return [ *({"type": "template", "id": item["id"]} for item in templates.list()["templates"]), *({"type": "animation", "id": item["id"]} for item in animations.list()["animations"]), ] def library_order_error(exc: LibraryOrderError) -> HTTPException: if isinstance(exc, LibraryOrderValidationError): status_code = 422 elif isinstance(exc, LibraryOrderConflictError): status_code = 409 elif isinstance(exc, LibraryOrderStorageFullError): status_code = 507 else: status_code = 500 logger.exception("Library order operation failed", exc_info=exc) return HTTPException(status_code=status_code, detail=str(exc)) def copy_scene_as_template(name: str, scene: dict[str, Any]) -> dict: candidate = f"{name} - 副本" sequence = 2 while True: try: return templates.create(candidate, scene) except TemplateConflictError: candidate = f"{name} - 副本 {sequence}" sequence += 1 @app.get("/api/templates") def list_templates(include_demo: bool = False) -> dict: result = templates.list() if include_demo: result["templates"] = [demo_template(), *result["templates"]] return result @app.get("/api/library/order") def get_library_order() -> dict: try: return library_order.get(current_library_items()) except LibraryOrderError as exc: raise library_order_error(exc) from exc @app.put("/api/library/order") def put_library_order( request: LibraryOrderRequest, if_match: str | None = Header(default=None, alias="If-Match"), ) -> dict: items = [item.model_dump() for item in request.items] expected_revision = normalize_if_match(if_match) demo_ids = {DEMO_STATIC_ID, DEMO_ANIMATION_ID} if any(item["id"] in demo_ids for item in items): raise HTTPException(status_code=422, detail="demo items cannot be included in library order") try: return library_order.update( items, current_library_items(), expected_revision, ) except LibraryOrderError as exc: raise library_order_error(exc) from exc @app.post("/api/templates", status_code=201) def create_template(request: TemplateCreateRequest) -> dict: try: return templates.create(request.name, request.scene) except TemplateError as exc: raise template_error(exc) from exc @app.get("/api/templates/{template_id}") def get_template(template_id: str) -> dict: try: if template_id == DEMO_STATIC_ID: return demo_template(include_scene=True) return templates.get(template_id) except TemplateError as exc: raise template_error(exc) from exc @app.get("/api/templates/{template_id}/thumbnail") def get_template_thumbnail(template_id: str, v: str | None = None) -> FileResponse: try: if template_id == DEMO_STATIC_ID: item = demo_template(include_scene=True) if v is not None and v != item["digest"]: raise TemplateNotFoundError("template thumbnail not found") return Response( content=demo_thumbnail(item["scene"]), media_type="image/png", headers={"Cache-Control": "public, max-age=31536000, immutable"}, ) return FileResponse( templates.thumbnail_path(template_id, digest=v), media_type="image/png", headers={"Cache-Control": "public, max-age=31536000, immutable"}, ) except TemplateError as exc: raise template_error(exc) from exc @app.put("/api/templates/{template_id}") def update_template( template_id: str, request: TemplateSceneRequest, if_match: str | None = Header(default=None, alias="If-Match"), ) -> dict: try: reject_demo_template(template_id) return templates.update_scene(template_id, request.scene, normalize_if_match(if_match)) except TemplateError as exc: raise template_error(exc) from exc @app.patch("/api/templates/{template_id}") def rename_template( template_id: str, request: TemplateNameRequest, if_match: str | None = Header(default=None, alias="If-Match"), ) -> dict: try: reject_demo_template(template_id) return templates.rename(template_id, request.name, normalize_if_match(if_match)) except TemplateError as exc: raise template_error(exc) from exc @app.post("/api/templates/{template_id}/copy", status_code=201) def copy_template(template_id: str) -> dict: try: if template_id == DEMO_STATIC_ID: item = demo_template(include_scene=True) return copy_scene_as_template(item["name"], item["scene"]) return templates.copy(template_id) except TemplateError as exc: raise template_error(exc) from exc @app.post("/api/templates/{template_id}/play") def play_template( template_id: str, if_match: str | None = Header(default=None, alias="If-Match"), ) -> dict: try: detail = ( demo_template(include_scene=True) if template_id == DEMO_STATIC_ID else templates.get(template_id) ) expected = normalize_if_match(if_match) if detail["revision"] != expected: raise TemplateConflictError("template was modified") resolved = { "type": "template", "id": template_id, "name": detail["name"], "revision": detail["revision"], "is_demo": template_id == DEMO_STATIC_ID, "image": render_scene_image(detail["scene"], font_resolver=fonts.resolve), } apply_resolved_content(resolved, notify_activity=True) return { "ok": True, "template_id": template_id, "revision": expected, } except TemplateError as exc: raise template_error(exc) from exc @app.delete("/api/templates/{template_id}") def delete_template( template_id: str, if_match: str | None = Header(default=None, alias="If-Match"), ) -> dict: try: reject_demo_template(template_id) expected = normalize_if_match(if_match) current = templates.get(template_id) if current["revision"] != expected: raise TemplateConflictError("template was changed by another client") previous = switch_default_before_delete("template", template_id) try: templates.delete(template_id, expected) except Exception: restore_default_after_failed_delete(previous) raise return {"ok": True} except TemplateError as exc: raise template_error(exc) from exc @app.get("/api/animations") def list_animations(include_demo: bool = False) -> dict: result = animations.list() if include_demo: result["animations"] = [demo_animation(), *result["animations"]] return result def media_error(exc: MediaImportError) -> HTTPException: return HTTPException(status_code=exc.status_code, detail=str(exc)) @app.post("/api/media-imports", status_code=202) async def create_media_import(request: Request, filename: str, name: str | None = None) -> dict: content_type = request.headers.get("content-type", "").split(";", 1)[0].strip().lower() if content_type != "application/octet-stream": raise HTTPException(status_code=415, detail="Content-Type must be application/octet-stream") raw_length = request.headers.get("content-length") if raw_length is None: raise HTTPException(status_code=411, detail="Content-Length is required") try: length = int(raw_length) except ValueError as exc: raise HTTPException(status_code=411, detail="Content-Length must be an integer") from exc job_id = None try: job, source = media_imports.begin_upload(filename, name, length) job_id = job["id"] received = 0 with source.open("xb") as output: async for chunk in request.stream(): received += len(chunk) if received > length: raise MediaImportError("uploaded data exceeds Content-Length") output.write(chunk) return media_imports.finish_upload(job_id, received) except MediaImportError as exc: if job_id is not None: media_imports.fail_upload(job_id) raise media_error(exc) from exc except OSError as exc: if job_id is not None: media_imports.fail_upload(job_id) raise HTTPException(status_code=507, detail=f"media upload failed: {exc}") from exc @app.get("/api/media-imports") def list_media_imports() -> dict: try: return media_imports.list() except MediaImportError as exc: raise media_error(exc) from exc @app.get("/api/media-imports/{job_id}") def get_media_import(job_id: str) -> dict: try: return media_imports.get(job_id) except MediaImportError as exc: raise media_error(exc) from exc @app.get("/api/media-imports/{job_id}/previews/{index}") def get_media_preview(job_id: str, index: int) -> FileResponse: try: return FileResponse( media_imports.preview_path(job_id, index), media_type="image/png", headers={"Cache-Control": "no-store"}, ) except MediaImportError as exc: raise media_error(exc) from exc @app.put("/api/media-imports/{job_id}/settings") def update_media_settings(job_id: str, request: MediaSettingsRequest) -> dict: try: return media_imports.update_settings( job_id, request.model_dump(exclude_unset=True, exclude_none=True), ) except MediaImportError as exc: raise media_error(exc) from exc @app.post("/api/media-imports/{job_id}/convert", status_code=202) def convert_media_import(job_id: str) -> dict: try: return media_imports.enqueue_conversion(job_id) except MediaImportError as exc: raise media_error(exc) from exc @app.post("/api/media-imports/{job_id}/retry", status_code=202) def retry_media_import(job_id: str) -> dict: try: return media_imports.retry(job_id) except MediaImportError as exc: raise media_error(exc) from exc @app.delete("/api/media-imports/{job_id}") def delete_media_import(job_id: str) -> dict: try: media_imports.cancel(job_id) return {"ok": True} except MediaImportError as exc: raise media_error(exc) from exc @app.post("/api/animations", status_code=201) def create_animation(request: AnimationCreateRequest) -> dict: try: return animations.create(request.name) except TemplateError as exc: raise template_error(exc) from exc @app.get("/api/animations/{animation_id}") def get_animation(animation_id: str, frame_offset: int | None = None, frame_limit: int | None = None) -> dict: try: if animation_id == DEMO_ANIMATION_ID: return demo_animation() return animations.get(animation_id, offset=frame_offset, limit=frame_limit) except TemplateError as exc: raise template_error(exc) from exc @app.patch("/api/animations/{animation_id}") def rename_animation(animation_id: str, request: TemplateNameRequest, if_match: str | None = Header(default=None, alias="If-Match")) -> dict: try: reject_demo_animation(animation_id) return animations.rename(animation_id, request.name, normalize_if_match(if_match)) except TemplateError as exc: raise template_error(exc) from exc @app.delete("/api/animations/{animation_id}") def delete_animation(animation_id: str, if_match: str | None = Header(default=None, alias="If-Match")) -> dict: try: reject_demo_animation(animation_id) expected = normalize_if_match(if_match) current = animations.get(animation_id) if current["revision"] != expected: raise TemplateConflictError("animation was modified") selected_default = default_matches("animation", animation_id) if display.is_animation_active(animation_id) and not selected_default: raise TemplateConflictError("apply other content before deleting the active animation") previous = switch_default_before_delete("animation", animation_id) try: animations.delete(animation_id, expected) except Exception: restore_default_after_failed_delete(previous) raise return {"ok": True} except TemplateError as exc: raise template_error(exc) from exc @app.get("/api/animations/{animation_id}/frames") def list_animation_frames(animation_id: str) -> dict: try: if animation_id == DEMO_ANIMATION_ID: return demo_animation() return animations.get(animation_id) except TemplateError as exc: raise template_error(exc) from exc @app.post("/api/animations/{animation_id}/frames", status_code=201) def create_animation_frame(animation_id: str, request: AnimationFrameCreateRequest, if_match: str | None = Header(default=None, alias="If-Match")) -> dict: try: reject_demo_animation(animation_id) return animations.add_frame(animation_id, request.scene, normalize_if_match(if_match), name=request.name, duration_ms=request.duration_ms) except TemplateError as exc: raise template_error(exc) from exc @app.post("/api/animations/{animation_id}/frames/copy", status_code=201) def copy_animation_frames( animation_id: str, request: AnimationFramesCopyRequest, if_match: str | None = Header(default=None, alias="If-Match"), ) -> dict: try: reject_demo_animation(animation_id) reject_demo_animation(request.destination_animation_id) return animations.copy_frames( animation_id, request.frame_ids, normalize_if_match(if_match), request.destination_animation_id, request.destination_revision, request.insert_after_frame_id, ) except TemplateError as exc: raise template_error(exc) from exc @app.post("/api/animations/{animation_id}/frames/batch-delete") def delete_animation_frames( animation_id: str, request: AnimationFramesDeleteRequest, if_match: str | None = Header(default=None, alias="If-Match"), ) -> dict: try: reject_demo_animation(animation_id) expected = normalize_if_match(if_match) current = animations.get(animation_id) selected_ids = set(request.frame_ids) all_ids = {frame["id"] for frame in current["frames"]} will_empty = bool(all_ids) and selected_ids == all_ids previous = ( switch_default_before_delete("animation", animation_id) if will_empty else None ) try: return animations.delete_frames( animation_id, request.frame_ids, expected, ) except Exception: restore_default_after_failed_delete(previous) raise except TemplateError as exc: raise template_error(exc) from exc @app.get("/api/animations/{animation_id}/frames/{frame_id}") def get_animation_frame(animation_id: str, frame_id: str) -> dict: try: if animation_id == DEMO_ANIMATION_ID: try: return demo_animation_frame(frame_id) except KeyError as exc: raise TemplateNotFoundError("animation frame not found") from exc return animations.get_frame(animation_id, frame_id) except TemplateError as exc: raise template_error(exc) from exc @app.put("/api/animations/{animation_id}/frames/{frame_id}") def put_animation_frame(animation_id: str, frame_id: str, request: AnimationFramePutRequest, if_match: str | None = Header(default=None, alias="If-Match")) -> dict: try: reject_demo_animation(animation_id) return animations.update_frame(animation_id, frame_id, normalize_if_match(if_match), scene=request.scene) except TemplateError as exc: raise template_error(exc) from exc @app.patch("/api/animations/{animation_id}/frames/{frame_id}") def patch_animation_frame(animation_id: str, frame_id: str, request: AnimationFramePatchRequest, if_match: str | None = Header(default=None, alias="If-Match")) -> dict: try: reject_demo_animation(animation_id) fields = request.model_fields_set return animations.update_frame( animation_id, frame_id, normalize_if_match(if_match), name=request.name if "name" in fields else ..., duration_ms=request.duration_ms if "duration_ms" in fields else ..., ) except TemplateError as exc: raise template_error(exc) from exc @app.delete("/api/animations/{animation_id}/frames/{frame_id}") def delete_animation_frame(animation_id: str, frame_id: str, if_match: str | None = Header(default=None, alias="If-Match")) -> dict: try: reject_demo_animation(animation_id) expected = normalize_if_match(if_match) current = animations.get(animation_id) will_empty = len(current["frames"]) == 1 and current["frames"][0]["id"] == frame_id previous = ( switch_default_before_delete("animation", animation_id) if will_empty else None ) try: return animations.delete_frame(animation_id, frame_id, expected) except Exception: restore_default_after_failed_delete(previous) raise except TemplateError as exc: raise template_error(exc) from exc @app.get("/api/animations/{animation_id}/frames/{frame_id}/thumbnail") def get_animation_thumbnail(animation_id: str, frame_id: str, v: str | None = None) -> FileResponse: try: if animation_id == DEMO_ANIMATION_ID: try: frame = demo_animation_frame(frame_id) except KeyError as exc: raise TemplateNotFoundError("animation thumbnail not found") from exc if v is not None and v != frame["digest"]: raise TemplateNotFoundError("animation thumbnail not found") return Response( content=demo_thumbnail(frame["scene"]), media_type="image/png", headers={"Cache-Control": "public, max-age=31536000, immutable"}, ) return FileResponse(animations.thumbnail_path(animation_id, frame_id, v), media_type="image/png", headers={"Cache-Control": "public, max-age=31536000, immutable"}) except TemplateError as exc: raise template_error(exc) from exc @app.put("/api/animations/{animation_id}/frame-order") def reorder_animation(animation_id: str, request: AnimationOrderRequest, if_match: str | None = Header(default=None, alias="If-Match")) -> dict: try: reject_demo_animation(animation_id) return animations.reorder(animation_id, request.frame_ids, normalize_if_match(if_match)) except TemplateError as exc: raise template_error(exc) from exc @app.post("/api/animations/{animation_id}/frame-move") def move_animation_frames(animation_id: str, request: AnimationMoveRequest, if_match: str | None = Header(default=None, alias="If-Match")) -> dict: try: reject_demo_animation(animation_id) return animations.move_frames( animation_id, request.frame_ids, normalize_if_match(if_match), before_frame_id=request.before_frame_id, ) except TemplateError as exc: raise template_error(exc) from exc @app.put("/api/animations/{animation_id}/frame-durations") def set_animation_durations(animation_id: str, request: AnimationDurationsRequest, if_match: str | None = Header(default=None, alias="If-Match")) -> dict: try: reject_demo_animation(animation_id) return animations.set_durations( animation_id, request.duration_ms, normalize_if_match(if_match), frame_ids=request.frame_ids, ) except TemplateError as exc: raise template_error(exc) from exc @app.post("/api/animations/{animation_id}/play") def play_animation(animation_id: str, if_match: str | None = Header(default=None, alias="If-Match")) -> dict: try: detail = demo_animation(include_scenes=True) if animation_id == DEMO_ANIMATION_ID else animations.playback_snapshot(animation_id) expected = normalize_if_match(if_match) if detail["revision"] != expected: raise TemplateConflictError("animation was modified") if not detail["frames"]: raise TemplateConflictError("animation has no frames") frames = detail["frames"] if animation_id != DEMO_ANIMATION_ID else [ { "id": frame["id"], "duration_ms": frame["duration_ms"], "image": render_scene_image(frame["scene"], font_resolver=fonts.resolve), } for frame in detail["frames"] ] display.start_animation( animation_id, expected, frames, content_source={ "category": "animation", "id": animation_id, "name": detail["name"], }, ) return {"ok": True, "animation_id": animation_id, "revision": expected, "frame_count": len(frames)} except TemplateError as exc: raise template_error(exc) from exc @app.post("/api/animations/{animation_id}/copy", status_code=201) def copy_animation(animation_id: str) -> dict: try: source = demo_animation(include_scenes=True) if animation_id == DEMO_ANIMATION_ID else animations.get(animation_id, scenes=True) return animations.copy_from_frames(source["name"], source["frames"]) except TemplateError as exc: raise template_error(exc) from exc @app.post("/api/library/copy", status_code=201) def copy_library_item(request: LibraryCopyRequest) -> dict: try: if request.source_type == "template": source = demo_template(include_scene=True) if request.source_id == DEMO_STATIC_ID else templates.get(request.source_id) if request.source_revision and source["revision"] != request.source_revision: raise TemplateConflictError("source template was modified") source_name = source["name"] scene = source["scene"] duration = DEFAULT_DURATION_MS if request.destination_type == "static": return {"type": "static", "item": copy_scene_as_template(source_name, scene)} else: if not request.source_animation_id: raise TemplateValidationError("source_animation_id is required") source = animations.get_frame(request.source_animation_id, request.source_id) if request.source_revision and source["animation_revision"] != request.source_revision: raise TemplateConflictError("source animation was modified") source_name = source["name"] or f"{source['animation_name']} - 第{source['position']}帧" scene = source["scene"] duration = source["duration_ms"] if request.destination_type == "animation": if not request.destination_animation_id or not request.destination_revision: raise TemplateValidationError("destination animation and revision are required") item = animations.add_frame(request.destination_animation_id, scene, request.destination_revision, name=source_name, duration_ms=duration) return {"type": "animation", "item": item} candidate = source_name suffix = 1 while True: try: item = templates.create(candidate, scene) return {"type": "static", "item": item} except TemplateConflictError: suffix += 1 candidate = f"{source_name} - 副本{'' if suffix == 2 else f' {suffix}'}" except TemplateError as exc: raise template_error(exc) from exc @app.post("/api/display/fill") def display_fill(request: FillRequest) -> dict: try: display.fill(parse_color(request.color)) return {"ok": True, "mode": "fill"} except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc except Exception as exc: logger.exception("Fill display operation failed") raise HTTPException(status_code=500, detail=str(exc)) from exc def display_test_response(display_test: dict) -> dict: return { "ok": True, "display_test": display_test, "effective_brightness": display.get_status()["state"]["effective_brightness"], } @app.post("/api/display/test/fill") def start_display_test_fill(request: FillRequest) -> dict: try: return display_test_response( display.start_display_test_fill(parse_color(request.color)) ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc except Exception as exc: logger.exception("Display test fill operation failed") raise HTTPException(status_code=500, detail=str(exc)) from exc @app.put("/api/display/test/brightness") def set_display_test_brightness(request: DisplayTestBrightnessRequest) -> dict: try: return display_test_response( display.set_display_test_brightness(request.brightness) ) except DisplayTestInactiveError as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc except Exception as exc: logger.exception("Display test brightness operation failed") raise HTTPException(status_code=500, detail=str(exc)) from exc @app.delete("/api/display/test") def stop_display_test() -> dict: try: return display_test_response(display.stop_display_test()) except Exception as exc: logger.exception("Display test exit operation failed") raise HTTPException(status_code=500, detail=str(exc)) from exc @app.post("/api/display/clear") def display_clear() -> dict: try: display.clear() return {"ok": True, "mode": "clear"} except Exception as exc: logger.exception("Clear display operation failed") raise HTTPException(status_code=500, detail=str(exc)) from exc @app.post("/api/display/text") def display_text(request: TextRequest) -> dict: try: display.show_text(text_options_from_request(request)) return {"ok": True, "mode": "text"} except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc except Exception as exc: logger.exception("Text display operation failed") raise HTTPException(status_code=500, detail=str(exc)) from exc @app.post("/api/preview/text") def preview_text(request: TextRequest) -> Response: try: image = render_text( text_options_from_request(request), size=(display.width, display.height), font_resolver=fonts.resolve, ) return image_png_response(image) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc except Exception as exc: logger.exception("Text preview operation failed") raise HTTPException(status_code=500, detail=str(exc)) from exc @app.post("/api/preview/text-layer") def preview_text_layer(request: TextLayerRequest) -> Response: try: image = render_text_layer( text_layer_options_from_request(request), size=(display.width, display.height), font_resolver=fonts.resolve, ) return image_png_response(image) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc except Exception as exc: logger.exception("Text layer preview operation failed") raise HTTPException(status_code=500, detail=str(exc)) from exc @app.post("/api/display/diagnostic") def display_diagnostic(request: DiagnosticRequest) -> dict: try: display.show_diagnostic(request.mode) return {"ok": True, "mode": f"diagnostic:{request.mode}"} except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc except Exception as exc: logger.exception("Diagnostic display operation failed") raise HTTPException(status_code=500, detail=str(exc)) from exc @app.post("/api/preview/diagnostic") def preview_diagnostic(request: DiagnosticRequest) -> Response: try: image = render_diagnostic(request.mode, size=(display.width, display.height)) return image_png_response(image) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc except Exception as exc: logger.exception("Diagnostic preview operation failed") raise HTTPException(status_code=500, detail=str(exc)) from exc @app.websocket("/ws/canvas") async def canvas_ws(websocket: WebSocket) -> None: await websocket.accept() logger.info("Canvas WebSocket connected") try: while True: try: message = await websocket.receive_json() if message.get("type") != "frame_rgb": raise ValueError("message type must be frame_rgb") if message.get("width") != display.width or message.get("height") != display.height: raise ValueError("frame size must be 64x64 RGB888") if message.get("encoding") != "base64_rgb888": raise ValueError("encoding must be base64_rgb888") if "source" not in message: mode = "canvas" elif message.get("source") == "composition": mode = "composition" else: raise ValueError("source must be composition when provided") data = base64.b64decode(str(message.get("data", "")), validate=True) content_source = None raw_source = message.get("content_source") if raw_source is not None: if mode != "composition": raise ValueError("content_source is only valid for composition frames") if not isinstance(raw_source, dict) or set(raw_source) != {"type", "id", "revision"}: raise ValueError("content_source must contain type, id and revision") if raw_source.get("type") != "template": raise ValueError("content_source type must be template") source_id = raw_source.get("id") source_revision = raw_source.get("revision") if not isinstance(source_id, str) or not isinstance(source_revision, str): raise ValueError("content_source id and revision must be strings") try: if str(UUID(source_id)) != source_id: raise ValueError except (ValueError, AttributeError) as exc: raise ValueError("content_source id must be a canonical UUID") from exc if not source_revision: raise ValueError("content_source revision must not be empty") try: source_detail = ( demo_template(include_scene=True) if source_id == DEMO_STATIC_ID else templates.get(source_id) ) source_image = render_scene_image( source_detail["scene"], font_resolver=fonts.resolve, ) if ( source_detail["revision"] == source_revision and source_image.tobytes() == data ): content_source = { "category": "template", "id": source_id, "name": source_detail["name"], } except TemplateError: content_source = None display.show_rgb_bytes(data, mode=mode, content_source=content_source) await websocket.send_json({"type": "ack", "mode": mode, "applied": True}) except WebSocketDisconnect: raise except ValueError as exc: logger.warning("Canvas WebSocket frame error: %s", exc) await websocket.send_json({"type": "error", "message": str(exc)}) except Exception as exc: logger.exception("Canvas WebSocket display error") await websocket.send_json({"type": "error", "message": str(exc)}) except WebSocketDisconnect: logger.info("Canvas WebSocket disconnected") @app.on_event("startup") def startup() -> None: if mobile_runtime: mobile_runtime.start() performance_mode.start(store.config["performance_mode_enabled"]) media_imports.start() power_monitor.sample_now() power_monitor.start() resource_monitor.start() def restore_after_ota() -> None: try: activate_default_content() network.start() except Exception: logger.exception("Failed to restore normal content after OTA completion") try: ota.schedule_kernel_recovery_after_ota() except Exception: logger.exception("Failed to evaluate kernel recovery after OTA completion") ota.set_completion_callback(restore_after_ota) ota.finalize_kernel_recovery_after_boot() if ota.resume_or_start_monitor(restore_after_ota): logger.warning("OTA transaction is active; keeping the update indicator visible") elif maintenance_black: logger.warning("Maintenance-black startup is active; leaving the initial black frame") else: activate_default_content() network.start() @app.on_event("shutdown") def shutdown() -> None: if mobile_runtime: mobile_runtime.close() logger.info("Shutting down; clearing display") media_imports.stop() power_monitor.close() resource_monitor.close() network.close() ota.close() try: performance_mode.restore(remove_record=True) except PerformanceModeError: logger.exception("Failed to restore CPU governors on shutdown") try: display.close() except Exception: logger.exception("Failed to clear display on shutdown") return app app = create_app(ui_copy_editor_build_enabled=UI_COPY_EDITOR_BUILD_ENABLED)