2026年10月27日 星期二

Pi 5 雙 WiFi × ESP32 : USB 網卡開 2.4G 熱點、MQTT 走 Tailscale、OTA 免插線(含完整設定與三個坑)

手上的 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。

沒有留言:

張貼留言