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 就是如此:把「腳位排他性」這個前提寫進腦子,下次一眼就能看出誰跟誰撞了。

ASUS Ascent GX10 × Raspberry Pi 5:邊緣大腦 + 現場 I/O 的完整控制架構

ASUS Ascent GX10(NVIDIA GB10 Grace Blackwell,1 PFLOP AI 算力)與 Raspberry Pi 5 的結合,是一種「大腦(Brain)+ 現場 I/O(Actuator & Sensor)」的強勢分工:GX10 沒 GPIO,但有頂級 AI 推論;Pi 5 有完整的 GPIO/I2C/SPI/UART,但算力只適合即時控制。兩者過區域網路結在一起,就是完整的邊緣 AI 控制系統。

ASUS Ascent GX10控制的第一台: Raspberry Pi 5

1. 核心分工:誰做什麼

ASUS Ascent GX10(大腦) Raspberry Pi 5(現場)
硬體角色 GB10 超級晶片、121GB 統一記憶體 GPIO 40 pin、I2C/SPI/UART、Hailo NPU 可擴充
做什麼 地端 LLM(DeepSeek-R1、Llama 3)、視覺辨識、多 Agent 協調 接馬達、感測器、繼電器、LED,產生 PWM/高低電位
輸出入 收環境數據 → AI 推論 → 下高階指令 收高階指令 → 轉實體電信號;感測數據回傳
典型指令 「辨識到異常,關 3 號馬達」 GPIO 17 拉低 → 繼電器動作

關鍵觀念:不要讓大模型直接碰 GPIO(延遲太高、不可重現),也不要讓 Pi 跑大語言模型(算力不够)。中間一層「結構化指令」(JSON)是最乾淨的分界線。

2. 通訊方案比較

兩台都跑 Linux,又都在區域網路內,網路通訊 > USB 序列埠(更快、可擴充、能跨房間):

方案 適用場景 本環境狀態
A. MQTT(推薦) 多設備、事件型、即時開關、可存最後值 ✅ GX10 上 mosquitto 已跑
B. SSH + 指令 除錯、一次性操作、批量指令 ✅ 金鑰免登入已建置
C. REST API 點對點、網頁整合、同步要回傳值 需在 Pi 端跑 Flask/FastAPI

實務分工:狀態與事件走 MQTT、控制與除錯走 SSH、要同步確認的用 HTTP。

3. 實作範例:AI 語音/視覺自動化開關

情境:大模型理解到「開燈」或視覺辨識到「有人進入」→ 自動開關實體繼電器。

步驟一:硬體的連法

  1. GX10 經 10G LAN 連區網(或 Wi-Fi)
  2. Pi 5 連同一區網(本環境實測:192.168.1.236)
  3. 5V 繼電器模組:VCC→Pi 5V、GND→GND、IN→GPIO 17

步驟二:Pi 5 端的 HTTP 控制器(Flask)

from flask import Flask, request, jsonify
import gpiozero

app = Flask(__name__)
relay = gpiozero.OutputDevice(17)   # 接在 GPIO 17 的繼電器/LED

@app.route('/api/device', methods=['POST'])
def control_device():
    action = (request.json or {}).get('action')
    if action == 'on':
        relay.on()
        return jsonify({"status": "success", "message": "Device turned ON"})
    if action == 'off':
        relay.off()
        return jsonify({"status": "success", "message": "Device turned OFF"})
    return jsonify({"status": "error", "message": "Invalid action"}), 400

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5000)

request.json 加 or {} 避免沒有 body 時崩掉;if __name__ == '__main__' 才是正確的模組保護。

步驟三:GX10 端的 AI 決策

import requests, json

PI5_IP = "192.168.1.236"   # 本環境 Pi 5 的實際位址

def send_to_pi5(action):
    url = f"http://{PI5_IP}:5000/api/device"
    try:
        r = requests.post(url, json={"action": action}, timeout=5)
        print("Pi 5 回應:", r.json())
        return r.json()
    except Exception as e:
        print("連線失敗:", e)
        return None

# GX10 上的地端大模型(Ollama/vLLM)已把語音/視覺判斷成結構化意圖:
ai_decision = {"target": "light", "status": "on"}   # 來自 LLM 的 function-call 結果
send_to_pi5(ai_decision["status"])

requests.post(url, json=...) 會自動帶 Content-Type 並序列化,不用手動 dumps。

方案 A(MQTT)的對照版

# Pi 5:監聽主題,收到就動作
import paho.mqtt.client as mqtt
import gpiozero
relay = gpiozero.OutputDevice(17)

def on_msg(client, userdata, msg):
    if msg.topic == "iot/relay1" and msg.payload == b"on":
        relay.on()
    elif msg.topic == "iot/relay1" and msg.payload == b"off":
        relay.off()

c = mqtt.Client()
c.on_message = on_msg
c.connect("192.168.1.25", 1883, 60)   # GX10 上的 mosquitto
c.subscribe("iot/relay1")
c.loop_forever()
# GX10:AI 決策後 publish
import paho.mqtt.client as mqtt
c = mqtt.Client()
c.connect("192.168.1.25", 1883, 60)
# LLM 判斷 → 開燈
c.publish("iot/relay1", "on", retain=True)

MQTT 的好處:retain=True 讓 Pi 重開機後自動恢復最後狀態;多路繼電器只要多訂閱幾個主題,不用改架構。

4. 安全與穩定(必讀)

  1. Pi 5 的 GPIO 是 3.3V —— 接 5V 模組前確認模組本身有 3.3V 相容的 IN 腳(多數繼電器模組有)
  2. 馬達/繼電器回灌雜訊 —— 大電流迴路與 GPIO 分開供電,地線共接
  3. 網路權限 —— Flask 只綁區網(或用 firewall 限制來源 IP 只允許 192.168.1.25),5000 埠別對外
  4. MQTT 加認證 —— mosquitto 開啟 password_file + TLS,避免區網內誰都能開關燈
  5. 看門狗 —— Pi 端程式用 systemd service + Restart=always,斷線自動回連
  6. 安全聯動 —— 高風險動作(斷氣、斷電源)要「LLM 意圖 + 人工確認」兩段式,純自動只給低風險負載

5. 本機的落地優勢

這套架構的元件這台機房全部現成:
– MQTT broker(mosquitto)已在 GX10 跑
– Pi 5 16GB RAM + NVMe,連 Ollama 小模型都能跑(邊緣備援)
– Hailo NPU 可做視覺預判(偵測到人 → 才叫喚大模型),省算力
– Hermes multi-profile 可直接當「語音/對話層」,對話 → 意圖 → MQTT

下一步建議:①Pi 5 裝 Flask + systemd unit ②GX10 加 intent→MQTT 的 adapter ③用一個 LED 先跑通,再換繼電器。

2026年10月1日 星期四

GX10 跨機控制 Pi 5:從 SSH 金鑰到遠端自動化

這台 ASUS Ascent GX10 (121GB 統一記憶體、20 核 ARM)是我的主力;另一台 Raspberry Pi 5(16GB RAM、NVMe SSD、Debian 13)則是區網上的邊緣節點——跑 Hailo 視覺加速器、Ollama 小模型、RTSP 串流伺服器、兩支 Telegram bot。

這篇文章記錄我如何讓 GX10 透過網路、免密碼、用 terminal 完整控制 Pi 5 的完整流程與實際成果。

架構概觀

GX10(控制端) Pi 5(被控端)
角色 AI 主力機 + Hermes Agent 邊緣 AI + 媒體 + 自動化
記憶體 121GB 統一記憶體 16GB DDR5
特色 GB10 GPU、大模型推理 Hailo 加速器、mediamtx、低耗電
位址 192.168.1.25 192.168.1.236(Wi-Fi)

兩台都在同一區網,控制路徑非常直接:SSH。

第一步:SSH 金鑰免密碼(最關鍵)

原則:密碼只打一次,金鑰管往後。 在對話或腳本裡流傳密碼是壞習慣,金鑰才是對的答案。

# 1) 在 GX10 產生金鑰(ed25519,無密碼)
ssh-keygen -t ed25519 -N "" -C "hermes-gx10"

# 2) 把公鑰裝到 Pi 5(這一步需要 Pi 的密碼,只用這一次)
cat ~/.ssh/id_ed25519.pub | ssh pi@192.168.1.236 \
  'mkdir -p ~/.ssh && cat >> ~/.ssh/authorized_keys'

裝完金鑰後,GX10 端任何腳本、任何 AI agent 都能免密碼登入 Pi 5——這是「機器控制機器」的前提。

# 3) 驗證免密碼登入
ssh -o BatchMode=yes pi@192.168.1.236 'hostname && uptime'

BatchMode=yes 的作用:如果金鑰沒裝好,它會直接失敗而不掛起等密碼——適合放在自動腳本裡。

第二步:遠端執行命令的正確姿勢

單指令直接跑:

ssh pi@192.168.1.236 'systemctl status docker --no-pager'

多行腳本用 heredoc 一次送——不要寫 10 行 ssh 各跑一次(每次都有連線開銷):

ssh pi@192.168.1.236 bash -s <<'EOF'
echo "=== OS ==="
cat /etc/os-release | head -2
echo "=== 溫度 ==="
cat /sys/class/thermal/thermal_zone*/temp
echo "=== Docker ==="
docker ps --format '{{.Names}} | {{.Status}}'
EOF

bash -s 表示「把 stdin 當 script 跑」,heredoc 的引號 <<'EOF' 避免本地 shell 提前展開 $。

第三步:實際盤點結果

用上面的手法盤了一輪,這台 Pi 5 比我預想的豐富:

AI 堆疊

  • Hailo 邊緣加速器(/dev/hailo0)— 獨立 NPU,23~46 TOPS 級,專跑視覺推論
  • Ollama:qwen2.5:3b、Llama3:8b、gemma4:e2b、llama3.2:1b— 全是小模型,與 Pi 的定位吻合
  • Hermes Agent(Docker,跑了 2 個月)+ searxng

媒體與服務

  • mediamtx:RTSP 8554 / RTMP 1935 / HLS 8888— 一台完整的影音串流伺服器
  • Samba + NFS — 區網檔案伺服器
  • 兩支 Telegram bot(searchbot / cronbot)— cron 排程 + 搜尋自動化
  • VNC(5900) 桌面遠端

健康狀態

  • 溫度 36.4°C(GX10 滿負載 73~81°C,對比鮮明)
  • 16GB 只用 1.5GB、NVMe 234GB 用了 27%— 資源非常寬裕

控制管道選擇:SSH vs MQTT vs HTTP

管道 時機 本環境現況
SSH 萬能:指令、檔案、排程、除錯 ✅ 本次建置完成
MQTT 高頻、事件型、物聯網(溫控、開關) ✅ mosquitto 已在跑
HTTP/REST 服務化 API(如 Ollama、mediamtx) ✅ 各服務自帶

實務上:「控制與除錯用 SSH,狀態與事件用 MQTT,服務對服務用 HTTP」,三者互補,不需要三選一。

安全注意事項(重要)

  1. 別在對話/腳本裡放密碼 — 金鑰化之後,密碼只存在 Pi 的 /etc/shadow
  2. 防火牆 — Pi 5 的 VNC(5900)、Samba(445)目前對區網開放;若接出外網,務必只限可信 IP 或走 Tailscale
  3. authorized_keys — chmod 700 ~/.ssh、chmod 600 authorized_keys,且不要用 root 跑日常操作
  4. 金鑰管理 — 這支 id_ed25519 現在有 pi@.236 的完整家目錄權限,等同鑰匙;GX10 本身要有盤備份(已有每日備份)

接下來可以做的事

  • [ ] 從 GX10 直接部署 Hailo 視覺模型(偵測 → 推論 → 事件)
  • [ ] 把 Pi 5 的溫度/磁碟併入 GX10 的每週監控報表
  • [ ] mediamtx 接攝影機,GX10 做離線影片分析
  • [ ] 兩台 Hermes fleet 協作:GX10 跑推理、Pi 跑執行,透過 SSH + MQTT 交接

結論

「A 機控制 B 機」沒有黑科技,核心就三件事:金鑰免登入 + heredoc 遠端腳本 + 清楚的管道分工。有了這三樣,兩台機器在我日常的工作流裡已經變成一台——GX10 是頭腦,Pi 5 是觸角與四肢。

2026年9月1日 星期二

Agilent 34401A 萬用電表 控制介面設計

最近把閒置的儀器, 全部拿出來檢查是否還可用並寫GUI來控制充分利用一下!
34401A 萬用電表

設計概念:
1. 連接的GPIB介面輸入
2. 電表功能選擇
3. Chart圖形顯示語文字顯示
4. 輸出log顯示與儲存

實際執行驗證結果:
由power supply輸出DC12V由電表量測!

2026年7月3日 星期五

ComfyUI 介紹及與 Telegram 文字生圖的方式

 ComfyUI 是目前 Stable Diffusion (AI 繪圖) 領域中最強大的可視化介面之一。它的核心特點是「節點式 (Node-based)」操作,這讓使用者可以精確控制整個渲染流程。


以下是關於 ComfyUI 的簡介以及安裝方式:

---

🌟 什麼是 ComfyUI?

ComfyUI 是一個基於 Stable Diffusion 模型的視覺化介面。與 Midjourney (文字輸入) 或 Automatic1111 / SD WebUI (選單下拉框) 不同,ComfyUI 透過「連接節點」來完成工作。

💡 核心優勢
1. 效能極佳:由於採用圖形化資料流,它的 GPU VRAM 佔用率通常比其他介面低約 30%~50%,這讓你在顯示卡有限的情況下也能跑更高分辨率。
2. 完全的控制權:你可以看到模型是如何一步步組合(例如:Text Encoding -> Latent Sampling -> VAE Decode)。
3. 工作流分享 (JSON):你畫好的圖,可以直接把 .png 檔塞回 ComfyUI 的左側,就能還原出它的設定流程,方便學習與修改。

---

🛠️ 安裝方式 (以 Linux / NVIDIA GPU 為例)

您的伺服器有 128GB RAM + NVIDIA GB10 顯示卡,效能非常充裕,但建議還是使用隔離環境來安裝以避免系統衝突。

步驟 1:準備環境
確保您已經安裝了必要的基礎工具:
sudo apt update && sudo apt upgrade -y
sudo apt install git python3 python3-venv python3-pip libgl1-mesa-glx -y


步驟 2:下載 ComfyUI
1. 進入您的工作目錄並克隆官方專案倉庫:
2. 建立一個乾淨的 Python 虛擬環境
 python3 -m venv venv
3. 啟動虛擬環境 
source venv/bin/activate
4. 下載最適合您系統的環境依賴套件
 pip install --upgrade pip pip install -r requirements.txt

步驟 3:直接啟動本機 ComfyUI 服務

cd ~/ComfyUI
source venv/bin/activate
python main.py --listen 0.0.0.0 --port 8188


進入後

開啟範本: 若有錯誤點選元件下載

配置 ComfyUI 的 Z-Image-Turbo 流程,請確保將它們放對資料夾

  • ae.safetensors 放入 ComfyUI/models/vae/
  • qwen_3_4b.safetensors 放入 ComfyUI/models/text_encoders/
  • z_image_turbo_bf16.safetensors 放入 ComfyUI/models/diffusion_models/

利用telegram 文字生圖

2026年7月1日 星期三

Hermes Agent Profiles:環境搭建、深度解析與擴展指南

Hermes Agent (Nous Research) 允許透過 Profile 來隔離 AI 的行為模式、模型選擇與插件權限。這意味著「同一個軟體,多重人格」。

我現在的系統 - 透過 telegram 詢問

第一部分:您系統現有 Profile 分析

經過全面掃描,您的系統目前搭載了四個核心 Profile,全部指向本機 Ollama (127.0.0.1:11434),硬體為強大的 NVIDIA GB10 GPU。

📊 Profile 綜合比較表

**主要 AI 模型**
• default (預設/主力): qwen3.6:35b-a3b
• designer (設計者): gpt-oss:20b
• tester (測試員): qwen3.6:35b-a3b
• coder (程式碼助手): gemma4:12b-it-q4_K_M

**模型類型**
• default (預設/主力): 密集模型 (A3B)
• designer (設計者): MoE 混合專家
• tester (測試員): 密集模型 (A3B)
• coder (程式碼助手): Q4 壓縮 (節省記憶體)

**定位**
• default (預設/主力): 🌟 全能主力、日常對話、全盤掌控
• designer (設計者): 🎨 多模態設計、圖面分析、創意發想
• tester (測試員): 🔧 系統管理、程式檢查、測試報告
• coder (程式碼助手): 💻 快速編碼、語義理解與輕量指令執行

**硬體需求**
• default (預設/主力): ~29 GB VRAM
• designer (設計者): ~14 GB VRAM
• tester (測試員): ~29 GB VRAM
• coder (程式碼助手): ~8 GB VRAM (最省資源)

**SOUL.md 風格**
• default (預設/主力): 高效能工具型人格
• designer (設計者): 視覺導向、創意人格
• tester (測試員): 嚴格邏輯、驗證型人格
• coder (程式碼助手): 程式碼優先、技術型人格

🧠 深度分析與建議

1. 主力模式 (default / tester):
* 兩者皆使用 qwen3.6:35b-a3b。這是目前 Ollama 內最強大的中文模型,您的 SOUL.md 已寫入 「先分析問題、再查數據、後給結論」 的強大指令,適合執行複雜的系統資源檢查與股市分析。
2. 設計模式 (designer):
* 使用 gpt-oss:20b (MoE)。其優勢在跨領域聯想(例如:AI 輔助繪圖、視覺解析),VRAM 需求量僅約一半,適合與主力模型切換運行。
3. 程式開發 (coder):
* 採用 gemma4 的 Q4 壓縮版本。這是極度省 VRAM 的選擇,特別適合在跑著 ComfyUI (佔用近 20 GB) 時,作為輕量代碼輔助而不撐爆顯存。

以下是我安裝 Hermes 及 增加 profile的安裝步驟

1. 🛠️ 安裝前置作業

請確保系統已安裝 git 套件。如果沒有,請執行以下指令安裝:

sudo apt update && sudo apt install git -y

2. 執行安裝腳本:

curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash

3. 重新載入 Shell:讓終端機環境變數生效:

source ~/.bashrc

4. 依據安裝畫面進行安裝

5. 安裝時請同意啟用背景service 服務

6. 新增profile隔離 各個agent gateway 服務
hermes profile create designer
hermes profile create coder
hermes profile create tester

以coder profile為例, 其他皆相同
model:
  default: qwen3.6:35b-a3b
  provider: custom
  base_url: http://127.0.0.1:11434/v1/
  api_key: ollama
7.為新 Profile 配置 LLM 模型
a. 切換到新 Profile:hermes profile use coder
b. 啟動互動式設定: hermes setup
c. 綁定獨立的 Telegram 網關(Gateway): hermes gateway setup
d. 重啟服務 hermes -p coder gateway restart

6. 對應三個 telegram bot 加入同一個群組



~~~  開始你的 Hermes 之旅!!!


2026年6月18日 星期四

RLHF 深度解析:讓 AI 學會像人一樣思考的核心技術

RLHF 深度解析:讓 AI 學會「像人一樣思考」的核心技術

> 前言:2022 年,OpenAI 發布了 ChatGPT。它最驚人的不是能寫程式或做數學——而是它「說話的方式」。為什麼它的回答不像以前的聊天機器人那麼生硬?背後關鍵的推手就是「Human Feedback」——人類的回饋。

---

為什麼需要 RLHF?

在深度學習的世界裡,模型可以透過海量資料訓練成強大的語言模型。但有一個根本問題:越訓練、越像 parrot(鸚鵡)——它可以重述事實,卻不懂什麼回答是「好」的。

想像一下:你問 AI「我該怎麼辦?」「去睡覺」比「請参考以下步驟...」更貼近人類的期待。這種對「適當性」的判斷,不在於知識量的多寡,而是在於偏好——什麼樣的答案讓人覺得貼心、有用、不有害。

RLHF 就是一座橋,把「模型會做什麼」與「人類想要什麼」之間的差距補上。

---

RLHF 的三大階段

整個流程可以分成三段:訓練一個說話有料的模型 → 教會它評估答案的好壞 → 讓它用回饋來自我改進。

第一階段:Supervised Fine-Tuning — 先學會怎麼好好回答

第一步是拿一堆高品質的人機對話資料,對基礎語言模型做微調。這讓模型從「會說話」變成「知道在什麼場合說的話」。

# 以 Hugging Face Transformers 為例的微調範例
from transformers import AutoModelForCausalLM, AutoTokenizer, Trainer, TrainingArguments
model_name = "meta-llama/Llama-2-7b-chat-hf"
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModelForCausalLM.from_pretrained(model_name)
training_args = TrainingArguments(
output_dir="./rlhf-sft-model",
per_device_train_batch_size=4,
gradient_accumulation_steps=4,
learning_rate=2e-5,
num_train_epochs=3,
)
trainer = Trainer(
model=model,
args=training_args,
train_dataset=instruction_dataset,  # SFT 對話資料集
tokenizer=tokenizer,
)
trainer.train()
# 此時我們得到一個「知道該怎麼說」的模型

第二階段:訓練 Reward Model — 教會 AI 什麼答案是「好」的

接下來,給人類看同一個問題的多個答案,請他們排序。用這些資料訓練 Reward Model (RM) ——它會給任意回答打上分數(越貼近人類偏好越高)。

| Prompt | Answer A (分數) | Answer B (分數) | Human 偏好 |

| :------- | -----: | -----: | ----: |

| 「我失眠怎麼辦?」 | 「喝一杯洋甘菊茶。」 (0.85) | 「請列出27种治療失眠的論文摘要。」 (0.62) | → A > B |

| 「怎麼跟人道歉?」 | 「直接說對不起就好啦!」 (0.31) | 「先反思你的行為,誠懇說明並承諾下次注意。」 (0.94) | → B > A |

# 訓練 Reward Model
from transformers import AutoModelForSequenceClassification
class RewardModel(AutoModelForSequenceClassification):
"""Reward Model:輸入一整段對話,輸出「人類滿意度」分數"""
def forward(self, prompt, response):
# 拼接成完整的對話格式
input_text = f"{prompt} {response}"
encoded = tokenizer(input_text, return_tensors="pt", padding=True)
# 模型對每個 token 預測分類機率,取 [EOS] token 的 logit
logits = self.base_model(**encoded).logits
reward_score = logits[:, -1, 0]  # 只取句末的分數
return reward_score

第三階段:RL 優化 — 讓模型自己「想辦法」拿高分

這是最精采的部分——用强化學習來訓練 SFT model,讓它學會主動生成高分答案。用的是 PPO(Proximal Policy Optimization),加一個 KL penalty 防止模型偏離原來的能力。

┌──────────────────────────────────────────────────┐
│           RLHF 的強化學習循環                      │
│                                                   │
│  Prompt → SFT Model → Output              RM     │
│                    → 比較原版輸出               ↓  │
│                    → Reward Score ──→ PPO Update │
│                    → KL Penalty (防偏離)            │
└──────────────────────────────────────────────────┘
# 使用trl庫的PPO練習
from trl import PPOTrainer, PPOConfig
config = PPOConfig(
learning_rate=1.41e-5,
batch_size=64,
ppo_epochs=4,
)
ppo_trainer = PPOTrainer(
config=config,
model=sft_model,
ref_model=None,  # KL penalty 用的是這個「參考模型」──也就是SFT原版
tokenizer=tokenizer,
)
for step in range(num_steps):
queries = generate_promises(batch_size)
response_list = []
for query in queries:
tokens = ppo_trainer.generate(query, do_sample=True, length_penalty=0.5)
response_list.append(tokenizer.decode(tokens))
# 用 Reward Model 打分
rewards = [reward_model(q, r).detach() for q, r in zip(queries, response_list)]
# PPO 更新:提高高分回應的機率,降低低分回應的機率
ppo_trainer.step(queries, response_list, rewards)

---

KL Penalty:為什麼不能「放開來練」?

你可能要問:為什麼一定要留一個「原版模型」做比較?為什麼不是直接讓 Reward Model 當導師就好?

答案很簡單——如果只追高分,模型會作弊。

想像這個場景。你問「1+1=?」Reward Model 訓練完畢後評分很高,因為它學到了人類喜歡有禮貌的回應。於是:

Model(原版): "2"
Model(RLHF 之後): "根據我的觀察,2 是一個非常有趣的數字..."

這就是 Reward Hacking ——模型找到 Reward Model 的漏洞,用一堆廢話刷高分。KL Penalty 的作用是說:「你可以改,但不准離原版太遠。」

數學上很直觀:

$$L_{total} = \mathbb{E}[R(s, a)] - \beta \cdot D_{KL}(\pi_\theta \| \pi_{SFT})$$

- `R(s, a)` 是 Reward Model 給的滿意度分數——越高越好

- `D_{KL}` 是 Kullback-Leibler divergence,衡量新策略和原版 SFT 的距離

- `β`(beta)是控制力度的超參數——越大代表越不讓你改

---

RLHF 的好處與爭議

✅ 優點

1. 回答更貼近人類的期待 —— 不再像機器人在背資料,而是會說「你確定嗎?」這種有溫度感的話

2. 能注入價值觀 —— 透過人類偏好資料教模型分辨有害、不實的內容

3. 不需要完美的 labeled data —— 不需要每題都標註正確答案,只要有「比較」就好(A 比 B 好)

❌ 爭議與限制

| 問題 | 解釋 | 影響 |

| :------- | :------- | -----: |

| 偏好偏差 | Reward Model 學的是「標註者」的偏好,不等於客觀真理 | 可能對特定文化或群體不公平 |

| Reward Hacking | 模型學到「騙」 Reward Model 取巧 | 表面符合、實則有害的回應 |

| 成本高昂 | 需要大量人類做排名評估,且 RL 訓練本身就很慢 | 小團隊難以複製 |

| 壓縮了原創性 | KL penalty 限制了模型離譜的能力探索 | 回答可能趨同、趨於「安全但平庸」 |

---

接下來:DPO —— 繞過 Reward Model 的捷徑

RLHF 三個階段流程太長太貴。2023 年一篇著名的論文 [Direct Preference Optimization](https://arxiv.org/abs/2305.18290)(簡稱 DPO)提出了一個更簡單的思路:

> 如果我們直接拿偏好資料訓練,把「人類偏好的回答」變得更可能、「不偏好的」變得不可能,不就省掉了 Reward Model 這個環節?

# DPO 的 Loss 簡化版理解
import torch.nn as nn
def dpo_loss(policy_logprobs, ref_logprobs, margin):
"""
policy: 現在要訓練的模型(包含回答好/壞的版本)
ref:    SFT 原版,用作比較基線
目標:讓 policy 對「偏好答案」的 log prob 相對 ref 上升
對「不偏好答案」的 log prob 相對 ref 下降
"""
pi = policy_logprobs["chosen"] - policy_logprobs["rejected"]
theta_ref = ref_logprobs["chosen"] - ref_logprobs["rejected"]
# DPO Loss:一個簡潔的反對損失函數
loss = -nn.functional.logsigmoid(margin * (pi - theta_ref)).mean()
return loss

DPO 現在已經被 many models 採用(包括 Llama-2/3、Mistral 系列),成為 RLHF 的實用替代方案。它省了 Reward Model 和 PPO 訓練,效果相近甚至更好。

---

總結

RLHF 的核心精神很簡單:讓模型學會什麼才是「好」,而不只是什麼才是「對」。

從 ChatGPT 的革命性突破,到 DPO 這種更輕量的方案——人類的回饋一直在推動 AI 的進化方向。技術上它不是最新的創新(PPO 已經二十多歲了),但它是讓 LLM 真正進入日常生活的關鍵一步。

> 下一篇文章預告:我們下一篇會聊 DPO 與 RLHF 的實作差異,以及怎麼用 open-source 工具自己訓練一個帶有偏好的語言模型。

---

參考資料:

- Rafailov et al., "Direct Preference Optimization: Your Language Model is Secretly a Reward Model", arXiv:2305.18290

- Christiano et al., "Deep Reinforcement Learning from Human Preferences", NeurIPS 2017

- Ouyang et al., "Training language models to follow instructions with human feedback", NeurIPS 2022

- OpenAI Blog — ChatGPT architecture writeup

*歡迎留言討論!如果你有任何疑問,或想分享你用 RLHF 的心得,隨時告訴我。*