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。

2026年10月21日 星期三

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

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

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

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

開啟 OpenAI API「ChatGPT 的每日免費額度(Data Sharing Free Tier)」,該功能在後台頁面設定 OpenAI API Rate limits。
以下是完整的設定步驟教學:
步驟 1:確認您的帳戶狀態
從您的圖片中可以看到,您目前是 Pay-as-you-go(按量計費) 的付費帳戶,且 API 餘額(API credit balance)為 $X.XX。
  • 💡 這代表您已經符合解鎖免費額度的第一個門檻(帳戶必須有至少 $5 的儲值紀錄,扣除目前餘額後符合資格)。

步驟 2:前往資料控制頁面(Data Controls)
您目前的畫面是「帳務中心 (Billing)」,此設定並不在這裡。請依照以下步驟切換頁面:
  1. 點擊網頁左側選單: 在瀏覽器左側的導覽列中,點擊最下方的 「Settings」(設定,齒輪圖示)。
  2. 進入組織設定: 在 Settings 展開的子選單中,點擊 「Organization」。
  3. 找到資料控制: 接著在組織選單內,點擊 「Data controls」 頁籤。

步驟 3:開啟資料共享
進入 Data controls 頁面後,您會看到一個關於模型訓練與隱私的區塊。
  1. 找到名為 「Share inputs and outputs with OpenAI」(與 OpenAI 共享輸入和輸出)的切換開關。
  2. 將這個開關手動調整為 「開啟 (ON)」。

💡 設定完成後的注意事項

  1. 免費額度如何生效: 開啟共享後,OpenAI 系統每天會自動重置並存入固定的免費 Token 額度


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

  1. 開 https://platform.openai.com(要登進 API 平台,不是 ChatGPT)
  2. 上方選單 Settings → Limits / Billing
  3. 會看到:
  4. Current usage tier:Tier 1 / 2 / 3
  5. Credits remaining:剩多少免費額度
  6. 各模型的 rate limit(RPM / TPM)— 以該頁表格為準,各模型、各 tier 數字不同

小提示:同一個 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月19日 星期一

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

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


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

64 顆 LED 若各自一個 IO,需要 64 腳——任何 Arduino 都給不起。解法是矩陣掃描(multiplexing):陣列在製作時就把 16 顆 LED 的同一面焊在一起(陽極共接=「行」),另一面也共接(陰極共接=「列」)。任一瞬間只有「1 行 × 1 列」交叉的那一顆 LED 有電流通過;但把這 16×16=256 個交點每秒掃幾百遍,人眼殘像就讀成「64 顆同時亮」——和螢幕的「逐行刷新」是同一个道理。

本專案用 Mega2560 剛好:16 行 + 16 列 = 32 個數位腳位,一個不少。


料件清單

料件 數量/規格 說明
4x4x4 LED 陣列 64 顆 3mm LED(本例清/藍)、16pin+16pin 排針 陣列陽極共接成 16 行、陰極共接成 16 列
Arduino Mega2560 1 片 32 個數位腳位剛好吃滿:16 行 + 16 列
聲音感測器(咪頭模組) 1 個(電子元件店的藍板電容咪) 接 A0,模擬量随聲音大小跳動
光感 sensor(LDR) 1 個(10kΩ 光電分壓) 接 A1,亮度 → 電阻 → 模擬值
ULN2003(或 P-MOS 陣列) 1~2 片 列(陰極)需要大電流拉低,IO 直接拉不動 64 顆
電阻 1kΩ × 16 行(陽極)限流,每行串一顆
杜邦線 / 麵包板或 PCB — 引腳 32+2,麵包板要够大
5V 供電 3A 以上 64 顆全亮峰值 ≈ 1.5~2A,USB 口未必穩,建議獨立 5V

接線總覽

接線總覽

物件 目標
陣列 16 行(陽極) 各串 1kΩ → Mega D0~D15
陣列 16 列(陰極) → ULN2003 輸入 → 輸出 → Mega D16~D31
咪頭模組 VCC/GND/OUT 5V / GND / A0
LDR 分壓(10kΩ 下拉) 節點 → A1
GND 全部共地(最重要,浮地 = 亂亮)

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

核心邏輯三件事:(1) 感測 每 30ms 讀一次咪頭/光感,把「音量→底 2 層,暗→頂層」寫進 cube[4][4][4] 模型;(2) 掃描 每幀只點亮「一行」該亮的列,16 次輪巡;(3) 幀間隔 120µs 左右,64 顆全亮的亮度 ≈ 實際的 1/16,所以限流電阻要比一般直驅稍小,或接受稍暗——這是多工的公定代價:

/*
 * 4x4x4 LED 立方體 — 聲音+光度互動版
 * 硬體: Arduino Mega2560
 *   16 行(陽極) → D0..D15   (經 1kΩ x16 限流)
 *   16 列(陰極) → D16..D031 (經 ULN2003 拉低)
 *   麦克风模块 AIN  → A0  (模擬)
 *   光感 (LDR 分壓) → A1
 */

const uint8_t ROWS = 16;
const uint8_t COLS = 16;
const uint16_t ROW_PIN[16]  = {0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15};
const uint16_t COL_PIN[16]  = {16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31};

// cube[x][y][z] → 行 = y*4+z (層內 z 從下到上), 列 = x*4+y
bool cube[4][4][4] = {0};

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

uint16_t scanRow = 0;

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. 掃描:一次一行,快掃讓人眼殘像為「64 顆同時亮」 ---------- */
  for (uint8_t i = 0; i < 16; i++) digitalWrite(ROW_PIN[i], HIGH);   // 全熄
  uint8_t r = scanRow;
  scanRow = (scanRow + 1) & 0b1111;

  uint8_t y = r >> 2, z = r & 3;
  digitalWrite(ROW_PIN[r], LOW);                          // 選行 (common-anode: 拉低 = 供電)
  for (uint8_t c = 0; c < 16; c++) {
    uint8_t x = c >> 2, y2 = c & 3;
    bool on = (y2 == y) && cube[x][y][z];
    digitalWrite(COL_PIN[c], on ? HIGH : LOW);            // 拉高 = 通電
  }
  delayMicroseconds(120);                                  // 16x120us ≈ 1.9ms/幀 → ~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 沒有共地,會看到「隨機一兩顆閃」——不是程式錯,是地線錯。共地是最優先的一條線。
  • 電流不夠,亮一半:USB 直供的 500mA 撐不住 64 顆峰值,會看到「上半截亮、下半截暗」或 Mega 重新開機。獨立 5V/3A 一接就好。
  • 掃描太慢,閃爍肉眼可見:16 幀總時間 > 50ms 就会看到「一行一行跑」。把 delayMicroseconds 收到 80~150µs 區間,或降低亮層數。

玩法建議(感測→效果)

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

結語

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

[學習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 錄片影像與狀態文字融合:方式、步驟與注意事項

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 運轉:從串流到自動錄影的完整配置

把之前舊玩具重新拿出來用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)完整記錄

把之前的文章 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 驗證,才算是真的接入艦隊。

2026年10月5日 星期一

Windows 11 接入 SSH 遠端控制:從 OpenSSH Server 到 TTS 中文朗讀的完整記錄

Windows 11 接入 SSH 遠端控制:從 OpenSSH Server 到 TTS 中文朗讀的完整記錄

ASUS Ascent GX10控制的第四台: windows 介面的 NB

AI fleet 第 5 台入列:WinsonNB(Windows 11 25H2、Ryzen 7 5900HS、RTX 3050 Ti)透過 Tailscale + 內建 OpenSSH Server,GX10 可以免密登入、遠端跑指令、甚至叫它喇叭念「XXX ,起床了」。

跟 Android 那台(Termux)不同,Windows 的 SSH 是官方內建的,不需要第三方套件——但坑一個不少。全部記錄如下。

一、目標機

項目 值
型號 WinsonNB(筆電)
系統 Windows 11 25H2(26200)
CPU AMD Ryzen 9 5900HS(8C/16T)
GPU NVIDIA RTX 3050 Ti + AMD Radeon 集顯
RAM 32 GB(實測時只剩 0.9 GB 可用,正有重任務在跑)
C 碟 288 GB NVMe,剩 169 GB
Tailnet 100.95.23.8

二、四步接入(實際走過的順序)

步驟 1:啟用 OpenSSH Server

Win+X → PowerShell(管理員):

# 確認功能(可能已預裝)
Get-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0 | Select-Object State
# 若 NotPresent:
Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0
# 啟動 + 自動
Set-Service -Name sshd -StartupType 'Automatic'
Start-Service sshd
# 確認監聽
Get-NetTCPConnection -State Listen | Where-Object {$_.LocalPort -eq 22}

確認:0.0.0.0:22 和 [::]:22 都 LISTENING(雙 stack 正常)。

⚠️ 排查小坑:用 netstat -ano | findstr ":22 " 會被其他埠號包含 22 的項目(如 22112)誤導,以為有在監聽——要用 Get-NetTCPConnection -State Listen | Where LocalPort -eq 22 才準。

步驟 2:防火牆

New-NetFirewallRule -Name sshd -DisplayName "OpenSSH Server (sshd)" -Enabled True -Direction Inbound -Protocol TCP -Action Allow -LocalPort 22

不加這條,tailnet 的探測會直接 timeout(我們的實測:規則建好前 22 埠從 GX10 連 = timeout,建好後秒開)。

步驟 3:放公鑰(最大的坑,見第五節)

步驟 4:驗證

# GX10 端
ssh winso@100.95.23.8 'whoami; hostname'
# → winsonnb\winso / WinsonNB

三、遠端能力(全部實測)

能力 方式 驗證
跑指令 ssh … "powershell -Command ..." ✅
讀資源(CPU/RAM/GPU/碟) WMI/CIM 指令 ✅ 抓到 5900HS + RTX 3050 Ti
TTS 中文朗讀 System.Speech.SpeechSynthesizer 內建,不需裝任何東西 ✅ 喇叭實播「XXX,起床了」
檔案傳輸 scp 雙向 ✅

TTS 關鍵腳本(Windows 內建,免安裝)

Add-Type -AssemblyName System.Speech
$synth = New-Object System.Speech.Synthesis.SpeechSynthesizer
$synth.Speak("張瑞琪,起床了")

比 Android 的 termux-tts-speak 更乾淨——Windows 的 SAPI 聲音引擎選擇多(可選女聲/男聲/不同腔調)。

四、跟 Android 端對照

項目 Android(vivo) Windows 11
SSH server Termux openssh(第三方) 內建 OpenSSH(官方)
監聽埠 8022(慣例) 22(標準)
免密原理 authorized_keys authorized_keys(同一把)
TTS termux-tts-speak System.Speech(內建)
重開機保持 手動 boot script(vivo 會殺) systemd 對等:服務,穩
難度 7 坑 4 坑
拍相機 termux-camera-photo ✅ 要寫 MediaCapture(未做)

五、踩坑記錄(4 個,依發生順序)

坑 1:netstat -ano | findstr ":22 " 的假陽性

  • 現象:findstr 抓到 127.0.0.1:22112(某 app 的本地埠),以為 sshd 在監聽
  • 解法:改用 Get-NetTCPConnection -State Listen | Where {$_.LocalPort -eq 22}
  • 教訓:shell 的 substring grep 是排查網路問題的第一陷阱

坑 2:Set-Content 預設 UTF-16 BOM

  • 現象:公鑰檔「內容完全一樣」(hash 都算過)但 sshd 一律拒認
  • 真相:Windows PowerShell 5.1 的 Set-Content 預設用 UTF-16LE + BOM 寫檔,OpenSSH 不認
  • 解法:重寫時加 -Encoding ASCII(公鑰是純 ASCII,安全)
  • 教訓:跨平台放金鑰,編碼要顯式聲明——Pi/手機都是 LF+UTF-8,Win 預設 CRLF+UTF-16

坑 3:粘貼又丟字元(老坑重演)

  • 現象:92 vs 93 bytes,第三次出現這個問題了(手機那次是 92 vs 93)
  • 解法:不再貼——GX10 開 python3 -m http.server,Win 上一行 curl.exe -o ... http://100.69.83.97:port/id_ed25519.pub,SHA256 雙邊校驗(F3498230…995E67D 一致)
  • 教訓:「長字串跨機器」一律走檔案傳輸 + 雜湊校驗。這已是第三台機器驗證的方法,可靠率 3/3。

坑 4(★最深★):管理員的公鑰根本不在 authorized_keys

  • 現象:編碼修好了、hash 對了、權限收緊了(icacls 只留 winso/SYSTEM),還是 Permission denied,連續 4 次
  • 真相:用 sshd.exe -d -o LogLevel=DEBUG 前台跑除錯模式,一眼看到:
    user winso matched group list administrators at line 87
    trying public key file PROGRAMDATA/ssh/administrators_authorized_keys
    Could not open authorized keys ... No such file or directory

    Windows OpenSSH 的預設 sshd_config對管理員帳號有獨立規定:金鑰必須放 C:\ProgramData\ssh\administrators_authorized_keys,根本忽略 C:\Users\winso\.ssh\authorized_keys
  • 解法:
    powershell
    Copy-Item "$env:USERPROFILE\.ssh\authorized_keys" "$env:PROGRAMDATA\ssh\administrators_authorized_keys"
    icacls "$env:PROGRAMDATA\ssh\administrators_authorized_keys" /setowner "BUILTIN\Administrators"

    → 一次過,ssh winso@100.95.23.8 立刻登入成功
  • 教訓:
  • 卡住時,看 log 比猜快 100 倍——sshd.exe -d -E file.log 前台除錯模式,每條拒絕原因都寫得出來
  • Windows 的管理員/一般使用者金鑰路徑不同,這個差異官方文件藏得很深
  • 對一般使用者(非管理員),~/.ssh/authorized_keys 照常有效——這坑只咬管理員帳號

六、最終連線方式

# 任何 fleet 成員(或你任何裝置上裝 Tailscale):
ssh winso@100.95.23.8          # 免密, 22 埠

# 遠端跑 Powershell:
ssh winso@100.95.23.8 "powershell -NoProfile -Command 'hostname'"

七、fleet 全景(5 台)

裝置 角色 接入方式
GX10 6679(100.69.83.97) AI 大腦 本機
Pi 5(192.168.1.236 / tailnet) GPIO 現場控制 SSH key
Pi 3(100.123.106.93) 鏡面時鐘 SSH key + systemd mm2.service
vivo V2248(100.117.225.79:8022) 行動終端/相機 Termux + sshd
WinsonNB(100.95.23.8:22) Windows 桌面 + TTS 內建 OpenSSH

同一把公鑰(hermes-gx10,sha256 校驗)開五台門。任何一台上線,都能從 GX10 一鍵觸達。

八、總結

Windows 的 SSH 接入比 Android 簡單得多——官方套件、標準埠、官方 TTS。但坑的品質更高:UTF-16 編碼和管理員金鑰路徑這類問題,不出錯時一切正常,出錯時沒有任何提示,只能靠除錯模式 sshd + 讀 log破案。

核心教訓(全 fleet 通用,第三次驗證):
1. 長字串跨機器,永遠走檔案傳輸 + 雜湊校驗:Pi 3 / vivo / Win 三台,手貼失敗率 100%,HTTP 下載+SHA256 成功率 100%
2. 卡住時,啟用除錯模式看 log:比無限猜快幾個數量級
3. Windows 管理員 ≠ 一般使用者:SSH 金鑰路徑、icacls 權限模型都不同

下一步可以做的:Windows 版「遠端打雷」(同一支 WAV + Start-Process)、相機遠端拍攝(MediaCapture)、或把它接進 fleet 的排程(定時 TTS 叫起床)。

用 SSH 控制 Android 手機:從 Termux 安裝到遠端拍照的完整踩坑記錄

用 SSH 控制 Android 手機:從 Termux 安裝到遠端拍照的完整踩坑記錄

ASUS Ascent GX10控制的第三台: Android 手機

這台 vivo(V2248,Android 15)原本只是口袋裡的電話。今天把它變成 AI fleet 的第 4 台成員:GX10 可以透過 Tailscale 直接 SSH 進去,甚至叫它用後鏡頭拍一張照回傳,再用 AI 分析畫面——全部不經過任何雲端服務。

過程比預期長,踩了 7 個坑。全部記錄如下。

一、目標架構

GX10 (100.69.83.97)
   │  ssh u0_a343@100.117.225.79 -p 8022   (Tailnet, WireGuard 加密)
   ▼
vivo 手機 (Termux + openssh + termux-api)
   │  termux-camera-photo
   ▼
實體相機 → 拍照 → scp 回 GX10 → AI 視覺分析

核心思路:Android 沒有正式的「SSH server」,但 Termux 可以跑一套完整的 Linux 環境,裡面直接裝 openssh,就成了。加上 Tailscale,手機永遠是固定 IP、免開埠、任何網路可達——跟 Pi 3 同一套玩法。

二、手機端安裝(3 個 app)

App 來源 用途
Termux(主程式) F-Droid 或 GitHub(⚠️ 不能用 Play Store 版,已棄置) Linux 環境
Termux:API 同上 相機/麥克風/感測器等硬體橋接
Tailscale 任何 store 都可以 加密私有網路

⚠️ 前兩個必須同來源(F-Droid 裝主程式+副程式,GitHub 也成對裝)——簽名金鑰不同就互相拒絕通訊,這是 Termux 生態最常見的坑。

三、踩坑總表(7 個,依發生順序)

坑 1:ssh-host-config: command not found

  • 現象:pkg install openssh 成功、主機金鑰都生好了,但這支腳本找不到
  • 真相:openssh 10.x 的 Termux 封裝裡,ssh-host-config 是獨立套件的一部分,沒裝上不影響 sshd 本身
  • 解法:根本不需要這支——手動把金鑰放進 ~/.ssh/authorized_keys 就行,sshd 預設就讀那裡

坑 2:sv enable sshd / sv start sshd 全部失敗

  • 現象:unable to change to service directory: …/service: No such file or directory,錯誤路徑裡還雜訊亂入字元(「y」)
  • 真相:Termux 的開源版本沒有預設的 service 骨架;「雜訊字元」是手機輸入法在長命令黏貼時拆行掉字
  • 解法:繞過 sv——用 nohup 背景跑 + 自己寫 boot script(見第五節)

坑 3:長命令黏貼全部變調

  • 現象:rm -f file 報 missing operand、nohup … & sleep 2 報 Extra argument: sleep、ls -ld ~ 變 unknown option -- -、-e 參數被吞……同一台手機、同一條命令,重貼 3-5 次才有一次成功
  • 真相:黏貼是這次最大坑。長命令經過輸入法層時會拆字、吞引號、吞 - 參數
  • 解法:(a) 命令盡量拆短、一詞一詞手打;(b) 需要放檔案時改用「GX10 開 mini HTTP + 手機 curl 拉取」——完全免黏貼(坑 5 的核心解法)

坑 4:Connection refused × 2

  • 現象:每次我連都拒接
  • 真相:第一次是 sshd 沒啟動;第二次是跑 sshd 的前台視窗被關掉了——Android 的記憶體管理會清掉「看起來背景」的行程
  • 解法:最後用純前台 sshd -p 8022 -D -e 跑通(螢幕上一直能看到 Server listening on :: port 8022);常駐方案見第五節

坑 5:金鑰 92 bytes vs 93 bytes,連 3 次 Permission denied(★最終坑★)

  • 現象:sshd 在聽、埠通、權限 700/600 都對了,但所有金鑰都被拒;root、u0_a123 也試過都不對
  • 真相:我貼進去的公鑰少了一個字元(92 vs 93)——又是坑 3 的輸入法拆字,只是這次是內容被吃而不是命令,完全無從對齊
  • 解法(妙手):GX10 上一行命令開 python3 -m http.server 8931,手機上一行 curl 從尾網直接拉取:
curl -o ~/.ssh/authorized_keys http://100.69.83.97:8931/id_ed25519.pub

然後雙邊 sha256 校驗(f3498230…995e67d 逐字一致)→ 下一次 SSH 一次過。這是整篇文章最重要的一課:凡是「長字串跨機器」,永遠走傳輸驗證,不要靠手貼。

坑 6:termux-camera-photo: missing file argument / unknown option -f

  • 真相:這台裝的是老版 Termux:API(0.60 代號,官方已 0.53 重編),語法跟線上文件不符——老版用位置參數、沒有 -f
  • 解法:直接 cat 那支 sh 腳本看 usage,一行就知道:termux-camera-photo <output文件路徑>(絕對路徑)

坑 7:閃光燈 = 軟體層沒打開

  • 硬體:✅ 有(termux-camera-info 顯示 ON_ALWAYS_FLASH 能力)
  • 軟體:❌ 翻完 Termux:API 原始碼(CameraPhotoAPI.java)確認:接收器只讀 file 和 camera 兩個參數,沒有 FLASH_MODE
  • 嘗試:am start -a android.media.action.IMAGE_CAPTURE --ei android.intent.extra.FLASH_MODE 1 → vivo 相機 App 的相容性問題,照片不落盤
  • 結論:A 方案(原生相機 App 手動設「閃光燈常亮」+ Intent 跳轉)最實際;B 方案(自己寫 Camera2 小程序)成本高;C 方案(等上游加參數)最被動

四、最終就緒的連線方式

# 從 GX10(或任何尾網成員, 包含你的筆記本/iPhone 如果裝了 Tailscale):
ssh u0_a343@100.117.225.79 -p 8022

# 免密、公鑰登入 (ed25519: hermes-gx10, 93 bytes, sha256 已校驗)

五、常駐設定(重開機不死)

檔 作用
~/sshd-start 一鍵重啟:pkill→nohup sshd -p 8022 -D→ps 自我檢查,報 SSHd-OK 或完整錯誤
~/.termux/boot-script.sh 調 ~/sshd-start,Termux 重開機時自動觸發
電池優化(手動設) 設定→應用程式→Termux→電池→不受限(不設的話 Android 幾個小時後會殺 sshd)
自動重啟(手動設) Termux 選單→「自動啟動」打開

⚠️ 實測發現:sv 這套在 Termux 開源版不好使(~/.sv 不存在、$PREFIX/var/service 也找不到),所以用上面的手動 boot script 方案,穩定且可控。

六、手機資源盤點(連上後第一時間拿的)

項目 值
型號 vivo V2248
Android 15 (API 35)
SoC 聯發科 MT6833 (Dimensity 700)
CPU 8 核 aarch64
RAM 7.4 GB(可用 ~3.2 GB)
內部儲存 228 GB,已用 129 GB(57%)
Termux 套件 86 個
尾網 IP 100.117.225.79(固定, 永不变)

七、遠端相機——已驗證成功的命令

# 從 GX10 遠端叫拍照 (手機後鏡頭):
ssh u0_a343@100.117.225.79 -p 8022 \
  'termux-camera-photo /data/data/com.termux/files/home/shot.jpg'

# 拉回來 (照片 3060×4080, 940KB~2MB):
scp -P 8022 u0_a343@100.117.225.79:/data/data/com.termux/files/home/shot.jpg ./

# 前置鏡頭:
ssh … 'termux-camera-photo -c 1 /path/to/front.jpg'

已實拍兩張驗證:第一張 940K,第二張 2.0M(客廳夜景、沙發上有人的場景)。

七.5、遠端聲音——打雷、TTS 中文朗讀(實測通過)

同一條 SSH 通道,手機喇叭也能被遠端控制,有兩招都實測成功:

招一:遠端播放音效
1. GX10 上用 Python 合成一段 8 秒低頻滾雷 WAV(布朗噪聲 + 三記指數衰減包絡,352KB)
2. scp -P 8022 thunder.wav u0_a343@100.117.225.79:thunder.wav
3. ssh … 'termux-media-player play ~/thunder.wav' → 手機喇叭響「轟——隆隆隆」三記雷聲
4. 已重播兩次,兩次都 rc=0,用戶親耳確認

招二:遠端 TTS 中文朗讀

ssh u0_a343@100.117.225.79 -p 8022 'termux-tts-speak "張瑞琪,起床了"'

→ 手機喇叭直接念中文繁體(用 Android 系統 TTS 引擎),一詞 rc=0。

附帶能力:termux-volume 可遠端讀/改六條音軌(ring/music/alarm/notification/system/call)的音量;配合 cron 可做「定時叫起床」「遠端報時」「警報推送」。

⚠️ 小坑:
– termux-media-player 要寫 play <檔案> 子命令,直接帶檔案會報 Invalid cmd
– SCP 落點要用相對家目錄(如 u0_a343@…:thunder.wav),用絕對路徑 /sdcard/ 在 Termux 環境會落錯位置
– TTS 要送繁體——手機端的引擎對簡體轉換不一定準確(「張」送成「张」聲母會跑)

八、總結

最難的不是 sshd 本身——Termux 把它包得很完整。

真正難的有兩個:
1. 輸入法黏貼長字串會掉字——任何跨機器的金鑰/設定,都應該走檔案傳輸 + 雜湊校驗,不要靠手貼
2. Android 的「活著」需要主動維持——電池優化、自動重啟、boot script 三件事都要做到,sshd 才會像你 Pi 3 的 pm2 一樣穩定常駐

這台 vivo 現在是 fleet 的第 4 台:GX10(大腦)、Pi 5(GPIO 控制)、Pi 3(鏡面)、手機(行動終端 + 遠端相機)——同一個 Tailscale,同一套 SSH key,任何一台上都能跳進另外三台。

2026年10月3日 星期六

Pi 3 的資源與 GX10 透過 Tailscale 外網溝通

把之前的文章 RaspberryPi Project - MagicMirror Function 用我的gx10做控制

ASUS Ascent GX10控制的第二台: Raspberry Pi3

這台 Raspberry Pi 3(1GB RAM、29GB SD 卡)在機架裡只有一項工作:當 Magic Mirror 2 的鏡面時鐘。而控制這台 Pi 3 的「大腦」是遠端的 ASUS Ascent GX10——兩台機器靠 Tailscale 建立一條加密的專有網路,無論在哪個物理位置都能直接對話。

一、Pi 3 的資源盤點

硬體規格

項目 值
CPU BCM2837 quadri-core(4 核 aarch64)
RAM 1GB(現用 527MB / 剩 378MB 可用)
磁碟 SD 卡 29GB,已用 20GB(70%)
溫 64.5°C(mirror 滿載下屬正常)
顯示 HDMI → 類比鏡面(lightdm + X11)
網路 Wi-Fi(區網)+ Tailscale(100.123.106.93)

資源使用現狀

Mem:  1.9G total, 527Mi used, 378Mi available
SD:   20G / 29G (70%)
temp: 64.5°C

1GB RAM 跑 Magic Mirror 2(Node + Electron + X 桌面)已經用到 55%——沒有餘裕再加大模型或瀏覽器多 tab。這台的定位是「只做一件事:時鐘 + 天氣 + 行事曆 + 新聞」,不適合當 LLM 或 Docker 主機。

二、Magic Mirror 2 的資源使用(鏡面程式)

mm2 在 1GB 機子上跑起來不輕——Node 後端 + Electron 前置 + 9 個 module(clock、weather、calendar、newsfeed、compliments、GoogleAssistant、iFrame、alert、updatenotification)吃掉了 527MB。

優化建議(如想省):
1. 把 updatenotification 關掉(每小時拉 git 檢查,吃 CPU)
2. weather 更新間隔改 30 分鐘(預設 15)
3. newsfeed 關掉或改 30 分鐘
4. 關 X 桌面用 start_x11=false(不需要的話)

三、GX10 側的資源(對比)

項目 GX10
CPU 20 核 ARM
RAM 121GB(統一記憶體)
GPU NVIDIA GB10(1 PFLOP AI)
角色 大腦(地端 LLM、監控、AI 決策)

Pi 3 是終端顯示器 + 輕量觸角;GX10 是中央處理 + 決策。兩者分工明確,互不干擾。

四、Tailscale 外網溝通的建置

為什麼選 Tailscale 而不是 VPN/Port Forward

方案 問題
Port Forward 需路由器開埠、動態 IP、攻擊面大
自建 VPN 需一台常開的中繼機
Tailscale 零配置、端到端加密、IP 固定(100.x)、任何網路可連 ✅

雙端 Tailnet 位址

GX10 : 100.69.83.97    (linux,  帳號 atstest666666)
Pi3  : 100.123.106.93  (linux, 帳號 atstest666666)

連線實測(區網 vs Tailnet)

路徑 延遲 用途
GX10 ↔ Pi3 區網(192.168.1.25 ↔ .236) 2-4ms 日常本地控制
GX10 ↔ Pi3 Tailnet(100.x) 88-323ms 出差/外網也能操控

外網路徑走 WireGuard 加密 + DERP 中繼(Pi 3 和 GX10 同時連到 Tailscale 的 DERP relay),雖然比區網慢 30 倍,但免設防火牆、免開埠、免動態 DNS——出差時一樣能 ssh pi@100.123.106.93 操作鏡面。

五、外網溝通的三種典型場景

場景 A:遠端 SSH 改鏡面

# 在出差酒店的筆電 / 手機 Termux — 只要同一 Tailscale 帳號
ssh pi@100.123.106.93
# 改 config.js、清日誌、重啟 mm2
pm2 restart mm2

場景 B:GX10 自動下達(今天的實作)

GX10 上的 AI(gemma4:e4b) → Tailnet HTTP → Pi3 mm2 網頁/模組

mm2 有 http://127.0.0.1:8080,GX10 可用 http://100.123.106.93:8080 遠端觸發 mm2 的 action(要開埠到 0.0.0.0 才有用;建議只開區網,重要操作走 SSH)。

場景 C:檔案交換(備份/圖檔)

# Pi3 → GX10
scp pi@100.123.106.93:/home/pi/20261003.tar.gz ~/backups/
# GX10 → Pi3(推圖給鏡面用)
scp photos.jpg pi@100.123.106.93:/home/pi/MagicMirror/assets/

六、安全模型

層 保護
傳輸 WireGuard 端到端加密(AES-256)
身份 Tailscale 帳號 + 裝置金鑰
來源限制 同一 Tailnet 才能看到 100.x,外部網路完全看不到這台 Pi
SSH 雙重金鑰(本機 key + 遠端 key)免密碼
防火牆 SD 卡上的 ufw 可選開;因為 100.x 已在 WireGuard 保護下,ufw 不必太嚴

重點:Pi 3 的公網 IP 可以是 0(完全 NAT 後面),外部掃埠根本掃不到 100.x——這是 Tailscale 最大的優勢。

七、實戰:今天完成的完整鏈路

[出差手機/家用筆電]
        ↓ (Tailscale, WireGuard)
     Pi 3 (100.123.106.93)
        ↓ SSH + 備份 tar
[出差中的 GX10, 100.69.83.97]
        ↓ (同一 Tailnet 或區網)
     鏡面(時鐘/天氣/行事曆)

今天實際發生過的:
– 17:04 區網 Wi-Fi 斷線 → Tailscale 路徑不受影響
– 18:00 備份 Pi3 鏡面 216MB → Tailscale scp 拉回 GX10
– 22:00 遠端 SSH 改 mm2(更新嘗試、中止、還原、重啟)— 全程 100.123.106.93,不用區網

八、結語

Pi 3 只有 1GB RAM,是最弱的一台;透過 Tailscale 跟 GX10 聯手,卻能從任何地方被控制、被備份、被遠端維護——弱機器也能當強終端。Tailscale 在這裡的核心價值不是「快」,而是「免配置 + 加密 + 固定位址」這三件事組合起來的無腦可達性。

一句話:區網是日常,Tailnet 是出差/急難,同一條 SSH 命令、同一份備份檔案、同一個遠端鏡面——不用記 IP 切換、不用開任何埠。

Pi 5 GPIO 控制踩坑實錄:AI 大模型下達指令的「GPIO busy」真相

上篇文章寫了 GX10 大腦 + Pi 5 現場 I/O 的架構,這篇文章記錄把它真的跑通的完整過程——包含一個花了很久的怪現象:同一支指令,別人能切、我就 busy。

最終成果(先講結論)

一句話 → 地端大模型判定意圖 → HTTP → Pi 5 真實切 GPIO,全程約 1 秒:

$ python3 gx10_ai_controller.py "把燈關了"
✅ Pi5 健康 | 目前: {'devices': {'relay1': 'on'}, ...}
⏱ LLM (gemma4:e4b) 耗時 0.4s
意圖: {"action": "off", "reason": "關燈"}
✏ Pi5 回應: {"dry_run": false, "pin": 22, "state": "off", "status": "success"}

兩端程式(已部署並在跑)

Pi 5 端 — HTTP 控制器(port 5001,systemd 常駐)

import logging, os, threading
from datetime import datetime
from flask import Flask, request, jsonify

DRY_RUN = os.environ.get("DRY_RUN", "0") == "1"
RELAY_PIN = int(os.environ.get("RELAY_PIN", "22"))
ACTIVE_HIGH = os.environ.get("ACTIVE_HIGH", "0") == "1"
logger = ...   # 省略

relay = None
if not DRY_RUN:
    from gpiozero import OutputDevice
    # 低電位觸發繼電器(active_high=False)
    relay = OutputDevice(RELAY_PIN, active_high=ACTIVE_HIGH, initial_value=False)

app = Flask(__name__)

@app.route("/api/device", methods=["POST"])
def control_device():
    data = request.get_json(silent=True) or {}
    action = data.get("action", "").lower()
    if action not in ("on", "off"):
        return jsonify({"status": "error", "message": "action must be on|off"}), 400
    if relay: relay.on() if action == "on" else relay.off()
    return jsonify({"status": "success", "state": action, "dry_run": bool(DRY_RUN),
                    "pin": None if DRY_RUN else RELAY_PIN})

systemd user service(~/.config/systemd/user/pi5-relay.service):

[Service]
Type=simple
Environment=DRY_RUN=0
Environment=RELAY_PIN=22
ExecStart=/home/pi/venv/bin/python3 /home/pi/pi5_relay.py
Restart=always

加上 loginctl enable-linger pi,重開機不需要登入就會自動起服務。

GX10 端 — AI 決策層

SYSTEM_PROMPT = """你是決策樞紐。只回一個 JSON 物件, 格式:
{"action":"on" 或 "off","reason":"簡短說明"}
開/open/turn on→on;關/close/turn off→off;無關控制→{"action":"none"}"""

# 1) 地端 Ollama(gemma4:e4b,temperature=0)解析意圖
# 2) 意圖 → POST http://<pi5>:5001/api/device
# 3) 非 on/off(含 none)一律不下達, 直接回報

踩坑實錄:「GPIO busy」的真相

這是本次最大的坑。現象:Pi 5 上已有一個程式(我稱它 search_bot)能用 gpiozero 正常切繼電器,我寫的新程式卻全部失敗,錯誤一路切換:

嘗試 結果
系統 python + OutputDevice(17) KeyError: PinInfo(number=11, ...)
RPi.GPIO Cannot determine SOC peripheral base address
sysfs GPIO export OSError: Invalid argument
換腳位再試 lgpio.error: 'GPIO busy'

一個一個排除下來,真相其實是最普通的一種:

GPIO pin 是排他的。search_bot 已經 claim 了 GPIO 14/15/17/18/21(4 顆 relay + 1 顆按鈕),任何新程序再 claim 同一支腳就会 busy。

驗證方式:挑一支 search_bot 沒用的腳(GPIO 22)做同一個測試——

✅ OutputDevice(22) 建立成功
GPIO22 -> on
GPIO22 -> off
--- gpiozero 在自由腳位上工作正常 ---

一次就通。所谓「怪現象」,就是「腳被佔用」。

為什麼 RPi.GPIO 會掛?

Pi 5 有多個 GPIO controller(/dev/gpiomem0~4、多個 gpiochip),RPi.GPIO 舊版要猜「SOC peripheral base」就猜錯;gpiozero + gpiozero/lgpio 走 gpiochip 介面,選對 controller 就正常——這就是同一台機子、同樣的 pin,一個能跑一個掛的原因。

三個實戰原則

  1. GPIO 是排他資源 — 多程序共用在 Pi 上一定會撞;分配腳位前,先用 journalctl 或 pids + 程式碼 grep 看誰已經 claim 了
  2. DRY_RUN 是上線安全閘 — 新控制器先跑 DRY_RUN=1,API 鏈路全通、GPIO 不碰,確認無誤再切 0
  3. 選對 pin factory — Pi 5 上 RPi.GPIO(舊)不穩;gpiozero + lgpio backend 是現行正解

可復用清單

項目 值
Pi 5 服務 systemctl --user status pi5-relay
狀態 API GET http://<pi5>:5001/api/status
下達 API POST /api/device {"action":"on\|off"}
換腳位 service RELAY_PIN= 改數字,restart 即可
反相 ACTIVE_HIGH=1
回滾 DRY_RUN DRY_RUN=1,API 鏈路照跑、不碰 pin

結語

「別人能跑、我不能跑」的坑,90% 是資源已被佔用而不是環境壞掉。這次的 GPIO busy 就是如此:把「腳位排他性」這個前提寫進腦子,下次一眼就能看出誰跟誰撞了。