安全(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(市场 / 开发者中心 / 审核 / 分润)
21 KiB
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 内置指标 | 只读 |
┌──────────────── 服务端(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):
{ "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} |
功能三合一:
- 在线态真源(App 在线/离线、后台在线数)。与
event分主题就是为了避免 retained 互相覆盖。 - 引脚表缓存:
DeviceService::onStatusMessage()把pins写成iot_device.pins(CSV),随deviceConfig的pins下发给 App。pin 只是属性(换板会变),位置序号才是身份。 - 自动补位置 + 配置重发:拿到 pins 后
fillMissing()按下标补齐缺失位置(只补不覆盖、停用不复活、有新增才bumpVer+push),无新增则repush()(同版本重发,治「设备端被打瘫、两侧版本却对齐」)。
⚠️ 设备从未上报过(≤1.6.2 或从未上线)时
pins为[],App 回退用 state 里的pin自补显示。
2.3 event —— 告警 / 事件
两种形态,任选其一:
// 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 —— 回执
{ "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 —— 命令 / 控制(透明管道)
{"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 —— 固件升级
// 下行 /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,…}} |
流程与铁律:
- 必须先订阅
resp再发布注册 —— 回执非 retain,错过即丢(固件mqttSubscribeAll()里对订阅结果做了检查,订阅失败就直接不发注册)。 mac必须 12 位小写 hex(去冒号),且与主题段一致(不一致回payload mac 与主题不一致)。- 首次注册回
secret明文,之后幂等更新type/model/ver,不再给 secret。 - ACL 恒定放行,可匿名连接完成注册(此时还没有凭证)。
- 旧协议兼容:
system/device/register/{sn}+ payload{"token":"..."}或裸 token(走activate_token激活)。 - 服务端处理:
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 |
七、字段/协议的硬约束(改代码前必读)
- 上下行的唯一不对称:
pos[]的index- 上行
state.pos[]:靠数组下标定位序号(未启用写null占位,每位置省 ≈10B); - 下行
config.pos[]:只含启用位置 ⇒ 下标塌缩 ⇒ 元素必须带index,固件不许用下标顶替。 - 两边不是「漏改」,是高频上行压字节换来的。
- 上行
config是全量覆盖语义:pos里没有的位置一律停用。⚠️ 位置全停用会下发pos:[],固件对空数组 = 清空 NVS ⇒ 后台至少留一个启用(push()对「真源为空」也会拒发)。- 改名要同步的清单(少一处就静默失效):
- 报文键名 →
SensorPointService::capture()、SceneService::dig()/upgradeStatePath()、AppnormalizePositions()/normalizeProbes()、后台device.html; - 位置序号键
index→SensorConfigService::normalize()、Appdevice-panel、后台device.html。
- 报文键名 →
- 协议升级顺序:先烧固件 ≥1.7.2,再上线服务端。旧固件收新格式只回 ack
missing pos(配置不生效但不误报成功,一眼可见)。 - 同一语义只留一个键名:历史上「
index+i双发」=一个字段挂两把钥匙,改一边就静默拆锁(2026-09-14 现场事故:ack 成功、位置全没启用)。兼容层已于 2026-09-15 全删。 - 服务端/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) |
告警没进 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(通用分支) |
九、待修 / 已知风险
- 🔴
/iot/api/publish(Mqtt::publish,noNeedLogin=['*'])无鉴权、无 topic 前缀校验 ⇒ 任何人可往任意主题发消息。带命名空间隔离的Client::publish未被路由绑定。上线前建议改路由或补鉴权。 cmd与config的回执复用同一type=config_ack、同一/ack主题 ⇒ 无法区分「命令执行结果」与「配置写入结果」(当前靠message文本区别)。Mqtt::publish失败时error()之后没有return,会继续执行到success()(响应内容自相矛盾)。- 固件头注释把上行
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。