2026-08-14

讓 Arduino Nano 能夠發送 USB HID 訊號

由於 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) 。
在現代開發環境中,許多底層框架早已將這些機制代為實作,開發者只需修改參數即可使用;
但在這個專案裡,需要從零開始用自己的邏輯去規劃與撰寫程式碼,只學習理論很沉悶,將理論實踐出來才會有趣,亦能證明論。

參考資料

沒有留言 :

張貼留言