手上的 ESP32 一直是插在 Pi 5 的 USB 上燒錄、看 log,線一拔就失聯。這次的目標很單純:
讓 Pi 5 多開一個 2.4GHz 熱點專門給 ESP32 連,之後 MQTT、HTTP 控制、韌體更新(OTA)全部走 WiFi,USB 線只在第一次燒錄時用一次。
本文記錄一整天從插網卡到 OTA 成功的完整流程,附上可以直接照抄的設定步驟,以及中間踩到的三個坑(其中 Docker 擋 NAT 那個最隱蔽)。
一、為什麼需要第二張 WiFi 網卡
Pi 5 內建網卡其實可以同時當 client 和熱點,但有個硬限制:兩者必須在同一個頻道。我的 Pi 5 連的是公司 WiFi 的 5GHz ch48,而 ESP32 只支援 2.4GHz,同一張卡做不到。所以插一張 USB 網卡,分工如下:
公司 WiFi(5GHz ch48)
│
wlan0(內建) ← 上網、Tailscale、SSH,維持原樣
┌─────────┴─────────┐
│ Pi 5 │ NAT(NetworkManager shared)
└─────────┬─────────┘
wlan1(USB 網卡) ← 2.4GHz 熱點 SSID: pi5-esp32
│ 10.42.0.1/24,DHCP 自動發
ESP32 10.42.0.x
│
MQTT ──(經 Pi NAT → Tailscale)──→ gx10 上的 mosquitto
二、硬體清單(實測)
| 項目 | 型號/規格 | 備註 |
|---|---|---|
| 主機 | Raspberry Pi 5 | kernel 6.18,Raspberry Pi OS |
| USB 網卡 | D-Link DWA-135 rev.A1(2001:3309) |
Realtek RTL8192CU,核心內建驅動 rtl8192cu,免裝;只有 2.4GHz、支援 AP 模式 |
| 開發板 | ESP32-D0WD rev1.0 | 雙核 240MHz,4MB flash |
| USB 轉序列 | CP2102(10c4:ea60) |
只有首次燒錄需要 |
| MQTT broker | 另一台主機 gx10 上的 Docker eclipse-mosquitto |
Pi 上不另外裝 |
插上網卡後先確認系統認得、而且支援 AP 模式:
lsusb | grep -i d-link
# Bus 003 Device 003: ID 2001:3309 D-Link Corp. DWA-135 ... [Realtek RTL8192CU]
nmcli -t dev | grep wifi
# wlan0:wifi:connected:DQA-WPA3
# wlan1:wifi:disconnected: ← 新網卡
nmcli -f WIFI-PROPERTIES.AP,WIFI-PROPERTIES.5GHZ dev show wlan1
# WIFI-PROPERTIES.AP: 是 ← 一定要「是」
# WIFI-PROPERTIES.5GHZ: 否
如果 AP 那一欄是「否」,這張卡就不能當熱點,要換卡。Linux 免驅、支援 AP 的晶片可以挑 RTL8192CU、MT7601U、MT7610U/MT7612U、RTL8188EU;盡量避開需要自己編驅動的 RTL8811/8812/8821 系列。
三、挑頻道:先掃一下附近
原本規劃用 ch6,但用新網卡掃一次就發現 ch6 已經擠了 6 個公司 AP:
sudo nmcli dev wifi rescan ifname wlan1
nmcli -f CHAN dev wifi list ifname wlan1 | sort -n | uniq -c
# 1 1
# 6 6 ← 很擠
# 3 9
所以熱點改用 ch1。2.4GHz 互不重疊的只有 1 / 6 / 11,挑最空的那個就好。
四、在 Pi 上建熱點(NetworkManager)
不用自己裝 hostapd、dnsmasq,NetworkManager 的 ipv4.method shared 會一次搞定:熱點、DHCP、NAT 全包,而且不會搶走 wlan0 的預設路由。
# 產生熱點密碼(只做一次,存在 root 才讀得到的地方)
sudo install -d -m 700 /etc/pi5-esp32
(tr -dc 'A-Za-z0-9' </dev/urandom || true) | head -c 16 | sudo tee /etc/pi5-esp32/ap.psk >/dev/null
sudo chmod 600 /etc/pi5-esp32/ap.psk
# 建立熱點連線
sudo nmcli con add type wifi ifname wlan1 con-name pi5-esp32-ap autoconnect yes ssid pi5-esp32 \
802-11-wireless.mode ap 802-11-wireless.band bg 802-11-wireless.channel 1 \
wifi-sec.key-mgmt wpa-psk wifi-sec.proto rsn wifi-sec.pairwise ccmp wifi-sec.group ccmp \
wifi-sec.psk "$(sudo cat /etc/pi5-esp32/ap.psk)" \
ipv4.method shared ipv4.addresses 10.42.0.1/24 ipv6.method disabled \
connection.autoconnect-priority 100
sudo nmcli con up pi5-esp32-ap
# 確認:wlan1 有 10.42.0.1,預設路由仍是 wlan0
ip -br addr show wlan1
ip route show default
加密刻意設成純 WPA2-AES(rsn + ccmp),這是 ESP32 相容性最好的組合。
五、坑 1:腳本「什麼都沒印」就結束
我一開始把上面寫成 set -euo pipefail 的 shell 腳本,第一次跑完全沒輸出、熱點也沒建,第二次跑卻成功。兇手是產生密碼那行:
tr -dc 'A-Za-z0-9' </dev/urandom | head -c 16 > ap.psk
head 讀滿 16 字元就結束,tr 接著寫入時收到 SIGPIPE 而以非 0 結束;在 pipefail 下整條管線算失敗,set -e 就默默把腳本終止了。檔案其實已經寫好,所以第二次才會過。解法是讓 tr 的失敗被吃掉:
(tr -dc 'A-Za-z0-9' </dev/urandom || true) | head -c 16 > ap.psk
六、坑 2:Docker 把 NAT 擋掉了(最隱蔽)
ESP32 連上熱點、MQTT 也通了,但狀態裡的 NTP 時間一直是空的——代表 ESP32 經 Pi 出去上網失敗。查下去:
sudo iptables -S FORWARD | head -1
# -P FORWARD DROP ← 罪魁禍首
Pi 上跑著 Docker,而 Docker 啟動時會把 iptables 的 FORWARD 預設政策改成 DROP。NetworkManager 的 shared 模式自己有放行規則,但在另一個 nftables 表裡,封包還是會被 Docker 那邊的 DROP 擋下。為什麼 MQTT 會通?因為它走 Tailscale,而 Tailscale 自己的 ts-forward 鏈有放行,剛好避開。
正確做法是把規則加在 Docker 保留給使用者的 DOCKER-USER 鏈(Docker 重啟也不會動它):
#!/usr/bin/env bash
# /usr/local/sbin/pi5-esp32-allow-forward
set -euo pipefail
AP_IF="${1:-wlan1}"; UPLINK="${2:-wlan0}"
iptables -n -L DOCKER-USER >/dev/null 2>&1 || exit 0
add() { iptables -C DOCKER-USER "$@" 2>/dev/null || iptables -I DOCKER-USER "$@"; }
add -i "$AP_IF" -o "$UPLINK" -j ACCEPT
add -i "$UPLINK" -o "$AP_IF" -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
iptables 規則重開機就會消失,所以再包成一個在 Docker 之後執行的 systemd 服務:
# /etc/systemd/system/pi5-esp32-forward.service
[Unit]
Description=Allow pi5-esp32 hotspot to forward past Docker's FORWARD DROP
After=docker.service NetworkManager.service
Wants=docker.service
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/pi5-esp32-allow-forward wlan1 wlan0
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
sudo chmod 755 /usr/local/sbin/pi5-esp32-allow-forward
sudo systemctl daemon-reload
sudo systemctl enable --now pi5-esp32-forward.service
套上之後,ESP32 的 NTP 時間馬上就有值了。只要 Pi 上有 Docker 又要做 NAT/熱點,幾乎一定會撞到這個。
七、MQTT:直接用另一台主機的 broker
原本打算在 Pi 上裝 mosquitto,後來決定不裝,直接用 gx10 上已經在跑的 Docker mosquitto。ESP32 的封包經 Pi NAT 出去,目的地若是 Tailscale 的 100.x 位址,Pi 會自動轉進
tailscale0,所以 ESP32 不用裝 Tailscale 也能連到 gx10。broker 位址用 gx10 的 Tailscale IP 而不是區網 IP,因為區網 IP 是 DHCP 給的,哪天會變;Tailscale IP 是固定的。先從 Pi 確認連得到:
timeout 3 bash -c "</dev/tcp/<gx10 的 Tailscale IP>/1883" && echo OK
八、ESP32 韌體重點
韌體沿用之前寫過的雙核模板(Core 0 跑 WiFi + OTA,Core 1 跑應用),在 Core 1 加上 MQTT、HTTP API、WebSocket、NTP:
| 功能 | 位置 | 說明 |
|---|---|---|
| MQTT | gx10:1883 | 每 5 秒發 esp32/<id>/status,收 esp32/<id>/cmd |
| HTTP API | ESP32 :8080 | /api/status、/api/led?state=on|off|toggle |
| WebSocket | ESP32 :81 | 每 2 秒推 JSON 狀態 |
| 狀態網頁 | ESP32 :80 | 顯示韌體版本、IP、RSSI |
| OTA | ESP32 UDP 3333 | ArduinoOTA,有設密碼 |
指令(MQTT 和 WebSocket 共用):led on、led off、led toggle、ping、reboot。
密碼統一放 secrets.h(在 .gitignore 裡,不進 git):
#pragma once
#define SECRET_PI_AP_PASS "..." // sudo cat /etc/pi5-esp32/ap.psk
#define SECRET_FALLBACK_SSID "家裡的 2.4G SSID"
#define SECRET_FALLBACK_PASS "..."
#define SECRET_OTA_PASS "..." // OTA 密碼,建議一定要設
MQTT 連線只在連到 Pi 熱點時才做(判斷 gateway 是不是 10.42.0.1),連到備援 WiFi 時就不嘗試,因為那邊到不了 gx10。
坑 3:裝置 ID 變成 esp32-000000
第一次燒完,MQTT topic 變成 esp32/esp32-000000/status。原因是 app 任務一開始就呼叫 WiFi.macAddress(),但那時 Core 0 的 WiFi 還沒初始化,讀回來全是 0。改成直接讀 eFuse 裡的出廠 MAC 就好:
#include <esp_mac.h>
uint8_t mac[6];
esp_read_mac(mac, ESP_MAC_WIFI_STA); // 讀 eFuse,不用等 WiFi 起來
snprintf(id, sizeof(id), "esp32-%02x%02x%02x", mac[3], mac[4], mac[5]);
改完後 ID 正確變成 esp32-48cfe4。舊 ID 留在 broker 上的 retained 訊息記得清掉:
mosquitto_pub -t esp32/esp32-000000/status -r -n
九、首次燒錄(USB,只要一次)
編譯和燒錄都在 Pi 5 上用 arduino-cli(核心 esp32:esp32 3.3.12,需要 PubSubClient、WebSockets 兩個程式庫)。燒之前先把板子上原本的 flash 整顆備份,萬一要回到舊程式還有路:
# 認板子
esptool --port /dev/ttyUSB0 chip-id
# Chip type: ESP32-D0WD (revision v1.0)
# 備份整顆 4MB flash
esptool --port /dev/ttyUSB0 -b 921600 read-flash 0 ALL backup/esp32-backup.bin
# 編譯 + 燒錄
arduino-cli compile --fqbn esp32:esp32:esp32 --output-dir build esp32/pi5_link
arduino-cli upload --fqbn esp32:esp32:esp32 -p /dev/ttyUSB0 --input-dir build esp32/pi5_link
開機 log:
=== pi5_link: ESP32 ↔ Pi 5 ===
[app] esp32-48cfe4 起來於 core 1
[ota] Trying AP1 "pi5-esp32" ...
[ota] WiFi OK: AP1 "pi5-esp32" -> 10.42.0.170 (gw 10.42.0.1, RSSI -44)
[ota] mDNS OK: esp32.local
[ota] service ready (core 0)
[app] MQTT 已連線 ...,訂閱 esp32/esp32-48cfe4/cmd
十、驗證:每一條路都走一遍
# 在 gx10 上收狀態
docker exec mosquitto mosquitto_sub -t 'esp32/+/status' -v
# esp32/esp32-48cfe4/status {"id":"esp32-48cfe4","ip":"10.42.0.170","ssid":"pi5-esp32",
# "rssi":-44,"uptime_s":33,"heap":176876,"led":false,"mqtt":true,"time":"2026-10-27 10:11:05"}
# 從 gx10 下指令
docker exec mosquitto mosquitto_pub -t esp32/esp32-48cfe4/cmd -m ping # 回 pong
docker exec mosquitto mosquitto_pub -t esp32/esp32-48cfe4/cmd -m 'led on' # 回 ok led on
# 在 Pi 上打 HTTP API
curl http://10.42.0.170:8080/api/status
curl "http://10.42.0.170:8080/api/led?state=off"
# Pi 上看誰連著熱點
cat /var/lib/NetworkManager/dnsmasq-wlan1.leases
| 路徑 | 結果 |
|---|---|
| ESP32 連熱點 | ✅ 10.42.0.170,RSSI 約 -28 ~ -49 dBm |
| MQTT 狀態上報 | ✅ 每 5 秒 |
| MQTT 指令/回覆 | ✅ ping → pong、led on → ok |
| HTTP API | ✅ |
| 經 Pi NAT 上網(NTP) | ✅(修好 Docker FORWARD 之後) |
| Pi 本身上網、Tailscale | ✅ 完全不受影響 |
十一、OTA:從此不用插線
最後一步,也是做這整套的目的。為了確定新韌體真的是走 WiFi 上去的,先把版本字串從 2.0.0 改成 2.1.0,然後在 Pi 上用 Arduino 核心附的 espota.py 推送:
ESPOTA=$(ls ~/.arduino15/packages/esp32/hardware/esp32/*/tools/espota.py | tail -1)
python3 "$ESPOTA" -i 10.42.0.170 -p 3333 -I 10.42.0.1 \
-a "$(sudo cat /etc/pi5-esp32/ota.pass)" \
-f build/pi5_link.ino.bin -r
# Sending invitation to 10.42.0.170
# Authenticating (PBKDF2-HMAC-SHA256)...
# OK
| OTA 前 | OTA 後 | |
|---|---|---|
| 韌體版本 | 2.0.0 (app+OTA template) | 2.1.0 (pi5_link) |
| uptime | 573 秒 | 11 秒(確實重開過) |
整個推送約 15 秒,重開後 MQTT 自動連回、NTP 也馬上對時。我把「編譯 + 從 DHCP 租約找 IP + 推送」包成一支 scripts/ota.sh,之後改完程式在 Pi 上一行就更新:
cd ~/pi5-dual-wifi-esp32 && bash scripts/ota.sh
十二、小結與順手記下的事
- 雙 WiFi 的關鍵:內建卡連 5GHz 上網、USB 卡開 2.4GHz 熱點,用 NetworkManager shared 模式一行建好,不碰預設路由。
- 有 Docker 就要處理 FORWARD DROP,規則加在
DOCKER-USER,再用 systemd 開機補上。 - broker 不一定要在 Pi 上:經 NAT + Tailscale,ESP32 可以直接連另一台主機的 MQTT。
- 燒錄前先備份 flash,4MB 幾十秒的事,換來隨時能退回。
- Pi 5 的 USB 孔也踩到一點小問題:某一組孔插 webcam 會反覆出現
error -71(列舉失敗),換到另一組就正常;CP2102 的接頭太寬會擋到隔壁孔,建議用短延長線。
下一步:把家裡 2.4G WiFi 填進備援設定,讓 Pi 熱點不在時 ESP32 還能自己連回家裡網路、維持可以 OTA。


沒有留言:
張貼留言