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

21 KiB
Raw Blame History

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}

功能三合一:

  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 —— 告警 / 事件

两种形态,任选其一:

// 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,…}}

流程与铁律:

  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)
告警没进 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。