2026年11月14日 星期六

Feiyu Gimbal (2): A Web Controller with Limit Protection and Overload Alerts | Feiyu 雲台(二):網頁控制介面、極限保護與「噹噹叫」的過載保護

上一篇把 Feiyu Vimble 3 雲台的藍牙協定從 App 反編譯出來,在樹莓派 4 上用 Python 讓它轉了起來。不過每次都要下指令列,也很容易一路轉到底頂住機構。這篇接著做幾件事:做一個手機就能操作的網頁控制介面、把雲台上手機的鏡頭變成即時影像放進頁面、加上極限保護避免撞到底,以及找出雲台「噹噹叫」到底在警告什麼。最後用手機相機拍照,逐項驗證。

網頁控制介面

伺服器用 Python 的 aiohttp 加 bleak,跑在靠近雲台的樹莓派 4 上,設成 systemd 使用者服務開機自動啟動。它常駐保持藍牙連線(斷線會自動重連),網頁透過 WebSocket 把搖桿的值傳過來,伺服器每 30 ms 重送一次搖桿封包。

安全上最重要的是「沒人操作就一定要停」:放開搖桿、關掉網頁,或伺服器 0.5 秒沒收到任何輸入(例如手機網路斷了),都會立刻送停止(deadman 機制)。另外速度設上限,避免一下子甩太快。

加上即時影像:雲台上的手機當網路攝影機

雲台夾著的就是一支手機,鏡頭跟著雲台轉,正好拿來當影像來源。手機上裝 IP Webcam(Play 商店的免費 App),它會把相機變成網頁伺服器,提供 MJPEG 串流(/video)和單張照片(/shot.jpg)。樹莓派和手機在同一個家用網路,由樹莓派的服務把串流轉送給瀏覽器(/cam/video)。這樣瀏覽器只要開控制頁這一個網址,不必另外連手機。

  • 只要影像,不要聲音:IP Webcam 預設會一起串流聲音,每次按 Start server 都會要麥克風權限。我把設定裡的「Audio mode」改成停用,麥克風權限也不給,這樣不會有任何聲音傳出去。
  • 降低解析度:預設 1920×1080 一張約 100 KB,連續串流太吃頻寬。用它的設定 API(/settings/video_size?set=640x480、/settings/quality?set=50)降到 640×480、畫質 50 後,一張約 21~30 KB,每秒約 28 張、約 600 KB/s。
  • 頁面上可以開關和旋轉:不看的時候關掉影像,節省流量和手機電量;手機在雲台上是橫裝的,畫面方向不對時可以旋轉,網頁會記住設定。
  • 免費版的限制:每次 Start server 之前要先看一段廣告;手機重開機後也要重新啟動。串流很耗電,長時間使用最好插著電。

讀懂雲台回報的姿態

要做極限保護,得先知道雲台現在轉到哪。配對(bond)並開啟 notify 之後,雲台會推送 cmd 0x1E 的姿態幀,但有個前提:要先送一次 GET GIMBAL_STATE(cmdId 48)它才開始送。資料裡是四個小端 int16,實際轉動各軸、比對數值變化後,對應如下:

欄位意義備註
軸 1俯仰單位 0.01°,重力基準的絕對角,上仰為正
軸 2橫滾正常時接近 0;馬達沒力、手機垂下時會跑到 −77° 左右
軸 3平移單位 0.01°,開機時鏡頭方向為 0,超過 ±180° 會繞回另一邊,右轉為負
軸 4 高位元組狀態碼02 馬達正常出力;保護關機後變 05

兩個要注意的地方:姿態推送固定只有約 4 Hz,主動多查幾次也不會變快;平移的 0° 每次開機都不一樣,而且會繞圈,程式裡要自己把繞圈接回來,變成連續的角度。

量測極限,再設計保護

先用慢速往每個方向轉到底,看角度停在哪:俯仰上限約 +32°、下限約 −25°;平移總行程約 280°。雲台協定裡其實有「角度到極限」的通知,但這支韌體轉到底時一筆都沒送,只能靠「數值停止變化」自己判斷。

第一版很單純:到了極限就不再送往那個方向的指令。結果俯仰還是一路衝到 34°。原因有兩個:

  • 雲台會滑行:它有運鏡平滑,送出停止後還會再轉一段。速度 250 時,停止後多轉了 17~22°。
  • 回報太慢:只有 4 Hz,0.25 秒內常常只有一筆資料,用它算速度常得到 0,等於沒有預判。

第二版改成一律提早煞車,結果又太保守,離極限還很遠就擋住了。最後的做法是依剩餘距離限速:速度同時用「回報推算」和「指令值換算(速度 250 約每秒 50°)」估,取比較大的那個;允許的最高速度=剩餘角度 ÷(0.5 秒 × 每單位約 0.2°/秒)。這樣越靠近越慢,貼近時才完全擋住。

俯仰是絕對角度,可以直接寫死軟體極限(+29°/−22°)。平移的零點每次開機都不同,所以改成「邊用邊學」:持續往同方向推,角度卻 0.6 秒幾乎沒動,就把這裡記成極限,之後在前 5° 擋下來。途中踩到一個坑:被擋住或限速時,雲台本來就會停,如果這時也做卡住偵測,會把擋下的位置誤當成極限,極限就越學越往內縮。所以只有在「真的有送動作指令」時才判斷卡住。

「噹噹叫」其實是過載保護

測試時雲台偶爾會「噹噹」叫,接著馬達沒力、手機垂下來。看藍牙紀錄,叫的時候雲台送了一個 cmd 0x62 的推送。一開始我在 RequestId 表裡查不到;後來才發現推送用的是另一張 AkProtocol.CmdId 表,兩張表的編號不一樣:0x62 是 GIMBAL_PROTECTION_SHUTDOWN_PUSH(保護關機)。原因碼的說明在 App 的 gimbal-core-common.json 裡:

原因碼說明
0低電量保護
1溫度過高保護
2手把空轉保護
3過載保護(實測叫聲就是這個)
4震動保護
5~7雲台內部錯誤
8~10俯仰/橫滾/航向未解鎖

過載不一定是頂到極限才發生:有一次只是用速度 200 上仰 0.8 秒、離極限還很遠,就觸發了。之後把測試速度降到 130~150,就沒有再發生過。不過樣本還不多,只能說「速度高比較容易觸發」。保護關機後,狀態碼會從 02 變成 05,要長按電源鍵重開雲台。伺服器現在看到 0x62 或狀態碼不是 02,就會清掉動作指令、暫停學習極限,網頁也會用紅字顯示原因。(另外電量太低時邊充邊用也撐不住,馬達一樣會沒力。)

用手機相機拍照驗證

手機就裝在雲台上,相機跟著轉,所以「拍照前後畫面有沒有移動」就是最直接的證據。Termux 的 termux-camera-photo 在這支手機上一直拍出 0 位元組,原因是 Android 擋了背景使用相機。改成用 adb 開系統相機、截預覽畫面才成功。截圖前還要先點一下螢幕,因為相機閒置一陣子會暫停預覽、變成黑畫面。腳本每段動作前後各截一張,搭配回報的角度,四個方向的極限都測過:

測試結果
右/左/上/下各轉 0.8 秒(速度 130)平移 −24.2°、+25.1°;俯仰 +14.5°、−26.3°,畫面都跟著移動
俯仰一直往上推 5 秒停在 26.6°(軟體極限 +29°)
俯仰一直往下推 5 秒停在 −19.3°(軟體極限 −22°)
平移往右推到底 → 再推 2 秒學到右極限;再推只動 0.1° 就被擋
平移往左推到底 → 再推 2 秒學到左極限;再推被擋
平移總行程約 284°
全程馬達正常,沒有觸發保護關機

測試途中藍牙短暫斷線一次,結果剛學到的右極限也被清掉了。原本的設計是「重連就清除」,因為雲台重開機後平移零點會變;但短暫斷線時零點其實沒變。現在改成:斷線超過 20 秒才視為雲台重開機、清除極限;短暫斷線就保留極限,角度也接續計算。

實際拍攝影片

下面是實際操作的錄影:右手在手機網頁上推搖桿,雲台上的手機鏡頭即時把畫面傳回控制頁。

小結

協定通了之後,難的反而是「安全地控制」:雲台會滑行、回報只有 4 Hz、平移零點每次開機都不同、轉到底不會通知,這些都要靠程式自己補。整理成幾句:姿態要先 GET 才會推送;俯仰用固定極限,平移邊用邊學;依剩餘距離限速,而不是到了才煞;0x62 是保護關機,原因碼 3 是過載;速度別開太高。

※ 本文為個人對自有裝置的互通性研究與學習紀錄。Feiyu、Vimble 等為飛宇科技的商標,與原廠無關。

Reverse-Engineering a Feiyu Gimbal's BLE Protocol and Driving It from a Raspberry Pi | 反編譯 Feiyu 雲台的藍牙協定,用樹莓派自己控制它

手邊有一支 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
Service6e400001-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 等為飛宇科技的商標,與原廠無關;相關協定資訊請勿用於侵權或商業用途。

2026年11月13日 星期五

In-Car ECU Field Guide: Module Types, Vehicle Networks, and Finding ECUs on a Test Bench with PCAN and UDS | 車內 ECU 圖鑑:模組種類、車內網路,以及用 PCAN+UDS 在測試台上找出有哪些 ECU

一台車上有幾十顆電子控制模組(ECU),各管一塊:引擎、煞車、車門、儀表、影音、通訊……它們再用 CAN、LIN、車用乙太網路串在一起。這篇分兩部分:前半是我整理的車內 ECU 圖鑑(38 種模組的位置、功能、別名、維修重點,加上車內網路和故障碼字首);後半是到一座車廠測試台上實作,用 PCAN 和 Python 找出台上實際有哪些 ECU、讀出它們的名稱和版本,最後試著假扮 ESP 讓儀表顯示車速(沒成功,但學到很多)。

說明:測試台是工作上的開發設備,文中不寫車廠、車型、料號與 VIN,也不放實機照片,只寫通用的做法和觀察。

一、車上有哪些 ECU:互動圖鑑

起點是一份介紹 ECU 種類的影片簡報,裡面列了 BCM、PDM、ACM、EVIC、SM、RSM、IC、DDM、HSM、MSM、APM、SKIM、TCM、CAB、PCM 這 15 個縮寫(都是某美系車廠的叫法)。光看縮寫很難記,所以我把它擴充成一個單頁網頁圖鑑:自己畫一台 SUV 線稿,把 38 種模組標在大致的位置上,點編號就在右邊看說明。


顏色依領域分,借用車用線束的顏色慣例當記憶點:安全氣囊的線束和接頭本來就是黃色,電動車高壓線是橘色。上方的按鈕可以只看某一個領域:

領域代表模組一句話
動力ECM/PCM、TCM引擎噴油點火、變速箱換檔
底盤ABS/ESC、EPS、EPB、TPMS、SAS煞車、轉向、手煞車、胎壓、轉向角
車身BCM、IC、HVAC、DDM/PDM、MSM、SKIM、ELV、EVIC、RSM、SM燈光門鎖、儀表、空調、車門、座椅、防盜、轉向柱鎖、頂置控制台、雨量、天窗
被動安全ACM/ACU、OCS氣囊、判斷前乘客座有沒有人
資訊娛樂IVI、AMP、TCU影音主機、擴大機、4G/5G 車載通訊
駕駛輔助前視攝影機、前雷達、盲點雷達、停車輔助、ADAS 域控制器車道維持、主動巡航、AEB
電動高壓VCU、BMS、MCU/逆變器、OBC、DC-DC整車控制、電池管理、馬達、充電、取代發電機
網路CGW 中央閘道、OBD-II 診斷座網段之間轉送、診斷入口

名詞卡片可以搜尋縮寫、英文或中文。同一種模組在不同車廠名字常常不一樣,例如車身控制模組 BCM 在 Benz 叫 SAM、在 BMW 叫 FEM/BDC、在 Volvo 叫 CEM,卡片的「別名」欄都列出來了;另外像 TCU 同時是「變速箱控制」和「車載通訊」的縮寫,也特別註明:

二、模組之間怎麼溝通

模組依領域掛在不同網段上,中央閘道(Gateway)負責在網段之間轉送需要的訊號,也是 OBD-II 診斷的入口。車窗、座椅馬達、雨量感知器這類便宜的小東西,多半掛在 BCM 或車門模組底下的 LIN 子匯流排:

  • CAN(ISO 11898):CAN-H/CAN-L 雙絞線,兩端各一顆 120 Ω 終端電阻;在 OBD 座量 pin 6 和 14 之間約 60 Ω 就表示兩顆都在。動力常見 500 kbit/s,車身 125~500 kbit/s。
  • CAN FD:資料段可加速到 2~5 Mbit/s、每框 64 位元組,底盤和刷寫韌體用得多。
  • LIN:單線 19.2 kbit/s、一主多從,最便宜。
  • FlexRay:10 Mbit/s、時間觸發、雙通道備援,高階車的底盤線控;後半段實作會遇到它。
  • 車用乙太網路(100/1000BASE-T1):一對雙絞線就能跑,攝影機、ADAS、影音主機和新一代骨幹網路用。
  • UDS(ISO 14229):統一診斷服務,修車廠的診斷電腦就是用它跟模組對話;後面掃描用到的是 0x22「讀資料」和 0x19「讀故障碼」。

三、故障碼開頭字母

四、實作:在測試台上找出有哪些 ECU

手邊有一座車廠的測試台:一個鐵架上裝著一部分真車模組(閘道、兩顆車身控制模組、轉向柱鎖、駕駛側車門模組、數位儀表、影音主機、車載通訊盒),背板把每條網段拉成一個 DB9 孔,標著「車身」「儀表」「資訊娛樂」「通訊」「診斷」……還有 FlexRay 和 LIN。電腦端用 PCAN-USB 加 python-can,全部用 SSH 遠端操作。

第 1 步:只聽不送,看匯流排上有誰

PCAN 先插在車身 CAN 的孔,聽 15 秒,統計每個 CAN ID 出現幾次、週期多長:

import can, time, collections
bus = can.Bus(interface="pcan", channel="PCAN_USBBUS1", bitrate=500000)
seen = collections.Counter(); t0 = time.time()
while time.time() - t0 < 15:
    m = bus.recv(0.5)
    if m is not None and not m.is_error_frame:
        seen[(m.arbitration_id, m.is_extended_id)] += 1
bus.shutdown()
for (i, ext), n in sorted(seen.items()):
    print(f"{i:08X}" if ext else f"{i:03X}", n, f"{15000 / n:.0f} ms")

結果是 85 個 ID、每秒約 270 框。光看 ID 不知道誰送的,但裡面有一組很有用的訊框:網路管理(NM)訊框。每顆醒著的模組會定期廣播「我在線上」,這個平台的 NM 訊框 ID 是固定前綴加上一個位元組的節點號,所以把前綴相同的挑出來,就知道這條匯流排上有 4 個節點。後來 UDS 掃描證實:節點號正好等於該模組診斷位址的低位元組。

第一個坑:隔了一陣子再跑,2 秒內一框都沒有,送出去的請求也沒人回 ACK,PCAN 報「錯誤計數達到警告值」。原因是測試台睡了:點火(KL15)關掉、車門也沒動之後,網路管理會讓整條 CAN 進入休眠,完全安靜。按一下啟動鈕就醒了。之後的掃描程式都先聽 2 秒,確認有訊框才開始。

第 2 步:UDS 掃描,問每個位址「你是誰」

每顆模組都有一組診斷用的 CAN ID:一個收請求、一個回答案。掃描就是對 11-bit 的 0x700~0x7EF、以及 29-bit 的一段位址,逐一送 22 F1 97(ReadDataByIdentifier,讀「系統名稱」),有回 62 F1 97 … 的就是存在的模組;再對它們讀料號(F187)、軟體版本(F189)、硬體料號與版本(F191、F1A3)、VIN(F190)。只用讀取服務,不切診斷模式、不寫入、不清碼。

名稱通常超過 7 個位元組,回應會用 ISO-TP 分成多框:收到首框(PCI 0x1_)要立刻回一個 Flow Control(30 00 00),模組才會繼續送連續框(0x2_)。另外模組可能先回 7F 22 78(處理中,請等),這時要把等待時間拉長,不能當成失敗:

def read_did(req, ext, did, timeout=0.2):
    send(req, ext, [0x03, 0x22, did >> 8, did & 0xFF])
    t_end, buf, total, rid = time.time() + timeout, b"", 0, None
    while time.time() < t_end:
        m = bus.recv(0.02)
        if not is_response(m, req, ext):
            continue
        d = bytes(m.data); pci = d[0] >> 4
        if pci == 0:                                  # 單框
            p = d[1:1 + (d[0] & 0x0F)]
            if p[:1] == b"\x7f" and p[2:3] == b"\x78":  # 處理中,多等一下
                t_end = time.time() + 2; continue
            return m.arbitration_id, p
        if pci == 1:                                  # 首框:回 Flow Control
            total = ((d[0] & 0x0F) << 8) | d[1]; buf, rid = d[2:], m.arbitration_id
            send(req, ext, [0x30, 0x00, 0x00]); t_end = time.time() + 1
        elif pci == 2 and m.arbitration_id == rid:    # 連續框
            buf += d[1:]
            if len(buf) >= total:
                return rid, buf[:total]
    return None, None

每個位址等 150~200 ms,全部掃完約 1~2 分鐘。我把掃描程式丟到測試電腦上用排程工作自己跑、結果寫成文字檔再取回,因為那台筆電的 Wi-Fi 很不穩(螢幕一合上,天線收訊就掉到快斷線),SSH 撐不了整段掃描。

第 3 步:接車身 CAN 和接診斷孔,掃到的不一樣

模組接車身 CAN接診斷孔原因
轉向柱鎖、駕駛側車門模組、第二顆車身控制模組有回有回本來就掛在車身 CAN 上,直接問得到
中央閘道不回(只看得到它的 NM 訊框)有回閘道只在診斷那一側回應診斷請求
數位儀表、影音主機、車載通訊盒看不到有回在別的網段,要靠閘道從診斷孔轉送過去
第一顆車身控制模組看不到有回掛在另一條匯流排
方向盤按鍵、頂置控制台、上下觸控螢幕—不回LIN 從屬節點或掛在主機後面,不直接回診斷

所以接車身 CAN 只找到 3 顆,接診斷孔找到 8 顆。結論很實用:要盤點模組、讀版本、讀故障碼,就接診斷孔;要監聽或模擬車身訊號(例如開關車門、點火狀態),就接那條網段本身,因為閘道不會把這類週期訊息從診斷孔轉進去。最後測試台上接了兩支 PCAN,一支固定接診斷孔、一支固定接車身 CAN,用 PCAN_USBBUS1/2 分開,診斷和測試腳本互不干擾。

這個平台的 11-bit 診斷 ID 有固定規律:回應 ID=請求 ID+固定差值,例如請求 0x74A 就從 0x7B4 回來。有趣的是,有兩顆模組在位址 0x700、0x701 也會「順便」回一次,掃描結果要用回應 ID 去重,才不會多算。掃描結果還有一個用途:通訊盒讀到的軟體版本,剛好和刷寫資料夾裡的軟體包版本一致,以後刷寫前後跑一次掃描,就能確認版本有沒有換成功。

第 4 步:驗證 E2E 保護(AUTOSAR CRC8)

和安全相關的 CAN 訊息(點火狀態、車速、煞車……)每一框都帶 E2E 保護:第 0 個位元組是 CRC,第 1 個位元組的低 4 位是 0~15 循環的計數器。接收端只要 CRC 對不上就整框丟掉,所以想自己送這類訊息,一定要先把 CRC 算對。

常見的做法是 AUTOSAR 的 CRC8(多項式 0x2F、初值和最後 XOR 都是 0xFF),對第 1~7 位元組做 CRC,最後再多餵一個「Data ID」位元組,每種訊息的 Data ID 不同。怎麼確定這個平台也是這樣算?拿監聽到的點火狀態訊框來反推:0~255 每個 Data ID 都試一次,看哪一個算出來的 CRC 跟實際的第 0 個位元組一樣:

def crc8_autosar(data):
    crc = 0xFF
    for b in data:
        crc ^= b
        for _ in range(8):
            crc = ((crc << 1) ^ 0x2F) & 0xFF if crc & 0x80 else (crc << 1) & 0xFF
    return crc ^ 0xFF

def e2e_crc(frame, data_id):            # frame[0] 是 CRC,算 frame[1:] 加 Data ID
    return crc8_autosar(bytes(frame[1:]) + bytes([data_id]))

for f in captured_frames:               # 兩框計數器不同的真實訊框
    print(f.hex(), [hex(i) for i in range(256) if e2e_crc(f, i) == f[0]])

兩框(計數器 11 和 13)都只有同一個 Data ID 對得上,而且跟開源社群公開的同平台實作一致,表示演算法確定沒錯。這招很好用:只要有一框真實訊息,就能確認 CRC 算法和那個訊息的 Data ID。

第 5 步:假扮 ESP 讓儀表顯示車速(失敗)

測試台上沒有引擎和 ESP,所以儀表一直顯示 0 km/h,下面亮一排煞車、ABS、胎壓、氣囊的故障燈。我把一支 PCAN 改插「儀表」那條網段,先聽:99 個 ID,裡面沒有任何 10~20 ms 的動力或車速訊息,表示沒有人在送車速,我送的話不會跟人搶。接著用開源資料裡同集團另一個平台的車速訊息格式(ID、位元位置、0.01 km/h 單位)加上算好的 CRC 和計數器,每 20 ms 送一框:

  • 車速 0→120 km/h 來回兩趟:儀表沒動。
  • 車速固定 60 km/h,把「車速有效旗標」從 0 試到 7,每種 5 秒:儀表還是沒動。

每次都要人盯著儀表很沒效率,所以換一個能自動判斷的方法:一邊送車速、一邊用 UDS 0x19 讀儀表自己的故障碼,比對送之前、送的時候、停止後。儀表有 83 筆故障碼,其中 30 筆是作用中的「收不到某某訊號」;如果我送的訊息被收下,對應那筆的「目前失敗」狀態位元應該會在幾秒內清掉,CRC 錯的話反而可能多出一筆「訊號無效」:

base = read_dtcs()                       # {DTC: 狀態位元組}
start_sending_speed()                    # 背景執行緒每 20 ms 送一框
time.sleep(8)
during = read_dtcs()
for k in sorted(set(base) | set(during)):
    if base.get(k) != during.get(k):
        print("變化", k, base.get(k), "→", during.get(k))

結果三次都是 83 筆、作用中 30 筆,一筆都沒變:儀表完全不理這個訊息。推測原因:這個平台的 ESP 和動力系統在實車上走 FlexRay,由閘道整理後再轉成儀表 CAN 上的訊息,轉過來的 ID、格式和 Data ID 都不一定跟別的平台一樣。沒有車廠的通訊矩陣(K-Matrix,DBC/ARXML),只能亂槍打鳥,所以先停在這裡。另外拍照才發現,這台是電動車版儀表,左邊那顆其實是功率表(READY/POWER),本來就沒有轉速可以顯示。

學到的幾件事

  • 先聽再送:NM 訊框就能數出網段上有幾顆模組,節點號還能對到診斷位址。
  • 匯流排完全安靜、送出去沒人 ACK,先懷疑測試台在休眠,不是線接錯。
  • 盤點模組接診斷孔,模擬車身訊號接該網段;兩支 PCAN 分工最省事。
  • E2E CRC 可以用一框真實訊息反推驗證;但不同訊息、不同平台的 Data ID 不能套用。
  • 想讓儀表顯示數值,沒有 K-Matrix 很難;邊送邊比對故障碼,比盯著儀表有效率。

延伸閱讀:用 PCAN 在 ATS 操作台讀 OBD-II 行車電腦模擬器、把車速餵給 Geotab IOX 車機(更早的 PeakCAN 與 ECU Simulator 通訊 系列)(OBD-II 是標準化的診斷,跟這篇的 UDS 掃描是同一套 ISO-TP 底層)、ATS 操作台的 CAN 介面。

Reading an OBD-II ECU Simulator with PCAN in the ATS Console and Feeding Live Vehicle Speed to a Geotab IOX Device | ATS 操作台讀行車電腦模擬器:OBD-II 掃描視窗、PCAN 實測,把即時車速餵給 Geotab IOX 車機

測車機(行車記錄器這類接在車上的裝置)時,常常需要「車子正在跑」的資料:車速、轉速、里程。實車不方便,所以手邊有一台行車電腦模擬器,它是一顆假的引擎 ECU,接上 OBD 診斷就會回車速、轉速,數值用旋鈕轉。這次在自己寫的 ATS 網頁操作台裡加了一個獨立的 OBD-II 掃描視窗,用 PCAN 讀模擬器的參數,再把讀到的車速當成 Geotab GO 的車輛資料,送給走 Geotab IOX 協定的車機。這篇記錄做法、實測數字和踩到的坑。

行車電腦模擬器 IMSB5010

這台是 imsapp 的 OBD2 ECU Simulator(型號 IMSB5010),有 LCD、6 顆旋鈕、MIL 按鈕和 USB 埠。手冊整理成下表:

項目內容
協定ISO15765-4(CAN)11-bit/29-bit × 250K/500K,另有 ISO9141-2、ISO14230 KWP(K-Line),用左下 4 位 DIP 開關切換,LCD 第一行會顯示目前協定
OBD 母座6 CAN-H、14 CAN-L、4/5 地、16 電源(附 13 V 變壓器)
6 顆旋鈕MAF(PID 10)、水溫(05)、轉速(0C)、車速(0D)、進氣溫度(0F)、節氣門(11)
MIL 按鈕按下去 MIL 亮、產生已存/待確認/永久故障碼和凍結資料
USBCP2102,115200 8N1,可以用指令改 PID 值、VIN 等
支援服務Mode 01、02、03、04、06、07、09、0A

PCAN 只能接 CAN 那四種,K-Line 收不到。手冊有一處前後矛盾:第 4 頁說旋鈕 1 是 PID 10(MAF),第 11 頁的 PID 表卻把「Knob 1」標在 PID 54(蒸發系統蒸氣壓)。實機轉旋鈕 1,變的是 MAF,PID 54 不動,所以第 11 頁是錯的。

ATS 的 OBD-II 掃描視窗怎麼做

ATS 的 CAN 視窗工具列多了一顆「OBD」,按下去開獨立視窗。流程是標準的 OBD-II(SAE J1979、ISO 15765-4):

  1. 先問 Mode 01 的 PID 00、20、40…「支援表」,每張表 4 bytes、32 個位元對應 32 個 PID,列出這台 ECU 支援哪些。
  2. 依序問支援的 PID,一問一答:送出請求後等到回應或逾時,才問下一個。
  3. 依 J1979 公式換算,例如轉速=(A×256+B)÷4、水溫=A−40、節氣門=A×100÷255。
  4. 按鈕讀 VIN(Mode 09)、故障碼(Mode 03 已存、07 待確認、0A 永久)、清除故障碼(Mode 04)。VIN 超過一框,要照 ISO-TP 回 Flow Control 才收得到後面幾框。
請求(功能定址 7DF):02 01 0D 55 55 55 55 55   ← 問 PID 0D 車速
回應(引擎 ECU 7E8):03 41 0D 3C ...            ← 0x3C = 60 km/h

實測(msi 筆電、PCAN-USB FD 用 Classic 500k、11-bit 功能定址):模擬器支援 93 個 PID(01~5F 幾乎都有),回應延遲約 31 ms(最大 62 ms),每秒約 20 問,0 逾時。輪詢一秒幾十框,預設不顯示在 CAN 視窗也不記 log,免得洗版;要留紀錄可以勾選打開。

踩到的坑

1. 讀得到值,兩秒後連線就自己斷了

ATS 原本的保護是「錯誤框持續 2 秒就自動中斷連線」,避免沒有 ACK 時控制器無限重送洗版。但 log 一看:這些 PCAN「錯誤框」的 ID 都是 0(沒有錯誤種類),而且接收錯誤計數一路往下掉(90 → 76 → 56)。PCAN 每成功收一框、計數減 1,就報一次狀態,34 次減少剛好對上那 35 個「錯誤框」。也就是說通訊正常、正在恢復,卻被當成錯誤。

修法:錯誤計數比上一次真的下降時,只當狀態顯示(每 5 秒最多一行),不算錯誤。計數上升或卡在同一個值,照舊重開通道、自動中斷。卡在同一個值這點很重要:真的錯誤很多時,計數會飽和在固定值,只用「沒有變大」來判斷會漏掉。我用假的錯誤框重播了四種情況驗證。一開始接收錯誤計數就有 90~127 的原因還沒查清,可能和終端電阻或共地有關。

2. 93 個 PID 輪流問,轉旋鈕要等 4~5 秒才看得到

一秒 20 問、93 個 PID 輪一圈要 4~5 秒,轉旋鈕時儀表要好幾秒才變。改成儀表上的 8 個 PID(6 顆旋鈕加負載、電壓)每一輪都問,其他 PID 每輪只插一個輪流,儀表大約每秒更新 2 次。

3. 轉旋鈕時 CAN 視窗沒有封包

OBD 是一問一答,模擬器不會因為旋鈕轉了就主動送東西,要等 ATS 問到那個 PID 才回。加上輪詢預設不顯示,CAN 視窗看起來就是空的。要看請求和回應一來一回,就勾「輪詢的框也顯示在 CAN 視窗」。

用 USB 指令改數值

旋鈕要用手轉,自動化測試不方便。模擬器的 USB 序列埠可以直接設 PID 值,指令以 | 結尾,成功回 OK>。在 ATS 裡用 UART 連那個 COM 埠,送 $PID0D:60|,OBD 視窗的車速就變成 60 km/h。我也把這些指令做成 ATS 的指令集(IMSB5010),可以點選或寫進腳本。模擬器也會從同一個埠印出它收到和回應的 OBD 封包,方便對照。

$PID0D:60|          車速 60 km/h
$PID0C:2000|        轉速 2000 rpm
$PID31:125|         清碼後行駛距離 125 km
$VINLFV3B28R8A3025310|   改 VIN(17 字)
<R>:<S>:[000007DF] 02 01 0D        ← 模擬器印出:收到請求
<T>:<S>:[000007E8] 03 41 0D 3C     ← 模擬器印出:回 60 km/h

還沒確認的是:用指令設了車速之後,再轉車速旋鈕會不會把它蓋掉。

把車速餵給車機(Geotab IOX)

車機走的是 Geotab 的 IO Expander 協定:GO 裝置是主控,車機(IOX)是從端,29-bit ID、500k。IOX 用 0x25 Type 2 跟 GO 要「GO 資料」,GO 回 0x27 Type 2,52 bytes 裡有時間、經緯度、車速(km/h)、轉速、里程(0.1 km)、狀態旗標(點火、匯流排有活動、車速/里程來自引擎…)、引擎時數等。ATS 本來就能模擬 GO,只是車速、轉速、里程是寫死的固定值。

這次在 IOX 視窗加了「車輛資料」:

  • 來源:可以選固定值,或任一條正在 OBD 掃描的 CAN 連線。也可以選偵測到、還沒連線的第二支 PCAN,ATS 會自動用同樣鮑率連上並開始掃描。
  • 選 OBD 時,車速(0D)、轉速(0C)、里程、引擎時數(1F 運轉秒數換算)都用 5 秒內的即時值,並設「車速/里程來自引擎」旗標。模擬器沒有里程 PID(A6),先用 PID 31(清碼後行駛距離)代替,可以用 $PID31:…| 設。
  • 同一支 PCAN 也行:OBD 用 11-bit、IOX 用 29-bit,模擬器和車機可以並在同一條 500k 匯流排上,ATS 同一條連線兩個功能一起跑。
  • 畫面上顯示車機來要過幾次、上次送了什麼,另外有「主動送一次」和「定時主動送」。

為什麼要「主動送」?翻了前一天的實機 log,車機只在每次 Linux 開機完成、報上 External Device ID 之後,跟 GO 要 1 次 GO 資料,之後就不再要了(一天 7 次,全部都是開機當下)。它每 10 秒送一次 protobuf,但對照 Geotab 官方的 iox_messaging.proto,最外層欄位編號對不上,不是標準的訂閱訊息,所以也不是在訂閱車速。照標準流程,車機只會拿到開機那一刻的車速;要即時車速只能由 GO 主動送,但這不在規格裡,車機收不收要實測。

藍牙篇:用 ELM327 讀 OBD

PCAN 要接線,有時候只想插上去就讀。手邊這顆是常見的 ELM327 藍牙 OBD 轉接頭,直接插在模擬器的 OBD 座上,從 OBD 座的 16 腳拿電。它用 ELM327 的 AT 指令集:電腦送文字 010D,它回一行一行的十六進位,最後以 > 提示字元結束;底層的 CAN、K-Line 協定和多框的 Flow Control 都由它自己處理。

Windows 配對與 COM 埠

  1. ELM327 插上 OBD 座(模擬器要開著)。
  2. Windows 設定 → 藍牙與裝置 → 新增裝置 → 藍牙,選 OBDII,PIN 碼 1234。
  3. 配對後 Windows 會多出兩個「透過藍牙連結的標準序列」COM 埠:一個外送(電腦主動連 ELM327)、一個內送。要用外送的那個:它的裝置執行個體路徑裡有 ELM327 的藍牙 MAC,內送的那個是一串 0。這台 msi 上外送是 COM47、內送是 COM46。這顆是傳統藍牙 SPP(不是 BLE),所以會有 COM 埠。

ATS 怎麼用 ELM327

在 ATS 用 UART 開外送的 COM 埠(藍牙序列埠不看鮑率,填 38400 就好),工具列一樣有「OBD」,開出來是同一個掃描視窗,只是設定區換成 ELM327 的協定選單(自動、CAN 11/29-bit、250k/500k、ISO 9141、KWP)。開始時 ATS 依序送:

ATZ      重置,回版本(例如 ELM327 v1.5)
ATE0     關回顯
ATL0     不要換行,只用 CR
ATS1     hex 之間加空白
ATH1     顯示表頭:每行變成 "7E8 03 41 0D 3C"
ATAT1    自動調整等待時間
ATSP0    協定自動
0100     第一次會 SEARCHING… 找協定,先用長逾時問一次
ATRV     OBD 座電壓
ATDPN    找到的協定(A6=自動找到 CAN 11-bit 500k)

重點是 ATH1:開表頭之後,CAN 回應每一行就是「回應 ID+原始 CAN 資料」,ATS 直接把它當成 CAN 框,餵給 PCAN 那條路已經寫好的解析(支援表、J1979 換算、VIN 多框、故障碼),兩種介面共用同一套程式。差別是 ELM327 一問一答要經過藍牙,比 PCAN 慢;但不用接線,而且 K-Line 的車它也讀得到。

實機結果:這顆回報是 ELM327 v2.1(市面上很多是仿製版),OBD 座電壓 12.3 V,用 CAN 11-bit 500k 讀到 ECU 7E8、93 個 PID,跟 PCAN 讀到的一致。

坑 1:自動找協定會把它弄當

第一次用 ATSP0(自動),ATZ 有回,但送 0100 讓它搜尋協定之後 12 秒都沒回應,接著藍牙連線整個斷掉,連寫入都卡住(Windows 的藍牙 COM 埠斷線時,寫入會一直等)。把連線關掉重開、改成直接指定 ATSP6(CAN 11-bit 500k)就正常了。所以 ATS 的「自動」現在不交給 ELM327 搜尋,而是自己依序試 CAN 11-bit 500k → 250k → 29-bit 500k → 250k,都不通才用 ATSP0;寫入也加了 2 秒逾時,藍牙斷了會提示重開連線,不會整個卡住。

坑 2:拔掉重插,連不回去(錯誤 121)

ELM327 從 OBD 座拔起來再插回去,在 ATS 按「連線」出現 OSError(22, '信號等待逾時。', None, 121)。原因是傳統藍牙 SPP 的 COM 埠不會自己連回去,而是「程式打開 COM 埠的那一刻」Windows 才去連 ELM327;轉接頭剛插上還在開機、或 Windows 還留著上一次的連線狀態時,這一次就會逾時。隔幾秒再開就好(從遠端再試一次,6.9 秒連上,ATI 回 ELM327 v2.1)。

所以 ATS 開 COM 埠時,遇到錯誤 121 就自動重試(最多 4 次、間隔 2 秒),其他錯誤(例如 COM 埠不存在)照舊直接顯示;4 次都不行才提示到 Windows 藍牙設定移除裝置、重新配對。改完實測:重插後直接按「連線」,大約 5 秒連上,接著初始化、找協定、讀到 93 個 PID 都正常。

坑 3:PCAN 和 ELM327 同時讀,兩邊一直逾時

PCAN 和 ELM327 接在同一條匯流排、兩個 OBD 視窗一起開,兩邊都開始出現「逾時」,ELM327 掉到每秒 0.8 問,水溫、MAF、進氣溫度一直讀不到;PCAN 那邊轉速、負載也逾時。

原因在 OBD 本身:ECU 的回應(7E8)沒有寫是回給誰的。兩個診斷工具都用功能定址 7DF 發問,ECU 一律從 7E8 回答,誰都分不出哪一則是自己的:

  • ELM327 這邊:它開了自動調整等待時間(ATAT1),收到第一個 7E8 就結束這次請求。PCAN 剛好問了轉速(010C),ECU 回的 41 0C … 就被正在問水溫(0105)的 ELM327 拿走——PID 對不上,等於沒有回應。
  • PCAN 這邊:兩邊的請求疊在一起時,模擬器(一顆單晶片)來不及處理,有些就漏回了。

這在真車上也一樣:同一條匯流排上同時掛兩個診斷工具會互搶回應。好在這兩個視窗都在同一個 ATS 裡,可以協調:

  1. 共用一把「匯流排鎖」:同一個 ATS 裡所有 OBD 掃描,一次只有一個請求在等回應,回應自然就是問的那一方的。一開始用一般的鎖,ELM327 放開後馬上又自己搶回來,PCAN 一直排不到(模擬測試 85 比 4.6 問/秒),所以改成先到先拿(排號碼牌)。
  2. 處理「晚到的回應」:PCAN 的請求等 150 ms 沒回就算逾時,但 ECU 有時候晚一點還是回了,這則晚到的 7E8 會被下一個發問的 ELM327 撿到。實測就遇到:先開 PCAN、再開 ELM327,ELM327 掃描第一問(支援表 PID 00)就被搶走,整個掃描停掉。所以支援表那一問改成沒回應先重試 3 次;ELM327 收到的回應如果不是它問的 PID,就再問一次。

為了在模擬環境重現,也把模擬的 ECU 改成回應要 5~15 ms、模擬的 ELM327 收到第一個回應就結束(跟真的一樣)。修之前模擬:ELM327 1283 問裡 1136 次逾時;修之後兩邊都 0 逾時。

情況ELM327PCAN
各自單獨跑約 9 問/秒,延遲約 110 ms約 22 問/秒,延遲約 15 ms
同時跑(修之前)0.4~0.8 問/秒,2579 問裡 1169 次逾時22 問/秒,43276 問裡 809 次逾時
同時跑(修之後)7.3 問/秒,469 問 2 次逾時7 問/秒,685 問 1 次逾時

同時跑時兩邊都只剩每秒 7 問左右,是因為要輪流用同一條匯流排;只開一個時速度不受影響。這把鎖只管得到同一個 ATS 裡的掃描,如果是別的程式(例如手機 App 透過同一顆 ELM327 或另一顆轉接頭)一起問,還是會搶。

ELM327 和 PCAN 怎麼選

ELM327 藍牙PCAN
接線插上 OBD 座就好,從 16 腳拿電要接 CAN-H/CAN-L、注意終端電阻
速度約 9 問/秒(藍牙+轉接頭處理)約 22 問/秒
協定CAN 和 K-Line(ISO 9141、KWP)都可以只有 CAN
看得到什麼只看得到回應匯流排上所有框(包括別人的請求)
注意仿製版自動找協定會卡、重插要幾秒才連得上錯誤計數、FD 設定

目前進度與下一步

  • ✔ OBD 視窗實機可用:93 個 PID、即時儀表、VIN、故障碼。
  • ✔ 旋鈕對應確認,手冊第 11 頁勘誤。
  • ✔ USB 指令改車速,OBD 讀得到。
  • ✔ PCAN 錯誤計數誤判斷線已修。
  • ✔ 車速可以當 GO 資料送給車機,同一支或兩支 PCAN 都行(模擬環境測過)。
  • ☐ 車機實機收不收「主動送」的車速、收到後在哪裡看得到。
  • ☐ USB 設的值會不會被旋鈕蓋掉;永久故障碼清不清得掉。
  • ☐ 一開始接收錯誤計數偏高的原因(終端電阻、共地)。
  • ✔ 藍牙 ELM327 實機在 ATS 上讀 OBD:v2.1、93 個 PID(仿製版的自動協定搜尋會卡死,改成 ATS 自己依序試)。
  • ✔ ELM327 重插自動重連(錯誤 121 重試,約 5 秒);PCAN 和 ELM327 同時讀不互搶(共用匯流排鎖,各約 7 問/秒)。

延伸閱讀:車內 ECU 圖鑑:模組種類、車內網路,以及用 PCAN+UDS 在測試台上找出有哪些 ECU(OBD-II 之外,用 UDS 盤點一整座測試台上的模組)。