纳入范围: - 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
38 KiB
中控屏开放接口文档(第三方对接版)
版本: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 除外)返回统一包装:
{
"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>
请求体
{
"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)
{
"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
请求体
{ "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)
{
"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 请求体
{ "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):
// app/build.gradle dependencies
implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
② 接口定义(ApiService)—— 与现有 Retrofit 调用一致,Authorization 走 Bearer:
@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);
③ 扫码 → 预检 → 选家庭 → 绑定:
// 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) 预检 → 选家庭 → 绑定流程:
// 弹框让用户输入 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)
{
"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)
{
"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)
{
"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)
{
"commandType": "set",
"params": { "power": true },
"timeout": 30000
}
202 响应示例
{
"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
{ "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
{ "bootId": "boot-20261022-1", "deliveryToken": "<投递时下发的凭证>" }
响应 StartResponse:commandId / state(EXECUTING) / executionToken(回报时带回) / expiresAt / serverTime。
命令执行回报 POST /api/v1/panels/{panelId}/commands/{commandId}/report
{
"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(共享端点)
{
"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
{
"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 去重)。
{
"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)
{
"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 流)
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)
// 仅在后端对该端点放行 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 鉴权)
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
{ "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. 安全与接入要求
- 全链路 HTTPS:禁止明文传输;SSE 亦走 HTTPS 长连接。
- 证书固定(建议):中控屏出厂内置服务端证书指纹,防局域网中间人攻击。
- 凭证保护:SN +
bootstrapSecret为设备唯一身份,bootstrapSecret一次性;设备仅安全存储 refreshToken,不存储任何会员密码。 - 权限隔离:中控屏仅可访问其绑定家庭(homeId 锁定),无法访问会员/管理员接口。
- 限流:建立会话、刷新、控制等敏感操作服务端限流,请勿高频重试(建议退避)。
- 审计:所有中控屏控制均记录来源可追溯到
panelId。 - 远程禁用:管理员可禁用某 SN,设备 Token 立即失效。
- 多屏安全:
routeRevision递增 + 在途命令作废,防止旧屏在交接后继续执行。 - 重置策略:设备恢复出厂需管理端重新签发
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 二次确认/诊断/多屏交接)。
- 接口路径/字段以本契约为准;后端实现严格遵循,如有调整将更新本文档并通知接入方。
- 联调问题请联系平台对接负责人(接口/环境/初始化凭证签发)。