安全(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(市场 / 开发者中心 / 审核 / 分润)
33 KiB
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 |
重置密钥,新明文仅返回一次 |
三个标识符别混用(各家接口用的不是一个东西):
标识符 是什么 哪些接口用 sniot_device.sn列,设备序列号bindDevice、deviceActivateuuidgenUuid()生成,形如dev_+ 16 位 hex,设备 MQTT 连接用户名unbindDevice、deviceState、deviceConfig、saveDeviceConfig、sendCommand、deviceLogs、publishOta、所有/devices/{uuid}/…主题device_idiot_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()(按位置取阈值复核)、Appdevice-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();AppnormalizePositions()/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 判定顺序):
$SYS只读- 注册主题放行
- 超管放行
- 私有主题硬归属(
/devices/{uuid}、/notify/{account}) acl_enabled开关- 首条命中规则
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 键重置章节。
九、已发现的不一致 / 待修正
device_register_mqtt.md称「插件 API 的Result::success用code=1表示成功」——错误,Result::success()内code硬编码为0。- 同文档「用户绑定设备识别码」返回示例写
"code": 1表示绑定成功——反了,实际code=0成功、code=1失败;且Client系列字段名是msg而非message。 - 文档 / 代码注释里出现的
/iot/api/myDevices、/iot/api/mqttTicket等(无client段)是历史写法;route/app.php实际注册的是/iot/api/client/*。 setCount()与apiSuccess()不配套 → 这些接口的响应里没有total。/iot/api/publish无鉴权、无 topic 校验,属安全隐患(见第三节)。