from __future__ import annotations import base64 import hashlib import json import logging import os from html import escape 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 LayerAppendRequest(BaseModel): model_config = ConfigDict(extra="forbid") layer: dict[str, Any] class FrameLayerAppendRequest(LayerAppendRequest): frame_ids: list[str] = Field(min_length=1) 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): edit_size: Literal[64, 192] = 64 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( "