Files
YwxAppThink/docs/guide/device_register_mqtt.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

13 KiB
Raw Blame History

设备注册(MQTT system/device/register/+)

设备通过 MQTT 主题向服务端注册入网,领取正式连接凭证(uuid/secret)与订阅主题清单;用户随后用设备识别码绑定设备获取设备信息与面板类型。

支持两种协议:

  1. MAC 即插即用注册(推荐):设备上电自动上报 mac/type/model/ver,服务端自动入网,无需后台预登记。
  2. 华为式预登记注册(旧协议,兼容):设备出厂预置 SN + activate_token,后台登记后激活。

主题约定

主题 方向 说明
system/device/register/{sn} 设备 → 服务端 设备发布注册/激活请求
system/device/register/{sn}/resp 服务端 → 设备 注册回执(凭证 + 订阅主题 / 错误原因)

ACL:上述主题内置放行(Auth::checkAcl 对 system/device/register/{sn} 发布与 .../resp 订阅始终放行,不依赖 ACL 开关),设备可在激活前匿名/以 SN 连接完成注册。


MAC 即插即用注册(推荐,ESP32 等设备直连入网)

设备无需后台预登记:上电后把自身信息发布到注册主题,服务端自动创建设备并发放凭证。

主题

主题 方向 说明
system/device/register/{mac} 设备 → 服务端 注册请求({mac} = 设备 MAC 去冒号小写,12 位 hex,如 a0b1c2d3e4f5)
system/device/register/{mac}/resp 服务端 → 设备 注册回执(凭证 + 订阅主题 / 错误原因)

ACL:上述主题内置放行,设备可在未激活前以任意 clientId(建议直接用 mac)连接并完成注册。

注册消息(设备上电 publish,建议 retained)

{
  "mac": "a0b1c2d3e4f5",
  "type": "esp32c3_temp",
  "model": "KF-C3-3KANG",
  "ver": "1.0"
}
字段 值 作用
mac 设备 MAC 去冒号小写 唯一身份证,服务端靠它区分设备、做绑定(须与主题中的 mac 一致,否则拒绝)
type esp32c3_temp 设备类型,服务端落库为 dev_type,App 据此渲染面板(炕温控器/插座/灯…)
model KF-C3-3KANG 产品型号,后台管理区分硬件版本
ver 1.0 固件版本,服务端记录,用于版本检测/OTA 提示

retained 说明:服务端收到 publish 即处理(不依赖 retained 标志)。retained 的作用是让之后订阅该主题的客户端也能看到设备注册信息。回执 .../resp 不会 retained(防止 secret 泄露给后来的订阅者),设备若未收到回执应延时重发注册消息(例如 5s/30s/5min 退避)。

处理流程

① 设备联网:订阅 system/device/register/{mac}/resp
② 设备发布(retained):system/device/register/{mac},payload 含 mac/type/model/ver
③ 服务端:mac 首次出现 → 自动创建设备(sn=mac)并生成 uuid/secret,写入 iot_device
④ 服务端回执:{uuid, secret(仅首次), dev_type, model, ver, topics}
⑤ 设备保存凭证,改用 clientId=uuid / username=uuid / password=secret 连接
⑥ 设备按 topics 订阅 /devices/{uuid}/#,收发状态与命令

再次上报(设备重启 / 固件升级后):

  • mac 已存在 → 幂等更新 type/model/ver,不重置凭证,回执不带 secret;
  • 固件 ver 变化自动同步到设备记录,后台/OTA 据此判断是否需要推送新固件。

回执 payload(成功)

{
  "code": 0,
  "message": "ok",
  "data": {
    "id": 1, "sn": "a0b1c2d3e4f5", "mac": "a0b1c2d3e4f5",
    "uuid": "dev_8f3a...",
    "dev_type": "esp32c3_temp", "model": "KF-C3-3KANG", "ver": "1.0",
    "secret": "明文凭证(仅首次注册返回)",
    "activated": true, "bound": false,
    "topics": {
      "state": "/devices/dev_8f3a.../state", "cmd": "/devices/dev_8f3a.../cmd",
      "ota": "/devices/dev_8f3a.../ota", "ota_report": "/devices/dev_8f3a.../ota/report",
      "event": "/devices/dev_8f3a.../event"
    }
  }
}

失败(code=1)示例:{"code":1,"message":"payload mac 与主题不一致","data":null}、{"code":1,"message":"mac 格式非法(应为12位十六进制,去冒号小写)","data":null}。

用户绑定(App 扫码/输 mac)

POST /iot/api/client/bindDevice   (需登录)
body: { "sn": "a0b1c2d3e4f5", "title": "炕温控器" }

MAC 自动注册的设备无配对码(扫码即绑,先到先得);如需防抢绑,可在后台设备详情为该设备补设 pair_code,此后绑定须携带该码。

绑定成功返回 mac / dev_type / ver 与 topics:App 用 dev_type 查「设备类型 → 面板」映射表渲染对应控制面板,用 ver 与后台最新固件比对触发 OTA 提示。


华为式预登记注册(旧协议,兼容)

① 后台预登记设备:生成 sn + pair_code(贴纸)+ activate_token(出厂预置)
② 设备联网:订阅 system/device/register/{sn}/resp
③ 设备发布:system/device/register/{sn},payload 见下
④ 收到回执:uuid / secret / topics → 保存凭证
⑤ 使用凭证连接 MQTT:clientId=uuid,username=uuid,password=secret
⑥ 按 topics 订阅 /devices/{uuid}/# 收发状态与命令

请求 payload(②③)

{
  "token": "b5d3...32位hex激活令牌"
}

也兼容裸 token 字符串形式(直接以 token 作为 payload 发布)。

回执 payload(④)

成功(code=0):

{
  "code": 0,
  "message": "ok",
  "data": {
    "id": 1,
    "sn": "SN20260826",
    "uuid": "dev_8f3a...",
    "title": "智能灯泡",
    "model": "LAMP-01",
    "secret": "明文凭证(仅激活成功这一次返回)",
    "activated": true,
    "bound": false,
    "topics": {
      "state":      "/devices/dev_8f3a.../state",
      "cmd":        "/devices/dev_8f3a.../cmd",
      "ota":        "/devices/dev_8f3a.../ota",
      "ota_report": "/devices/dev_8f3a.../ota/report",
      "event":      "/devices/dev_8f3a.../event"
    }
  }
}

失败(code=1):{ "code": 1, "message": "激活令牌无效或已失效", "data": null }

幂等:已激活设备重复注册不报错,回执返回 activated:true 但 不再下发 secret(如需换凭证走 resetSecret)。

用户绑定设备识别码(App 侧)

设备激活后,用户通过 App 绑定:

POST /iot/api/client/bindDevice   (需登录)
body: { "sn": "SN20260826", "pair_code": "123456", "title": "客厅灯" }

绑定成功返回(含订阅主题与设备信息):

{
  "code": 1,
  "data": {
    "id": 1, "sn": "SN20260826", "uuid": "dev_8f3a...",
    "title": "客厅灯", "model": "LAMP-01", "room": "",
    "bound": true, "activated": true, "secret": "",
    "topics": { "state": "/devices/dev_8f3a.../state", "cmd": "/devices/dev_8f3a.../cmd", "ota": "/devices/dev_8f3a.../ota", "ota_report": "/devices/dev_8f3a.../ota/report", "event": "/devices/dev_8f3a.../event" }
  }
}

注:插件 API 的 Result::success 用 code=1 表示成功(与框架 code=0 相反),解析时以 message 键为准。

绑定后 App 即可使用 uuid 连接 MQTT 推送(WebSocket ws://{host}:8083)或通过 REST 接口下发命令。

其他相关接口

接口 说明
POST /iot/api/deviceActivate HTTP 方式激活(sn + token),与 MQTT 注册等效,适合无 MQTT 客户端的设备
POST /iot/api/resetSecret 重置设备密钥(返回新明文,旧密钥作废)
GET /iot/api/myDevices 我的设备列表(主人 + 共享,含在线状态/影子)
POST /iot/api/sendCommand 向设备下发命令(写入 /devices/{uuid}/cmd)
POST /iot/api/deviceLogs 设备消息流水

设备侧配网(ESP32 固件 water_temp.ino)

现场部署不再需要为了改 WiFi 重烧固件(OTA 又必须先联网,过去是死锁)。固件用 WiFiManager 自带配网页,v1.6.2 起表单里只有 WiFi(服务器与端口已写死在固件里,见下节):

  1. 上电先用已保存凭据连网(WIFI_CONNECT_S = 15s),连上即正常跑,不会打扰用户;
  2. 连不上(首次上电 / 换路由器 / 改密码)→ 自动开配网热点 TempMonit-Esp32<MAC后4位>(默认密码 12345678,见 WIFI_AP_PASS);
  3. 手机连热点 → 浏览器打开 http://192.168.4.1(Android 会自动弹配网页) → 表单只填 WiFi → 保存 → 设备自动重启生效;
  4. 热点 180s(WIFI_PORTAL_S)无人操作自动关闭,回后台继续重试 —— 要重新配网请断电重上电。

服务器地址:编译期写死 + 备用机自动切换(v1.6.2)

项 位置 说明
服务器表 固件常量区 SRV_HOSTS[] 按 主 → 备 顺序填,只填域名或 IP(表尾空串/nullptr 即结束)
端口 DEF_MQTT_PORT = 1883 写死,所有服务器共用(配网页已无端口输入框)
切换阈值 SRV_FAILOVER_N = 3 当前这台连续失败 3 次(5s/次 ⇒ 约 15s)切下一台,走到表尾回到第 0 台,永不卡死
切换条件 仅「网络层」失败 -2/-3/-4/3(TCP 连不上/超时/被断/服务端不可用)才切;CONNACK 1~5 是「服务器已应答、只是拒绝」(凭据/ACL 问题),换台毫无意义

默认表(按现场改这几行即可):

const char* SRV_HOSTS[SRV_MAX] = {
    "mqtt.ywxapp.cn",        // 0 主:域名(正常走这条)
    "211.149.215.200",       // 1 备:同机 IP 直连 —— 免 DNS(解析失败/被劫持时仍能连上)
    // "mqtt2.ywxapp.cn",    // 2 备:真·第二台服务器(有独立机器时填它的域名或 IP)
};
  • 串口:开机打印整张表([SRV] ... <- 当前),心跳行带 srv=,切换时打 [SRV] 连接连续失败:A → B(第 2/2 台) —— 排障一眼看清在用哪台、切过没有。
  • NVS 里的 mqtt_host / mqtt_port(≤v1.6.1 写过的)一律不再读取;遗留键不擦除(省一次 flash 写)。
  • ⚠️ 换服务器 / 加备用机 = 改表后重新编译烧录;现场设备不需要任何配置,也就没有填错的可能。
  • 注册流程同样参与切换(主服务器挂掉时,新设备也必须能注册上)。

远程清空重配(设备还连得上时最方便,App 用 sendCommand 把 payload 原样发下去即可):

{"action": "wifi_reset"}

设备先回 ack,随后抹掉 WiFi 凭据并重启 → 开机连不上 → 自动进配网热点。 ⚠️ 只清 WiFi 凭据:uuid / secret / 传感器配置全部保留(服务器写死在固件里,不受影响),不需要重新注册。

清除身份 / 恢复出厂(固件 v1.3.3+,BOOT 键)

⚠️ 背景(重要):后台「删除设备」是硬删 —— DeviceService::remove() 会清掉 iot_device、iot_auth、iot_acl、iot_retain、iot_topic、iot_offline、 分组关联 / 共享关系 / 传感器配置。但设备端 NVS 里的 uuid/secret 不会被清,于是:

设备拿旧凭据去连 → iot_auth 里已无此用户 → CONNACK 4/5 被拒; 而注册流程只在「uuid 为空」时才启动 → 设备永久爬不起来。 现场表现:串口一直 [MQTT] fail rc=4,后台也永远不会再出现该设备。

固件 v1.3.3 起提供设备端「清除身份」入口,现场无需拆机重烧:

操作 清除内容 结果
按住 BOOT 键 3 秒后松手 uuid / secret / 传感器配置 重启 → 自动重新注册,拿全新凭证;WiFi 与服务器地址保留,不用重新配网
按住 BOOT 键 8 秒不松 上述 + WiFi 凭据(彻底恢复出厂) 重启 → 进配网热点 TempMonit-Esp32<MAC后4位>
  • 键位:ESP32-C3 = GPIO9,经典 ESP32 = GPIO0(改固件宏 BTN_PIN);固件里的 ONEWIRE_PINS(当前 {3,4,10})不要与它撞脚。
  • 上电时已按住 也生效(恢复出厂的标准姿势),无 3s 内的时机要求。
  • 串口日志以 [BTN] / [RST] 开头,会在按满 3s 时提示"现在松手=清身份 / 继续按住=全清"。
  • 护栏:开机按住超 15s(BTN_ABNORMAL_MS)判定为引脚异常并放弃清除;运行期必须先 观察到一次「松开」才允许触发(armed 标志),引脚恒低的板子不会被反复恢复出厂。

远程等价命令(设备仍在线时最方便,发到 /devices/{uuid}/cmd):

{"action": "clear_identity"}
{"action": "factory_reset"}

⚠️ 重新注册后拿到的是全新 uuid,后台会新增一条设备(member_id=0 未绑定), 需在 App / 后台重新绑定;旧 iot_device_log / iot_alert 中按旧 uuid 存的历史不会自动迁移。