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年10月24日 星期六

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

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

最近翻開一個開源專案 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——實作紀錄與踩坑筆記

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

緣起

這片 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。



延伸閱讀

2026年10月21日 星期三

[OpenAI API] 用免費額度:從 Tier 確認到 Hermes 接入的完整流程 + 踩坑記錄

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

前言:為什麼要把 Hermes 接上 OpenAI

Hermes 預設跑本機 Ollama 模型(qwen3.8:27b、gpt-oss:20b),優點是免費、不花錢;缺點是本機 ARM 機上 tok/s 不高、長 context 慢。想讓某一條 profile(例如 designer)用 OpenAI 的快模型,只要把 API key 接上、把模型指過去——這篇文章整理完整流程、實際踩到的坑,以及「免費額度」到底怎麼算。

一、先搞清楚:OpenAI 的「免費額度」

OpenAI API 平台有一個「資料共享換每日免費 token」的機制:同意把 API 的輸入與輸出分享給 OpenAI 用來改進模型,系統就每天給一份免費的 token 額度,用完才開始扣儲值餘額。

開啟步驟

  1. 確認帳戶已儲值:帳戶要是 Pay-as-you-go(按量計費),而且有過付費儲值紀錄(至少 $5),才有資格開啟。
  2. 登入 https://platform.openai.com → 左側選單 Settings(齒輪圖示)→ Organization → Data controls。
  3. 找到「Share inputs and outputs with OpenAI」(與 OpenAI 共享輸入和輸出),切到 ON。

注意事項

  • 額度怎麼算:開啟後每天自動重置一份免費 token,只適用於特定模型;各模型的額度與適用範圍以 Data controls 頁面上的說明為準。
  • 代價是資料共享:經過這個組織送出的內容會被拿去改進模型——含個資、公司或客戶資料的請求,不要走這個組織的 key。
  • 用完照常計費:免費額度用完就照一般價格從儲值餘額扣款,所以後面的「預算警示」一樣要設。

確認目前帳號層級(實測步驟)

  1. 開 https://platform.openai.com(要登進 API 平台,不是 ChatGPT)
  2. 左側選單 Settings → Limits
  3. Usage Tiers 區塊顯示目前等級(如上圖):Free tier → Build($5)→ Launch($100)→ Grow($500),累積儲值達門檻會自動升級
  4. 同一頁上方可以設 預算警示(例如花到 80%、100% 時寄信)
  5. 往下的 Rate limits 是各模型的 RPM / TPM — 以該頁表格為準,各模型、各等級數字不同

小提示:同一個 API key 對應的 org 才有 tier;如果你有多個帳號,先確認你要用哪個,key 也是那個帳號發的。

二、建立 API Key

  1. platform.openai.com → API keys → Create new secret key
  2. 記下 key(以 sk- 開頭)。離開頁面後就再也看不到完整 key——存進檔案或密碼管理器
  3. 權限建議依需求最小化;別用超級管理員 key
  4. 這個 key 就是帳單歸屬:之後 Hermes 每打一個 token 都算在你這把 key 上

三、把 OpenAI 接到 Hermes(實測流程)

以下每個指令都在 designer profile 實測過(2026-10-21):

1. 把 key 放進 .env(金鑰絕不過 chat)

# 用你的 profile 的 .env(預設在 ~/.hermes/.env,profile 各有獨立一份)
echo 'OPENAI_API_KEY=***' >> ~/.hermes/profiles/designer/.env
chmod 600 ~/.hermes/profiles/designer/.env

Hermes 的約定:secret 放 .env,一般設定放 config.yaml。放對了,log / 輸出 / 回應都會自動把 key 遮蔽成 ***。

2. 切換 provider 和模型

hermes --profile designer config set model.provider openai
hermes --profile designer config set model.default gpt-4o-mini

gpt-4o-mini 是「聰明 / 便宜 / 快」三者平衡的常見選擇;要更強可以換更高階模型,但單次成本會差幾十倍,先跑 mini 驗證通話再升。

3. 清掉 Ollama 殘留(最容易漏)

如果這個 profile 之前指向本機 Ollama,config 裡會有兩個欄位蓋過你的 OpenAI 設定:

model:
  base_url: http://127.0.0.1:11434/v1/    # 這行會讓所有請求打到本機
  api_key: ollama                          # 這行會讓 OpenAI 401

必須清:

hermes --profile designer config unset model.base_url
hermes --profile designer config unset model.api_key

沒清的話,你會看到「OpenAI API 401 / 找不到模型」的假象,其實是請求被打回 127.0.0.1:11434。

4. 重啟 gateway

每支 profile 有獨立的 systemd user service,例如 designer 是 hermes-gateway-designer。重啟指令要在 gateway 外面的 terminal 跑(gateway 內部會擋住自我重啟):

systemctl --user restart hermes-gateway-designer

5. 驗證(兩層)

# 層 1:CLI 直跑,確認 OpenAI 真的回
hermes --profile designer chat -q 'Reply with exactly: OK'

層 2:在 designer 的 Telegram bot 發 /status,應看到:

Model: gpt-4o-mini

四、真正的坑:舊 session 還鎖著舊模型

改完 config、重啟完 gateway,發 /status 卻看到 gpt-oss:20b(本機模型)——那是這個 session 之前用 /model 切過的舊值。Hermes 允許「session-level model override」,會蓋過 config 預設。

解法(二擇一):

  1. 在這個 bot 對話發 /new — 開新 session,吃 config 預設 = gpt-4o-mini
  2. 或在現 session 發 /model gpt-4o-mini — 本 session 直接切

/new 是乾淨做法;/model 是「保留這個 session 的記憶 / context」的做法。

五、注意事項(成本、Tier、安全)

1. 成本:context 比你以為的大

Hermes 每一回合都帶 system prompt + tools + 歷史 context,一次 API call 進到 10 萬 token 很平常。gpt-4o-mini 單 turn 大概 $0.01~0.03;更高階模型可能一個 turn $3~15。建議:

  • 開 Billing → Set budget alert,把月預算鎖在 $5~$20,超了會收到 mail
  • 跑長任務前先估:每回合平均 input token × 單價 × 預估回合數

2. Tier 卡住(429 Too Many Requests)

Tier 1 的 RPM 很緊,連發幾條就 429。解法:降頻、換更便宜的模型、累積消費升 Tier(門檻以 Billing 頁顯示為準)、或用 hermes fallback 接備援 provider。

3. API key 安全

  • 永遠不要把 key 貼在 chat、Telegram、GitHub 或任何公開處
  • Hermes 的 secret-redaction 會遮蔽輸出,不代表你可以亂放
  • 如果 key 洩漏:立刻回平台 Revoke,重發一把、改 .env、重啟 gateway

4. .env 權限

chmod 600 ~/.hermes/profiles/designer/.env

只有 owner 可讀;多 user 主機上尤其重要。

5. 別混 OpenAI 和 Ollama

同一台機器上,OpenAI 打 api.openai.com,Ollama 打 127.0.0.1:11434。不要指望 .env 同時開兩個 provider 然後自動切——那要用 model_aliases + key_env 明確指定。

6. 本地模型仍是備援

OpenAI 掛、429、斷網時,fallback 回本機 gpt-oss:20b 或 qwen3.8:27b 能繼續跑(慢但可用)。用 hermes fallback add 設好。

小結

步驟 一句話
看 Tier platform.openai.com → Billing / Limits
建 key API keys → Create,離開頁面就看不到了
.env OPENAI_API_KEY=sk-…(600 權限、不入 chat)
config set model.provider openai + set model.default gpt-4o-mini
清 Ollama 殘留 unset model.base_url + unset model.api_key(漏掉最悲)
重啟 gateway 外面的 terminal 跑重啟指令
驗證 CLI chat -q + bot /status
舊 session /status 還舊模型 → 發 /new
控成本 Billing 設 budget alert,鎖 $5~$20/月

接上 OpenAI 之後,Hermes 的 designer profile 就能跑雲端模型,成本可預期、Tier 可觀察、本地模型留當備援。這套流程在 coder、tester 或任何 profile 都可以重用。

更新紀錄

2026-10-28:改寫「免費額度」一節(原本是貼上的 AI 回答,夾帶多餘格式),補上資料共享的隱私提醒;「確認帳號層級」改成與截圖一致的 Free / Build / Launch / Grow 等級。



延伸閱讀

2026年10月19日 星期一

[DIY專案] 4x4x4 LED 立方體:用 Arduino Mega2560 + 麥克風 + 光感做出「會呼吸的音箱」

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

這一個 DIY 專案是「4×4×4 64 顆 LED 的互動式立方體」:用 Arduino Mega2560 做控制核心,外接麥克風模組與光感(LDR),讓立體光譜「聽得到、看得見」——音量越大,底層越亮;環境越暗,頂層抬頭;中層隨時間流動,像一顆會呼吸、會應答的水晶球。


為什麼要「多工」而不能直接 64 腳

64 顆 LED 若各自一個 IO,需要 64 腳——任何 Arduino 都給不起。解法是矩陣掃描(multiplexing):陣列在製作時把垂直方向的 4 顆 LED 陽極焊成一根「柱」(共 16 根),再把同一層 16 顆 LED 的陰極焊在一起成為「層」(共 4 層)。任一瞬間只導通一層,同時送出這一層 16 根柱子該亮的狀態;4 層輪流每秒掃幾百遍,人眼殘像就讀成「64 顆同時亮」——和螢幕的「逐行刷新」是同一個道理。

這樣只需要 16 柱 + 4 層 = 20 個數位腳位,而且每顆 LED 有 1/4 的時間在亮。Mega2560 有 54 個數位腳,從 D22 開始用,刻意避開 D0/D1(那是 USB Serial 的 RX/TX,接 LED 會干擾 Serial 和上傳程式)。


料件清單

料件 數量/規格 說明
4x4x4 LED 陣列 64 顆 3mm LED(本例清/藍)、16+4 pin 排針 陽極每 4 顆串成一柱(16 柱)、陰極每層共接(4 層)
Arduino Mega2560 1 片 用 20 個數位腳位:16 柱(D22~D37)+ 4 層(D38~D41)
聲音感測器(咪頭模組) 1 個(電子元件店的藍板電容咪) 接 A0,類比值隨聲音大小跳動
光感 sensor(LDR) 1 個(10kΩ 光電分壓) 接 A1,亮度 → 電阻 → 模擬值
ULN2003(或 P-MOS 陣列) 1 片 層(陰極)一次要吸 16 顆的電流,IO 直接拉不動;ULN2003 每通道可吸 500mA
電阻 220~330Ω × 16 柱(陽極)限流,每柱串一顆(1kΩ 會偏暗;依 LED 規格調整)
杜邦線 / 麵包板或 PCB — 引腳 20+2,麵包板要夠大
5V 供電 USB 即可 一次只亮一層,最多 16 顆 × 約 10mA ≈ 160mA;要調大電流更亮時再用獨立 5V

接線總覽

物件 目標
16 根柱子(陽極) 各串 220~330Ω → Mega D22~D37
4 層(陰極) → ULN2003 OUT1~OUT4;IN1~IN4 ← Mega D38~D41;ULN2003 GND 接共地
咪頭模組 VCC/GND/OUT 5V / GND / A0
LDR 分壓(10kΩ 下拉) 節點 → A1
GND 全部共地(最重要,浮地 = 亂亮)

控制端程式(重點:掃描)

核心邏輯三件事:(1) 感測 每 30ms 讀一次咪頭/光感,把「音量→底 2 層,暗→頂層」寫進 cube[4][4][4] 模型;(2) 掃描 一次導通一層,送出該層 16 根柱子的狀態,4 層輪巡;(3) 每層停留 約 500µs,4 層一輪 2ms ≈ 500fps,每顆 LED 的亮度 ≈ 直驅的 1/4,所以限流電阻比一般直驅稍小——這是多工的公定代價:

/*
 * 4x4x4 LED 立方體 — 聲音+光度互動版
 * 硬體: Arduino Mega2560
 *   16 根柱子(陽極) → D22..D37 (各串 220~330Ω 限流)
 *   4 層(陰極)      → ULN2003 OUT1..4,由 D38..D41 控制
 *   麥克風模組 AIN  → A0 (類比)
 *   光感 (LDR 分壓) → A1
 *   D0/D1 保留給 Serial,不接 LED
 */

const uint8_t COL_PIN[16]  = {22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37};
const uint8_t LAYER_PIN[4] = {38,39,40,41};   // HIGH = ULN2003 導通 = 該層接地

// cube[x][y][z]:x、y 決定哪一根柱子(柱 = x*4+y),z 是層(0 在最下面)
bool cube[4][4][4] = {0};
uint8_t layer = 0;

void setup() {
  for (uint8_t i = 0; i < 16; i++) { pinMode(COL_PIN[i], OUTPUT);   digitalWrite(COL_PIN[i], LOW); }
  for (uint8_t i = 0; i < 4;  i++) { pinMode(LAYER_PIN[i], OUTPUT); digitalWrite(LAYER_PIN[i], LOW); }
  Serial.begin(115200);
}

void loop() {
  /* ---------- 1. 讀感測 → 更新 cube ---------- */
  static uint32_t lastSense = 0;
  uint32_t now = millis();
  if (now - lastSense > 30) {          // 30ms 感測一次
    lastSense = now;

    int16_t mic  = analogReadA0avg(20);   // 平均20次降噪
    int16_t light= analogReadA1avg(20);
    uint16_t micDB   = map(mic,   200, 800, 0, 1023);  // 依你的音量範圍校正
    uint16_t darkPct = map(light, 1023, 0, 0, 1023);   // 越暗越大

    /* 規則: 底層=音量, 頂層=光線, 中層=呼吸 */
    uint8_t vol  = (uint8_t)(micDB   / 1024.0 * 4);      // 0..3(音量4層)
    uint8_t lig  = (uint8_t)(darkPct/ 1024.0 * 4);       // 0..3
    uint32_t t = now / 200;                                // 呼吸節拍
    for (uint8_t x = 0; x < 4; x++)
      for (uint8_t y = 0; y < 4; y++)
        for (uint8_t z = 0; z < 4; z++) {
          if      (z < 2) cube[x][y][z] = (z < vol);                          // 音量填底2層
          else if (z == 3) cube[x][y][z] = (lig <= 1);                        // 暗 → 頂層亮
          else            cube[x][y][z] = ((t + x + y) % 4 == 0);             // 中層流動
        }
  }

  /* ---------- 2. 掃描:一次一層,4 層輪流,人眼殘像為「64 顆同時亮」 ---------- */
  digitalWrite(LAYER_PIN[layer], LOW);                   // 先關掉上一層,避免換資料時殘影
  layer = (layer + 1) & 3;
  for (uint8_t c = 0; c < 16; c++) {
    uint8_t x = c >> 2, y = c & 3;
    digitalWrite(COL_PIN[c], cube[x][y][layer] ? HIGH : LOW);   // 柱子拉高 = 供電
  }
  digitalWrite(LAYER_PIN[layer], HIGH);                  // 打開這一層
  delayMicroseconds(500);                                // 4 × 0.5ms = 2ms/幀 → ~500 fps
}

/* 20 次類比取平均, 擋住模組的抖動 */
int16_t analogReadA0avg(uint8_t n){ long s=0; for(uint8_t i=0;i<n;i++){s+=analogRead(A0); delayMicroseconds(60);} return s/n; }
int16_t analogReadA1avg(uint8_t n){ long s=0; for(uint8_t i=0;i<n;i++){s+=analogRead(A1); delayMicroseconds(60);} return s/n; }

調試時最常遇到的三個坑

  • 浮地=亂亮:陣列、ULN2003、感測模組的 GND 沒有共地,會看到「隨機一兩顆閃」——不是程式錯,是地線錯。共地是最優先的一條線。
  • 太暗或某層特別暗:先看限流電阻是不是太大(1kΩ 在 1/4 佔空比下會偏暗,改 220~330Ω);某一層特別暗通常是 ULN2003 那一路沒接好或 GND 沒共到。
  • 掃描太慢,閃爍肉眼可見:4 層一輪的總時間 > 20ms 就會看到「一層一層跑」。每層停留 300~800µs 之間都很穩;另外感測那段每次會卡住約 5ms,讀太頻繁也會讓畫面微閃。

玩法建議(感測→效果)

  • 音量柱:底層亮 = 音量高;拍手/說話 = 立方體「長高」,最直觀的音頻視覺化。
  • 暗室模式:LDR 越暗,頂層越亮 + 掃描頻率越慢,變成一盞「夜燈」;手遮光 → 瞬間熄滅。
  • 風暴模式:音量 > 閾值 → 隨機閃爍 2 秒(類似警報燈),閾值在 map() 裡調。
  • 呼吸模式:中層隨 sin(t) 慢速起伏,不用感測也好看,做「常亮桌面小物」用。

結語

這個專案把「多工、電流、時序」三個電子學核心一次走完:64 顆 LED 靠「快」而不是「多」跑起來——人眼看不見的是 500fps 的掃描,感覺到的是 64 顆同時亮。做完它,LED 矩陣、OLED、甚至 VGA framebuffer 的「逐行刷新」就全部通了。

更新紀錄

2026-10-28:修正接線架構。初版寫成「16 行 + 16 列、32 腳」,這和 64 顆 LED 對不起來(16×16 是 256 個交點),且用到了 Serial 專用的 D0/D1。現改為標準的「16 柱 + 4 層、20 腳」接法(D22~D41),程式、料件、電流估算與調試建議一併更新。



延伸閱讀

[學習FPGA] DE10-Lite + VHDL:從長出第一條電路開始

這一次要學的是「最底層」的語言——不是寫在 CPU 上的指令,而是直接長出電路的語言:VHDL(VHSIC Hardware Description Language)。載體是手上這塊 Terasic DE10-Lite 開發板:

FPGA 與 CPU:同一塊矽,兩種玩法

CPU 是「通用引擎」:同一顆晶片靠指令流跑任何程式。FPGA(Field Programmable Gate Array)則是「白地」:一整片由數萬個可配置閘(LUT + 觸發器)組成的矩陣,你的程式碼會被翻譯成一條條真實的電路——寫完、編譯、燒錄之後,計數器就真的「存在」晶片的矽上了。沒有「取指令、解譯」這回事:每個時脈週期,所有電路同時在動。

這就是為什麼 50MHz 的時脈可以直接餵給計數器:VHDL 寫的不是「程式」,而是硬體描述——你描述的是「電路長什麼樣、每個時脈週期發生什麼」,不是「依序做哪些事」。

DE10-Lite 上有哪些資源

  • Altera MAX 10 FPGA(10M02SAE144I7G):約 1.2 萬個 logic element,學完所有基礎專題綽綽有餘
  • 4 顆 7 段顯示器(HEX0~3):16 進位,是看「內部數字」最直接的方法
  • 10 顆開關 SW、10 顆 LED、4 顆按鍵 KEY0~3(KEY0 = Reset、KEY1 = Clock,注意低有效)
  • 50MHz 時脈(CLOCK_50):所有入門專題的標準心跳
  • SDRAM、USB、VGA、100-pin 排針——進階專題(framebuffer、UART、影像)留給後面

工具鏈:Quartus Prime

Altera(現 Intel)自家的 Quartus Prime 是這塊板子的「編譯器」:VHDL → 綜合(synthesis)→ 佈線(placement & routing)→ 產生 .sof 設定檔 → 燒進 FPGA。Lite 版編 MAX 10 免費可用,學生完全夠。固定流程:

  1. Project → Add File 放進 .vhd,Device 選 10M02SAE144I7G
  2. Pin Planner 把 port 對到板子腳位:CLOCK_50、LEDR[9..0]、SW[7..0]、HEX0..3
  3. Compilation 全套編譯,看 timing 報告——50MHz 要求 20ns 週期,入門設計通常裕量很大
  4. Programmer 走 USB 燒錄;上電後你的「電路」才會真正跑起來

第一個電路:LED 閃爍

50MHz 的時脈,人眼完全看不見(一秒翻轉 5000 萬次)。所以第一步永遠是時脈分頻:數到 2500 萬再翻一次位 = 2Hz,眼睛就看得見了。

library IEEE;
use IEEE.std_logic_1164.ALL;

entity led_blink is
    Port (
        clk : in  std_logic;                          -- 50MHz
        led : out std_logic_vector(9 downto 0)        -- LEDR
    );
end entity;

architecture behave of led_blink is
    signal cnt    : integer range 0 to 24_999_999 := 0;
    signal toggle : std_logic := '0';
begin
    process(clk)
    begin
        if rising_edge(clk) then
            if cnt = 24_999_999 then      -- 50MHz / 2 * 25M = 2Hz
                cnt    <= 0;
                toggle <= not toggle;
            else
                cnt <= cnt + 1;
            end if;
        end if;
    end process;

    led <= (others => toggle);
end architecture;

三個重點:(1) 所有邏輯都寫在 process(clk) 的 rising_edge 裡——這是「同步電路」的鐵律;(2) 計數器數到上限歸零、同時翻一位;(3) (others => toggle) 一次把十顆 LED 綁到同一個信號——看到「十顆齊閃」的那一刻,FPGA 和 CPU 的差別就有了身體感。

第二個電路:計數 + 7 段顯示

這是一道分水嶺:三個功能(分頻、計數、解碼顯示)在同一個檔案協作——已經有「模塊化」的味道了:

library IEEE;
use IEEE.std_logic_1164.ALL;
use IEEE.numeric_std.ALL;

entity counter is
    Port (
        clk   : in  std_logic;                  -- 50MHz
        rst   : in  std_logic;                  -- KEY[0] Reset(低有效)
        sw    : in  std_logic_vector(7 downto 0);
        seg   : out std_logic_vector(3*7 downto 0);  -- HEX3..HEX0
        an    : out std_logic_vector(3 downto 0);    -- HEEn 低有效
        led   : out std_logic_vector(7 downto 0)
    );
end entity;

architecture behave of counter is
    signal clk_1k  : std_logic := '0';   -- 50MHz 分頻到 1kHz
    signal cnt1k   : integer range 0 to 49_999 := 0;
    signal count   : unsigned(15 downto 0) := (others => '0');
    type t7seg is array (7 downto 0) of std_logic_vector(6 downto 0);
    constant SEGTAB : t7seg := (
        "1000000",  -- 0  (a~g)
        "1100001",  -- 1
        "0100100",  -- 2
        "0100001",  -- 3
        "0110000",  -- 4
        "0010011",  -- 5
        "0010111",  -- 6
        "1000000",  -- 7
        "0000000",  -- 8
        "0010000",  -- 9
        "0001000",  -- A
        "0011111",  -- b
        "0111011",  -- C
        "1111001",  -- d
        "0111111",  -- E
        "0011111");-- F
begin
    -- 1kHz 心跳
    process(clk)
    begin
        if rising_edge(clk) then
            if cnt1k = 49_999 then
                cnt1k <= 0;
                clk_1k <= not clk_1k;
            else
                cnt1k <= cnt1k + 1;
            end if;
        end if;
    end process;

    -- 計數(每 1kHz +1)
    process(clk_1k)
    begin
        if rising_edge(clk_1k) then
            if rst = '1' then
                count <= (others => '0');
            else
                count <= count + 1;
            end if;
        end if;
    end process;

    led <= sw;

    -- 四顆7段顯示
    process(clk_1k, count)
        variable nib : unsigned(3 downto 0);
    begin
        if rising_edge(clk_1k) then
            for i in 0 to 3 loop
                nib := count((i*4+3) downto i*4);
                seg((i*7+6) downto i*7) <= SEGTAB(conv_integer(nib));
            end loop;
        end if;
        an <= "0111";  -- 全亮(簡單版;動態掃描見進階篇)
    end process;
end architecture;

7 段解碼表建議自己手推一遍:0~9 每個數字,七段 a~g 哪些點亮、哪些熄,推完再對照常數表——比死背快三倍。這裡先用「四顆同時常亮」的簡接法;真正的動態掃描(一顆一輪詢、用 1kHz 換「四顆都亮」的錯覺)留給進階篇——那裡會第一次遇到「時序錯配」的真實問題。

學習路線(由淺入)

  1. LED 閃爍 → 雙計數器 → 交通號誌(Finite State Machine 的入口)
  2. 7 段動態掃描 → 分頻樹 → timing 約束(timing constraint)
  3. 同步 vs 非同步 Reset 的差別(這塊板 KEY0 低有效,極性寫反不報錯、但會亂動)
  4. VGA framebuffer(SDRAM)→ 第一次碰「外部介面」
  5. 進階:UART / SPI / I²C 軟硬體協定 → 與微控制器協作

新手最容易摔的三坑

  • 把 VHDL 當 C 寫:預設是「併行」,C 語言順敘寫法在硬體上會變成組合邏輯迴路。先想電路、再寫程式,是唯一解法。
  • 用 50MHz 餵組合邏輯:計數、解碼、顯示都要掛在 rising_edge 上,否則編譯出一堆 timing violation,燒錄成功但行為亂跳。
  • 7 段選通(HEEn)是低有效:拉低才亮——接反了 = 全滅。新手最容易懷疑「晶片壞了」,其實只是極性。

結語

從 DE10-Lite 上電的那刻起,學習的主題就從「寫程式」變成「長電路」。這塊板夠小、夠耐玩——先把 LED 閃爍跟 7 段計數跑通,往後的路就順了。

2026年10月17日 星期六

CNC 錄片影像與狀態文字融合:方式、步驟與注意事項

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

CNC 錄片「影像 + 狀態文字」融合:方式、步驟與注意事項

一、要做的事

把 CNC 執行時的控制面板狀態(log)疊在 webcam 錄片上,變成單一支「畫面 + 即時狀態字幕」的影片。
狀態文字包含:Run / Idle / Alarm、MPos 座標、FS 進給速度、SD 目前指令、Ov 倍率等,每 0.4 秒更新一次。

二、融合方式總覽

                 ┌──────────────────────────────────────────┐
  webcam ──RTSP──►  Pi4 (mediamtx) ──RTSP──► Pi4 (monitor)
                 │                                        │
  ESP32 FluidNC ──WebSocket(即時狀態)─► monitor 同步記 log
                 │                                        │
                 └────────► 收檔 run_YYYYMMDD_HHMMSS.mp4
                                 + .mp4.log (每 0.4s 一行的狀態)
  • 錄製端(Pi4):monitor.py 同時抓兩路——RTSP 影像串流寫 MP4,WebSocket 狀態寫成時間軸 log。
  • 收檔觸發:偵測到 Run/Hold/Alarm 開始錄,回 Idle 8 秒確認後收檔。
  • 回傳/疊字端(GX10):Pi4 的 sync timer 每 2 分鐘掃一次,把新錄片 + log scp 到 GX10 /home/gx/cnc_records/,校驗大小一致後 Pi4 端直接刪除(不留檔,檔案只存在 GX10),並自動跑 stitch.py 疊字。


三、stitch.py 如何把 log 融進影片(技術)

  1. 不用「切段再拼」:ffmpeg -ss 切段 drawtext → concat 的方式,seek 是跳到關鍵幀,每段最多漂 1~2 秒,字幕會「跟不上畫面」。
  2. 改用 ASS 字幕單次 pass:每行 log → 一條 Dialogue(起始/结束時間戳),ffmpeg -i in.mp4 -vf ass=subs.ass 一次解碼一次編碼就疊完,逐幀精確。
  3. 每行 log 都是字幕:161 行 log = 161 條字幕,MPos 數字隨機器逐行跳動,不會被「同狀態 5 秒合併」規則吃掉的訊息。
  4. 時基校正 (L2V_OFF = 2.7s):影片走 webcam → mediamtx → RTSP → ffmpeg,有固定的串流累積延遲(本環境實測 2.7 秒,用圓弧起點兩點對齊 + 像素運動法交叉驗證)。所有字幕時間戳統一 +2.7s。
  5. 結尾裁定 (TAIL = 0.5s):錄片尾段有 mediamtx 的凍住緩衝幀(收檔瞬間 ffmpeg 還在吐尾),cut = min(log_tstop + L2V_OFF + TAIL, 影片長度),ffmpeg -t cut 直接裁掉凍尾。
  6. ASS 時間戳格式:HH:MM:SS.cc(ASS 用 1/100 秒的兩位小數)。65 秒 = 00:01:05.00,不是 00:65.00——寫錯的話 ASS 會把「分」和「秒」搞亂,整支片時間軸錯位。

四、一次完整流程

[1] 執行工: ssh Pi4 → 透過 curl 對 FluidNC 下 $LocalFS/Run=<檔>.gcode 指令要包含$跟gcode要對
[2] Pi4 monitor 偵測到 Run,自動起錄 webcam + WebSocket 狀態 log
[3] 機器回 Idle → 8s 後收檔 recordings/run_*.mp4 + .mp4.log
[4] cnc-sync.timer (每 2 min) 掃 recordings/ → 校驗+回傳 GX10
[5] 回傳成功 → Pi4 端刪除; 失敗 → 保留等待下輪
[6] GX10 自動跑 stitch.py,產出 run_*_annotated.mp4 (同目錄)
[7] 收片於 GX10,需要就自取,不要就手動刪

五、注意事項 (踩過的坑)

1. 時基 (最重要)

  • 串流是慢的,log 是即時的——兩個時基不對齊,字幕就會「跟不上」或「超前」。
  • 換硬體/換路由器/改 mediamtx 設定後,L2V_OFF 要重新實測。
  • 實測方法:在 log 挑一個 MPos 已知的點(例如圓弧起點),用 ffmpeg -filter:v select='gt(scene,0.03)' 或逐 1 秒抽幀比像素差,找到畫面第一次出現該位置對應的時間,差值就是偏移。

2. ASS 字幕格式

  • 每一行 log = 一條 Dialogue,[start, end) 半開區間,相切不重疊。
  • 文字中的 : 是 ASS 分隔符,若文字本身要出現冒號要轉義;我們直接用半形冒號無事,遇到 : 分隔符要留意。
  • 過長行 (>64 char) 會被截斷加 …,避免單行跨兩行顯示。

3. 不要切段拼

  • drawtext -ss 切段 → concat demuxer → -c copy 看似省事,seek 會跳到關鍵幀,每段都會飄。
  • 用 ass 單次 pass,一次解碼一次編碼,零飄移,還比較快。

4. Pi4 端不要留錄片

  • 回傳校驗 size 一致 + 已落 GX10 → Pi4 端直接 rm。
  • 校驗不符 / scp 失敗 → 保留 Pi4 原檔,標記下輪重傳,絕不能雙邊都刪。
  • 收檔剛完成的檔案 (<30s) timer 會跳過(避免抓到寫入中)——這是 monitor 端 recorder_active flag 與 sync 端的約定。

5. 結尾裁定

  • mediamtx 在收檔瞬間還會吐 3~6 秒緩衝凍幀,不做 ffmpeg -t cut 會看到「畫面停了、文字還在跳」的殘影。
  • cut 用 min(log_tstop + L2V_OFF + 0.5s, 影片長度),不要超過影片實際長度(ffmpeg 會報錯)。

6. log 格式

  • START epoch=<t> / <offset> <state> <detail> / END 三行結構。
  • 解析 regex ^([\d.]+)\s+(\S+)\s*(.*)$,第一欄是「距 START 的偏移秒」不是絕對時間。
  • 若改 log 寫入格式,stitch.py 的 parse_log 要同步改。

7. 不要雙邊都跑 stitch

  • stitch 只在 GX10 跑一次,Pi4 只負責同步回傳。
  • 若 Pi4 也跑了 stitch,雙邊會產生同一個 _annotated.mp4,回傳時以 GX10 版本為準。

8. 時間戳顯示

  • 65 秒是 00:01:05,不是 00:65——ASS 的 HH:MM:SS.cc 是「小時:分鐘:秒.百分秒」,把 65 秒當秒寫進去會被解讀成 64 分鐘 + 5 秒,整支片時間軸錯位 63 分鐘以上。
  • 寫 ts(sec) 工具時:h=sec//3600, m=(sec%3600)//60, s=sec%60,不要用 %60 套在 m 上(會把 65 秒寫成 00:65.00,ASS 解讀成 0 分 65 秒,等於 1 分 5 秒,跟預期差 53 秒)。

9. 收片位置

  • 原版 run_*.mp4、log run_*.mp4.log、疊字版 run_*_annotated.mp4 三者都在 /home/gx/cnc_records/,自取即可。
  • Pi4 端 recordings/ 不應留任何 mp4(只有 trace.log 是正常)。

六、驗證清單 (每支片收工前)

項 驗法
機器跑完整支 gcode log 最後有 Idle MPos:0.000,0.000,0.000
字幕不重疊 awk 掃 ass 檔,end_i < start_{i+1}
結尾 = Idle MPos:0,0,0 抽最後 3 幀看 MPos
結尾 = 圓完整 抽最後 1 幀看圓弧
中途不跳 抽 30% / 60% / 90% 三幀,每幀 MPos 跟 log 對應行一致
Pi4 端乾淨 ls ~/cnc-monitor/recordings/*.mp4 無結果
GX10 三件齊 run_*.mp4 + run_*.mp4.log + run_*_annotated.mp4 都在

七、文件位置

檔 位置
monitor.py (錄製 + log) Pi4 ~/cnc-monitor/monitor.py
stitch.py (疊字) GX10 /home/gx/cnc_records/stitch.py
sync_rec.sh (回傳 + 清理) Pi4 ~/cnc-monitor/sync_rec.sh
錄片 + log + annotated GX10 /home/gx/cnc_records/
本文 WP http://localhost:8082/ (區網)


延伸閱讀

用 Webcam 監看 FluidNC 運轉:從串流到自動錄影的完整配置

📺 影片版(約 8 分鐘):想先看實際操作的話可以直接看這支影片,完整步驟、設定與程式碼在下方文字版。
YouTube:https://youtu.be/AZ_RN-PXs_0(winson-DIY 頻道)
把之前舊玩具重新拿出來用AI 玩, telegram 透過tailscale下指令給 Pi4+ESP32
之前文章 ESP32 Grbl Controller

而這篇文章把 ESP32 的韌體升級到了 FluidNC, 直接官網更新


這次把「眼睛」也接上——用一支 USB Webcam 監視 CNC 的運轉,瀏覽器低延時直播、手機可以看、跑 G-code 時自動錄影,影片自動回传到主機。整套東西架在 Raspberry Pi 4 上,全部開機自啟,不佔用 PC。

一、架構總覽

整套系統有四個常駐服務,全跑在 Pi4 上:

服務 作用
mediamtx 串流伺服器,把一路 RTSP 來源重新打包成 WebRTC / RTSP / HLS
cam-push ffmpeg 從相機抓幀,常駐推到 MediaMTX
cnc-monitor Python 腳本盯 FluidNC 狀態:Run 時開錄、Idle 自動收檔
cnc-sync.timer 每 2 分鐘把新錄好的影片 scp 回主機

訊號流:

C310 /dev/video0
    → ffmpeg (H.264 720p15, zerolatency)
        → MediaMTX
            ① WebRTC  瀏覽器 <300ms 低延時
            ② RTSP    手機 VLC
            ③ HLS     瀏覽器備用(有延遲)
    → cnc-monitor 偵測 FluidNC 狀態 → Run 時再錄一份 mp4 → 自動回傳

二、硬體與前置

  • Raspberry Pi 4(64 位 Raspberry Pi OS,本次用 Debian 13 版),有 USB 2.0 口
  • USB Webcam 一支(實測 Logitech C310,C922 也可;720p 等級足夠)
  • FluidNC 韌體的 ESP32(wifi 版,同一台 AP 或同一 tailnet 內可互達)
  • 相機建議斜上方 45 度拍機頭,距離 50–80 cm,避免正對刀頭(反光且看錯切削點)

三、確認相機

插上去之後先確認 OS 認得到:

lsusb | grep -i logitech        # 確認 UVC 相機在線
ls /dev/video*                  # 會有 video0(主流)video1(副流)
v4l2-ctl -d /dev/video0 --info  # 卡型/驅動確認

抓一幀確認能出圖:

ffmpeg -f v4l2 -video_size 1280x720 -i /dev/video0 -frames:v 1 /tmp/t.jpg

C922 的 video0/video1 是雙路串流(一路 1080p、一路 720p),C310 是 1280×720 單路。

四、安裝 MediaMTX

MediaMTX 是輕量串流伺服器,單檔二進位即可,aarch64 有官方包:

mkdir -p ~/cnc-monitor && cd ~/cnc-monitor
curl -fsSL https://github.com/bluenviron/mediamtx/releases/download/v1.21.2/mediamtx_v1.21.2_linux_arm64.tar.gz | tar xz
./mediamtx -h   # 會自動產生默認設定與憑證

最小設定檔 mediamtx.yml(注意欄位要寫對,別亂加會 unmarshal 失敗):

logLevel: info
rtspAddress: :8554
webrtcAddress: :8889
hlsAddress: :8888
paths:
  cnc:
    source: publisher

做成 user systemd 服務:

# ~/.config/systemd/user/mediamtx.service
[Unit]
Description=MediaMTX (CNC monitor streaming)
After=network-online.target

[Service]
Type=simple
WorkingDirectory=%h/cnc-monitor
ExecStart=%h/cnc-monitor/mediamtx %h/cnc-monitor/mediamtx.yml
Restart=on-failure

[Install]
WantedBy=default.target
systemctl --user daemon-reload
systemctl --user enable --now mediamtx

五、常駐推播(cam-push)

# ~/cnc-monitor/push_cam.sh
#!/bin/bash
while true; do
  ffmpeg -loglevel warning -f v4l2 -video_size 1280x720 -framerate 15 -i /dev/video0 \
    -c:v libx264 -tune zerolatency -preset ultrafast -pix_fmt yuv420p -b:v 2500k -g 30 \
    -f rtsp -rtsp_transport tcp rtsp://localhost:8554/cnc
  echo "push ended, reconnect in 2s"; sleep 2
done

重點參數:
– -tune zerolatency -preset ultrafast:把編碼延時壓到最低,4 核 Pi 推 720p15 沒壓力
– -rtsp_transport tcp:避免 UDP 在 AP/WiFi 環境掉幀
– 外層 while true 做斷線自動重推

同樣做成 user 服務,記得 After=mediamtx.service、Requires=mediamtx.service。

六、三條收看路徑

用途 位址(把 IP 換成你的 Pi)
PC 瀏覽器(WebRTC,延時最低) http://<PI_IP>:8889/cnc/
手機 VLC(RTSP,轉運選 TCP) rtsp://<PI_IP>:8554/cnc
瀏覽器備用(HLS,有秒級延遲) http://<PI_IP>:8888/cnc/index.m3u8

Pi 在 AP 的 WiFi 網段內直連即可;跨網段或在外就用 Tailscale IP(tailnet 內 100.x 位址,三端口都能通,不需穿透)。

七、自動錄影(cnc-monitor)

這支腳本是整套裡最吃螺絲的一部分,把實踩的坑都寫出來。

FluidNC 的 WebSocket 協定(最大的坑)

  • Endpoint:ws://<FLUIDNC_IP>(port 80,不是 81),subprotocol 要帶 arduino
  • 送 G-code 指令,字串結尾一定要帶換行 \n——FluidNC 的 WSChannel 對沒有 \n 的輸入會緩存不執行,你只收得到 PING 永遠等不到 ok/error
  • 狀態行格式:<Run|MPos:x,y,z|FS:feedrpm> / <Idle|...>,狀態偵測用正規表達式抓 <Run|<Idle|<Alarm
  • 同一 time 只有「最新一個」WebSocket 客戶端是被當 primary terminal 的(它會收到 activeID: push,晚到的客端被踢成 passive,passive 送指令不會回 ok)。所以設計上是:monitor 常駐佔住這個 ws session,其他需要發指令的地方(比如手機端想下指令)走文件轉發:寫一行指令到 /home/pi/cnc-monitor/cmd.txt,monitor 看到就往自己的 ws 送出——這是唯一不會搶 session 的方式。UGS 從 PC 走 USB 序列口完全不受影響。

錄影觸發邏輯

  1. 輪詢 ? 拿狀態(0.4~1 秒一次)
  2. 連續 6 次收不到任何回應 → 判定 ws 失效,重連(防止 FluidNC 重啟或 session 被搶後卡死)
  3. 狀態從 Idle 轉 Run/Hold → 啓動 ffmpeg 錄 MediaMTX 的 rtsp://localhost:8554/cnc,檔名 run_YYYYmmdd_HHMMSS.mp4,同時寫一個 recorder_active 標記檔
  4. 狀態回 Idle 並持續 8 秒→ 收檔(防止 G-code 行與行之間短暫 Idle 就誤收)
  5. 安全上限:錄超過 10 分鐘強制收檔
  6. 收檔判斷要依賴「recorder_active 標記」,不要用 state 變數——實測過用 state 變數做 elif state=='Run' 的判斷会因第一次 Idle 就把 state 改掉而永遠不走收檔分支,ffmpeg 無限錄下去

ffmpeg 參數(錄製版,比推播版可放寬):

ffmpeg -rtsp_transport tcp -i rtsp://localhost:8554/cnc \
  -c:v libx264 -preset veryfast -tune zerolatency -b:v 2500k \
  -pix_fmt yuv420p -movflags frag_keyframe+empty_moov run_xxx.mp4

八、自動回傳主機(cnc-sync)

用 user timer 每 2 分鐘掃一次 recordings 目錄:

systemctl --user enable --now cnc-sync.timer

腳本要防三個坑:
1. 檔身還在寫——mp4 錄好前 size 會一直長,要檢查「距上次修改 ≥20 秒」且「兩秒間 size 不變」才動手
2. 傳半截——先 scp 成 xxx.mp4.part,傳完比對远端 size 一致才 mv 改回正式檔名
3. 本地 size 對遠端 size 不一致——以遠端為準重新傳,別直接 mv

回傳目標例:主機的 /home/<user>/cnc_records/(走 Tailscale IP,免開洞)。

九、必做:啟用 lingering

systemctl --user 的服務只活著在登入 session 裡面。Pi 上通常是用 VNC/SSH 登進去操作的,一登出(或重開機)這些服務就都沒了。必跑一次:

sudo loginctl enable-linger pi
loginctl show-user pi -p Linger   # 應回 Linger=yes

(注意是 enable-linger 不是 enable-lingering,多一個 g 會報 invalid choice。)

十、注意事项清單

  1. 相機角度:斜上方拍機頭,避直對刀尖(反光 + 看不到實際切削位置)
  2. 延時:看走 WebRTC(8889),別走 HLS(8888 有秒級延遲,看切削對位会慢半拍)
  3. Pi 4 USB 2.0 帶寬:720p 推播 + 720p 錄製并行实测沒問題,但別再堆更多路;若要 1080p 建議 Pi4 升 8GB 或 Pi5
  4. Webcam 被佔:/dev/video0 同一 time 只能被一個 ffmpeg 讀,cam-push 死掉重連時如果舊 process 還在,新 process 會 Device or resource busy——重前用 fuser -v /dev/video0 查
  5. FluidNC ws 單一 primary:別同時開 dashboard 和 monitor 又發指令,指令會被 passive 客端吞掉不回 ok;要發指令就走 cmd.txt 轉發
  6. 指令一定要帶 \n 結尾,否則不會執行
  7. 自動收檔要留 idle buffer(8 秒),不然 G-code 行間的微 Idle 會把影片切得七零八落
  8. lingering 必開,不然重開/登出後整套服務消失
  9. 錄製檔要等它「穩」再回傳,size 快照 + .part 暫存 + 遠端比對,三層防護
  10. 看片與看 FluidNC 狀態分屏:左邊 WebRTC 看切削,右邊 FluidNC dashboard 看 position/feed,跑長 G-code 時比單看影片可靠

十一、故障排查快速表

症狀 原因 解法
瀏覽器連 8889 沒畫面 cam-push 沒在推 pgrep -af ffmpeg|cam,重啟 cam-push
VLC 連 8554 卡住 UDP 丢幀 VLC 轉運改 TCP / 命令加 -rtsp_transport tcp
發指令只收 PING 字串沒帶 \n 結尾 ws.send(cmd + '\n')
錄影片無止境長大 收檔判斷用了 state 變數 用 recorder_active 標記檔判
重開 Pi 後串流沒了 lingering 沒開 sudo loginctl enable-linger pi
相機 Device or resource busy 舊 ffmpeg 殘留 fuser -v /dev/video0 找到 pid 殺掉

結語

這套東西的最大價值不是「能看」,而是「自動留證據」——每段 Run 的運動都對應一個 run_xxx.mp4,事後回放配 FluidNC 的狀態流,能精確對到是哪一行 G-code 出了問題。整套 Pi4 常駐之後完全免維護,跑完機就回家看影片。

下次接好馬_driver、寫完設定,第一刀跑完就能收到自己的 run_20261013_xxxxxx.mp4。



延伸閱讀

2026年10月9日 星期五

用本地 ComfyUI 生成圖片與影片:完整流程與注意事項

本文環境:GX10(NVIDIA GB10、121GB 統一記憶體、Linux)+ ComfyUI v0.34.2,全部本地推理,不依賴任何雲端 API。流程已實測:落日黃昏圖片 16 秒、LTX-2.3 22B 影片 75 秒。

一、隨開隨用:用完即停的資源模式

ComfyUI 常駐會吃約 30GB 系統記憶體(GB10 統一記憶體,VRAM 外溢)。為了平常不佔資源,我做了兩個指令:

comfy-on    # 啟動 service + 輪詢 API 就緒(實測 8 秒)
comfy-off   # 停止 service + 確認 port 8188 釋放 + 回報可用 RAM

使用節奏:comfy-on → 生成 → comfy-off。平常狀態 72-75GB 可用,開著時剩 40GB 左右,121GB 的機器扛得住,但不建議同時跑其他重任務(本地 LLM 推理等)。

二、生圖(SDXL,1024×768)

模型:sd_xl_base_1.0.safetensors(models/checkpoints/)。流程:

  1. comfy-on
  2. 組 workflow JSON(CheckpointLoaderSimple → CLIPTextEncode ×2 → EmptyLatentImage → KSampler → VAEDecode → SaveImage)
  3. POST http://127.0.0.1:8188/prompt 提交,拿到 prompt_id
  4. 輪詢 /history/<prompt_id> 到 completed
  5. 從 outputs 拿 filename,用 /view?filename=...&subfolder=...&type=output 下載
  6. comfy-off

本次實例:seed 20261008、steps 25、cfg 4.0、dpmpp_2m/karras,16 秒出圖 1MB PNG。



三、生影片(LTX-2.3 22B,文字→影片)

這台有 ltx-2.3-22b-dev-fp8.safetensors(44GB)+ SVD-XL。LTX-2 系列是新架構,有以下幾點一定要正確組合,任何一錯都會在執行時報錯:

1. 文字編碼器(最容易踩的坑)

  • LTXV 需要 Gemma3-12B(hidden 3840),不是 SDXL 那套 CLIP,也不是 T5-XXL。
  • ComfyUI 要的是單一 safetensors,內部同時含:權重重映射為 model.* 前綴 + spiece_model(sentencepiece tokenizer 原始 bytes)+ LTX 的 text_embedding_projection.* 四支張量(後者本來在 LTX checkpoint 裡,要撈出來合併)。
  • HF 上 google 官方 repo 是 gated、Lightricks 官方是 12+ 分片;實務上抓公開整包的 bf16(5 分片約 22.7GB),合併、key 重整後變成一台 25GB 的單檔,一次搞定。
  • FP4/NVFP4 量化版會撞 ComfyUI 的權重載入路徑('Linear' object has no attribute 'weight'),用 bf16 版。

2. VAE 用錯 = 影片只剩 1 幀

LTX checkpoint 自帶 VAE(vae.encoder/decoder),VAEDecode 要拿 [checkpoint, 2],不要外接 flux2-vae。接錯的症狀是:影片成功輸出,但 ffprobe 只看到 1 幀、尺寸縮半——很容易誤以為「生成成功」。

3. 參數(本次實測值)

  • EmptyLTXVLatentVideo:768 × 512(32 的倍數);幀數 8n+1(97 ≈ 3.9 秒 @25fps)
  • DualCLIPLoader:type=ltxv,clip1/clip2 都填同一支 Gemma3-12B
  • KSampler:cfg=1.0(LTX-2 是 guidance-free,別用 2-7 的舊值)、steps 20-25、euler/simple
  • SaveVideo:新版節點要求必填 format(mp4)

4. 圖→影片(img2vid)

可用「先生圖 → 上傳 /upload/image(會進 input/ 目錄)→ LoadImage 當第一幀」的做法,起幅最穩定。SVD-XL 也可走 img2vid,但品質舊一代;LTX 走 LTXVAddGuide/img2video 節點。



四、驗證清單(每次生成必做)

  • ffprobe 看 duration / nb_frames / 解析度——幀數=1 或尺寸縮半 = VAE 接錯
  • 抽中間幀肉眼確認(ffmpeg -ss N -frames:v 1)
  • 生成完 comfy-off,確認 RAM 還原

五、產出位置

  • 圖片:~/ComfyUI/output/
  • 影片:~/ComfyUI/output/video/
  • 本文章案例:落日黃昏 768×512 / 97 frames / H.264 MP4,本地生成 75 秒。

本文僅記錄自家環境的本地生成流程;模型版權屬原作者(LTX-2.3: Lightricks,Gemma: Google)。



延伸閱讀

2026年10月7日 星期三

用 SSH/ADB 遠端控制 Android 平板(Android on Raspberry Pi)完整記錄

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

把之前的文章 Raspberry Pi4 Project - Android system用我的gx10做控制

用 SSH/ADB 遠端控制 Android 平板(Android on Raspberry Pi)完整記錄

這台「平板」其實是一台跑 Android 15 on Raspberry Pi 4 的開發機(aosp_rpi4,userdebug build),區域網路 IP 固定 192.168.1.254。它的特殊之處:

  • Android 是 userdebug 版——有 su 0 可以直接進 root
  • 系統內建 root sshd(不是 Termux 開的),port 22
  • 金鑰放 /data/ssh/authorized_keys

本文記錄從 GX10 連到這台平板的完整步驟,以及過程中踩到的坑。

一、設備規格

項目 值
核心 Raspberry Pi 4(ARM64), Android 15(userdebug)
Kernel Linux 6.6
記憶體 7.6 GB
/data 分區 12 GB
IP 192.168.1.254(wlan0)
SSH

port 22,root 金鑰登入

二、連線前的準備(GX10 端)

1. 安裝 ADB client

GX10 是 Ubuntu 24.04 ARM64,adb 套件依賴舊的 OpenSSL 1.0,直接裝會缺庫。解法:

  • 從 Ubuntu bionic 的 android-tools-adb .deb 抽出 aarch64 的 adb 二進位
  • 搭配 bionic 的 libssl1.0(放 /tmp/ssl10/)
  • 建立 /home/gx/.local/bin/adb 包裝腳本,執行時設 LD_LIBRARY_PATH=/tmp/ssl10/ext/lib/aarch64-linux-gnu

2. 確認 SSH 金鑰

艦隊統一用 ~/.ssh/id_ed25519(同一支金鑰,所有設備)。

三、ADB 授權流程(最關鍵的一步)

adb devices -l
192.168.1.254:5555     device product:aosp_rpi4 model:Raspberry_Pi_4

坑: 第一次連上狀態是 offline,不是壞掉——是平板螢幕上彈著「允許從此電腦遠端調試嗎?」的對話框。

必做: 在平板上勾「永遠允許」→「允許」。否則永遠是 offline。(注意:離人時做這步會卡住,務必在平板前操作。)

四、找對 SSH 用戶(踩坑最多的部分)

坑 1:先試了十幾個用戶全被拒

pi / root / winson / user / shell / android ... 都 Permission denied。

解法:用 ADB 看 sshd 到底用誰跑

adb shell "ps -A | grep sshd -v grep"
root   2168   1   ...   S   sshd

→ sshd 是以 root 跑的。

坑 2:金鑰檔裡的公鑰「不是我的」

userdebug build 可以 su 0:

adb shell "su 0 sh -c 'wc -l /data/ssh/authorized_keys; cut -c1-40 /data/ssh/authorized_keys'"

檢查結果:檔裡有 5 支公鑰,沒有一支是 GX10 的——之前「已放金鑰」其實是另一台的。

坑 3:經 shell 貼多行文字會被轉義

解法:一律 base64 傳輸

KEY=$(cat ~/.ssh/id_ed25519.pub)
B64=$(printf '%s\n' "$KEY" | base64 -w0)
adb shell "su 0 sh -c 'echo $B64 | base64 -d >> /data/ssh/authorized_keys'"

放完立刻測:

ssh root@192.168.1.254 "id"
uid=0(root) gid=0(root)

✅ 成功。

五、使用方式

需求 指令
SSH ssh root@192.168.1.254(免密碼)
裝 APK adb install xxx.apk
截圖 adb exec-out screencap -p > screen.png
進 root 直接就是 root(免 su)
看 log adb logcat

六、注意事項(重點整理)

  1. ADB 授權框要人在場——沒按允許就是永遠 offline;重開機/清資料後權限可能被清,要重新授權
  2. root sshd 是安全敏感點——這是 userdebug 開發機,金鑰檔 /data/ssh/authorized_keys 等於 root 後門,只放自己人的公鑰;別用 password 登入
  3. 金鑰要對「這台」的金鑰檔核對——艦隊多台設備共用流程,但每台公鑰不同,放完一定要 ssh 驗證一次,並對照 cut -c1-40 確認是自己這支
  4. userdebug 的特性:有 su 0、adb root 可用,但不能當正式版用——升級 ROM、重刷後 sshd 服務可能不見
  5. IP 固定 192.168.1.254,走區網(wlan0),不在 Tailscale 上——GX10 須在區域網路內才連得到
  6. /data 只有 12G——裝模型/大檔案要挑,別當資料盤用

七、一句話總結

Android on Pi 平板 = ADB 授權(人在場)+ root sshd + 金鑰經 base64 放 /data/ssh/authorized_keys;userdebug 的 su 0 是找路徑的金鑰工具。放完公鑰先 ssh root@192.168.1.254 驗證,才算是真的接入艦隊。



延伸閱讀