# promotion-app-iot-commands.md — IoT 命令面 APP 端对接规格（AI Agent 摄取版）

> 版本 v1.0 · 2026-09-11 · 适配 iot-be ≥ 0.0.147（2026-09-10 命令面统一）
> 本文件是 `15.05_IoT命令接入指引-APP端.md` 的机读交付物：纯协议、自包含、无叙述。Agent 按本文件即可完成 APP 侧接入，无需读取任何仓库代码。全部 JSON 字段与样例均出自源码（出处见 15.05 附录 A）。

## 0. 主责边界（APP 端只做这些）

APP = **命令发起方与消费方**：经 HTTP 命令端点下发指令、经 WSS 订阅接收命令生命周期与设备遥测推送、经 REST 轮询领域投影。APP **不**做：设备凭证铸造（publishToken/uploadToken 永不出现在任何 APP 响应）、状态机裁决、并发治理、直连 MQTT（永远禁止）。

## 1. 通道接入

- **C 端 HTTP 基址** `https://iot-be.<env>/api/v1`；**admin HTTP 基址** `https://iot-be.<env>/api/admin/iot`
- 鉴权：C 端 `Authorization: Bearer <用户JWT>`（auth-be 签发，登录/PKCE 静默续期获取）；admin 面 = admin-be 会话 token（`POST /api/admin/auth/local-login` → 信封 `data.accessToken`，opaque 非 JWT）
- 追踪头：每请求 `X-Request-Id`（uuid）；可选 `X-Trace-Id`
- **C 端命令端点**：`POST /api/v1/commands/{sn}`——先 SN 级物权（非 owner → 403/42009，未激活 → 403/42008）
- **admin 命令端点**：`POST /api/admin/iot/devices/{sn}/commands`——前置 **WSS 在场**：同一 adminId 必须持有一条已 `subscribe` 该 SN 的活跃 `wss://iot-be.<env>/ws/v1/admin` 连接，否则 412/42028
- **WSS**：C 端 `/ws/v1/client`（用户 JWT）；admin `/ws/v1/admin`（admin token）。token 唯一合法载体=子协议头 `Sec-WebSocket-Protocol: bearer.<token>`（query/Authorization 不被读取）。握手限流 60 次/60s/IP；非浏览器客户端不带 Origin
- **MQTT**：仅设备↔iot-be（EMQX mTLS，clientid `device:{sn}`）。APP 禁止直连

## 2. 响应判定与错误码

判定规则：
1. body 含 `code` 键 → 信封：成功唯一判据 `code == 0`（载荷在 `data`）；非 0 → 按 `code` 分支
2. 无 `code` 键 → 裸 DTO：2xx 即成功
3. 命令端点专属：**200 = 裸 TranslatedCommandResponse/CommandResponse（无信封壳），业务结果取 `.data`**；错误必为信封

信封结构（错误时出现）：

```json
{ "code": 42007, "success": false, "msg": "device is offline — open the device (simulator) first",
  "timestamp": 1757568000123, "request_id": "8f3c19a0..." }
```

成功信封（devices 域/verify 投影域）：

```json
{ "code": 0, "success": true, "msg": "ok", "timestamp": 1757568000123,
  "request_id": "8f3c19a0...", "data": { } }
```

错误码全表（code / HTTP / 语义 / 处理）：

| code | HTTP | 语义 | 处理 |
| --- | --- | --- | --- |
| 40100 | 401 | UNAUTHENTICATED | 重新登录 |
| 40300 | 403 | FORBIDDEN | 无权限 |
| 40400 | 404 | NOT_FOUND（含 SN 错位防护：对象属于别的 SN） | 检查 ID/刷新 |
| 40900 | 409 | CONFLICT（通用） | 读 msg |
| 42900 | 429 | RATE_LIMITED（seek>30 次/60s） | 退避到窗口结束 |
| 50000 | 500 | INTERNAL_ERROR | 带 request_id 报障 |
| 42001 | 409 | IDEMPOTENCY_CONFLICT | 幂等键换新 |
| 42003 | 409 | STATE_CONFLICT（FPV 占用[msg 含已开时长]/录像已删/终态操作） | msg 可操作，照做 |
| 42004 | 409 | SHADOW_VERSION_CONFLICT | 重读 shadow |
| 42005 | 422 | COMMAND_NOT_FEASIBLE | 检查设备能力 |
| 42007 | 503 | DEVICE_OFFLINE | 提示设备离线（非冲突） |
| 42008 | 403 | DEVICE_NOT_ACTIVATED | 引导激活 |
| 42009 | 403 | DEVICE_ACCESS_DENIED（非 owner） | 切账号/重绑 |
| 42013 | 409 | DEVICE_BOUND_TO_ANOTHER | 引导解绑 |
| 42014 | 400 | BIND_CODE_INVALID | 重新出码 |
| 42015 | 400 | UNBIND_CODE_INVALID | 重取验证码 |
| 42016 | 503 | MESSAGING_UNAVAILABLE | 稍后重试+报障 |
| 42023 | 409 | RELAY_TASK_CONCURRENT | 等终态/轮询既有任务 |
| 42024 | 409 | ASSET_VERSION_MISMATCH（msg 含 requested/current） | 重取详情拿新版本 |
| 42028 | 412 | PRECONDITION_FAILED（admin 无 WSS 在场订阅） | 建立并保持 subscribe |
| 42029 | 503 | SERVICE_UNAVAILABLE | 稍后重试 |

## 3. 命令生命周期（状态机 + 推送帧）

时序（R32 裁决）：`queued → delivered(设备枚举词 sended) → executing(processing) → succeeded/failed/timeout/cancelled`。

- cmd/ack：queued/delivered → delivered
- cmd/progress（仅 PERSISTENT）：{queued, delivered, executing} → executing（progress=设备已执行隐式证据，幂等推进不拒绝）
- cmd/result：任一非终态 → succeeded(success=true)/failed(success=false)；仅四终态拒绝为重复结果
- 超时：timeoutAt（请求 timeoutSeconds，≤0 用服务端默认）到点 → timeout（扫描器）
- 终态后迟到事件：留痕不推进不报错

每步迁移推 WSS 帧（订阅该 SN 的客户端）：

```json
{ "type": "command.status", "sn": "<SN>",
  "data": { "commandId": "<id>", "status": "executing", "eventType": "progress",
            "payload": { "commandId": "<id>", "percent": 50, "phase": "HOMING", "status": "executing" } } }
```

`data.status` ∈ {queued, delivered, executing, succeeded, failed, timeout, cancelled}；`data.eventType` ∈ {ack, progress, result, timeout}；失败附加 `data.errorMessage`。

## 4. 转译业务命令（五条 timelapse 动词 + binding.request）

统一请求体（C 端 `POST /api/v1/commands/{sn}`；admin 换 admin 端点）：

```json
{ "commandId": "可选", "commandType": "<T>", "payload": { }, "mode": "INSTANT",
  "idempotencyKey": "可选", "timeoutSeconds": 0 }
```

统一成功响应（200 裸体；下文各命令只列 `data`）：

```json
{ "commandId": null, "commandType": "<T>", "data": { } }
```

统一设备侧五体（APP 不接触，联调对照）：cmd/req 信封 `{"commandId","mode":"INSTANT","commandType","payload"}`；cmd/ack `{"commandId","status":"delivered"}`（永远第一帧）；五条 timelapse 设备命令均 INSTANT **无 cmd/progress**；cmd/result `{"commandId","success":bool,"result":...}`。

### 4.1 `timelapse.playback-start`（建回放会话+双侧凭证）

请求 payload：`{ "recordingId": "rec-9f21ab34", "startOffsetMs": 0 }`（recordingId 必填；startOffsetMs 默认 0）

成功 `data`（CreateResult）：

```json
{ "sessionId": "6f1e2b4a-...", "recordingId": "rec-9f21ab34", "sn": "<SN>", "status": "CREATED",
  "startOffsetMs": 0, "provider": "vertc", "appId": "vertc-app-xxxx",
  "viewerToken": "<viewer接流令牌>", "viewerIdentity": "viewer-6f1e2b4a",
  "expiresAtEpochSeconds": 1757571600 }
```

前置/错误：无活跃 FPV（42003/409，msg 含 FPV 已开 X 分 Y 秒）；录像未删（DELETED→42003/409）；设备在线（否则 42007/503）；owner（42009/403）；recordingId 不存在或 SN 错位（40400/404）。`data` 绝无 publishToken。

设备五体：

```json
{ "commandId": "a1b2c3d4-...", "mode": "INSTANT", "commandType": "timelapse.playback-start",
  "payload": { "sessionId": "6f1e2b4a-...", "startOffsetMs": 0, "room": "timelapse-6f1e2b4a-...",
    "provider": "vertc", "appId": "vertc-app-xxxx", "publishToken": "<设备令牌>",
    "expiresAtEpochSeconds": 1757571600 } }
→ ack {"commandId":"a1b2c3d4-...","status":"delivered"}
→ timelapse/stream {"sessionId":"6f1e2b4a-...","streamInstanceId":"<设备UUID>","event":"STREAM_STARTED","actualOffsetMs":0,"gopSeconds":2}
→ result {"commandId":"a1b2c3d4-...","success":true,"result":{"sessionId":"6f1e2b4a-...","streamInstanceId":"<UUID>"}}
```

后续：veRTC `joinRoom(token=viewerToken, roomId="timelapse-"+sessionId, userId=viewerIdentity 逐字)`；轮询 `GET /api/v1/timelapse/playback-sessions/{id}` 至 `STREAMING` 取 `actualOffsetMs`；TTL 600s 剩 60s `POST .../heartbeat`（绝对寿命 4h）。

### 4.2 `timelapse.playback-seek`（SEEK 事务·换推流器重推）

请求 payload：`{ "sessionId": "6f1e2b4a-...", "targetOffsetMs": 132000 }`（targetOffsetMs 可省=回读当前 actualOffsetMs；客户端先按 GOP 2s 量化）

成功 `data`（SeekResult）：

```json
{ "sessionId": "6f1e2b4a-...", "streamInstanceId": "e7d8f9a0-...", "status": "STREAMING" }
```

错误：429/42900（>30 次/60s）；42003/409（会话终态）。

设备五体（转译为 `timelapse.seek`）：

```json
{ "commandId": "b2c3d4e5-...", "mode": "INSTANT", "commandType": "timelapse.seek",
  "payload": { "sessionId": "6f1e2b4a-...", "room": "timelapse-6f1e2b4a-...",
    "startOffsetMs": 132000, "streamInstanceId": "e7d8f9a0-...",
    "appId": "vertc-app-xxxx", "publishToken": "<现铸令牌>" } }
→ ack；→ timelapse/stream {"sessionId","streamInstanceId"(回显①载荷值),"event":"STREAM_SEEKED","actualOffsetMs":132000,"gopSeconds":2}
→ result {"commandId":"b2c3d4e5-...","success":true,"result":{"sessionId":"...","streamInstanceId":"...","repushed":true}}
```

后续：本地 playhead 即跳+Loading，≤5s 首帧宽限退出；旧实例 SUPERSEDED 由云端吸收。

### 4.3 `timelapse.playback-stop`（幂等停止：删 viewer+停推+销毁房间）

请求 payload：`{ "sessionId": "6f1e2b4a-..." }`

成功 `data`（StopResult）：

```json
{ "sessionId": "6f1e2b4a-...", "status": "STOPPED" }
```

设备五体（同名命令）：

```json
{ "commandId": "c3d4e5f6-...", "mode": "INSTANT", "commandType": "timelapse.playback-stop",
  "payload": { "sessionId": "6f1e2b4a-...", "room": "timelapse-6f1e2b4a-..." } }
→ ack；→ timelapse/stream {"sessionId","streamInstanceId":"<UUID>","event":"STREAM_STOPPED"}（幂等：无推流器也回）
→ result {"commandId":"c3d4e5f6-...","success":true,"result":{"sessionId":"...","stopped":true}}
```

### 4.3.1 `live.publisher.renew`（veRTC 凭证 15min 滚动续期·域无关媒体面·云端自动下发）

veRTC AccessToken TTL=15min，到期即移出房间。回放与 FPV live 共用 veRTC 通道 → 续期协议不分域（room 前缀分流：`timelapse-*`=按 resumeOffsetMs 换推流器续推；live=重启推流器）。**APP 不下发此命令**——云端锚点自动触发（回放=每次 heartbeat；live=设备 5s 心跳半衰窗），APP 只在 heartbeat 回包消费 viewer 续期字段：

```json
POST /api/v1/timelapse/playback-sessions/{id}/heartbeat →
{ "sessionId": "6f1e2b4a-...", "status": "STREAMING", "heartbeatExpiresAt": "...",
  "viewerToken": "<现铸新 viewer token>", "viewerTokenExpiresAtEpochSeconds": 1789065000 }
```

在房时 `engine.updateToken(viewerToken)` 无感换发（不停拉流不重进房）；台账：`live_viewer_renew_issued` / `live_publisher_renew_issued`（SLS）+ 命令台账 + 设备 cmd/result。


### 4.4 `timelapse.relay-create`（clip/master 按需中转·零持久化）

请求 payload：`{ "recordingId": "rec-9f21ab34", "assetType": "clip", "assetVersion": 1, "highlightId": "H3" }`（master 不传 highlightId；幂等：同 recording+assetType+version 返回既有活动任务）

成功 `data`（CreateResult）：

```json
{ "taskId": "d4e5f6a7-...", "recordingId": "rec-9f21ab34", "sn": "<SN>",
  "assetType": "clip", "assetVersion": 1, "status": "CREATED" }
```

错误：42024/409（版本不符 msg 含 requested/current）；42023/409（并发上限非幂等重放）；42007/503。

设备五体（转译为 `timelapse.file-upload`）：

```json
{ "commandId": "d4e5f6a7-...", "mode": "INSTANT", "commandType": "timelapse.file-upload",
  "payload": { "taskId": "d4e5f6a7-...", "uploadToken": "<128bit URL-safe B64>",
    "objectKey": "timelapse/relay/d4e5f6a7-....mp4", "assetType": "clip", "highlightId": "H3" } }
// compose 产物中转：payload = {taskId, uploadToken, objectKey, assetType:"compose-product", composeId}
→ ack
→ timelapse/relay {"taskId":"...","event":"RELAY_UPLOAD_PROGRESS","uploadedBytes":1024,"totalBytes":2048,"uploadToken":"<首帧回显>"}
→ timelapse/relay {"taskId":"...","event":"RELAY_UPLOAD_PROGRESS","uploadedBytes":2048,"totalBytes":2048}
→ result {"commandId":"...","success":true,"result":{"taskId":"...","uploadedBytes":2048,"videoBytes":true}}
// 失败：result={"taskId":"...","step":"presign-put","httpStatus":403} + success=false
```

后续：轮询 `GET /api/v1/timelapse/relay-tasks/{id}`（`{taskId,status,uploadedBytes,totalBytes}`，状态机 `CREATED→UPLOADING→READY_FOR_PULL→COMPLETED`，支路 EXPIRED/FAILED/CANCELLED）→ `GET .../content`（`{taskId,status,url,expiresInSeconds}`；**pull 即 COMPLETED 一次性语义**，默认 1800s 窗）→ 中断 `POST .../cancel`。

### 4.5 `timelapse.compose-create`（设备端渲染+产物上传）

请求 payload：`{ "recordingId": "rec-9f21ab34", "highlightIds": ["H3","H5"], "template": "standard" }`（template 默认 "standard"；产物=所选窗口时长之和 1:1）

成功 `data`（ComposeResult）：

```json
{ "composeId": "e5f6a7b8-...", "recordingId": "rec-9f21ab34", "sn": "<SN>", "status": "DISPATCHED" }
```

错误：42007/503（设备离线；compose 任务落 `FAILED(dispatch_failed)` 留痕可查）。

设备五体（转译为 `timelapse.compose`）：

```json
{ "commandId": "e5f6a7b8-...", "mode": "INSTANT", "commandType": "timelapse.compose",
  "payload": { "composeId": "e5f6a7b8-...", "recordingId": "rec-9f21ab34",
    "highlightIds": ["H3","H5"], "template": "standard" } }
→ ack
→ timelapse/compose {"composeId":"...","event":"COMPOSE_PROGRESS","percent":30,"phase":"LOCAL_MATERIAL"}
→ timelapse/compose {"composeId":"...","event":"COMPOSE_PROGRESS","percent":70,"phase":"CONCAT"}
→ timelapse/compose {"composeId":"...","event":"COMPOSE_RESULT","result":"ok","productBytes":2048}
→ result {"commandId":"...","success":true,"result":{"composeId":"...","productBytes":2048,"videoBytes":true}}
// 此后云端自动建 compose-product 中转任务并下发 4.4 的 file-upload
```

后续：轮询 `GET /api/v1/timelapse/composes/{id}`（`{composeId,status,percent?,phase?,failureReason?,productRelayTaskId?}`；`DISPATCHED→RENDERING→SUCCEEDED/FAILED`，EXPIRED 支路）→ SUCCEEDED 按 `productRelayTaskId` 走 4.4 后续。

### 4.6 `binding.request`（设备出码·REST 包装触发）

HTTP：`POST /api/v1/devices/{sn}/bind-codes/request`（无 body；admin 同构 + 412 在场前置）

```json
// 200（信封域）
{ "code": 0, "success": true, "msg": "ok", "timestamp": 1757568000123, "request_id": "8f3c...",
  "data": { "sn": "<SN>", "requested": true } }
// 失败：42007/503、42005/422、42009/403（类型化透传，不折叠 requested:false）
```

设备五体：

```json
{ "commandId": "f6a7b8c9-...", "mode": "INSTANT", "commandType": "binding.request",
  "payload": { "type": "bind-code-request", "sn": "<SN>", "requestedBy": "user:<uid>",
    "at": "2026-09-11T08:00:00Z" } }
→ ack；→ result {"commandId":"f6a7b8c9-...","success":true,"ticket":"4831"}
```

后续：轮询 `GET /api/v1/devices/{sn}/bind-codes/latest`（信封；无码 `data:null`）：

```json
{ "code": 0, "success": true, "msg": "ok", "timestamp": 1757568000123, "request_id": "8f3c...",
  "data": { "deviceSn": "<SN>", "ticket": "4831", "version": 21, "sign": "<一机一密签名>",
            "status": "ACTIVE", "expiresAt": "2026-09-11T08:05:00Z" } }
```

QR 票据四元组 `{ticket, sn(取 deviceSn), version, sign}`；状态 `ACTIVE→SCANNED_VERIFIED→BOUND`（残标随 5min TTL 消失）。

## 5. 裸指令目录（16 条 COMMAND_SERVICE_*·自构 payload）

请求（admin 示例；C 端换 `/api/v1/commands/{sn}`+JWT）：

```json
POST /api/admin/iot/devices/{sn}/commands
{ "commandType": "COMMAND_SERVICE_CNC_HOME", "mode": "PERSISTENT",
  "payload": { "target": "HOME_TARGET_ALL" }, "timeoutSeconds": 60 }
```

成功 200 裸 CommandResponse（`status:"queued"`，后续靠 WSS command.status / GET 追踪）：

```json
{ "commandId": "1a2b3c4d-...", "sn": "<SN>", "mode": "PERSISTENT",
  "commandType": "COMMAND_SERVICE_CNC_HOME", "status": "queued", "idempotencyKey": null,
  "errorMessage": null, "queuedAt": "2026-09-11T08:00:00Z", "deliveredAt": null,
  "completedAt": null, "timeoutAt": "2026-09-11T08:01:00Z" }
```

设备五体（HOME 例；cmd/req 信封同 §4）：

```json
{ "commandId": "1a2b3c4d-...", "mode": "PERSISTENT", "commandType": "COMMAND_SERVICE_CNC_HOME",
  "payload": { "target": "HOME_TARGET_ALL" } }
→ ack {"commandId":"1a2b3c4d-...","status":"delivered"}
→ progress（仅 PERSISTENT）{"commandId":"1a2b3c4d-...","percent":50,"phase":"HOMING","status":"executing"}
→ result {"commandId":"1a2b3c4d-...","success":true,"result":"simulated-ok"}
```

目录（commandType / mode / payload 字段）：

| commandType | mode | payload 字段 |
| --- | --- | --- |
| `COMMAND_SERVICE_CNC_ESTOP_CTRL` | INSTANT | `action`∈{ESTOP_ACTION_ENGAGE,ESTOP_ACTION_RESET}（必） |
| `COMMAND_SERVICE_CNC_MACHINE_CTRL` | INSTANT | `action`∈{MACHINE_ACTION_ENABLE,DISABLE,RESET,RESTART}（必） |
| `COMMAND_SERVICE_CNC_SET_MANUAL_MODE` | INSTANT | 无 |
| `COMMAND_SERVICE_SELECT_FILE` | INSTANT | `programPath` text（必） |
| `COMMAND_SERVICE_CNC_CTRL` | PERSISTENT | `action`∈{CNC_ACTION_START,FEED_HOLD,RESUME,STOP}（必）；`programPath`?；`startLine`? |
| `COMMAND_SERVICE_CNC_MDI` | INSTANT | `commands` string[]（必，每行一条如 `G0 X10`） |
| `COMMAND_SERVICE_CNC_JOG_CTRL` | PERSISTENT | `action`∈{JOG_ACTION_CONTINUOUS,INCREMENTAL,STOP}（必）；`axis`?∈{AXIS_X..AXIS_C}；`direction`?∈{JOG_DIRECTION_POSITIVE,NEGATIVE}；`speed`?；`distance`?（仅增量） |
| `COMMAND_SERVICE_CNC_HOME` | PERSISTENT | `target`∈{HOME_TARGET_ALL,X,Y,Z,B,C}（必） |
| `COMMAND_SERVICE_CNC_OVERRIDE_SET` | INSTANT | `target`∈{OVERRIDE_TARGET_FEED,SPINDLE}（必）；`value` 0..2（必，1.0=100%） |
| `COMMAND_SERVICE_CNC_AUTO_TASK_START` | PERSISTENT | `task`∈{AUTO_TASK_TYPE_CENTER_FINDING,TOOL_SETTING}（必）；`timeoutS`? |
| `COMMAND_SERVICE_SELF_TEST_START` | PERSISTENT | 无 |
| `COMMAND_SERVICE_CNC_INI_CTRL` | INSTANT | `action`∈{INI_ACTION_READ,WRITE}（必）；`key`（必，`section.key`）；`value`?（WRITE 必） |
| `COMMAND_SERVICE_CNC_CALIBRATION_CTRL` | INSTANT | `action`∈{CALIBRATION_ACTION_READ,WRITE}（必）；`scope`∈{CALIBRATION_SCOPE_ITEM,ALL}（必）；`section`/`key`?（ITEM 必）；`value`?（WRITE+ITEM 必） |
| `COMMAND_SERVICE_LIGHT_CTRL` | INSTANT | `target`∈{LIGHT_TARGET_AMBIENT,WORK}（必）；`enabled` bool（必） |
| `COMMAND_SERVICE_MQL_CTRL` | INSTANT | `on` bool（必） |
| `COMMAND_SERVICE_SAFETY_POLICY_SET` | INSTANT | `target`∈{SAFETY_POLICY_TARGET_DOOR_OPEN,MQL_LOW_WATER}（必）；`pauseEnabled` bool（必） |
| `COMMAND_SERVICE_STATUS` | INSTANT | 无（拉 status/state/hms 快照） |

PERSISTENT 进度时间线出厂值（percent/phase）：ESTOP_CTRL 0.3s[(10,ESTOP_RECEIVED)(100,ESTOP_APPLIED)]；MACHINE_CTRL 0.5s；SET_MANUAL_MODE 0.5s；SELECT_FILE 0.8s[(50,LOADING_FILE)]；CNC_CTRL 0.5s；JOG_CTRL 0.4s；AUTO_TASK_START 1.0s[(20,TASK_STARTED)(100,TASK_RUNNING)]；CALIBRATION_CTRL 2.0s[(50,CALIBRATING)]；LIGHT/MQL 0.2s；SAFETY_POLICY 0.6s；STATUS 0.2s；MDI 8.0s[(20,MDI_EXECUTING)(80,MDI_EXECUTING)]；HOME 1.0s[(50,HOMING)]；OVERRIDE 2.0s；SELF_TEST 20.0s[(20,SELF_TEST_ELECTRICAL)(50,SELF_TEST_DRIVES)(80,SELF_TEST_SPINDLE)]；INI_CTRL 5.0s[(30,INI_WRITING)(90,INI_VERIFYING)]。无 phases 的 PERSISTENT 默认 [(50,EXECUTING)]。

查询：C 端 `GET /api/v1/commands/{commandId}`；admin 另有 `GET /api/admin/iot/devices/{sn}/commands?page&pageSize`、`.../devices/commands/{commandId}`、`.../commands/{commandId}/events`（ack/progress/result 轨迹）、`.../commands/{commandId}/interactions`（全生命周期台账）。

## 6. live 面（控制面非命令端点；设备 live.start/live.stop 由云端自动下发）

C 端：`GET /api/v1/live/provider`→裸 `{provider}`；`POST /api/v1/live/{sn}` 开会话（LiveSessionResponse）；`POST /api/v1/live/{sn}/viewers/{viewerId}/heartbeat`、`.../unsubscribe`→裸 `{ok:true}`；`DELETE /api/v1/live/{sessionId}` 关闭。admin：`GET /api/admin/iot/live/{sn}/credentials?viewerId=<稳定ID>`（无会话则触发创建）、`.../{sn}/active`→`{active,...}`。WSS 推送 `live.session` 帧（§8）。FPV 与延时回放互斥（冲突 42003，msg 含已开时长）。

## 7. 保留 REST 面（非命令通道）

C 端（基址 `/api/v1`）：`POST /timelapse/playback-sessions/{id}/heartbeat`、`GET /timelapse/playback-sessions/{id}`、`GET /timelapse/relay-tasks/{id}`、`GET /timelapse/relay-tasks/{id}/content`、`POST /timelapse/relay-tasks/{id}/cancel`、`GET /timelapse/composes/{id}`、`DELETE /timelapse/recordings/{recordingId}`、`GET /machining-jobs/{sn}`(+`/{jobId}`)、`GET /shadow/{sn}`、`GET /devices/{sn}/events`、`GET /hms/{sn}/current|summary`。admin verify 只读投影（信封）：`GET /api/admin/iot/timelapse/verify/{playback-sessions|relay-tasks|relay-tasks/*/content|composes}/...`。

## 8. WSS 帧协议全集

连接：见 §1。客户端帧（`v:1` 必带）：

```json
{ "v": 1, "type": "subscribe", "sn": "<SN>" }    → {"type":"ack","result":"subscribed","sn":"<SN>"}
{ "v": 1, "type": "unsubscribe", "sn": "<SN>" }  → {"type":"ack","result":"unsubscribed","sn":"<SN>"}
{ "v": 1, "type": "ping" }                        → {"type":"ack","result":"pong","sn":""}
```

错误帧 `{"type":"error","code","message"}`：`AUTH_REQUIRED/BAD_FRAME/VERSION_UNSUPPORTED/MISSING_SN/MISSING_TYPE/UNSUPPORTED_TYPE/FORBIDDEN`。C 端 subscribe 与一切 SN 帧先过物权（非 owner 拒）；admin 只许 subscribe/unsubscribe/ping（live.* 帧需在场订阅）。

服务端推送（`{"type","sn","data"}`）：

```json
{ "type": "command.status", "sn": "<SN>", "data": { "commandId": "...", "status": "succeeded",
  "eventType": "result", "payload": { } } }
{ "type": "device.status", "sn": "<SN>", "data": { "seq": "1024", "timestampMs": "...",
  "status": { "motionRealtime": { }, "machiningRealtime": { } } } }        // 遥测（business_status 1Hz 广播）
{ "type": "device.status", "sn": "<SN>", "data": { "sn": "<SN>", "online": true, "protocol": "mqtt" } } // 连接态（同 type 按 data 键区分）
{ "type": "device.state", "sn": "<SN>", "data": { "seq": "104", "timestampMs": "...", "state": { } } }  // 低频状态广播
{ "type": "shadow.reported", "sn": "<SN>", "data": { "reported": { }, "version": 57 } }
{ "type": "live.session", "sn": "<SN>", "data": { "sessionId": "live-...", "status": "active",
  "provider": "vertc", "appId": "...", "channel": "...", "viewerToken": "...",
  "viewerIdentity": "...", "expiresAtEpochSeconds": 1757571600, "reason": "..."? } }
```

## 9. HMS/Status/State 字段字典

三帧：`business_status` 1Hz / `business_state` 0.1Hz+变更即推 / `business_hms` 1Hz。顶层 `{seq, timestampMs, <载荷键>}`；载荷键 `status`/`state`/`hms`。`seq`=单调生产者序号（string/number 双接受，乱序 last-writer-wins 全量落库）；`timestampMs`=设备生产时刻 Unix ms（双形态，缺省用接收时刻兜底）；键名 camelCase/snake_case 双兼容。HMS 无独立 WSS type（落库+current/summary 聚合查询）。

### 9.1 BusinessStatus（高频）

```json
{ "seq": "1024", "timestampMs": "1757568000123",
  "status": {
    "motionRealtime": {
      "commandPosition": { "x": 10.5, "y": 20.25, "z": 3.0, "b": 0.0, "c": 0.0 },
      "actualPosition": { "x": 10.501, "y": 20.251, "z": 3.001, "b": 0.0, "c": 0.0 },
      "workPosition": { "x": 0, "y": 0, "z": 0, "b": 0, "c": 0 },
      "workActualPosition": { "x": 0, "y": 0, "z": 0, "b": 0, "c": 0 },
      "jointPosition": { "x": 0, "y": 0, "z": 0, "b": 0, "c": 0 } },
    "machiningRealtime": { "programLine": 42, "progressPct": 36.5, "elapsedS": 1024.0,
      "spindleSpeedRpm": 12000.0, "spindleTemperatureC": 31.0, "feedRate": 500.0,
      "spindleLoad": 42.0, "rapidRate": 8000.0 } } }
```

字段：`motionRealtime.commandPosition|actualPosition|workPosition|workActualPosition|jointPosition`（各=AxisPosition{x,y,z,b,c}：指令/实测绝对、指令/实测工件、物理关节坐标）；`machiningRealtime.programLine`（执行行号）、`progressPct`（0..100）、`elapsedS`、`spindleSpeedRpm`（ESTOP=0）、`spindleTemperatureC`、`feedRate`；`spindleLoad/rapidRate` 为模拟器增量字段。无效值：int32=2147483647，int64/double=9007199254740991——计算/展示前必须检查。

### 9.2 BusinessState（低频）

```json
{ "seq": "104", "timestampMs": "1757568000123",
  "state": {
    "summaryStatus": { "displayState": "DISPLAY_STATE_READY", "sinceTs": 1757567000.0 },
    "controllerStatus": { "connection": "CONNECTION_STATE_CONNECTED", "estop": "ESTOP_STATE_RELEASED",
      "enable": "ENABLE_STATE_ENABLED", "mode": "CONTROLLER_MODE_IDLE",
      "taskState": "CONTROLLER_TASK_STATE_IDLE", "interpState": "INTERPRETER_STATE_IDLE",
      "recoveryStatus": "RECOVERY_STATUS_NONE" },
    "taskStatus": {
      "machining": { "phase": "MACHINING_PHASE_IDLE", "result": "TASK_RESULT_NONE", "startedTs": 0, "finishedTs": 0 },
      "homing": { "phase": "HOMING_PHASE_IDLE", "result": "TASK_RESULT_NONE", "axis": "AXIS_NONE" },
      "selfTest": { "phase": "SELF_TEST_PHASE_IDLE", "result": "TASK_RESULT_NONE" },
      "autoCenter": { "phase": "AUTO_TASK_PHASE_IDLE", "result": "TASK_RESULT_NONE" },
      "autoTool": { "phase": "AUTO_TASK_PHASE_IDLE", "result": "TASK_RESULT_NONE" } },
    "safetyStatus": {
      "drive": { "state": "DRIVE_AGGREGATE_STATE_NORMAL" },
      "softLimit": { "active": false },
      "door": { "position": "DOOR_POSITION_CLOSED", "changedTs": 1757566000.0,
                "interlock": "INTERLOCK_STATE_ARMED", "paused": "BOOL_STATE_FALSE" },
      "mqlInterlock": { "state": "INTERLOCK_STATE_ARMED" },
      "hardwareEstop": { "active": "BOOL_STATE_FALSE", "occurred": "BOOL_STATE_FALSE" } },
    "processStatus": {
      "spindle": { "state": "SPINDLE_PHASE_OFF", "commandedOn": "BOOL_STATE_FALSE",
                   "enabled": "BOOL_STATE_FALSE", "override": 1.0 },
      "feed": { "override": 1.0, "overrideEnabled": "BOOL_STATE_TRUE", "hold": "BOOL_STATE_FALSE" },
      "currentTool": { "number": 3, "length": 52.1, "radius": 3.175 },
      "workCoordinateSystem": { "g5xIndex": 1, "rotationXyDeg": 0.0 } },
    "peripheralStatus": {
      "mql": { "connection": "PERIPHERAL_CONNECTION_CONNECTED", "relay": "PERIPHERAL_RELAY_OFF", "available": true },
      "handwheel": { "connection": "CONNECTION_STATE_CONNECTED", "enable": "ENABLE_STATE_ENABLED",
                     "axis": "AXIS_X", "rate": "HANDWHEEL_RATE_X1", "scale": 0.1 },
      "timelapse": { "connection": "CONNECTION_STATE_CONNECTED", "stream": "STREAM_STATE_OFF" },
      "ambientLight": { "available": true, "enabled": "BOOL_STATE_TRUE" },
      "workLight": { "available": true, "on": "BOOL_STATE_TRUE" },
      "beep": { "available": true } },
    "machineMetadata": { "bootId": "boot-...", "protocolVersion": "1.0.0",
      "activeAxes": ["AXIS_X", "AXIS_Y", "AXIS_Z", "AXIS_B", "AXIS_C"],
      "capabilities": ["MACHINE_CAPABILITY_MACHINING", "MACHINE_CAPABILITY_TIMELAPSE"] },
    "programMetadata": { "selectedPath": "/programs/demo.ngc", "loadedPath": "/programs/demo.ngc", "totalLines": 1204 },
    "motionState": { "homing": "BOOL_STATE_FALSE",
      "axesHomed": { "x": "BOOL_STATE_TRUE", "y": "BOOL_STATE_TRUE", "z": "BOOL_STATE_TRUE",
                     "b": "BOOL_STATE_TRUE", "c": "BOOL_STATE_TRUE" },
      "allHomed": "BOOL_STATE_TRUE" },
    "configStatus": { "doorPauseEnabled": true, "mqlPauseOnLow": false,
      "timelapseServerUrl": "", "timelapseActualUrl": "", "spindleOvertempThresholdC": 60.0 } } }
```

枚举全集：displayState `DISPLAY_STATE_UNKNOWN/OFFLINE/ESTOP/FAULT/SELF_TESTING/HOMING/PAUSE/MACHINING/DISABLED/READY`；connection `CONNECTION_STATE_UNKNOWN/DISCONNECTED/CONNECTED`；estop `ESTOP_STATE_UNKNOWN/RELEASED/ACTIVE`；enable `ENABLE_STATE_UNKNOWN/DISABLED/ENABLED`；mode `CONTROLLER_MODE_UNKNOWN/MANUAL/AUTO/MDI`；taskState `CONTROLLER_TASK_STATE_UNKNOWN/ESTOP/ESTOP_RESET/OFF/ON`；interpState（别名 interpreterState）`INTERPRETER_STATE_UNKNOWN/IDLE/READING/PAUSED/WAITING`；recoveryStatus `RECOVERY_STATUS_UNKNOWN/NONE/RUNNING/SUCCEEDED/FAILED`；machining phase `MACHINING_PHASE_UNKNOWN/IDLE/READY/RUNNING/PAUSE`；task result `TASK_RESULT_NONE/SUCCEEDED/FAILED/CANCELLED`；homing/auto/selfTest phase `*_PHASE_UNKNOWN/IDLE/RUNNING`；drive `DRIVE_AGGREGATE_STATE_UNKNOWN/NORMAL/FAULT`；softLimit 方向 `SOFT_LIMIT_DIRECTION_NONE/MIN/MAX`；door `DOOR_POSITION_UNKNOWN/CLOSED/OPEN/ERROR`；interlock `INTERLOCK_STATE_UNKNOWN/DISABLED/ARMED/BLOCKING/TRIGGERED`；spindle `SPINDLE_PHASE_UNKNOWN/OFF/STARTING/RUNNING/COASTING`；MQL level `MQL_LEVEL_UNKNOWN/NORMAL/LOW`；relay `RELAY_STATE_UNKNOWN/OFF/ON`；handwheel rate `HANDWHEEL_RATE_UNKNOWN/X1/X10/X100`；stream `STREAM_STATE_UNKNOWN/OFF/ON`；capability `MACHINE_CAPABILITY_MACHINING/HOMING/SELF_TEST/TIMELAPSE/MQL/HANDWHEEL/AMBIENT_LIGHT/WORK_LIGHT/BEEP`；BoolState `BOOL_STATE_UNKNOWN/TRUE/FALSE`；Axis `AXIS_NONE/X/Y/Z/B/C`。

命令→状态联动：ESTOP→estop=ACTIVE+enable=DISABLED+主轴进给 0+HMS `ESTOP_ACTIVE`(CRITICAL)；HOME→坐标归零+axesHomed/allHomed=TRUE；JOG/MDI→坐标移动；CNC START→mode=AUTO+interpState=READING+progressPct 推进；SELF-TEST→HMS `SELF_TEST_RUNNING`(INFO)；LIGHT/MQL→peripheralStatus 对应 EnumState 翻转；SAFETY_POLICY→configStatus 布尔翻转；门开→door.position=OPEN+HMS `DOOR_OPEN`(ERROR)。

### 9.3 BusinessHms

```json
{ "seq": "2048", "timestampMs": "1757568000123",
  "hms": {
    "alarmStatus": { "severity": "ALARM_SEVERITY_CRITICAL",
      "activeCodes": ["DOOR_OPEN", "ESTOP_ACTIVE"],
      "items": [
        { "code": "DOOR_OPEN", "severity": "ALARM_SEVERITY_ERROR", "message": "safety door open",
          "source": "safety_status.door", "occurredAt": 1757568.0, "firstSeenTs": 1757568.0, "lastSeenTs": 1757568.0 },
        { "code": "ESTOP_ACTIVE", "severity": "ALARM_SEVERITY_CRITICAL", "message": "emergency stop engaged",
          "source": "controller_status.estop", "occurredAt": 1757568.0, "firstSeenTs": 1757568.0, "lastSeenTs": 1757568.0 } ] },
    "taskDiagnostics": [], "driveDiagnostics": [],
    "peripheralDiagnostics": [], "controllerDiagnostics": [] } }
// 无警：alarmStatus={severity:"ALARM_SEVERITY_NONE",activeCodes:[],items:[]}，四诊断数组恒在
```

字段：`alarmStatus.severity`（最高级：`ALARM_SEVERITY_UNSPECIFIED/NONE/INFO/WARNING/ERROR/CRITICAL`）；`activeCodes[]`；`items[]{code,severity,message,source,firstSeenTs,lastSeenTs,occurredAt}`（时刻 Unix 秒 double）；`taskDiagnostics[]/peripheralDiagnostics[]/controllerDiagnostics[]`（同 item 形）；`driveDiagnostics[]{axis,errorCode(CiA402，0=正常),description,severity,source,firstSeenTs,lastSeenTs}`。

查询：`GET /api/v1/hms/{sn}/current`→`[{sn,errorCode,firstSeenAt,lastSeenAt,resolvedAt?}]`；`GET /api/v1/hms/{sn}/summary?from&to`→`[{errorCode,occurrences,activeCount,totalActiveSeconds,firstSeenAt?,lastSeenAt?}]`。事件历史：`GET /api/v1/devices/{sn}/events?eventType=business.status|business.state|business.hms`（`{id,sn,eventType,source,commandId?,payloadJson,occurredAt}` 分页）。快照：`GET /api/v1/shadow/{sn}`→`{sn,reported,desired,version}`；admin `GET /api/admin/iot/devices/{sn}/snapshot`→`{sn,lastReportAt,status,state,hms,version}`。

## 10. 纪律红线

1. publishToken/uploadToken/objectKey 仅设备命令载荷——零前端、零日志、零落盘；viewerToken 绑定 viewerIdentity 逐字回传。
2. 42007=503 离线（非冲突）；409/42003 才是冲突（msg 可操作）。
3. admin 命令前置=同 adminId 的 WSS 在场订阅（412/42028），操作全程在线。
4. SEEK 先 GOP 2s 量化；429/42900 退避。
5. 裸指令显式 mode；INSTANT 无 progress。
6. 命令结果以 WSS command.status + REST 查询为准，不本地猜超时（服务端 timeoutAt 推送 timeout 帧）。
7. 幂等键请求级唯一；relay 同资产幂等=返回既有任务，终态后重看=新建。
8. assetVersion 操作前对位；42024=重取再试。
9. relay content 一次性（pull 即 COMPLETED）；URL 窗 1800s。
10. WSS token 只走子协议头；APP 永不直连 MQTT。
11. 终态后迟到事件=留痕，不是错误。
