由於 Arduino Nano 原生不支援 USB HID 協定,無法直接作為 鍵盤或滑鼠 等 輸入設備 向宿主 發送訊號。
因此,若想將 Arduino Nano 模擬為 USB HID 裝置,通常需要借助額外的 硬體改裝 或 軟件模擬 來實現。
若要讓不具備原生 USB HID 功能的 微控制器 模擬 HID訊號,通常需要透過 序列通訊 (Serial Communication) 來 橋接 (Bridging,
由 微控制器 將 控制指令 封裝成 序列資料 (Serial Data) 送出,再交給 宿主 (Host) 並將其 轉譯 為 標準的HID輸入訊號。
發送序列資料
const byte PINS[] = {2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, /*13,*/ 14, 15, 16, 17, 18, 19, 20, 21};
bool previous_statuses[sizeof(PINS) / sizeof(byte)];
void setup() {
Serial.begin(115200);
for (byte i = 0; i < sizeof(PINS) / sizeof(byte); i++) {
pinMode(PINS[i], INPUT_PULLUP);
previous_statuses[i] = false;
}
}
void loop() {
for (byte i = 0; i < sizeof(PINS) / sizeof(byte); i++) {
bool current_status = !digitalRead(PINS[i]);
if (previous_statuses[i] && !current_status) { // release
previous_statuses[i] = !previous_statuses[i];
Serial.write(0);
Serial.write(PINS[i]);
} else if (!previous_statuses[i] && current_status) { // press
previous_statuses[i] = !previous_statuses[i];
Serial.write(1);
Serial.write(PINS[i]);
}
}
delay(1);
}
在下撰寫 Arduino Sketch ,並自訂了一套簡易的 通訊封包格式 (Package Format):
- 第0位元組(狀態): 0x00 代表 釋放 (Released) , 0x01 代表 按下 (Pressed) 。
- 第1位元組(編號): 直接引用 硬體引腳編號 (Pin Number) 作為按鈕的 唯一識別碼 (Unique Identifier, UID) 。
確立這套訊號結構後,即可透過 序列埠 (Serial) 將指令送出,並由宿主系統中的常駐程式即時監聽,並而觸發對應操作。
接收序列資料
from serial import Serial
import pyautogui
SERIAL_PORT = "/dev/ttyUSB0"
BAUD_RATE = 115200
serial = Serial(SERIAL_PORT, BAUD_RATE)
while True:
if serial.in_waiting >= 2:
data = serial.read(2)
print(data)
if data[1] == 2:
if data[0] == 0: # release
pyautogui.keyUp("a")
elif data[0] == 1: # press
pyautogui.keyDown("a")
在下在宿主裝置使用 Python 製作背景常駐程式實時監聽。
當程式接收到 序列封包 時,會依據 data[0](狀態) 及 data[1](編號) 來解析事件,並觸發對應的操作。
若日後需要擴充更多按鈕功能,只需在 Python 腳本中新增相應的編號邏輯即可,具備良好的擴充性。
測試效果。
自動連接
from serial.tools.list_ports import comports def get_serial_port(): for port in comports(): # CH340, FT232RL if (port.vid == 0x1A86 and port.pid == 0x7523) or (port.vid == 0x0403 and port.pid == 0x6001): return port.device return None SERIAL_PORT = get_serial_port()
由於 Arduino Nano 在每次連線時的系統連接埠並不固定,
為了避免每次執行程式前都需手動修改路徑,在下讓 Python 程式具備自動搜尋與動態配置連接埠的功能。
這不僅省去繁瑣的操作,也能實現跨平台相容,使程式在不同作業系統上記錄並即插即用。
不過,系統偵測到的裝置未必燒錄了對應的自訂韌體。
為避免誤連其他硬體設備,在下在通訊架構中引入了 握手機制(Handshake) 。
- 主動驗證:連線時由宿主裝置發送特定配對訊號。
- 雙向確認:微控制器接收後回應預設的驗證暗號。
透過這種雙向驗證機制,能確保當前連接的硬體確實支援預期的按鍵通訊功能,提升系統運作的穩定度與安全性。
配對設備
const byte PINS[] = {2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, /*13,*/ 14, 15, 16, 17, 18, 19, 20, 21};
bool previous_statuses[sizeof(PINS) / sizeof(byte)];
void setup() {
Serial.begin(115200);
// handshake
bool paired = false;
while (!paired) {
if (Serial.available() > 0) {
String input = Serial.readStringUntil('\n');
input.trim();
if (input == "are you arduino nano?") {
paired = true;
Serial.println("yes, i am arduino nano.");
} else {
Serial.println("again!");
}
}
}
for (byte i = 0; i < sizeof(PINS) / sizeof(byte); i++) {
pinMode(PINS[i], INPUT_PULLUP);
previous_statuses[i] = false;
}
}
void loop() {
for (byte i = 0; i < sizeof(PINS) / sizeof(byte); i++) {
bool current_status = !digitalRead(PINS[i]);
if (previous_statuses[i] && !current_status) { // release
previous_statuses[i] = !previous_statuses[i];
Serial.write(0);
Serial.write(PINS[i]);
} else if (!previous_statuses[i] && current_status) { // press
previous_statuses[i] = !previous_statuses[i];
Serial.write(1);
Serial.write(PINS[i]);
}
}
delay(1);
}
在 Arduino 端的實作上,在 setup() 階段會主動等待宿主裝置送出 are you arduino nano? 的握手請求:
- 驗證成功:當接收到的字串比對無誤時,Arduino 會回應 yes, i am arduino nano.,隨後正式進入 loop() 主程式運作。
- 驗證失敗:若接收到的請求不符,則回應 again! 提示宿主裝置需要重新發送握手請求。
from serial import Serial
from serial.tools.list_ports import comports
import time
def get_serial(baudrate):
for port in comports():
if port.vid == 0x1A86 and port.pid == 0x7523:
serial = Serial(port.device, baudrate)
print("{} connecting.".format(port.device))
time.sleep(5)
print("{} connected.".format(port.device))
serial.write(b"are you arduino nano?\n")
while True:
if serial.in_waiting > 0:
response = serial.readline().decode('utf-8').strip()
if response == "yes, i am arduino nano.":
print("{} handshake complete.".format(port.device))
return serial
return None
serial = get_serial(115200)
宿主裝置與 Arduino Nano 連接時,會先發送握手請求,唯有在微控制器的回應符合預期時,才會正式建立 Serial 連線物件。
然而在目前的設計中,只要完成一次握手,後續隨時都能直接進行連線與操作,導致握手機制失去作用。
除非手動將 Arduino Nano 重置 (Reset) ,否則微控制器無法重新回到等待握手的初始狀態。
心跳機制
const byte PINS[] = {2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, /*13,*/ 14, 15, 16, 17, 18, 19, 20, 21};
bool previous_statuses[sizeof(PINS) / sizeof(byte)];
bool paired = false;
unsigned long last_heartbeat = 0;
const unsigned long HEARTBEAT_TIMEOUT = 60000;
void setup() {
Serial.begin(115200);
for (byte i = 0; i < sizeof(PINS) / sizeof(byte); i++) {
pinMode(PINS[i], INPUT_PULLUP);
}
}
void loop() {
// handshake
if (!paired) {
if (Serial.available() > 0) {
String input = Serial.readStringUntil('\n');
input.trim();
if (input == "are you arduino nano?") {
for (byte i = 0; i < sizeof(PINS) / sizeof(byte); i++) {
previous_statuses[i] = false;
}
paired = true;
last_heartbeat = millis();
Serial.println("yes, i am arduino nano.");
} else {
Serial.println("again!");
}
}
} else {
// keep alive
if (Serial.available() > 0) {
String input = Serial.readStringUntil('\n');
input.trim();
if (input == "are you still alive?") {
last_heartbeat = millis();
Serial.println("yes, i am still alive.");
}
}
// expired
if (millis() - last_heartbeat > HEARTBEAT_TIMEOUT) {
paired = false;
Serial.println("please pair again.");
} else {
// polling
for (byte i = 0; i < sizeof(PINS) / sizeof(byte); i++) {
bool current_status = !digitalRead(PINS[i]);
if (previous_statuses[i] && !current_status) { // release
previous_statuses[i] = !previous_statuses[i];
Serial.write(0);
Serial.write(PINS[i]);
} else if (!previous_statuses[i] && current_status) { // press
previous_statuses[i] = !previous_statuses[i];
Serial.write(1);
Serial.write(PINS[i]);
}
}
}
}
delay(1);
}
為了避免宿主裝置繞過握手驗證,技術上雖然可以在接收到宿主程式的 退出請求 (Disconnect) 時,讓 Arduino Nano 重新回復到等待握手的狀態;
但實際應用中會發生 程式崩潰 或 使用者強制關閉 等意外,導致 Arduino 無法接收到退出的訊號。
因此, 重置握手狀態 不能依賴宿主程式發起,而必須由 Arduino Nano 主動超時重置。
要實現重複驗證的機制,需要將握手邏輯移至 loop() 中,並引入 時間限制 (Timeout) ,
一旦宿主程式未能在規定時間內發送續約請求, Arduino Nano 便會判定連線失效,並自動恢復為等待握手的狀態。
from serial import Serial
from serial.tools.list_ports import comports
import time
import pyautogui
import subprocess
handshake_question = "are you arduino nano?"
handshake_answer = "yes, i am arduino nano."
alive_question = "are you still alive?"
alive_answer = "yes, i am still alive."
def get_serial(vid, pid, baudrate):
for port in comports():
if port.vid == vid and port.pid == pid:
serial = Serial(port.device, baudrate, timeout = 0.1)
print("{} connecting".format(port.device))
time.sleep(2)
print(handshake_question)
serial.reset_input_buffer()
serial.write(handshake_question.encode("UTF-8"))
start = time.time()
while time.time() - start < 5:
if serial.in_waiting > 0:
response = serial.readline().decode("UTF-8").strip()
if response == handshake_answer:
print(response)
return serial
return None
serial = get_serial(0x1A86, 0x7523, 115200)
last_ping = time.time()
PING_INTERVAL = 4
if serial != None:
while True:
if time.time() - last_ping >= PING_INTERVAL:
#print(alive_question)
serial.write(alive_question.encode("UTF-8"))
start = time.time()
while time.time() - start < 5:
if serial.in_waiting > 0:
response = serial.readline().decode("UTF-8").strip()
if response == alive_answer:
#print(response)
last_ping = time.time()
break
if serial.in_waiting > 0:
data = serial.read(2)
if len(data) >= 2:
for i in range(2, 22, 1):
if data[1] == i:
if data[0] == 1:
subprocess.Popen(["bash", "-c", "mate-terminal"])
elif data[0] == 0:
pass
測試效果。
為了配合 Arduino Nano 的超時機制,宿主程式必須加入 心跳機制 (Heartbeat) 的功能。
在設計此請求的發送間隔時,需要權衡:
- 避免頻率過高:過於頻繁地發送會佔用序列埠頻寬與系統資源,造成無謂的輪詢負擔並影響操作體驗。
- 必須小於超時時間:為防止連線意外中斷,發送週期必須小於超時臨界值。
理想的設計會在超時時間的 30% 至 45% 之間發送延期請求。
這樣的時間設定,能確保即使延期請求發送失敗或遺失,宿主程式在超時前仍有 1 至 2 次 重試 (Retry) 的機會,大幅提升連線的穩定度。
補充資料
這種架構除了能模擬標準 USB HID 外,還能直接調用系統層級的操作,例如:
- 將游標移動至螢幕的絕對座標
- 直接啟動指定應用程式
- 執行與系統硬體相關的底層操作
這賦予了它傳統 HID 裝置無法實現的強大控制能力。
然而,這種方式必須先由 Arduino Nano 將操作封包經由序列埠送出,再交由宿主端背景程式解析與轉譯指令,一連串處理無可避免地會帶來額外延遲。
因此,此方案並不適用於追求極致低延遲、需要即時反應的情境;
若有毫秒級回應的需求,仍建議直接使用原生支援 HID 功能的微控制器。
事實上,許多電腦週邊產品,特別是電競裝置,也採用了非常相似的設計架構,主要可分為以下三種模式:
- 硬體原生 (Native)
採用具備原生 HID 協定的晶片,直接在硬體層面發送標準的鍵盤或滑鼠訊號。
優點是無需安裝任何驅動程式,在任何宿主系統上皆能隨插即用。 - 軟件橋接 (Bridge)
裝置本身僅發送 原始資料 (Raw Data) ,高度依賴運行於宿主系統上的背景軟件進行轉譯與觸發。
雖然能實現調用系統 API 等進階操作,但缺乏軟件支援時便無法獨立運作。 - 混合模式 (Hybrid)
結合兩者優勢。裝置內部整合了 處理晶片 與 板載記憶體 (On-board Memory) ,
能調用系統 API 等進階操作;當宿主系統未安裝或無法執行背景軟件時,仍能透過板載記憶體發送自訂的標準 HID訊號;
一旦偵測到背景軟件連線,則能切換並執行更複雜的系統級操作。
由於專案使用了 pyautogui 與 pyserial 函式庫,執行前需要在終端機輸入:
pip install pyautogui pyserial
來安裝對應函式庫。
部分 Linux發行版 (例如 Debian系列) 禁止使用 pip 安裝 Python套件,避免與系統套件衝突,需要改用 系統套件 (例如 apt) 來安裝。
但若部分 Python套件 未收錄於 系統套件庫 中,則需前往 PyPI https://pypi.org/ 下載套件及其相依套件並手動設定。
亦可透過 python-venv 建立 Python虛擬環境 來安裝套件,但執行已調用相關函式庫的程式時,必須在該虛擬環境下運作。
在下發現可以在 pip 加上 --break-system-packages 參數以繞過系統限制。
但如果 系統套件庫 有提供 對應Python套件,建議使用 系統套件,以免有套件衝突等問題。
另外,在 Linux 下存取 /dev/tty* 裝置時,使用者帳號必須加入 dialout群組;否則執行時需要使用 root 或 sudo 。
還有,避免封包傳輸的產生雜訊影片,令宿主程式分析錯誤,可以加入標頭或任何專用訊號的標誌,確保必須是符合標頭才分析往後的資料。
總結
這個專案的靈感,源自於在下對市面上某些 巨集鍵盤 的觀察與好奇。
在下發現這類產品除了需要透過專用軟件更改設定外,有些還必須讓軟件保持執行狀態,巨集功能才能正常運作。
更關鍵的是,它們竟能執行 開啟軟件 這類 系統級操作,但按常理來說,標準的 USB HID 裝置是不可能開啟作業系統的軟件。
這讓在下意識到這些裝置必然是透過背景軟件在背後轉譯。
既然是靠宿主端的軟件運作,那即使像 Arduino Nano 這種原生不支援 USB HID 協定的微控制器,
理論上也完全能達到相同的效果。經過實際驗證,這個猜想確實可行。
此外,這個專案也讓我重新複習了通訊領域中的 握手協定 (Handshake) 、 配對驗證 (Pairing) 、 心跳機制 (Heartbeat) 。
在現代開發環境中,許多底層框架早已將這些機制代為實作,開發者只需修改參數即可使用;
但在這個專案裡,需要從零開始用自己的邏輯去規劃與撰寫程式碼,只學習理論很沉悶,將理論實踐出來才會有趣,亦能證明論。


沒有留言 :
張貼留言