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

2067 lines
83 KiB
Python

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(
"<title>",
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)