# IoT 插件接口与 MQTT 主题参考 > 本文以**代码为准**(`addon/iot/route/app.php`、`controller/api/Client.php`、`controller/api/Mqtt.php`、`service/*.php`、`firmware/water_temp/water_temp.ino`)。 > 与 `device_register_mqtt.md` 冲突处,以本文为准(差异见文末「已发现的不一致」)。 --- ## 一、通用约定 ### 1.1 基址与鉴权 | 项 | 值 | |---|---| | App 接口前缀 | `https://{host}/iot/api/client/*`(接入层:绑定/共享/凭证/命令);时序·配置·告警在 `https://{host}/iot/api/monitor/*`(见 §2.4,旧 `client/*` 路径仍作兼容别名) | | 设备 / 公开接口 | `https://{host}/iot/api/{deviceActivate,status,publish}` | | 后台接口前缀 | `https://{host}/iot/backend/*` | | App 鉴权 | Header `Authorization: Bearer {会员登录 token}`(框架 JWT,`ApiBase` 统一拦截,未登录 HTTP 401) | | 后台鉴权 | `verifyAuth()` + `tokenParse()`,Bearer JWT | | 免登录白名单 | 仅 `Client::deviceActivate`;`Mqtt` 控制器整个 `noNeedLogin = ['*']` | ### 1.2 响应格式(⚠️ 有两套,字段名不同) **A. `Client.php`(App 全部接口)→ `apiSuccess()` / `apiError()`** ```json { "code": 0, "msg": "success", "data": {}, "timestamp": 1789000000 } ``` - `code=0` 成功 / `1` 业务失败 / `401` 未登录(HTTP 同为 401) - 字段名是 **`msg`**,不是 `message` - 携带 token 时附加 `tokens.access_token` / `tokens.access_expired` / `tokens.refresh_token` **B. `Mqtt.php`(`status` / `publish`)→ `success()` / `error()`** ```json { "code": 0, "message": "success", "data": {}, "timestamp": 1789000000, "count": 12 } ``` - 字段名是 **`message`**,分页字段是 `count` - 发布失败时 `error()` 之后**仍会继续往下执行**(`Mqtt::publish` 未 return),属已知小 bug > ⚠️ `$this->result->setCount(n)->apiSuccess($list)` 这个组合**不会输出 `total`**:`apiSuccess()` 只认第三个参数 `$count`,而 `setCount()` 设的是 `$this->Total`(只有 HTML 版 `success()` 会用)。 > ⇒ App 端不要依赖这些接口的 `total`,条数从 `data` 数组长度取。 --- ## 二、App / 会员 REST API(前缀 `/iot/api/client/`;时序·配置·告警 `/iot/api/monitor/*`) ### 2.1 设备发现与绑定 | 方法 | 路径 | 参数 | 说明 | |---|---|---|---| | GET | `devices` | — | 我的 MQTT 连接记录(`iot_connection`,按 `username = 会员账号` 查,上限 200 条) | | GET | `subscriptions` | — | 我的订阅主题列表,`data:{list,total}` | | GET | `myDevices` | — | **我的设备**(主人 + 被共享,含 `role`、在线态、影子、**内联位置配置** `sensors/cfg_ver/sync_ver/applied`;已剔除 secret) | | POST | `bindDevice` | `sn`(必填)、`pair_code`(设备设了配对码则必填)、`title`、`model`、`room` | 绑定;已激活设备不重置凭证;未激活且无配对码时绑定即激活 | | POST | `unbindDevice` | `uuid`(必填) | 解绑,**会重置设备密钥** | | POST | `shareDevice` | `device_id`、`account`(对方会员账号)、`perm`(1 只读 / 2 可控,默认 2) | 仅主人可操作 | | POST | `revokeShare` | `device_id`、`member_id` | 撤销共享 | | GET | `shareList` | `device_id` | 共享成员列表(仅主人) | | POST | `resetSecret` | `uuid` | 重置密钥,新明文仅返回一次 | > **三个标识符别混用**(各家接口用的不是一个东西): > > | 标识符 | 是什么 | 哪些接口用 | > |---|---|---| > | `sn` | `iot_device.sn` 列,**设备序列号** | `bindDevice`、`deviceActivate` | > | `uuid` | `genUuid()` 生成,形如 `dev_` + 16 位 hex,设备 MQTT 连接用户名 | `unbindDevice`、`deviceState`、`deviceConfig`、`saveDeviceConfig`、`sendCommand`、`deviceLogs`、`publishOta`、所有 `/devices/{uuid}/…` 主题 | > | `device_id` | `iot_device.id`(自增整数) | `shareDevice`、`revokeShare`、`groupAddDevice`、`groupRemoveDevice` | > > **`sn` 是不是就是 mac?** 看设备怎么入的库: > - **即插即用注册**(当前固件走的路径,`registerByMac()`):插入时 `'sn' => $mac, 'mac' => $mac` → **`sn` 就等于纯 mac**(12 位小写 hex、去冒号)。 > - **后台预登记**(`register()`):`sn` 是手填的序列号(≤64 字符任意),真 MAC 存在独立的 `mac` 列 → 此时**必须传 `sn`**。 > > ⚠️ `bind()` 只按 `sn` 列精确查,**没有 `mac` 兜底**(`register()` / `registerByMac()` 有 `where('mac', …)` 兜底)。 > ⚠️ 更危险:`bind()` 查不到时**直接新建一台设备**(`sn` 填你传的值、`member_id` 填你)——sn 传错不报错,会静默建出「空壳设备」,而真设备仍是 `member_id=0`(看起来绑定成功,实际设备永远不在线)。 > > ⚠️ `myDevices` 的返回:主人侧是**整行减 `secret`**(含 `pair_code`、`activate_token`、`member_id`、`cfg_ver` 等);共享侧(`sharedToMe()`)是 12 字段白名单(`sn/uuid/title/model/room/role/perm/online/shadow/topics/…`,**无 `dev_type`/`ver`/`mac`**)→ App 统一渲染时要按缺失字段兜底。 > > ✅ **列表已内联位置配置**(`DeviceService::attachSensorConfig()`,2026-09-14 起):每台设备(**含共享**)都带 > `sensors:[{index,alias,probes,upper,lower,gap,enabled}]` 与 `cfg_ver/sync_ver/applied`。字段与 > `deviceConfig` 接口**完全一致**(同一 `SensorConfigService::format()` 出口,见 `getAll()`) > ⇒ App 拉一次设备列表即可画骨架(离线/首屏可用、骨架屏好做),省掉 `deviceConfig` 那次往返; > 旧服务端不返回该字段(`sensors` 缺失或为空)时,App 回退到 `deviceConfig` 即可,新旧服务端双向兼容。 > > ⚠️ 列表里的 `pins` 是 `iot_device.pins` 的 **CSV 字符串**(`"3,4,10"`),与 `deviceConfig` 下发的 > `pins`(数组)**不同型** —— 别把列表里的 `pins` 直接当数组用(App 端 `applyPins()` 只从 > `deviceConfig`/`/status` 取,就是为避开这个差异)。 > > ⚠️ 实现固定 **2 条 SQL**(`whereIn(uuid)` 批量后按 uuid 分组),**不要**改成在循环里逐台 > `SensorConfigService::get()` —— 那是 N+1,设备一多就把列表接口拖垮。 ### 2.2 连接凭证与指令 | 方法 | 路径 | 参数 | 返回 | |---|---|---|---| | POST | `mqttTicket` | — | `{ticket(48 位 hex), account, expire_in:60, expires_at}`;顺带自愈 ACL(`resyncMemberAcl` + `resyncMember`) | | POST | `sendCommand` | `uuid`、`payload`(JSON 字符串)、`qos`(0/1/2,默认 0) | 写入 `/devices/{uuid}/cmd`,`retain=0` | | GET | `deviceState` | `uuid` | `{uuid, online, shadow}`(shadow = 最后一条 retained `state`) | ### 2.3 传感器配置 | 方法 | 路径 | 参数 | 返回 | |---|---|---|---| | GET | `deviceConfig` | `uuid` | `{cfg_ver, sync_ver, sync_at, applied, pins:[3,4,10], sensors:[{index,alias,probes,upper,lower,gap,enabled}], uuid}` | | POST | `saveDeviceConfig` | `uuid`、`sensors`(数组或 JSON 字符串) | **全量覆盖**语义,保存即 retained 下发 | > > 🔑 **位置序号键统一为 `index`**(2026-09-15):`sensors[]` 元素 = `{index,alias,probes,upper,lower,gap,enabled}`。 > 服务端 `SensorConfigService::normalize()` 同时兼容旧键 `i` 与列名 `pos_index`(老 App 提交不会错位), > 但**新代码一律只发 `index`**。 > ⚠️ 改这个键名必须同步三处消费方:`SensorPointService::capture()`(按位置取阈值复核)、App `device-panel`、 > 后台 `view/backend/admin/device.html` —— 少改一处,位置序号会**静默变 `0`**(不报错): > 多个位置挤到同一序号,影子数据全填到位置 0,且保存时被「全量覆盖 + 同序号后写覆盖」合并成**一个位置**。 > ⚠️ 别与**下行 config 报文里的序号键**混为一谈:报文里它同样叫 `index`,而且**只有这一个键** > (v1.7.0 的「index+i 双发」已于 2026-09-15 废除)。唯 `data[]`(≤1.4.0 老固件专用副本)仍用旧键 `i`, > 改它等于让老设备一个位置都不启用(见 §5.2)。 - `applied = (sync_ver >= cfg_ver)`;`false` 表示设备尚未回 `config_ack`(离线,上线即生效) - `saveDeviceConfig` 需 `PERM_CTRL`(只读共享会报「无权限修改该设备配置」) - `pins`:**总线 GPIO 表,下标 = 位置序号**(对应 `iot_device.pins`)。取自设备上线时 `status` 报文里的 `pins`(v1.7.0+),服务端缓存后下发;设备从没上报过(≤1.6.2 老固件) 或从没上线过则为 `[]` —— 此时 App 靠 state 报文里的 `pin` 自行补齐显示 ### 2.4 实时曲线与时序聚合(前缀 `/iot/api/monitor/*`) > ⚠️ **三个接口返回结构互不相同**,这是 App 端最容易踩的坑:`sensorSeries` 回**原始稀疏点**, > `sensorAvg` / `recentAvg` 回**聚合值**。拿 series 的键(`temp`/`interval`/`temp_diff`/`probes`/`alert_mask`) > 去读聚合返回 ⇒ 键不存在,曲线**全是 `0` 和 `[]`**,而数据库里明明有数据(`ts` 两边都有,所以时间轴是对的)。 > 症状口诀:**「时间点对、温度全 0」= 读错键,不是没数据。** | 方法 | 路径 | 参数 | 返回 `data` | |---|---|---|---| | GET | `sensorSeries` | `uuid`、`pos=0`、`from`、`to`、`limit=500`(钳 [1,2000])、`debug=1?` | `{uuid,pos,from,to,count,points}` | | GET | `sensorAvg` | `uuid`、`pos=0`、`step=60`(钳 [60,86400])、`window=30`、`from`、`to`、`limit=720`、`debug=1?` | `{uuid,pos,step,from,to,count,points}` | | GET | `recentAvg` | `uuid`、`window=10`(钳 [1,1440] 分钟)、`pos`(默认 `-1` = 全部位置) | `{uuid,window_min,from,to,samples_total,positions}` | **① `sensorSeries` → `points[]`:原始稀疏点(细粒度,逐条上报)** ```json { "ts": 1789700400, "interval": 120, "temp": 26.5, "temp_diff": 0.2, "probes": [ { "id": "A3F2", "temp": 26.5 } ], "alert_mask": 0 } ``` - `interval` = 本条覆盖秒数(曲线有效期 `[ts, ts+interval]`);`temp=null` = 该位置本刻**无读数**(不是 0℃) - `probes[].id` = 探头 ROM 短码(未识别为 `null`);`alert_mask`:bit j = 槽位 j 越限、bit7 = 位置内温差超限 - 服务端**不展开**等间隔序列(温变上报 10~120s 一条,展开纯浪费带宽)⇒ 平滑曲线由客户端前向填充 **② `sensorAvg` → `points[]`:按 `step` 分桶(画趋势用这个)** ```json { "ts": 1789700400, "avg": 26.54, "min": 26.5, "max": 26.56, "samples": 86, "coverage_s": 1800 } ``` - 温度读 **`avg`**(时间加权 `SUM(t×秒)/SUM(秒)`,温变期比简单 AVG 准)—— **没有 `temp` 键** - `ts` = 桶起点(UTC 对齐,App 按本地时区格式化);`coverage_s < step` = 桶内有断档,可画「数据缺口」 - 「昨天 11:00 → 现在,每 30 分钟一点」:`step=1800&from=1789614000&limit=2000`(不传 `from` 时用 `window=N` 分钟) - **只回有数据的桶**,空档不补点(服务端不铺骨架、不补 0);想要连续骨架由客户端按 `step` 自己铺 **③ `recentAvg` → `positions[]`:每位置一条汇总值(首屏/排障用这个)** ```json { "index": 1, "alias": "位置B", "avg": 39.6, "min": 31.81, "max": 46.19, "samples": 57, "coverage_s": 3480, "last_ts": 1789699572, "data_ok": true } ``` - 无样本时 `avg=null` 且 `data_ok=false`(前端显示「无数据」,**不要显示 0℃**) **排障:`debug=1`(`sensorSeries` / `sensorAvg` 都支持)** 附加 `data.debug`:`database / table / sql / server_ts / uuid / pos / from / to / rows_uuid / rows_pos / rows_range / min_ts / max_ts / pos_min_ts / pos_max_ts / recent[3] / error` —— 用于定位「直接跑 SQL 有数据、接口却空 / 全 0」: - `database`、`table` 与 SQL 客户端不一致 ⇒ 接口连错库 / 表前缀不对 - `rows_uuid=0` ⇒ 这个库里没有这台设备(你查的是另一个库/另一个站点) - `rows_uuid>0` 但 `rows_range=0` ⇒ 时间条件不成立或时钟偏移(比 `server_ts` 与 DB 的 `UNIX_TIMESTAMP()`,并确认 `ts` 单位是秒不是毫秒) - `error` 非空 ⇒ 原来被 `catch` 静默吞掉的异常原文(同时落 `runtime/log`:`[iot] sensorSeries/sensorAvg 查询失败`) ### 2.5 日志与告警 | 方法 | 路径 | 参数 | |---|---|---| | GET | `deviceLogs` | `uuid`、`page=1`、`size=20`(上限 100)、`direction=in\|out`(空 = 全部) | | GET | `alertList` | `page`、`size`(上限 100)、`unread=0\|1` | | POST | `readAlert` | `ids[]`(**不传或空数组 = 全部标已读**),返回 `{affected}` | | GET | `alertUnread` | — → `{unread}` | ### 2.6 场景联动 | 方法 | 路径 | 参数 | |---|---|---| | GET | `sceneList` | — | | GET | `sceneDetail` | `id` | | POST | `saveScene` | `id?`、`title`、`icon`、`enabled`、`triggers`(JSON 串)、`actions`(JSON 串) | | POST | `deleteScene` | `id` | | POST | `toggleScene` | `id`、`enabled`(0/1) | | POST | `runScene` | `id`(手动触发) | | GET | `sceneLogs` | `id`、`page`、`size` | ```jsonc // triggers[] { "type": "device_state|schedule|manual", "topic": "/devices/+/state", "condition_json": [{"field": "positions.0.temp", "op": "gt", "value": 60, "ref": "..."}], "cron": "0 8 * * *", "enabled": 1 } // actions[] { "type": "send_cmd|http|notify", "device_uuid": "...", "payload_json": "...", "topic": "..." } ``` ### 2.7 设备分组 | 方法 | 路径 | 参数 | |---|---|---| | GET | `groupList` | — (含设备数 / 在线数) | | POST | `saveGroup` | `id?`、`title`、`type`(room/custom)、`sort?` | | POST | `deleteGroup` | `id` | | POST | `groupAddDevice` | `group_id`、`device_id` | | POST | `groupRemoveDevice` | `group_id`、`device_id` | | GET | `groupDevices` | `group_id` | ### 2.8 OTA | 方法 | 路径 | 参数 | 说明 | |---|---|---|---| | GET | `otaList` | `uuid?`(不传 = 全部设备) | 返回 `{list:[{device, ota\|null}]}`,按 `model` 取最新启用固件 | | POST | `publishOta` | `ota_id`、`uuid` | 需 `PERM_CTRL` | --- ## 三、设备侧 / 运维 API(公开) | 方法 | 路径 | 参数 | 说明 | |---|---|---|---| | POST | `/iot/api/deviceActivate` | `sn`、`token` | 入云激活(免登录),幂等:已激活不再下发 secret | | GET | `/iot/api/status` | — | `{online, total, messages, topics}` | | POST | `/iot/api/publish` | `topic`、`payload`、`qos` | **免登录、无命名空间校验**,直接进出站队列 | > ⚠️ **安全提醒**:路由 `api/publish` 指向的是 `Mqtt::publish`(`noNeedLogin=['*']`),它**没有** `topic` 前缀校验; > 而带 `{account}/` 命名空间隔离的那版 `Client::publish` **未被路由绑定**。 > ⇒ 该接口目前任何人可往任意主题发消息。上线前建议改为只走 `client/publish` 或补鉴权。 --- ## 四、MQTT 连接参数 | 项 | 值 | |---|---| | TCP | `mqtt://{host}:1883`(MQTT 3.1.1 / 5.0) | | WebSocket | `ws://{host}:8083`(**无子路径、二进制帧**);iOS 线上必须 `wss://` | | 端口来源 | 后台「服务设置」`/iot/backend/setting` 的 `tcp_port` / `ws_port` | | 设备 clientId / username | 均为 `uuid`;password = `secret` 明文(库中存 bcrypt) | | 注册阶段 clientId | 建议直接用 `mac`(未激活无凭证) | | App clientId | 建议 `app-{account}-{随机串}`,避免与设备 `uuid` 撞名 | ### 认证三选一 | 方式 | username | password | 前提 | |---|---|---|---| | 认证账号 | 后台「认证」表账号 | 对应密码 | `iot_auth` 启用 | | 会员账号 + 登录密码 | `member_user.account` | 会员登录密码(bcrypt 校验) | 后台开 `allow_member_login` | | **一次性 ticket(推荐)** | `account`(`mqttTicket` 返回) | `ticket` | 每次连接 / 重连前重新换,TTL 60s | - Clean Session 建议开启;重连不依赖持久会话,靠 retained 兜底 - 订阅被拒时服务端**仍回 SUBACK**,结果码 `0x80`(3.1.1)/ `0x87`(5.0)→ App 必须处理 `onSubAck` --- ## 五、设备私有主题 `/devices/{uuid}/…` > 📄 **主题速查(功能 / 参数 / 谁发谁订 / 权限矩阵 / 排错)见 `iot_topics.md`** —— 本节只保留报文版式与历史沿革细节。 | 主题 | 方向 | QoS | retain | payload | |---|---|---|---|---| | `state` | 设备 → 服务端 | 0 | **1**(影子) | 见 5.1 | | `status` | 设备 → 服务端 | 0 | **1** | 上线 `{"online":1,"pins":[3,4,10]}`(v1.7.0+ 顺带报一次总线 GPIO 表);LWT 固定 `{"online":0}` | | `config` | 服务端 → 设备 | 1 | **1** | 见 5.2 | | `ack` | 设备 → 服务端 | 0 | 0 | `config_ack` 回执,见 5.3 | | `cmd` | 服务端 / App → 设备 | 请求决定(默认 0) | 0 | `{"action":"..."}`,见 5.4 | | `ota` | 服务端 → 设备 | 1 | 0 | 见 5.5 | | `ota/report` | 设备 → 服务端 | 0 | 0 | 见 5.5 | | `event` | 设备 → 服务端 | 0 | **1** | 见 5.6 | ### 5.1 `state`(retained 影子) 固件 **v1.7.0** 起键名压到 2 字母,并去掉两处冗余(顶层 `uuid`、每位置 `pin`); 结构沿用 **v1.6.0** 的**紧凑版式** (全长报文 ≈533B → ≈370B(v1.6.0)→ ≈349B(v1.7.0 键名)→ ≈300B(v1.7.0 去冗余)): ```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 ] } ``` > **两处冗余为什么能去掉** > ① `uuid`:报文本来就发布在 `/devices/{uuid}/state` 上,「谁发的」主题里已经写着; > 服务端 `SensorPointService::capture()` 本来就从主题正则取 uuid,App 订阅时也早知道 ⇒ 省 ≈25B。 > ② `pin`:它是**编译期常量** `ONEWIRE_PINS[下标]`,设备这辈子都不会变。改成设备**上线时** > 在 `status` 报文里报一次(见上表),服务端缓存进 `iot_device.pins`、随 `deviceConfig` > 下发 ⇒ 人眼对接线端子的信息一点没少,每启用位置省 ≈8B。 > ⚠️ **已烧录的 ≤1.6.2 固件仍会带这两个键**,服务端/App 一律「有则用、无则退」, > 所以**不需要重烧、也不需要灰度窗口**。 > **键名沿革**(服务端 / App 一律「新键优先、旧键兜底」,**老固件不用重烧**): > `iv` ← `interval`(≤1.5.0 也曾叫 `iv`)、`pos` ← `positions`、`pr` ← `probes`、`cv` ← `cfg_ver`。 > 只改键名,结构与语义一字未动。 | 字段 | 含义 | |---|---| | `pos`(旧名 `positions`) | ⚠️ **数组下标就是位置序号**(0 起,= OneWire 总线 A/B/C),**已没有 `index` 字段**;`null` = 该位置未启用(尾部占位会截掉,**中间的不截** —— 截了下标就前移) | | ~~`pin`~~ | ⚠️ **v1.7.0 起 state 不再带此键**(改由设备上线时在 `status` 报一次 → 服务端缓存 → 经 `deviceConfig` 的 `pins` 下发)。它本来就是**纯属性、不是身份**:换板/改脚时它变、位置序号不变,所以别名/阈值/历史/告警都还能对上。≤1.6.2 老固件仍会带,解析照旧 | | `t` | 该位置温度(有效探头均值);`null` = 无有效读数(`null` 显示 `--`,别显示 0) | | `df` | 位置内极差(max − min);`null` 同 `t` | | `pr[]`(旧名 `probes`) | 该总线各探头;**下标 == 总线槽位**,尾部空槽已截(空总线 `[]`)。元素是**二元组 `[身份, 温度]`** | | `pr[][0]` | 探头身份(ROM 短码);`null` = 未识别。**ROM 枚举到就上报,与读数成败无关** | | `pr[][1]` | 该槽温度;`null` = 无读数。配身份可区分「没插」与「插了读不到」 | | `ts` | NTP Unix 秒,`0` = 未对时 | | `iv`(旧名 `interval`) | 本条覆盖秒数(前向填充有效期 `[ts-iv, ts]`)。≤1.5.0 的固件也叫 `iv` | | `cv`(旧名 `cfg_ver`) | 设备当前生效配置版本;与本地不一致就重拉 `deviceConfig`(HTTP 字段名仍叫 `cfg_ver`) | > **压字节的账(v1.7.0 已做全)**:`pos`/`pr` 的字节数随「位置数 × 探头数」线性增长,是重点; > `iv`/`cv` 每条只出现一次、收益最小,但既然这份报文的定位就是「压到最小」,索性一次压到底: > ① 键名 `interval/positions/probes/cfg_ver` → `iv/pos/pr/cv`;② 去掉顶层 `uuid`; > ③ 去掉每位置的 `pin`(改为上线时在 `status` 报一次)。 > 累计 549B → 300B(3 位置 2 探头),**其中高频部分压掉约 45%**。 > **探头身份(`probes[][0]`)怎么来的**(v1.6.1):DS18B20 的 64bit ROM = `[0]` 家族码(0x28) > + `[1..6]` 48bit 序列号 + `[7]` CRC8,身份无信息量的两端不参与。短码取**序列号末 2 字节** > (`addr[5..6]`)→ 4 位十六进制;若末 2 字节为 `0000`(克隆/翻新片常见:序列号高位恒 0), > 依次退让到中 2 字节、前 2 字节,取第一个非 0 的 —— 这类片才有身份。 > **只有 6 字节序列号全 0** 才发 `null`:那种片各片 ROM 完全相同,任何切片都分不开, > 报文上只能按槽位区分(固件串口会明确提示)。 > 🚨 **身份是 `null` 但温度有值(`[null, 24]`)= 取不到 ROM 短码,不是读数失败**: > `[null, null]` 才是「该槽没插器件」。三种成因按概率排: > ① 固件 **< v1.6.1** 且上电瞬间 `getAddress()` 失败过一次 —— 旧代码照样把计数记成枚举数, > 「数量变了才重读 ROM」永久为假 ⇒ 身份永久 `null`(**温度正常、重启也不恢复**,v1.6.1 已修); > ② 该片序列号 6 字节全 0(无唯一身份,只能按槽位区分);③ 该槽真的没插(此时温度也 `null`)。 > **更早的三个版式**(服务端与 App 一律按「键在不在」判版式,**不比较版本号字符串**): > - **v1.6.0**:`positions[].{pin,t,df,probes[[id,temp]]}` —— 与 v1.7.0 完全同构,只是键名写全。 > - **v1.5.0**:`positions[].{index,pin,temp,temp_diff,probes[{id,temp}]}` —— 键名写全、按 `index` 对齐。 > 与 v1.6.0+ 靠**元素里有没有 `index`** 区分(v1.5 每个位置都写,v1.6 起彻底删掉)。 > - **≤1.4.0**:`data[].{i,t,df,v,vs[],ok[],ids[]}` + 顶层 `iv`。 > ⚠️ 旧版的 `t` 与 v1.6.0+ 的 `t` **同键名但语义不同**:旧版靠 `v` 标志判有效(`v=0` 时 `t` 照样带 > `0`),新式靠 `null` 判 —— 解析必须**先判有没有 `v`**,否则会把「没读数」存成 `0.00℃`。 > - 解析实现:服务端 `SensorPointService::capture()`;App `normalizePositions()` / `normalizeProbes()`; > 场景条件路径 `SceneService::upgradeStatePath()` —— 它把 `data.` / `positions.` / `probes.` / > `interval.` 这些历史词汇自动收敛到 `pos.` / `pr.` / `iv.`。⚠️ **改报文键名时必须同步这里**, > 否则库里已存的场景条件会「取不到值 → 恒不成立」,且**不报错**(静默失效)。 **上报节奏**:采样 10s;触发上报 = 温变 ≥ 0.3℃ / 有效性掩码变化 / 告警翻转 / 距上次 ≥ 120s(另加开机、重连、收到新配置)。 ### 5.2 `config`(服务端下发,retained,QoS1) ```json { "cv": 7, "pos": [ {"index":0, "alias":"位置A", "pn":3, "upper":85, "lower":0, "gap":2} ] } ``` 全量覆盖语义:`pos` 里没有的位置一律停用。设备写入 NVS 后回 ack。 `pos[]` **只含启用位置**(服务端 `get(uuid, true)`),所以元素里的 `index` 是必需的(见下)。 ⚠️ 旧固件(≤1.7.1)收到本格式只回 ack `missing pos`:配置不生效,但**不会误报成功**,一眼可见。 > 🔑 **上下行同键名**(v1.7.2 起):下行 `cv` / `pos` / `pn` 与 §5.1 的 state 完全一致,抄报文不用换脑子。 > v1.7.0 曾为「已烧录的老固件不重烧也能收配置」叠了三层兼容:下行写长名 `positions`/`probes`、 > 复制一份旧式 `data[]`、标量新旧同发(`cfg_ver`/`probes` 与 `cv`/`pn` 同值双发)—— > 2026-09-15 **全部删除**。理由:现场只有两台自用设备、都会重烧固件,兼容收益为 0,而它 > 每多一个「同义键」就多一把备用钥匙:服务端读 `index`、固件读 `i`,改键名时只改一边就 > **静默拆锁**(当天现场事故:位置序号全变 0、数据全填位置 0、编辑一行改掉所有行)。 > 旧的「宁可多发 6B 标量也不复制数组」是为了绕开已烧录设备的 1024B ArduinoJson 解析池; > 现在没有老设备,这个约束也就没有必要了。 > · ⚠️ `probes` 在**上行是探头明细数组、下行原是探头数(数字)** —— 下行改名 `pn` 后两义消失。 > 🔑 **下行必须有 `index`,上行反而没有** —— 这是唯一一处上下行"不对称",**不是漏改**: > · 上行 state 的 `pos[]` 靠**数组下标**定位序号:未启用位置写 `null` 占位把下标撑住, > 每个位置省 10B,而它是每 10s~120s 一条的高频报文,字节值钱; > · 下行 `pos[]` **只含启用位置** ⇒ 下标会塌缩(位置 0 停用时,位置 2 排在报文下标 0 上) > ⇒ 元素里必须带 `index`,固件也**不能用下标顶替**(见固件 `onConfigJson` 注释)。 > 🔴 升级顺序:**先烧固件 ≥1.7.2,再上线本改动。** ### 5.3 `ack`(设备 → 服务端,非 retained) ```json { "type":"config_ack", "success":true, "message":"...", "cv":7, "mac":"a0b1c2d3e4f5", "uuid":"A1B2C3D4E5F6" } ``` `cv` 是唯一的版本键(v1.7.2 起;≤1.6.2 老固件回 `cfg_ver`,服务端已不再兜底)。 服务端 `SensorConfigService::onAck()` 据此记 `sync_ver`。 ### 5.4 `cmd`(下发命令) ```json {"action": "report"} // 立刻上报一次 state {"action": "reboot"} // 重启(保留凭证) {"action": "wifi_reset"} // 只清 WiFi 凭据 → 重启进配网页;凭证/配置保留 {"action": "clear_identity"} // 清 uuid/secret/配置 → 重启自动重注册;WiFi 保留(服务器写死在固件) {"action": "factory_reset"} // 全清(含 WiFi)→ 进配网页 ``` - 三个清除类动作设备会先回 `ack` 再重启(`delay(500)` 等 ack 出网) - 未知 `action` 只打日志 `[CMD] unknown action=...`,不报错 - 前提:调用方对 `cmd` 有**发布权限**(主人 或 `perm=2` 共享者) ### 5.5 `ota` / `ota/report` ```json // 下行 /devices/{uuid}/ota(固件实际只用 url + version,hash/size 被忽略) {"version":"1.2.4", "url":"https://.../fw.bin", "hash":"sha256(64hex)", "size":123456} // 上行 /devices/{uuid}/ota/report {"state":"downloading|installing|done|fail", "progress":42, "version":"1.2.4", "error":"http 404"} ``` `done` → 写 level 1 告警;`fail` → level 3 告警;其它 → `push` 进度通知(不写告警表)。 ### 5.6 `event`(retained 告警) 固件 **v1.6.0** 起为结构化告警(**v1.7.0** 起键名 `probes` → `pr`): ```json { "type": "alert", "alerts": [ { "alias": "位置A", "t": 92.5, "df": 0.3, "rs": "ov", "pr": [ [0, "A3F2", 92.5] ] }, // 旧名 probes(≤1.6.2),服务端两版都认 null ] } ``` - `alerts` **数组下标 = 位置序号**(未告警的位置写 `null` 占位,尾部占位截掉) - `rs` 是原因**短码**:`ov` = 探头越限 / `df` = 位置内温差超限。服务端 `AlertService::REASON_TEXT` 统一展开成中文 —— **改文案不用重烧固件**。 (v1.5.0 发的是中文字符串 `reason`,一条约 24B;≤1.4.0 发 `overunder` / `gap`) - `alias` **保留写全**:它是「当时的叫法」的快照,告警文案要能独立成立,不依赖事后查配置 - `probes[]` 只列异常探头(稀疏),故每条是三元组 `[槽位, 身份, 温度]` —— 槽位必须显式带,不能靠下标推 - 旧版对应字段:`bad[]`(槽位下标)+ `bad_ids[]`(探头身份,与 `bad[]` 一一对应); 且 ≤1.4.0 的 `alerts` 里根本没有位置号 → 服务端只能记 `POS_NA`(255) - `AlertService::onDeviceEvent()` 三版都认,逐位置落 `iot_alert`(含 `pos_index`) 采样后判定:新出现告警立即发,持续告警按 `ALERT_REPEAT_MS` 复报。 --- ## 六、注册主题与通知主题 ### 6.1 注册(MAC 即插即用) | 主题 | 方向 | payload | |---|---|---| | `system/device/register/{mac}` | 设备 → 服务端 | `{"mac":"a0b1c2d3e4f5","type":"esp32c3_temp","model":"KF-C3-3KANG","ver":"1.0"}` | | `system/device/register/{mac}/resp` | 服务端 → 设备 | `{"code":0,"message":"ok","data":{...}}` | - **必须先订阅 resp 再 publish 注册**,否则错过回执(回执 QoS1、**非 retained**) - mac 必须 12 位小写 hex(去冒号),且与主题段一致 - 首次注册回 `secret` 明文,之后幂等更新 `type/model/ver` 不再给 secret - ACL:这两个主题**恒定放行**,不依赖 ACL 开关,可匿名连接完成注册 - 旧协议兼容:`system/device/register/{sn}` + payload `{"token":"..."}` 或裸 token ### 6.2 通知 `/notify/{account}/{子主题}`(QoS1,retain=1) | 子主题 | 触发 | payload 摘要 | |---|---|---| | `alert` | 设备离线 / OTA 完成失败 | `{"id","type":"alert","level":1..3,"title","content","uuid","time"}` | | `event` | 设备 `/event` 上报 | 同上,`type=event` | | `scene` | 场景 `notify` 动作 | `{"id","type":"scene","level":2,"title","content","uuid","time"}`(`SceneService` → `AlertService::add($memberId,'scene',2,title,content)`;原先此处写的 `scene_id`/`message` 字段代码里并不存在,2026-09-15 校正) | | `ota` | 升级进度 | `{"type":"progress","uuid","state","progress","version","time"}` | | `ota` | 升级完成 / 失败 | `{"type":"alert","uuid","level","title","content","time"}` | `time` 均为 Unix 秒。App 统一订阅 `/notify/{account}/#` 即可。 --- ## 七、服务端消息流(Broker 钩子) ```text 设备 publish /devices/{uuid}/{state,event,ota/report,ack} 或 system/device/register/{mac} → Broker(QoS0/1 立即分发;QoS2 在 PUBREL) ├─ DeviceLogService::log 入库留痕(仅 /devices/*) ├─ SceneService::onMessagePublished 场景条件评估 → 动作入 outbox ├─ OtaService::onReport ota/report → 告警 / 进度通知 ├─ AlertService::onDeviceEvent event → 告警 + notify ├─ SensorConfigService::onAck ack → 记 sync_ver └─ handleDeviceRegister 注册 → 回执 dist 到 resp → outbox 消费器(0.5s)发布 cmd / notify ``` ACL 关键规则(`Auth::checkAcl` 判定顺序): 1. `$SYS` 只读 2. 注册主题放行 3. 超管放行 4. **私有主题硬归属**(`/devices/{uuid}`、`/notify/{account}`) 5. `acl_enabled` 开关 6. 首条命中规则 7. `acl_default` 兜底 私有主题只看 `iot_device.member_id` + `iot_share`:设备本体放行、主人全权、`perm=2` 全权、`perm=1` 仅可订阅 `state|status|event|config|ack|ota/report` 且**不能发 `cmd`**;`/devices/+/state` 这类跨设备通配符默认拒绝。 --- ## 八、后台接口(`/iot/backend/*`,Bearer JWT) | 分类 | 接口 | |---|---| | 概览 | `GET index`、`statsData` | | 连接 / 消息 | `GET connections`、`connectionsTable`、`messages`、`messagesTable` | | 发布 / 设置 | `GET|POST publish`、`GET|POST setting` | | 认证 | `GET auth`、`authTable`、`POST saveAuth`、`deleteAuth` | | ACL | `GET acl`、`aclTable`、`POST saveAcl`、`deleteAcl` | | 设备 | `GET device`、`deviceTable`、`deviceLogs`、`deviceConfig`;`POST saveDevice`、`resetDeviceSecret`、`resetActivateToken`、`deleteDevice`、`saveDeviceConfig` | | 规则 | `GET rule`、`ruleTable`;`POST saveRule`、`deleteRule` | | 场景 | `GET scene`、`sceneDetail`、`sceneLogs`;`POST saveScene`、`deleteScene`、`toggleScene`、`runScene` | | 分组 | `GET group`;`POST saveGroup`、`deleteGroup` | | 告警 | `GET alertsPage`、`alerts`;`POST readAlertAll` | | OTA | `GET ota`、`otaList`、`otaTable`;`POST saveOta`、`deleteOta`、`toggleOta`、`publishOta` | > ⚠️ broker 配置**只在启动时读一次** → 后台改完「服务设置」必须重启 broker。 > ⚠️ 后台「删除设备」是**硬删**(清 `iot_device`/`iot_auth`/`iot_acl`/`iot_retain`)+ 设备端 NVS 仍留旧 uuid → 设备永久变砖,见 `device_register_mqtt.md` 的 BOOT 键重置章节。 --- ## 九、已发现的不一致 / 待修正 1. `device_register_mqtt.md` 称「插件 API 的 `Result::success` 用 `code=1` 表示成功」——**错误**,`Result::success()` 内 `code` 硬编码为 `0`。 2. 同文档「用户绑定设备识别码」返回示例写 `"code": 1` 表示绑定成功——**反了**,实际 `code=0` 成功、`code=1` 失败;且 `Client` 系列字段名是 `msg` 而非 `message`。 3. 文档 / 代码注释里出现的 `/iot/api/myDevices`、`/iot/api/mqttTicket` 等(无 `client` 段)是历史写法;`route/app.php` 实际注册的是 `/iot/api/client/*`。 4. `setCount()` 与 `apiSuccess()` 不配套 → 这些接口的响应里没有 `total`。 5. `/iot/api/publish` 无鉴权、无 topic 校验,属安全隐患(见第三节)。