這個 Mirabox M18 在下已經購買了一段時間,但因為官方不支援 Linux,導致在下過去幾乎無法使用。
直到最近受某個專案啟發,終於找到解決方向,因此決定再次嘗試逆向工程這個裝置。
外觀
Mirabox M18 的正面。
15個圖案按鈕。
3個一般按鈕
Mirabox M18 的背面。
使用 USB Type-C 插座。
背面會有官方網站連結,並提供 Windows版 及 MacOS版 軟件的下載連結,但遺憾地沒有 Linux版。
雖然開源社群開發 OpenDeck 來支援 Linux,但 OpenDeck 目前並未支援 Mirabox M18,
即便使用 sudo 權限執行也無法連線。
既然官方與開源社群都尚未支援,那就只能靠自己。
由於 Mirabox M18 尚未完成配對,因此按下按鈕時系統不會有任何反應,僅會被當作裝置本身的螢幕開關。
訊號逆向
首使用 usbmon 與 Wireshark 等工具,擷取裝置啟動時的封包與訊號。
開始連接
第1筆資料: 0x43, 0x52, 0x54, 0x00, 0x00, 0x44, 0x49, 0x53 ,
後面則全部為 0x00,總長度為 1024 位元組 。
第2筆資料: 0x43, 0x52, 0x54, 0x00, 0x00, 0x4C, 0x49, 0x47, 0x00, 0x00, 0x19 ,
後面則全部為 0x00,總長度為 1024 位元組 。
第3筆資料: 0x43, 0x52, 0x54, 0x00, 0x00, 0x43, 0x4C, 0x45, 0x00, 0x00, 0x00, 0xFF ,
後面則全部為 0x00,總長度為 1024 位元組 。
第4筆資料: 0x43, 0x52, 0x54, 0x00, 0x00, 0x42, 0x41, 0x54, 0x00, 0x00, 0x11, 0x58, 0x0B ,
後面則全部為 0x00,總長度為 1024 位元組 。
第5至第9筆資料: 與前4筆不同,完全沒有出現回覆的 0x43, 0x52, 0x54, 0x00, 0x00 ,
而是看起來毫無規律的數據內容,不過每筆資料的總長度同樣維持在 1024 位元組。
第9筆資料: 後面出現大量的 0x00 填充。
第10筆資料: 0x43, 0x52, 0x54, 0x00, 0x00, 0x53, 0x54, 0x50 ,
後面則全部為 0x00,總長度為 1024 位元組 。
測試資料
檢查第5至第9筆看似雜亂的資料後,在下注意到幾個熟悉的位元組:
第5筆資料 開頭的前2個位元組為 0xFF, 0xD8 ,而 第9筆資料 在出現大量 0x00 填充之前,末端剛好是 0xFF, 0xD9。
這兩組位元組分別是 JPEG 的 起始標記(SOI) 與 結束標記(EOI) ,因此,在下先驗證這個推論是否正確。
首先,在 Wireshark 中使用 Ctrl-M 標記選取的封包,接著點擊選單 File > Export Packet Dissections > As JSON... 。
勾選 Marked packets only 後即可將資料匯出為 JSON 檔案。
(若不想標記,也可以在選取封包後直接選擇 Selected packets only 匯出。)
開啟 JSON 檔後,可以在 _source > layers > usb.capdata 路徑下找到需要的 原始資料 。
import json
with open("data.json", "r") as file:
with open("image.jpg", "wb") as image:
for json_object in json.load(file):
string_data = ''.join(json_object["_source"]["layers"]["usb.capdata"].replace(":", ""))
bytes_data = bytes.fromhex(string_data)
if b"\xFF\xD9" in bytes_data:
bytes_data = bytes_data.rstrip(b"\x00")
image.write(bytes_data)
由於 usb.capdata 欄位格式為 字串(String) 而非 位元組陣列(Byte Array) ,必須先將字串轉換為位元組陣列才能正確輸出檔案。
編寫一段 Python 腳本處理後,將資料還原輸出為 JPEG圖像。
最後再透過 十六進位編輯器(Hex Editor) 驗證,確認檔案結構完全正確。。
保持連接
第1筆資料: 0x43, 0x52, 0x54, 0x00, 0x00, 0x43, 0x4F, 0x4E, 0x4E, 0x45, 0x43, 0x54 ,
後面則全部為 0x00,總長度為 1024 位元組 。
中斷連接
第1筆資料: 0x43, 0x52, 0x54, 0x00, 0x00, 0x43, 0x4C, 0x45, 0x00, 0x00, 0x44, 0x43 ,
後面則全部為 0x00,總長度為 1024 位元組 。
第2筆資料: 0x43, 0x52, 0x54, 0x00, 0x00, 0x48, 0x41, 0x4E ,
後面則全部為 0x00,總長度為 1024 位元組 。
封面結構
第1筆資料: 0x43, 0x52, 0x54, 0x00, 0x00, 0x4C, 0x4F, 0x47, 0x00, 0x00, 0xFC, 0xB7, 0x01 ,
後面則全部為 0x00,總長度為 1024 位元組 。
第2至最後資料,同樣都在開頭發現 0xFF, 0xD8,並在結尾發現 0xFF, 0xD9。
由此推測,這些封包全都包含 JPEG資料,且每筆資料的總長度同樣維持在 1024 位元組。
訊號結構
Mirabox M18 每次傳送的封包長度固定為 1024位元組。
雖然看似資料量很大,但實際上只有前 13位元組 有變化,剩餘部分皆以 0x00 填充。
因此,只需集中分析前 13位元組 ,即可掌握裝置的操作指令與行為。
| 資料 目錄 |
位元組偏移 | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | |
| 1 | 43 | 52 | 54 | 00 | 00 | 44 | 49 | 53 | |||||
| 2 | 43 | 52 | 54 | 00 | 00 | 4C | 49 | 47 | 00 | 00 | 19 | ||
| 3 | 43 | 52 | 54 | 00 | 00 | 43 | 4C | 45 | 00 | 00 | 00 | FF | |
| 4 | 43 | 52 | 54 | 00 | 00 | 42 | 41 | 54 | 00 | 00 | 11 | 58 | 0B |
| 5 | 43 | 52 | 54 | 00 | 00 | 53 | 54 | 50 | |||||
| 6 | 43 | 52 | 54 | 00 | 00 | 43 | 4F | 4E | 4E | 45 | 43 | 54 | |
| 7 | 43 | 52 | 54 | 00 | 00 | 43 | 4C | 45 | 00 | 00 | 44 | 43 | |
| 8 | 43 | 52 | 54 | 00 | 00 | 48 | 41 | 4E | |||||
| 9 | 43 | 52 | 54 | 00 | 00 | 4C | 4F | 47 | 00 | 00 | FC | B7 | 01 |
分析了 9筆封包後,前 13位元組 的結構整理如下:
| 偏移 | 功能 |
|---|---|
| [0:4] | 標頭資料:內容固定為 0x43, 0x52, 0x54, 0x00, 0x00 |
| [5:7] | 指令資料:代表具體執行的操作指令 |
| [8:12] | 參數資料:附帶的相關參數或狀態設定 |
針對標頭資料,由於 0x43, 0x52, 0x54 對應 ASCII 字元中的 "CRT",
在撰寫程式碼時若採用 自描述語法(Self documenting) 的風格,例如編寫為:
// C語法
{'C', 'R', 'T', 0x00, 0x00}
或:
# Python語法 b"CRT\x00\x00"
提升程式碼的可讀性,也更容易直觀理解其作用。
指令與參數
由於 標頭資料 固定,因此無需額外解析,而且 指令資料 皆為固定字串,
故此分析重點便集中在 參數資料 的格式上。
經比對封包後,整理出以下主要的通訊指令與參數:
| 標頭資料 | 指令資料 | 參數資料 | 功能 | ||||
|---|---|---|---|---|---|---|---|
| [0:4] | [5:7] | [8] | [9] | [10] | [11] | [12] | |
| "CRT\x00\x00" | "DIS" | 0x00 | 0x00 | 0x00 | 0x00 | 0x00 | 估計指令 Discover ,搜尋並與裝置建立初始配對。 |
| "CRT\x00\x00" | "LIG" | 0x00 | 0x00 | Level | 0x00 | 0x00 | 估計指令 Light ,調整屏幕背光亮度, Level 範圍為 0 至 255 。 |
| "CRT\x00\x00" | "CLE" | 0x00 | 0x00 | H.Byte | L.Byte | 0x00 | 估計指令 Clear ,清除指定圖示或狀態 H.Byte 與 L.Byte 組成 16元位數值。 0x0001 至 0x000F:清除對應編號按鈕的圖示。 0x00FF:清除所有按鈕圖示。 0x4443:取消裝置配對。 |
| "CRT\x00\x00" | "BAT" | 0x00 | 0x00 | H.Byte | L.Byte | Index | 估計指令 Batch ,設定按鈕圖示, 接受解像度為 64x64 檔案小於 8192位元組 的 JPEG資料。 H.Byte 與 L.Byte 代表 JPEG資料 的體積,Index 為目標按鈕編號。 傳送此指令後,需緊接著送出對應體積的 JPEG資料 。 |
| "CRT\x00\x00" | "STP" | 0x00 | 0x00 | 0x00 | 0x00 | 0x00 | 估計指令 Stop ,完成設定指令發送,結束配對流程。 |
| "CRT\x00\x00" | "HAN" | 0x00 | 0x00 | 0x00 | 0x00 | 0x00 | 估計指令 Hang Up ,主動撤回或終止配對。 |
| "CRT\x00\x00" | "CON" | 'N' | 'E' | 'C' | 'T' | 0x00 | 估計指令 Connect ,維持與裝置的連線狀態, 參數 固定是 "NECT\x00" 。 |
| "CRT\x00\x00" | "LOG" | 0x00 | 0x00 | H.Byte | L.Byte | 0x01 | 估計指令 Logo ,設定裝置啟動時顯示的桌布, 接受解像度為 480x272 檔案小於 65536位元組 的 JPEG資料。 H.Byte 為 圖案體積的 高位元組, L.Byte 為 圖案體積的 低位元組。 傳送此指令後,需緊接著送出對應體積的 JPEG資料 。 |
備註與特別觀察:
測試過程中發現, LIG 與 LOG 無需 配對 即可直接執行。
操作流程
綜合上述封包分析,Mirabox M18 的整體操作流程與生命週期可歸納為以下四個階段:
- 開始連接
- DIS
- LIG
- CLE
- BAT
- STP
- 保持連接
- CON (或 CONNECT )
- 結束連接
- CLE
- HAN
- 設定背景(獨立功能)
- LOG
按鈕訊號逆向
接著測試實體按鈕按下與釋放時,裝置回傳給主機的輸入封包。
按下 第1列第1欄方形按鈕: 0x41, 0x43, 0x4B, 0x00, 0x00, 0x4F, 0x4B, 0x00, 0x00, 0x01, 0x01 ,
後面則全部為 0x00,總長度為 512 位元組 。
釋放 第1列第1欄方形按鈕: 0x41, 0x43, 0x4B, 0x00, 0x00, 0x4F, 0x4B, 0x00, 0x00, 0x01, 0x00 ,
後面則全部為 0x00,總長度為 512 位元組 。
按下 第1列第2欄方形按鈕: 0x41, 0x43, 0x4B, 0x00, 0x00, 0x4F, 0x4B, 0x00, 0x00, 0x02, 0x01 ,
後面則全部為 0x00,總長度為 512 位元組 。
按下 左邊圓形按鈕: 0x41, 0x43, 0x4B, 0x00, 0x00, 0x4F, 0x4B, 0x00, 0x00, 0x25, 0x01 ,
後面則全部為 0x00,總長度為 512 位元組 。
按下 中間圓形按鈕: 0x41, 0x43, 0x4B, 0x00, 0x00, 0x4F, 0x4B, 0x00, 0x00, 0x30, 0x01 ,
後面則全部為 0x00,總長度為 512 位元組 。
按下 右邊圓形按鈕: 0x41, 0x43, 0x4B, 0x00, 0x00, 0x4F, 0x4B, 0x00, 0x00, 0x31, 0x01 ,
後面則全部為 0x00,總長度為 512 位元組 。
訊號結構
| 資料 目錄 |
位元組偏移 | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | |
| 1 | 41 | 43 | 4B | 00 | 00 | 4F | 4B | 00 | 00 | 01 | 01 |
| 2 | 41 | 43 | 4B | 00 | 00 | 4F | 4B | 00 | 00 | 01 | 00 |
| 3 | 41 | 43 | 4B | 00 | 00 | 4F | 4B | 00 | 00 | 02 | 01 |
| 4 | 41 | 43 | 4B | 00 | 00 | 4F | 4B | 00 | 00 | 25 | 01 |
| 5 | 41 | 43 | 4B | 00 | 00 | 4F | 4B | 00 | 00 | 30 | 01 |
| 6 | 41 | 43 | 4B | 00 | 00 | 4F | 4B | 00 | 00 | 31 | 01 |
分析上述 6筆資料後,發現 前9位元組 內容完全相同,推測為固定的 標頭資料;
第9位元組 代表 按鈕編號,第10位元組 代表 按鈕狀態。
| 偏移 | 功能 |
|---|---|
| [0:8] | 標頭資料:固定為 0x41, 0x43, 0x4B, 0x00, 0x00, 0x4F, 0x4B, 0x00, 0x00 |
| [9] | 按鈕編號:對應觸發的實體按鈕編號 |
| [10] | 按鈕狀態: 0x01 表示 按下,0x00 表示 釋放。 |
針對標頭資料,由於包含 ASCII 字元 "ACK" 與 "OK",
在程式實作時同樣可採用 自描述語法(Self documenting) 編寫為:
// C語法
{'A', 'C', 'K', 0x00, 0x00, 'O', 'K', 0x00, 0x00}
或:
# Python語法 b"ACK\x00\x00OK\x00\x00"
按鈕佈局
在撰寫驅動或映射腳本時,請特別注意:
設定按鈕圖案 與 偵測按鈕狀態 所使用的 按鈕編號順序 並不一致。
黃色文字:採用由上至下、從左至右的順序。
綠色文字:採用由下至上、從左至右的順序。
測試裝置
在終端機輸入以下指令列出所有 USB 裝置:
lsusb
在列表中確認 Mirabox M18 的裝置名稱為 HOTSPOTEKUSB 後,
接著透過 dmesg 尋找其對應的 HID裝置路徑:
dmesg | grep "hiddev" | grep "HOTSPOTEKUSB"
順利取得並確認裝置節點路徑後,即可開始嘗試直接向 Mirabox M18 發送控制封包,模擬各項功能操作。
LIG 指令
#!/bin/bash
path="/dev/"`dmesg | grep "hiddev" | grep "HOTSPOTEKUSB" | sed -r 's/.*hiddev[0-9]+,(hidraw[0-9]+):.*/\1/g' | tail -1`
chunk_size="1024"
header="CRT\x00\x00"
expand=$( yes "\x00" | head -$(( $chunk_size - 13 )) | tr -d "\n" )
run_LIG_command() {
command="LIG"
data="${1}"
command_list="${header}${command}\x00\x00${data}\x00\x00${expand}"
echo "${data}" 1>&2
printf "${command_list}" >"${path}"
}
run_LIG_command "\x00"
sleep 1
run_LIG_command "\xFF"
執行此腳本後,觀察到 Mirabox M18 的 熒幕背光 調暗1秒,接著重新亮起。
確認直接向 /dev/hidraw 路徑寫入封包的控制邏輯完全正確。
LOG 指令
#!/bin/bash
path="/dev/"`dmesg | grep "hiddev" | grep "HOTSPOTEKUSB" | sed -r 's/.*hiddev[0-9]+,(hidraw[0-9]+):.*/\1/g' | tail -1`
chunk_size="1024"
header="CRT\x00\x00"
expand=$( yes "\x00" | head -$(( $chunk_size - 13 )) | tr -d "\n" )
run_LOG_command() {
command="LOG"
file="${1}"
size=`stat -c "%s" "${file}"`
count=$(( ($size + $chunk_size - 1) / $chunk_size ))
high_byte=$(( ($size >> 8) & 0xFF ))
low_byte=$(( $size & 0xFF ))
bytes=$( printf '\\x%02X\\x%02X' "${high_byte}" "${low_byte}" )
command_list="${header}${command}\x00\x00${bytes}\x01${expand}"
{
echo "Sending command" 1>&2
printf "${command_list}"
i="0"
while [ "${i}" -lt "${count}" ]; do
sleep 0.001
echo "Sending image byte array: $(( $i + 1 ))/${count}" 1>&2
dd if="${file}" status="none" conv="sync" count="1" skip="${i}" bs="${chunk_size}"
i=$(( $i + 1 ))
done
}>"${path}"
}
run_LOG_command "image-0.jpg"
sleep 3
run_LOG_command "image-1.jpg"
傳送 JPEG檔案 時需要將資料拆解成多個 1024位元組 依序發送,並在標頭資料需要填入 JPEG檔案 的體積。
在迴圈中加入 1毫秒延遲 避免裝置的 緩衝區 資料量暴增導致封包遺失,令裝置完全沒有反應。
連接及中斷
import os, time, hid
class MiraboxM18:
def __init__(self):
self.__CHUNK_SIZE = 1024
self.__DEVICE = hid.device()
def open(self, vid, pid):
self.__DEVICE.open(vid, pid)
def close(self):
self.__DEVICE.close()
def sendCommand(self, command, parameter):
command_bytes = b"CRT\x00\x00" + command + b"\x00\x00" + parameter
command_bytes += b"\x00" * (self.__CHUNK_SIZE - len(command_bytes))
self.__DEVICE.write(command_bytes)
print(command)
def sendICOCommand(self, command, index, image_path):
with open(image_path, "rb") as file:
size = os.path.getsize(image_path)
self.sendCommand(command, size.to_bytes(2, byteorder = "big") + index.to_bytes(1, byteorder = "big"))
while True:
data = file.read(self.__CHUNK_SIZE)
if len(data) == 0:
break
self.__DEVICE.write(data + b"\x00" * (self.__CHUNK_SIZE - len(data)))
def sendLOGCommand(self, image_path):
self.sendICOCommand(b"LOG", 1, image_path)
def sendBATCommand(self, index, image_path):
self.sendICOCommand(b"BAT", index, image_path)
def sendLIGCommand(self, level):
self.sendCommand(b"LIG", level.to_bytes(1, byteorder = "big"))
def sendDISCommand(self):
self.sendCommand(b"DIS", b"")
def sendSTPCommand(self):
self.sendCommand(b"STP", b"")
def sendHANCommand(self):
self.sendCommand(b"HAN", b"")
def sendCLECommand(self, index):
self.sendCommand(b"CLE", index.to_bytes(2, byteorder = "big"))
def sendCONCommand(self):
self.sendCommand(b"CONNECT", b"")
miraboxm18 = MiraboxM18()
miraboxm18.open(0x6603, 0x1009)
miraboxm18.sendDISCommand()
miraboxm18.sendLIGCommand(0xFF)
miraboxm18.sendCLECommand(0x00FF)
for i in range(15):
miraboxm18.sendBATCommand(i + 1, "button-{:02d}.jpg".format(i + 1))
time.sleep(0.1)
miraboxm18.sendSTPCommand()
time.sleep(1)
miraboxm18.sendCONCommand()
miraboxm18.sendCLECommand(0x4443)
miraboxm18.sendHANCommand()
miraboxm18.close()
當測試邏輯逐漸複雜,使用 Bash 會讓程式碼變得難以維護,
因此在下改用 Python 並搭配 hidapi函式庫,將所有 Mirabox M18 的 HID控制封包 封裝成一個完整的操作 類別(Class)。
取得回應資料
import os, time, hid, signal
class MiraboxM18:
def __init__(self):
self.__CHUNK_SIZE = 1024
self.__DEVICE = hid.device()
def open(self, vid, pid):
self.__DEVICE.open(vid, pid)
def close(self):
self.__DEVICE.close()
def sendCommand(self, command, parameter):
command_bytes = b"CRT\x00\x00" + command + b"\x00\x00" + parameter
command_bytes += b"\x00" * (self.__CHUNK_SIZE - len(command_bytes))
self.__DEVICE.write(command_bytes)
print(command)
def sendICOCommand(self, command, index, image_path):
with open(image_path, "rb") as file:
size = os.path.getsize(image_path)
self.sendCommand(command, size.to_bytes(2, byteorder = "big") + index.to_bytes(1, byteorder = "big"))
while True:
data = file.read(self.__CHUNK_SIZE)
if len(data) == 0:
break
self.__DEVICE.write(data + b"\x00" * (self.__CHUNK_SIZE - len(data)))
def sendLOGCommand(self, image_path):
self.sendICOCommand(b"LOG", 1, image_path)
def sendBATCommand(self, index, image_path):
self.sendICOCommand(b"BAT", index, image_path)
def sendLIGCommand(self, level):
self.sendCommand(b"LIG", level.to_bytes(1, byteorder = "big"))
def sendDISCommand(self):
self.sendCommand(b"DIS", b"")
def sendSTPCommand(self):
self.sendCommand(b"STP", b"")
def sendHANCommand(self):
self.sendCommand(b"HAN", b"")
def sendCLECommand(self, index):
self.sendCommand(b"CLE", index.to_bytes(2, byteorder = "big"))
def sendCONCommand(self):
self.sendCommand(b"CONNECT", b"")
def getData(self):
return bytes(self.__DEVICE.read(self.__CHUNK_SIZE, 1))
miraboxm18 = MiraboxM18()
miraboxm18.open(0x6603, 0x1009)
miraboxm18.sendDISCommand()
miraboxm18.sendLIGCommand(0xFF)
miraboxm18.sendCLECommand(0x00FF)
for i in range(15):
miraboxm18.sendBATCommand(i + 1, "button-{:02d}.jpg".format(i + 1))
miraboxm18.sendSTPCommand()
try:
while True:
data = miraboxm18.getData()
if len(data) > 0:
print(data)
except KeyboardInterrupt as exception:
print(exception)
miraboxm18.sendCONCommand()
miraboxm18.sendCLECommand(0x4443)
miraboxm18.sendHANCommand()
miraboxm18.close()
完成按鈕面板圖案的渲染並送出 STP 後,裝置便會切換到 事件回報(Event Report) 模式。
此時,需要使用 hid.device.read() 監聽輸入串流,就能即時擷取按鈕按下、釋放等事件封包。
裝置回報封包長度為 512位元組,
第0至8位元組 為 ACK\x00\x00OK\x00\x00 時,才判斷 第9位元組 及 第10位元組 的操作。
捕獲 KeyboardInterrupt 即是 Ctrl-C ,再依序執行 CON, CLE, HAN ,能確保 Mirabox M18 乾淨地中斷,避免裝置懸掛在異常狀態。
分析回應資料
import os, time, hid, signal
class MiraboxM18:
def __init__(self):
self.__CHUNK_SIZE = 1024
self.__DEVICE = hid.device()
def open(self, vid, pid):
self.__DEVICE.open(vid, pid)
def close(self):
self.__DEVICE.close()
def sendCommand(self, command, parameter):
command_bytes = b"CRT\x00\x00" + command + b"\x00\x00" + parameter
command_bytes += b"\x00" * (self.__CHUNK_SIZE - len(command_bytes))
self.__DEVICE.write(command_bytes)
print(command)
def sendICOCommand(self, command, index, image_path):
with open(image_path, "rb") as file:
size = os.path.getsize(image_path)
self.sendCommand(command, size.to_bytes(2, byteorder = "big") + index.to_bytes(1, byteorder = "big"))
while True:
data = file.read(self.__CHUNK_SIZE)
if len(data) == 0:
break
self.__DEVICE.write(data + b"\x00" * (self.__CHUNK_SIZE - len(data)))
def sendLOGCommand(self, image_path):
self.sendICOCommand(b"LOG", 1, image_path)
def sendBATCommand(self, index, image_path):
self.sendICOCommand(b"BAT", index, image_path)
def sendLIGCommand(self, level):
self.sendCommand(b"LIG", level.to_bytes(1, byteorder = "big"))
def sendDISCommand(self):
self.sendCommand(b"DIS", b"")
def sendSTPCommand(self):
self.sendCommand(b"STP", b"")
def sendHANCommand(self):
self.sendCommand(b"HAN", b"")
def sendCLECommand(self, index):
self.sendCommand(b"CLE", index.to_bytes(2, byteorder = "big"))
def sendCONCommand(self):
self.sendCommand(b"CONNECT", b"")
def getData(self):
return bytes(self.__DEVICE.read(self.__CHUNK_SIZE, 1))
miraboxm18 = MiraboxM18()
miraboxm18.open(0x6603, 0x1009)
miraboxm18.sendDISCommand()
miraboxm18.sendLIGCommand(0xFF)
miraboxm18.sendCLECommand(0x00FF)
for i in range(15):
miraboxm18.sendBATCommand(i + 1, "button-{:02d}.jpg".format(i + 1))
miraboxm18.sendSTPCommand()
try:
while True:
data = miraboxm18.getData()
if data[0:9] == b"ACK\x00\x00OK\x00\x00":
if data[9] == 0x01 and data[10] == 0x01:
print("A1 pressed")
if data[9] == 0x01 and data[10] == 0x00:
print("A1 released")
if data[9] == 0x02 and data[10] == 0x01:
print("B1 pressed")
if data[9] == 0x02 and data[10] == 0x00:
print("B1 released")
if data[9] == 0x03 and data[10] == 0x01:
print("C1 pressed")
if data[9] == 0x03 and data[10] == 0x00:
print("C1 released")
if data[9] == 0x04 and data[10] == 0x01:
print("D1 pressed")
if data[9] == 0x04 and data[10] == 0x00:
print("D1 released")
if data[9] == 0x05 and data[10] == 0x01:
print("E1 pressed")
if data[9] == 0x05 and data[10] == 0x00:
print("E1 released")
if data[9] == 0x06 and data[10] == 0x01:
print("A2 pressed")
if data[9] == 0x06 and data[10] == 0x00:
print("A2 released")
if data[9] == 0x07 and data[10] == 0x01:
print("B2 pressed")
if data[9] == 0x07 and data[10] == 0x00:
print("B2 released")
if data[9] == 0x08 and data[10] == 0x01:
print("C2 pressed")
if data[9] == 0x08 and data[10] == 0x00:
print("C2 released")
if data[9] == 0x09 and data[10] == 0x01:
print("D2 pressed")
if data[9] == 0x09 and data[10] == 0x00:
print("D2 released")
if data[9] == 0x0A and data[10] == 0x01:
print("E2 pressed")
if data[9] == 0x0A and data[10] == 0x00:
print("E2 released")
if data[9] == 0x0B and data[10] == 0x01:
print("A3 pressed")
if data[9] == 0x0B and data[10] == 0x00:
print("A3 released")
if data[9] == 0x0C and data[10] == 0x01:
print("B3 pressed")
if data[9] == 0x0C and data[10] == 0x00:
print("B3 released")
if data[9] == 0x0D and data[10] == 0x01:
print("C3 pressed")
if data[9] == 0x0D and data[10] == 0x00:
print("C3 released")
if data[9] == 0x0E and data[10] == 0x01:
print("D3 pressed")
if data[9] == 0x0E and data[10] == 0x00:
print("D3 released")
if data[9] == 0x0F and data[10] == 0x01:
print("E3 pressed")
if data[9] == 0x0F and data[10] == 0x00:
print("E3 released")
if data[9] == 0x25 and data[10] == 0x01:
print("left pressed")
if data[9] == 0x25 and data[10] == 0x00:
print("left released")
if data[9] == 0x30 and data[10] == 0x01:
print("down pressed")
if data[9] == 0x30 and data[10] == 0x00:
print("down released")
if data[9] == 0x31 and data[10] == 0x01:
print("right pressed")
if data[9] == 0x31 and data[10] == 0x00:
print("right released")
except KeyboardInterrupt as exception:
print(exception)
miraboxm18.sendCONCommand()
miraboxm18.sendCLECommand(0x4443)
miraboxm18.sendHANCommand()
miraboxm18.close()
完成連線與圖案寫入後,針對 Mirabox M18 18個按鈕的事件測錄。
每個按鈕皆包含 按下 與 放開 兩種狀態,總計驗證了 36種獨立事件訊號。
自訂功能
import os, time, hid, signal, pyautogui
class MiraboxM18:
def __init__(self):
self.__CHUNK_SIZE = 1024
self.__DEVICE = hid.device()
def open(self, vid, pid):
self.__DEVICE.open(vid, pid)
def close(self):
self.__DEVICE.close()
def sendCommand(self, command, parameter):
command_bytes = b"CRT\x00\x00" + command + b"\x00\x00" + parameter
command_bytes += b"\x00" * (self.__CHUNK_SIZE - len(command_bytes))
self.__DEVICE.write(command_bytes)
print(command)
def sendICOCommand(self, command, index, image_path):
with open(image_path, "rb") as file:
size = os.path.getsize(image_path)
self.sendCommand(command, size.to_bytes(2, byteorder = "big") + index.to_bytes(1, byteorder = "big"))
while True:
data = file.read(self.__CHUNK_SIZE)
if len(data) == 0:
break
self.__DEVICE.write(data + b"\x00" * (self.__CHUNK_SIZE - len(data)))
def sendLOGCommand(self, image_path):
self.sendICOCommand(b"LOG", 1, image_path)
def sendBATCommand(self, index, image_path):
self.sendICOCommand(b"BAT", index, image_path)
def sendLIGCommand(self, level):
self.sendCommand(b"LIG", level.to_bytes(1, byteorder = "big"))
def sendDISCommand(self):
self.sendCommand(b"DIS", b"")
def sendSTPCommand(self):
self.sendCommand(b"STP", b"")
def sendHANCommand(self):
self.sendCommand(b"HAN", b"")
def sendCLECommand(self, index):
self.sendCommand(b"CLE", index.to_bytes(2, byteorder = "big"))
def sendCONCommand(self):
self.sendCommand(b"CONNECT", b"")
def getData(self):
return bytes(self.__DEVICE.read(self.__CHUNK_SIZE, 1))
miraboxm18 = MiraboxM18()
miraboxm18.open(0x6603, 0x1009)
miraboxm18.sendDISCommand()
miraboxm18.sendLIGCommand(0xFF)
miraboxm18.sendCLECommand(0x00FF)
for i in range(15):
miraboxm18.sendBATCommand(i + 1, "button-{:02d}.jpg".format(i + 1))
miraboxm18.sendSTPCommand()
try:
while True:
data = miraboxm18.getData()
if data[0:9] == b"ACK\x00\x00OK\x00\x00":
if data[9] == 0x01 and data[10] == 0x01:
pyautogui.keyDown("b")
if data[9] == 0x01 and data[10] == 0x00:
pyautogui.keyUp("b")
if data[9] == 0x02 and data[10] == 0x01:
pyautogui.keyDown("c")
if data[9] == 0x02 and data[10] == 0x00:
pyautogui.keyUp("c")
if data[9] == 0x03 and data[10] == 0x01:
pyautogui.keyDown("d")
if data[9] == 0x03 and data[10] == 0x00:
pyautogui.keyUp("d")
if data[9] == 0x04 and data[10] == 0x01:
pyautogui.keyDown("e")
if data[9] == 0x04 and data[10] == 0x00:
pyautogui.keyUp("e")
if data[9] == 0x05 and data[10] == 0x01:
pyautogui.keyDown("f")
if data[9] == 0x05 and data[10] == 0x00:
pyautogui.keyUp("f")
if data[9] == 0x06 and data[10] == 0x01:
pyautogui.keyDown("6")
if data[9] == 0x06 and data[10] == 0x00:
pyautogui.keyUp("6")
if data[9] == 0x07 and data[10] == 0x01:
pyautogui.keyDown("7")
if data[9] == 0x07 and data[10] == 0x00:
pyautogui.keyUp("7")
if data[9] == 0x08 and data[10] == 0x01:
pyautogui.keyDown("8")
if data[9] == 0x08 and data[10] == 0x00:
pyautogui.keyUp("8")
if data[9] == 0x09 and data[10] == 0x01:
pyautogui.keyDown("9")
if data[9] == 0x09 and data[10] == 0x00:
pyautogui.keyUp("9")
if data[9] == 0x0A and data[10] == 0x01:
pyautogui.keyDown("a")
if data[9] == 0x0A and data[10] == 0x00:
pyautogui.keyUp("a")
if data[9] == 0x0B and data[10] == 0x01:
pyautogui.keyDown("1")
if data[9] == 0x0B and data[10] == 0x00:
pyautogui.keyUp("1")
if data[9] == 0x0C and data[10] == 0x01:
pyautogui.keyDown("2")
if data[9] == 0x0C and data[10] == 0x00:
pyautogui.keyUp("2")
if data[9] == 0x0D and data[10] == 0x01:
pyautogui.keyDown("3")
if data[9] == 0x0D and data[10] == 0x00:
pyautogui.keyUp("3")
if data[9] == 0x0E and data[10] == 0x01:
pyautogui.keyDown("4")
if data[9] == 0x0E and data[10] == 0x00:
pyautogui.keyUp("4")
if data[9] == 0x0F and data[10] == 0x01:
pyautogui.keyDown("5")
if data[9] == 0x0F and data[10] == 0x00:
pyautogui.keyUp("5")
if data[9] == 0x25 and data[10] == 0x01:
pyautogui.keyDown("left")
if data[9] == 0x25 and data[10] == 0x00:
pyautogui.keyUp("left")
if data[9] == 0x30 and data[10] == 0x01:
pyautogui.keyDown("down")
if data[9] == 0x30 and data[10] == 0x00:
pyautogui.keyUp("down")
if data[9] == 0x31 and data[10] == 0x01:
pyautogui.keyDown("right")
if data[9] == 0x31 and data[10] == 0x00:
pyautogui.keyUp("right")
except KeyboardInterrupt as exception:
print(exception)
miraboxm18.sendCONCommand()
miraboxm18.sendCLECommand(0x4443)
miraboxm18.sendHANCommand()
miraboxm18.close()
完成按鈕事件測試後,最後就是將事件綁定到系統層級的 HID操作模擬。
可以透過 pyautogui函式庫 來模擬標準鍵盤操作,例如複製、貼上、音量控制、開啟特定軟件,甚至組合操作。
補充資料
顯示動態內容
在完成 逆向工程 與 HID按鈕模擬 後,若能將 Mirabox M18 的 熒幕按鈕 運用為 硬件監視器,便增加其實用價值。
因此需要安裝 psutil 函式庫,在終端機輸入:
sudo apt install python3-psutil
亦可以使用 pip 安裝,輸入:
pip install psutil
import os, time, hid, signal, pyautogui, psutil, io, cairosvg
from PIL import Image
class MiraboxM18:
def __init__(self):
self.__CHUNK_SIZE = 1024
self.__DEVICE = hid.device()
self.__SVG_TEMPLATE = """<svg version="1.1" viewBox="0,0 64,64" xmlns="http://www.w3.org/2000/svg">
<rect width="64" height="64" fill="#000000"/>
<g font-size="24" font-family="Courier New" text-anchor="middle" fill="#00FF00">
<text x="32" y="22">CPU{index}</text>
<text x="32" y="42">{cpu_val}</text>
<text x="32" y="62">%</text>
</g>
</svg>"""
def open(self, vid, pid):
self.__DEVICE.open(vid, pid)
def close(self):
self.__DEVICE.close()
def sendCommand(self, command, parameter):
command_bytes = b"CRT\x00\x00" + command + b"\x00\x00" + parameter
command_bytes += b"\x00" * (self.__CHUNK_SIZE - len(command_bytes))
self.__DEVICE.write(command_bytes)
#print(command)
def sendICOCommand(self, command, index, image_path):
with open(image_path, "rb") as file:
size = os.path.getsize(image_path)
self.sendCommand(command, size.to_bytes(2, byteorder = "big") + index.to_bytes(1, byteorder = "big"))
while True:
data = file.read(self.__CHUNK_SIZE)
if len(data) == 0:
break
self.__DEVICE.write(data + b"\x00" * (self.__CHUNK_SIZE - len(data)))
def sendLOGCommand(self, image_path):
self.sendICOCommand(b"LOG", 1, image_path)
def sendBATCommand(self, index, image_path):
self.sendICOCommand(b"BAT", index, image_path)
def sendLIGCommand(self, level):
self.sendCommand(b"LIG", level.to_bytes(1, byteorder = "big"))
def sendDISCommand(self):
self.sendCommand(b"DIS", b"")
def sendSTPCommand(self):
self.sendCommand(b"STP", b"")
def sendHANCommand(self):
self.sendCommand(b"HAN", b"")
def sendCLECommand(self, index):
self.sendCommand(b"CLE", index.to_bytes(2, byteorder = "big"))
def sendCONCommand(self):
self.sendCommand(b"CONNECT", b"")
def getData(self):
return bytes(self.__DEVICE.read(self.__CHUNK_SIZE, 1))
def __svg_to_jpeg(self, index, cpu_val):
svg_data = self.__SVG_TEMPLATE.format(index = index, cpu_val = cpu_val)
png_bytes = cairosvg.svg2png(bytestring = svg_data.encode("UTF-8"))
img = Image.open(io.BytesIO(png_bytes))
rgb_img = img.convert("RGB")
jpeg_io = io.BytesIO()
rgb_img.save(jpeg_io, format = "JPEG", quality = 100)
return jpeg_io.getvalue()
def monitor(self):
cpu_percents = psutil.cpu_percent(interval = 1, percpu = True)
index = 1
for cpu_percent in cpu_percents:
cpu_percent = int(cpu_percent)
image_bytes = self.__svg_to_jpeg(index, cpu_percent)
size = len(image_bytes)
self.sendCommand(b"BAT", size.to_bytes(2, byteorder = "big") + index.to_bytes(1, byteorder = "big"))
for i in range(0, len(image_bytes), self.__CHUNK_SIZE):
data = image_bytes[i:i+self.__CHUNK_SIZE]
self.__DEVICE.write(data + b"\x00" * (self.__CHUNK_SIZE - len(data)))
index += 1
self.sendSTPCommand()
miraboxm18 = MiraboxM18()
miraboxm18.open(0x6603, 0x1009)
miraboxm18.sendDISCommand()
miraboxm18.sendLIGCommand(0xFF)
miraboxm18.sendCLECommand(0x00FF)
try:
while True:
miraboxm18.monitor()
except KeyboardInterrupt as exception:
print(exception)
miraboxm18.sendCONCommand()
miraboxm18.sendCLECommand(0x4443)
miraboxm18.sendHANCommand()
miraboxm18.close()
傳統使用 Python Pillow (PIL.ImageDraw) 繪製動態資訊時,經常面臨以下挑戰:
- 字型與排版調整繁瑣:計算文字寬高、向量對齊與多行置中需耗費大量程式碼。
- 靜態檔案限制:若將每種資料輸出成 JPEG檔案 再傳送,會造成不必要的快取與磁碟損耗。
在下的解決方案:
採用 SVG 作為 向量繪圖範本,在語法中留下 {variable} 等預留位置,
當接收到動態資料後,透過 Python 字串替換,再於記憶體內直接轉換為區塊資料傳送。
要達到效果,需要使用 cairosvg 負責將 SVG 轉為向量點陣資料,再由 Pillow 轉換成 JPEG資料,
因此需要安裝 cairosvg 及 Pillow 函式庫,在終端機輸入:
sudo apt install python3-cairosvg python3-pil
或 按此安裝 python3-cairosvg 及 按此安裝 python3-pil
亦可以使用 pip 安裝,輸入:
pip install cairosvg Pillow
雖然經過 SVG > PNG > JPEG 2次轉碼,但全部操作都在 RAM (io.BytesIO) 中完成。
對於 64x64 或 480x272 解析度的圖案而言, CPU 耗用極低。
既然能夠顯示硬件資訊,即是可以顯示其他資料,例如:
- 網路頻寬監視器
- 股市走勢
- 氣象通知
設定 udev規則
在 Linux 中,存取 /dev/hidraw 裝置預設都需要 root 權限。
然而,在日常使用時以 sudo 運行背景服務或用戶端程式不僅不方便,也違反了 Linux 的最小權限原則。
為了讓普通使用者也能直接存取 Mirabox M18,在下建立 udev規則 授予存取權限。
在 /etc/udev/rules.d/99-mirabox-m18.rules 目錄下建立自訂規則檔案:
SUBSYSTEM=="hidraw", ATTRS{idVendor}=="6603", ATTRS{idProduct}=="1009", TAG+="uaccess"
SUBSYSTEM=="usb", ATTRS{idVendor}=="6603", ATTRS{idProduct}=="1009", MODE="0666", TAG+="uaccess"
udev 是依照檔名開頭的數字順序來載入規則的,選用 99- 作為前綴可以確保新規則在系統預設規則載入完畢後才執行,
數值越大,優先度越低或越後執行,避免被預設規則覆蓋。
完成後在終端機輸入:
sudo udevadm control --reload-rules sudo udevadm trigger
完成後,便能以一般使用者身分直接執行 Python 驅動腳本與控制介面。
總結
這項逆向工程專案歷經了多次重試、失敗與擱置,終於在 第8次嘗試時成功讓 Mirabox M18 在 Linux 環境下運作。
雖然 Mirabox M18 在外觀與功能上與知名的 Stream Deck 非常相似,但遺憾的是無法直接相容開源社群的 OpenDeck 專案。
不過在購入 Mirabox M18 前,早已預料到過程不會順利,需要自行逆向分析才能在 Linux 上使用也在意料之中。
一般的巨集鍵盤 通常會直接輸出標準的 HID訊號,插上電腦後即可即插即用。
然而,無論是 Stream Deck 還是 Mirabox M18 雖然在系統硬體資訊中均顯示為 HID裝置,
但它們本身並不直接發送標準的 HID掃描碼(Scan Code) ,而是向宿主系統發送封包。
必須在系統環境下運行官方專用軟體,由軟體接收裝置的封包,再根據封包觸發對應的系統操作。
原因是 標準HID裝置只能發送標準掃描碼,無法觸發系統級別的操作,例如開啟軟件(否則系統安全性會崩潰)。
因此,這類設備本質上只是一個 硬件通訊週邊 配合 軟體觸發器 的組合。
在分析通訊封包、研究圖像更新指令時,
預計需要將圖像手動轉換為 LCD螢幕常見的 RGB565 或 RGB888 原始點陣圖格式。
然而,在分析擷取到的資料時發現了關鍵特徵:
- 表頭資料 的 0xFF 0xD8 (JPEG SOI Marker)
- 表尾資料 的 0xFF 0xD9(JPEG EOI Marker)
這個發現證實了 Mirabox M18 韌體內部整合了 JPEG解碼器,裝置能直輸傳入的 JPEG資料並渲染至 LCD螢幕上。
這對開發者來說極為貼心,不需要在軟件端編寫繁瑣的 Raw RGB 轉換演算法,
也不需要先用影像處理軟體轉檔,直接將標準 JPEG資料 發送給 /dev/hidraw 即可完成圖像更新。
有趣的是,官方軟件提供了天氣預報功能,令在下想到可以增加硬件監控的功能。
而在下完成逆向工程後,透過 psutil, cairosvg, Pillow 等工具,實現了官方軟件沒有提供的功能。
唯有當工具的操控權完全在自己手中時,才能真正發揮工具完整功能。
由於 Mirabox M18 的外殼採用無螺絲設計,如果強行拆解,極有可能對外殼產生永久性損壞,因此在下沒有拆解進行。
在下在到來自 Adafruit 拆解 Stream Deck 的影片 ,讓在下能夠在不破壞 Mirabox M18 外殼的前提下,
參照同類型產品來深入剖析這類 LCD巨集鍵盤 的內部結構與設計細節。
在 影片10分43秒位置 ,顯示:Stream Deck 採用了 Atmel AT91SAM9G45 晶片。
這是一款運行時脈達 400MHz 的多功能整合型 微處理器(Microprocessor),內部整合了 LCD控制器、電阻式觸控螢幕控制器、USB控制器 及 多種周邊控制器。
這完美解釋了為何這類裝置能流暢處理 JPEG資料 , 因為它本質上就是一部小型嵌入式單板電腦。
也讓在下能推論 Mirabox M18 極有可能採用了同等級或相容的 ARM MPU 方案,例如 Nuvoton 或 Allwinner 同級晶片。
在 影片14分19秒位置 ,揭示裝置內部實際上僅採用了一塊 480x272 解析度的彩色 TFT LCD熒幕板。
在 影片16分25秒位置 ,顥示按鈕以搭配透明切面按鈕、矽膠導電墊、電阻式觸控板,來模擬獨立LCD按鈕的效果。
在下查證過,如果採用真正的 熒幕按鈕 的成本:
- 36x24 解析度的 LCD按鈕 平均單價 25美元
- 64x48 解析度的 LCD按鈕 平均單價 35美元
- 64x48 解析度的 OLED按鈕 平均單價 100美元
最初在下也質疑為何這類裝置能以低廉價格售出,了解後才發現是以觸控螢幕替代設計,將成本大幅降低。
由於確認了裝置是採用 電阻式觸控,硬體上不支援多點觸控,因此在撰寫編程時,完全不需要考慮組合鍵的操作邏輯。
程式可以簡化為單一按鈕的按下與釋放事件處理,降低了驅動開發與狀態機管理的難度。
參考資料
文章經 AI 整理及潤飾。



































沒有留言 :
張貼留言