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

38 KiB
Raw Permalink Blame History

中控屏开放接口文档(第三方对接版)

版本: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);
}
// 仅在后端对该端点放行 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. 安全与接入要求

  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 二次确认/诊断/多屏交接)。
  • 接口路径/字段以本契约为准;后端实现严格遵循,如有调整将更新本文档并通知接入方。
  • 联调问题请联系平台对接负责人(接口/环境/初始化凭证签发)。