Add AMI CAN DBC and verified SDC/BMS firmware handoff

This commit is contained in:
shenlefengnai-rgb
2026-10-03 15:57:54 +08:00
parent 39268e07d2
commit f1d1f6da5c
345 changed files with 360479 additions and 18 deletions
+150
View File
@@ -0,0 +1,150 @@
"""Generate the Chinese handoff document directly from AMI.dbc."""
import argparse
from pathlib import Path
import cantools
ROOT = Path(__file__).resolve().parents[1]
OUTPUT = ROOT / 'docs/AMI_CAN_DBC说明.md'
PURPOSE = {
0x300: 'AMI -> VCU:用户确认后的任务选择请求',
0x6A0: 'SDC -> AMI:板标识、版本、序号、上电时间',
0x6A1: 'SDC -> AMI:低压电池电压,精确 DLC=2',
0x6A2: 'SDC -> AMI:PA0 / PA2 / PA6 / PB14 原始频率',
0x6A4: 'SDC -> AMI:DAC 设置应答,识别但不显示',
0x6A6: 'SDC -> AMI:PD0..PD14 原始电平和变化掩码',
0x140: 'BMS -> AMI:高压总电压、带偏置的总电流',
0x156: 'BMS -> AMI:八路实际温度,AMI 派生最高有效温度',
0x186040F3: 'BMS -> AMI:高压总电压、总电流扩展帧别名',
0x186140F3: 'BMS -> AMI:最高单体温度;不使用 BMS 低压字段',
}
def render():
db = cantools.database.load_file(ROOT / 'AMI.dbc', strict=True)
lines = [
'# AMI CAN DBC 说明', '',
f'版本:`{db.version}`。由 [AMI.dbc](../AMI.dbc) 自动生成,10 条报文、33 个信号。', '',
'## 使用范围', '',
'CAN1 / CAN2 功能相同,均为 500 kbit/s、Classic CAN、87.5% 采样点。'
'节点 AMI 为仪表 MCU,VCU 为任务请求的逻辑接收者,SDC_ACQ_DAC 和 BMS 为数据源。', '',
'本 DBC 包含 AMI 唯一发送的 `0x300` 任务请求,以及 AMI 当前识别的 SDC / BMS 输入。'
'SDC `0x6A3` DAC 控制、`0x6A5` ASSI 控制、BMS 其他电芯/故障字段不属于此接口子集。'
'没有定义电机温度、逆变器温度、扭矩、标定后的车速、AMI 周期心跳或 VCU 执行反馈。'
'旧 `0x180 / 0x181 / 0x280` 输入已停用。', '',
'SDC 字段依据 `SDC_ACQ_DAC.dbc` VERSION 1.1,保持其信号名、位布局、缩放和单位;'
'仅将输入接收节点设为 AMI。BMS 字段依据 '
'[BmsMonitor-CANTest](https://git.acetaffy.ltd/acetaffy/BmsMonitor-CANTest) '
'提交 `e65c95203d1689df05343775982d7e85bd782646` 的 `BmsProtocolParser.cs`。', '',
'DBC 为 ASCII 编码,注释和枚举使用英文,便于导入 CAN 工具;本文为 UTF-8 中文。'
'物理值 = 原始无符号值 × Factor + Offset;下表范围为编码范围,不表示车辆正常工作范围。', '',
'## 报文总览', '',
'| CAN ID | 类型 | DLC | 报文名 | 方向和作用 | 周期 / 触发 |',
'| --- | --- | --- | --- | --- | --- |',
]
triggers = {0x300:'确认任务触发',0x6A4:'DAC 设置后应答',0x6A6:'启动 / 电平变化'}
for m in db.messages:
cycle = f'{m.cycle_time} ms' if m.cycle_time else triggers.get(m.frame_id, '源协议未指定')
lines.append(f'| `0x{m.frame_id:X}` | {"扩展" if m.is_extended_frame else "标准"} | '
f'{m.length} | `{m.name}` | {PURPOSE[m.frame_id]} | {cycle} |')
lines += [
'', '扩展帧在 DBC 的 `BO_` 十进制 ID 中设置 bit31:'
'`0x186040F3 -> 2556444915`,`0x186140F3 -> 2556510451`。'
'CAN 工具中使用实际 29 位仲裁 ID 并选择扩展帧;bit31 不是总线上发送的仲裁 ID。', '',
'`GenMsgCycleTime` 仅填写已确认的 SDC 周期 100 / 500 / 20 ms。'
'其余帧未填写周期,默认 0 表示未指定,不据此推断 BMS 周期。', '',
'## 信号布局', '',
'Start 为 DBC 起始位,位号从 0 开始。Intel `@1` 从最低有效位起算;'
'Motorola `@0` 从最高有效位起算。例如 BMS 的 `7|16@0+` 对应字节 0..1 大端无符号数,'
'`23|16@0+` 对应字节 2..3。单字节温度的字节序不改变实际解码。', '',
]
for m in db.messages:
lines += [f'### 0x{m.frame_id:X} - {m.name}', '',
'| 信号 | Start | 位数 | 字节序 | Factor | Offset | 单位 | 编码范围 |',
'| --- | --- | --- | --- | --- | --- | --- | --- |']
for s in m.signals:
lines.append(f'| `{s.name}` | {s.start} | {s.length} | '
f'{"Intel" if s.byte_order=="little_endian" else "Motorola"} | '
f'{s.scale:g} | {s.offset:g} | {s.unit or "-"} | '
f'{s.minimum:g}..{s.maximum:g} |')
choices = [f'`{s.name}`:' + ','.join(f'{value} = {name}' for value,name in s.choices.items())
for s in m.signals if s.choices]
if choices:
lines += ['', ';'.join(choices) + '。']
lines += ['']
lines += [
'## AMI 任务请求语义', '',
'| Task_ID | 任务 |', '| --- | --- |',
'| 1 | 八字绕环 |', '| 2 | 直线加速 |', '| 3 | 高速循迹 |',
'| 4 | 车检测试 |', '| 5 | EBS 测试 |', '| 6 | 手动驾驶 |', '',
'用户确认时,屏幕通过串口发送 `AA 55 01 NN 0D 0A`,MCU 才产生 CAN `0x300` 请求;'
'取消不产生请求。Protocol_Version 和 Request_Source 固定为 1;Request_Sequence '
'从 MCU 启动后的 0 开始,在各目标口完成入队后加一,255 后回到 0。MCU_Uptime '
'为请求入队时的 `HAL_GetTick()` 毫秒值,小端 uint32,自然回绕。', '',
'优先发送到最近 500 ms 内收到过有效协议报文的 CAN 口;两路活跃则发送两路;'
'没有活跃口时尝试两路。若未连接端口自动重发导致 TX FIFO 填满,现有队列可能等待。'
'该策略沿用当前固件。任务表示操作员请求,VCU 负责裁决和安全互锁;'
'未定义应用层执行确认、运行完成、故障回传,也不把任务号换成 ASSI 灯状态。', '',
'## 显示缩放与失效规则', '',
'| 显示项 | 输入和显示规则 | 失效条件 |', '| --- | --- | --- |',
'| 低压电池 | `0x6A1` uint16 mV;`lvbat.val=(mV+5)/10`,显示两位小数 V | 1500 ms 无有效 ADC |',
'| 高压电压 | BMS 大端 uint16 × 0.1 V;现有控件四舍五入到整数 V | 2000 ms 无有效 BMS 状态 |',
'| 高压电流 | BMS 大端 uint16 × 0.1 - 500 A;四舍五入到整数 A | 2000 ms 无有效 BMS 状态 |',
'| 最高单体温度 | `0x156` 有效通道最大值,或扩展帧字节 4 - 40 | 全无效或 2000 ms 无有效温度源 |', '',
'电流字段是带偏置的 uint16,不能按 int16 解码。温度原始 `0xFF` 为无效;'
'DBC 枚举将其标为 Invalid。关闭枚举解码的工具可能显示 215,聚合时必须先排除 '
'原始 255,不能把它当作 215 ℃。有效实际温度范围为 -40..214 ℃。'
'第 9/10 路虚拟温度没有总线字段,最高温度也属于 MCU 派生结果,不新增虚构信号。', '',
'BMS 扩展帧的其他字段没有纳入本 DBC,尤其字节 6..7 的低压电压不覆盖 SDC ADC。'
'两类温度帧共用最高温度显示,后收到的温度帧更新该显示及有效性。'
'SDC GPIO 原始电平尚无具名安全信号映射和正常极性,因此 debug 保持未知;'
'轮频 Hz 尚无周长和每圈脉冲数,不能直接当 km/h。', '',
'有效值显示白色;未知、无效、超时显示深灰色,历史数值保留但不作为有效测量。'
'各源独立超时,其他流量不续期。每 5 秒重新同步界面,刚连接需等待首轮同步。', '',
'## 报文样例', '',
'| ID / DLC | 数据(十六进制) | 解码及仪表显示 |', '| --- | --- | --- |',
'| `0x300 / 8` | `01 02 00 01 E8 03 00 00` | 直线加速请求、序号 0、来源 AMI、上电 1000 ms |',
'| `0x6A1 / 2` | `0C 30` | ADC 12300 mV;低压 12.30 V |',
'| `0x6A2 / 8` | `32 00 64 00 96 00 C8 00` | PA0 / PA2 / PA6 / PB14:50 / 100 / 150 / 200 Hz |',
'| `0x140 / 8` | `0F A0 14 03 00 00 00 05` | 400.0 V / +12.3 A;显示 400 / 12 |',
'| `0x186040F3 / 8` 扩展 | `10 73 10 D1 00 00 00 05` | 421.1 V / -69.5 A;显示 421 / -70 |',
'| `0x156 / 8` | `42 FF 43 47 37 0A 50 41` | 第二路无效,有效最高温度 40 ℃ |',
'| `0x186140F3 / 8` 扩展 | `10 04 0C E4 3F 17 00 7B` | 最高温度 23 ℃;BMS 低压字段不使用 |', '',
'## 验证和复现', '',
'在仓库根目录运行:', '', '```powershell',
'python -m pip install -r scripts/requirements.txt',
'python scripts/verify_dbc.py',
'python scripts/export_dbc_docs.py --check',
'# 若同时检出 SDC 仓库,可额外比较五条输入帧的原始布局:',
'python scripts/verify_dbc.py --sdc-dbc ../SDC_ACQ_DAC/SDC_ACQ_DAC.dbc',
'```', '',
'校验覆盖严格 DBC 解析(长度、信号越界与重叠)、10 个 ID 与固件宏对应、'
'标准/扩展格式、33 个信号、发送/接收节点、SDC 周期、24 个已知报文解码、'
'六个任务号与计数边界编码、大端偏置电流、无效温度枚举、全负温度和截短 DLC。'
'已安装并验证 cantools 44.1.0,接口参考 [cantools 官方文档](https://cantools.readthedocs.io/en/latest/)。', '',
'上述是静态及软件编解码验证。之前 CAN 盒 -> 实物 AMI CAN1 / MCU -> USART2 -> COM4 '
'-> 官方 HMI 调试器的实际更新和六个任务回传见 [联调报告](SDC_BMS联调报告.md)。'
'物理串口屏、CAN2 接线、真实 SDC/BMS 节点和车辆执行动作尚未实测;'
'文档生成和 DBC 解析不替代这些测试。', '',
'修改 AMI.dbc 后运行 `python scripts/export_dbc_docs.py` 更新本文;'
'固件字节布局、DBC 与此文档需同时保持一致。', '',
]
return '\n'.join(lines)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--check', action='store_true')
args = parser.parse_args()
text = render()
if args.check:
assert OUTPUT.read_text(encoding='utf-8') == text, 'DBC document is stale; run export_dbc_docs.py'
print('DBC documentation matches AMI.dbc')
else:
OUTPUT.write_text(text, encoding='utf-8', newline='\n')
print('Generated docs/AMI_CAN_DBC说明.md')
if __name__ == '__main__':
main()
+1
View File
@@ -0,0 +1 @@
cantools==44.1.0
+147
View File
@@ -0,0 +1,147 @@
"""Validate AMI.dbc against source layouts, firmware IDs and known CAN payloads.
Run: python -m pip install -r scripts/requirements.txt
python scripts/verify_dbc.py [--sdc-dbc path/to/SDC_ACQ_DAC.dbc]
"""
import argparse
import json
import math
from pathlib import Path
import re
import cantools
ROOT = Path(__file__).resolve().parents[1]
def near(actual, expected):
assert math.isclose(actual, expected, rel_tol=0, abs_tol=1e-8), (actual, expected)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--sdc-dbc', type=Path)
args = parser.parse_args()
db = cantools.database.load_file(ROOT / 'AMI.dbc', strict=True)
header = (ROOT / 'firmware/Core/Inc/ami_protocol.h').read_text(encoding='utf-8')
identifiers = {int(value, 16) for value in re.findall(
r'^#define\s+AMI_CAN_ID_\w+\s+(0x[\dA-Fa-f]+)', header, re.MULTILINE)}
expected = {0x300:(False,8), 0x6A0:(False,8), 0x6A1:(False,2),
0x6A2:(False,8), 0x6A4:(False,4), 0x6A6:(False,8),
0x140:(False,8), 0x156:(False,8),
0x186040F3:(True,8), 0x186140F3:(True,8)}
actual = {m.frame_id:(m.is_extended_frame,m.length) for m in db.messages}
assert actual == expected, actual
assert set(actual) == identifiers, (actual, identifiers)
assert len(db.messages) == 10
assert sum(len(m.signals) for m in db.messages) == 33
for m in db.messages:
if m.frame_id == 0x300:
assert m.senders == ['AMI'] and m.receivers == {'VCU'}
else:
assert m.receivers == {'AMI'}
assert m.senders == (['SDC_ACQ_DAC'] if 0x6A0 <= m.frame_id <= 0x6A6 else ['BMS'])
assert db.dbc.attributes['Baudrate'].value == 500000
for ident, cycle in [(0x6A0,100), (0x6A1,500), (0x6A2,20)]:
assert db.get_message_by_frame_id(ident).cycle_time == cycle
for ident in [0x300,0x6A4,0x6A6,0x140,0x156,0x186040F3,0x186140F3]:
assert db.get_message_by_frame_id(ident).cycle_time in (None,0)
checks = []
def decode(ident, data):
checks.append({'id':hex(ident),'data':data})
return db.decode_message(ident, bytes.fromhex(data), decode_choices=False)
# Canonical request at 1000 ms, first sequence, task 2; all six task enums.
task = decode(0x300,'01 02 00 01 E8 03 00 00')
assert task == {'Protocol_Version':1,'Task_ID':2,'Request_Sequence':0,
'Request_Source':1,'MCU_Uptime':1000}
request = db.get_message_by_frame_id(0x300)
assert request.encode(task) == bytes.fromhex('01 02 00 01 E8 03 00 00')
for task_id in range(1,7):
for seq in (0,255):
data=dict(task, Task_ID=task_id, Request_Sequence=seq, MCU_Uptime=0xffffffff)
payload=request.encode(data)
assert payload == bytes([1,task_id,seq,1,255,255,255,255])
assert request.decode(payload,decode_choices=False) == data
assert set(request.get_signal_by_name('Task_ID').choices) == set(range(1,7))
for raw, volts in [('08 20',8.2),('0C 30',12.3),('88 31',12.68),('A4 38',14.5),('FF FF',65.535)]:
near(decode(0x6A1,raw)['Input_Voltage'],volts)
assert db.encode_message(0x6A1,{'Input_Voltage':volts}) == bytes.fromhex(raw)
heartbeat=decode(0x6A0,'A1 01 34 12 E8 03 00 00')
assert heartbeat['Board_Identifier']==0xA1 and heartbeat['Protocol_Version']==1
assert heartbeat['Heartbeat_Sequence']==0x1234
near(heartbeat['Uptime'],1.0)
freq=decode(0x6A2,'32 00 64 00 96 00 C8 00')
assert list(freq.values()) == [50,100,150,200]
gpio=decode(0x6A6,'AA 55 FF 7F E8 03 00 00')
assert gpio['SDC_Input_State']==0x55AA and gpio['SDC_Changed_Mask']==0x7fff
near(gpio['Timestamp'],1.0)
ack=decode(0x6A4,'01 01 C4 09')
assert ack['DAC_Set_Result']==1 and ack['DAC_Select']==1
near(ack['DAC_Output_Voltage'],2.5)
for ident in (0x140,0x186040F3):
for raw, voltage, current in [
('0F A0 14 03 00 00 00 05',400,12.3),
('10 73 10 D1 00 00 00 05',421.1,-69.5),
('02 21 13 88 64 48 01 05',54.5,0),
('00 00 00 00 00 00 00 00',0,-500),
('FF FF FF FF 00 00 00 00',6553.5,6053.5)]:
values=decode(ident,raw)
near(values['Pack_Voltage'],voltage)
near(values['Pack_Current'],current)
# Only bytes 0..3 are consumed; remaining bytes encode to zero.
encoded=db.encode_message(ident,values)
assert encoded[:4] == bytes.fromhex(raw)[:4]
assert encoded[4:] == bytes(4)
assert all(not sig.is_signed and sig.byte_order=='big_endian'
for sig in db.get_message_by_frame_id(ident).signals)
payload=bytes.fromhex('42 FF 43 47 37 0A 50 41')
temperatures=decode(0x156,payload.hex())
assert list(temperatures.values()) == [26,215,27,31,15,-30,40,25]
valid=[temperature for byte,temperature in zip(payload,temperatures.values()) if byte != 0xff]
assert max(valid)==40
assert str(db.decode_message(0x156,payload)['Temperature_2']) == 'Invalid'
all_invalid=db.decode_message(0x156,bytes([255])*8)
assert all(str(value)=='Invalid' for value in all_invalid.values())
negative=decode(0x156,'00 01 0A FF FF FF FF FF')
assert max(value for index,value in enumerate(negative.values()) if index < 3) == -30
for payload,maximum in [('10 04 0C E4 3F 17 00 7B',23),
('10 04 0C E4 64 17 00 55',60)]:
assert decode(0x186140F3,payload)['Maximum_Cell_Temperature'] == maximum
assert str(db.decode_message(0x186140F3,bytes.fromhex('00 00 00 00 FF 00 00 00'))[
'Maximum_Cell_Temperature']) == 'Invalid'
assert len(db.get_message_by_frame_id(0x186140F3).signals)==1
# Mandatory signal-overlap and frame-length checks are exercised by strict load.
try:
db.decode_message(0x6A1,bytes([0]))
except cantools.database.DecodeError:
pass
else:
raise AssertionError('Truncated ADC payload unexpectedly decoded')
source_check=False
if args.sdc_dbc:
source=cantools.database.load_file(args.sdc_dbc,strict=True)
for ident in (0x6A0,0x6A1,0x6A2,0x6A4,0x6A6):
original=source.get_message_by_frame_id(ident)
current=db.get_message_by_frame_id(ident)
assert original.length==current.length and original.is_extended_frame==current.is_extended_frame
for a,b in zip(original.signals,current.signals,strict=True):
fields=('name','start','length','byte_order','is_signed','scale','offset','unit','minimum','maximum')
assert all(getattr(a,key)==getattr(b,key) for key in fields),(ident,a,b)
source_check=True
print(json.dumps({'result':'passed','messages':len(db.messages),'signals':33,
'known_payload_checks':len(checks),'all_task_ids_and_counter_edges':True,
'firmware_ids_match':True,'sdc_source_layout_match':source_check,
'cantools_version':cantools.__version__},indent=2))
if __name__=='__main__':
main()