當你喜愛的軟體停止更新時,該怎麼辦?
這是一個關於如何為已停止維護的遙控軟體注入新生命的故事。透過協定研究、技術探索與創新實作,我打造了一個現代化的多功能遙控解決方案。
專案起源:為什麼要做這個專案?
這個專案源於我自己的特殊需求:由於我僅右手食指可活動,需要一個能透過 Android 平板遠端控制 PC 的解決方案。原本的 Unified Remote 和 EmulStick 雖然都是不錯的工具,但兩者都已停止維護和更新,且缺乏針對像我這樣有身體限制使用者的最佳化。
為什麼這些軟體停止更新很重要?
- Unified Remote:官方已停止維護,Android 客戶端不再更新,協定規格凍結
- EmulStick:硬體接收器韌體已穩定,不再改版
這意味著:
- ✅ 協定穩定性高:不用擔心協定變更導致相容性問題
- ✅ 長期可維護:研究出來的協定格式可以長期使用
- ❌ 功能凍結:無法針對新需求客製化
- ❌ 相容性問題:新版 Android 可能出現相容性問題
這正是我開發新客戶端的契機:既然官方不再更新,我可以自由研究協定、客製化功能,為這些停更的工具注入新生命。
產品優勢整合
這兩個產品各有優勢:
- Unified Remote:功能豐富,可透過網路(Tailscale)遠端控制
- EmulStick:即插即用,無需伺服器軟體,但需要藍牙接收器(一個小型USB裝置,可將Android裝置變成PC的鍵盤/滑鼠/遊戲手把)
EmulStick 接收器簡介: EmulStick 是一款創新的藍牙接收器,外觀類似小型USB隨身碟。插入PC後,會建立一個藍牙HID(人機介面裝置)連線,讓Android平板或手機能夠直接模擬成PC的鍵盤、滑鼠和遊戲手把。與傳統的軟體解決方案不同,EmulStick透過硬體層面的模擬,確保了極低的延遲和最高的相容性,幾乎所有PC遊戲和應用程式都能完美支援。
於是我決定打造一個整合方案,讓自己能根據情境選擇最適合的連線模式。
第一個挑戰:研究 BLE HID 協定
背景:由於 EmulStick 官方已停止更新,無法從官方文件取得協定規格。我必須透過研究原廠 APP 的實作來了解 BLE 通訊協定。
掃描裝置:從完全失敗到成功
專案初期最大的挫折是 無法掃描到 EmulStick 裝置。當時的情況是:
- ❌ nRF Connect 看不到裝置
- ❌ 平板藍牙設定看不到裝置
- ❌ 我開發的 APP 掃描不到裝置
- ✅ 但原廠 APP 可以正常連線
這個矛盾讓我陷入困境。經過深入研究原廠 APP 的程式碼,我發現了問題所在:
錯誤的兩段式掃描策略
原本我實作了一個「聰明」的兩段式掃描:
- Phase 1 (1.5秒):使用 Service UUID 過濾 (
0xF800) - Phase 2 (2.5秒):無過濾器(與原廠一致)
但實際上 EmulStick 的廣播封包可能不包含 Service UUID,導致 Phase 1 完全無法命中,而 Phase 切換過程中又可能遺失裝置。
解決方案:回歸簡單
我建立了一個診斷 APP,完全模仿原廠的單段掃描:
// 單段 4 秒掃描,無過濾器
val scanSettings = ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
.build()
bluetoothLeScanner.startScan(null, scanSettings, scanCallback)
結果立刻成功!這個經驗讓我學到:不要過度最佳化,先求能動,再求最佳化。
身份驗證:AES 加密挑戰
成功掃描到裝置後,下一個挑戰是身份驗證。EmulStick 使用 AES 加密來防止未授權存取:
驗證流程
- 讀取裝置的 System ID(8 bytes)
- 根據 Software Version 取得對應的明文密碼
- 使用 System ID 作為 AES 密鑰加密明文
- 傳送「取得密文」指令(
0x91) - 比對接收器回傳的密文與本地加密結果
關鍵程式碼
// 生成 AES 密鑰(System ID 轉 16 進位字串)
val key = systemId.joinToString("") { "%02X".format(it) }
// AES 加密
val encrypted = AesCryptUtil.encrypt(key, plainText)
// 取前 16 字元比對
val expectedCipher = encrypted.take(16).toByteArray(Charsets.UTF_8)
return expectedCipher.contentEquals(cipherFromDongle)
雖然這個機制看似複雜,但所有關鍵資訊(明文密碼、加密算法)都在原廠 APP 中,因此只要完全複製邏輯就能通過驗證。
突破時刻:找到正確的 HID Report 格式
身份驗證完成後,連線成功,但所有滑鼠/鍵盤操作都無反應!
經過深入研究,我發現 EmulStick 有兩個版本:
| 版本 | 滑鼠格式 | 通道 | Report ID | 大小 |
|---|---|---|---|---|
| Ver 0/-1 (舊版) | Mouse | CH1 (0xF801) | 3 | 7 bytes |
| Ver ≥1 (新版) | MouseV1 | CH3 (0xF803) | 0 (無) | 6 bytes |
關鍵發現:
- 滑鼠改用 CH3 通道(從 CH1 改為 CH3)
- 移除 Report ID(從獨立 byte 改為不加 ID)
- 報告大小減少 1 byte
修正後的滑鼠報告
// 6 bytes,無 Report ID
fun buildMouseReport(
buttons: Int,
deltaX: Int,
deltaY: Int,
wheel: Int
): ByteArray {
return byteArrayOf(
buttons.toByte(), // [0] 按鈕(無 Report ID)
(clampedX and 0xFF).toByte(), // [1] X 低位元組
((clampedX shr 8) and 0xFF).toByte(), // [2] X 高位元組
(clampedY and 0xFF).toByte(), // [3] Y 低位元組
((clampedY shr 8) and 0xFF).toByte(), // [4] Y 高位元組
clampedWheel.toByte() // [5] 滾輪
)
}
修正後,滑鼠終於能動了!
第二個挑戰:中文輸入的困境
HID 鍵盤只能傳送 ASCII 字元,那中文怎麼辦?我研究了三種方案:
方案 1: Big5 Alt 碼(⚠️ 有限制)
原理:按住 Alt → 數字鍵台輸入十進制 → 釋放 Alt → Windows 轉換為字元
例如:Alt + 43938 → 哈(Big5: 0xABA2)
結果:
- ⚠️ 某些程式即使收到 CP950 的 Alt 鍵加上數字鍵台序列,仍會強制轉換為 CP1252,導致中文字元無法正確顯示
- ❌ 在 RDP 環境中完全無效
- ⚠️ 只支援 Big5 字元(約 13,000 個字)
- ✅ 在某些傳統應用程式中仍可正常運作
結論:更改鍵盤的實作邏輯,優先使用方案二(Alt+X Unicode 模式),方案一(Big5 Alt 碼)作為備援,兩者互補以提升中文輸入的相容性和可靠性。雖然方案三(IME Direct)速度最快,但由於需要官方轉譯程式且非標準HID協定,已實作過但後來移除,因此不作為主要方案。
方案 2: Alt+X Unicode 模式(⚠️ 可行但效率較低)
原理:輸入十六進制 Unicode 碼 → 按 Alt+X → Windows 轉換為字元
例如:54C8 + Alt+X → 哈(Unicode: U+54C8)
優點:
- ✅ 在記事本中也能用
- ✅ 支援所有 Unicode 字元(U+0000 - U+FFFF)
- ✅ 在 RDP 環境中有效
缺點:
- ❌ 每個中文字需要 4-5 個字元 + Alt+X(較慢)
- ❌ 可能在某些應用程式中不支援
效能分析:
- 單字輸入時間:約 170ms / 字
- 相比 IME Direct 慢約 3.4 倍
方案 3: IME Direct(CustomIn 報告)(❌ 已移除)
原理:透過 EmulStick CustomIn 報告直接傳送 UTF-8 文字
優點:
- ✅ 輸入速度最快(約 50ms / 字)
- ✅ 支援所有 Unicode 字元(包括 Emoji、特殊符號等)
- ✅ 不依賴應用程式特性(通用性最高)
- ✅ 不需要複雜的編碼轉換(直接使用 UTF-8)
限制:
- ⚠️ 需要官方開發的轉譯程式在背景執行
- ⚠️ 不是標準的 HID 協定,已實作過但後來移除
- ⚠️ 依賴特定硬體接收器的韌體支援
實作:
fun buildCustomInReport(text: String): List<ByteArray> {
val utf8Bytes = text.toByteArray(Charsets.UTF_8)
val reports = mutableListOf<ByteArray>()
var offset = 0
while (offset < utf8Bytes.size) {
val chunkSize = minOf(16, utf8Bytes.size - offset)
val chunk = utf8Bytes.copyOfRange(offset, offset + chunkSize)
val report = ByteArray(19).apply {
this[0] = 40 // Report ID
this[1] = 32 // UNICODE_TEXT
this[2] = chunk.size.toByte()
chunk.copyInto(this, 3)
}
reports.add(report)
offset += chunkSize
}
return reports
}
效能對比
| 方法 | 單字時間 | 「哈囉」時間 | 相對速度 |
|---|---|---|---|
| Big5 Alt 碼 | ~600ms | ~1200ms | ⚠️ 較慢(12x) |
| Alt+X Unicode | ~170ms | ~340ms | ⚠️ 中等(6.8x) |
| IME Direct | ~50ms | ~50ms | ❌ 已移除(曾最快) |
第三個挑戰:遊戲手把模擬
後來我想到一個有趣的想法:能否將平板變成遊戲手把來玩 PC 遊戲?
研究 EmulStick 協定時,我發現它支援切換為 Xbox 360 控制器模式!
XInput 模式切換
切換指令:
val command = byteArrayOf(
0x50.toByte(), // CMD_SET_EMULDEVICE
systemId[6],
systemId[7],
0x5E.toByte(), // VID 低位(0x045E)
0x04.toByte(), // VID 高位
0x8E.toByte(), // PID 低位(0x028E)
0x02.toByte() // PID 高位
)
虛擬手把介面設計
我設計了一個符合真實 Xbox 360 控制器佈局的虛擬手把介面。
橫向模式(符合真實 Xbox 360 布局):
┌────────────────────────────────────────────────────────┐
│ [LB] [LT滑桿] [RT滑桿] [RB] │ ← 上緣
├────────────────┬───────────────────────────────────────┤
│ │ │
│ ┌─────┐ │ [Y] │
│ │ ● │ │ [X] [B] │ ← 中央
│ └─────┘ │ [A] │
│ 左搖桿 │ │
│ │ │
│ ↑ │ ┌─────┐ │
│ ← → │ │ ● │ │
│ ↓ │ └─────┘ │
│ D-Pad │ 右搖桿 │
├────────────────┴───────────────────────────────────────┤
│ [Back] [Start] [L3] [R3] │ ← 底部
└────────────────────────────────────────────────────────┘
關鍵技術點:
- 虛擬搖桿實作
Box(modifier = Modifier
.size(130.dp)
.pointerInput(Unit) {
detectDragGestures(
onDrag = { change, dragAmount ->
// 計算搖桿位置(-1.0 ~ 1.0)
val newX = (currentX + dragAmount.x).coerceIn(-maxRadius, maxRadius)
val newY = (currentY + dragAmount.y).coerceIn(-maxRadius, maxRadius)
// 傳送到控制器
onMove(newX / maxRadius, newY / maxRadius)
},
onDragEnd = {
// 自動回中心
onRelease()
}
)
}
)
- 扳機狀態同步問題與解決
初期遇到一個 bug:調整左扳機時,右扳機被重置為 0;調整右扳機時,左扳機被重置為 0。
原因:每個扳機組件內部都有獨立的狀態,互相覆蓋。
解決方案(State Hoisting):
// 父布局同時持有左右扳機狀態
var leftTrigger by remember { mutableStateOf(0f) }
var rightTrigger by remember { mutableStateOf(0f) }
// 左扳機
TriggerSlider(
value = leftTrigger,
onValueChange = { newValue ->
leftTrigger = newValue
xInputController.setTriggers(newValue, rightTrigger)
}
)
// 右扳機
TriggerSlider(
value = rightTrigger,
onValueChange = { newValue ->
rightTrigger = newValue
xInputController.setTriggers(leftTrigger, newValue)
}
)
技術亮點總結
1. 協定研究與實作
BLE GATT 服務架構:
EmulStick Service (0xF800)
├── CH1 (0xF801) - 鍵盤 HID 報告(SingleKeyboard, 8 bytes)
├── CH2 (0xF802) - CustomIn 報告(IME Direct, 19 bytes) + 遊戲手把
├── CH3 (0xF803) - 滑鼠 HID 報告(MouseV1, 6 bytes)
├── CH4 (0xF804) - 觸控筆/多媒體 HID 報告
└── COMMAND (0xF80F) - 控制指令(身份驗證、模式切換)
2. 創新解決方案
- 中文輸入:CustomIn 報告直接傳送 UTF-8,繞過 HID 限制
- 遊戲手把模式:完整的 Xbox 360 控制器模擬
- 單指最佳化:大按鈕、簡潔介面、手勢操作
3. 模式整合
最終實現了雙系統三模式架構:
系統 1: Unified Remote(網路遙控器)
- 模式 1-A: TCP/IP 連線(透過 Tailscale)
- 模式 1-B: 藍牙 RFCOMM 連線
系統 2: EmulStick(接收器模式)
- 模式 2: BLE HID 連線
- 組合模式:鍵盤 + 滑鼠
- XInput 模式:Xbox 360 控制器
- IME Direct 模式:中英文即時輸入
4. 跨平台挑戰:為什麼 iOS 版難以實現?
在完成 Android 版本後,我曾研究是否能將此專案移植到 iOS 平台。然而,我遇到了 iOS 平台的根本性限制:
iOS 不支援傳統藍牙 RFCOMM
問題核心:
- ❌ iOS 不開放 Bluetooth Classic (RFCOMM/SPP):第三方 APP 無法使用傳統藍牙
- ✅ iOS 僅支援 BLE (GATT):必須完全重新設計協定
這意味著:
- Unified Remote 的藍牙模式(RFCOMM)無法在 iOS 上運作
- 即使 PC 有 Unified Remote Server,iOS 也無法透過藍牙連線
可行的替代方案
| 方案 | 可行性 | 開發成本 | 限制 |
|---|---|---|---|
| TCP/IP 模式 | ✅ 完全可行 | ⭐ 低 | 需要網路環境(Tailscale 或區域網路) |
| EmulStick BLE 模式 | ✅ 完全可行 | ⭐⭐ 中低 | 需要購買硬體接收器 |
| 自訂 BLE 協定 | ⚠️ 理論可行 | ⭐⭐⭐⭐⭐ 極高 | 需開發 PC 橋接程式 + iOS 傳輸層 |
為什麼自訂 BLE 協定困難?
若要在 iOS 上實作類似 Unified Remote 的藍牙功能,需要:
PC 端扮演 BLE Peripheral:
- ❌ Windows 對 BLE Peripheral 支援極差
- ⚠️ 可能需要外接 Raspberry Pi 作為藍牙橋接器
重新設計完整協定:
- 實作 BLE GATT Profile(自訂 Service/Characteristic)
- 處理 MTU 限制與封包分段
- 實作可靠傳輸(ACK/重傳機制)
- 設計安全驗證機制
開發 PC 橋接程式:
- 接收 BLE 封包
- 轉換為 Unified Remote 協定
- 轉送至 Unified Remote Server
預估開發時間:4-6 週以上
結論
基於開發成本與技術限制,我決定:
- ✅ Android 平台:完整支援三種模式(TCP/RFCOMM/BLE)
- ⚠️ iOS 平台:建議使用 TCP 模式(透過 Tailscale)或 EmulStick BLE 模式(需硬體)
- ❌ iOS RFCOMM 模式:技術上不可行
這也是為什麼原廠 Unified Remote iOS 版僅支援 Wi-Fi 模式的原因:Apple 的平台限制使得藍牙遙控變得極為複雜。
開發心得與反思
成功關鍵
- 完全模仿原廠架構:不要過度最佳化,先求能動
- 詳細日誌記錄:診斷 APP 的詳細日誌是成功關鍵
- 隔離測試:建立獨立的診斷 APP,避免複雜邏輯干擾
避免的陷阱
- 過度設計:兩段式掃描聽起來很聰明,但實際上增加複雜度
- 假設問題所在:一開始以為是 UUID 問題,實際是掃描流程問題
- 忽略細節:
device.namevsscanRecord.deviceName的差異很關鍵
經驗教訓
- ✅ 技術探索需要耐心和細心
- ✅ 原廠程式碼是最好的參考資料
- ✅ 問題解決過程中的思考比結果更重要
- ✅ 完整的文件記錄對後續維護至關重要
專案成果
程式碼規模:
- Kotlin 原始碼:約 50 個檔案
- Compose UI 元件:15+ 個畫面/元件
- 總程式碼行數:約 10,000+ 行
功能完成度:
- ✅ TCP 連線:100%
- ✅ BLE 掃描與連線:100%
- ✅ BLE 滑鼠控制:100%
- ✅ BLE 鍵盤控制:100%
- ✅ BLE IME Direct:100%
- ✅ BLE XInput 模式:100%
- ✅ 虛擬手把 UI:100%
專案連結:
- 📦 GitHub 儲存庫: https://github.com/jason5545/unified-remote-evo
- 完整原始碼、建置說明和使用文件
- 歡迎提交 Issue 和 Pull Request
結語
這個專案從我個人的簡單需求出發,逐步演化為一個功能完整的多模式遙控解決方案。開發過程中遇到的每個技術挑戰,都成為學習和成長的機會。
為停更軟體注入新生命
當 Unified Remote 和 EmulStick 官方停止維護時,許多使用者面臨著「好用的工具逐漸不相容新系統」的困境。這個專案證明了:
- ✅ 開放原始碼精神的價值:即使官方停更,社群仍能延續產品生命
- ✅ 協定研究的可行性:透過技術分析,可以實作完全相容的客戶端
- ✅ 客製化的自由:不受原廠限制,可以針對特殊需求最佳化
- ✅ 現代化改造:使用最新技術棧(Kotlin、Jetpack Compose)重新實作
最重要的是,這個專案實現了它的初衷:為我自己(以及其他有身體限制的使用者)提供一個靈活、高效的 PC 控制方案。
專案特色:
- 🎯 針對身體限制使用者最佳化
- 🔀 靈活的多模式切換
- ⚡ 高效的中文輸入方案
- 🎮 創新的遊戲手把模擬
- 📱 現代化的 Material 3 介面
專案定位:
「為已停止維護的遙控軟體注入新生命:現代化復刻 + 硬體接收器整合」
核心理念:
- 📦 延續產品生命:當官方停止更新,社群接手維護
- 🔓 協定開放:研究並實作相容協定,打破原廠限制
- ♿ 無障礙最佳化:針對身體限制使用者進行深度最佳化
- 🆕 現代化技術:使用最新工具重新打造,確保長期可維護
技術棧:Kotlin、Jetpack Compose、BLE GATT、HID over GATT
最後更新:2025-10-16