Init
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
"""Application services."""
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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),
|
||||
]
|
||||
|
||||
@@ -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()}
|
||||
|
||||
Reference in New Issue
Block a user