# 中控屏开放接口文档(第三方对接版) > 版本:**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 │ 返回 accessToken / refreshToken / homeId / panelId / stateEpoch … ▼ [业务] 携带 Authorization: Bearer 调用 /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 `(不带 Bearer,不带 JWT)。 - **业务调用**:其余所有 `/api/v1/panels/**` 接口使用 `Authorization: Bearer `。 - **管理端接口**:`/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 ` **请求体** ```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> getPanelBindable( @Header("Authorization") String auth, @Query("serialNumber") String serialNumber); @POST("v1/member/panels/bind") Call> bindPanel( @Header("Authorization") String auth, @Body Map body); // { "serialNumber", "homeId" } @GET("v1/member/panels") Call>> getMyPanels(@Header("Authorization") String auth); @POST("v1/member/panels/{panelId}/unbind") Call> 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 `(除命令发起共享端点 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 二次确认/诊断/多屏交接)**。 - 接口路径/字段以本契约为准;后端实现严格遵循,如有调整将更新本文档并通知接入方。 - 联调问题请联系平台对接负责人(接口/环境/初始化凭证签发)。