# 15.05 IoT 命令接入指引-APP端

> 版本 v2.0 · 2026-09-14 · 交付对象：手机 App / Web 客户端团队与端侧 AI Agent · **口径=最终目标态契约**（历史裁决与迁移过程见附录 C）
> 参照实现：C 端 Web `infimaker_user_fe`（timelapse/api.ts、devices/iotApi.ts、commandCards.ts）与验证控制台/远程加工仓 `infimaker_admin_fe`（timelapseVerify.ts、deviceCloud.ts、DeviceCloud/catalog.ts、commandCards.ts）
> **本册自包含**：单独读本册即可完成 IoT 命令面接入——架构、全部指令、每条指令的接入配方（端点/鉴权 lane/前置条件/请求 JSON/成功与错误响应 JSON）、命令全生命周期状态机、WSS 协议（含 message.log/command.lifecycle 观测帧与 watch 声明）、HMS/Status/State 字段字典均已内联，无需翻阅其他仓库或文档。所有契约标注源码出处（附录 A）。
> 命名总则（D6 全协议终态）：**HTTP REST / WSS 帧 / MQTT 命令信封的键一律 snake_case**；int64 值发 proto3 规范十进制字符串（number 形态亦接受）；服务端上行解析**只认 snake**（camel 违约帧 fail-fast 落 parse_error，可观测不静默）。记录在案的 camel 豁免域：timelapse 媒体载荷（设备 MQTT 命令 payload 内层与 timelapse/* 上行帧，见 §3 各处标注）与加工账本详情嵌套段（见 15.03 §3.1）。

---

## 0. 一页速览

| 判定项 | 结论 |
| --- | --- |
| 命令下发端点（两条 lane） | C 端 `POST /api/v1/commands/{sn}`（用户 JWT）；admin `POST /api/admin/iot/devices/{sn}/commands`（admin-be 会话 + **WSS 在场前置**） |
| 转译业务命令（薄载荷） | `timelapse.playback-start / playback-seek / playback-stop / relay-create / compose-create` 五条（user-fe 与 admin-fe 均已集成）；另有 `binding.request`（设备出码）经 REST 包装触发 |
| 裸指令（自构 payload） | FPV 指令台 16 条 `COMMAND_SERVICE_*`（急停/机器/CNC/手动/倍率/自动任务/配置标定/外设/状态查询），admin-fe 已集成 |
| 命令双模式 | `INSTANT`（ack→result）与 `PERSISTENT`（ack→progress×N→result）；转译命令内部固定 INSTANT 下发设备 |
| 命令生命周期（6 态） | `queued → delivered → executing → succeeded / failed / timeout`——**无 cancelled**（中止加工由 eStop 业务指令承担）；每步经 WSS `command.status` 帧推送，终态另有 `command.lifecycle` 闭环帧 |
| 命令去重 | dispatch 层 **command_id 级去重**：重试同 command_id 返回既有命令行原样（不重发 cmd/req），并记 `duplicate_request` 对账边 |
| 响应判定 | **成功 = 2xx 裸体**（命令面为裸 `TranslatedCommandResponse`/`CommandResponse`，无信封壳）；**失败 = 信封** `{code,success:false,msg,timestamp,request_id}`，按数字 `code` 分支 |
| 离线语义 | `42007` = HTTP **503**（不是 409）；`42028` = HTTP **412**（admin 未建立 WSS 在场订阅）；`42900` = HTTP **429**（seek 限流 30 次/60s） |
| 三通道分工 | HTTP=下发与查询；WSS=`/ws/v1/client`（C 端）/`/ws/v1/admin`（admin）实时推送；MQTT=**仅设备↔iot-be**，APP 永不直连 |
| WSS 观测 | `subscribe/unsubscribe/ping` + 可选 **`watch_command_ids`** 连接级观测声明（定向接收 `command.status`/`command.lifecycle`）；`message.log` 全通道审计流（cmd 帧结构化增强） |
| HMS/Status/State | 设备 1Hz/0.1Hz/1Hz 周期上行三帧（**内层键 snake 终态**），云端全量落库 + WSS 广播；APP 消费 WSS 推送或 REST 查询投影 |
| 机读交付包 | 同目录 `promotion-app-iot-commands.md`（纯协议版，与本册同口径） |

---

## 1. 架构总览

### 1.1 三通道分工

```mermaid
flowchart LR
  subgraph APP侧
    U[user-fe / 手机APP<br/>C端 lane=JWT]
    A[admin-fe 验证台/远程加工仓<br/>admin lane]
  end
  subgraph 云端
    I[iot-be<br/>命令面+状态机+投影]
  end
  D[设备/模拟器<br/>MQTT mTLS]
  U -- "HTTP: POST /api/v1/commands/{sn}<br/>REST: 查询/心跳/内容" --> I
  A -- "HTTP: POST /api/admin/iot/devices/{sn}/commands<br/>(前置: WSS 在场订阅)" --> I
  U <-- "WSS: /ws/v1/client 推送<br/>command.status / command.lifecycle / message.log<br/>device.status / device.state / shadow.*" --> I
  A <-- "WSS: /ws/v1/admin 推送" --> I
  I -- "MQTT: devices/{sn}/cmd/req (qos1)" --> D
  D -- "MQTT: devices/{sn}/cmd/ack|progress|result<br/>business_status|state|hms 等" --> I
```

| 通道 | 谁用 | 用途 | 明确不用 |
| --- | --- | --- | --- |
| **HTTP REST** | APP（C 端 + admin） | 命令下发、命令/任务状态查询、heartbeat、内容拉取、快照查询 | 不做实时推送 |
| **WSS** | APP（C 端 `/ws/v1/client`；admin `/ws/v1/admin`） | 订阅 SN → 接收 `command.status`/`command.lifecycle`（按 command_id watch 定向）、`message.log`（全通道审计流）、`device.status`/`device.state`（遥测）、`shadow.reported`；admin 路径的 WSS 订阅同时是命令下发的**在场前置** | 不在 WSS 上下发命令（admin 一律禁止；C 端仅保留 `command`/`shadow.desired` 入站帧） |
| **MQTT** | 仅 设备↔iot-be（EMQX，mTLS，clientid=`device:{sn}`） | 命令下行 `cmd/req`、设备上行 `cmd/ack|progress|result`、`business_status|state|hms`、`timelapse/*`、`live/*` 等 | **APP 永不直连 MQTT**；一切设备交互经 iot-be 中转 |

### 1.2 命令面两条 lane 与转译机制

命令端点收到请求后先查**转译注册表**（`CommandTranslationRegistry`）：

- **命中**（五条 `timelapse.*` 业务动词）→ 服务端做领域裁决（物权/互斥/状态机/凭证铸造）+ 组装厚载荷下发设备 + **同步返回业务结果**（裸 `TranslatedCommandResponse`）。APP 只送薄载荷，凭证（publishToken/uploadToken）只出现在设备命令载荷里，**响应里绝无设备凭证**。
- **未命中**（调试/FPV 指令台的 `COMMAND_SERVICE_*`）→ 裸命令：自构 payload 原样投递设备，同步返回 `CommandResponse`（状态 `queued`），业务结果靠 WSS `command.status`/`command.lifecycle` 帧 + `GET /commands/{command_id}` 追踪。

| | C 端 lane | admin lane |
| --- | --- | --- |
| 端点 | `POST /api/v1/commands/{sn}` | `POST /api/admin/iot/devices/{sn}/commands` |
| 鉴权 | `Authorization: Bearer <用户 JWT>`（auth-be 签发；iot-be 经 auth-be resolve 出 `internalUserId`） | admin-be 管理会话（`AdminSessionCallbackAuthFilter` 回调 admin-be 校验） |
| 物权 | `DeviceAccessGuard.checkOwnership(userId, sn)` fail-closed（非 owner → 42009/403） | 转译器内：已激活设备按 owner UID；未激活设备回退 `admin:<adminId>` 代行 |
| 额外前置 | 无 | **WSS 在场**：发起 HTTP 的同一 adminId 必须持有一条已 `subscribe` 该 SN 的活跃 `/ws/v1/admin` 连接，否则 **412 / 42028** |
| 转译 lane 标记 | `LANE_USER` | `LANE_ADMIN` |

SN 错位防护：转译器把领域对象（recording_id/session_id）解析出的 SN 与 URL `{sn}` 比对，不一致直接 404（防跨设备错投）。

### 1.3 双模式与命令全生命周期状态机

**设备命令帧模式**（`mode` 字段，设备侧枚举同名）：

- `INSTANT`：一次性动作。设备立即 `cmd/ack` → 执行 → `cmd/result`（无 progress）。
- `PERSISTENT`：长任务。设备立即 `cmd/ack` → 按 SCENARIOS 时间线发 `cmd/progress`×N → `cmd/result`。

**云端状态机**（6 态，无 cancelled；R32 语义内化）：

```mermaid
stateDiagram-v2
  [*] --> queued: dispatch 落库+发布 cmd/req
  queued --> delivered: cmd/ack
  delivered --> delivered: 迟到/重复 ack 幂等吸收
  queued --> executing: cmd/progress 隐式证据<br/>（竞态幂等推进, 不拒绝）
  delivered --> executing: cmd/progress
  executing --> executing: cmd/progress×N
  queued --> succeeded: cmd/result 竞态直达
  delivered --> succeeded: cmd/result success=true
  executing --> succeeded: cmd/result success=true
  queued --> failed: cmd/result success=false
  delivered --> failed: cmd/result success=false
  executing --> failed: cmd/result success=false
  delivered --> timeout: 超时扫描（timeout_at 到点）
  executing --> timeout: 超时扫描
  succeeded --> [*]
  failed --> [*]
  timeout --> [*]
```

要点（源码 `CommandStateMachine`）：

1. **progress 允许集 = {queued, delivered, executing}**——progress 到达即设备已执行的隐式证据，从 `queued` 幂等推进到 `executing`，不拒绝（拒绝=丢进度→假超时）。
2. **result 同理**：任一非终态收到 result 直达 `succeeded/failed`；仅三终态（succeeded/failed/timeout）拒绝为重复结果冲突（留痕 `*_skipped_terminal`）。
3. `INSTANT` 命令收到 progress → 状态机冲突（设备侧契约错误），落交互留痕。
4. 终态后的迟到 ack/progress/result：**只记录留痕（`*_skipped_terminal`），不推进、不报错**；终态后晚到的 result 另落 `late_result` 对账边。
5. 超时：`timeout_at`（客户端 `timeout_seconds`，0/负 → 服务端默认值兜底）到点由扫描器置 `timeout`；另有无响应追查边 `response_missing`（静默设备也得到结论性结局）。
6. **去重**：dispatch 层 command_id 级去重——重试同 command_id 返回既有命令行原样（状态机零副作用、不重发 cmd/req），并记 `duplicate_request` 对账边（SLS 按 command_id 可查）。并发重试由 `uk_command_id` 唯一键闭环（竞态落败方返回赢家行）。

**每个状态迁移产生一条 WSS `command.status` 推送帧**；命令进终态时另推一条 `command.lifecycle` 闭环聚合帧（§7.3）：

```json
{
  "v": 1,
  "type": "command.status",
  "sn": "BCZJ00000101KE00002682F000000000050T",
  "data": {
    "command_id": "0b1f...e9",
    "status": "executing",
    "event_type": "progress",
    "payload": { "command_id": "0b1f...e9", "phase": "COMMAND_PROGRESS_PHASE_RUNNING", "progress_permille": 500, "stage": "HOMING", "execution_id": "exec-0b1f...e9", "timestamp_ms": "1757568000123" }
  }
}
```

字段：`data.command_id` / `data.status`（枚举 queued/delivered/executing/succeeded/failed/timeout）/ `data.event_type`（`ack|progress|result|timeout`）/ `data.payload`（设备上行帧原文）/ 失败时附加 `data.error_message`。**投递路由 = watch**：仅声明观测该 command_id 的连接收到（§7.2）；其余帧类型仍按 SN 扇出。

### 1.4 全链路时序（一条 PERSISTENT 裸指令的完整生命周期）

```mermaid
sequenceDiagram
  participant APP as APP(user/admin)
  participant IOT as iot-be
  participant DEV as 设备(MQTT)
  Note over APP,IOT: C端: JWT 已获取 / admin: WSS 已在场订阅该SN
  APP->>IOT: POST 命令端点 {command_id?,command_type,mode,payload,idempotency_key?,timeout_seconds}
  IOT->>IOT: command_id 去重→限流→幂等键→在线断言→可行性断言→queued 落库
  IOT->>DEV: MQTT devices/{sn}/cmd/req {"command_id","mode","command_type","payload"}
  IOT-->>APP: 200 裸 CommandResponse {command_id, status:"queued", ...}
  IOT-->>APP: WSS message.log（cmd/req 下行帧, 结构化 command_id/command_type/execution_id/payload）
  DEV-->>IOT: devices/{sn}/cmd/ack {"command_id","status":"COMMAND_ACK_STATUS_ACCEPTED","execution_id","received_at_ms"}
  IOT-->>APP: WSS command.status {status:"delivered", event_type:"ack"}
  loop PERSISTENT 进度
    DEV-->>IOT: devices/{sn}/cmd/progress {"command_id","phase","progress_permille","stage","execution_id","timestamp_ms"}
    IOT-->>APP: WSS command.status {status:"executing", event_type:"progress", payload:↑}
  end
  DEV-->>IOT: devices/{sn}/cmd/result {"command_id","success":true,"result":{...}}
  IOT-->>APP: WSS command.status {status:"succeeded", event_type:"result", payload:↑}
  IOT-->>APP: WSS command.lifecycle {终态聚合帧: request+timeline 全边+progress_snapshot}
  Note over APP,DEV: 任一步超时: IOT 扫描器置 timeout 并推送 event_type:"timeout" + lifecycle 帧
```

转译命令（五条 timelapse 动词）的差异：HTTP 同步响应已是**业务结果**（会话/任务已建好），设备侧命令（`timelapse.playback-start/seek/stop/file-upload/compose`）由服务端在事务内自动下发，APP 无需再发第二条指令，后续只做 REST 轮询。

---

## 2. 鉴权、在场前置与通用协议

### 2.1 C 端 lane：JWT 获取与物权校验

1. **取 token**：登录走 auth-be（Auth0 托管域，PKCE；参照 user-fe `getAccessTokenSilently()` 静默续期）。该 JWT 即所有 C 端 iot 接口的 Bearer token。
2. **iot-be 侧解析**：`Authorization: Bearer <JWT>` → iot-be `JwtAuthFilter` 调 auth-be resolve → 得 `internalUserId`（属性名 `iot.userId`）。解析失败=未认证（401 / 40100）。
3. **SN 级准入（S1，fail-closed；两层权模型）**：命令端点先 `DeviceAccessGuard.checkOwnership(userId, sn)`——**机主（物权）或控制权持有者（多人扫码获得 `iot_device_control_grant`）均放行**；两者皆无 → 403 / `42009`（`DEVICE_ACCESS_DENIED`）。设备未激活 → 403 / `42008`。模型全貌见 15.07《设备管理》。

### 2.2 admin lane：opaque token + WSS 在场（412/42028 触发与解除）

admin 命令端点走 **admin-be 管理会话**（不是 JWT）：

**第 1 步 · 拿 admin 会话 token**（opaque 串，非 JWT）：

```bash
curl -s -X POST https://admin-be.<env>/api/admin/auth/local-login \
  -H 'Content-Type: application/json' \
  -d '{"username":"<admin账号>","password":"<密码>"}'
# 响应信封 data.accessToken = "5bffd0b3-….ead56"（opaque，≈101 字符）
```

**第 2 步 · 建立 admin 控制 WSS**（token 唯一合法载体 = 子协议头）：

```text
URL:   wss://iot-be.<env>/ws/v1/admin
Header: Sec-WebSocket-Protocol: bearer.<accessToken>     ← 无空格，不带 "Bearer " 前缀
期望:   HTTP 101 Switching Protocols
```

禁项：query 传 token（已移除）、`Authorization` 头（握手不读）、非浏览器客户端带 `Origin` 头（须命中 CORS 白名单）。握手限流：每 IP 60 次/60s（超了 401 `rate_limited`，等窗口再试）。

**第 3 步 · 订阅目标 SN**（412 的解除条件）：

```json
{"type": "subscribe", "sn": "<目标SN>"}
```

成功回 `{"type":"ack","result":"subscribed","sn":"<SN>"}`。此后 `AdminPresence.hasAdminSubscriber(sn, adminId)=true`——**第 4 步的 HTTP 命令下发就要求发起请求的同一 adminId 持有该订阅**，否则：

```json
HTTP 412
{
  "code": 42028,
  "success": false,
  "msg": "active admin control websocket required for device <SN>",
  "timestamp": 1757568000123,
  "request_id": "8f3c19a0..."
}
```

语义：snapshot 等无副作用读不要求在场；**命令下发/通知推送是危险操作，要求"发起请求的 admin 正在通过 WSS 盯着这台设备"**——刻意安全设计。断开或 unsubscribe 后 412 复现，操作全程保持 socket 在线。

### 2.3 通用请求头与追踪

| 头 | 必带 | 说明 |
| --- | --- | --- |
| `Authorization: Bearer <token>` | 是 | C 端=用户 JWT；admin 面=admin-be 会话 token |
| `Content-Type: application/json` | 是 |  |
| `X-Request-Id` | 建议 | 每请求唯一（如 uuid）；响应信封回显 `request_id`，报障必带 |
| `X-Trace-Id` | 可选 | 链路追踪（优先级高于 X-Request-Id 的回显选择） |

### 2.4 响应判定规则（裸体 vs 信封）

**信封 v2 结构**（错误时才出现；成功命令面响应是裸体）：

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

客户端判定（与 user-fe 拦截器同款逻辑）：

1. 响应 body 含 `code` 键 → 信封：成功唯一判据 `code == 0`（业务载荷在 `data`）；非 0 → 按 `code` 分支，`msg` 展示、`request_id` 留痕。
2. 无 `code` 键 → **iot 裸 DTO**：2xx 即成功。
3. 命令端点专属：**200 = 裸 `TranslatedCommandResponse` / `CommandResponse`（无信封壳），业务结果取 `.data`**；错误必为信封。devices 域 REST（bind-codes 等）显式包信封（成功信封 `data` 为载荷）；admin verify 投影 GET 显式包信封。

### 2.5 错误码全表（含 HTTP 码与处理建议）

iot 专属段 42xxx（登记簿 `IotErrorCodes`）+ 通用段（`ErrorCodes`）：

| code | HTTP | 语义 | 触发场景（命令面） | APP 处理建议 |
| --- | --- | --- | --- | --- |
| 40100 | 401 | UNAUTHENTICATED | 无/坏 token、WSS 握手身份解析失败 | 引导重新登录；WSS 见 §7.1 |
| 40300 | 403 | FORBIDDEN | 权限不足（admin 面无 `device.cloud.read`/`*:*:*`） | 提示无权限 |
| 40400 | 404 | NOT_FOUND | recording_id/session_id/task_id/compose_id 不存在；**SN 错位防护**（领域对象属于另一台 SN） | 检查 ID；刷新列表 |
| 40900 | 409 | CONFLICT（通用） | 通用冲突 | 读 msg |
| 42900 | **429** | RATE_LIMITED | `timelapse.playback-seek` > 30 次/60s（按 session_id 限流）；WSS 握手超限为 401 `rate_limited`（字符串码） | 退避到窗口结束再试；SEEK 目标先按 GOP 2s 量化减少请求 |
| 50000 | 500 | INTERNAL_ERROR | 兜底 | 带 `request_id` 报障 |
| 42001 | 409 | IDEMPOTENCY_CONFLICT | 同 `idempotency_key` 配了不同请求体 | 幂等键必须请求级唯一 |
| 42003 | 409 | STATE_CONFLICT | 状态机拒绝：FPV 占用（msg 含已开时长）、录像已删除、会话/任务终态操作、命令终态重复 result | msg 可操作：先关 FPV/换录像/不再操作终态对象 |
| 42004 | 409 | SHADOW_VERSION_CONFLICT | shadow `expected_version` 不匹配 | 重读 shadow 再试 |
| 42005 | **422** | COMMAND_NOT_FEASIBLE | 命令对目标设备不可行 | 检查设备能力/状态 |
| 42007 | **503** | DEVICE_OFFLINE | 命令下发时设备离线（在线存储判定） | 提示"设备离线，请先开机"；**不是冲突** |
| 42008 | 403 | DEVICE_NOT_ACTIVATED | 设备无激活记录 | 引导激活流程 |
| 42009 | 403 | DEVICE_ACCESS_DENIED | 当前用户既非机主也不持控制权（两层权模型） | 扫码/输码获得控制权，或切换机主账号 |
| 42025 | 404 | TRANSFER_EMAIL_NOT_FOUND | 过户目标邮箱无已验证账号（`POST /devices/{sn}/transfer`） | 核对对方邮箱（须为已验证注册邮箱） |
| 42014 | 400 | BIND_CODE_INVALID | 绑定码无效/过期/已核销 | 重新出码 |
| 42015 | 400 | UNBIND_CODE_INVALID | 解绑验证码无效/过期 | 重新获取 |
| 42016 | **503** | MESSAGING_UNAVAILABLE | MQTT 通道不可用（broker 不可达；命令会以 failed 留痕） | 稍后重试；带 request_id 报障 |
| 42023 | 409 | RELAY_TASK_CONCURRENT | 同 (sn,asset_type) 活跃中转任务已达上限且非同资产幂等重放 | 等当前任务终态或轮询既有任务 |
| 42024 | 409 | ASSET_VERSION_MISMATCH | `asset_version` 与当前不符（msg 含 requested/current） | 重取详情拿新版本再试 |
| 42028 | **412** | PRECONDITION_FAILED | admin 命令/通知下发时无在场 WSS 订阅 | 按 §2.2 三步建立并保持订阅 |
| 42029 | 503 | SERVICE_UNAVAILABLE | ResponseStatus 503 泛化 | 稍后重试 |

关键错误信封样例（其余同构，替换 code/msg）：

```json
// 42007 设备离线（HTTP 503）
{ "code": 42007, "success": false, "msg": "device is offline — open the device (simulator) first",
  "timestamp": 1757568000123, "request_id": "c01d..." }

// 42003 FPV 占用冲突（HTTP 409，msg 可操作：含 FPV 已开时长）
{ "code": 42003, "success": false,
  "msg": "live FPV already streaming for 3分12秒 (sn=BCZJ..., liveSessionId=...) — close FPV before timelapse playback",
  "timestamp": 1757568000123, "request_id": "c01d..." }

// 42900 seek 限流（HTTP 429）
{ "code": 42900, "success": false, "msg": "seek rate limit exceeded for session <sessionId>",
  "timestamp": 1757568000123, "request_id": "c01d..." }

// 42024 版本不匹配（HTTP 409）
{ "code": 42024, "success": false, "msg": "recording assetVersion has moved on (requested=1, current=2)",
  "timestamp": 1757568000123, "request_id": "c01d..." }

// 42028 admin 未在场（HTTP 412）
{ "code": 42028, "success": false, "msg": "active admin control websocket required for device BCZJ...",
  "timestamp": 1757568000123, "request_id": "c01d..." }
```

---

## 3. 业务命令接入配方（转译命令·五条 timelapse + binding.request）

**统一请求体**（`CommandDispatchRequest`，snake；五条转译命令只需 `command_type` + `payload` 两键；`command_id` 可省——服务端生成）：

```json
{
  "command_id": "可选-客户端自定幂等ID（重试同id=取回既有命令行,不重发）",
  "command_type": "timelapse.playback-start",
  "payload": { },
  "mode": "INSTANT",
  "idempotency_key": "可选-幂等键",
  "timeout_seconds": 0
}
```

**统一成功响应**（200 裸体，snake；`data` = 领域结果；下文各指令只给 `data` 内容）：

```json
{ "command_id": null, "command_type": "timelapse.playback-start", "data": { } }
```

**统一设备侧五体**（APP 不接触，供联调对照；设备命令与 APP 动词的转译对应：`playback-seek→timelapse.seek`、`relay-create→timelapse.file-upload`、`compose-create→timelapse.compose`，其余同名直达）：

- 下行 `devices/{sn}/cmd/req`：`{"command_id","mode":"INSTANT","command_type","payload":{...}}`（信封固定四键，snake；payload 内层为 timelapse 媒体域 camel 豁免）
- 上行 `devices/{sn}/cmd/ack`：`{"command_id":"...","status":"COMMAND_ACK_STATUS_ACCEPTED","execution_id":"exec-...","received_at_ms":"..."}`（设备收到即回，永远第一帧）
- 上行 `devices/{sn}/cmd/progress`：仅 PERSISTENT 命令；五条 timelapse 设备命令均为 INSTANT，**无 cmd/progress**（进度走领域通道：`timelapse/relay`、`timelapse/compose`）
- 上行 `devices/{sn}/cmd/result`：`{"command_id","success":true|false,"result":{...}}`（失败可带 `"error":"..."`）
- 领域上行帧：`devices/{sn}/timelapse/stream|relay|compose`（camel·媒体豁免域，各指令小节给出）

### 3.1 `timelapse.playback-start` —— 打开延时摄影回放（建会话+双侧凭证分发）

**业务含义**：为指定录像创建播放会话——云端签发 veRTC 房间与 viewer 凭证、命令设备从指定偏移起推流。是回放链的入口动作。

**前置**：设备在线（否则 42007/503）；无活跃实时 FPV（否则 42003/409，msg 含 FPV 已开时长）；录像未删除（DELETED → 42003/409）；C 端须为设备 owner 或控制权持有者。

**请求 JSON（C 端）**：

```json
POST /api/v1/commands/BCZJ0000...50T
Authorization: Bearer <用户JWT>
{
  "command_type": "timelapse.playback-start",
  "payload": { "recording_id": "rec-9f21ab34", "start_offset_ms": 0 }
}
```

`payload` 字段：`recording_id`（必填）、`start_offset_ms`（可选，默认 0，毫秒）。**转译器只认 snake**（camel 键不解析）。

**成功响应（200 裸体，`data` = `CreateResult`，snake）**：

```json
{
  "command_id": null,
  "command_type": "timelapse.playback-start",
  "data": {
    "session_id": "6f1e2b4a-9c3d-4e5f-8a7b-2c1d3e4f5a6b",
    "recording_id": "rec-9f21ab34",
    "sn": "BCZJ00000101KE00002682F000000000050T",
    "status": "CREATED",
    "start_offset_ms": 0,
    "provider": "vertc",
    "app_id": "vertc-app-xxxx",
    "viewer_token": "eyJhbGciOi...（viewer 接流令牌）",
    "viewer_identity": "viewer-6f1e2b4a",
    "expires_at_epoch_seconds": 1757571600
  }
}
```

**注意：`data` 绝无 `publish_token`**——设备推流凭证仅存在于下行命令载荷（零日志/零前端/零落库）。admin lane 同构：`POST /api/admin/iot/devices/{sn}/commands`，未激活设备 owner 由 `admin:<adminId>` 代行。

**关键错误分支**：42007（503 离线）/ 42003（409 FPV 占用·含已开时长；409 录像已删除）/ 42009（403 非 owner）/ 40400（404 recording_id 不存在或属于别的 SN）。样例见 §2.5。

**设备侧五体（联调对照；payload 内层 camel·媒体豁免域）**：

```json
// ① cmd/req（服务端铸厚载荷；publishToken 仅此处出现）
{ "command_id": "a1b2c3d4-...", "mode": "INSTANT", "command_type": "timelapse.playback-start",
  "payload": { "sessionId": "6f1e2b4a-...", "startOffsetMs": 0,
    "room": "timelapse-6f1e2b4a-...", "provider": "vertc", "appId": "vertc-app-xxxx",
    "publishToken": "<设备推流令牌>", "expiresAtEpochSeconds": 1757571600 } }

// ② cmd/ack
{ "command_id": "a1b2c3d4-...", "status": "COMMAND_ACK_STATUS_ACCEPTED",
  "execution_id": "exec-a1b2c3d4-...", "received_at_ms": "1757568000123" }

// ③ cmd/progress —— 无（INSTANT）

// ④ 领域上行 devices/{sn}/timelapse/stream（推流就绪校准）
{ "sessionId": "6f1e2b4a-...", "streamInstanceId": "<设备生成UUID>", "event": "STREAM_STARTED",
  "actualOffsetMs": 0, "gopSeconds": 2 }

// ⑤ cmd/result
{ "command_id": "a1b2c3d4-...", "success": true,
  "result": { "sessionId": "6f1e2b4a-...", "streamInstanceId": "<UUID>" } }
```

**后续动作**：veRTC `joinRoom(token=viewer_token, roomId="timelapse-"+sessionId, userId=viewer_identity)`——userId **逐字用返回值**（token 绑定身份）；轮询 `GET /api/v1/timelapse/playback-sessions/{sessionId}` 到 `status=STREAMING` 并取 `actual_offset_ms` 校准播放轴；会话 TTL 600s，剩 60s 时 `POST .../heartbeat` 续期（绝对寿命 4h）。

### 3.2 `timelapse.playback-seek` —— 拖动进度（换推流器重推，拉流不断）

**业务含义**：SEEK 事务——云端旧流实例置 SUPERSEDED、新实例建档、现场再铸设备凭证下发，设备换推流器从新点位重推。

**前置**：会话非终态（终态 → 42003/409）；未触发限流（>30 次/60s → 429/42900）。

**请求 JSON（C 端）**：

```json
POST /api/v1/commands/BCZJ0000...50T
{
  "command_type": "timelapse.playback-seek",
  "payload": { "session_id": "6f1e2b4a-...", "target_offset_ms": 132000 }
}
```

`target_offset_ms` 可省（默认回读会话当前 `actual_offset_ms`）。客户端先行把目标按 GOP **2s** 量化再换算毫秒。

**成功响应（`data` = `SeekResult`，snake）**：

```json
{
  "command_id": null,
  "command_type": "timelapse.playback-seek",
  "data": { "session_id": "6f1e2b4a-...", "stream_instance_id": "e7d8f9a0-...", "status": "STREAMING" }
}
```

**设备侧五体**（转译为设备命令 `timelapse.seek`；payload camel·媒体豁免域）：

```json
// ① cmd/req（凭证现场再铸，随载荷下发）
{ "command_id": "b2c3d4e5-...", "mode": "INSTANT", "command_type": "timelapse.seek",
  "payload": { "sessionId": "6f1e2b4a-...", "room": "timelapse-6f1e2b4a-...",
    "startOffsetMs": 132000, "streamInstanceId": "e7d8f9a0-...",
    "appId": "vertc-app-xxxx", "publishToken": "<现铸设备令牌>" } }

// ② cmd/ack
{ "command_id": "b2c3d4e5-...", "status": "COMMAND_ACK_STATUS_ACCEPTED",
  "execution_id": "exec-b2c3d4e5-...", "received_at_ms": "1757568000123" }

// ③ 领域上行 STREAM_SEEKED（streamInstanceId 必须回显①里的载荷值）
{ "sessionId": "6f1e2b4a-...", "streamInstanceId": "e7d8f9a0-...", "event": "STREAM_SEEKED",
  "actualOffsetMs": 132000, "gopSeconds": 2 }

// ④ cmd/result
{ "command_id": "b2c3d4e5-...", "success": true,
  "result": { "sessionId": "6f1e2b4a-...", "streamInstanceId": "e7d8f9a0-...", "repushed": true } }
```

**后续动作**：本地 playhead 立即跳 + Loading（旧流持续渲染），≤5s 首帧宽限退出；迟到旧实例事件由云端 SUPERSEDED 幂等吸收，APP 无需处理。

### 3.3 `timelapse.playback-stop` —— 主动停止（幂等终态闭环）

**业务含义**：一次性执行三步——①删 viewer（provider 销毁拉流凭证）②命令设备停推 ③销毁房间。幂等由状态机守门（重复 stop 返回既有 `STOPPED`）。

**请求 JSON（C 端）**：

```json
POST /api/v1/commands/BCZJ0000...50T
{ "command_type": "timelapse.playback-stop", "payload": { "session_id": "6f1e2b4a-..." } }
```

**成功响应（`data` = `StopResult`，snake）**：

```json
{ "command_id": null, "command_type": "timelapse.playback-stop",
  "data": { "session_id": "6f1e2b4a-...", "status": "STOPPED" } }
```

**设备侧五体**（设备命令同名 `timelapse.playback-stop`；payload camel·媒体豁免域）：

```json
// ① cmd/req
{ "command_id": "c3d4e5f6-...", "mode": "INSTANT", "command_type": "timelapse.playback-stop",
  "payload": { "sessionId": "6f1e2b4a-...", "room": "timelapse-6f1e2b4a-..." } }

// ② cmd/ack
{ "command_id": "c3d4e5f6-...", "status": "COMMAND_ACK_STATUS_ACCEPTED",
  "execution_id": "exec-c3d4e5f6-...", "received_at_ms": "1757568000123" }

// ③ 领域上行 STREAM_STOPPED（幂等：无推流器也回）
{ "sessionId": "6f1e2b4a-...", "streamInstanceId": "<UUID>", "event": "STREAM_STOPPED" }

// ④ cmd/result
{ "command_id": "c3d4e5f6-...", "success": true,
  "result": { "sessionId": "6f1e2b4a-...", "stopped": true } }
```

**后续动作**：销毁本地播放器与轮询；页面离开/关闭 FPV 时也应触发 stop。

#### 3.3.1 `live.publisher.renew` —— veRTC 凭证 15min 滚动续期（域无关媒体面）

**背景契约**：veRTC AccessToken TTL=15min（Apollo `iot.media.volcengine.token-ttl-seconds=900`）；token 到期即被移出房间。延时摄影回放与 FPV live 共用同一 veRTC 通道 → 续期协议**不分域**，按 room 前缀分流（`timelapse-*`=按偏移续推；其他=live 推流器重启）。

**两条通道**：

| 通道 | 载体 | 语义 | 台账 |
|---|---|---|---|
| publisher（设备） | MQTT 命令 `live.publisher.renew` | 设备换推流器续凭证（seek 同语义，viewer 拉流不断） | `live_publisher_renew_issued`（SLS）+ 命令台账 + cmd/result |
| viewer（APP） | HTTP 回包（heartbeat 响应新增字段） | App 在房 `engine.updateToken()` 无感换发 | `live_viewer_renew_issued`（SLS）|

**触发锚点（云端自动，APP 不感知 publisher 侧）**：回放=每次 `POST /timelapse/playback-sessions/{id}/heartbeat`（STREAMING 时）现铸双端凭证；live=设备 5s 心跳半衰窗。

**设备命令五体（设备侧，APP 仅供理解；payload camel·媒体豁免域）**：

```json
// ① cmd/req（cloud → device; publishToken 仅此出现, 零日志零落盘）
{ "command_id": "e5f6a7b8-...", "mode": "INSTANT", "command_type": "live.publisher.renew",
  "payload": { "sessionId": "6f1e2b4a-...", "room": "timelapse-6f1e2b4a-...",
    "appId": "<veRTC AppId>", "publishToken": "<新铸 device token>",
    "expiresAtEpochSeconds": 1789065000, "resumeOffsetMs": 4200 } }

// ② cmd/ack
{ "command_id": "e5f6a7b8-...", "status": "COMMAND_ACK_STATUS_ACCEPTED",
  "execution_id": "exec-e5f6a7b8-...", "received_at_ms": "1757568000123" }

// ③ cmd/result（幂等: 无上下文 renewed=false + reason, 不伪造成功）
{ "command_id": "e5f6a7b8-...", "success": true,
  "result": { "sessionId": "6f1e2b4a-...", "room": "timelapse-6f1e2b4a-...",
    "renewed": true, "resumeOffsetMs": 4200 } }
```

**APP 侧动作（viewer 续期，三行代码）**：heartbeat 响应字段 `viewer_token` / `viewer_token_expires_at_epoch_seconds`（现铸、同 identity 绑定）——在房时调 `engine.updateToken(res.viewer_token)`；不在房忽略。**注意**：§3.2"剩 60s 续期"语义=每次续期同时完成 veRTC 双端凭证滚动换发，App 心跳调用方式不变。


### 3.4 `timelapse.relay-create` —— 高光/原片中转取件（clip / master / 幂等重放）

**业务含义**：建按需中转任务——云端铸 uploadToken/objectKey，命令设备裁剪（clip 按高光窗口）或读原片（master）后预签 PUT 上对象存储；APP 稍后经 content 拉预签 URL。零持久化：对象即用即删。

**前置**：`asset_version` 对位（不符 → 42024/409）；同 (sn,asset_type) 并发上限（非幂等重放场景 → 42023/409）；clip 必传 `highlight_id`。

**请求 JSON（C 端）**：

```json
POST /api/v1/commands/BCZJ0000...50T
{
  "command_type": "timelapse.relay-create",
  "payload": { "recording_id": "rec-9f21ab34", "asset_type": "clip",
               "asset_version": 1, "highlight_id": "H3" }
}
```

master 不传 `highlight_id`。幂等语义：同 recording+asset_type+version 的重复创建**返回既有活动任务**（不报错）；终态后重看=新建任务。

**成功响应（`data` = `CreateResult`，snake）**：

```json
{
  "command_id": null,
  "command_type": "timelapse.relay-create",
  "data": { "task_id": "d4e5f6a7-...", "recording_id": "rec-9f21ab34",
            "sn": "BCZJ0000...50T", "asset_type": "clip", "asset_version": 1, "status": "CREATED" }
}
```

**设备侧五体**（转译为设备命令 `timelapse.file-upload`；uploadToken/objectKey 仅此处出现；payload camel·媒体豁免域）：

```json
// ① cmd/req
{ "command_id": "d4e5f6a7-...", "mode": "INSTANT", "command_type": "timelapse.file-upload",
  "payload": { "taskId": "d4e5f6a7-...", "uploadToken": "Ab3d...-_（128bit URL-safe B64）",
    "objectKey": "timelapse/relay/d4e5f6a7-....mp4", "assetType": "clip", "highlightId": "H3" } }
// （compose 产物中转时 payload 换成 {taskId, uploadToken, objectKey, assetType:"compose-product", composeId}）

// ② cmd/ack
{ "command_id": "d4e5f6a7-...", "status": "COMMAND_ACK_STATUS_ACCEPTED",
  "execution_id": "exec-d4e5f6a7-...", "received_at_ms": "1757568000123" }

// ③ 领域进度 devices/{sn}/timelapse/relay（两帧：半程+完成；首帧回显 uploadToken）
{ "taskId": "d4e5f6a7-...", "event": "RELAY_UPLOAD_PROGRESS", "uploadedBytes": 1024,
  "totalBytes": 2048, "uploadToken": "Ab3d...-_" }
{ "taskId": "d4e5f6a7-...", "event": "RELAY_UPLOAD_PROGRESS", "uploadedBytes": 2048, "totalBytes": 2048 }

// ④ cmd/result
{ "command_id": "d4e5f6a7-...", "success": true,
  "result": { "taskId": "d4e5f6a7-...", "uploadedBytes": 2048, "videoBytes": true } }
// 失败样例（预签 PUT 失败）：{ "command_id":"...", "success": false,
//   "result": { "taskId": "...", "step": "presign-put", "httpStatus": 403 } }
```

**后续动作**：轮询 `GET /api/v1/timelapse/relay-tasks/{task_id}`（`{task_id,status,uploaded_bytes,total_bytes}`）到 `READY_FOR_PULL` → `GET .../content`（一次性拉取语义：pull 即 COMPLETED）→ 窗内下载。中途放弃 → `POST .../cancel`。

### 3.5 `timelapse.compose-create` —— 高光合成分（设备渲染 + 产物上传）

**业务含义**：选高光集合下发设备本地 concat 渲染；产物经中转通道（`compose-product`）上 OSS；APP 轮询五阶段。产物契约=所选窗口时长之和（1:1 原速）。

**请求 JSON（C 端）**：

```json
POST /api/v1/commands/BCZJ0000...50T
{
  "command_type": "timelapse.compose-create",
  "payload": { "recording_id": "rec-9f21ab34", "highlight_ids": ["H3", "H5"], "template": "standard" }
}
```

`template` 可省（默认 `"standard"`）。

**成功响应（`data` = `ComposeResult`，snake）**：

```json
{
  "command_id": null,
  "command_type": "timelapse.compose-create",
  "data": { "compose_id": "e5f6a7b8-...", "recording_id": "rec-9f21ab34",
            "sn": "BCZJ0000...50T", "status": "DISPATCHED" }
}
```

设备离线时命令面抛 42007/503，且 compose 任务以 `FAILED(dispatch_failed)` 留痕（可查）。

**设备侧五体**（转译为设备命令 `timelapse.compose`；payload camel·媒体豁免域）：

```json
// ① cmd/req
{ "command_id": "e5f6a7b8-...", "mode": "INSTANT", "command_type": "timelapse.compose",
  "payload": { "composeId": "e5f6a7b8-...", "recordingId": "rec-9f21ab34",
    "highlightIds": ["H3", "H5"], "template": "standard" } }

// ② cmd/ack
{ "command_id": "e5f6a7b8-...", "status": "COMMAND_ACK_STATUS_ACCEPTED",
  "execution_id": "exec-e5f6a7b8-...", "received_at_ms": "1757568000123" }

// ③ 领域进度 devices/{sn}/timelapse/compose（两帧）
{ "composeId": "e5f6a7b8-...", "event": "COMPOSE_PROGRESS", "percent": 30, "phase": "LOCAL_MATERIAL" }
{ "composeId": "e5f6a7b8-...", "event": "COMPOSE_PROGRESS", "percent": 70, "phase": "CONCAT" }

// ④ 领域结果帧
{ "composeId": "e5f6a7b8-...", "event": "COMPOSE_RESULT", "result": "ok", "productBytes": 2048 }

// ⑤ cmd/result（此后云端自动建产物中转任务并下发 file-upload[compose-product]）
{ "command_id": "e5f6a7b8-...", "success": true,
  "result": { "composeId": "e5f6a7b8-...", "productBytes": 2048, "videoBytes": true } }
```

**后续动作**：轮询 `GET /api/v1/timelapse/composes/{compose_id}`（`{compose_id,status,percent?,phase?,failure_reason?,product_relay_task_id?}`）：`DISPATCHED → RENDERING(percent/phase) → SUCCEEDED`；SUCCEEDED 后按 `product_relay_task_id` 复用 §3.4 的 GET 轮询 + content 取产物。

### 3.6 `binding.request` —— 请求设备出码（绑定码 v2.1，REST 包装触发）

**业务含义**：FPV 面板"请求设备出码"——云端下发 INSTANT `binding.request` 命令，设备本地出 4 位 ticket + 一机一密签票并 sync 回云端；APP 轮询 latest 拿码渲染二维码。**两层权模型**：已确权（已激活/已绑定）设备照常出码——扫码/输码兑换获得的是**控制权**（多人可共控），物权不受影响。机主过户走 `POST /api/v1/devices/{sn}/transfer`（输入对方已验证邮箱直接转出，控制权全清需重新扫码），详见 15.07。
**HTTP 面（C 端，信封包装域）**：

```json
POST /api/v1/devices/BCZJ0000...50T/bind-codes/request
Authorization: Bearer <用户JWT>
// 无请求体

// 200 成功（信封）
{ "code": 0, "success": true, "msg": "ok", "timestamp": 1757568000123,
  "request_id": "8f3c...", "data": { "sn": "BCZJ0000...50T", "requested": true } }

// 失败：42007/503 离线、42005/422 不可行、42009/403 非 owner（异常类型化透传，不折叠成 requested:false）
```

admin 面同构：`POST /api/admin/iot/devices/{sn}/bind-codes/request`（412/42028 在场前置）。

**设备侧五体**：

```json
// ① cmd/req（payload snake）
{ "command_id": "f6a7b8c9-...", "mode": "INSTANT", "command_type": "binding.request",
  "payload": { "type": "bind-code-request", "sn": "BCZJ0000...50T",
    "requested_by": "user:<internalUserId>", "at": "2026-09-11T08:00:00Z" } }

// ② cmd/ack
{ "command_id": "f6a7b8c9-...", "status": "COMMAND_ACK_STATUS_ACCEPTED",
  "execution_id": "exec-f6a7b8c9-...", "received_at_ms": "1757568000123" }

// ③ cmd/result（设备如实回报出码+sync 结果；ticket=4 位数字）
{ "command_id": "f6a7b8c9-...", "success": true, "result": { "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": { "device_sn": "BCZJ0000...50T", "ticket": "4831", "version": 21,
            "sign": "<一机一密签名>", "status": "ACTIVE", "expires_at": "2026-09-11T08:05:00Z" } }
```

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

---

## 4. 裸指令目录（FPV 指令台 16 条 `COMMAND_SERVICE_*`）

未命中转译注册表的 command_type 直接投递设备（admin-fe 远程加工仓已全量集成）。APP 端集成时按目录自构 payload；命令面响应为裸 `CommandResponse`（`status:"queued"`），进度经 WSS `command.status` 帧。

### 4.1 分组总表（admin-fe catalog 全量）

| 分组 | command_type（service 名） | 模式 | 字段（key: kind[必填]） |
| --- | --- | --- | --- |
| 急停与机器 | `COMMAND_SERVICE_CNC_ESTOP_CTRL` | INSTANT | `action`: enum[必] {ESTOP_ACTION_ENGAGE, ESTOP_ACTION_RESET} |
| | `COMMAND_SERVICE_CNC_MACHINE_CTRL` | INSTANT | `action`: enum[必] {MACHINE_ACTION_ENABLE/DISABLE/RESET/RESTART} |
| | `COMMAND_SERVICE_CNC_SET_MANUAL_MODE` | INSTANT | 无 |
| CNC 加工 | `COMMAND_SERVICE_SELECT_FILE` | INSTANT | `programPath`: text[必]（如 `/programs/demo.ngc`） |
| | `COMMAND_SERVICE_CNC_CTRL` | **PERSISTENT** | `action`: enum[必] {CNC_ACTION_START/FEED_HOLD/RESUME/STOP}；`programPath`?: text（START 可指定）；`startLine`?: number |
| | `COMMAND_SERVICE_CNC_MDI` | INSTANT | `commands`: stringList[必]（每行一条，如 `G0 X10`） |
| 手动与回零 | `COMMAND_SERVICE_CNC_JOG_CTRL` | **PERSISTENT** | `action`: enum[必] {JOG_ACTION_CONTINUOUS/INCREMENTAL/STOP}；`axis`?: enum {AXIS_X..AXIS_C}；`direction`?: enum {JOG_DIRECTION_POSITIVE/NEGATIVE}；`speed`?: number（mm/min 或 deg/min）；`distance`?: number（仅增量） |
| | `COMMAND_SERVICE_CNC_HOME` | **PERSISTENT** | `target`: enum[必] {HOME_TARGET_ALL/X/Y/Z/B/C} |
| 倍率 | `COMMAND_SERVICE_CNC_OVERRIDE_SET` | INSTANT | `target`: enum[必] {OVERRIDE_TARGET_FEED/SPINDLE}；`value`: number[必] 0..2（1.0=100%） |
| 自动任务 | `COMMAND_SERVICE_CNC_AUTO_TASK_START` | **PERSISTENT** | `task`: enum[必] {AUTO_TASK_TYPE_CENTER_FINDING/TOOL_SETTING}；`timeoutS`?: number（0=默认） |
| | `COMMAND_SERVICE_SELF_TEST_START` | **PERSISTENT** | 无 |
| 配置与标定 | `COMMAND_SERVICE_CNC_INI_CTRL` | INSTANT | `action`: enum[必] {INI_ACTION_READ/WRITE}；`key`: text[必]（`section.key`）；`value`?: text（WRITE 必填） |
| | `COMMAND_SERVICE_CNC_CALIBRATION_CTRL` | INSTANT | `action`: enum[必] {CALIBRATION_ACTION_READ/WRITE}；`scope`: enum[必] {CALIBRATION_SCOPE_ITEM/ALL}；`section`/`key`?: text（ITEM 必填）；`value`?: text（WRITE+ITEM 必填） |
| 外设 | `COMMAND_SERVICE_LIGHT_CTRL` | INSTANT | `target`: enum[必] {LIGHT_TARGET_AMBIENT/WORK}；`enabled`: switch[必] |
| | `COMMAND_SERVICE_MQL_CTRL` | INSTANT | `on`: switch[必] |
| | `COMMAND_SERVICE_SAFETY_POLICY_SET` | INSTANT | `target`: enum[必] {SAFETY_POLICY_TARGET_DOOR_OPEN/MQL_LOW_WATER}；`pauseEnabled`: switch[必] |
| 状态 | `COMMAND_SERVICE_STATUS` | INSTANT | 无（一次性拉取 status/state/hms 快照） |

**payload 内层键形态说明**：裸指令 payload 内层键为 **camel 过渡形态**（`programPath`/`startLine`/`timeoutS`/`pauseEnabled`——admin-fe catalog 与设备侧解析一致的现行 wire 形态；设备参考实现同时容忍 `program_path` 等 snake 别名，为遗留容错未收口域）。cmd/req **信封**四键（command_id/mode/command_type/payload）已 snake 终态，与内层形态正交。

另有同通道收编的设备命令（APP 一般经领域 REST 间接触发，不在指令台目录）：`live.stop`（载荷 `{sessionId, reason:"viewer_closed"}`·媒体豁免域，设备停推后上行 `live/stopped`）。

### 4.2 请求/响应与进度时间线（统一五体）

**请求 JSON（admin，C 端同构换端点/鉴权；snake）**：

```json
POST /api/admin/iot/devices/BCZJ0000...50T/commands
Authorization: Bearer <admin-be会话token>
{
  "command_type": "COMMAND_SERVICE_CNC_HOME",
  "mode": "PERSISTENT",
  "payload": { "target": "HOME_TARGET_ALL" },
  "timeout_seconds": 60
}
```

**成功响应（200 裸 `CommandResponse`，snake，14 键；`status:"queued"`；末三键=progress 快照列，首帧 progress 到达前为 null）**：

```json
{
  "command_id": "1a2b3c4d-5e6f-...",
  "sn": "BCZJ00000101KE00002682F000000000050T",
  "mode": "PERSISTENT",
  "command_type": "COMMAND_SERVICE_CNC_HOME",
  "status": "queued",
  "idempotency_key": null,
  "error_message": null,
  "queued_at": "2026-09-11T08:00:00Z",
  "delivered_at": null,
  "completed_at": null,
  "timeout_at": "2026-09-11T08:01:00Z",
  "last_phase": null,
  "last_permille": null,
  "last_stage": null
}
```

断线重连恢复：`GET /api/v1/commands/{command_id}` 详情即携带 `last_phase/last_permille/last_stage`——轻量 REST 对齐断档（无需帧重放）。

**设备侧五体（以 HOME 为例；设备 cmd/req 信封与 §3 统一）**：

```json
// ① cmd/req
{ "command_id": "1a2b3c4d-...", "mode": "PERSISTENT", "command_type": "COMMAND_SERVICE_CNC_HOME",
  "payload": { "target": "HOME_TARGET_ALL" } }

// ② cmd/ack（永远立即）
{ "command_id": "1a2b3c4d-...", "status": "COMMAND_ACK_STATUS_ACCEPTED",
  "execution_id": "exec-1a2b3c4d-...", "received_at_ms": "1757568000123" }

// ③ cmd/progress×N（PERSISTENT 才有；permille/phase/stage 取自 SCENARIOS 表）
{ "command_id": "1a2b3c4d-...", "phase": "COMMAND_PROGRESS_PHASE_RUNNING",
  "progress_permille": 500, "stage": "HOMING",
  "execution_id": "exec-1a2b3c4d-...", "timestamp_ms": "1757568000123" }

// ④ cmd/result
{ "command_id": "1a2b3c4d-...", "success": true, "result": "simulated-ok" }
```

**PERSISTENT 进度时间线出厂值**（设备 SCENARIOS 表，permille 为 0..1000）：

| command_type | duration(s) | (permille, stage) 序列 |
| --- | --- | --- |
| `COMMAND_SERVICE_CNC_ESTOP_CTRL` | 0.3 | (100, ESTOP_RECEIVED) (1000, ESTOP_APPLIED) |
| `COMMAND_SERVICE_CNC_MACHINE_CTRL` / `SET_MANUAL_MODE` | 0.5 | —（默认 (500, EXECUTING)） |
| `COMMAND_SERVICE_SELECT_FILE` | 0.8 | (500, LOADING_FILE) |
| `COMMAND_SERVICE_CNC_CTRL` / `CNC_JOG_CTRL` | 0.5 / 0.4 | — |
| `COMMAND_SERVICE_CNC_AUTO_TASK_START` | 1.0 | (200, TASK_STARTED) (1000, TASK_RUNNING) |
| `COMMAND_SERVICE_CNC_CALIBRATION_CTRL` | 2.0 | (500, CALIBRATING) |
| `LIGHT_CTRL` / `MQL_CTRL` | 0.2 | — |
| `SAFETY_POLICY_SET` | 0.6 | — |
| `STATUS` | 0.2 | — |
| `CNC_MDI` | 8.0 | (200, MDI_EXECUTING) (800, MDI_EXECUTING) |
| `CNC_HOME` | 1.0 | (500, HOMING) |
| `OVERRIDE_SET` | 2.0 | — |
| `SELF_TEST_START` | 20.0 | (200, SELF_TEST_ELECTRICAL) (500, SELF_TEST_DRIVES) (800, SELF_TEST_SPINDLE) |
| `CNC_INI_CTRL` | 5.0 | (300, INI_WRITING) (900, INI_VERIFYING) |
| `REC_UPLOAD`（云录制，内部） | 6.0 | (300, CLIP_GENERATING) (800, UPLOADING) |

命令历史/详情（admin）：`GET /api/admin/iot/devices/{sn}/commands?page&pageSize`（分页）、`GET /api/admin/iot/devices/commands/{command_id}`（详情）、`.../commands/{command_id}/events`（ack/progress/result 事件轨迹）、`.../commands/{command_id}/interactions`（全生命周期交互台账）。C 端：`GET /api/v1/commands/{command_id}`（详情）。

### 4.3 命令效果与状态联动（驾驶舱闭环）

每条指令同时驱动真实机器模型，状态/告警随之收敛（APP 下发后可在 WSS 推送/快照里观察到）：ESTOP → `controller_status.estop=ESTOP_STATE_ACTIVE`、主轴/进给归零、HMS 出现 `ESTOP_ACTIVE`（CRITICAL）；HOME → 轴坐标归零、`motion_state.axes_homed` 全 TRUE；JOG/MDI → 轴坐标向目标移动；AUTO 任务 → `progress_pct/program_line` 推进；SELF-TEST → 期间 HMS 出现 `SELF_TEST_RUNNING`（INFO）；灯光/MQL → `peripheral_status` 对应位翻转；安全策略 → `config_status.door_pause_enabled/mql_pause_on_low` 翻转。详见 §8。

---

## 5. live 实时画面面（FPV）

live 的**控制面主体不是命令端点**（APP 侧不直接下发 live 设备指令；云端在会话建立/关闭时自动下发 `live.start`/`live.stop` 给设备）：

| 动作 | C 端（user-fe 同款） | admin（验证台同款） | 响应 |
| --- | --- | --- | --- |
| 能力查询 | `GET /api/v1/live/provider` | `GET /api/admin/iot/live/provider` | 裸 `{provider:"vertc"}`（空=禁用） |
| 打开/取流凭证 | `POST /api/v1/live/{sn}` | `GET /api/admin/iot/live/{sn}/credentials?viewer_id=<稳定观看会话ID>`（无活跃会话则触发创建） | `LiveSessionResponse`/凭证 Map（含 viewer_token 等） |
| 活跃会话探测 | — | `GET /api/admin/iot/live/{sn}/active` | 裸 `{active:true/false, session_id?, status?, provider?, opened_at?}` |
| 渲染心跳（30s） | `POST /api/v1/live/{sn}/viewers/{viewerId}/heartbeat` | 同构 admin 面 | 裸 `{ok:true}` |
| 主动离开 | `POST /api/v1/live/{sn}/viewers/{viewerId}/unsubscribe` | 同构 | 裸 `{ok:true}` |
| 关闭 | `DELETE /api/v1/live/{sessionId}` | （viewer 全退后房间自动评估关闭；云端对设备下发 `live.stop`） | `LiveSessionResponse` |

**unsubscribe 离场归因契约（leave_reason）**：可选 JSON body `{"reason":"user_closed"|"background_playback_timeout"}`——缺 body/缺字段/空白 → `user_closed`；白名单外（含服务端专属值 `heartbeat_timeout`/`server_closed`/`viewer_ttl_expired`、拼写错误、非字符串）→ **400**（fail-fast，不静默回退）。完整分类法（五值，SLS `live_viewer_left.leave_reason` 唯一完整归因流）：`user_closed`（用户主动关）/ `background_playback_timeout`（UI 后台播放≥2min 自动退订，FE 携带）/ `heartbeat_timeout`（服务端 stale 清扫）/ `server_closed`（房间销毁三通道滞留归因）/ `viewer_ttl_expired`（历史审计事件展示映射，新代码不再写）。

注意互斥：活跃 FPV 与延时摄影回放互斥（先开者占用，冲突 42003 + msg 含已开时长）。

---

## 6. 保留 REST 面清单（为什么不是命令通道）

命令面统一只收编**五类设备写操作**；以下保留 REST，原因标注：

| 端点（C 端基址 `/api/v1`） | 动作 | 保留原因（非命令通道） |
| --- | --- | --- |
| `POST /timelapse/playback-sessions/{id}/heartbeat` | TTL 续期 + **veRTC 双端凭证滚动续期**（回包携带现铸 `viewer_token`/`viewer_token_expires_at_epoch_seconds` → App `engine.updateToken()`；STREAMING 时云端同时下发 `live.publisher.renew` 设备命令；台账 `live_viewer_renew_issued`/`live_publisher_renew_issued`） | 云端会话状态 + 凭证铸造；publisher 侧由云端代理下发，**APP 只消费 viewer_token 字段** |
| `GET /timelapse/playback-sessions/{id}` | 会话状态回读（actual_offset_ms 校准） | 只读投影轮询 |
| `GET /timelapse/relay-tasks/{id}` | 中转进度轮询（uploaded_bytes/total_bytes） | 只读投影 |
| `GET /timelapse/relay-tasks/{id}/content` | 预签 GET 拉取 `{task_id,status,url,expires_in_seconds}` | 云端对象操作（pull 即 COMPLETED） |
| `POST /timelapse/relay-tasks/{id}/cancel` | 中断中转 `{task_id,status:"CANCELLED"}` | 云端任务状态操作 + 对象删除 |
| `GET /timelapse/composes/{id}` | 合成进度轮询 | 只读投影 |
| `DELETE /timelapse/recordings/{recording_id}` | 删除录像（幂等） | 云端投影元数据操作 |
| `GET /machining-jobs/{sn}`、`GET /machining-jobs/{sn}/{job_id}` | 加工账本/详情（timeline/highlights 唯一来源） | 只读账本 |
| `GET /shadow/{sn}`、`GET /devices/{sn}/events`、`GET /hms/{sn}/current|summary` | 快照/事件/HMS 查询 | 只读 |
| bind-codes latest/绑定/解绑域 | 设备绑定生命周期 | REST 包装（request 内部已走命令管线，见 §3.6） |

admin 验证台只读投影同构（信封包装）：`GET /api/admin/iot/timelapse/verify/playback-sessions/{id}`、`/relay-tasks/{id}`、`/relay-tasks/{id}/content`、`/composes/{id}`。

---

## 7. WSS 客户端协议

### 7.1 连接与鉴权

```text
C 端:  wss://iot-be.<env>/ws/v1/client   子协议: ["iot-json", "bearer.<用户JWT>"]
admin: wss://iot-be.<env>/ws/v1/admin    子协议: ["bearer.<admin会话token>"]
```

- token **只**走 `Sec-WebSocket-Protocol` 子协议头（query/Authorization 均不被读取——防进网关日志/浏览器历史）。
- 路径身份矩阵：`/ws/v1/admin` 只认 admin 会话 token（用户 token → 401 `user_on_admin_path`）；`/ws/v1/client` 认用户 JWT（admin token 过渡期容忍，`iot.ws.client-path-strict=false` 时）。
- 浏览器页面天然满足 Origin；非浏览器客户端不带 Origin 或须命中 CORS 白名单。
- 握手限流 60 次/60s/IP（401 `rate_limited`）；成功标志 101。

### 7.2 客户端 → 服务端帧（v=1）

```json
// 订阅（C 端须为 owner/控制权；admin 注册在场）
{ "v": 1, "type": "subscribe", "sn": "BCZJ0000...50T",
  "watch_command_ids": ["1a2b3c4d-...", "9f8e7d6c-..."] }
// → {"type":"ack","result":"subscribed","sn":"..."}

// 退订
{ "v": 1, "type": "unsubscribe", "sn": "BCZJ0000...50T" }
// → {"type":"ack","result":"unsubscribed","sn":"..."}

// 保活（纯连接心跳；不刷新任何观看台账）
{ "v": 1, "type": "ping" }
// → {"type":"ack","result":"pong","sn":""}
```

**`watch_command_ids`（连接级观测声明，替换语义）**：可选数组键，声明本连接要观测的 command_id 集合——`command.status` 与 `command.lifecycle` 两类帧**只投递给 watch 了该 command_id 的连接**（不 watch 不收；无 command_id 不可路由时服务端丢弃并 WARN，绝不退化为 SN 扇出）。约束：每连接上限默认 100（Apollo `iot.ws.max-watch-command-ids`，超限 error 帧 `WATCH_INVALID`）；声明被拒时既有 SN 订阅与旧 watch 集不变；终态帧到达后 FE 应剪枝 watched 集并重声明（避免占满上限）。

错误回帧：`{"type":"error","code":"...","message":"..."}`——稳定字符串码：`AUTH_REQUIRED`（未认证即关连）、`BAD_FRAME`（非 JSON）、`VERSION_UNSUPPORTED`（v≠1，软失败不关连）、`MISSING_SN`、`MISSING_TYPE`、`UNSUPPORTED_TYPE`、`WATCH_INVALID`、`FORBIDDEN`（admin 只许 subscribe/unsubscribe/ping；C 端非 owner SN 帧被拒，code 取物权异常稳定码 `DEVICE_NOT_ACTIVATED`/`DEVICE_ACCESS_DENIED`）。C 端其他 SN 帧类型（`command`、`shadow.desired`）同样先过物权校验。

### 7.3 服务端 → 客户端推送帧全集

统一帧形：`{"v":1,"type":"...","sn":"...","data":{...}}`。**watch 路由面**（command_id 定向）：`command.status`、`command.lifecycle`；**SN 扇出面**（订阅该 SN 即收）：其余全部。

```json
// ① 命令生命周期（每 ack/progress/result/timeout 一条；watch 路由）
{ "v": 1, "type": "command.status", "sn": "BCZJ0000...50T",
  "data": { "command_id": "1a2b3c4d-...", "status": "succeeded", "event_type": "result",
            "payload": { "command_id": "1a2b3c4d-...", "success": true, "result": "simulated-ok" } } }

// ①' 命令闭环聚合帧（终态时一条：request + timeline 全边 + progress 快照；watch 路由）
{ "v": 1, "type": "command.lifecycle", "sn": "BCZJ0000...50T",
  "data": { "command_id": "1a2b3c4d-...", "command_type": "COMMAND_SERVICE_CNC_HOME",
            "mode": "PERSISTENT", "execution_id": "exec-1a2b3c4d-...", "status": "succeeded",
            "boot_id": "boot-...",
            "request": { "payload": { "target": "HOME_TARGET_ALL" }, "timeout_seconds": 60 },
            "timeline": [ { "edge": "request", "direction": "OUT", "timestamp": "1757568000123" },
                          { "edge": "downlink", "direction": "OUT", "timestamp": "1757568000031" },
                          { "edge": "awaiting_response", "direction": "OUT", "timestamp": "1757568000032" },
                          { "edge": "ack", "direction": "IN", "timestamp": "1757568000180" },
                          { "edge": "progress", "direction": "IN", "timestamp": "1757568000520" },
                          { "edge": "result", "direction": "IN", "timestamp": "1757568001210" } ],
            "progress_snapshot": { "last_phase": "COMMAND_PROGRESS_PHASE_RUNNING",
                                   "last_permille": 500, "last_stage": "HOMING" } } }

// ② 消息审计流（全通道 DOWN/UP 帧；SN 扇出；cmd/* 帧带结构化增强字段）
{ "v": 1, "type": "message.log", "sn": "BCZJ0000...50T",
  "data": { "event_type": "mqtt.cmd.progress", "direction": "UP",
            "topic": "devices/BCZJ0000...50T/cmd/progress",
            "payload_json": "{\"command_id\":\"1a2b...\",\"phase\":\"COMMAND_PROGRESS_PHASE_RUNNING\",...}",
            "occurred_at_ms": 1757568000520,
            "command_id": "1a2b3c4d-...", "command_type": "COMMAND_SERVICE_CNC_HOME",
            "execution_id": "exec-1a2b3c4d-...", "payload": { "phase": "COMMAND_PROGRESS_PHASE_RUNNING", "progress_permille": 500, "stage": "HOMING" },
            "sequence": 7, "boot_id": "boot-...", "timestamp": "1757568000520" } }
// （非 cmd 通道的 message.log 保持五字段基础形态：event_type/direction/topic/payload_json/occurred_at_ms）

// ③ 高频遥测（business_status 广播；1Hz；内层键 snake）
{ "v": 1, "type": "device.status", "sn": "BCZJ0000...50T",
  "data": { "seq": "1024", "timestamp_ms": "1757568000123",
    "status": { "motion_realtime": { "command_position": { "x": 10.5, "y": 0, "z": 2, "b": 0, "c": 0 }, "...": "..." },
      "machining_realtime": { "program_line": 42, "progress_pct": 36.5, "elapsed_s": 1024.0,
        "spindle_speed_rpm": 12000.0, "spindle_temperature_c": 31.0, "feed_rate": 500.0 } } } }

// ③' 连接状态（EMQX webhook 产；与③同 type，按 data 形状区分：含 online 键）
{ "v": 1, "type": "device.status", "sn": "BCZJ0000...50T",
  "data": { "sn": "BCZJ0000...50T", "online": true, "protocol": "mqtt" } }

// ④ 低频状态（business_state 广播；0.1Hz + 变更即推；内层键 snake）
{ "v": 1, "type": "device.state", "sn": "BCZJ0000...50T",
  "data": { "seq": "104", "timestamp_ms": "1757568000123",
    "state": { "summary_status": { "display_state": "DISPLAY_STATE_READY", "since_ts": 1757567000 },
      "controller_status": { "...": "见 §8.3" } } } }

// ⑤ 影子报告（shadow.reported）
{ "v": 1, "type": "shadow.reported", "sn": "BCZJ0000...50T",
  "data": { "reported": { "...": "整帧 status/state/hms 快照" }, "version": 57 } }
```

**message.log cmd 帧增强字段**（仅 topic 为 `cmd/req|ack|progress|result` 的帧携带；其余通道五字段基础形态）：`command_id`（解析自 payload，无则省略）/ `command_type` / `execution_id`（回帧取帧值，cmd/req 派生 `exec-{command_id}`）/ `payload`（结构化对象：cmd/req=指令 payload 原样；ack=execution_id 字符串；progress=`{phase,progress_permille,stage}`；result=`{success,error}`）/ `sequence`（帧内 `sequence→command_seq→seq` 首命中，数字）/ `boot_id`（该 SN 当前 boot 作用域）/ `timestamp`（帧内 `timestamp_ms→received_at_ms→finished_at_ms` 首命中，int64 字符串）。增强失败静默降级为基础五字段（审计行永不丢）。

**command.lifecycle timeline 边全集**（按实际出现顺序）：`request / downlink / awaiting_response / ack / progress×n / result / timeout / response_missing / late_result / duplicate_request / status / publish_failed / parse_error / unknown_command`。可省键一律省略不置 null；`timestamp` 为 int64 字符串。

**FE 实时面板消费口径（参照 admin-fe/user-fe commandCards 模块）**：cmd 类 message.log 帧（结构化 `command_id` 非空）按 command_id 聚合为命令卡片（全生命周期：请求→下发→ACK→进度→终态），`command.lifecycle` 帧为权威时间线（终态章优先，推知终态粘滞不回退），`command.status` 终态作兜底；非命令消息（business_* 等）保留流式行展示 boot_id/sequence/timestamp 列。观测面板四栏位：指令（BY 指令聚合）/ HMS / State / Status 观测流。

---

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

### 8.1 三帧通道与顶层字段

设备经 MQTT 周期上行三帧，云端**全量落库**（device_events，event_type=`business.status|state|hms`）并 WSS 广播（§7.3 ③④）；HMS 另进告警区间聚合（current/summary 查询面）。APP 消费路径：WSS 实时帧 / REST 快照与历史查询。

| 通道 | 频率 | 顶层键 | 载荷键 | WSS type |
| --- | --- | --- | --- | --- |
| `business_status` | 1Hz | `seq`,`timestamp_ms` | `status` | `device.status` |
| `business_state` | 0.1Hz + 变更即推 | `seq`,`timestamp_ms`,`boot_id`?,`uptime_s`? | `state` | `device.state` |
| `business_hms` | 1Hz | `seq`,`timestamp_ms` | `hms` | （落库+HMS 聚合；无独立 WSS type） |

顶层字段语义：

- `seq`：**单调递增生产者序号**（int64 发字符串，number 亦接受）；per-kind（status/state/hms 各自独立），`boot_id` 为重启作用域。服务端消费语义：回退（`business_seq_regressed`）与跳号（`business_seq_gap`）**只告警进 SLS 不拒绝**；重启签名（seq 与 uptime_s 同步归零 → `business_seq_restart_signature`）；boot 切换（`business_boot_changed`）。
- `timestamp_ms`：设备生产时刻 Unix 毫秒（双形态接受；缺省/非法时云端用接收时刻兜底）。
- `boot_id` / `uptime_s`：一次开机周期稳定（boot_id 设备持久化记忆，跨进程重启不变）/ 开机时长纯内存秒计数（进程重启归零）。
- 时间双列：`occurred_at`（机器时间，展示）+ `received_at`（服务端接收时间，**排序/快照/E2E 断言权威列**）。
- **键名只认 snake**：服务端解析 camel 键一律不识别（camel 违约 → 字段按缺失处理/`*_payload_invalid` 拒帧/时间回退 now，可观测不静默）；APP 按 snake 键实现。

### 8.2 BusinessStatus（高频状态位·motion_realtime + machining_realtime）

整帧样例（模拟器真实出厂帧）：

```json
{
  "seq": "1024", "timestamp_ms": "1757568000123",
  "status": {
    "motion_realtime": {
      "command_position": { "x": 10.5, "y": 20.25, "z": 3.0, "b": 0.0, "c": 0.0 },
      "actual_position":  { "x": 10.501, "y": 20.251, "z": 3.001, "b": 0.0, "c": 0.0 },
      "work_position":    { "x": 0, "y": 0, "z": 0, "b": 0, "c": 0 },
      "work_actual_position": { "x": 0, "y": 0, "z": 0, "b": 0, "c": 0 },
      "joint_position":   { "x": 0, "y": 0, "z": 0, "b": 0, "c": 0 }
    },
    "machining_realtime": {
      "program_line": 42, "progress_pct": 36.5, "elapsed_s": 1024.0,
      "spindle_speed_rpm": 12000.0, "spindle_temperature_c": 31.0, "feed_rate": 500.0,
      "spindle_load": 42.0, "rapid_rate": 8000.0
    }
  }
}
```

字段字典（proto `BusinessStatus` snake 终态；标 ※ 为模拟器增量字段，proto 外、影子原样保留）：

| 路径 | 类型 | 业务含义 |
| --- | --- | --- |
| `status.motion_realtime.command_position` | AxisPosition | 五轴指令绝对坐标（x/y/z/b/c，单位 mm/deg） |
| `status.motion_realtime.actual_position` | AxisPosition | 五轴实测绝对坐标 |
| `status.motion_realtime.work_position` | AxisPosition | 指令工件坐标 |
| `status.motion_realtime.work_actual_position` | AxisPosition | 实测工件坐标 |
| `status.motion_realtime.joint_position` | AxisPosition | 物理关节坐标 |
| `status.machining_realtime.program_line` | int32 | 当前执行程序行号 |
| `status.machining_realtime.progress_pct` | double | 行基进度 0..100 |
| `status.machining_realtime.elapsed_s` | double | 已加工时长（秒） |
| `status.machining_realtime.spindle_speed_rpm` | double | 主轴实时转速 RPM（ESTOP 时 0） |
| `status.machining_realtime.spindle_temperature_c` | double | 主轴温度 ℃ |
| `status.machining_realtime.feed_rate` | double | 实时进给速度（机器单位/分钟） |
| ※ `status.machining_realtime.spindle_load` | double | 主轴负载（proto 外，模拟器增量） |
| ※ `status.machining_realtime.rapid_rate` | double | 快移速率（proto 外，模拟器增量） |

数值无效值约定（proto 保留）：int32 无效=2147483647；int64/double 无效=9007199254740991——接收方计算/展示前必须检查。

### 8.3 BusinessState（低频状态位·运行态与元数据）

整帧样例：

```json
{
  "seq": "104", "timestamp_ms": "1757568000123", "boot_id": "boot-...", "uptime_s": 3600,
  "state": {
    "summary_status": { "display_state": "DISPLAY_STATE_READY", "since_ts": 1757567000.0 },
    "controller_status": {
      "connection": "CONNECTION_STATE_CONNECTED",
      "estop": "ESTOP_STATE_RELEASED",
      "enable": "ENABLE_STATE_ENABLED",
      "mode": "CONTROLLER_MODE_IDLE",
      "task_state": "CONTROLLER_TASK_STATE_IDLE",
      "interp_state": "INTERPRETER_STATE_IDLE"
    },
    "task_status": {
      "machining": { "phase": "MACHINING_PHASE_IDLE", "result": "TASK_RESULT_NONE",
                     "started_ts": 0, "finished_ts": 0 },
      "homing": { "phase": "HOMING_PHASE_IDLE", "result": "TASK_RESULT_NONE", "axis": "AXIS_NONE" },
      "self_test": { "phase": "SELF_TEST_PHASE_IDLE", "result": "TASK_RESULT_NONE" },
      "auto_center": { "phase": "AUTO_TASK_PHASE_IDLE", "result": "TASK_RESULT_NONE" },
      "auto_tool": { "phase": "AUTO_TASK_PHASE_IDLE", "result": "TASK_RESULT_NONE" }
    },
    "safety_status": {
      "drive": { "state": "DRIVE_AGGREGATE_STATE_NORMAL" },
      "soft_limit": { "active": false },
      "door": { "position": "DOOR_POSITION_CLOSED", "changed_ts": 1757566000.0,
                "interlock": "INTERLOCK_STATE_ARMED", "paused": "BOOL_STATE_FALSE" },
      "mql_interlock": { "state": "INTERLOCK_STATE_ARMED" },
      "hardware_estop": { "active": "BOOL_STATE_FALSE", "occurred": "BOOL_STATE_FALSE" }
    },
    "process_status": {
      "spindle": { "state": "SPINDLE_PHASE_OFF", "commanded_on": "BOOL_STATE_FALSE",
                   "enabled": "BOOL_STATE_FALSE", "override": 1.0 },
      "feed": { "override": 1.0, "override_enabled": "BOOL_STATE_TRUE", "hold": "BOOL_STATE_FALSE" },
      "current_tool": { "number": 3, "length": 52.1, "radius": 3.175 },
      "work_coordinate_system": { "g5x_index": 1, "rotation_xy_deg": 0.0 }
    },
    "peripheral_status": {
      "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" },
      "ambient_light": { "available": true, "enabled": "BOOL_STATE_TRUE" },
      "work_light": { "available": true, "on": "BOOL_STATE_TRUE" },
      "beep": { "available": true }
    },
    "machine_metadata": { "boot_id": "boot-...", "protocol_version": "1.0.0",
                         "active_axes": ["AXIS_X", "AXIS_Y", "AXIS_Z", "AXIS_B", "AXIS_C"],
                         "capabilities": ["MACHINE_CAPABILITY_MACHINING", "MACHINE_CAPABILITY_TIMELAPSE"] },
    "program_metadata": { "selected_path": "/programs/demo.ngc", "loaded_path": "/programs/demo.ngc",
                         "total_lines": 1204 },
    "motion_state": {
      "homing": "BOOL_STATE_FALSE",
      "axes_homed": { "x": "BOOL_STATE_TRUE", "y": "BOOL_STATE_TRUE", "z": "BOOL_STATE_TRUE",
                     "b": "BOOL_STATE_TRUE", "c": "BOOL_STATE_TRUE" },
      "all_homed": "BOOL_STATE_TRUE"
    },
    "config_status": { "door_pause_enabled": true, "mql_pause_on_low": false,
                      "timelapse_server_url": "", "timelapse_actual_url": "",
                      "spindle_overtemp_threshold_c": 60.0 }
  }
}
```

字段字典（proto `BusinessState`，键=snake 终态；`recovery_status` 为 proto 字段，参考实现样本常省略）：

| 路径 | 业务含义 | 取值（枚举全集） |
| --- | --- | --- |
| `summary_status.display_state` | 设备总显示态（不含故障明细） | `DISPLAY_STATE_UNKNOWN/OFFLINE/ESTOP/FAULT/SELF_TESTING/HOMING/PAUSE/MACHINING/DISABLED/READY` |
| `summary_status.since_ts` | 当前显示态起始时刻（Unix 秒） | double |
| `controller_status.connection` | 控制器连接 | `CONNECTION_STATE_UNKNOWN/DISCONNECTED/CONNECTED` |
| `controller_status.estop` | 急停状态 | `ESTOP_STATE_UNKNOWN/RELEASED/ACTIVE` |
| `controller_status.enable` | 使能状态 | `ENABLE_STATE_UNKNOWN/DISABLED/ENABLED` |
| `controller_status.mode` | 控制模式 | `CONTROLLER_MODE_UNKNOWN/MANUAL/AUTO/MDI` |
| `controller_status.task_state` | LinuxCNC 任务态 | `CONTROLLER_TASK_STATE_UNKNOWN/ESTOP/ESTOP_RESET/OFF/ON` |
| `controller_status.interp_state` | 解释器态 | `INTERPRETER_STATE_UNKNOWN/IDLE/READING/PAUSED/WAITING` |
| `controller_status.recovery_status` | 急停恢复流程态 | `RECOVERY_STATUS_UNKNOWN/NONE/RUNNING/SUCCEEDED/FAILED` |
| `task_status.machining.phase/result` | 加工任务阶段/结果 | phase：`MACHINING_PHASE_UNKNOWN/IDLE/READY/RUNNING/PAUSE`；result：`TASK_RESULT_NONE/SUCCEEDED/FAILED/CANCELLED`（各任务同构，含 `started_ts/finished_ts` Unix 秒；homing 另有 `axis`，self_test 另有 `items[]{name,progress,state}`） |
| `task_status.homing/auto_center/auto_tool/self_test.phase` | 回零/自动对中/自动对刀/自检阶段 | `*_PHASE_UNKNOWN/IDLE/RUNNING` |
| `safety_status.drive.state` | 驱动聚合态 | `DRIVE_AGGREGATE_STATE_UNKNOWN/NORMAL/FAULT` |
| `safety_status.soft_limit.active` | 软限位触发 | bool（axes 细分：`SOFT_LIMIT_DIRECTION_NONE/MIN/MAX`） |
| `safety_status.door.position/changed_ts/interlock/paused` | 安全门位置/变更时刻/联锁/门致暂停 | position：`DOOR_POSITION_UNKNOWN/CLOSED/OPEN/ERROR`；interlock：`INTERLOCK_STATE_UNKNOWN/DISABLED/ARMED/BLOCKING/TRIGGERED`；paused：BoolState |
| `safety_status.mql_interlock.state` | MQL 低液位联锁 | InterlockState 同上 |
| `safety_status.hardware_estop.active/occurred` | 硬件急停输入 当前/本次运行曾发生 | BoolState |
| `process_status.spindle.state/commanded_on/enabled/override` | 主轴相位/指令开启/使能/倍率 | state：`SPINDLE_PHASE_UNKNOWN/OFF/STARTING/RUNNING/COASTING`；override 1.0=100% |
| `process_status.feed.override/override_enabled/hold` | 进给倍率/倍率启用/进给保持 | override 1.0=100%；hold：BoolState |
| `process_status.current_tool.number/length/radius` | 当前刀号（0=无刀）/长度补偿 mm/半径补偿 mm | int32/double |
| `process_status.work_coordinate_system.g5x_index/rotation_xy_deg` | 工件坐标系（1=G54…）/XY 旋转角 | int32/double |
| `peripheral_status.mql.connection/level/relay/available` | MQL 连接/液位/继电器/能力在位 | level：`MQL_LEVEL_UNKNOWN/NORMAL/LOW`；relay：`RELAY_STATE_UNKNOWN/OFF/ON` |
| `peripheral_status.handwheel.*` | 手轮连接/使能/当前轴/速率/每脉冲移动量 | rate：`HANDWHEEL_RATE_UNKNOWN/X1/X10/X100`；scale double |
| `peripheral_status.timelapse.connection/stream` | 延时摄影服务连接/推流态 | stream：`STREAM_STATE_UNKNOWN/OFF/ON` |
| `peripheral_status.ambient_light.available/enabled` | 环境灯在位/开启 | bool/BoolState |
| `peripheral_status.work_light.available/on` | 工作灯在位/开启 | bool/BoolState |
| `peripheral_status.beep.available` | 蜂鸣器在位 | bool |
| `machine_metadata.boot_id/protocol_version/active_axes/capabilities` | 启动标识（持久化记忆）/协议版本/生效轴/能力集 | capabilities：`MACHINE_CAPABILITY_MACHINING/HOMING/SELF_TEST/TIMELAPSE/MQL/HANDWHEEL/AMBIENT_LIGHT/WORK_LIGHT/BEEP` |
| `program_metadata.selected_path/loaded_path/total_lines` | 选中/已装载程序路径与总行数 | string/int32 |
| `motion_state.homing/axes_homed{}/all_homed` | 回零中/各轴已回零/全部已回零 | BoolState（axes_homed 键小写轴名） |
| `config_status.door_pause_enabled/mql_pause_on_low` | 开门暂停/MQL 低水位暂停策略 | bool（驾驶舱开关位） |
| `config_status.timelapse_server_url/timelapse_actual_url/spindle_overtemp_threshold_c` | 延时服务配置地址（配置/实际）/主轴过温阈值 | string/double |

注：常驻模拟器出厂 state 帧为该结构的稳定子集，个别安全位以布尔镜像形态上报（`safety_status.chunk_door/lamp_on/mql_on/feed_override_pct/spindle_override_pct`）——云端按 snake 全量落库原样保留，FE 按本表规范键实现并对未知键宽容。

### 8.4 BusinessHms（故障信息）

整帧样例（安全门开 + 急停叠加态）：

```json
{
  "seq": "2048", "timestamp_ms": "1757568000123",
  "hms": {
    "alarm_status": {
      "severity": "ALARM_SEVERITY_CRITICAL",
      "active_codes": ["DOOR_OPEN", "ESTOP_ACTIVE"],
      "items": [
        { "code": "DOOR_OPEN", "severity": "ALARM_SEVERITY_ERROR", "message": "safety door open",
          "source": "safety_status.door", "occurred_at": 1757568.0,
          "first_seen_ts": 1757568.0, "last_seen_ts": 1757568.0 },
        { "code": "ESTOP_ACTIVE", "severity": "ALARM_SEVERITY_CRITICAL", "message": "emergency stop engaged",
          "source": "controller_status.estop", "occurred_at": 1757568.0,
          "first_seen_ts": 1757568.0, "last_seen_ts": 1757568.0 }
      ]
    },
    "task_diagnostics": [], "drive_diagnostics": [],
    "peripheral_diagnostics": [], "controller_diagnostics": []
  }
}
```

无警报时：`alarm_status = {severity:"ALARM_SEVERITY_NONE", active_codes:[], items:[]}`，四个诊断数组恒在（空数组）。

字段字典（proto `BusinessHms`，snake 终态）：

| 路径 | 业务含义 |
| --- | --- |
| `hms.alarm_status.severity` | 当前活动告警最高严重级：`ALARM_SEVERITY_UNSPECIFIED/NONE/INFO/WARNING/ERROR/CRITICAL` |
| `hms.alarm_status.active_codes[]` | 活动告警码集合（空=已知无警） |
| `hms.alarm_status.items[].code` | 告警码（如 `DOOR_OPEN`、`MQL_LOW_LEVEL`、`ESTOP_ACTIVE`、`SELF_TEST_RUNNING` 或厂商数字码） |
| `hms.alarm_status.items[].severity/message/source` | 级别/人读文本/来源状态路径（如 `safety_status.door`） |
| `hms.alarm_status.items[].first_seen_ts/last_seen_ts/occurred_at` | 首见/末次确认/发生时刻（Unix 秒，double） |
| `hms.task_diagnostics[]` | 任务类诊断（回零/自检/加工运行错误）；条目=同 AlarmItem 形 |
| `hms.drive_diagnostics[]` | 驱动诊断（带 `axis` 轴名 + `error_code` CiA402/厂商码，0=正常 + `description`） |
| `hms.peripheral_diagnostics[]` | 外设诊断（手轮/延时服务等） |
| `hms.controller_diagnostics[]` | 控制器/采集诊断（含 controller last_error） |

查询面（告警区间聚合，REST）：`GET /api/v1/hms/{sn}/current` → `[{sn,error_code,first_seen_at,last_seen_at,resolved_at?}]`；`GET /api/v1/hms/{sn}/summary?from&to` → `[{error_code,occurrences,active_count,total_active_seconds,first_seen_at?,last_seen_at?}]`。

### 8.5 命令→状态联动语义（模拟器出厂行为，真实设备同契约）

| 指令 | 状态收敛（观察点） |
| --- | --- |
| ESTOP ENGAGE | `controller_status.estop=ACTIVE`、`enable=DISABLED`、status 主轴/进给=0、`summary_status.display_state=ESTOP`、HMS 增 `ESTOP_ACTIVE`(CRITICAL) |
| ESTOP RESET | 上述反转，HMS 告警消除 |
| HOME | 轴坐标归零、`motion_state.axes_homed`→TRUE、`all_homed`=TRUE |
| JOG/MDI | `motion_realtime.command_position` 向目标移动；MDI 可令主轴旋转（8000RPM 档） |
| CNC_CTRL START | `mode=AUTO`、`task_state=ON`、`interp_state=READING`、`progress_pct/program_line` 推进 |
| AUTO_TASK / SELF_TEST | task_status 对应段 phase=RUNNING；自检期间 HMS 增 `SELF_TEST_RUNNING`(INFO) |
| LIGHT/MQL | `peripheral_status.work_light.on`/`mql.relay` 翻转（EnumState，非布尔） |
| SAFETY_POLICY_SET | `config_status.door_pause_enabled/mql_pause_on_low` 翻转（bool） |
| 门开（设备侧场景） | `safety_status.door.position=OPEN` + HMS `DOOR_OPEN`(ERROR) |

### 8.6 查询面（REST 投影）

| 端点 | 返回 |
| --- | --- |
| `GET /api/v1/shadow/{sn}` | `{sn, reported(整帧快照), desired, version}` |
| admin `GET /api/admin/iot/devices/{sn}/snapshot` | `{sn,last_report_at,status,state,hms,version}`（三帧最新值，回落 shadow；排序/鲜度按 `received_at` 权威列） |
| `GET /api/v1/devices/{sn}/events?event_type&…` | 事件分页（`{id,sn,event_type,source,command_id?,payload_json,occurred_at,received_at}`）——status/state/hms 历史唯一来源 |
| admin 报表 | `list_status_reports/list_state_reports/list_hms_reports`（时间窗分页）、`list_current_hms/list_hms_summary`、`list_message_logs`（EMQX 投递台账） |

---

## 9. 红线清单

1. **凭证纪律**：`publish_token`/`upload_token`/`object_key` 只存在于设备命令载荷；任何 APP 响应/日志/落盘不得出现。`viewer_token` 绑定 `viewer_identity`——joinRoom userId 逐字用返回值。
2. **键名纪律**：请求/响应/WSS 帧/命令信封全 snake（camel 违约服务端不识别）；仅 timelapse 媒体载荷与加工账本详情嵌套段为记录在案的 camel 豁免域。int64 值按字符串消费（number 亦接受）。
3. **离线≠冲突**：503/42007 提示"设备离线"；409/42003 才是业务冲突（msg 可操作，照读给用户）。
4. **admin 命令必须先建 WSS 在场**（§2.2 三步），操作全程保持订阅在线；412/42028 = 没盯设备就下发，不是故障。
5. **SEEK 先 GOP 2s 量化**再发；429/42900 自觉退避到窗口结束。
6. **裸指令必须显式 `mode`**（INSTANT/PERSISTENT）；INSTANT 无 progress，别等进度帧。
7. **命令结果是事件驱动的**：以 WSS `command.status`/`command.lifecycle` 帧 + REST 查询为准，不要本地猜超时（服务端 timeout_at 会兜底推送 timeout 帧）；断档恢复走轻量 REST（详情自带 progress 快照三列），不依赖帧重放。
8. **重试语义**：重试同 `command_id` = 取回既有命令行（不重发设备）；幂等键请求级唯一；relay 幂等语义="同资产返回既有任务"，终态后重看=新建。
9. **FE 幂等契约**：UI 状态按 command_id 幂等收敛——终态粘滞不可回退（lifecycle 权威 > status 推知），重复/乱序帧无害；command.status/command.lifecycle 只会到达 watch 了该 command_id 的连接（先声明再等帧）。
10. **`asset_version` 对位**：操作前重取详情；42024=版本已前进。
11. **content 只拉一次**（pull 即 COMPLETED）；重看走新建任务。
12. **WSS token 只走子协议头**；query/Authorization 传 token 一律 401 `missing_token`。
13. **APP 永不直连 MQTT**；一切设备语义经 iot-be 命令/查询面。
14. **终态后的迟到事件是留痕不是错误**——不要把 `*_skipped_terminal`/`late_result` 当异常处理。
15. **离场归因如实上报**：unsubscribe body 只发 `user_closed`/`background_playback_timeout` 两值（或不发=缺省 user_closed）；服务端专属值伪造=400。

---

## 附录 A. 源码出处索引（终态）

| 契约 | 文件 |
| --- | --- |
| C 端命令端点/裸 TranslatedCommandResponse | `infimaker_iot_be/.../api/CommandController.java` |
| admin 命令端点 + 412 在场前置 | `infimaker_iot_be/.../api/AdminDeviceCloudController.java` |
| 命令请求 DTO（六键 snake）/ 响应 DTO（14 键含 progress 快照三列） | `.../dto/CommandDispatchRequest.java`、`CommandResponse.java`（`@JsonNaming(SnakeCase)`） |
| dispatch 流程（command_id 去重/限流/幂等/在线/可行性/queued/publish fail-closed） | `.../application/command/CommandService.java`（dispatch/acceptDuplicateDispatch） |
| 6 态状态机（R32 幂等推进/终态拒绝/超时/response_missing/late_result） | `.../domain/CommandStateMachine.java`、`CommandStatus.java` |
| cmd/req 信封（command_id/mode/command_type/payload） | `CommandService.publishToDevice` |
| cmd/ack·progress·result 上行解析（只认 snake） | `.../application/command/CmdAckMessageHandler.java`、`CmdProgressMessageHandler.java`、`CmdResultMessageHandler.java`、`CommandProtocolFields.java` |
| WSS 帧类型注册与 watch 路由面 | `.../dto/ClientFrame.java`（`WATCH_ROUTED_TYPES`）、`infrastructure/ws/LocalWsFrameSender.java` |
| watch 声明（上限/替换语义） | `.../api/ClientWebSocketHandler.java`（declareWatches）、`.../application/command/CommandWatchService.java` |
| message.log 帧增强（cmd 结构化字段） | `.../application/command/CommandLogFrameEnricher.java`、`application/audit/MessageLogService.java` |
| command.lifecycle 闭环帧 | `.../application/command/CommandLifecycleReporter.java` |
| 转译注册表双 lane / 五转译器（薄载荷 snake/物权/SN 错位防护） | `.../application/command/CommandTranslationRegistry.java`、`.../application/timelapse/TimelapseCommandTranslators.java` |
| playback Create/Seek/Stop 结果与设备载荷（camel 媒体豁免域） | `.../application/timelapse/PlaybackSessionService.java` |
| relay CreateResult/幂等重放/并发/版本/file-upload·compose-product 载荷 | `.../application/timelapse/RelayTaskService.java` |
| compose ComposeResult/载荷/离线 FAILED 留痕 | `.../application/timelapse/ComposeTaskService.java` |
| bind-codes request（REST→INSTANT binding.request，payload snake）/latest 视图 | `.../api/DeviceDirectoryController.java`、`.../application/binding/BindingService.java` |
| leave_reason 分类法（白名单/400/服务端归因） | `.../application/media/LeaveReason.java`、`AdminLiveController.java`、`LiveController.java` |
| 错误码登记簿 42xxx / 异常→信封映射 | `.../api/IotErrorCodes.java`、`IotExceptionHandler.java` |
| 通用错误码 / 信封 v2 结构 | `infimaker_common/.../errorcodes/ErrorCodes.java`、`envelope/ApiEnvelope.java` |
| WSS 端点/子协议鉴权/身份矩阵/帧协议 | `.../api/ClientWebSocketAuthInterceptor.java`、`ClientWebSocketHandler.java`、`config/WebSocketConfig.java` |
| business 三帧解析（snake only）/落库/WSS 广播 | `.../application/business/BusinessStatus|State|HmsMessageHandler.java`、`BusinessReportSupport.java` |
| seq 消费告警（regressed/gap/boot_changed/restart_signature） | `.../application/business/BusinessSeqMonitor.java` |
| device.status 连接态帧 / shadow.reported 帧类型 | `.../application/device/DeviceConnectivityService.java`、`.../application/shadow/ShadowService.java` |
| 业务事件结构化日志（SLS 键） | `infimaker_common/.../BusinessEvents.java` + iot-be 各域调用点 |
| proto 字段字典（Status/State/Hms/枚举全集） | `infimaker_iot_be/src/main/proto/infimaker/iot/v1/business_{status,state,hms,common}.proto`（纯参考文档，D1(b)） |
| 设备侧帧出厂值（build_status/state/hms snake、SCENARIOS、ack/progress/result 构造） | `infimaker_upper_machine/src/infimaker_upper_machine/domain/machining.py`、`business_report.py`；`infimaker_device_simulator/src/device_simulator/simulator.py`、`timelapse.py` |
| FE 已集成清单 | `infimaker_user_fe/src/features/timelapse/api.ts`、`src/features/devices/iotApi.ts`、`commandCards.ts`；`infimaker_admin_fe/src/services/deviceCloud.ts`、`src/pages/DeviceCloud/catalog.ts`、`commandCards.ts`、`RemoteMachining.tsx` |

## 附录 B. 机读交付包

同目录 **`promotion-app-iot-commands.md`**：本册的纯协议机读版（通道 → 命令四体全 JSON → 时序 → 错误码表），AI Agent 可直接摄取实现 APP 侧；与本册差异为零（同源同口径）。

## 附录 C. 历史过程存档（裁决与迁移记录）

正文为最终目标态契约；以下为达到该形态的关键裁决与迁移过程，留档备查（详细过程见 15.06 附录·执行记录）。

### C.1 命令面统一（2026-09-10，iot-be 0.0.147）

- 五类 timelapse 写操作从 REST POST 收编命令端点；`live.stop`/`binding.request` 同通道收编。
- admin 命令面 WSS 在场门（412/42028）对 timelapse 域豁免（本域全轮询驱动，不依赖 command.status 实时帧）。

### C.2 veRTC 凭证滚动续期（2026-09-11 裁决）

- veRTC AccessToken TTL=15min，回放与 FPV 共用通道 → 续期协议不分域：publisher 走 `live.publisher.renew` 设备命令（seek 同语义），viewer 走 heartbeat 回包字段（`viewer_token`/`viewer_token_expires_at_epoch_seconds`）。

### C.3 D6 全协议 snake 迁移（2026-09-13，X4/X5/X7/X8 批）

- 迁移前形态：proto3-canonical camelCase 发射 + 服务端双键容错。裁决改判为全协议（WSS/MQTT/HTTP）统一 snake_case。
- 三步落地：① 服务端 snake 优先双读 + camel fallback SLS 观测；② 发射面全量切 snake（iot-be 下行/推送、两 FE 读键、常驻模拟器上行）；③ 收口删双读容错——上行解析只认 snake，camel 违约帧 fail-fast（`parse_error`/`*_payload_invalid`/时间回退 now，可观测）。
- 键映射要点：`timestampMs→timestamp_ms`、`commandId→command_id`、`receivedAtMs→received_at_ms`、`motionRealtime→motion_realtime`、`summaryStatus→summary_status`、`activeCodes→active_codes`、`firstSeenTs→first_seen_ts` 等（全表见 X4/X5 批报告）。
- cmd/progress 数值语义同步收敛：`percent(0..100)` → `progress_permille(0..1000)`；短词 `status` 弃用，`phase` 发 proto 枚举名。
- 豁免/过渡域裁决：timelapse 媒体载荷（设备命令 payload 内层与 timelapse/* 上行帧）与加工账本详情嵌套段保持 camel；裸指令 payload 内层键（programPath/startLine/timeoutS/pauseEnabled 等）为 camel 过渡形态（设备侧容忍 snake 别名，未收口）。

### C.4 命令状态机与幂等终局（2026-09-13，D2/D3/D4 裁决）

- **D2(a)**：dispatch 层 command_id 级去重落地——重试返回既有行 + `duplicate_request` 对账边；onAck 消费 DUPLICATE_ACCEPTED 不做（设备侧 _seen DUPLICATE_ACCEPTED 回环因服务端不再重发而不可达）。
- **D3(b)**：删除 `cancelled` 死状态（原 7 态→6 态）——中止加工由既有 eStop 业务指令承担，命令状态机不做 cancel 生命周期；S26 场景取消。
- **D4(b)**：终态后晚到 result 落 `late_result` 对账边（不做 cancel 下行）。
- R32 语义（progress 允许集/隐式证据幂等推进）为 2026-09-05 权威裁决，已内化进状态机。

### C.5 时间与序列模型（2026-09-13，D5 裁决）

- **D5-a**：`occurred_at` 双写——机器时间列（展示）+ `received_at` 服务端接收时间列（排序/快照/E2E 断言**权威列**）。
- **D5-b**：business seq 消费——per-kind 记上次 seq（boot_id 为重启作用域），回退/gap 只告警进 SLS（`business_seq_regressed/gap`）不拒绝。
- **D5-c**：boot_id 持久化记忆（一次开机周期稳定、跨进程重启不变）+ uptime_s 纯内存秒计数；进程重启签名（seq 与 uptime_s 同步归零）进告警分类。
- **D5-d**：execution_id 一律派生 `exec-{command_id}`（INSTANT/PERSISTENT 同规），服务端不消费。

### C.6 WSS 观测面（2026-09-13，D7 裁决 + X11/X12/X13/X14 批）

- **D7-a**：跨会话按 SN 扇出是设计非缺陷（命令生命周期归属 SN+command_id，不归属连接）。
- **D7-b**：WSS 连接级 watch 属性（`watch_command_ids`）定向投递 command.status（否决帧内 initiator 回显方案）；X11 扩展 command.lifecycle 同路由。
- **D7-c**：FE 幂等契约成文（本册 §9.9）。
- **D7-d**：断档恢复走轻量 REST（命令详情自带 progress 快照三列 `last_phase/last_permille/last_stage`），不引帧重放。
- **X11/X12**：message.log cmd 帧结构化增强 + command.lifecycle 闭环帧 + FE 命令卡片视图（按 command_id 聚合全生命周期）。
- **X13/X14**：live 离场归因分类法（leave_reason 五值 taxonomy）+ 两 FE unsubscribe 携带 reason + 后台 120s 自动退订。
- live.* WSS 帧面已于 2026-09-12 整体移除（FPV 事务/通知/续期全在 REST + 心跳响应）。

### C.7 观测与断言治理（2026-09-13，P0 治理）

- 业务事件全结构化（BusinessEvents：message=事件名 + snake 键值字段；SLS 索引键 `command_id`/`boot_id`/`sequence`）。
- E2E 断言迁 SlsAssert 字段等值引擎（五规则），全文关键词查询通道删除（R17 收口）。
