This commit is contained in:
TJY
2026-07-16 08:31:52 +08:00
commit 8590fafac1
34 changed files with 2611 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
"""Application services."""
+82
View File
@@ -0,0 +1,82 @@
import json
from pathlib import Path
from threading import Lock
from led_platform.core.clock import epoch_ms
from led_platform.domain import Scene, SceneState
class SceneStore:
def __init__(self, config_path: Path) -> None:
self.config_path = config_path
self._lock = Lock()
self._scenes: dict[str, Scene] = {}
self._state: SceneState | None = None
self.reload()
def reload(self) -> None:
data = json.loads(self.config_path.read_text(encoding="utf-8"))
scenes = [Scene(**item) for item in data["scenes"]]
if not scenes:
raise ValueError("At least one scene is required.")
default_scene_id = data.get("default_scene_id") or scenes[0].id
scene_map = {scene.id: scene for scene in scenes}
if default_scene_id not in scene_map:
raise ValueError(f"Default scene {default_scene_id!r} does not exist.")
default_scene = scene_map[default_scene_id]
with self._lock:
version = self._state.version + 1 if self._state else 1
self._scenes = scene_map
self._state = self._build_state(default_scene, version=version, apply_at_ms=None)
def list(self) -> list[Scene]:
with self._lock:
return list(self._scenes.values())
def get(self, scene_id: str) -> Scene:
with self._lock:
if scene_id not in self._scenes:
raise KeyError(scene_id)
return self._scenes[scene_id]
def state(self) -> SceneState:
with self._lock:
if self._state is None:
raise RuntimeError("Scene state is not initialized.")
return self._state
def switch(self, scene_id: str, apply_at_ms: int) -> SceneState:
with self._lock:
if scene_id not in self._scenes:
raise KeyError(scene_id)
version = self._state.version + 1 if self._state else 1
self._state = self._build_state(
self._scenes[scene_id],
version=version,
apply_at_ms=apply_at_ms,
)
return self._state
def action(self, action: str, apply_at_ms: int) -> SceneState:
with self._lock:
if self._state is None:
raise RuntimeError("Scene state is not initialized.")
self._state = self._state.model_copy(
update={
"version": self._state.version + 1,
"action": action,
"apply_at_ms": apply_at_ms,
}
)
return self._state
@staticmethod
def _build_state(scene: Scene, version: int, apply_at_ms: int | None) -> SceneState:
return SceneState(
active_scene_id=scene.id,
active_view=scene.view,
active_url=str(scene.url),
version=version,
scene_started_at_ms=apply_at_ms or epoch_ms(),
apply_at_ms=apply_at_ms,
)
+115
View File
@@ -0,0 +1,115 @@
from threading import Lock
from led_platform.core.clock import epoch_ms
from led_platform.domain import CommandEnvelope, CommandRecord, OutputNode, PerformanceSample
class SyncCoordinator:
def __init__(self, target_tiles: list[str], command_ttl_seconds: int) -> None:
self.target_tiles = target_tiles
self.command_ttl_seconds = command_ttl_seconds
self._lock = Lock()
self._nodes: dict[str, OutputNode] = {}
self._commands: dict[str, CommandRecord] = {}
def connect_node(
self,
*,
node_id: str,
tile_id: str,
app_version: str | None = None,
user_agent: str | None = None,
) -> OutputNode:
with self._lock:
node = OutputNode(
node_id=node_id,
tile_id=tile_id,
app_version=app_version,
user_agent=user_agent,
)
self._nodes[node_id] = node
return node
def disconnect_node(self, node_id: str | None) -> None:
if not node_id:
return
with self._lock:
self._nodes.pop(node_id, None)
def register_command(self, message: CommandEnvelope, payload: dict) -> CommandRecord:
with self._lock:
self._prune_locked()
apply_at_ms = int(payload["apply_at_ms"])
record = CommandRecord(
command_id=message.command_id,
command_type=message.type,
target_tiles=self.target_tiles,
apply_at_ms=apply_at_ms,
expires_at_ms=epoch_ms() + self.command_ttl_seconds * 1000,
payload=payload,
)
self._commands[message.command_id] = record
return record
def ack(self, payload: dict) -> CommandRecord | None:
with self._lock:
self._prune_locked()
command_id = payload.get("command_id")
if not isinstance(command_id, str) or command_id not in self._commands:
return None
ack_payload = {**payload, "server_received_ms": epoch_ms()}
record = self._commands[command_id]
record.acks = [
ack for ack in record.acks if ack.get("node_id") != ack_payload.get("node_id")
]
record.acks.append(ack_payload)
return record
def clock_quality(
self,
node_id: str | None,
clock_offset_ms: float | None,
rtt_ms: float | None,
) -> None:
if not node_id:
return
with self._lock:
node = self._nodes.get(node_id)
if not node:
return
node.last_seen_ms = epoch_ms()
node.clock_offset_ms = clock_offset_ms
node.rtt_ms = rtt_ms
def telemetry(self, sample: PerformanceSample) -> None:
with self._lock:
node = self._nodes.get(sample.node_id)
if not node:
return
node.last_seen_ms = epoch_ms()
node.fps = sample.fps
node.frame_time_ms = sample.frame_time_ms
node.dropped_frames = sample.dropped_frames
def status(self) -> dict:
with self._lock:
self._prune_locked()
return {
"server_time_ms": epoch_ms(),
"target_tiles": self.target_tiles,
"nodes": [node.model_dump(mode="json") for node in self._nodes.values()],
"commands": [
{**record.model_dump(mode="json"), "complete": record.complete}
for record in self._commands.values()
],
}
def _prune_locked(self) -> None:
now = epoch_ms()
stale = [
command_id
for command_id, record in self._commands.items()
if record.expires_at_ms < now
]
for command_id in stale:
self._commands.pop(command_id, None)
+73
View File
@@ -0,0 +1,73 @@
import json
from pathlib import Path
from led_platform.domain import PhysicalOutput, Tile
class TileStore:
def __init__(self, config_path: Path, wall_width: int, wall_height: int) -> None:
self.config_path = config_path
self.wall_width = wall_width
self.wall_height = wall_height
self._tiles: dict[str, Tile] = {}
self.reload()
def reload(self) -> None:
if self.config_path.exists():
data = json.loads(self.config_path.read_text(encoding="utf-8"))
tiles = [Tile(**item) for item in data["tiles"]]
else:
tiles = self._default_tiles()
self._tiles = {tile.id: tile for tile in tiles}
def all(self) -> list[Tile]:
return list(self._tiles.values())
def get(self, tile_id: str) -> Tile:
if tile_id not in self._tiles:
raise KeyError(tile_id)
return self._tiles[tile_id]
def ids(self) -> list[str]:
return list(self._tiles.keys())
def _default_tiles(self) -> list[Tile]:
half_width = self.wall_width // 2
return [
Tile(
id="left",
name="Left output server",
x=0,
y=0,
width=half_width,
height=self.wall_height,
wall_width=self.wall_width,
wall_height=self.wall_height,
desktop_width=7680,
desktop_height=4320,
physical_outputs=self._default_outputs("left"),
),
Tile(
id="right",
name="Right output server",
x=half_width,
y=0,
width=self.wall_width - half_width,
height=self.wall_height,
wall_width=self.wall_width,
wall_height=self.wall_height,
desktop_width=7680,
desktop_height=4320,
physical_outputs=self._default_outputs("right"),
),
]
@staticmethod
def _default_outputs(prefix: str) -> list[PhysicalOutput]:
return [
PhysicalOutput(id=f"{prefix}-1", index=1, x=0, y=0),
PhysicalOutput(id=f"{prefix}-2", index=2, x=3840, y=0),
PhysicalOutput(id=f"{prefix}-3", index=3, x=0, y=2160),
PhysicalOutput(id=f"{prefix}-4", index=4, x=3840, y=2160),
]
+50
View File
@@ -0,0 +1,50 @@
import asyncio
from collections import defaultdict
from fastapi import WebSocket
from led_platform.domain import CommandEnvelope
class WebSocketHub:
def __init__(self) -> None:
self._lock = asyncio.Lock()
self._groups: dict[str, set[WebSocket]] = defaultdict(set)
async def connect(self, group: str, websocket: WebSocket) -> None:
await websocket.accept()
async with self._lock:
self._groups[group].add(websocket)
async def disconnect(self, group: str, websocket: WebSocket) -> None:
async with self._lock:
if group in self._groups:
self._groups[group].discard(websocket)
if not self._groups[group]:
self._groups.pop(group, None)
async def send(self, websocket: WebSocket, message: CommandEnvelope) -> None:
await websocket.send_json(message.model_dump(mode="json"))
async def broadcast(self, message: CommandEnvelope, *groups: str) -> None:
async with self._lock:
targets: list[tuple[str, WebSocket]] = []
for group in groups:
targets.extend((group, websocket) for websocket in self._groups.get(group, set()))
stale: list[tuple[str, WebSocket]] = []
for group, websocket in targets:
try:
await self.send(websocket, message)
except RuntimeError:
stale.append((group, websocket))
if stale:
async with self._lock:
for group, websocket in stale:
self._groups[group].discard(websocket)
async def counts(self) -> dict[str, int]:
async with self._lock:
return {group: len(websockets) for group, websockets in self._groups.items()}