# IoT 设备主题速查(功能 / 参数 / 权限) > 本文只讲**主题**(topic):值不值得用、谁发谁订、payload 每个字段是什么、服务端收到后做什么、有哪些坑。 > REST 接口清单见 `iot_api_reference.md` §二/§三;报文版式沿革见该文 §5.1;固件实现见 `addon/iot/firmware/water_temp/water_temp.ino`。 > 主题常量唯一出处:`addon/iot/service/DeviceService.php`(`TOPIC_*` 与 `deviceTopics()`)、`addon/iot/service/OtaService.php`(OTA 两条)。 --- ## 一、命名空间总览 | 命名空间 | 归属 | 保护方式 | |---|---|---| | `/devices/{uuid}/…` | 单台设备私有 | **私有主题硬归属**(`iot_device.member_id` + `iot_share`),**不受 `acl_enabled` 开关影响** | | `/notify/{account}/…` | 单个会员私有 | 同上,仅 account 本人 | | `system/device/register/{mac}[/resp]` | 注册通道 | **恒定放行**(可匿名连接完成注册) | | `$SYS/…` | Broker 内置指标 | 只读 | ```text ┌──────────────── 服务端(Broker 钩子 + outbox 消费器)────────────────┐ 固件 ──publish──► │ state / status / event / ack / ota/report / register │ │ │ │ │ └─(业务处理)─► iot_device / iot_sensor_point │ │ iot_alert / iot_scene │ 固件 ◄─subscribe─ │ config / cmd / ota ◄── outbox ◄── SensorConfigService / App / 场景 │ App ◄─subscribe─ │ state / status / event / ack / ota/report / notify/{account}/# │ └─────────────────────────────────────────────────────────────────────┘ ``` --- ## 二、设备私有主题 `/devices/{uuid}/…`(8 条) | 主题 | 方向 | QoS | retain | 发布者 | 订阅者 | |---|---|---|---|---|---| | `state` | 上行 | 0 | **1** | 固件 | 服务端、App(含 `perm=1` 只读共享) | | `status` | 上行 | 0 | **1** | 固件(含 LWT) | 服务端、App | | `event` | 上行 | 0 | **1** | 固件 | 服务端、App | | `ack` | 上行 | 0 | 0 | 固件 | 服务端 | | `ota/report` | 上行 | 0 | 0 | 固件 | 服务端 | | `config` | 下行 | 1 | **1** | `SensorConfigService` | 固件 | | `cmd` | 下行 | 调用方指定(默认 0) | 0 | App `sendCommand` / 场景 `send_cmd` / 后台发布 | 固件 | | `ota` | 下行 | 1 | 0 | `OtaService::publish` | 固件 | **retain 的分工是刻意的**:四条 **retain=1** 的是「当前状态」(App/固件订阅瞬间就拿到全量现状,首屏不用轮询);四条 **retain=0** 的是「一次性动作/回执」(重连时不该收到过期的命令和旧回执)。 ### 2.1 `state` —— 影子(设备现状快照) 功能:设备的**唯一状态真源**。服务端写「影子」(retain 兜底),App 面板/曲线/骨架都由它驱动。 固件 v1.7.0 起(旧名见 §2.9): ```json { "ts": 1789000000, "ver": "1.7.0", "rssi": -57, "iv": 120, "cv": 7, "pos": [ { "t": 45.63, "df": 0.25, "pr": [ ["A3F2", 45.50], ["B71C", null] ] }, null ] } ``` | 字段 | 参数含义 | |---|---| | `ts` | NTP Unix 秒;`0` = 未对时(服务端用接收时间兜底) | | `ver` | 固件版本字符串。**判定版式不要看它**(只看键在不在) | | `rssi` | 信号强度 dBm(诊断用) | | `iv` | 本条覆盖秒数(旧名 `interval`)→ 曲线前向填充有效期 `[ts-iv, ts]` | | `cv` | 设备当前**生效**的配置版本(旧名 `cfg_ver`)。与本地不一致 → 重拉 `deviceConfig` | | `pos[]` | **数组下标 = 位置序号**(= 一条 OneWire 总线);`null` = 该位置未启用(占位撑下标) | | `pos[].t` | 位置温度(有效探头均值);`null` = 无有效读数(**显示 `--`,不是 0**) | | `pos[].df` | 位置内极差(max − min);`null` 同 `t` | | `pr[]` | 该总线各探头(`pr` = probes 明细);**下标 = 总线槽位**,尾部空槽已截 | | `pr[][0]` | 探头身份(ROM 短码,4 位 hex);`null` = 未识别 | | `pr[][1]` | 该槽温度;`null` = 无读数。`[null,24]` = 有读数无身份;`[null,null]` = 没插器件 | > ⚠️ **报文里没有位置名**。位置别名只来自 `deviceConfig` 接口(`SensorConfigService::format()`);App 靠下标/元素序号匹配骨架。 > ⚠️ **没有 `index`**:上行靠下标定位,未启用位置写 `null` 占位 —— **下标永不前移**。`pr` 同理,下标恒等于总线槽位,禁止 `filter` 掉 null 重编号。 > ⚠️ **没有 `pin`**(≤1.6.2 老固件仍会带):pin 是编译期常量,改由上线时 `/status` 报一次(见 §2.2)。 > ⚠️ `t` 是**同名陷阱**:≤1.5.0 的 `data[].t` 靠 `v` 标志判有效(`v=0` 时照样带 `0`),≥1.6.0 靠 `null` 判 ⇒ 解析必须**先判有没有 `v`**,否则把「没读数」存成 `0.00℃`。 上报节奏:采样 10s;触发上报 = 温变 ≥ 0.3℃ / 有效性掩码变化 / 告警翻转 / 距上次 ≥ 120s(另加开机、重连、收到新配置)。 服务端收到后:`DeviceLogService::log` → `SensorPointService::capture`(时序落库 `iot_sensor_point` + 云端阈值复核写 `alert_mask`)→ `SceneService::onMessagePublished`(场景条件评估)。 ### 2.2 `status` —— 在线态 + 引脚表 | 场景 | payload | |---|---| | 上线 | `{"online":1,"pins":[3,4,10]}`(v1.7.0+ 顺带报一次总线 GPIO 表) | | 掉线(LWT) | `{"online":0}` | 功能三合一: 1. **在线态真源**(App 在线/离线、后台在线数)。与 `event` 分主题就是为了避免 retained 互相覆盖。 2. **引脚表缓存**:`DeviceService::onStatusMessage()` 把 `pins` 写成 `iot_device.pins`(CSV),随 `deviceConfig` 的 `pins` 下发给 App。pin 只是**属性**(换板会变),位置序号才是身份。 3. **自动补位置 + 配置重发**:拿到 pins 后 `fillMissing()` 按下标补齐缺失位置(只补不覆盖、停用不复活、有新增才 `bumpVer`+push),无新增则 `repush()`(同版本重发,治「设备端被打瘫、两侧版本却对齐」)。 > ⚠️ 设备从未上报过(≤1.6.2 或从未上线)时 `pins` 为 `[]`,App 回退用 state 里的 `pin` 自补显示。 ### 2.3 `event` —— 告警 / 事件 **两种形态,任选其一**: ```jsonc // A. 结构化温度告警(alerts 下标 = 位置序号;未告警位置写 null 占位) { "type": "alert", "alerts": [ { "alias": "位置A", "t": 92.5, "df": 0.3, "rs": "ov", "pr": [ [0, "A3F2", 92.5] ] }, null ] } // B. 通用事件(任何品类可用,服务端零改动) { "title": "风扇已开启", "content": "手动触发 / 定时触发", "level": 2 } ``` | 字段 | 含义 | |---|---| | `alerts[].alias` | 告警发生时的**位置叫法快照**(保留写全,让文案能独立成立) | | `alerts[].t` / `.df` | 触发时的温度 / 极差 | | `alerts[].rs` | 原因**短码**:`ov` = 探头越限 / `df` = 位置内温差超限(服务端 `AlertService::REASON_TEXT` 展开中文,**改文案不用重烧固件**) | | `alerts[].pr[]` | 只列异常探头(稀疏)⇒ 每条是**三元组** `[槽位, 身份, 温度]`,槽位必须显式带 | | 通用分支 | `title` / `content`(缺省取 `msg` 或原文)/ `level`(默认 2) | 服务端:`AlertService::onDeviceEvent()` 逐条落 `iot_alert`(带 `pos_index`)+ 推送 `/notify/{account}/alert`。 ≤1.4.0 老版式(`bad[]` / `bad_ids[]`,无位置号 ⇒ 记 `POS_NA`=255)与 ≤1.6.2 的 `probes` 键名服务端仍认。 无告警时不发(`event` 是 retained,所以「无告警」靠不发布,而不是发空数组)。 ### 2.4 `ack` —— 回执 ```json { "type":"config_ack", "success":true, "message":"Saved 3 sensor(s)", "cv":7, "mac":"a0b1c2d3e4f5", "uuid":"A1B2C3D4E5F6" } ``` | 字段 | 含义 | |---|---| | `type` | 恒为 `config_ack`(**没有单独的 `cmd_ack`**,命令执行结果也复用本格式) | | `success` | 写入结果 | | `message` | 失败原因(如 `missing pos` —— 旧固件收到新格式版式会这样回,**可见、不静默**) | | `cv` | 写进 NVS 的配置版本,服务端据此记 `sync_ver` ⇒ `applied = (sync_ver >= cfg_ver)` | 服务端:`SensorConfigService::onAck()`,**只在 `type` 为空或 `config_ack` 时处理**。 > ⚠️ **缺 `type` 的回执会被当成 config_ack**(写 `sync_ver=0, sync_at=now`)。非数值品类的回执务必带自己的 `type`(如 `{"type":"sw_ack"}`),或者干脆不发 `/ack`。 ### 2.5 `cmd` —— 命令 / 控制(透明管道) ```json {"action":"report"} // 立刻上报一次 state {"action":"reboot"} // 重启(保留凭证/配置) {"action":"wifi_reset"} // 只清 WiFi 凭据 → 重启进配网页(凭证/配置保留) {"action":"clear_identity"} // 清 uuid/secret/配置 → 重启自动重注册(WiFi 保留) {"action":"factory_reset"} // 全清(含 WiFi)→ 配网页 ``` | 要点 | 说明 | |---|---| | 服务端**不校验 payload** | `Client::sendCommand` 任意 JSON 直接进 outbox;**指令词表由固件定义**(`onCmdJson`),服务端零改动 | | `action` 之外的字段 | 固件可自行扩展(如开关类 `{"action":"set","sw":[1,0]}`);未知 action 固件只打 `[CMD] unknown action=…`,不报错 | | QoS | 由调用方指定(`sendCommand` 的 `qos` 参数,默认 0) | | retain | 恒 0(一次性动作) | | 权限 | 需对该设备有**发布**权:主人 / `perm=2` 共享者。`perm=1` 连订阅 `cmd` 都被拒 | | 回执 | 清除类动作先 `ack` 再重启(`delay(500)` 等 ack 出网);`reboot` 无 ack | ### 2.6 `ota` / `ota/report` —— 固件升级 ```jsonc // 下行 /devices/{uuid}/ota(QoS1,retain=0) {"version":"1.2.4", "url":"https://.../fw.bin", "hash":"sha256(64hex)", "size":123456} // 固件实际只用 url + version,hash/size 被忽略 // 上行 /devices/{uuid}/ota/report(QoS0,retain=0) {"state":"downloading|installing|done|fail", "progress":42, "version":"1.2.4", "error":"http 404"} ``` 服务端 `OtaService::onReport()`: | `state` | 处理 | |---|---| | `done` | `iot_alert` level 1「固件升级完成」 | | `fail` | `iot_alert` level 3「固件升级失败」+ 错误原因 | | 其它 | 只推 `/notify/{account}/ota` 进度通知(`{"type":"progress",…}`),**不写告警表**(避免刷屏) | --- ## 三、注册主题(MAC 即插即用) | 主题 | 方向 | QoS | retain | payload | |---|---|---|---|---| | `system/device/register/{mac}` | 设备 → 服务端 | 0 | 1(固件传 `true`) | `{"mac":"a0b1c2d3e4f5","type":"esp32c3_temp","model":"KF-C3-3KANG","ver":"1.0"}` | | `system/device/register/{mac}/resp` | 服务端 → 设备 | 1 | 0 | `{"code":0,"message":"ok","data":{uuid,secret,topics,…}}` | 流程与铁律: 1. **必须先订阅 `resp` 再发布注册** —— 回执非 retain,错过即丢(固件 `mqttSubscribeAll()` 里对订阅结果做了检查,订阅失败就直接不发注册)。 2. `mac` 必须 12 位小写 hex(去冒号),且与主题段一致(不一致回 `payload mac 与主题不一致`)。 3. 首次注册回 `secret` 明文,之后幂等更新 `type/model/ver`,**不再给 secret**。 4. ACL 恒定放行,**可匿名连接**完成注册(此时还没有凭证)。 5. 旧协议兼容:`system/device/register/{sn}` + payload `{"token":"..."}` 或裸 token(走 `activate_token` 激活)。 6. 服务端处理:`Broker::handleDeviceRegister()` → `DeviceService::registerByMac()`。 --- ## 四、通知主题 `/notify/{account}/{子主题}`(QoS1,retain=1) | 子主题 | 触发场景 | payload 摘要 | |---|---|---| | `alert` | 云端告警(温度越限、设备离线、OTA 完成/失败…) | `{id,type:"alert",level:1..3,title,content,uuid,pos,time}` | | `event` | 设备 `/event` 上报 | `{id,type:"event",level,title,content,uuid,time}` | | `scene` | 场景 `notify` 动作 | `{id,type:"scene",level:2,title,content,uuid,time}`(`SceneService` 走 `AlertService::add($memberId,'scene',2,…)`,`title` 取动作的 `topic`、`content` 取 `payload_json`) | | `ota` | 升级**进度** | `{type:"progress",uuid,state,progress,version,time}` | - 子主题名就是 `AlertService::add()/push()` 的 `type` 参数值(`alert` / `event` / `scene` / `ota`),payload 里的 `type` 与它同值。 - `time` 均为 Unix 秒。 - retain=1 ⇒ App 冷启动订阅后立刻能收到最近一条(当作「有未读」的兜底提示;真正的未读数是 `alertUnread` 接口)。 - **App 只订阅 `/notify/{account}/#` 一条即可**。 - 该主题仅 account 本人可订(`/notify/{账号}/…`,服务端内部推送不经 ACL)。 --- ## 五、权限矩阵(谁能订什么 / 发什么) `Auth::checkPrivateTopic()` 的裁决(顺序在常规 ACL **之前**,与 `acl_enabled` 开关**无关**): | 身份 | `/devices/{uuid}/*` 权限 | |---|---| | 设备本体(`username=clientId=uuid` 且 `iot_auth` 密码已验) | 全权 | | 设备主人(`iot_device.member_id`) | 全权(含 `cmd`/`ota` 发布) | | 共享 `perm=2`(`ShareService::PERM_CTRL`) | 全权 | | 共享 `perm=1`(`ShareService::PERM_VIEW`) | **仅订阅** `state` `status` `event` `config` `ack` `ota/report`(`Auth::isReadonlyTail()`);**不能订 `cmd`/`ota`、不能发任何东西** | | 认证账号(`iot_auth`,非会员) | 默认拒绝;仅「ACL 规则显式点名该账号 allow」可通 | | 匿名 / 未知身份 | 一律拒绝 | 补充规则: - **跨设备通配符订阅**(`/devices/+/state`、`/devices/#`)默认拒绝 —— 单一归属表达不了,放行等于把全部设备暴露给一个人;要放行必须 ACL 规则**点名该账号**。 - 匹配的 ACL 过滤器必须**以 `/devices/` 或 `/notify/` 开头**,否则直接封堵(防 `#` 越权)。 - 服务端在每次 App 换 MQTT ticket 时自愈 ACL 快照(`mqttTicket` → `resyncMemberAcl` + `resyncMember`):自动生成的 4 条 `auto:member:{uuid}:*`(`state` 订 / `status` 订 / `cmd` 发 / `notify` 订)是**生成即快照**,新增规则不会自动补到老设备上,靠这次 resync 补齐。 - 私钥(`secret`)只走注册回执和后台设备管理,**不进任何主题**。 - ⛔ 订阅被拒时服务端**仍回 SUBACK**,结果码 `0x80`(3.1.1)/ `0x87`(5.0)⇒ App 必须处理 `onSubAck`,否则表现为「连上了但一片空白」。拒因定位:`Auth::lastDenyReason()`。 --- ## 六、服务端钩子分发映射(`Broker.php`) QoS0/QoS1 消息在 `handlePublish()` 末尾触发;QoS2 延迟到 `handlePubRel()`(逻辑两处**必须同步改**)。`$SYS` 前缀不触发任何钩子。 | 触发条件 | 调用 | |---|---| | 任意非 `$SYS` 主题 | `DeviceLogService::log($topic,$msg,'in')` | | 任意主题 | `SensorPointService::capture()`(内部自行判 `pos`,非 state 立即 return) | | 任意主题 | `DeviceService::onStatusMessage()`(内部判 `status`) | | 任意主题 | `SceneService::onMessagePublished()`(场景条件评估 → 动作入 outbox) | | `^/devices/([^/]+)/ota/report$` | `OtaService::onReport()` | | `^/devices/([^/]+)/event$` | `AlertService::onDeviceEvent()` | | `^/devices/([^/]+)/ack$` | `SensorConfigService::onAck()` | | `^system/device/register/([^/]+)$` | `Broker::handleDeviceRegister()` | 下行链路(**不经 Broker 直连**):业务代码 → `Store::enqueueManual(topic, payload, qos, retain, source)` 写 `iot_outbox` → outbox 消费器(0.5s)发布并投递。 | 入队方 | 主题 | qos / retain | |---|---|---| | `SensorConfigService::push()` | `config` | 1 / 1 | | `Client::sendCommand()`、场景 `send_cmd`、后台发布 | `cmd` | 调用方 / 0 | | `OtaService::publish()` | `ota` | 1 / 0 | | `AlertService::push()` | `/notify/{account}/{type}` | 1 / 1 | --- ## 七、字段/协议的硬约束(改代码前必读) 1. **上下行的唯一不对称:`pos[]` 的 `index`** - 上行 `state.pos[]`:**靠数组下标**定位序号(未启用写 `null` 占位,每位置省 ≈10B); - 下行 `config.pos[]`:**只含启用位置** ⇒ 下标塌缩 ⇒ **元素必须带 `index`**,固件不许用下标顶替。 - 两边不是「漏改」,是高频上行压字节换来的。 2. **`config` 是全量覆盖语义**:`pos` 里没有的位置一律停用。⚠️ 位置**全停用会下发 `pos:[]`**,固件对空数组 = 清空 NVS ⇒ 后台至少留一个启用(`push()` 对「真源为空」也会拒发)。 3. **改名要同步的清单**(少一处就静默失效): - 报文键名 → `SensorPointService::capture()`、`SceneService::dig()`/`upgradeStatePath()`、App `normalizePositions()`/`normalizeProbes()`、后台 `device.html`; - 位置序号键 `index` → `SensorConfigService::normalize()`、App `device-panel`、后台 `device.html`。 4. **协议升级顺序:先烧固件 ≥1.7.2,再上线服务端**。旧固件收新格式只回 ack `missing pos`(配置不生效但**不误报成功**,一眼可见)。 5. **同一语义只留一个键名**:历史上「`index` + `i` 双发」=一个字段挂两把钥匙,改一边就静默拆锁(2026-09-14 现场事故:ack 成功、位置全没启用)。兼容层已于 2026-09-15 全删。 6. 服务端/App 对**上行**一律「新键优先、旧键兜底」(`pos??positions??data`、`iv??interval`、`cv??cfg_ver`、`pr??probes`)—— **老固件不用重烧**。 --- ## 八、常见排查 | 现象 | 先看这里 | |---|---| | App「连上了但没有任何数据」 | `onSubAck` 是否处理 `0x80`;`Auth::lastDenyReason()`;订 `/notify/{自己账号}/test` 能成功而设备主题失败 = 降级成 `anonymous-named`(填了用户名但三种验证全没过,member_id=0) | | 设备在线但 `applied=false` | `ack` 是否收到(`sync_ver` 是否更新);`cv` 是否回对;retained `config` 是否真的发出去(outbox 卡住) | | 位置配置「老是自动变化」/ 影子全填位置 0 | `iot_sensor_cfg.update_at` 集中在上线时刻 = `fillMissing()` 正常行为;集中在用户操作时刻 = 保存路径读错键回写 | | 设备上报了但时序库没数据 | `capture()` 首行 `if (!is_array($rawPos) || !$rawPos) return;` —— 报文里没有 `pos`/`positions` 会被**静默跳过**(非数值品类属正常) | | 告警没进 `iot_alert` | `/event` 是否真的发(retain=1 的旧报文会被 Broker 缓存,注意别被历史 retained 误导);通用分支需要 `title`+`level` | | 重启后设备不再上报 | 硬删设备后设备端 NVS 仍留旧凭据 ⇒ CONNACK 4/5;救:BOOT 3s 重置 / 向 `…/resp` 发 `{"code":0,data:{uuid,secret}}` / 重烧勾 Erase | | 想给新品类加控制 | **不用改服务端**:`/cmd` 是透明管道,指令词表由固件定义;状态走 `/state`,事件走 `/event`(通用分支) | --- ## 九、待修 / 已知风险 1. 🔴 `/iot/api/publish`(`Mqtt::publish`,`noNeedLogin=['*']`)**无鉴权、无 topic 前缀校验** ⇒ 任何人可往任意主题发消息。带命名空间隔离的 `Client::publish` 未被路由绑定。上线前建议改路由或补鉴权。 2. `cmd` 与 `config` 的回执复用同一 `type=config_ack`、同一 `/ack` 主题 ⇒ 无法区分「命令执行结果」与「配置写入结果」(当前靠 `message` 文本区别)。 3. `Mqtt::publish` 失败时 `error()` 之后没有 `return`,会继续执行到 `success()`(响应内容自相矛盾)。 4. 固件头注释把上行 `state/status/event` 标为「QoS1」,实际 `PubSubClient::publish(topic,payload,retained)` 走 **QoS0**(第三个参数是 retain,不是 QoS);`subscribe` 才是 QoS1。文档以**实际行为**为准。 --- ## 十、变更记录 | 版本 | 变化 | |---|---| | v1.7.2(下行) | 下行键名与上行统一(`cv`/`pos`/`pn`);删除全部下行兼容层(`positions`/`data[]`/`cfg_ver`/`index`+`i` 双发);升级顺序要求「先烧固件」 | | v1.7.0(上行) | 短键 `iv`/`cv`/`pos`/`pr`;去掉顶层 `uuid` 与每位置 `pin`(`pin` 改由 `/status` 上线报一次);约 549B → 300B | | v1.6.1 | 探头身份 ROM 短码(`pr[][0]`) | | v1.6.0 | `positions[]` 结构化 + `event` 结构化告警(`alerts[]`/`rs` 短码) | | v1.5.0 | `positions[].index` 对齐(v1.6.0 起删除该键,改靠下标) | | ≤1.4.0 | `data[]` + 顶层 `interval`;有效性靠 `v` 标志 | > 更细的报文演进与压字节账目见 `iot_api_reference.md` §5.1。