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