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(市场 / 开发者中心 / 审核 / 分润)
This commit is contained in:
yangwx committed 2026-10-22 00:53:39 +08:00
1 parent 1d49e6f5ee
commit 18101cf11a
636 files changed
+58517 -9519

No files matched your search

+317
View File
@@ -0,0 +1,317 @@
# 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。