ATS 網頁操作台除了 UART、GPIB、Ethernet,還有一個 CAN 分頁:插 PCAN(PEAK)、Kvaser 或 CANable 都能用,支援 CAN FD,可以手動送框、依 ID 彙整、寫腳本。這篇整理 CAN 介面的用法和實機踩到的坑,後半段是這次的重點:把 NXP NCJ29D6 UWB 模組的測距(ranging) 整合進 ATS。原廠的測試工具是加密的 exe,這次照它的 CAN 收發方式,在 ATS 裡用腳本就能讓兩片模組開始測距,即時看到距離、AoA,還有一個相對位置圖。
說明:本文截圖是在開發機上用模擬模式拍的(virtual CAN 上有一顆假的 ECU 和一片假的 NCJ29D6,回應格式照原廠 log),數值只是示意;實機結果是 11/06 在筆電上用兩支 PCAN-USB FD 對測的紀錄。
一、連線:掃描轉接器、鮑率、CAN FD
切到 CAN 分頁會自動掃描插著的轉接器(python-can 的 detect),下拉選單列出全部,正在使用的會反灰。選鮑率,要 CAN FD 就再選資料段速率(1M/2M/5M),別名可以取「UWB 模組」「車身 CAN」方便分辨。開了 FD 的連線名稱會帶「500k/2M FD」,跟不開 FD 視為不同裝置,log 檔也分開。
第一個坑在 PCAN 的 FD 模式:python-can 的 pcan 介面開 FD 時不吃 bitrate/data_bitrate,直接傳會得到「A parameter contains an invalid value」。要給完整的位元時序,ATS 用 80 MHz 時脈、取樣點 80% 算出來,跟 PCAN-View 的 SAE J2284-4 預設(500k/2M)一致:
# ats/ifaces.py CanBus
if c["interface"] == "pcan":
# PCAN 的 FD 模式不吃 bitrate/data_bitrate,要完整位元時序
kw = {"interface": "pcan", "channel": c["channel"], "timing": can.BitTimingFd.from_sample_point(
f_clock=80_000_000, nom_bitrate=kw["bitrate"], nom_sample_point=80.0,
data_bitrate=kw["data_bitrate"], data_sample_point=80.0)}
二、收發:手動送框、快捷、依 ID 彙整
下方輸入 ID(hex)和資料,勾 EXT(29 bit)或 FD 就能送;可以每 N ms 重複送,常用的框存成快捷(「ID#資料」格式),點一下就送。收發紀錄 → 是送出、← 是收到,FD 框會標 FD 和長度。下圖是模擬 ECU:0x100 每秒心跳,送 OBD 請求 7E0 會從 7E8 回應。
匯流排上框很多的時候,勾「依 ID 彙整」改成每個 ID 一行,顯示方向、DLC、最新資料、次數和實際週期,一眼看出哪個 ID 有沒有在送、週期對不對:
所有收發都記到 CSV(utf-8 BOM,Excel 直接開):一份總表,另外每個連線一份 logs/can/<別名+名稱>_<時間>.csv。
三、錯誤框:只顯示一次、看懂原因、2 秒自動中斷
實機遇到的狀況:接上 DUT 還沒送任何框,畫面就被錯誤框洗版,每秒約 7700 個。一開始以為是沒有 ACK,後來把 PCAN 的錯誤框解開才知道是「接收時填充錯誤、REC 127」:對方送的是 CAN FD,我們這邊沒開 FD。PCAN 錯誤框的格式是 ID=錯誤種類(1 位元/2 格式/4 填充/8 其他),data[0] 0=送出時、1=接收時,data[2]/[3] 是接收/送出錯誤計數:
ERR_KIND = {1: "位元錯誤", 2: "格式錯誤", 4: "填充錯誤", 8: "其他錯誤"}
# 例:ID 4 [4] 01 00 7f 00 → 接收時填充錯誤、REC 127
# → 提示「鮑率不同或對方送 CAN FD(沒開 FD 時建議 500k/2M)」
# 送出端的錯誤才提示 ACK(沒接東西、只有自己一個節點)
現在的處理方式:同一波錯誤框只顯示第一個,附一行中文原因和建議;之後每秒(10 秒後每 10 秒)一行「已 N 個」。錯誤持續超過 2 秒就自動中斷這個連線,視窗留著標「已中斷」看原因,改好設定再連。另外,沒接東西時送框會因為沒有 ACK 讓控制器無限重送,PCAN 實機證實 bus.reset() 停不了,所以「剛送過框又一直錯」時會把通道關掉重開。
四、CAN 腳本
腳本一行一個框,格式跟快捷一樣是 ID#資料,前面可以加 FD、EXT;@wait 2 等 2 秒,# 開頭 是註解。CAN 沒有「查詢」,所以另外加了等回應的語法:
| 語法 | 意思 |
|---|---|
7E0#02 01 0D 00 00 00 00 00 | 送一個框 |
@expect 011#41 03 XX XX 00 3s | 等收到開頭符合的框(ID 寫 * 不限、XX 不比對),沒等到腳本就停 |
UCI 012#20 02 00 00 | 送一則 UCI 訊息,一律 FD,超過 64 bytes 自動分段(下面說明) |
@uci */@uci off | 把收到的框當 UCI 解讀(每則翻成一行中文);off 關掉並印統計 |
@to COM11 dio17_on => ok> 3s | 送到另一個開著的連線(例如 UART 控制板讓 relay 上電),可等回應 |
@expect 從「上一個送出的框之後」收到的開始找,而且依序消耗:同一個回應不會被兩行 @expect 用到,所以「先等 RSP、再等 NTF」這種順序可以照寫。每個 CAN 裝置各自存一份腳本(macros\can\<介面>_<通道>.yaml)。
五、UWB 測距:NXP NCJ29D6 的 UCI over CAN
NCJ29D6 是 NXP 的車用 UWB 晶片,公司的模組透過 CAN FD 跟 PC 溝通,原廠有一支 Ranging Test Tool(Python 包成 exe、Pyarmor 加密)。從它的 log、PCAN-View 的紀錄,以及另一版沒加密的原始碼,把收發方式整理出來:
| 項目 | 內容 |
|---|---|
| 匯流排 | CAN FD+BRS,500k/2M(80 MHz,取樣點約 80%) |
| PC → 模組 | ID 依板子不同(原廠 Setting.ini:0x12/0x10/0x06),ATS 預設 012 |
| 模組 → PC | 實機是 0x011(原廠收的時候不分 ID,所以腳本的 @expect 用 *) |
| 資料 | 就是原始 UCI 訊息:標頭 4 bytes [MT|PBF|GID][OID][長度高][長度低];MT 1=指令 0x2X、2=回應 0x4X、3=通知 0x6X;回應後面補 00 到 FD 長度 |
| 長訊息 | 超過 64 bytes 用 UCI 的 PBF 分段:每段 payload 最多 60、各自帶標頭,中間段 byte0 加 0x10,送的時候段間隔 20 ms |
腳本的 UCI ID#訊息 會自動切段;收的方向開 @uci 時自動接回完整訊息再解讀。切段的程式照原廠 transmit_uci_message 的做法:
# ats/uci.py
def fragments(msg, size=64):
"""完整 UCI 訊息 → 一個個 CAN FD 框的資料(每段 payload 最多 60,標頭 byte2/3 是這段長度)"""
msg = bytes(msg)
if len(msg) <= size:
return [msg]
body, room, out = msg[4:], size - 4, []
for k in range(0, len(body), room):
seg = body[k:k + room]
last = k + room >= len(body)
out.append(bytes([msg[0] & 0xEF if last else msg[0] | 0x10, msg[1], len(seg) >> 8, len(seg) & 0xFF]) + seg)
return out
內建四個腳本
這些腳本寫在 instruments/NCJ29D6/commands.yaml,標了 iface: can,所以會成為所有 CAN 連線的預設腳本:
- 讀版本:CORE_GET_DEVICE_INFO(回應 200 多 bytes、分 4 段,接回來後顯示韌體/MAC 版本)+ DEVICE_GET_REBOOT_REASON。
- FiRa Responder 開始測距:重設 → 低功耗關 → 天線延遲(0x2A)/天線間距 17 mm → SESSION_INIT(session ID 0xCEFAFECA)→ 共同參數(DS-TWR、channel 9、間隔 1000 ms)→ responder 參數(自己 MAC DDCC、對方 BBAA)→ START。
- FiRa Initiator 開始測距:一樣的流程,改成 controller/initiator、自己 BBAA、對方 DDCC。
- 停止測距:原廠結束時只送 CORE_DEVICE_RESET,沒有 SESSION_STOP;ATS 照做,並關掉 UCI 解讀、印出統計。
每一步都用 @expect 確認回應狀態 00(OK),SESSION_INIT 還會確認 handle 是 00 00 01 00(原廠後面的指令是固定照這個值寫的)。腳本開頭先 reset 是原廠沒有的,免得上一次的 session 還在,SESSION_INIT 回 SESSION_DUPLICATE。
解讀 RANGE_DATA_NTF:距離與兩個 AoA
開始測距後,模組每秒送一筆 RANGE_DATA_NTF(68 bytes,分兩個 FD 框)。開了 @uci,ATS 會把它組回來翻成一行:距離(cm)、自己量的 AoA,以及對方量的 AoA:
# ats/uci.py Decoder.range_data():每筆 TWR 量測 31 bytes(短 MAC)
mac, st, d = m[1::-1].hex().upper(), m[2], int.from_bytes(m[4:6], "little") # MAC、狀態、距離 cm
if st == 0 and d != 0xFFFF:
a = int.from_bytes(m[9:11], "little", signed=True) / 128 # 自己量的 AoA(度)
if len(m) >= 18 and m[17]: # FOM 不是 0 才有
b = int.from_bytes(m[15:17], "little", signed=True) / 128 # 對方量的 AoA
這個「對方量的 AoA」是實機對測才發現的:Initiator 收到的 RANGE_DATA 裡,每筆第 15~16 byte 還帶著 Responder 量到的角度,Responder 那邊這個欄位則全是 0。所以 ATS 對 Initiator 顯示「自己量/對方量」兩個 AoA,統計也分開算。
實機對測結果
11/06 在筆電上插兩支 PCAN-USB FD,各接一片模組,一邊跑 Responder、一邊跑 Initiator:CAN ID 用預設 012 就通,模組從 0x011 回。
| 項目 | 結果 |
|---|---|
| 距離 | 約 106 cm |
| Responder 自己量的 AoA | -1.33° |
| Initiator 收到的「Responder 量的 AoA」 | -0.88°(跟上一列是同一個角度,兩邊差 0.45°) |
| Initiator 自己量的 AoA | -17.73°(擺放角度造成,確認過不用處理) |
停止腳本送 CORE_DEVICE_RESET 後 @uci off,會印出這次的統計:筆數、距離平均/最小/最大/差,兩個 AoA 各自的平均和範圍:
UWB 相對位置圖
只看數字不直觀,所以 CAN 視窗工具列多了「UWB 圖」,開一個彈出視窗:Responder 固定在下方朝上,Initiator 依「距離+Responder 量的 AoA」定位,箭頭是 Initiator 的正前方(由 Initiator 自己量的 AoA 推回來),還有軌跡、平滑(1/3/5/10 筆平均)、右側的距離與兩個 AoA 統計,下方是 2 分鐘曲線,滑鼠移上去看每一點的數值。伺服器解到 RANGE_DATA_NTF 時另外廣播一個 uwb 事件給這個頁面,不進收發紀錄。
還沒確認的
- AoA 的正負方向(左正還是右正)實機還沒確認,UWB 圖先留了「左右反轉」開關。
- PCAN-View 有設 SSP Offset 32,python-can 沒有這個參數;如果資料段不穩,先查這裡。
- 每片板子 PC → 模組的 CAN ID 可能不同,不是 012 時要把腳本裡的
UCI 012#改掉。






















