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。