# Разбор CAN-кадров ProtoCAN Boot v1 и SETProtocol v2 Документ описывает wire-форматы двух протоколов обновления прошивки: - **v1** — `templates/c/protocan-boot`, одна команда или 8 байт образа в одном Extended CAN-кадре; - **v2** — `templates/c/set-protocol`, полный кадр SETProtocol разбивается на несколько Extended CAN-кадров. Все многобайтные поля payload передаются **little-endian**. CAN ID — 29-битный. Для рабочего кода нужно использовать канонические реализации из `templates`, а приведённый ниже Python-парсер удобен для анализатора, логов и отладки. ## 1. ProtoCAN Boot v1 ### 1.1. Разметка Extended CAN ID ```text bits size field 28 1 Priority 27 1 Route: 0 = host -> device, 1 = device -> host 26..24 3 Device Type 23..20 4 Device ID 19..16 4 Message Type 15..0 16 Message Body ``` Формула: ```text ID = Priority << 28 | Route << 27 | DeviceType << 24 | DeviceID << 20 | MessageType << 16 | MessageBody ``` Типы загрузочных сообщений: | Message Type | Имя | Message Body | CAN payload | |---:|---|---|---| | `0x9` | `BOOT_CONTROL` | `SessionID << 8 \| Command` | параметры команды | | `0xA` | `BOOT_DATA_A` | индекс блока | 8 байт слота A | | `0xB` | `BOOT_DATA_B` | индекс блока | 8 байт слота B | | `0xC` | `BOOT_STATUS` | `SessionID << 8 \| Command` | статус и прогресс | | `0xD` | `BOOT_DISCOVERY` | подтип | информация об устройстве | Команды `BOOT_CONTROL`: | Код | Команда | Payload | |---:|---|---| | `0x01` | `IDENTIFY` | пустой | | `0x02` | `ENTER_BOOT` | пустой | | `0x03` | `BEGIN_IMAGE` | `image_size u32`, `image_crc32 u32` | | `0x04` | `BEGIN_COMPAT` | `product u16`, `hw_min u8`, `hw_max u8`, `version u32` | | `0x05` | `ERASE` | пустой | | `0x06` | `VERIFY` | пустой | | `0x07` | `COMMIT` | пустой | | `0x08` | `CONFIRM` | пустой | | `0x09` | `REBOOT` | пустой | | `0x0A` | `ABORT` | пустой | | `0x0B` | `QUERY_PROGRESS` | пустой | `BOOT_STATUS` всегда содержит 8 байт: ```text offset size field 0 1 status 1 1 target_slot 2 2 next_block u16 LE 4 4 running_crc32 u32 LE ``` `BOOT_DISCOVERY` с body `1` содержит: ```text offset size field 0 2 product_type u16 LE 2 1 hardware_revision 3 1 protocol_version = 1 4 4 firmware_version u32 LE ``` Пример запроса `IDENTIFY` для `DeviceType=7`, `DeviceID=13`: ```text CAN ID: 17D90001 DLC: 0 ``` ### 1.2. Python-парсер v1 ```python def parse_v1(can_id: int, data: bytes) -> dict: if not 0 <= can_id <= 0x1FFFFFFF: raise ValueError("неверный Extended CAN ID") if len(data) > 8: raise ValueError("DLC больше 8") result = { "version": 1, "priority": (can_id >> 28) & 0x01, "route": (can_id >> 27) & 0x01, "device_type": (can_id >> 24) & 0x07, "device_id": (can_id >> 20) & 0x0F, "message_type": (can_id >> 16) & 0x0F, "message_body": can_id & 0xFFFF, "data": bytes(data), } msg_type = result["message_type"] body = result["message_body"] if msg_type in (0x9, 0xC): result["session_id"] = (body >> 8) & 0xFF result["command"] = body & 0xFF elif msg_type in (0xA, 0xB): result["slot"] = msg_type - 0xA result["block_index"] = body if msg_type == 0xC: if len(data) != 8: raise ValueError("BOOT_STATUS должен содержать 8 байт") result.update({ "status": data[0], "target_slot": data[1], "next_block": int.from_bytes(data[2:4], "little"), "running_crc32": int.from_bytes(data[4:8], "little"), }) elif msg_type == 0xD and body == 1: if len(data) != 8: raise ValueError("BOOT_DISCOVERY должен содержать 8 байт") result.update({ "product_type": int.from_bytes(data[0:2], "little"), "hardware_revision": data[2], "protocol_version": data[3], "firmware_version": int.from_bytes(data[4:8], "little"), }) return result ``` ## 2. SETProtocol v2 поверх classic CAN В v2 CAN-кадр является только транспортным сегментом. Сначала нужно собрать полный SETP-пакет, и только затем разбирать его заголовок, payload и CRC32. ### 2.1. Разметка Extended CAN ID ```text bits size field 28..24 5 Prefix = 0x12 23..16 8 Destination node 15..8 8 Source node 7 1 Priority 6..0 7 Channel ``` Формула: ```text ID = 0x12 << 24 | Destination << 16 | Source << 8 | Priority << 7 | Channel ``` ### 2.2. CAN-сегменты Первый байт CAN payload — PCI: | PCI | Назначение | Формат CAN payload | |---:|---|---| | `0x10` | первый сегмент | `10`, `total_length u16 LE`, первые 5 байт SETP | | `0x20..0x2F` | продолжение | `2N`, следующие 1–7 байт SETP | | `0x30..0x32` | flow control | `3S`, `block_size`, `st_min_ms` | `N` — циклический номер сегмента `1..15,0..`; следующий сегмент обязан иметь ожидаемый номер, тот же CAN ID и прийти до тайм-аута сборки 500 мс. ### 2.3. Внутренний кадр SETProtocol v2 ```text offset size field 0 2 SOF = A5 5A 2 1 version = 02 3 1 flags 4 2 message_type u16 LE 6 2 source u16 LE 8 2 destination u16 LE 10 2 sequence u16 LE 12 2 payload_length u16 LE 14 N payload 14+N 4 CRC32 IEEE u32 LE ``` CRC32 считается по байтам от `version` на offset 2 до конца payload. Поля `source`, `destination` и `priority` внутреннего заголовка должны совпадать с CAN ID. Флаги: | Бит | Значение | |---:|---| | `0x01` | RESPONSE | | `0x02` | EVENT | | `0x04` | ERROR | | `0x08` | ACK_REQUIRED | | `0x10` | MORE | | `0x20` | PRIORITY | Каждый response начинается с `status u16 LE`. Основные firmware message types: `FW_BEGIN=0x0100`, `FW_DATA=0x0101`, `FW_END=0x0102`, `FW_ABORT=0x0103`, `FW_STATUS=0x0104`, `FW_ACTIVATE=0x0105`. Пример `PING` к BALZAM node `13`, source `0`, sequence `1`, priority `1`, channel `1`: ```text Полный SETP: A5 5A 02 28 01 00 00 00 0D 00 01 00 00 00 E7 29 51 40 CAN ID 120D0081, сегменты: 10 12 00 A5 5A 02 28 01 21 00 00 00 0D 00 01 00 22 00 00 E7 29 51 40 ``` ### 2.4. Python-парсер и сборщик v2 ```python import binascii def parse_v2_can_id(can_id: int) -> dict: if not 0 <= can_id <= 0x1FFFFFFF: raise ValueError("неверный Extended CAN ID") if (can_id >> 24) & 0x1F != 0x12: raise ValueError("не SETProtocol v2 CAN ID") return { "destination": (can_id >> 16) & 0xFF, "source": (can_id >> 8) & 0xFF, "priority": (can_id >> 7) & 0x01, "channel": can_id & 0x7F, } def parse_setp(packet: bytes, can_id: int) -> dict: if len(packet) < 18 or packet[:2] != b"\xA5\x5A": raise ValueError("нет полного SETP-кадра") if packet[2] != 2: raise ValueError("неподдерживаемая версия SETP") flags = packet[3] if flags & 0xC0: raise ValueError("установлены зарезервированные флаги") payload_length = int.from_bytes(packet[12:14], "little") if len(packet) != 14 + payload_length + 4: raise ValueError("не совпадает payload_length") expected_crc = int.from_bytes(packet[-4:], "little") actual_crc = binascii.crc32(packet[2:-4]) & 0xFFFFFFFF if actual_crc != expected_crc: raise ValueError("ошибка CRC32 SETP") address = parse_v2_can_id(can_id) source = int.from_bytes(packet[6:8], "little") destination = int.from_bytes(packet[8:10], "little") priority = int(bool(flags & 0x20)) if (source, destination, priority) != ( address["source"], address["destination"], address["priority"] ): raise ValueError("SETP header не совпадает с CAN ID") payload = packet[14:-4] result = { "version": 2, "flags": flags, "message_type": int.from_bytes(packet[4:6], "little"), "source": source, "destination": destination, "sequence": int.from_bytes(packet[10:12], "little"), "payload": payload, "can": address, } if flags & 0x01: if len(payload) < 2: raise ValueError("response не содержит status") result["status"] = int.from_bytes(payload[:2], "little") result["body"] = payload[2:] return result class V2CanReassembler: def __init__(self, timeout_ms: int = 500): self.timeout_ms = timeout_ms self.reset() def reset(self): self.can_id = None self.total = 0 self.data = bytearray() self.next_sequence = 1 self.deadline_ms = 0 def feed(self, can_id: int, data: bytes, now_ms: int): parse_v2_can_id(can_id) if not 1 <= len(data) <= 8: raise ValueError("DLC вне диапазона 1..8") if self.can_id is not None and now_ms >= self.deadline_ms: self.reset() raise ValueError("тайм-аут сборки SETP") pci_type = data[0] & 0xF0 if pci_type == 0x10: if len(data) != 8: raise ValueError("первый сегмент должен иметь DLC 8") total = int.from_bytes(data[1:3], "little") if not 18 <= total <= 530: raise ValueError("неверный размер SETP") self.can_id = can_id self.total = total self.data = bytearray(data[3:]) self.next_sequence = 1 self.deadline_ms = now_ms + self.timeout_ms return None if pci_type == 0x20: sequence = data[0] & 0x0F if ( self.can_id is None or can_id != self.can_id or sequence != self.next_sequence or len(data) < 2 ): self.reset() raise ValueError("ошибка последовательности CAN-сегментов") if len(data) - 1 > self.total - len(self.data): self.reset() raise ValueError("лишние байты CAN-сегмента") self.data.extend(data[1:]) self.next_sequence = (self.next_sequence + 1) & 0x0F self.deadline_ms = now_ms + self.timeout_ms if len(self.data) == self.total: packet = bytes(self.data) packet_can_id = self.can_id self.reset() return parse_setp(packet, packet_can_id) return None if pci_type == 0x30: return {"flow_control": data[0] & 0x0F, "data": data[1:]} raise ValueError("неизвестный PCI") ``` В SETGUI эти операции уже реализованы в `third_party/templates/python/setprotocol/can.py`; собственный parser нужен только внешнему анализатору или диагностическому скрипту. ## 3. Как отличать v1 от v2 Для используемых сейчас адресов достаточно следующих признаков: - v2: верхние пять бит CAN ID равны `0x12`, PCI начинается с `0x10`, `0x2N` или `0x3S`, после reassembly присутствует `A5 5A 02`; - v1: `MessageType` в битах `19..16` равен `0x9..0xD`, каждый кадр разбирается самостоятельно. Однако универсальное автоопределение только по одному CAN ID невозможно: комбинация `Priority/Route/DeviceType` v1 теоретически тоже может дать верхнее поле `0x12`, а первый байт firmware data v1 может случайно совпасть с PCI. Надёжный анализатор должен учитывать настроенный режим узла либо подтвердить v2 только после сборки кадра с корректными `A5 5A 02`, длиной и CRC32. ## 4. Канонические исходники - v1 ID и state machine: `third_party/templates/c/protocan-boot/src/pcan_boot.c`; - v2 CAN transport: `third_party/templates/c/set-protocol/src/set_can.c`; - v2 frame/CRC: `third_party/templates/c/set-protocol/src/set_protocol.c`; - v2 firmware payload: `third_party/templates/c/set-protocol/src/set_firmware.c`; - Python v2 CAN: `third_party/templates/python/setprotocol/can.py`.