2026-09-27

Serial USB HID 技術補充

之前在下曾使用 Arduino Nano 搭配 Python 模擬HID 功能,讓不支援 硬件HID 的裝置也能達到相同的效果。
由於 模擬HID 具備更高的操作權限,因此,在下想再次探索功能可行性。

不論是 硬件HID 還是 模擬HID ,即便功能再豐富、能自動執行大量指令,依然只能執行固定操作。
雖然在多數情況下這不成問題,但在特定情境下就會產生限制。
例如在 桌面Linux 環境中,一般的 複製快捷鍵 是 Ctrl+C;
但在 終端機 中, Ctrl+C 是發送 中止訊號 (Terminate Signal) , Ctrl+Shift+C 才是複製。
如果只維持固定的按鈕輸出,複製功能便無法在終端機中正常運作。

當然也有折衷的替代方案,例如專為 終端機 另外設定一顆 Ctrl+Shift+C 的按鈕。
然而,當同一項操作在不同軟件間都有不同的快捷鍵時,就必須為每個軟件設定專用按鍵。
在硬件按鍵數量有限的情況下,這樣做極為浪費資源。

因此在下認為,如果能夠在背景程式中自動判斷當前焦點的軟件,
就能實現 單一按鈕根據不同軟件,自動匹配相應快捷鍵 的效果。

製作 Sketch

在下使用先前撰寫的 Arduino Sketch 測試。

#define PIN 2
bool previous_status = false;
void setup() {
    Serial.begin(115200);
    pinMode(PIN, INPUT_PULLUP);
}
void loop() {
    bool current_status = !digitalRead(PIN);
    if (previous_status && !current_status) { // release
        previous_status = !previous_status;
        Serial.write(0x00);
    } else if (!previous_status && current_status) { // press
        previous_status = !previous_status;
        Serial.write(0x01);
    }
    delay(1);
}

這是一個非常簡單的 Arduino Sketch:
當 第2支數碼腳位 偵測到 下降沿 (Falling Edge) 時,會向主機發送 0x01;
當 偵測到 上升沿 (Rising Edge) 時,則發送 0x00。

完成微控制器的訊號輸出設定後,便需要撰寫 宿主 (Host) 用於接收與處理訊號的程式。

製作背景程式

import time
from Xlib import X
from Xlib.display import Display
from serial import Serial
def get_active_window_class():
	try:
		return Display().create_resource_object(
			"window", # 必須全小細
			Display().screen().root.get_full_property(
				Display().intern_atom("_NET_ACTIVE_WINDOW"), # 必須全大細
				X.AnyPropertyType
			).value[0]
		).get_wm_class()
	except:
		return None
serial = Serial("/dev/ttyUSB0", 115200)
while True:
	if serial.in_waiting >= 1:
		raw_data = serial.read(1)
		if raw_data == b"\x01":
			window_class = get_active_window_class()
			if window_class == None:
				print("No Window info")
			else:
				if "mate-terminal" in window_class:
					print("偵測到 {} ,Terminal 程式".format(window_class))
				else:
					print("偵測到 {} ,一般 GUI 程式".format(window_class))
	time.sleep(0.001)

正常情況下,與軟件互動是依據 當前視窗焦點 來判斷。
因此,背景程式同樣可以將當前的視窗焦點作為判斷基準。

安裝Xlib

既然需要判斷視窗焦點,在 桌面Linux 中便可使用 Xlib函式庫 。
它除了是 X11 的基礎圖形介面函式庫外,在 Wayland 環境下也能透過相容層提供支援,例如 XWayland 。

在 終端機 輸入:

sudo apt install python-xlib

或 按此安裝 python-xlib 。

或使用 pip 安裝,輸入:

pip install xlib

安裝 Xlib函式庫 後即可執行上述 Python腳本 。

測試效果

連接 微控制器 並啟動 背景程式 後,只要 視窗焦點 切換並觸發 微控制器訊號,
背景程式 便能即時 擷取該視窗的 類別名稱 (Class Name) 或 軟體名稱,甚至連桌面環境都能區分。

import time, pyautogui
from Xlib import X
from Xlib.display import Display
from serial import Serial
def get_active_window_class():
	try:
		return Display().create_resource_object(
			"window", # 必須全小細
			Display().screen().root.get_full_property(
				Display().intern_atom("_NET_ACTIVE_WINDOW"), # 必須全大細
				X.AnyPropertyType
			).value[0]
		).get_wm_class()
	except:
		return None
serial = Serial("/dev/ttyUSB0", 115200)
while True:
	if serial.in_waiting >= 1:
		raw_data = serial.read(1)
		if raw_data == b"\x01":
			window_class = get_active_window_class()
			if window_class == None:
				print("No Window info")
			else:
				if "mate-terminal" in window_class:
					print("偵測到 {} ,Terminal 程式".format(window_class))
					pyautogui.hotkey("ctrl", "shift", "c")
				else:
					print("偵測到 {} ,一般 GUI 程式".format(window_class))
					pyautogui.hotkey("ctrl", "c")
	time.sleep(0.001)

確定能夠區分不同視窗或軟件後,便能修改 背景程式,根據當前焦點視窗執行對應的操作。

補充資料

不過需要注意的是, 桌面Linux 在 X11 與 Wayland 架構下,視窗管理機制 已經不同;
如果閣下是使用 Windows,可搭配 pywin32函式庫,而 Mac OS 則可使用 AppKit函式庫 來達成類似效果。

由於本文重點在於示範 Serial USB HID 背景程式如何實現跨視窗偵測,因此這裡就不逐一測試各個作業系統的具體實作,
在下只撰寫簡單腳本展示自動根據作業系統擷取視窗類別名稱。

import sys
def get_active_window_class():
	try:
		if sys.platform == "win32":
			# Windows
			# pip install pywin32
			import win32gui
			return win32gui.GetClassName(win32gui.GetForegroundWindow()
		elif sys.platform == "darwin":
			# Mac OS
			# pip install pyobjc-framework-Cocoa
			from AppKit import NSWorkspace
			return NSWorkspace.sharedWorkspace().frontmostApplication().localizedName()
		elif sys.platform.startswith("linux"):
			# Linux
			# pip install xlib
			from Xlib import X
			from Xlib.display import Display
			return Display().create_resource_object(
				"window",
				Display().screen().root.get_full_property(
					Display().intern_atom("_NET_ACTIVE_WINDOW"),
					X.AnyPropertyType
				).value[0]
			).get_wm_class()
		else:
			return None
	except:
		return None

總結

雖然這次的內容屬於技術補充與測試驗證專案,但只要根據實際需求調整,
就能製作出高度客製化且靈活的專屬功能。

參考資料

文章經 AI 整理及潤飾。

沒有留言 :

張貼留言