iotsaas/docs/中控屏对接接口文档.md
2995840376 0ce191c448 chore: 初始化 iotsaas 版本库基线
纳入范围:
- backend/  Spring Boot 多模块后端(iot-common/device/rule/data/alarm/auth)
- frontend/admin/  Vue3 + Element Plus 管理后台
- Android/   涂鸦原生 APP (com.xhai.iot)
- backend/sql/  Flyway 风格迁移脚本(V2~V21)
- docs/       对接文档与设计文档

排除范围:
- 构建产物: node_modules/ target/ build/ dist/ .gradle/
- 签名密钥: *.jks *.keystore (严禁入库)
- 本机配置: Android/local.properties
- 运行日志: logs/ *.log
- AI 协作本地数据 .codebuddy/ 与第三方 demo tuya-demo/

.gitattributes 统一以 LF 存储, Windows 批处理保留 CRLF
2026-10-27 12:00:41 +08:00

929 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 中控屏开放接口文档(第三方对接版)
> 版本:**v2.1**
> 状态:已实现契约(严格对齐后端 P1 / P2 / P3 实际代码)
> 适用对象:10.1 寸中控屏设备厂商 / 第三方接入方
> 更新日期:2026-10-22
> 说明:本文档取代 v1.0(WebSocket 设计稿)。生产环境已落地,**实时通道为「长轮询 + SSE 双通道」,不再使用 WebSocket**;v2.1 新增**会员侧 APP 扫码自助绑定**。
---
## 1. 概述
本接口用于 **中控屏(家庭内常驻大屏设备)** 接入 MumIoT 平台,实现家庭/房间管理、设备管理与本地执行、场景执行、OTA、运营配置、诊断上报以及**家庭增量事件实时推送**。
中控屏使用**专属设备身份**:出厂烧录 SN,由平台管理端预置一次性**初始化凭证 `bootstrapSecret`**(替代旧版的「激活码 + 扫码激活」流程)。屏与家庭的绑定支持两种模式:
- **后台预置即绑定**(装维/厂家场景):管理端预置屏时直接指定 `homeId`,屏用 `bootstrapSecret` 建会话即可拿到 `homeId`。
- **用户 APP 扫码自助绑定**(零售/用户场景):管理端预置屏时 `homeId` 留空(待绑定库存),用户用 APP 扫描屏上二维码(含 SN)调会员接口把屏绑定到自己拥有的家庭,详见 **3.5 节**。
无论哪种模式,屏均用 `bootstrapSecret` 建立会话,由平台签发设备级 JWT(与会员账号体系隔离)。中控屏**不持有任何个人会员密码**,仅持有短期设备 Token。
### 1.1 接入流程
```
[出厂] 设备烧录 SN;管理端预置屏终端并下发一次性 bootstrapSecret
│
▼
[开机] 中控屏展示绑定二维码(内容为 SN;若管理端已预置 homeId 则无需用户操作)
│ ◀── 待绑定库存的屏:用户 APP 扫码 → 预检 bindable → 选择家庭 → 绑定(见 3.5)
▼
[建会话] POST /api/v1/panels/session
│ Header: Authorization: PanelBootstrap <bootstrapSecret>
│ 返回 accessToken / refreshToken / homeId / panelId / stateEpoch …
▼
[业务] 携带 Authorization: Bearer <accessToken> 调用 /api/v1/panels/* 接口
│
▼
[实时] 二选一接收家庭增量事件:
├─ 长轮询 GET /api/v1/panels/{panelId}/events?cursor=…&timeout=…
└─ SSE 流 GET /api/v1/panels/{panelId}/events/stream?cursor=… (推荐)
```
### 1.2 环境
| 环境 | Base URL | 说明 |
|------|----------|------|
| 测试 | `https://dev-api.xhai.example.com` | 联调使用 |
| 生产 | `https://api.xhai.example.com` | 正式环境 |
- 所有接口基于 **HTTPS**。中控屏出厂建议内置服务端证书指纹(证书固定)以防局域网中间人。
- **不使用 WebSocket**;实时通道为 SSE(`text/event-stream`,HTTPS 长连接)或长轮询,二者共用同一事件源。
---
## 2. 通用约定
### 2.1 协议与编码
- 传输协议:HTTPS(SSE 亦为 HTTPS 长连接)
- 请求/响应体:`application/json; charset=UTF-8`(SSE 响应为 `text/event-stream`)
- 时间字段:`serverTime` 为**毫秒时间戳**(Long);部分字段为字符串时间
- 所有请求体字段使用 **小驼峰**(camelCase)
- ID 统一对外为字符串(`panelId` / `homeId` / `deviceId` / `memberId` 等)
### 2.2 统一响应结构
所有 HTTP 接口(SSE 除外)返回统一包装:
```json
{
"code": 200,
"message": "操作成功",
"data": { },
"timestamp": 1758200000000
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| code | Integer | `200` 表示业务成功,非 200 表示失败(见错误码表) |
| message | String | 提示信息 |
| data | Object | 业务数据,失败时为 null |
| timestamp | Long | 响应时间戳(毫秒) |
### 2.3 鉴权
- **建立会话**:`POST /api/v1/panels/session` 使用 `Authorization: PanelBootstrap <bootstrapSecret>`(不带 Bearer,不带 JWT)。
- **业务调用**:其余所有 `/api/v1/panels/**` 接口使用 `Authorization: Bearer <accessToken>`。
- **管理端接口**:`/api/v1/admin/**` 使用管理员 JWT(非设备 Token)。
> 中控屏绑定的 `homeId` 由 Token 锁定。接口路径中的 `{panelId}` 必须与当前会话一致(服务端 `requireSelf` 强制校验),家庭范围只从 Token 解析,忽略任何请求参数;传入不一致将返回 `403`。
### 2.4 标准 HTTP 语义(panel-v1)
屏端请求可带请求头 `X-MUM-Protocol: panel-v1`。带该头时,命令发起接口(见 4.5)在设备由本地执行屏执行的情况下,返回 **HTTP 202 ACCEPTED + `state=QUEUED`**(命令已受理但未执行,真正的物理动作由执行屏领取完成)。**旧客户端不带该头则保持原有同步语义,无感知**。
> 屏端判定成功应以「HTTP 状态码 + `data.result.state`」为准,**不得硬编码 `code === 0`**(平台统一 `code=200` 表示业务成功)。
### 2.5 分页
不同接口分页方式不同:
- **家庭快照设备分页**:游标式 `pageToken` + `limit`(见 4.1),非 page/size。
- 其余列表接口(管理端列表等)按需使用 `page` / `size`(默认 20,最大 100)。
### 2.6 幂等
- 设备登记(`localDeviceId` 稳定)天然幂等:重复登记返回既有映射(`created=0`)。
- 状态上报、命令回报、场景回报均带递增序号/ `reportId`,服务端去重(旧回执不覆盖新状态)。
- 命令发起带 `X-MUM-Protocol: panel-v1` 时生成唯一 `commandId`,可据此追踪。
---
## 3. 认证与会话
### 3.1 建立会话
`POST /api/v1/panels/session`
Header:`Authorization: PanelBootstrap <bootstrapSecret>`
**请求体**
```json
{
"serialNumber": "PANEL2026ABC123",
"installationId": "inst-001",
"bootId": "boot-20261022-1",
"model": "Panel10",
"appVersion": "1.0.0",
"protocolVersion": "1.0",
"features": ["matter", "zigbee"]
}
```
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| serialNumber | String | 是 | 屏序列号 |
| installationId | String | 是 | 安装实例 ID(一台设备重装后可能变化) |
| bootId | String | 是 | 当前启动实例 ID(每次冷启动变化;命令/上报需与之匹配) |
| model | String | 是 | 屏型号 |
| appVersion | String | 是 | 应用版本 |
| protocolVersion | String | 是 | 协议版本 |
| features | String[] | 否 | 本屏支持的协议/功能 |
**响应 data(Session)**
```json
{
"panelId": "3",
"gatewayDeviceId": "gw-xxxx",
"homeId": "1",
"memberId": "1001",
"role": "GATEWAY",
"accessToken": "eyJ...",
"refreshToken": "eyJ...",
"tokenType": "Bearer",
"expiresIn": 1800,
"refreshExpiresIn": 2592000,
"stateEpoch": 5,
"heartbeatIntervalSeconds": 30,
"leaseSeconds": 90,
"maxWaitSeconds": 20,
"serverTime": 1792668443000
}
```
| 字段 | 说明 |
|------|------|
| panelId | 平台中控 ID(后续路径使用) |
| homeId | 绑定的家庭 ID(锁定) |
| role | `GATEWAY`(可本地执行设备命令)/ `DISPLAY`(仅展示与提交控制) |
| accessToken / refreshToken | 访问/刷新令牌 |
| expiresIn / refreshExpiresIn | 有效期(秒),默认 1800 / 2592000 |
| stateEpoch | 状态世代,bootId 变化后递增 |
| heartbeatIntervalSeconds | 建议心跳间隔(秒) |
| leaseSeconds | 在线租期(秒),心跳续租 |
| maxWaitSeconds | 长轮询最大等待(秒) |
| serverTime | 服务器时间(毫秒),用于估算时差 |
### 3.2 刷新会话
`POST /api/v1/panels/session/refresh`
**请求体**
```json
{ "refreshToken": "eyJ...", "bootId": "boot-20261022-1" }
```
**响应 data(RefreshResponse)**:`accessToken` / `refreshToken` / `tokenType` / `expiresIn` / `refreshExpiresIn` / `serverTime`。旧的 refreshToken 立即作废(轮换)。
> 建议 accessToken 剩余 ~10% 有效期时主动刷新;刷新失败(refresh 过期/被吊销)则重新建立会话(3.1)。
### 3.3 屏心跳
`POST /api/v1/panels/{panelId}/heartbeat`
续在线租期(租期自收到有效心跳起算)。
**请求体(Heartbeat)**
```json
{
"bootId": "boot-20261022-1",
"appVersion": "1.0.0",
"networkType": "wifi",
"uptimeSeconds": 3600,
"deviceCounts": { "matter": 12, "zigbee": 3 },
"protocols": { "matter": "READY", "zigbee": "READY" }
}
```
**响应 data(HeartbeatResponse)**:`serverTime` / `leaseSeconds` / `configurationRevision` / `homeRevision`。
### 3.4 屏信息
`GET /api/v1/panels/{panelId}`
**响应 data(Info)**:`panelId` / `gatewayDeviceId` / `homeId` / `role` / `enabled` / `online` / `lastSeenAt` / `features`。
### 3.5 会员扫码绑定(APP)
> 适用「用户自助绑定」模式。屏身份由管理端预置(`serialNumber` + 一次性 `bootstrapSecret`,`homeId` 留空=待绑定库存)。用户用 APP 扫描屏上二维码(含 `serialNumber`)后,把屏绑定到自己拥有的家庭。所有接口前缀 `/api/v1/member/panels`,**要求会员 JWT(`Authorization: Bearer <会员token>`)**,`memberId` 由服务端从 token 解析(不信任请求参数,防越权)。
**绑定前置:屏必须已预置、启用;会员必须是目标家庭的 `accepted` 成员;若屏已绑定到别的账号则拒绝(`403`,防抢绑)。**
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/v1/member/panels/bindable?serialNumber=` | 扫码后预检:该 SN 是否已预置/启用/已绑定(`Bindable`) |
| POST | `/api/v1/member/panels/bind` | 绑定:`{ serialNumber, homeId, pairingCode? }` |
| GET | `/api/v1/member/panels` | 列出当前会员绑定的所有屏 |
| POST | `/api/v1/member/panels/{panelId}/unbind` | 解绑(仅绑定者本人,释放家庭后屏需重新绑定方可使用) |
**bind 请求体**
```json
{ "serialNumber": "PANEL2026ABC123", "homeId": "2113207217787404289", "pairingCode": "123456" }
```
**bind 响应 data(BindResult)**:`panelId` / `serialNumber` / `homeId` / `memberId` / `role` / `boundAt`。绑定成功后屏端**重新建立会话**(`POST /api/v1/panels/session`)即可拿到新 `homeId`。
**bindable 响应 data(Bindable)**:`exists`(是否已预置) / `enabled`(是否启用) / `bound`(是否已绑定家庭) / `panelId`。
**典型 APP 流程**
```
APP 扫码拿到 SN
→ GET /member/panels/bindable?serialNumber=SN (展示「可绑定/已绑定」)
→ 用户在 APP 选择自己拥有的家庭
→ POST /member/panels/bind { serialNumber, homeId }
→ 提示屏重启/重新联网,屏自动建会话获得 homeId
```
> 解绑会**作废该屏的刷新凭证**,屏的现有会话立即失效,需重新绑定后才能再次建会话使用。
### 3.5.1 Android APP 接入示例(原生 Java)
> 本仓库 `Android/` 工程已实现该流程:`PanelListActivity`(列表 + 解绑 + 扫码绑定)。下面给出关键代码片段,供其它端参考。
**① 引入扫码(中控屏二维码内容即 `serialNumber`,本端用 zxing-android-embedded):**
```gradle
// app/build.gradle dependencies
implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
```
**② 接口定义(`ApiService`)—— 与现有 Retrofit 调用一致,`Authorization` 走 `Bearer`:**
```java
@GET("v1/member/panels/bindable")
Call<Result<PanelBindable>> getPanelBindable(
@Header("Authorization") String auth,
@Query("serialNumber") String serialNumber);
@POST("v1/member/panels/bind")
Call<Result<PanelBindResult>> bindPanel(
@Header("Authorization") String auth,
@Body Map<String, Object> body); // { "serialNumber", "homeId" }
@GET("v1/member/panels")
Call<Result<List<PanelInfo>>> getMyPanels(@Header("Authorization") String auth);
@POST("v1/member/panels/{panelId}/unbind")
Call<Result<Void>> unbindPanel(
@Header("Authorization") String auth,
@Path("panelId") long panelId);
```
**③ 扫码 → 预检 → 选家庭 → 绑定:**
```java
// 1) 启动扫码
new IntentIntegrator(this)
.setPrompt("将中控屏二维码放入框内")
.setOrientationLocked(false)
.initiateScan();
// 2) onActivityResult 解析
IntentResult r = IntentIntegrator.parseActivityResult(requestCode, resultCode, data);
if (r != null && r.getContents() != null) {
String sn = extractSn(r.getContents()); // 兼容 "https://...?sn=XXX" 或纯 SN
checkBindable(sn);
}
// 3) 预检
api.getPanelBindable(auth, sn).enqueue(cb -> {
PanelBindable b = body.getData();
if (!b.isExists()) show("该屏未激活或不存在"); // 后端 10001
else if (!b.isEnabled()) show("该屏已禁用");
else if (b.isBound()) show("该屏已被其它账号绑定"); // 后端 403 防抢绑
else showHomePicker(sn); // 4) 选家庭后调用 bindPanel
});
```
**③-extra 兜底:手动输入 SN(屏固件暂未渲染二维码时使用)**
当屏固件还没把 SN 渲染成二维码时,提供「手动输入」入口,直接复用上面的 `checkBindable(sn)` 预检 → 选家庭 → 绑定流程:
```java
// 弹框让用户输入 SN,校验非空后走与扫码完全一致的预检逻辑
EditText input = new EditText(this);
input.setHint("请输入中控屏序列号 SN");
new AlertDialog.Builder(this)
.setTitle("手动绑定中控屏")
.setView(input)
.setPositiveButton("下一步", (d, w) -> {
String sn = input.getText().toString().trim();
if (sn.isEmpty()) { show("请输入序列号 SN"); return; }
checkBindable(sn); // 与扫码解析后同一入口
})
.setNegativeButton("取消", null)
.show();
```
> 注意:SN 必须与管理端预置的 `serialNumber` 完全一致(大小写/前后空格需 trim)。屏固件上线二维码后,扫码与手输两条路径等价。
**④ 错误码**:`10001`=屏未预置/不存在;`403`=已绑他人 / 非目标家庭成员 / 非绑定本人解绑。前端按 `Result.code` 区分提示即可。
---
## 4. 业务接口(屏端)
所有接口前缀 `/api/v1`,均需 `Authorization: Bearer <accessToken>`(除命令发起共享端点 4.5 注明外),且 `{panelId}` 须与会话一致。
### 4.1 家庭快照
`GET /api/v1/panels/{panelId}/snapshot?pageToken=&limit=200`
一次拉取家庭、房间与设备的完整视图(首屏数据)。家庭与房间始终全量返回;**设备支持游标分页**(设备多时分页拉取)。`stateEpoch` 为一致性游标。
**响应 data(Snapshot)**
```json
{
"home": { "homeId": "1", "name": "我的家" },
"rooms": [ { "roomId": "1", "homeId": "1", "name": "客厅" } ],
"devices": [
{
"deviceId": "pd-xxxx",
"name": "卧室灯",
"roomId": "2",
"homeId": "1",
"status": "ONLINE",
"executionSource": "LOCAL_PANEL",
"executorPanelId": "3",
"routeRevision": 2,
"schemaRevision": 1,
"localDeviceId": "local-001",
"protocol": "MATTER",
"states": { "power": true, "brightness": 80 },
"stateVersion": 5
}
],
"stateEpoch": 5,
"nextPageToken": null,
"serverTime": 1792668443000
}
```
| 字段 | 说明 |
|------|------|
| executionSource | `CLOUD`(云端执行)/ `LOCAL_PANEL`(由中控屏本地执行) |
| executorPanelId | 当前执行屏 ID(`LOCAL_PANEL` 时有效) |
| routeRevision | 路由世代(多屏交接时递增;旧屏据此判定 ROUTE_CHANGED 停止执行) |
| schemaRevision | 设备级物模型版本(物模型更新时递增,使旧命令失效) |
| states | 当前属性状态(key 为属性码) |
| stateVersion | 状态版本(最大序号,用于上报新旧判定) |
| nextPageToken | 设备下一页游标;为空表示已到末页 |
> 客户端应缓存 `stateEpoch` / `routeRevision` / `schemaRevision`,变化时刷新本地视图或失效旧命令。
### 4.2 登记本地设备
`POST /api/v1/panels/{panelId}/devices/register`
同一屏内同一 `localDeviceId` 重复登记返回既有映射(`created=0`);执行归属由平台计算,屏端不可指定。
**请求体(Registration)**
```json
{
"bootId": "boot-20261022-1",
"localDeviceId": "local-001",
"nativeNodeId": "0x1234",
"protocol": "MATTER",
"platformDeviceId": null,
"name": "卧室灯",
"roomId": "2",
"productId": 101,
"schema": {
"properties": [
{ "code": "power", "name": "开关", "type": "BOOLEAN", "access": "READ_WRITE", "writable": true }
],
"actions": [
{ "code": "toggle", "name": "翻转", "completionMode": "STATE" }
]
}
}
```
| 字段 | 说明 |
|------|------|
| localDeviceId | 屏内稳定设备 ID(重装后不变) |
| protocol | `MATTER` / `ZIGBEE` / `BLE_GATT` / `BLUETOOTH_MESH` |
| platformDeviceId | 可选,把平台已有设备挂到本屏执行 |
| schema.properties | 属性规格(code/name/type/access/min/max/step/unit/enumList/defaultValue/semantic/channel) |
| schema.actions | 动作规格(code/name/completionMode:`STATE` 需回读状态 / `ACK_ONLY` 仅确认) |
**响应 data(RegistrationResult)**:`deviceId` / `localDeviceId` / `routeRevision` / `schemaRevision` / `executionSource` / `executorPanelId` / `created`(1 新建 / 0 复用)。
### 4.3 更新设备标准控制模型
`PUT /api/v1/panels/{panelId}/devices/{deviceId}/schema`
更新后递增设备级 `schemaRevision`,用于让基于旧模型的命令失效。
**请求体(SchemaUpdateRequest)**:`bootId` + `schema`(同 4.2 的 schema 结构)。
**响应 data(SchemaUpdateResult)**:`deviceId` / `schemaRevision`。
### 4.4 批量状态上报
`POST /api/v1/panels/{panelId}/reports`
按 `(stateEpoch, sequence)` 判断新旧;旧状态后到不会覆盖已有新值(通过 `appliedToCurrentState` 返回)。
**请求体(ReportBatch)**
```json
{
"bootId": "boot-20261022-1",
"stateEpoch": 5,
"reports": [
{
"deviceId": "pd-xxxx",
"localDeviceId": "local-001",
"sequence": 3,
"occurredAt": "2026-10-22 11:00:00",
"properties": { "power": true, "brightness": 80 },
"online": "ONLINE"
}
]
}
```
**响应 data(ReportResult)**:`serverTime` / `stateEpoch` / `results[]`,每项 `deviceId` / `propertyCode` / `appliedToCurrentState`(是否生效) / `stateVersion` / `errorCode`。
### 4.5 发起控制(共享端点)
`POST /api/v1/devices/{deviceId}/commands`
> 该端点与手机/网页端共用。屏端发起时**必须**带请求头 `X-MUM-Protocol: panel-v1`。
- 若目标设备 `executionSource = LOCAL_PANEL`(由中控屏本地执行):返回 **HTTP 202 ACCEPTED**,且 `data.result.state = QUEUED`,真正的执行由执行屏通过 4.6 链路完成(此时 `success=false`,**不能当作执行成功**)。
- 其余情况(云端执行等):保持原同步语义,返回 200 + 执行结果。
**请求体(DeviceCommand)**
```json
{
"commandType": "set",
"params": { "power": true },
"timeout": 30000
}
```
**202 响应示例**
```json
{
"code": 200,
"message": "操作成功",
"data": {
"success": false,
"message": "命令已接受,等待执行屏执行",
"result": { "commandId": "cmd-xxx", "state": "QUEUED", "terminal": false }
}
}
```
`commandId` 用于执行屏领取与追踪。`commandType` 常见:`set`(下发属性)/ `action`(触发动作)/ `query`(查询)。
### 4.6 命令执行链路(网关屏)
仅 `executionSource = LOCAL_PANEL` 且屏 `role = GATEWAY` 时参与。状态机:`QUEUED → DELIVERED → EXECUTING → CONFIRMED/终态`。
**拉取待执行命令** `POST /api/v1/panels/{panelId}/commands/pull`
```json
{ "bootId": "boot-20261022-1", "maxCommands": 10 }
```
响应 `PullResponse`:`commands[]`(`CommandDelivery`:`commandId` / `deviceId` / `commandType` / `params` / `deliveryToken` / `deliveryExpiresAt` / `expiresAt` / `routeRevision` / `schemaRevision` / `completionMode` / `reportId`)、`serverTime`、`nextPullDelayMs`(无命令时退避用)。
> `bootId` 与当前有效启动实例不一致时拒绝;`routeRevision` 与本地不一致视为 `ROUTE_CHANGED`,应停止执行。
**申请执行命令** `POST /api/v1/panels/{panelId}/commands/{commandId}/start`
```json
{ "bootId": "boot-20261022-1", "deliveryToken": "<投递时下发的凭证>" }
```
响应 `StartResponse`:`commandId` / `state`(EXECUTING) / `executionToken`(回报时带回) / `expiresAt` / `serverTime`。
**命令执行回报** `POST /api/v1/panels/{panelId}/commands/{commandId}/report`
```json
{
"executionToken": "<申请执行返回的凭证>",
"reportId": "rpt-001",
"reportSequence": 1,
"state": "CONFIRMED",
"items": [
{ "propertyCode": "power", "state": "CONFIRMED", "actualValue": true, "evidenceSource": "DEVICE_REPORT" }
],
"result": { "power": true },
"error": null,
"occurredAt": "2026-10-22T11:00:05"
}
```
| 字段 | 说明 |
|------|------|
| reportId | 本次回报 ID,重试保持不变(幂等) |
| reportSequence | 同命令内单调递增,用于去重与排序 |
| state | `ACCEPTED` / `CONFIRMED` / `PARTIAL_FAILURE` / `FAILED` / `UNKNOWN` |
| items[].evidenceSource | `DEVICE_REPORT` / `DEVICE_ACK` / `READ_BACK` / `UNSUPPORTED` / `NO_EVIDENCE` |
| error | `{ code, message, retryable }` |
响应 `PanelCommandResult`:`commandId` / `state` / `terminal`(是否终态) / `result` / `evidenceSource` / `error` / `expiresAt` / `serverTime`。回报终态后服务端会向家庭内所有屏广播 `COMMAND_RESULT` 事件(见 5.3)。
### 4.7 OTA
**拉取下一个待升级任务** `GET /api/v1/panels/{panelId}/ota-tasks/next`
按本屏管理的设备匹配运行中的任务,返回首个待升级设备与固件包;无待升级时 `data` 为 `null`。
响应 `OtaNextTask`:`taskId` / `deviceId` / `targetVersion` / `fileUrl` / `size` / `fileMd5` / `packageType`(full/delta) / `targetModel` / `minVersion` / `releaseNote` / **`attemptId`**(上报时原样带回,用于幂等)。
**上报升级结果** `POST /api/v1/ota-tasks/{id}/report`(共享端点)
```json
{
"deviceId": "pd-xxxx",
"success": true,
"version": "1.2.3",
"attemptId": "<下发时的 attemptId>",
"reportId": "rpt-ota-001",
"reportSequence": 1,
"stage": "DOWNLOAD",
"progress": 100,
"errorCode": null,
"message": "ok",
"oldVersion": "1.2.2"
}
```
### 4.8 配置
`GET /api/v1/panels/{panelId}/configuration`
返回当前运营配置与版本/校验和;屏端据 `checksum` 判断是否需要更新本地缓存。
响应 `Configuration`:`panelId` / `version` / `config`(Map) / `checksum`。
### 4.9 诊断上报
`POST /api/v1/panels/{panelId}/diagnostics`
```json
{
"bootId": "boot-20261022-1",
"level": "ERROR",
"category": "mqtt",
"message": "连接断开",
"data": { "code": 500 }
}
```
`category`:`network` / `mqtt` / `ota` / `command` / `config`。
### 4.10 场景执行
**上报执行结果** `POST /api/v1/panels/{panelId}/scene-runs/report`
云端场景涉及本屏本地设备时,屏上报执行结果(按 `sceneRunId` 或 `sceneId + attemptId` 定位,`reportSequence` 去重)。
```json
{
"sceneRunId": 123,
"sceneId": 456,
"triggerSource": "manual",
"attemptId": "att-001",
"reportId": "rpt-scene-001",
"reportSequence": 1,
"status": "success",
"result": { "pd-xxxx": { "state": "success" } }
}
```
响应 `RunView`:`id` / `sceneId` / `homeId` / `triggerSource` / `status` / `executePanelId` / `result` / `reportSequence` / `serverTime`。
**查询场景执行单据** `GET /api/v1/scene-runs/{sceneRunId}`
屏侧按家庭校验,返回 `RunView`(家庭内进度追踪)。
---
## 5. 增量事件通道(长轮询 + SSE)
家庭内设备状态变化、命令结果等通过**事件通道**实时推送给屏。提供两种取用方式,屏端**二选一**:
| 方式 | 端点 | 方向 | 特点 |
|------|------|------|------|
| 长轮询 | `GET /api/v1/panels/{panelId}/events` | 请求-响应 | 兼容性好,无长连接;最坏等待 `timeout` |
| **SSE 流** | `GET /api/v1/panels/{panelId}/events/stream` | 服务端单向推送 | **推荐**;事件到达即推,毫秒级;一条长连接 |
两者**共用同一 `panel_event` 事件源**(跨实例一致,无需额外消息中间件)。事件按 `(homeId, panelId匹配 OR 广播)` 过滤:屏收到自身事件 + 家庭内广播事件。事件类型目前实现 `DEVICE_STATE`、`COMMAND_RESULT`,通道本身可扩展。
### 5.1 长轮询
`GET /api/v1/panels/{panelId}/events?cursor=<上次nextCursor>&timeout=20000&limit=100`
- `cursor`:上次返回的 `nextCursor`(事件 id),首次传 `0`。
- `timeout`:最长阻塞毫秒(默认 20000),或直到有事件到达。
- `limit`:单次最大返回条数(默认 100)。
**响应 data(EventsResponse)**
```json
{
"events": [
{
"id": 2113112213823905793,
"eventType": "DEVICE_STATE",
"deviceId": "pd-xxxx",
"payload": { "propertyCode": "power", "value": true, "stateEpoch": 5, "sequence": 3 },
"serverTime": 1792668986000
}
],
"nextCursor": 2113112213823905793,
"serverTime": 1792668986000
}
```
### 5.2 SSE 流(推荐)
`GET /api/v1/panels/{panelId}/events/stream?cursor=<上次最后事件id>`
- `Content-Type: text/event-stream`,`Accept: text/event-stream`。
- 连接建立即收到一行注释 `:connected`(确认链路 + 刷新代理计时)。
- 无事件时每 ~20s 发一行心跳注释 `:keepalive`(保活,防止代理/中间人掐断)。
- 连接 90s 超时后服务端正常结束;客户端重连即可(见 5.4)。
**事件信封(SSE 文本格式)**
```
id: 2113112213823905793
event: DEVICE_STATE
data: {"id":2113112213823905793,"eventType":"DEVICE_STATE","deviceId":"pd-xxxx","payload":{"propertyCode":"power","value":true,"stateEpoch":5,"sequence":3},"serverTime":1792668986000}
:keepalive
```
> `id` 为事件游标(= 事件 id),`event` 为事件类型,`data` 为 `EventView` 的 JSON。浏览器 `EventSource` 会自动把 `id` 暂存为 `Last-Event-ID`,重连时自动带上。
### 5.3 事件类型
| event(eventType) | payload 示例 | 说明 |
|-------------------|--------------|------|
| `DEVICE_STATE` | `{ "propertyCode":"power", "value":true, "stateEpoch":5, "sequence":3 }` | 设备属性状态变化(状态上报生效时产生) |
| `COMMAND_RESULT` | `{ "commandId":"cmd-xxx", "state":"CONFIRMED", "terminal":true, "result":{...} }` | 命令终态(广播给家庭内所有屏,用于界面即时刷新) |
> 屏端收到 `DEVICE_STATE` 更新对应设备卡片;收到 `COMMAND_RESULT` 刷新对应命令状态。`payload` 为 `Map`,字段以实际为准。
### 5.4 重连与游标
- **浏览器(EventSource)**:因每条事件带 `id`,断线重连时浏览器自动在请求头带 `Last-Event-ID`,服务端从该游标续传;无需屏端额外处理。
- **自定义客户端(OkHttp 等)**:自行记录收到的最后 `id`,重连时带 `?cursor=<最后id>`;若未带 `cursor`,则从头(`0`)拉取全部事件(仅调试/首连用)。
- 重连建议**指数退避**(1s→2s→4s…上限 30s)。重连后建议补一次 **家庭快照**(4.1)以对齐离线期间的整体状态(事件通道为增量,不保证全量)。
### 5.5 部署要求(nginx 等反向代理)
SSE 长连接须经反代透传:
- `/api/v1/panels` 路径需配置 `proxy_buffering off;`(关闭缓冲,保证事件实时到达)。
- `proxy_read_timeout` 建议 ≥ 60s(配合 20s 心跳足够;心跳会持续重置读超时)。
- 屏端若走 WSS/HTTPS 隧道同理保持长连接。
### 5.6 客户端接入示例
> 关键限制:`EventSource` **不支持自定义请求头**,无法直接带 `Authorization: Bearer`。因此:
> - 同源(Web 屏 + Cookie 鉴权)场景可用 `EventSource`(最省心,自动 `Last-Event-ID` 重连);
> - 需 Bearer 鉴权的场景(多数中控屏)请用 **`fetch` 流** 或 **OkHttp 流** 手动解析 `text/event-stream` 并自己维护游标与重连。
#### 5.6.1 Web(Bearer 鉴权,fetch 流)
```js
let lastCursor = 0;
let retryDelay = 1000;
async function connectPanelEvents(panelId, token) {
const url = `/api/v1/panels/${panelId}/events/stream?cursor=${lastCursor}`;
let resp;
try {
resp = await fetch(url, {
method: 'GET',
headers: { 'Authorization': `Bearer ${token}`, 'Accept': 'text/event-stream' }
});
} catch (e) { return reconnect(panelId, token); }
if (!resp.ok) { // 401 等:刷新 token 后重连
await refreshToken();
return reconnect(panelId, token);
}
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buf = '', id = null, event = null, data = '';
const flush = () => {
if (id !== null) lastCursor = Number(id); // 记录游标
if (data) onPanelEvent(event, JSON.parse(data)); // 投递业务
id = event = data = null;
};
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split('\n');
buf = lines.pop(); // 保留未完成的行
for (const raw of lines) {
const line = raw.trimEnd();
if (line === '' || line.startsWith(':')) { flush(); continue; } // 空行=事件结束;:开头=注释/心跳
if (line.startsWith('id:')) id = line.slice(3).trim();
else if (line.startsWith('event:')) event = line.slice(6).trim();
else if (line.startsWith('data:')) data += (data ? '\n' : '') + line.slice(5).trim();
}
}
} finally {
flush();
reconnect(panelId, token); // 连接关闭→重连
}
}
function reconnect(panelId, token) {
setTimeout(() => connectPanelEvents(panelId, token), retryDelay);
retryDelay = Math.min(retryDelay * 2, 30000); // 指数退避,上限 30s
}
function onPanelEvent(eventType, view) {
if (eventType === 'DEVICE_STATE') updateDeviceCard(view.deviceId, view.payload);
else if (eventType === 'COMMAND_RESULT') refreshCommand(view.payload);
}
```
#### 5.6.2 Web(同源 Cookie 鉴权,EventSource)
```js
// 仅在后端对该端点放行 Cookie/Session 鉴权时使用;断线浏览器自动带 Last-Event-ID 续传
const es = new EventSource(`/api/v1/panels/${panelId}/events/stream?cursor=${lastCursor}`);
es.onmessage = (e) => { /* e.data 为 EventView JSON;e.lastEventId 即游标 */ };
es.addEventListener('DEVICE_STATE', (e) => updateDeviceCard(JSON.parse(e.data)));
es.onerror = () => { /* 浏览器会自动重连,无需手动处理 */ };
```
#### 5.6.3 Android(OkHttp,Bearer 鉴权)
```kotlin
private val client = OkHttpClient.Builder()
.readTimeout(0, TimeUnit.MILLISECONDS) // 长连接:禁用读超时
.build()
fun connect(panelId: Long, token: String, cursor: Long) {
val req = Request.Builder()
.url("$BASE/api/v1/panels/$panelId/events/stream?cursor=$cursor")
.header("Authorization", "Bearer $token")
.header("Accept", "text/event-stream")
.build()
client.newCall(req).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) = scheduleReconnect(panelId, token)
override fun onResponse(call: Call, resp: Response) {
if (!resp.isSuccessful) { resp.close(); refreshToken(); scheduleReconnect(panelId, token); return }
var id: String? = null; var event: String? = null; var data = StringBuilder()
resp.body?.source()?.use { src ->
while (!src.exhausted()) {
val line = src.readUtf8Line() ?: break
when {
line.isEmpty() || line.startsWith(":") -> { // 事件结束 / 心跳
id?.let { lastCursor = it.toLong() }
if (data.isNotEmpty()) onEvent(event, data.toString())
id = null; event = null; data = StringBuilder()
}
line.startsWith("id:") -> id = line.substring(3).trim()
line.startsWith("event:") -> event = line.substring(6).trim()
line.startsWith("data:") -> data.append(line.substring(5).trim())
}
}
}
scheduleReconnect(panelId, token) // 连接关闭→重连
}
})
}
private fun scheduleReconnect(panelId: Long, token: String) {
handler.postDelayed({ connect(panelId, token, lastCursor) }, backoffMs.also { backoffMs = min(backoffMs * 2, 30000) })
}
```
#### 5.6.4 uni-app / 小程序 注意
- **小程序端**:`wx.request` 不支持流式响应,无法直接消费 SSE;如需实时,建议用 WebSocket(但本平台未提供 WS 端点)或退化为**长轮询**(5.1 的 `GET /panels/{id}/events`,每次请求-响应,适合小程序定时拉取)。
- **uni-app App 端(Android/iOS 原生)**:`uni.request` 同样非流式;App 端应通过**原生插件 / 原生模块**调用上述 OkHttp(Android)或 `URLSession` 流(iOS)消费 SSE,再用 bridging 回传业务层。
- 通用建议:屏端优先采用**长轮询端点(5.1)**做兜底,能力具备后再切 SSE 流以降低接入复杂度。
---
## 6. 管理端接口(管理员)
路径前缀 `/api/v1/admin/panels`,需管理员 JWT(`ROLE_ADMIN`)。
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/api/v1/admin/panels` | 预置屏终端,返回一次性 `bootstrapSecret`(仅此一次,请妥善保存) |
| GET | `/api/v1/admin/panels` | 屏终端列表 |
| PUT | `/api/v1/admin/panels/{panelId}/status?status=` | 启用/禁用(`status=0` 禁用后该屏刷新凭证立即失效) |
| POST | `/api/v1/admin/panels/{panelId}/rotate` | 轮换初始化凭证(泄露时使用,旧凭证立即失效) |
| GET | `/api/v1/admin/panels/{panelId}/configuration` | 查看屏配置 |
| PUT | `/api/v1/admin/panels/{panelId}/configuration` | 保存屏配置(`configJson`),`version` 递增 |
| POST | `/api/v1/admin/panels/executor-reassign` | 多屏归属交接(见下) |
**预置屏终端 请求(CreateRequest)**:`serialNumber`(必填) / `homeId`(**可选**:留空=待绑定库存,由用户 APP 扫码自助绑定,见 3.5;填了则后台预置即绑定) / `memberId` / `role`(`GATEWAY`/`DISPLAY`) / `gatewayDeviceId` / `features` / `model`。
**响应(CreateResult)**:`panelId` / `serialNumber` / `bootstrapSecret`(仅返回一次)。
**多屏归属交接(executor-reassign)**
`POST /api/v1/admin/panels/executor-reassign`
```json
{ "deviceIds": ["pd-xxxx", "pd-yyyy"], "targetPanelId": 5 }
```
将一批设备从原执行屏改派到目标屏,递增 `routeRevision` 并作废在途命令(`ROUTE_CHANGED`),使旧屏停止执行。响应 `ReassignResult`:`reassigned`(实际交接数) / `expiredCommands`(作废在途命令数)。
---
## 7. 错误码
| code | message | 说明 / 处理建议 |
|------|---------|----------------|
| 200 | 操作成功 | — |
| 400 | 参数错误 | 检查请求体字段 |
| 401 | 未授权,请先登录 | Token 缺失/过期,用 refresh 刷新或重新建立会话 |
| 403 | 禁止访问 | panelId 与会话不一致 / 设备被禁用 / 权限不足 |
| 404 | 资源不存在 | 设备/场景/家庭不存在 |
| 500 | 操作失败 | 服务端异常,稍后重试 |
| 503 | 服务不可用 | 平台维护 |
| 10002 | 设备离线 | 设备不可达,无法控制 |
| 10004 | 指令发送失败 | 控制失败,重试或检查设备在线 |
| 10008 | 设备凭证无效 | SN / bootstrapSecret 错误或未绑定 |
| 10011 | Token 无效或已过期 | 刷新或重新建立会话 |
| 10013 | 权限不足 | 设备无权访问该资源 |
| 11003 | 第三方 API 调用失败 | 平台对接第三方失败,稍后重试 |
| 10100 | 设备未激活 | 需先完成 APP 扫码绑定 |
| 10101 | 初始化凭证无效或已使用 | 凭证错误/失效,需管理端重新签发(rotate) |
| 10102 | 设备已被禁用 | 管理员禁用该中控屏,联系管理员 |
**会员绑定相关(3.5)实际返回:**
- `10001`(DEVICE_NOT_FOUND):绑定/解绑时 `serialNumber` 未预置或不存在(提示「该屏未激活或不存在」)。
- `403`(FORBIDDEN):① 屏已绑定到其他账号(防抢绑);② 会员非目标家庭 `accepted` 成员;③ 解绑者非绑定本人。
> 错误码以服务端实际返回为准(统一 `ResultCode`);上表 10100/10101/10102 为中控屏登录/会话相关语义说明。
---
## 8. 安全与接入要求
1. **全链路 HTTPS**:禁止明文传输;SSE 亦走 HTTPS 长连接。
2. **证书固定(建议)**:中控屏出厂内置服务端证书指纹,防局域网中间人攻击。
3. **凭证保护**:SN + `bootstrapSecret` 为设备唯一身份,`bootstrapSecret` 一次性;设备仅安全存储 refreshToken,**不存储任何会员密码**。
4. **权限隔离**:中控屏仅可访问其绑定家庭(homeId 锁定),无法访问会员/管理员接口。
5. **限流**:建立会话、刷新、控制等敏感操作服务端限流,请勿高频重试(建议退避)。
6. **审计**:所有中控屏控制均记录来源可追溯到 `panelId`。
7. **远程禁用**:管理员可禁用某 SN,设备 Token 立即失效。
8. **多屏安全**:`routeRevision` 递增 + 在途命令作废,防止旧屏在交接后继续执行。
9. **重置策略**:设备恢复出厂需管理端重新签发 `bootstrapSecret` 后方可再次绑定。
---
## 9. 数据字典
- **device role(屏角色)**:`GATEWAY`(可本地执行设备命令)/ `DISPLAY`(仅展示与提交控制)。
- **executionSource(设备执行来源)**:`CLOUD`(云端执行)/ `LOCAL_PANEL`(由中控屏本地执行)。
- **completionMode(动作完成模式)**:`STATE`(需回读状态确认)/ `ACK_ONLY`(仅确认送达)。
- **online(设备在线)**:`ONLINE` / `OFFLINE` / `ERROR`。
- **命令 state**:`QUEUED` / `DELIVERED` / `EXECUTING` / `CONFIRMED` / `PARTIAL_FAILURE` / `FAILED` / `UNKNOWN`。
- **事件 eventType**:`DEVICE_STATE` / `COMMAND_RESULT`(通道可扩展)。
- **诊断 category**:`network` / `mqtt` / `ota` / `command` / `config`。
- **场景 status**:`pending` / `running` / `partial` / `success` / `failed`。
---
## 10. 版本与联系
- 本文档版本 **v2.0**,对应后端实现阶段 **P1(会话/设备/命令/快照/管理端)**、**P2(事件双通道/OTA/配置/物模型)**、**P3(场景/AI 二次确认/诊断/多屏交接)**。
- 接口路径/字段以本契约为准;后端实现严格遵循,如有调整将更新本文档并通知接入方。
- 联调问题请联系平台对接负责人(接口/环境/初始化凭证签发)。