2107 lines
85 KiB
Python
2107 lines
85 KiB
Python
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(
|
|
"<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.replace(
|
|
"</head>",
|
|
f'<meta name="device-boot-id" content="{escape(wifi_store.boot_id, quote=True)}"></head>',
|
|
1,
|
|
))
|
|
|
|
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,
|
|
on_session_open=network.dismiss_for_boot,
|
|
wifi_address=lambda: network.get_cached_status().get("active", {}).get("ipv4_address")
|
|
if network.get_cached_status().get("active", {}).get("connected") else None)
|
|
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
|
|
from app.mobile.wifi import install_wifi_route
|
|
install_wifi_route(app)
|
|
|
|
@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.post("/api/templates/{template_id}/layers")
|
|
def append_template_layer(template_id: str, request: LayerAppendRequest, if_match: str | None = Header(default=None, alias="If-Match")) -> dict:
|
|
try:
|
|
if template_id == DEMO_STATIC_ID:
|
|
raise TemplateConflictError(DEMO_RESTRICTED_MESSAGE)
|
|
return templates.append_layer(template_id, request.layer, normalize_if_match(if_match))
|
|
except TemplateError as exc:
|
|
raise template_error(exc) from exc
|
|
|
|
@app.post("/api/animations/{animation_id}/frames/layers")
|
|
def append_animation_layer(animation_id: str, request: FrameLayerAppendRequest, if_match: str | None = Header(default=None, alias="If-Match")) -> dict:
|
|
try:
|
|
reject_demo_animation(animation_id)
|
|
return animations.append_layer(animation_id, request.layer, request.frame_ids, normalize_if_match(if_match))
|
|
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:
|
|
if template_id == DEMO_STATIC_ID:
|
|
raise TemplateConflictError(DEMO_RESTRICTED_MESSAGE)
|
|
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:
|
|
if template_id == DEMO_STATIC_ID:
|
|
raise TemplateConflictError(DEMO_RESTRICTED_MESSAGE)
|
|
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:
|
|
if template_id == DEMO_STATIC_ID:
|
|
raise TemplateConflictError(DEMO_RESTRICTED_MESSAGE)
|
|
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=(request.edit_size, request.edit_size),
|
|
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)
|