Files
YwxAppThink/docs/guide/iot_api_reference.md
T
yangwx 18101cf11a feat: 上线前安全加固与发布工程化;appmall 由插件迁移为独立应用
安全(P0)
- 发布包签名:新增 scripts/package_keygen.php、scripts/package_verify.php、
  ywxapp\service\PackageVerifier;FrameworkService::apply() 解压前强制验签
  (ywxapp.framework_package_verify,默认开启)
- JWT 收紧:config/jwt.php 移除公开默认密钥,JwtService::assertStrongSecret()
  校验非空 / 长度≥32 / 已知弱值;.env.production.example 键名由 JWT_SECRET_KEY 修正为 JWT_SECRET
- /api/v1/Article:免登录白名单收窄为只读接口,写操作字段白名单 + assertOwner() 越权校验
- 开发者令牌:查找接口不再回显明文、统一失败文案 + 限流
- TemplateInstaller:文件名白名单 + realpath 归一化 + 目录内断言,修复 Zip Slip;补 use ZipArchive
- 后台登录:接入验证码、失败锁定与登录限流(config/ratelimit.php)
- 缓存 / Session 驱动可由 .env 覆盖(CACHE_DRIVER / SESSION_TYPE),集群部署改 redis
- paycenter 支付日志开关改为 PAY_LOG,不再跟随 APP_DEBUG

工程化 / 交付
- 新增 CHANGELOG.md 与 .env.production.example;composer.lock 入库锁定依赖版本
- 插件离线安装 CLI 增加 --uid,后台上传安装透传 uid,付费插件授权校验可通过
- 授权拒绝文案区分「未携带 uid」与「未找到有效授权」,便于排障
- .gitignore 放行 .env.production.example / .env.addon.example / .gitattributes / public/.htaccess

结构
- appmall 由 addon/appmall 迁移为独立应用 app/appmall(市场 / 开发者中心 / 审核 / 分润)
2026-10-22 00:53:39 +08:00

537 lines
33 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 校验,属安全隐患(见第三节)。