Files
YwxAppThink/docs/guide/iot_topics.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

318 lines
21 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 设备主题速查(功能 / 参数 / 权限)
> 本文只讲**主题**(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。