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

33 KiB
Raw Blame History

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()

{ "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()

{ "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[]:原始稀疏点(细粒度,逐条上报)

{ "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 分桶(画趋势用这个)

{ "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[]:每位置一条汇总值(首屏/排障用这个)

{ "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
// 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 去冗余)):

{ "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)

{ "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)

{ "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(下发命令)

{"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

// 下行 /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):

{ "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 钩子)

设备 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
认证 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 校验,属安全隐患(见第三节)。