顯示具有 docker 標籤的文章。 顯示所有文章
顯示具有 docker 標籤的文章。 顯示所有文章

2026年10月27日 星期二

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

📺 影片版(約 9 分鐘):想先看實際操作的話可以直接看這支影片,完整步驟、設定與程式碼在下方文字版。
YouTube:https://youtu.be/7rlWNReOv-8(winson-DIY 頻道)

上一篇〈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 免插線(含完整設定與三個坑)

📺 影片版(約 7 分鐘):想先看實際操作的話可以直接看這支影片,完整步驟、設定與程式碼在下方文字版。
YouTube:https://youtu.be/ZKFBWpY9sjM(winson-DIY 頻道)

手上的 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年6月6日 星期六

ASUS Ascent GX10 AI agent 開發工具設定與安裝

📺 影片版(約 8 分鐘):想先看實際操作的話可以直接看這支影片,完整步驟、設定與程式碼在下方文字版。
YouTube:https://youtu.be/p4KSkddnM6E(winson-DIY 頻道)

入手一台ASUS Ascent GX10非常期待能夠做一些有用的專案, 在此之前先來把一些要用到的工具確認跟安裝好.

 1. SSH 連線

可以不用連接螢幕用NB或是手機連進去GX10主機.
在GX10主機安裝好後, 就可以直接使用.

2. FTP 資料檔案傳輸
方便傳輸需要的檔案更新.
在GX10主機安裝好後, 就可以直接使用. 設定SFTP格式.







3. 遠端桌面連接
可直接進入主機的GUI畫面操作

4. TailScale 內外網路穿透工具
可在不同網路存取控制GX10主機
安裝後設定開機自動啟用Tailscale服務

sudo systemctl enable --now tailscaled


5. Openclaw
下載並安裝 Openclaw:









6. Ollama

下載並安裝 Ollama:

curl -fsSL https://ollama.com/install.sh | sh










7. Docker 
在GX10主機安裝好後, 就可以直接使用.






補充(2026/10 更新):用了四個月後的實際環境與設定細節
這篇是剛拿到 GX10 時的安裝清單。用了四個月之後,把每一項實際的版本、設定方式和踩過的小坑補上來,給剛入手的人參考。

補充一、這台 GX10 的實際環境
項目實際值
作業系統Ubuntu 24.04.5 LTS(NVIDIA 客製核心 7.0.0-nvidia)
CPU20 核 ARM(aarch64)
記憶體121 GB 統一記憶體(CPU 與 GPU 共用)
GPUNVIDIA GB10,驅動 580.178.04
儲存3.6 TB NVMe,目前用了 14%

注意:GX10 是 ARM64 架構,不是一般 PC 的 x86。下面這些工具的官方安裝腳本都支援 ARM64,但如果要裝其他軟體,記得選 arm64 / aarch64 版本。

補充二、各工具目前的版本與狀態
工具版本狀態 / 埠號
SSH(OpenSSH)9.6p1系統服務,port 22
SFTPOpenSSH 內建跟 SSH 共用 port 22
遠端桌面GNOME 遠端桌面 46RDP,port 3389
Tailscale1.104.1開機自動啟用
OpenClawv2026.7.1Gateway 常駐,port 18789
Ollama0.40.3開機自動啟用,127.0.0.1:11434
Docker29.6.2已裝 NVIDIA Container Toolkit 1.20.1

補充三、各項設定細節與小坑
1. SSH:改用金鑰免密碼
從筆電連進 GX10,建議第一天就改成金鑰登入,之後腳本、AI agent 都能免密碼操作:
# 在筆電上(Windows 11 / macOS / Linux 都內建 ssh)
ssh-keygen -t ed25519
ssh-copy-id 使用者@GX10的IP     # Windows 沒有 ssh-copy-id,手動把 .pub 內容附加到 GX10 的 ~/.ssh/authorized_keys
更完整的做法可以參考:GX10 跨機控制 Pi 5:從 SSH 金鑰到遠端自動化

2. FTP:其實用的是 SFTP,不用另外裝 FTP 伺服器
GX10 上沒有裝 vsftpd 之類的 FTP 伺服器,檔案傳輸用的是 OpenSSH 內建的 SFTP(/etc/ssh/sshd_config 裡的 Subsystem sftp)。
  • FileZilla 或 WinSCP:協定選 SFTP,主機填 GX10 的 IP,port 填 22,帳號密碼跟 SSH 一樣
  • 好處:不用多開 port 21、傳輸有加密,SSH 金鑰也能直接用

3. 遠端桌面:Ubuntu 內建的就夠用,不要跟 xrdp 同時開
Ubuntu 24.04 內建「GNOME 遠端桌面」,走標準 RDP 協定(port 3389),Windows 直接用「遠端桌面連線」(mstsc)就能連。
  • 開啟方式:設定 → 系統 → 遠端桌面,打開並設定帳號密碼
  • 小坑:我這台另外也裝過 xrdp,兩個都要用 3389,結果 xrdp 一直啟動失敗。兩者擇一就好,用內建的最省事

4. Tailscale:裝好後還要登入一次
curl -fsSL https://tailscale.com/install.sh | sh
sudo systemctl enable --now tailscaled
sudo tailscale up        # 印出網址,用瀏覽器登入 Tailscale 帳號
登入後 GX10 會拿到一個固定的 100.x.x.x 位址,在任何網路都連得到,不用開路由器的埠。實際應用可以參考:Pi 3 的資源與 GX10 透過 Tailscale 外網溝通

5. OpenClaw:Gateway 以使用者服務常駐
安裝腳本跑完後,OpenClaw 的 Gateway 會以 systemd 使用者服務常駐(port 18789):
systemctl --user status openclaw-gateway
journalctl --user -u openclaw-gateway -f      # 看即時 log
小坑:使用者服務預設要登入才會啟動。要讓 GX10 重開機後不用登入就自動起來,要開 linger:
sudo loginctl enable-linger $USER

6. Ollama:預設只給本機用,模型可以常駐
Ollama 預設只聽 127.0.0.1:11434,區網其他電腦連不到,這是安全的預設值。兩個常用的調整都寫在 systemd override:
sudo systemctl edit ollama
# 加入:
[Service]
Environment="OLLAMA_KEEP_ALIVE=30m"      # 模型用完留在記憶體 30 分鐘,不用每次重新載入
Environment="OLLAMA_HOST=0.0.0.0"        # 需要給區網其他機器用時才加
sudo systemctl restart ollama
用 ollama ps 可以確認模型有沒有跑在 GPU 上,這台顯示 100% GPU。121 GB 統一記憶體是 GX10 最大的優勢,目前裝的模型:
模型大小用途
qwen3.6:35b-a3b23 GBMoE(每次只啟用約 3B 參數),大模型但推論快
gemma4:31b19 GB通用對話
qwen3.8:27b17 GBAI agent(Hermes profile 用)
gpt-oss:20b13 GB通用 / 推理
gemma4:e4b9.6 GB快速意圖判斷(控制 Pi 5 用它,約 0.4 秒)
qwen2.5vl:7b6.0 GB視覺(看圖)
deepseek-coder:6.7b3.8 GB程式碼
qwen3-embedding:0.6b639 MB向量嵌入(RAG)

7. Docker:要讓容器用 GPU,還要登錄 NVIDIA runtime
這台已經裝了 NVIDIA Container Toolkit,但 Docker 目前只登錄了預設的 runc。如果要在容器裡用 GPU,先執行:
sudo nvidia-ctk runtime configure --runtime=docker
sudo systemctl restart docker
docker run --rm --gpus all ubuntu nvidia-smi      # 能看到 GB10 就成功
目前這台用 Docker 跑的服務:WordPress + MySQL(部落格)、searxng(自架搜尋引擎)、mosquitto(MQTT broker,控制 Pi 5、ESP32 用)。

補充四、後來加入的 AI agent 工具
  • Hermes Agent(v0.22):開了 4 個 profile(default / coder / designer / tester),各自一個 Gateway 接 Telegram,手機傳訊息就能叫它做事,背後可以接 Ollama 的地端模型
  • Claude Code:在 GX10 上以 systemd 服務常駐 Remote Control,從手機或網頁就能下指令操作這台主機,另外配一個每 3 分鐘檢查的 watchdog,斷線卡住會自動重啟

補充五、安裝完的檢查清單
systemctl is-active ssh tailscaled ollama docker
systemctl --user is-active openclaw-gateway
tailscale status            # 看得到自己和其他裝置
ollama ps                   # 模型在 GPU 上
docker ps                   # 容器有在跑
nvidia-smi                  # GPU 正常

補充六、用這些工具做出來的專案


延伸閱讀

2025年1月22日 星期三

CVAT-標註模型 Deploy a model with nuclio successful, but status unhealthy

使用有Nuclio的CVAT docker image
Nuclio是一個無伺服器(Serverless)平台,Serverless架構的Nuclio較一般的server簡單,適合用來快速製作無需資料庫的API。
前往安裝CVAT的位置
docker compose down # 關閉該CVAT的docker image
啟動具Nuclio的CVAT container。
docker compose -f docker-compose.yml -f components/serverless/docker-compose.serverless.yml up -d # 啟動具serverless 架構的CVAT container

當在CVAT部屬自動標註模型時, 重開了Docker後會造成部屬的自動標註模型"unhealthy"的情況

先在Nuclio上的模型按 stop function
再到Docker上的模型按 start
然後再到Nuclio上啟動



延伸閱讀