手邊有一支 Feiyu(飛宇)的 Vimble 3 手持雲台,平常是用手機上的官方 App「Feiyu ON」透過藍牙控制。這次想試試不經官方 App、自己寫程式直接控制雲台——結果官方 App 在測試機上一開就閃退,連「側錄藍牙封包」這條路都走不了,只好改成直接反編譯 App 把控制協定挖出來。最後在一台樹莓派 4 上用 Python 把雲台轉了起來,而且能靠雲台自己回報的角度確認動作,不用拿相機對著看。這篇記錄整個過程、協定細節,和一路踩到的坑。
起點:官方 App 一開就閃退
原本的計畫很單純:手機開官方 App 連雲台,同時打開 Android 開發者選項的「藍牙 HCI 封包記錄」,動一動雲台,再把側錄到的封包反推出指令。沒想到 App 一打開就退回桌面,點回來只剩白畫面。抓 crash log 一看,原因很明確:
java.lang.Throwable: MobSDK渠道版本配置错误,Google Play渠道未配置GPP版本
at MyApplication.initMobSdk(MyApplication.kt:120)
at MyApplication.initAfterPolicyAgreed(MyApplication.kt:62)
at MyApplication.onCreate(MyApplication.kt:35)
這是這支 Google Play 版的 App 在 Application.onCreate 初始化一個第三方 SDK(MobSDK)時,偵測到「Google Play 通道沒配置 GPP 版本」就直接未捕捉例外閃退——而且它是在「同意隱私政策之後」才初始化,所以政策一旦同意過,之後每次都是畫面都還沒出來就崩掉。這是 App 本身的建置問題,不是我設定能解的。App 跑不起來,就沒有封包可以側錄。
既然側錄走不通,乾脆換另一條路:反編譯。App 的 APK 就在手邊,用 jadx 把它解回 Java 原始碼,直接讀控制邏輯,不需要 App 跑起來、也不需要雲台在旁邊。
反編譯挖出協定
用 jadx 反編譯後,控制核心在 com.feiyutech.lib.gimbal 這個套件。幾個關鍵類別:組封包的 AkCmdWriter、搖桿控制的 JoystickController、協定常數 Protocol/RequestId,以及校驗用的工具函式。把 generateWholeCmd 讀懂後,整個封包格式就清楚了:
重點整理:
- 前導碼:
A5 5A 03固定,第 4 位元組是 target(搖桿即時控制用 0)。 - 指令欄位:高 3 bit 是 cmdType(push=0、set=1、get=2、respSet=3、respGet=4),低 13 bit 是 cmdId,兩者組成一個小端 16-bit。
- 校驗:CRC-16/XMODEM(多項式 0x1021、初始 0x0000、不反射、不 xorout),範圍從第 3 位元組算到資料末端,不含兩個 header 與 CRC 本身。
- 即時搖桿:cmdId = 14,資料 5 bytes =
[enableRoll, LE16(course 平移), LE16(pitch 俯仰)],皆為帶號 int16,0代表停。要持續運鏡就每 20~50ms 重送一次,送0,0停止。
藍牙這端,APK 裡的 gimbal-ble-properties.json 依機型分類了服務與特徵。AK 系列(含 Vimble 3)用的是標準的 Nordic UART Service:
| 用途 | UUID |
|---|---|
| Service | 6e400001-b5a3-f393-e0a9-e50e24dcca9e |
| Write(送指令) | 6e400002-b5a3-f393-e0a9-e50e24dcca9e |
| Notify(收狀態) | 6e400003-b5a3-f393-e0a9-e50e24dcca9e |
把這些湊起來,就能用 Python 重現封包編碼器。CRC 與組框的核心大概長這樣:
def crc16_xmodem(data: bytes) -> int:
crc = 0
for b in data:
for i in range(8):
bit = (b >> (7 - i)) & 1
msb = (crc >> 15) & 1
crc = (crc << 1) & 0xFFFF
if bit ^ msb:
crc ^= 0x1021
return crc & 0xFFFF
def build_frame(target, cmd_type, cmd_id, msg_id, data=b""):
body = bytes([0x03, target & 0xFF]) \
+ ((cmd_type << 13 | cmd_id) & 0xFFFF).to_bytes(2, "little") \
+ bytes([msg_id & 0xFF]) + len(data).to_bytes(2, "little") + data
return bytes([0xA5, 0x5A]) + body + crc16_xmodem(body).to_bytes(2, "little")
def joystick(course, pitch, roll=False):
data = bytes([1 if roll else 0]) \
+ (course & 0xFFFF).to_bytes(2, "little") \
+ (pitch & 0xFFFF).to_bytes(2, "little")
return build_frame(0, 0, 14, 0, data) # target=0, push, cmdId=14
在樹莓派 4 上實測
控制主機要「實體靠近雲台、有藍牙、能跑 Python」,手邊的樹莓派 4 正好符合,用 bleak 這個跨平台 BLE 函式庫。先掃描,果然看到雲台在廣播:
$ python3 send_gimbal.py --scan
EE:xx:xx:xx:xx:xx FY_Vimble3_xx
...
連上去、往 write 特徵送 joystick 封包——雲台真的往右轉了。反編譯還原的協定,第一次就驅動了實體雲台。
踩到的坑
- 一直往同方向送會頂到極限:持續送同向的 joystick,yaw 會一路轉到機械極限頂住,雲台「噹噹」叫警告。正常操作要用短脈衝或回中,不要一直壓著。
- 重連會卡「半連線」:BLE 斷線後雲台會卡在半連線狀態,害下次連線逾時。解法是重連前先讓它斷乾淨、重新廣播,再掃描連線。
- 開 notify 直接被拒:想訂閱 notify 特徵讀回報時,CCCD 寫入回了
UNLIKELY_ERROR並且連線馬上斷。這個錯誤指向一個關鍵——
關鍵:一定要先「配對(bond)」
重開雲台之後,同樣的程式卻怎麼送都不動了,而且雲台一直閃藍燈(藍牙搜尋/等待連線狀態)。反覆試下來,結論是:這支雲台必須先完成 BLE 配對(bond、加密連線),才會進入「受 App 控制」模式,也才允許開 notify。第一次沒配對就能動,只是雲台還殘留著先前被手機 App 連過的受控狀態,是巧合。
用 bluetoothctl 做一次 Just Works 配對(無 PIN)後,一切就通了:
bluetoothctl
power on
agent NoInputNoOutput
default-agent
scan on
pair EE:xx:xx:xx:xx:xx # 出現 Bonded: yes 即成功
配對之後:notify 正常開啟、雲台開始持續回報姿態、joystick 立刻生效。(小提醒:bond 後不要把裝置 remove,會連 bond 一起刪掉;重連只要先 disconnect 讓它重廣播再掃描即可。)
不用相機,靠回報角度自己驗證
配對+開 notify 之後,雲台會持續推送姿態幀(含即時的各軸角度)。這帶來一個很方便的驗證方式:送移動指令前後各讀一次角度,比對差值,就能判斷雲台有沒有照指令轉動,完全不必拿相機對著拍。實際跑一段右轉:
$ python3 send_gimbal.py --addr EE:xx:xx:xx:xx:xx --course 300 --hold 1.5 --verify
已連線 FY_Vimble3_xx
notify 已開(需 bond 才會成功)
[前] 軸1=-101 軸3=-2750
[後] 軸1=-130 軸3=-3383
角度變化:軸1 -101→-130 (Δ-29), 軸3 -2750→-3383 (Δ-633)
→ 有明顯變化=雲台有動
送右轉後,回報的其中一軸角度變化了 633(移動前只在 ±1 抖動),遠超過漂移,確認雲台確實轉動了。有了這個,之後要自動化測試或遠端驗證動作,都不用人在現場盯著。
小結
官方 App 崩潰反而逼出一條更乾淨的路:直接反編譯把協定挖出來,用 Python 重現,在樹莓派上就能控制雲台。過程中最花時間的不是協定本身(格式很規矩),而是 BLE 的狀態細節——尤其「要先配對才受控」這點,沒有回報角度幫忙判斷的話很難定位。整理成幾句給之後的自己:封包是 A5 5A 03 開頭加 CRC16-XMODEM、搖桿是 cmdId 14、BLE 走 Nordic UART、而且務必先 bond。
※ 本文為個人對自有裝置的互通性研究與學習紀錄。Feiyu、Vimble 等為飛宇科技的商標,與原廠無關;相關協定資訊請勿用於侵權或商業用途。



沒有留言:
張貼留言