2026年10月27日 星期二

追一個封包 : ESP32 經 Pi 5 NAT 走 Tailscale 連到 MQTT broker 的完整路徑

上一篇〈Pi 5 雙 WiFi × ESP32〉裡,ESP32 連的是 Pi 5 開的熱點(10.42.0.x),MQTT broker 卻在另一台主機 gx10 上,而且 broker 位址填的是 gx10 的 Tailscale IP。ESP32 根本沒裝 Tailscale,這條路是怎麼通的?

這篇把那一行「經 Pi NAT → Tailscale」拆開,用 Pi 5 上實際抓下來的路由表、防火牆規則,一步一步追一個 MQTT 封包從 ESP32 走到 gx10 再走回來的完整路徑。順便解釋當初那個怪現象:為什麼 MQTT 通、但 ESP32 上不了網。

一、角色與位址

角色 介面 位址
ESP32 WiFi(連 Pi 熱點) 10.42.0.170,閘道 10.42.0.1
Pi 5 wlan1(USB 網卡,熱點) 10.42.0.1/24
wlan0(內建,公司 WiFi) 192.168.1.236
tailscale0 100.108.160.111
gx10 LAN 192.168.1.25
tailscale0 100.69.83.97(mosquitto :1883 在 Docker 裡)

(100.x 是 Tailscale 的 CGNAT 位址,只有同一個 tailnet 的裝置才連得到,公開出來沒有風險。)

二、全貌

ESP32 10.42.0.170:5xxxx ──WiFi──▶ Pi5 wlan1 (10.42.0.1)
   目的 100.69.83.97:1883              │
                                       │ ① 路由:查 table 52 → 走 tailscale0
                                       │ ② 轉發過濾:ts-forward 放行
                                       │ ③ 來源 NAT:10.42.0.170 → 100.108.160.111
                                       ▼
                              tailscale0 → tailscaled(WireGuard 加密)
                                       │ 包成 UDP,從 wlan0 送出(同區網直連)
                                       ▼
                     gx10 tailscale0 → docker-proxy → mosquitto 容器

三、第 0 步:ESP32 把封包丟給閘道

ESP32 從熱點的 DHCP(NetworkManager 內建的 dnsmasq)拿到 10.42.0.170,預設閘道 10.42.0.1。它要連 100.69.83.97:1883,這不在自己網段,於是交給閘道 Pi 5。

重點:ESP32 完全不知道 Tailscale 存在,對它來說 100.69.83.97 就只是一個「外面的 IP」。

四、第 1 步:Pi 5 決定往哪送(路由)

NetworkManager 的 ipv4.method shared 會打開 ip_forward,Pi 5 開始幫別人轉送封包。接著查 policy routing 規則:

$ ip rule
0:      from all lookup local
5210:   from all fwmark 0x80000/0xff0000 lookup main
5230:   from all fwmark 0x80000/0xff0000 lookup default
5250:   from all fwmark 0x80000/0xff0000 unreachable
5270:   from all lookup 52        ← Tailscale 的路由表,優先查
32766:  from all lookup main      ← 一般路由表(default 走 wlan0)
32767:  from all lookup default
$ ip route show table 52 | grep 100.69.83.97 100.69.83.97 dev tailscale0

Tailscale 會把 tailnet 裡每一台節點都寫進 table 52,並用優先序 5270 的規則讓它比 main 表先被查到。(5210–5250 那幾條帶 fwmark 0x80000 的,是給 tailscaled 自己送出去的 WireGuard UDP 用的,避免它被繞回 tailscale0 形成迴圈。)

用 ip route get 模擬「從 wlan1 進來、來源是 ESP32」的封包,差異一目了然:

$ ip route get 100.69.83.97 from 10.42.0.170 iif wlan1
100.69.83.97 from 10.42.0.170 dev tailscale0 table 52     ← 走 Tailscale

$ ip route get 1.1.1.1 from 10.42.0.170 iif wlan1
1.1.1.1 from 10.42.0.170 via 192.168.1.1 dev wlan0       ← 一般上網走 wlan0

目的地是 tailnet 節點 → tailscale0;其他 → wlan0。這一步是自動的,不需要任何設定。

五、第 2 步:轉發過濾(兩張表都要放行)

這台 Pi 上同時有兩套防火牆在看 FORWARD:NetworkManager 自己建的 nftables 表,以及 Docker/Tailscale 透過 iptables-nft 管的 filter 表。封包必須通過每一個 forward hook,任何一個 DROP 就沒了。

NetworkManager 的表(shared 模式自動建立):

table ip nm-shared-wlan1 {
    chain filter_forward {
        type filter hook forward priority filter; policy accept;
        ip daddr 10.42.0.0/24 oifname "wlan1" ct state { established, related } accept
        ip saddr 10.42.0.0/24 iifname "wlan1" accept      ← ESP32 出去的封包:✅
        iifname "wlan1" oifname "wlan1" accept
    }
}

Docker + Tailscale 的 iptables FORWARD:

$ sudo iptables -S FORWARD
-P FORWARD DROP                   ← Docker 把預設政策改成 DROP
-A FORWARD -j DOCKER-USER
-A FORWARD -j DOCKER-ISOLATION-STAGE-1
...(docker0 相關)
-A FORWARD -j ts-forward

$ sudo iptables -S ts-forward
-A ts-forward -i tailscale0 -j MARK --set-xmark 0x40000/0xff0000
-A ts-forward -m mark --mark 0x40000/0xff0000 -j ACCEPT
-A ts-forward -s 100.64.0.0/10 -o tailscale0 -j DROP
-A ts-forward -o tailscale0 -j ACCEPT    ← 往 tailscale0 的封包:✅

ESP32 的封包要出 tailscale0,在 ts-forward 最後一條被 ACCEPT。前一條 -s 100.64.0.0/10 -o tailscale0 -j DROP 是防偽造 Tailscale 來源用的,我們的來源是 10.42.x,不受影響。

解謎:為什麼「MQTT 通、上網不通」

目的地 出口 NM 表 iptables FORWARD 結果
gx10 的 100.x(MQTT) tailscale0 ✅ ✅ ts-forward 放行 通
NTP / 外網 wlan0 ✅ ❌ 沒有規則認領,掉進 -P DROP 不通

MQTT 是「剛好」被 Tailscale 的規則救了。解法是在 Docker 保留給使用者的 DOCKER-USER 鏈補上 wlan1 ↔ wlan0 的放行(細節見上一篇第六節)。

六、第 3 步:來源 NAT(masquerade)——整條路的關鍵

封包要離開 Pi 5 之前,經過 NAT 的 postrouting:

table ip nm-shared-wlan1 {
    chain nat_postrouting {
        type nat hook postrouting priority srcnat; policy accept;
        ip saddr 10.42.0.0/24 ip daddr != 10.42.0.0/24 masquerade
    }
}

這條規則不管出口是哪張網卡,只要來源是熱點網段、目的不是熱點網段就 masquerade。出口是 tailscale0,所以來源被改成 tailscale0 的位址:

改寫前:10.42.0.170:5xxxx    → 100.69.83.97:1883
改寫後:100.108.160.111:yyyy → 100.69.83.97:1883

為什麼一定要這步?

  • gx10 不知道 10.42.0.0/24 在哪。來源若保持 10.42.0.170,gx10 回封包時查不到路由。
  • Tailscale 本身也會擋:WireGuard 的 AllowedIPs 機制下,gx10 只接受「來自 Pi 5 這個 peer、來源位址是 Pi 5 自己 100.x(或它有公告的子網段)」的封包。10.42.0.170 兩者都不是,會直接被丟掉。

改成 100.108.160.111 之後,在 gx10 眼中這就是 Pi 5 自己發起的連線,一切合法。Linux 的 conntrack 記下對應 100.108.160.111:yyyy ⇄ 10.42.0.170:5xxxx,回程時用得到。

附帶一提,Tailscale 自己也有一條 masquerade(ts-postrouting),但它只處理帶 0x40000 標記、也就是「從 tailscale0 進來再轉出去」的封包,跟我們這個方向無關;真正做事的是 NetworkManager 那條。

七、第 4 步:WireGuard 加密,從 wlan0 出門

tailscale0 是 TUN 虛擬網卡,封包寫進去後由 tailscaled 用 WireGuard 加密,包成 UDP(預設 41641 埠),再從實體網卡送出。這個 UDP 封包本身帶著 fwmark 0x80000,所以會命中前面 5210 那條規則、查 main 表從 wlan0 出去,而不是又繞回 tailscale0。

因為 Pi 5 和 gx10 剛好在同一個公司區網,Tailscale 會打通區網直連(192.168.1.236 → 192.168.1.25:41641),不經過外部 DERP 中繼,延遲只有幾 ms。確認方式:

$ tailscale ping gx10-6679
pong from gx10-6679 (100.69.83.97) via 192.168.1.25:41641 in 5ms   ← "via 區網IP" 就是直連

八、第 5 步:gx10 收件

gx10 的 tailscaled 解密後,從 tailscale0 吐出 100.108.160.111 → 100.69.83.97:1883。1883 是 Docker 發佈的埠(0.0.0.0:1883->1883/tcp),經 docker-proxy 轉進 mosquitto 容器。所以在容器裡看,連線來源是 Docker 閘道 172.17.0.1:

$ docker exec mosquitto netstat -tn | grep 1883
tcp   0   0 172.17.0.2:1883   172.17.0.1:60584   ESTABLISHED

經過兩層轉換(Pi 的 masquerade + docker-proxy),broker 已經看不出這是 ESP32 了;要辨識裝置,得靠 MQTT 的 client ID(esp32-48cfe4)。

九、回程

  1. mosquitto 回覆 → docker-proxy → gx10 tailscale0,目的 100.108.160.111。
  2. WireGuard 加密,UDP 直連回 Pi 5 的 wlan0,tailscaled 解密後從 Pi 的 tailscale0 吐出。
  3. conntrack 查到對應,把目的地還原成 10.42.0.170:5xxxx。注意 NAT 規則只在連線第一個封包時評估,回程不會再跑一次 masquerade。
  4. 路由:10.42.0.0/24 是 wlan1 直連網段 → 從 wlan1 送出。
  5. 轉發過濾:NM 表的 ct state established 放行;iptables 那邊,ts-forward 對「從 tailscale0 進來」的封包打上 0x40000 標記後 ACCEPT。
  6. ESP32 收到 MQTT 回應。

十、這個設計帶來的三個特性

1. 單向:ESP32 能找 gx10,gx10 找不到 ESP32

gx10(以及 tailnet 裡任何機器)的路由表裡沒有 10.42.0.0/24,無法主動連 ESP32。所以 ESP32 的 HTTP API(:8080)、WebSocket(:81)、OTA(:3333)都只能在 Pi 5 上操作。MQTT 剛好是「裝置主動連 broker」的模型,天生適合這種單向架構——指令也是 ESP32 自己去 broker 訂閱拿回來的。

2. 熱點裡的裝置等於能碰到整個 tailnet

table 52 裡的每個 100.x 都會被轉進 tailscale0,而且來源被改成 Pi 5 的身分。換句話說,任何連上這個熱點的裝置,都能以 Pi 5 的名義連到你所有 Tailscale 機器。目前熱點只有自己的 ESP32、密碼是隨機 16 字元的 WPA2,風險可控;但如果要給不信任的裝置用,應該在 forward 加規則,限制熱點只能連特定的 IP:port(例如只放行 gx10:1883)。

3. 想要雙向:用 Subnet Router

如果希望 gx10 或筆電能直接打 ESP32 的 HTTP API、甚至用 Arduino IDE 直接 OTA,正規做法是讓 Pi 5 把熱點網段公告進 tailnet:

# Pi 5 上
sudo tailscale set --advertise-routes=10.42.0.0/24
# 然後到 Tailscale 管理後台核准這條路由;
# Linux 用戶端要 tailscale set --accept-routes,Windows/macOS 預設接受

這樣其他 tailnet 節點會在自己的 table 52 裡多一條 10.42.0.0/24 → Pi 5,連線就能雙向。比起在筆電手動加靜態路由、把熱點開放給整個公司區網,這個做法只對自己的 Tailscale 裝置開放,安全很多。

十一、小結

  • 路由:Tailscale 的 table 52(優先序 5270)讓 100.x 目的地自動走 tailscale0,不用設定。
  • 過濾:同時有 NM 的 nftables 表和 Docker/Tailscale 的 iptables 表,兩邊都要放行;Docker 的 FORWARD DROP 只會讓走 wlan0 的流量出事。
  • NAT:NM shared 的 masquerade 不挑出口,把 10.42.x 換成 Pi 5 的 100.x 身分,這是 Tailscale 願意收這個封包的前提。
  • 傳輸:WireGuard over UDP,同區網會直連,延遲幾 ms。
  • 代價:單向,而且熱點裝置繼承了 Pi 5 在 tailnet 裡的存取範圍。

排查類似問題時,最好用的三個指令:ip route get <目的> from <來源> iif <入口> 看路由、iptables -S + nft list ruleset 看過濾和 NAT、tailscale ping 看是不是直連。

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。

2026年10月24日 星期六

遠端清理 Sony BRAVIA 電視:Pi4 工具鏈安裝 + ADB 連線實戰(cleanyourtv 第一集)

最近翻開一個開源專案 cleanyourtv(26k+ stars),專門把 Android TV / Google TV 的肥軟「去肥」:遙測、廣告服務、預載垃圾 app 全數 disable,再換上一個乾淨、Apple TV 風格的 launcher。重點是全部走「停用」而非「卸載」,一行 restore.sh 就能完整還原。

這台主角是家裡的 Sony BRAVIA 4K UR3,執行機則是跟電視同一個 LAN 的 Raspberry Pi 4。本文是第一集:把「改裝電視」的三個前置完整走通——Pi4 要裝哪些軟體、電視要開哪些設定、以及 ADB-over-Wi-Fi 連線的完整測試。實際的 debloat 與 launcher 安裝,放第二集。

一、電視資訊(實測)

以下全部是用 ADB getprop 從電視抓回來的,不是翻型錄:

項目 值
品牌/型號 Sony BRAVIA 4K UR3
裝置代號 BRAVIA_UR3(MediaTek mt5893)
系統 Android TV 10(API 29)
韌體 BRAVIA_UR3_TW-user 10 QTG3.200305.006.S97 662301(台灣版)
IP 192.168.1.115
ADB 埠 5555(TCP,走 Wi-Fi)

兩個對後繼步驟很關鍵的事實:

  • 這台型號在 repo 的實測清單裡(examples/sony-bravia-4k-ur3),連韌體字串(QTG3.200305.006.S97)都吻合,現成有一份為它量身做的 debloat 清單。
  • 官方 launcher 的最低系統要求是 Android 8.0(minSdk 26);這台是 SDK 29,裝得上。

二、Pi4 要裝的軟體

Pi4 的角色就是「跟電視同一台交換器的 Linux 電腦」。工具鏈分兩塊:Python 工具(純 Python 的 ADB client,不用裝 adb 二進位)+ Android SDK(build launcher 用)。

組件 用途 我 pi4 的版本
git 抓 repo 2.43
Python 3 + venv 官方工具 tools/tv.py 的核心 3.13.5
adb-shell / rsa / cryptography Python 端 ADB 通訊與金鑰 0.4.4 / 4.9.1
JDK 17+ Gradle build launcher OpenJDK 21
Android platform-tools adb 協議工具 34.0.x
Android platform android-34 launcher compileSdk=34 android-34
Android build-tools aapt2/d8 等 34.0.0
Gradle 8.9 建構 由 gradle wrapper 自動下載

空間需求:SDK + Gradle + build cache 約 3–4 GB;我 pi4 有 33 GB 可用、7.6 GB RAM,夠用。

完整安裝指令(全在 pi4 上跑,可複製):

# 1) 抓 repo
git clone https://github.com/Gathin23/cleanyourtv.git ~/cleanyourtv
cd ~/cleanyourtv

# 2) Python 工具鏈(官方工具是純 Python,不用裝 adb)
cd tools
python3 -m venv venv
venv/bin/pip install -r requirements.txt
cd ..

# 3) Android SDK(給 launcher build 用;sdkmanager 需要 Java 17+)
mkdir -p ~/Android/Sdk
curl -sSLo /tmp/cmdtools.zip \
  https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
unzip -q /tmp/cmdtools.zip -d /tmp/sdkx
mkdir -p ~/Android/Sdk/cmdline-tools
mv /tmp/sdkx/cmdline-tools ~/Android/Sdk/cmdline-tools/latest
~/Android/Sdk/cmdline-tools/latest/bin/sdkmanager \
  --sdk_root=$HOME/Android/Sdk --licenses
~/Android/Sdk/cmdline-tools/latest/bin/sdkmanager \
  --sdk_root=$HOME/Android/Sdk \
  "platform-tools" "platforms;android-34" "build-tools;34.0.0"

第一次跑 SDK 那兩行會下載 1–2 GB,pi4 這種 100 Mbps LAN 大概五六分鐘。

三、電視端要做的步驟

全部用電視遙控,約三到五分鐘:

  1. 電視保持在 Wi-Fi(跟 Pi4 同一個 LAN),不要進休眠。
  2. 設定 → 系統(有的機型叫「裝置偏好」)→ 關於 → 把「Build」連按 7 次,出現「您現在處於開發人員模式」。
  3. 回上一層 → 開發人員選項 → USB 偵錯 = ON。
  4. 若看到「ADB over network / 網路偵錯」類型的開關,一併打開。我的 Sony 打開後 5555 埠就一直在監聽。
  5. 保留電視開著;首次連線時電視畫面會彈「Allow USB debugging?(允許 USB 偵錯?)」→ 按允許。之後同一組金鑰就不會再問。

四、連線測試(實測輸出)

先從 pi4 確認同一網路:ping 192.168.1.115 通,RTT 12 ms。

然後一發 probe:

TV_HOST=192.168.1.115 tools/venv/bin/python -I tools/tv.py --probe

輸出(一次就成功,電視端那下「允許」我按掉了):

ro.product.model = BRAVIA 4K UR3
ro.product.device = BRAVIA_UR3
ro.build.version.release = 10
ro.build.version.sdk = 29
ro.build.display.id = BRAVIA_UR3_TW-user 10 QTG3.200305.006.S97 662301 release-keys
ro.product.manufacturer = Sony
ro.adb.secure = 1
service.adb.tcp.port = 5555

ro.adb.secure = 1 代表這台是「安全模式」的 ADB——每個新機器第一次連都要人在電視上按「允許」,不會被網內任何其他主機直接撳開,算安全的。

tv.py 還內建 --screenshot:直接把電視畫面抓回檔案,做「遠端改裝、人不用站在電視前」的驗證非常方便,第二集會用。

五、注意事項(改裝前務必看完)

  • 全部是停用、不是卸載:動到的只有 pm disable-user,tools/restore.sh 一行命令全數還原。這台 Sony 的還原指令長這樣:
    TV_HOST=192.168.1.115 tools/restore.sh examples/sony-bravia-4k-ur3/debloat.txt
  • 不要手動 disable 系統核心包:repo 內建保護——任何 list 裡出現 com.android.*、com.google.android.*、com.sony.dtv.tvapi、tvinput*、braviasync、sonysystemservice、update* 這類,工具直接拒絕執行。
  • 這台 Sony 的官方清單保留項(重點,都是要保命的服務):Chromecast 內嵌、AirPlay、HomeKit、BraviaSync(多裝置 HDMI 聯動)、調頻器(tuner)、Sony 韌體更新(OTA)。
  • 系統更新副作用:Sony 推系統更新時,有可能把部分 disable 的包重新啟用。用 tools/check.py 定期複查;發現被還原的包,重跑一次 disable.sh 就好。
  • 保固:專案 README 明說軟體改裝可能影響保固。實務上全部是可復原的 disable(不是刷機),風險很低,但仍請自行評估。
  • 網路安全:ADB 5555 只該在 LAN 開放,不要對外網曝光。用完想收,回電視開發人員選項把 USB 偵測關掉(注意:部分機型「USB 偵測」一關,5555 也一起關,屬正常行為)。
  • 遙控器麥克風:這份清單會把語音助理(katniss / tv.assistant)也停用,遙控器上的麥克風搜尋隨之失效——想保留的人,把清單裡那三行註解掉再跑。

六、本集到此

前置三件事全部收尾:

項 狀態
Pi4 工具鏈(Python + JDK + Android SDK) ✅ 全部裝好
電視 ADB 開啟 + 允許金鑰 ✅ 192.168.1.115:5555 一次連通
型號比對 + 現成清單 ✅ sony-bravia-4k-ur3,韌體字串吻合

下一集:套用 debloat 清單、build 並安裝 Apple TV 風格 launcher、主題挑選,以及「改裝前/改裝後」的截圖對照。

2026年10月22日 星期四

ESP32 雙核分工:OTA 服務跑 Core 0、應用跑 Core 1——實作紀錄與踩坑筆記

緣起

這片 ESP32 D0WD(WROOM-32,4MB flash)之前的 OTA 韌體(v1.1)已經跑通:ArduinoOTA(UDP 3333 協定)、雙 AP 自動切換(家裡 AP1 / 公司 AP2)、狀態網頁。

但下一個問題很實際:以後要寫別的應用(感測、馬達、MCU 通訊)到這片板子,如果新應用沒把 OTA 帶進去,就不能免 USB 更新了。

所以定了一個架構原則:OTA 是「服務」,不是某一顆韌體的功能 — 讓它常駐在一個核心、永遠在線;應用放在另一個核心,隨便換。

架構

ESP32 是雙核 Xtensa,FreeRTOS 可以把任務釘(pin)在指定核心:

核心 角色 內容
Core 0 常駐服務 WiFi 雙 AP 連線 + 30 秒重試、ArduinoOTA(UDP 3333)、狀態網頁(:80)
Core 1 應用 你的業務邏輯(範例:藍燈 1Hz + 計數)

檔案구조(share-folder 與 Pi5 各一份,保持同步):

ESP32/
├── common/wifi_config.h       # 共用 WiFi/OTA 設定(AP1 家 / AP2 公司 / OTA 密碼)
├── esp32_ota/                 # 純 OTA 韌體 v1.1(首次 USB 刷機可用)
└── esp32_app_template/        # 雙核應用模板 v2.0
    ├── esp32_app_template.ino # 主程式:啟服務 + 建應用任務
    ├── ota_service.h          # 對外只暴露 startOtaService()
    ├── ota_service.cpp        # Core-0 常駐:WiFi/OTA/狀態頁
    └── README.txt

新應用做法:複製整個 esp32_app_template/ 為專案名,只改 .ino 裡標了 ★★★ 的區塊與 LED 腳位,服務檔不動。

核心程式

// esp32_app_template.ino — 先啟服務,再起應用
void setup() {
  Serial.begin(115200);
  startOtaService();   /* Core 0:WiFi + OTA + 狀態頁 */
  /* Core 1:你的應用 */
  xTaskCreatePinnedToCore(appTaskFunc, "app", 8192, nullptr, 2, nullptr, 1);
}

// ota_service.cpp — tryConnect 關鍵段
for (size_t i = 0; i < 2; i++) {
  WiFi.disconnect(true, aps[i].ssid);
  WiFi.setMinSecurity(WIFI_AUTH_WPA2_PSK);   /* WPA2 地板,可向上協商 WPA3 */
  WiFi.begin(aps[i].ssid, aps[i].pass);      /* ★ 這行漏掉會災難(見踩坑 #1) */
  unsigned long t0 = millis();
  while (WiFi.status() != WL_CONNECTED && millis() - t0 < 12000) delay(250);
  if (WiFi.status() == WL_CONNECTED) return (uint8_t)(i + 1);
}

更新流程:

  1. 首次:USB 或 IDE 刷(板子還沒 WiFi 時)。
  2. 之後:python3 ota_flash.py <bin> --ip <板子IP>(或 --ip esp32.local)。
  3. 或用 Arduino IDE:Tools → Port → IP Network,直接 Upload。

驗證結果(2026-10-22)

  • 公司 AP2 1.3 秒關聯成功,IP 192.168.1.170,RSSI -38
  • 狀態頁 :80 全綠:韌體 2.0.0、連線 AP、核配置「Core 0=OTA/WiFi/網頁 | Core 1=應用」
  • OTA 自我刷機(模板 963KB)成功,自動重啟、自動重連
  • 應用任務(core 1)1Hz 藍燈 + tick 計數正常

踩坑筆記(重點)

1. tryConnect 漏了 WiFi.begin(浪費一整天)

v1.1 的試連函式有 WiFi.begin(ssid, pass),移植到任務裡的版本漏了這一行 — 變成「每 12 秒乾等一次狀態」,任何 AP 都「失敗」。症狀非常騙人:

  • 掃描看得到 SSID
  • 關聯每次都 12 秒超時
  • 另一台裝置(筆電)在同一個 AP 上正常連線
  • 斷電重開、換 2.4G 熱點、換密碼都沒用

最後逐行 diff v1.1 vs v2.0 才發現就缺這一行。教訓:看到「掃得到、關聯超時、別台裝置正常」,第一步先看自己的韌體有沒有真的下 WiFi.begin,別急著往 AP 端 / WPA3 / 5GHz / 供電 / 射頻硬體推。

2. esp32 core 3.3.12 的 API 差異

慣用寫法 3.3.12 實際
xTaskCreatePinnedToTask(...) 是 xTaskCreatePinnedToCore
LED_BUILTIN 不存在,自定義(本板藍燈 = GPIO2)
WiFi.encryptionType() 不存在
ArduinoOTA.getType()/getErrorString() 不存在(error 碼 0=AUTH…4=END)
Client c = server.available() Client 是抽象(要用 WiFiClient);available() 已被 accept() 取代(警告)
WiFi.disconnect(true, ssid) 簽名 (bool, unsigned long),第二參不是 SSID
WiFi.disconnectReason() 只有 disconnectReasonName(code),code 自己帶
mDNS 掛 arduino 服務 MDNS.enableArduino(port, auth)(與 ArduinoOTA 自註冊重複會打「Service already exists」ERROR,無害)

3. ArduinoOTA 協定(3.3.x)不是常見的 TCP 推流

流程:傳控制資料包到板子 UDP 3333 → 板子回 OK(或 AUTH nonce 走 PBKDF2 認證)→ 板子主動 TCP 連回你的聽取埠 → 你送出 .bin 原始位元組 → 板子定期回十進位 ack → 驗完 MD5 回 OK 並重啟。ota_flash.py(純標準函式庫,任何有 Python 3 的機器都能跑)就是照這個實作。

4. OTA 密碼

WIFI_OTA_PASS 留空 = 無認證,同一網路任何人可刷你的板。公司/對外網路建議填密碼(腳本 --password 參數配合)。

5. 板子 IP 與網路綁定

IP 是 DHCP 給的,換網路(家↔公司)會變。找不到就:--ip esp32.local(mDNS)、開狀態頁、或 ping 掃。

6. 腳位紅線(這片 D0WD)

  • GPIO 6–11:內部 flash,禁用
  • 34/35/36/39:只讀
  • 32/33 是 ADC1,WiFi 開著仍可量;25/26/27 是 ADC2,WiFi 一開就讀不到
  • 0/2/15 是 strapping,2/15 當輸出要注意上電狀態
  • 藍燈 = GPIO2

7. 小工具坑

cp 來源檔進目錄時,目標若只寫到目錄沒帶檔名,檔案會以來源檔名掉進目錄 — 我們因此製造出一個 os.cpp,連結時報「multiple definition」,花了一輪才找到。(同步檔案時目標永遠寫全路徑。)

結論

一個可以直接照抄的配方:

OTA 服務 = Core 0 常駐任務(WiFi + ArduinoOTA + 狀態頁);應用 = Core 1 可替換任務;兩方共享一份 wifi_config.h — 以後每個新應用都是「生來就有 OTA」,USB 只在首次亮相。

整組原始碼在 Arduino/Source/FW/ESP32/(share-folder 與 Pi5 各一份),模板說明在 esp32_app_template/README.txt。