Mosquitto MQTT + ESP32-P4 面板接入 Home Assistant 实录
本文基于 2026-10-18 的一次真实接入整理,Broker、HA 集成与端到端控制均已在实机跑通。
环境:NanoPi R5C / Armbian 13 / Docker 29.9 / Home Assistant 2026.10.2 (Container),局域网192.168.*.30
成果:Mosquitto 2.x 常驻运行,匿名访问关闭,ha/p4双账号最小权限,78 条状态主题 retained,P4 发一条toggle到实体动作全程约 4 秒
说明:文中密码、访问令牌、设备 UID 均已替换为占位符,内网 IP 以192.168.*.*掩码表示,照抄前请换成自己的值
目录
- 1. 目标:让面板和 HA 说上话
- 2. 拓扑与端口规划
- 3. 部署 Mosquitto
- 4. 权限设计:ACL 才是重点
- 5. 四项安全验证
- 6. Home Assistant 侧接入
- 7. 主题协议:一套不用改代码的桥
- 8. ESP32-P4(ESPHome)侧
- 9. 排错速查
- 10. 安全与维护
- 11. 写在最后
1. 目标:让面板和 HA 说上话
手上有一块 JC-ESP32P4-M3 触摸屏开发板,想把它当作家里的中控面板:按钮点一下能控制 HA 里的设备,屏幕上还能显示设备的当前状态。
方案没有走 HA 的 REST API,也没有在 P4 上跑复杂的 HTTP 客户端,而是用最省事的 MQTT:局域网里跑一个 Mosquitto,HA 和 P4 各自用独立账号连上去,靠主题约定分工。
| 需求 | 实现方式 |
|---|---|
| HA 与 P4 通讯 | 局域网 Mosquitto Broker(1883),双方各一个账号 |
| P4 控制 HA 设备 | P4 发布 p4/cmd/<domain>/<object>/<service>,HA 自动化转发成服务调用 |
| P4 显示设备状态 | HA 的 mqtt_statestream 把状态镜像到 ha/<domain>/<object>/state(retain) |
| 不让 P4 越权 | ACL:p4 只能读 ha/#、读写 p4/# |
完成状态
| 项目 | 状态 |
|---|---|
| Mosquitto 2.x 容器 | ✅ 已运行,随 Docker 自启 |
| 匿名访问 | ✅ 已禁用(实测被拒) |
账号 ha / p4 与 ACL |
✅ 已生效(实测越权被断连) |
| 局域网 1883 可达 | ✅ Windows 侧直连成功 |
| HA MQTT 集成 | ✅ 已添加,状态 loaded |
mqtt_statestream 状态镜像 |
✅ 78 条 retained(3 climate / 2 light / 73 switch) |
| P4 → HA 控制桥 | ✅ 实测:MQTT 发 toggle,实体 on→off→on |
| P4 ESPHome 包与示例 | 📄 已备好,待烧录 |
凭据文件(密码明文)放在仓库之外,例如
D:\HA\mqtt-credentials.txt之类的本地文件,不要提交到 Git。本文示例里一律写成<...>占位符。
2. 拓扑与端口规划
1 | ┌──────────────────────────────┐ |
| 项目 | 值 |
|---|---|
| Broker 地址(P4 用) | 192.168.*.30:1883 |
| Broker 地址(HA 用) | 127.0.0.1:1883(HA 是 host 网络) |
| WebSocket | 未开启(P4 不需要) |
| TLS | 未启用(仅内网,见第 10 节) |
| 配置文件 | /opt/mosquitto/config/ |
| 数据 / 日志 | /opt/mosquitto/data/、/opt/mosquitto/log/ |
一个常被问到的问题:为什么 HA 用 127.0.0.1 而不是局域网 IP? 因为容器用了 network_mode: host,HA 容器里的 127.0.0.1 就是宿主机的 127.0.0.1,流量根本不经过网卡,省掉一层无谓的回环开销,也避免网卡切换时连接抖动。
3. 部署 Mosquitto
3.1 拉镜像:先绕开 Docker Hub
这台机器上 docker.io 直连超时,和之前装 HA 时一模一样。惯用办法是走国内镜像站拉取,再改回官方标签:
1 | docker pull docker.m.daocloud.io/library/eclipse-mosquitto:2 |
实测 docker.m.daocloud.io、docker.1ms.run、dockerproxy.net、hub.rat.dev 四个站都能用,镜像 ID 38c0da4f2ef8,43.3 MB,体积比 HA 小得多。
改标签这步不能省。 compose 里写的是
eclipse-mosquitto:2,不改标签的话,下次docker compose up还是会去找官方源。
3.2 目录:和 HA 分开
1 | mkdir -p /opt/mosquitto/{config,data,log} |
用绝对路径挂载,与 /opt/homeassistant 完全分开。好处很实际:备份 HA 的 config 时不会误带上 broker 的数据,两边升级、迁移也互不影响。
3.3 mosquitto.conf
/opt/mosquitto/config/mosquitto.conf:
1 | listener 1883 0.0.0.0 |
几个配置项的取舍:
allow_anonymous false必须配合password_file,只写前者容器会直接起不来。log_dest stdout让日志走docker logs,交给/etc/docker/daemon.json里已有的 10 MB × 3 轮转,不写 eMMC——这点对长期跑在 SD 卡 / eMMC 上的小主机很重要。persistence true把 retained 消息落盘。P4 依赖的“当前状态”本质就是 retained 消息,broker 重启后 P4 一订阅就能拿到值,不用等实体下次变化。autosave_interval 300比默认的 1800 秒短,掉电最多丢 5 分钟的状态快照,对 eMMC 的写入压力也可接受。max_keepalive 120限制客户端心跳上限,防止某个客户端协商出超长 keepalive 导致 broker 长时间挂着死连接。
3.4 ACL:让 P4 只能看,不能改
/opt/mosquitto/config/aclfile:
1 | user ha |
ha:MQTT 集成、设备发现、mqtt_statestream、控制桥都要用,索性给全量。p4:只读 HA 的状态镜像;只能写自己的p4/命名空间(命令、遥测、ESPHome 的 birth/debug 主题)。
这条规则的价值在“固件被改坏”的假设下才显现:即使 P4 上的程序出 bug、或者有人拿到了 P4 的账号,它也无法伪造 ha/... 状态去欺骗面板或 HA 的其它订阅者。权限边界画在 broker 上,比画在固件里可靠。
3.5 生成账号
用镜像自带的 mosquitto_passwd 生成哈希,避免明文写进配置文件:
1 | docker run --rm --entrypoint mosquitto_passwd \ |
踩过的坑:
mosquitto_passwd -c生成的文件属主是root:root、权限600,而镜像以 uid1883运行,容器会报Unable to open pwfile并退出。必须chown 1883:1883且给640。第二次用-b(不带-c)追加账号,否则会把已有账号覆盖掉。
3.6 加入 compose
在 /opt/homeassistant/compose.yaml 末尾追加下面这段,原有的 homeassistant 段不动:
1 | mosquitto: |
为什么用 network_mode: host:HA 已经是 host 网络,broker 也走 host,HA 里填 127.0.0.1:1883 即可;若改成端口映射,容器和宿主机之间多一层 NAT,排错时反而要多想一步“这个包到底从哪进来的”。内网单机服务,没必要引入这层复杂度。
3.7 启动
1 | cd /opt/homeassistant |
config --quiet 这步别省,它能在真正启动前抓出缩进错误和路径笔误。
4. 权限设计:ACL 才是重点
回头单独说一下这块,因为它是整套方案里最容易被忽略、又最值钱的部分。
很多教程给 P4 直接开 topic readwrite #,图省事,能跑通。但那样意味着:面板固件可以往 homeassistant/... 发发现消息,凭空注册一个假设备;也可以往 ha/light/xxx/state 写任意值,让面板显示“灯开着”而实际关着。
本文的划分把主题空间切成三层:
1 | ha/... 只由 HA 写,P4 只读 ← 状态镜像(retain) |
对应到 ACL 就是两行。如果面板最终只控制固定几台设备,还可以进一步收窄到具体主题(见第 10 节),把“读整个 ha/#”也砍掉。
5. 四项安全验证
配置写完不算数,得证明它真的拦得住。四个用例覆盖了认证和授权两面:
| # | 用例 | 命令要点 | 实测结果 |
|---|---|---|---|
| 1 | 匿名连接 | 不带账号 mosquitto_pub |
Connection Refused: not authorised ✅ |
| 2 | ha 收发 |
订阅 p4/test/# 后发布 {"ok":1} |
收到 {"ok":1} ✅ |
| 3 | p4 读状态 |
ha 发 ha/light/demo/state=ON,p4 订阅 ha/# |
收到 ON ✅ |
| 4 | p4 越权写 |
p4 发布到 ha/light/demo/state |
被断连(sending DISCONNECT)✅ |
第 4 条尤其值得看日志:Mosquitto 对 ACL 拒绝的发布不是静默丢弃,而是直接断开连接,日志里能看到 not authorised 和 sending DISCONNECT。这个行为对调试友好——P4 一直重连,基本就是账号密码错或 ACL 拒绝。
监听与启动日志:
1 | LISTEN 0 100 0.0.0.0:1883 users:(("mosquitto",pid=74075,fd=5)) |
Windows 侧直连也确认了防火墙和路由没问题:
1 | $c = New-Object System.Net.Sockets.TcpClient |
常用排错命令
1 | docker logs mosquitto --tail=50 |
第二条是“上帝视角”:用 ha 账号订阅全量主题,能一眼看出谁在发什么。-W 3 表示 3 秒后自动退出,不会挂住终端。
6. Home Assistant 侧接入
6.1 添加 MQTT 集成
界面路径是:设置 → 设备与服务 → 添加集成 → MQTT,填 127.0.0.1:1883、用户名 ha、密码。
本次为了可复现,用长期访问令牌直接调配置流 API 完成(等价于界面操作):
POST /api/config/config_entries/flow,body{"handler":"mqtt"}→ 返回flow_idPOST /api/config/config_entries/flow/<flow_id>,body:1
2
3{"broker":"127.0.0.1","port":1883,"protocol":"5","username":"ha",
"password":"<HA_PASSWORD>",
"other_settings":{"set_client_cert":false,"set_ca_cert":"off","transport":"tcp"}}返回
type=create_entry,集成立即loaded。
令牌只用于这一次调用,没有落盘。用完要撤销的话:HA 左下角头像 → 安全 → 长期访问令牌 → 删除。长期访问令牌等同于密码,尤其不要贴进博客、Issue 或聊天记录。
6.2 状态镜像(mqtt_statestream)
把下面这段加到 /opt/homeassistant/config/configuration.yaml 顶层,然后重启 HA:
1 | mqtt_statestream: |
发布规则:
1 | ha/<domain>/<object_id>/state retain=true, qos=1 |
设计取舍:只镜像 light/switch/fan/cover/climate,不含 sensor。传感器高频刷新(温湿度、功率曲线)会把带宽和 broker 的 retained 存储迅速撑大,而面板真正需要的只是开关状态。要显示温度,把 sensor 加进 domains 即可,代价自己权衡。
publish_attributes: false 也很关键:设备属性(亮度、色温、HVAC 模式)动辄几十个字段,全量镜像后主题数量翻倍,而 P4 端通常只用得上 state 本身。
6.3 控制桥自动化
把下面两条追加进 /opt/homeassistant/config/automations.yaml,然后 开发者工具 → 操作 → automation.reload:
- P4 面板:MQTT 控制桥 —— 订阅
p4/cmd/+/+/+,从主题里拆出 domain / object_id / service,调对应服务。 - P4 面板:状态镜像初始化 —— HA 启动 15 秒后,把所有常用实体状态重新 retain 一遍。
第二条是为了补 mqtt_statestream 的一个缺口:它只在状态变化时发布,不主动发初始值。broker 重启、retained 数据清空、或者 HA 刚起来时,P4 订阅了也拿不到东西,屏幕只能显示 --。用一条启动后补发的自动化把这个洞堵上。
第一条的模板部分大致是:
1 | trigger: |
这样 service 直接就是 HA 的服务名,toggle / turn_on / turn_off / set_hvac_mode / open_cover 全部通用,新增设备不需要改任何后端代码。
6.4 没有 P4 也能验收
没有硬件不代表不能验证。用 broker 自带的客户端冒充面板就够了(本次就是这么验收的):
1 | # 用 p4 账号发一条 toggle(实体换成你自己的) |
用 p4 账号而不是 ha 来验收还有额外好处:顺带证明了 ACL 里“读 ha/#”这条规则真的放行了,写 p4/cmd/... 也真的允许了。
6.5 实测记录
1 | BEFORE : on |
- 控制桥自动化:
last_triggered = 15:34:06 - 状态镜像初始化自动化:
last_triggered = 15:33:21(启动补发) - retained 主题共 78 条:
ha/climate/*3 条、ha/light/*2 条、ha/switch/*73 条
4 秒的延迟主要来自“MQTT → 自动化触发 → 服务调用 → 设备响应 → 状态回流”这条链路,其中设备本身的响应占大头。局域网内这个量级对面板交互完全够用。
6.6 关于实体构成的一点观察
这台 HA 里 light/switch/fan/cover/climate 的实际构成挺有意思:
| 类型 | 数量 | 说明 |
|---|---|---|
climate.* |
3 | 壁挂式空调(Midea) |
light.* |
8 | 绝大多数是设备指示灯 / 氛围灯,其中 5 个 unknown/unavailable |
switch.* |
70+ | 小米设备的子开关(摄像机 AI 检测、扫地机功能项等),不是物理开关 |
也就是说:列表里没有常规照明实体。如果你的目标是控制真正的灯或插座,先确认它们是否已经接入 HA,再谈面板按钮。P4 上的按钮直接填对应 entity_id 即可,桥不关心设备品牌——但前提是那个实体真实存在且可用。
7. 主题协议:一套不用改代码的桥
7.1 命令(P4 → HA)
1 | p4/cmd/<domain>/<object_id>/<service> 负载:JSON 对象 |
| 场景 | 主题 | 负载 |
|---|---|---|
| 切换灯 | p4/cmd/light/keting/toggle |
{} |
| 开灯并调亮度 | p4/cmd/light/keting/turn_on |
{"brightness":180} |
| 关空调 | p4/cmd/climate/keting_ac/turn_off |
{} |
| 开窗帘 | p4/cmd/cover/keting_curtain/open_cover |
{} |
| 空调切制冷 | p4/cmd/climate/keting_ac/set_hvac_mode |
{"hvac_mode":"cool"} |
注意 object_id 是 entity_id 去掉 domain 前缀的部分:light.keting 对应 object_id=keting。用小写英文而不是中文,避免 URL / 主题编码上的麻烦。
7.2 状态(HA → P4)
1 | ha/<domain>/<object_id>/state retain=true(订阅即得当前值) |
P4 只需订阅 ha/+/+/state 一个通配符主题,按 entity_id 分发到各个控件。
7.3 命名空间
1 | p4/panel/... ESPHome 自身的 birth/debug 主题(topic_prefix: p4/panel) |
把 ESPHome 的内部主题和业务命令分到不同子树,p4/# 授权时不会互相干扰,抓包时也一眼能分清是面板自检还是用户操作。
8. ESP32-P4(ESPHome)侧
8.1 前提条件
- ESPHome 里 P4 的写法是
variant: ESP32P4+framework: esp-idf,且需要较新的版本(示例工程用min_version: 2026.5.2)。版本太旧会直接报esp_ldo/esp32_hosted未知。 - P4 没有内置 WiFi:靠封装里的 ESP32-C6 通过
esp32_hosted(SDIO)提供网络。 - MIPI 屏需要
esp_ldo给 DSI PHY 供电(常见channel: 3、2.5V),再配display: platform: mipi_dsi。 - 这些板级配置沿用你现有工程即可,示例里只给占位注释。
8.2 引入桥包
把 p4-ha-bridge.yaml 放进 ESPHome 配置目录,secrets.yaml 加一行:
1 | mqtt_p4_password: "<P4_PASSWORD>" |
面板 yaml 里:
1 | packages: |
包内已经包含:
mqtt:客户端(topic_prefix: p4/panel,discovery: false)script.p4_send(domain, object_id, service, data)—— 发任意命令script.p4_toggle(domain, object_id)—— 只发 toggle
discovery: false 是刻意的:ESPHome 的 MQTT discovery 会把面板自己注册成 HA 设备,而这里面板是控制端,不需要被控制,关掉能少一堆实体。
8.3 UI 绑定
按钮(每加一个设备复制一份,改 4 个值):
1 | - button: |
状态标签 + 订阅。订阅只需一次,放在 on_boot 里:
1 | esphome: |
那段 substr(3, size - 9) 是把 ha/ 前缀和 /state 后缀切掉,得到 light/keting,再把斜杠换成点,拼成 light.keting 这样的 entity_id。偏移量写死看着不优雅,但对固定格式的主题是最省 CPU 的写法。
8.4 两个实现细节
回调线程:ESP32 上 MQTT 回调运行在 esp-mqtt 的任务里,不是主循环。直接 lv_label_set_text 在实测中可用(ESPHome 自带组件也这么干),但如果面板复杂、出现偶发花屏或重启,改成“回调只写全局变量,interval 里刷新控件”即可。这是典型的“能用”和“稳”之间的取舍。
订阅只做一次:MQTTClientComponent::subscribe() 不去重,每次调用都会新增一条订阅,订阅表只增不减。所以:
- 不要放在
on_connect里——每次重连都会多加一条,跑一天能堆出几十条重复订阅,回调被重复执行。 - 放在
on_boot,或只在首次连接时执行一次。 - 重连后的重订阅由 ESPHome 组件自己完成,不用你操心。
9. 排错速查
| 现象 | 原因 | 处理 |
|---|---|---|
容器起不来,日志 Unable to open pwfile |
passwd 属主是 root | chown 1883:1883 + chmod 640 |
docker pull eclipse-mosquitto:2 超时 |
Docker Hub 不可达 | 用镜像站拉 + docker tag |
HA 报 Connection refused |
broker 没起 / 端口不对 | ss -tlnp | grep 1883 |
| P4 一直重连 | 账号密码错 / ACL 拒绝发布 | docker logs mosquitto 看 not authorised |
P4 订阅 ha/# 收不到 |
mqtt_statestream 没配,或 domain 不在 include |
检查 configuration.yaml,重启 HA |
P4 上电后状态是 -- |
statestream 不发初始值 | 等实体变化,或靠启动补发自动化 |
| 控制没反应 | 实体名 / 服务名写错,或被 condition 挡掉 | 开发者工具 → 模板里跑 states('light.keting') |
ESPHome 编译报 esp_ldo / esp32_hosted 未知 |
ESPHome 版本太旧 | 升级到 2026.5+ |
| P4 能连 broker 但 HA 收不到命令 | HA 里没加 MQTT 集成 | 见 6.1 |
| 面板回调重复执行 | 订阅加在了 on_connect |
改到 on_boot,只订阅一次 |
排错的顺序建议是:先 broker,再 HA,最后 P4。broker 用 mosquitto_sub 能收到就说明网络和认证没问题;HA 侧用开发者工具能手动调服务就说明集成和实体没问题;剩下的才轮到固件。
10. 安全与维护
边界
1883 不要映射到公网。 外网访问走 Tailscale / WireGuard 之类的隧道,或者至少上 TLS(
listener 8883+cafile/certfile/keyfile)。p4账号权限已最小化。如果面板只控制固定几台设备,可以把 ACL 从“读整个ha/#”收窄到具体主题:1
2
3
4user p4
topic read ha/light/keting/state
topic read ha/switch/chafang/state
topic write p4/cmd/light/keting/#这样即使 P4 完全失陷,攻击面也只有这两三个灯。
日常
换密码:
mosquitto_passwd改完docker restart mosquitto,再同步改 HA 集成和 ESPHome 的secrets.yaml。三处必须一起改,漏一处就是“P4 一直重连”。备份:
/opt/mosquitto不在原有 HA 备份脚本范围内。retained 状态丢了会自动重建(靠启动补发),但配置建议单独备份:1
tar czf /var/backups/mosquitto-$(date +%F).tar.gz /opt/mosquitto/config
升级:
docker compose pull mosquitto && docker compose up -d mosquitto。2.x 内部小版本可以直接升,跨大版本前先看 release notes 里 ACL 和配置项的变化。别把密码写进 compose 或 Git:凭据放本地文件或密码管理器,
secrets.yaml加进.gitignore。
11. 写在最后
整套东西配置下来不到一小时,真正花时间的是把权限边界想清楚,以及验证它确实拦得住。四条测试用例里,前三条是“能用”,第四条才是“安全”——很多人只测前三条。
另一个感受是 mqtt_statestream + 通配符订阅这个组合的性价比很高:HA 侧几乎零代码(一段 YAML + 两条自动化),P4 侧也不用为每台设备写协议适配,toggle / turn_on / turn_off 这些服务名天然就是通用的。新增设备基本只是复制一个按钮、改四个值。
接下来准备把 P4 真正烧进去,接上实体跑一轮完整交互。ESPHome 那段 lv_label_set_text 在回调里是否稳定,还得在真实面板上多看几天。
唯一要提醒的是:文章里所有密码、ID、令牌都换成了占位符,你照抄的时候记得改。 尤其是长期访问令牌,它和密码等价,别顺手贴进任何一个公开的地方。
变更记录:
| 时间 | 操作 |
|---|---|
| 15:20 | 确认 1883 空闲、HA 无 MQTT 集成 |
| 15:22 | 拉取镜像(Hub 超时,改 daocloud 镜像站) |
| 15:23 | 写入 mosquitto.conf / aclfile,生成 ha、p4 账号 |
| 15:24 | compose 增加 mosquitto 服务并启动 |
| 15:25 | 四项验证通过(匿名拒绝、收发、读状态、越权拒绝) |
| 15:30 | 备好 HA 自动化 / statestream / ESPHome 包 |
| 15:31 | 用长期访问令牌添加 MQTT 集成(entry loaded) |
| 15:32 | 停 HA,追加 mqtt_statestream 与 2 条自动化,重启 |
| 15:34 | 端到端验证:p4 发 toggle → 实体 on→off→on;78 条 retained 状态主题 |