diff --git a/README.md b/README.md
index 7fc85c9..a92d1fe 100644
--- a/README.md
+++ b/README.md
@@ -96,3 +96,8 @@ python -m compileall led_platform tests
node --check led_platform/web/static/output/main.js
node --check led_platform/web/static/admin/main.js
```
+
+## 文档
+
+- [使用文档](docs/usage.md)
+- [生产架构说明](docs/production-architecture.md)
diff --git a/config/scenes.json b/config/scenes.json
index 51e1f1d..97427f5 100644
--- a/config/scenes.json
+++ b/config/scenes.json
@@ -24,6 +24,30 @@
"view": "security",
"description": "\u89c6\u9891\u5899\u3001\u544a\u8b66\u3001\u5730\u56fe\u8054\u52a8\u573a\u666f",
"preload": ["camera-grid", "security-map"]
+ },
+ {
+ "id": "command",
+ "name": "\u6307\u6325\u8c03\u5ea6",
+ "url": "/wall-app",
+ "view": "command",
+ "description": "\u4e8b\u4ef6\u6307\u6325\u3001\u4efb\u52a1\u6d3e\u53d1\u548c\u8de8\u533a\u534f\u540c\u573a\u666f",
+ "preload": ["command-network", "dispatch-tasks"]
+ },
+ {
+ "id": "transport",
+ "name": "\u4ea4\u901a\u8fd0\u884c",
+ "url": "/wall-app",
+ "view": "transport",
+ "description": "\u8f66\u6d41\u3001\u8def\u7f51\u3001\u7ad9\u70b9\u8fd0\u884c\u548c\u8fd0\u529b\u8c03\u5ea6\u573a\u666f",
+ "preload": ["traffic-flow", "station-load"]
+ },
+ {
+ "id": "dataflow",
+ "name": "\u6570\u636e\u4e2d\u67a2",
+ "url": "/wall-app",
+ "view": "dataflow",
+ "description": "\u6570\u636e\u6d41\u5165\u3001\u8ba1\u7b97\u96c6\u7fa4\u3001AI \u5206\u6790\u548c\u8f93\u51fa\u6307\u6807\u573a\u666f",
+ "preload": ["data-pipeline", "ai-cluster"]
}
]
}
diff --git a/docs/production-architecture.md b/docs/production-architecture.md
index 0af72fe..d680305 100644
--- a/docs/production-architecture.md
+++ b/docs/production-architecture.md
@@ -2,48 +2,43 @@
## 目标
-将一个前端大屏应用稳定投放到 `14880 x 3510` LED 大屏。
+本项目用于把一个逻辑尺寸为 `14880 x 3510` 的 LED 大屏内容,拆分到两台 GPU 输出服务器本地渲染。
-硬件约束:
+生产形态:
-- 两台 GPU 服务器。
-- 每台服务器 4 路 4K 输出。
-- 两台服务器品牌或 GPU 可能不同,无法依赖硬件级 GPU 同步。
+- 控制服务运行 FastAPI,负责场景、动作、时间同步、ACK、状态监控。
+- 左 GPU 服务器打开 `/output/left`,只显示左半屏 tile。
+- 右 GPU 服务器打开 `/output/right`,只显示右半屏 tile。
+- 每台 GPU 服务器输出 4 路 4K 到拼控或 LED 控制器。
+- 拼控或 LED 控制器完成最终物理拼接。
-因此系统目标不是让两张 GPU 每一帧硬同步,而是:
+WebSocket 不传大视频流,只传状态、命令、时间、ACK 和性能指标。画面由两台 GPU 服务器本地渲染。
-- 统一业务状态。
-- 统一提交时间。
-- 统一动画时间轴。
-- 输出端本地渲染。
-- 拼控完成物理拼接和输入对齐。
-
-## 推荐架构
+## 逻辑拓扑
```text
-控制服务 FastAPI
- - 场景管理
- - WebSocket 同步
- - 时间校准
- - ACK 追踪
- - 性能监控
-
-左 GPU 服务器
- - 打开 /output/left
- - 渲染完整大屏应用的 left tile
- - 4 路 4K 输出到拼控
-
-右 GPU 服务器
- - 打开 /output/right
- - 渲染完整大屏应用的 right tile
- - 4 路 4K 输出到拼控
-
-拼控 / LED 控制器
- - 接收 8 路 4K
- - 按物理坐标拼接成 14880 x 3510
+ 控制台 /
+ http://control:8000/
+ |
+ v
+ FastAPI 控制服务
+ 场景管理 / WebSocket Hub / SyncCoordinator / 状态接口
+ | |
+ prepare/commit/action status/telemetry/ACK
+ | |
+ -------------------------------
+ | |
+ v v
+ 左 GPU 输出服务器 右 GPU 输出服务器
+ /output/left /output/right
+ left tile 本地渲染 right tile 本地渲染
+ 4 路 4K 输出 4 路 4K 输出
+ | |
+ ---------> 拼控 / LED 控制器 <-
+ 14880 x 3510
```
-## 左右屏如何分开
+## Tile 切分
完整逻辑画面:
@@ -56,13 +51,19 @@
```text
left:
- x=0, y=0, width=7440, height=3510
+ x=0
+ y=0
+ width=7440
+ height=3510
right:
- x=7440, y=0, width=7440, height=3510
+ x=7440
+ y=0
+ width=7440
+ height=3510
```
-输出端页面创建完整逻辑大屏坐标系,然后根据自己的 tile 做视口偏移。真实 Three.js 项目中,应使用:
+输出端页面使用完整逻辑大屏坐标系,再根据自己的 tile 做视口偏移。Three.js 项目中建议使用:
```js
camera.setViewOffset(
@@ -71,19 +72,19 @@ camera.setViewOffset(
tile.x,
tile.y,
tile.width,
- tile.height
+ tile.height,
);
```
## 每台服务器 4 路 4K
-每台服务器建议配置为 `2 x 2` 逻辑桌面:
+每台 GPU 服务器建议配置为 `2 x 2` 逻辑桌面:
```text
7680 x 4320
```
-四路输出:
+四路输出映射:
```text
1: x=0, y=0, 3840 x 2160
@@ -96,35 +97,50 @@ camera.setViewOffset(
## 同步协议
-场景切换:
+### 场景切换
+
+控制台调用:
```text
POST /api/scenes/{scene_id}/switch
```
-服务端广播:
+服务端生成同一个 `command_id`,先广播:
```text
-prepare_scene(command_id, apply_at_ms)
-commit_scene(command_id, apply_at_ms)
+prepare_scene(command_id, apply_at_ms, state, scene)
```
-输出端流程:
+输出端收到 `prepare_scene` 后:
+
+1. 预加载资源。
+2. 等待至少两个 RAF,让浏览器完成布局和首帧准备。
+3. 如果存在 `window.ledPlatformPrepareScene`,等待业务方的真实预渲染/超分完成。
+4. 回 ACK:`status=prepared`。
+
+服务端 `SyncCoordinator` 等 `target_tiles` 中的 `left` 和 `right` 都返回 `prepared` 后,才广播:
```text
-1. 收到 prepare,记录命令,可预加载资源。
-2. 收到 commit,等到 apply_at_ms。
-3. 到点提交场景。
-4. 回 ACK。
+commit_scene(command_id, apply_at_ms, state, scene)
```
-局部动作:
+输出端收到 `commit_scene` 后,等到统一的 `apply_at_ms` 再切换画面,并回 ACK:
+
+```text
+status=committed
+```
+
+这样谁渲染慢就等谁,两个输出端都准备好后才同步放行。
+
+### 局部动作
+
+控制台调用:
```text
POST /api/actions
```
-支持结构化动作:
+支持动作:
```text
page.next
@@ -135,37 +151,93 @@ timeline.pause
timeline.resume
```
-## 关键渲染原则
+局部动作目前直接按 `apply_at_ms` 调度并回 ACK:
-所有动画、Three.js、地图和视频都应基于统一时间轴:
+```text
+status=action_committed
+```
+
+如果后续某些局部动作也需要超分或重资源准备,可以复用场景切换的 prepare/barrier/commit 模式。
+
+## 画面回显
+
+控制台内置两个只读预览 iframe,并把它们无缝拼成一块完整画面:
+
+```text
+/output/left?preview=1
+/output/right?preview=1
+```
+
+预览模式通过 `/ws/admin` 接收状态和命令,不连接 `/ws/output/{tile_id}`,因此:
+
+- 不注册为真实输出节点。
+- 不发送 `prepared`、`committed`、`telemetry` ACK。
+- 不会提前释放同步 barrier。
+- 只用于控制台观察画面。
+
+真实生产输出端仍然打开:
+
+```text
+/output/left
+/output/right
+```
+
+## 时间同步
+
+输出端通过 `clock_ping` / `clock_pong` 估算服务端时间:
+
+```text
+serverNowMs = Date.now() + serverOffsetFromDateMs
+```
+
+RTT 使用 `performance.now()` 估算,服务端时间偏移使用客户端 wall clock 的发送时间和返回时间中点估算。
+
+渲染和动画应基于统一时间轴:
```js
const t = serverNowMs() - sceneStartedAtMs;
renderSceneAt(t);
```
-不要让左右服务器各自自由播放:
+不要让左右输出端各自累计本地 delta:
```js
// 不推荐
animation += localDeltaTime;
```
-这样即使某台机器偶尔慢一帧,也会在下一帧追到统一时间,不会越播越偏。
+## 状态观测
-## 为什么不推荐单机渲染后网络分发
+控制台显示:
-单侧半屏未压缩数据量:
+- 输出节点数量。
+- 每个输出节点 FPS、frame time、RTT、clock offset。
+- 最近命令回显。
+- 每个 tile 的 `prepared`、`committed`、`late`、`error` 状态。
+- barrier 放行耗时。
+
+接口:
```text
-7440 x 3510 x 4 bytes x 60fps ~= 6.3 GB/s
+GET /api/sync/status
+GET /healthz
```
-左右两侧合计超过 `12 GB/s`。普通网络视频流需要编码、传输、解码,会带来延迟、画质损失、文字细线压缩失真,以及新的编码/解码同步问题。
+## 为什么不传视频流
+
+单侧半屏未压缩数据量约为:
+
+```text
+7440 x 3510 x 4 bytes x 60 fps ~= 6.3 GB/s
+```
+
+左右两侧合计超过 `12 GB/s`。普通网络视频流还需要编码、传输、解码,会带来延迟、画质损失、文字细线压缩失真,以及新的编码/解码同步问题。
+
+本系统选择“控制服务发命令,输出端本地渲染”,更适合高分辨率 LED 墙。
## 生产验收指标
-建议关注 P95/P99,而不是平均值:
+建议关注 P95/P99:
```text
60 FPS:
@@ -182,6 +254,7 @@ animation += localDeltaTime;
```text
WebSocket RTT < 10ms
clock offset < 5ms
+left/right 都返回 prepared
left/right 都返回 committed 或 action_committed
不得频繁出现 late ACK
```
@@ -199,12 +272,13 @@ left/right 都返回 committed 或 action_committed
能保证:
- 两边业务状态一致。
-- 两边按同一服务端时间点提交。
+- 两边都准备好后才释放场景提交。
+- 两边按统一服务端时间点提交。
- 动画长期不漂移。
- 命令和性能可观测。
不能单独保证:
-- 两张不同 GPU 每一帧物理扫描完全同相。
+- 两张不同 GPU 的物理扫描完全同相。
如果必须达到广播级帧同步,需要硬件层支持 Genlock / Frame Lock / 专业视频墙控制器。
diff --git a/docs/usage.md b/docs/usage.md
new file mode 100644
index 0000000..dbd7596
--- /dev/null
+++ b/docs/usage.md
@@ -0,0 +1,279 @@
+# 使用文档
+
+## 1. 环境要求
+
+- Python `3.10` 到 `3.12` 推荐。
+- macOS 本机如果没有 `python` 命令,使用 `python3` 或明确的 Python 3.12 路径。
+- 浏览器建议使用 Chrome / Chromium / Edge。
+- 生产输出服务器建议关闭休眠、屏保和自动更新弹窗。
+
+当前项目依赖见:
+
+```text
+requirements.txt
+pyproject.toml
+```
+
+## 2. 首次启动
+
+在项目根目录执行:
+
+```bash
+cd /Users/tjy/Documents/code/work/dp
+/Users/tjy/.local/bin/python3.12 -m venv .venv
+.venv/bin/python -m pip install -r requirements.txt
+.venv/bin/python -m led_platform.cli serve
+```
+
+服务默认监听:
+
+```text
+0.0.0.0:8000
+```
+
+如果要指定端口:
+
+```bash
+.venv/bin/python -m led_platform.cli serve --host 0.0.0.0 --port 8000
+```
+
+## 3. 日常启动
+
+```bash
+cd /Users/tjy/Documents/code/work/dp
+.venv/bin/python -m led_platform.cli serve
+```
+
+查看本机局域网地址:
+
+```bash
+.venv/bin/python -m led_platform.cli urls
+```
+
+也可以直接查看:
+
+```bash
+ipconfig getifaddr en0
+```
+
+## 4. 常用地址
+
+本机访问:
+
+```text
+控制台: http://127.0.0.1:8000/
+左输出: http://127.0.0.1:8000/output/left
+右输出: http://127.0.0.1:8000/output/right
+健康检查: http://127.0.0.1:8000/healthz
+同步状态: http://127.0.0.1:8000/api/sync/status
+API 文档: http://127.0.0.1:8000/docs
+```
+
+局域网访问时,把 `127.0.0.1` 换成控制服务 IP,例如:
+
+```text
+http://192.168.0.182:8000/
+```
+
+## 5. 控制台使用
+
+打开:
+
+```text
+http://控制服务IP:8000/
+```
+
+控制台包含:
+
+- 画面回显:把 left/right 两个只读预览无缝拼成一块完整画面,桌面布局下位于控制台顶部。
+- 场景切换:切换 overview、energy、security。
+- 输出与同步:查看输出端连接数量、FPS、frame time、RTT、clock offset。
+- 生产命令回显:查看真实 `/output/left` 和 `/output/right` 的 prepared、放行、committed 状态。
+- 局部动作:翻页、能源模式、告警、暂停/恢复时间轴。
+
+画面回显使用:
+
+```text
+/output/left?preview=1
+/output/right?preview=1
+```
+
+控制台会把两个预览 iframe 贴在一起,形成 `14880 x 3510` 的完整大屏回显。它是只读预览,不会参与真实输出端 ACK。所以上方画面变化只代表控制台预览已更新;生产命令回显里的等待、放行和完成,代表真实输出端是否已经 ACK。
+
+## 6. 生产输出端使用
+
+左 GPU 输出服务器打开:
+
+```text
+http://控制服务IP:8000/output/left
+```
+
+右 GPU 输出服务器打开:
+
+```text
+http://控制服务IP:8000/output/right
+```
+
+输出端打开后会:
+
+1. 请求 `/api/bootstrap/{tile_id}` 获取 tile、场景和当前状态。
+2. 连接 `/ws/output/{tile_id}`。
+3. 上报 `hello`,注册为真实输出节点。
+4. 定时进行时钟同步。
+5. 定时上报 FPS、frame time、dropped frames。
+6. 收到场景命令后执行 prepare/barrier/commit。
+
+## 7. 场景切换流程
+
+控制台点击“切换”后:
+
+1. 后端生成新场景状态和 `command_id`。
+2. 后端广播 `prepare_scene`。
+3. left/right 真实输出端预加载或预渲染。
+4. left/right 都回 `prepared`。
+5. 后端记录 `released_at_ms` 并广播 `commit_scene`。
+6. left/right 等到 `apply_at_ms` 同步显示。
+7. left/right 回 `committed` 或 `late`。
+
+在控制台“命令回显”里可以看到每个阶段。
+
+## 8. 接入真实超分渲染
+
+输出端预留了 hook:
+
+```js
+window.ledPlatformPrepareScene = async ({ commandId, tile, state, scene }) => {
+ // 1. 根据 scene/state 准备真实资源
+ // 2. 执行超分或离屏渲染
+ // 3. 确认下一次 commit 可以无卡顿显示
+};
+```
+
+只要这个 Promise 不 resolve,输出端就不会发送 `prepared`。后端也就不会释放 `commit_scene`。
+
+如果超分失败,输出端会回:
+
+```text
+status=error
+```
+
+## 9. 配置文件
+
+场景配置:
+
+```text
+config/scenes.json
+```
+
+字段:
+
+```text
+id 场景 ID
+name 控制台显示名
+url 场景 URL,目前示例都使用 /wall-app
+view 输出端内部视图名
+description 描述
+preload 准备阶段预加载资源
+```
+
+Tile 配置:
+
+```text
+config/tiles.json
+```
+
+字段:
+
+```text
+id left / right
+x, y 在完整逻辑大屏中的起点
+width, height tile 尺寸
+wall_width 完整大屏宽度
+wall_height 完整大屏高度
+desktop_width 单台 GPU 服务器桌面宽度
+desktop_height 单台 GPU 服务器桌面高度
+physical_outputs 4 路 4K 输出映射
+```
+
+环境变量前缀:
+
+```text
+LED_
+```
+
+示例:
+
+```bash
+LED_APP_PORT=9000 .venv/bin/python -m led_platform.cli serve
+LED_SWITCH_PREPARE_DELAY_MS=3000 .venv/bin/python -m led_platform.cli serve
+```
+
+## 10. 验证命令
+
+```bash
+.venv/bin/python -m pytest -q
+.venv/bin/python -m compileall led_platform tests
+node --check led_platform/web/static/output/main.js
+node --check led_platform/web/static/admin/main.js
+```
+
+健康检查:
+
+```bash
+curl http://127.0.0.1:8000/healthz
+```
+
+同步状态:
+
+```bash
+curl http://127.0.0.1:8000/api/sync/status
+```
+
+## 11. 常见问题
+
+### 控制台看不到画面回显
+
+先强制刷新控制台页面。画面回显在控制台顶部,标题为“画面回显”。
+
+如果仍然看不到,检查:
+
+```text
+http://控制服务IP:8000/output/left?preview=1
+http://控制服务IP:8000/output/right?preview=1
+```
+
+这两个地址应能单独打开预览画面。
+
+### 命令回显一直显示等待
+
+检查真实输出端是否打开的是:
+
+```text
+/output/left
+/output/right
+```
+
+不要把生产输出端打开成 `?preview=1`,预览模式不会 ACK。
+
+### 切换场景不放行
+
+说明至少一个真实输出端没有回 `prepared`。查看:
+
+```text
+/api/sync/status
+```
+
+重点看 `nodes` 是否有 left/right,`commands[].acks` 是否有两个 tile。
+
+### 局域网机器打不开
+
+检查:
+
+- 服务是否监听 `0.0.0.0:8000`。
+- 控制服务机器防火墙是否允许 8000。
+- 输出服务器是否和控制服务在同一网络。
+- URL 是否使用控制服务的局域网 IP,而不是 `127.0.0.1`。
+
+### 控制台预览和生产输出不同步
+
+控制台预览是观察用途,运行在控制台浏览器中,不参与 ACK,也不代表生产输出端性能。最终验收应以真实 `/output/left` 和 `/output/right` 为准。
diff --git a/led_platform/api/http.py b/led_platform/api/http.py
index 1c3c66e..81baaf1 100644
--- a/led_platform/api/http.py
+++ b/led_platform/api/http.py
@@ -13,6 +13,11 @@ STATIC_DIR = WEB_DIR / "static"
router = APIRouter()
+HTML_HEADERS = {
+ "Cache-Control": "no-store",
+ "Clear-Site-Data": '"cache"',
+}
+
ALLOWED_ACTIONS = {
"page.next",
"page.prev",
@@ -20,12 +25,14 @@ ALLOWED_ACTIONS = {
"security.alert",
"timeline.pause",
"timeline.resume",
+ "motion.mode",
+ "scenario.focus",
}
@router.get("/", include_in_schema=False)
async def admin_index() -> FileResponse:
- return FileResponse(STATIC_DIR / "admin" / "index.html")
+ return FileResponse(STATIC_DIR / "admin" / "index.html", headers=HTML_HEADERS)
@router.get("/output/{tile_id}", include_in_schema=False)
@@ -34,12 +41,12 @@ async def output_index(tile_id: str) -> FileResponse:
tiles.get(tile_id)
except KeyError as exc:
raise HTTPException(status_code=404, detail=f"Unknown output tile: {tile_id}") from exc
- return FileResponse(STATIC_DIR / "output" / "index.html")
+ return FileResponse(STATIC_DIR / "output" / "index.html", headers=HTML_HEADERS)
@router.get("/wall-app", include_in_schema=False)
async def wall_app() -> FileResponse:
- return FileResponse(STATIC_DIR / "output" / "index.html")
+ return FileResponse(STATIC_DIR / "output" / "index.html", headers=HTML_HEADERS)
@router.get("/favicon.ico", include_in_schema=False)
@@ -106,7 +113,6 @@ async def switch_scene(scene_id: str) -> dict:
)
sync.register_command(commit, payload)
await hub.broadcast(prepare, "admin", "outputs")
- await hub.broadcast(commit, "admin", "outputs")
return payload
@@ -149,4 +155,3 @@ async def sync_status() -> dict:
async def telemetry(sample: PerformanceSample) -> dict:
sync.telemetry(sample)
return {"ok": True}
-
diff --git a/led_platform/api/ws.py b/led_platform/api/ws.py
index dd43aed..6ad5629 100644
--- a/led_platform/api/ws.py
+++ b/led_platform/api/ws.py
@@ -90,6 +90,19 @@ async def output_ws(websocket: WebSocket, tile_id: str) -> None:
node_id = str(raw.get("node_id") or node_id or "")
sync.clock_quality(node_id, raw.get("clock_offset_ms"), raw.get("rtt_ms"))
record = sync.ack(raw)
+ release_record = None
+ if record and raw.get("status") == "prepared":
+ release_record = sync.release_if_prepared(record.command_id)
+ if release_record:
+ await hub.broadcast(
+ CommandEnvelope(
+ type=CommandType.COMMIT_SCENE,
+ command_id=release_record.command_id,
+ payload=release_record.payload,
+ ),
+ "admin",
+ "outputs",
+ )
await hub.broadcast(
CommandEnvelope(
type=CommandType.ACK,
diff --git a/led_platform/domain/models.py b/led_platform/domain/models.py
index 9f67c3e..66e9aa5 100644
--- a/led_platform/domain/models.py
+++ b/led_platform/domain/models.py
@@ -128,9 +128,19 @@ class CommandRecord(BaseModel):
apply_at_ms: int
created_at_ms: int = Field(default_factory=epoch_ms)
expires_at_ms: int
+ released_at_ms: int | None = None
payload: dict[str, Any] = Field(default_factory=dict)
acks: list[dict[str, Any]] = Field(default_factory=list)
+ @property
+ def prepared(self) -> bool:
+ prepared_tiles = {
+ ack.get("tile_id")
+ for ack in self.acks
+ if ack.get("status") in {AckStatus.PREPARED, AckStatus.COMMITTED}
+ }
+ return set(self.target_tiles).issubset({str(tile) for tile in prepared_tiles if tile})
+
@property
def complete(self) -> bool:
completed = {
diff --git a/led_platform/main.py b/led_platform/main.py
index 361cda6..e435233 100644
--- a/led_platform/main.py
+++ b/led_platform/main.py
@@ -1,18 +1,26 @@
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
+from starlette.responses import Response
from led_platform.api.http import STATIC_DIR, router as http_router
from led_platform.api.ws import router as ws_router
from led_platform.core.config import get_settings
+class NoCacheStaticFiles(StaticFiles):
+ async def get_response(self, path: str, scope: dict) -> Response:
+ response = await super().get_response(path, scope)
+ response.headers["Cache-Control"] = "no-store"
+ return response
+
+
def create_app() -> FastAPI:
app = FastAPI(
title="LED Wall Projection Platform",
version="1.0.0",
description="Tile-aware dual GPU-server LED wall control and synchronization platform.",
)
- app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
+ app.mount("/static", NoCacheStaticFiles(directory=STATIC_DIR), name="static")
app.include_router(http_router)
app.include_router(ws_router)
return app
diff --git a/led_platform/services/sync_coordinator.py b/led_platform/services/sync_coordinator.py
index c8421f5..b2b9908 100644
--- a/led_platform/services/sync_coordinator.py
+++ b/led_platform/services/sync_coordinator.py
@@ -65,6 +65,15 @@ class SyncCoordinator:
record.acks.append(ack_payload)
return record
+ def release_if_prepared(self, command_id: str) -> CommandRecord | None:
+ with self._lock:
+ self._prune_locked()
+ record = self._commands.get(command_id)
+ if not record or record.released_at_ms is not None or not record.prepared:
+ return None
+ record.released_at_ms = epoch_ms()
+ return record
+
def clock_quality(
self,
node_id: str | None,
diff --git a/led_platform/web/static/admin/index.html b/led_platform/web/static/admin/index.html
index c60af09..fb8bf83 100644
--- a/led_platform/web/static/admin/index.html
+++ b/led_platform/web/static/admin/index.html
@@ -7,25 +7,95 @@
-
+
-
+
+
+
+
+
画面回显
+
14880 x 3510 拼接预览,只读不 ACK
+
+
+
+
+
+
+ left
+ {{ previewLabels.left }}
+
+
+
+
+
+ right
+ {{ previewLabels.right }}
+
+
+
+
+
+
场景切换
-
+
-
+
+
+
+
{{ scene.name }}
+
{{ scene.view }} | {{ scene.url }}
+
{{ scene.description }}
+
+
+
+
+
+
+
+
+
局部动作
+
+
+
+
+
+
@@ -38,30 +108,54 @@
同步状态
API 文档
-
+
+
+ 输出节点
+ {{ nodes.length }}/{{ targetTiles.length }}
+
+
+ {{ node.tile_id }} {{ node.node_id.slice(0, 16) }}
+ {{ nodeMetric(node) }}
+
+
-
-
局部动作
+
+
+
生产命令回显
+
真实输出端 ACK,不代表上方预览
+
-
-
-
-
-
-
-
-
+
+
暂无命令回显
+
+
+
+
{{ commandTitle(command) }}
+
{{ command.command_id.slice(0, 12) }} | 计划 {{ formatClock(command.apply_at_ms) }} | {{ releaseText(command) }}
+
{{ commandNote(command) }}
+
+
{{ commandStateText(command) }}
+
+
+
+ {{ tileId }}
+ {{ tileEchoLabel(command, tileId) }}
+ {{ tileEchoDetail(command, tileId) }}
+
+
+
-
-
+