diff --git a/doc/CAN_FRAME_PARSE_V1_V2.md b/doc/CAN_FRAME_PARSE_V1_V2.md new file mode 100644 index 0000000..d312288 --- /dev/null +++ b/doc/CAN_FRAME_PARSE_V1_V2.md @@ -0,0 +1,379 @@ +# Разбор 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`. diff --git a/doc/build-setprotocol-html.ps1 b/doc/build-setprotocol-html.ps1 index 6183b25..e783583 100644 --- a/doc/build-setprotocol-html.ps1 +++ b/doc/build-setprotocol-html.ps1 @@ -4,9 +4,22 @@ param() $ErrorActionPreference = 'Stop' $outputPath = Join-Path $PSScriptRoot 'setprotocol.html' $sourcePath = Join-Path $PSScriptRoot '..\c\set-protocol\docs\SETPROTOCOL.md' +$canFrameSourcePath = Join-Path $PSScriptRoot 'CAN_FRAME_PARSE_V1_V2.md' -$markdown = Get-Content -Raw -LiteralPath $sourcePath -Encoding UTF8 -$html = (ConvertFrom-Markdown -InputObject $markdown).Html +function Convert-DocumentationMarkdown { + param([string]$Path) + + $markdown = Get-Content -Raw -LiteralPath $Path -Encoding UTF8 + $html = (ConvertFrom-Markdown -InputObject $markdown).Html + + # Wide protocol tables must scroll horizontally instead of squeezing their + # contents into unreadable one-character columns on a narrow viewport. + $html = $html -replace '', '
' + $html = $html -replace '
', '' + return $html +} + +$html = Convert-DocumentationMarkdown -Path $sourcePath $generatedBlock = @"
@@ -15,6 +28,15 @@ $html "@ +$canFrameHtml = Convert-DocumentationMarkdown -Path $canFrameSourcePath +$canFrameBlock = @" + +
+$canFrameHtml +
+ +"@ + $page = Get-Content -Raw -LiteralPath $outputPath -Encoding UTF8 $pattern = '(?s).*?' if ($page -notmatch $pattern) { @@ -26,6 +48,16 @@ $page = [regex]::Replace($page, $pattern, [System.Text.RegularExpressions.MatchE $generatedBlock }, 1) +$canFramePattern = '(?s).*?' +if ($page -notmatch $canFramePattern) { + throw 'Не найдены маркеры CAN-FRAME-PARSE:START/END в doc/setprotocol.html.' +} + +$page = [regex]::Replace($page, $canFramePattern, [System.Text.RegularExpressions.MatchEvaluator]{ + param($match) + $canFrameBlock +}, 1) + $page = $page.TrimEnd("`r", "`n") + [Environment]::NewLine $utf8WithoutBom = [System.Text.UTF8Encoding]::new($false) [System.IO.File]::WriteAllText($outputPath, $page, $utf8WithoutBom) diff --git a/doc/index.html b/doc/index.html index c09ac93..c4861e4 100644 --- a/doc/index.html +++ b/doc/index.html @@ -17,7 +17,7 @@ git subtree pull --prefix lib/templates https://git.rd12.ru/Andrey/templates.git

eeprom-ft24c256

EEPROM 24Cxx с нарезкой записи по страницам.

I²Cstorage

Порт: I²C write, write-read и delay.

can-sensor

Однокадровые SETCAN SETTINGS для 64-битных ROM датчиков.

CANsensor

Порт: готовый bxCAN STM32F1 либо callbacks CAN TX/RX.

ds18b20

1-Wire, CRC8, SEARCH ROM и несколько термометров.

1-Wireпорты F1/G4/F4

Порт: Init, DelayUs, Reset, WriteBit, ReadBit.

-

SETProtocol

Единое C99-ядро: SET v2, ProtoCAN/GUI v1, телеметрия, firmware flow, GAS и стабильный ABI.

UART/USB/CAN/EthernetWindows + Android + STM32F4

Порт: COM, SLCAN, SocketCAN, JNI или callbacks MCU.

+

SETProtocol

Единое C99-ядро: SET v2, ProtoCAN/GUI v1, телеметрия, firmware flow, GAS и стабильный ABI.

UART/USB/CAN/EthernetWindows + Android + STM32F4

Порт: COM, SLCAN, SocketCAN, JNI или callbacks MCU.

protocan-boot

Прошивка по ProtoCAN: A/B-слоты, resume, CRC32, verify и rollback.

CANfirmware

Порт: CAN TX, Flash, metadata, verify, reboot.

rs485-boot

SETGUI v1 firmware update: stream parser, CRC32 и resume.

RS-485порты F103/G474

Порт: UART TX/RX, DE и Flash.

rtc-service

RTC с резервным backup-томом и единым API времени.

RTCпорт K1921VK028

Порт: RTC и backup storage.

diff --git a/doc/setprotocol.html b/doc/setprotocol.html index 258e50f..f8e800e 100644 --- a/doc/setprotocol.html +++ b/doc/setprotocol.html @@ -14,7 +14,7 @@ .callout{margin:20px 0;padding:17px 19px;border-left:4px solid var(--mint);border-radius:0 12px 12px 0;background:var(--panel)}.callout.warn{border-left-color:var(--amber)}.callout.danger{border-left-color:var(--red)} .flow{display:grid;grid-template-columns:1fr 44px 1fr 44px 1fr;align-items:stretch;margin:22px 0}.flow .node{display:grid;place-items:center;min-height:126px;padding:18px;border:1px solid var(--line);border-radius:15px;background:var(--panel);text-align:center}.flow .node b{display:block;color:var(--mint);font-size:17px}.flow .arrow{display:grid;place-items:center;color:var(--mint);font-size:28px}.core-node{box-shadow:inset 0 0 0 1px #4ddbb055,0 18px 60px var(--shadow)!important} .wire{display:flex;flex-wrap:wrap;gap:4px;margin:15px 0}.byte{padding:9px 10px;border:1px solid var(--line);background:#07131b;color:#c9dcdf;font:13px Consolas,monospace}.byte:first-child{border-radius:9px 0 0 9px}.byte:last-child{border-radius:0 9px 9px 0}.byte.sof{border-color:#2b816b;color:var(--mint)}.byte.crc{border-color:#75612f;color:#ffd879}.byte.payload{flex:1;min-width:130px;text-align:center} - table{width:100%;border-collapse:collapse;margin:17px 0}th,td{padding:11px 13px;border:1px solid var(--line);text-align:left;vertical-align:top}th{color:var(--mint);background:var(--panel2)}td{background:#0a1822c4}.table-wrap{overflow:auto}.status{white-space:nowrap}.copy{position:absolute;right:9px;top:9px;border:1px solid var(--line);border-radius:7px;padding:5px 8px;color:var(--muted);background:#0b1b27;cursor:pointer}.copy:hover{color:var(--text);border-color:var(--mint)} + table{width:100%;border-collapse:collapse;margin:17px 0}th,td{padding:11px 13px;border:1px solid var(--line);text-align:left;vertical-align:top}th{color:var(--mint);background:var(--panel2)}td{background:#0a1822c4}.table-wrap{max-width:100%;overflow:auto}.full-doc .table-wrap table{min-width:720px}.full-doc .table-wrap th,.full-doc .table-wrap td{overflow-wrap:normal;word-break:normal}.status{white-space:nowrap}.copy{position:absolute;right:9px;top:9px;border:1px solid var(--line);border-radius:7px;padding:5px 8px;color:var(--muted);background:#0b1b27;cursor:pointer}.copy:hover{color:var(--text);border-color:var(--mint)} .steps{counter-reset:s;display:grid;gap:12px}.step{position:relative;padding:19px 20px 19px 68px;border:1px solid var(--line);border-radius:14px;background:var(--panel)}.step:before{counter-increment:s;content:counter(s);position:absolute;left:19px;top:19px;display:grid;place-items:center;width:32px;height:32px;border-radius:50%;color:#03100d;background:var(--mint);font-weight:900} .full-doc{padding:27px}.full-doc h1{font-size:34px;letter-spacing:-.025em}.full-doc h2{margin-top:34px;padding-top:22px;border-top:1px solid var(--line);font-size:25px}.full-doc h3{margin-top:25px}.full-doc li{margin:5px 0}.full-doc blockquote{margin:16px 0;padding:2px 17px;border-left:4px solid var(--mint);color:var(--muted)}footer{padding:25px 0 45px;border-top:1px solid var(--line);color:var(--muted)} @media(max-width:900px){.grid,.grid.two{grid-template-columns:repeat(2,1fr)}.flow{grid-template-columns:1fr}.flow .arrow{transform:rotate(90deg);height:42px}.flow .node{min-height:100px}}@media(max-width:620px){.grid,.grid.two{grid-template-columns:1fr}header{padding-top:42px}.full-doc{padding:18px}.wrap{width:min(100% - 22px,1200px)}th,td{padding:9px}} @@ -34,6 +34,7 @@ +
@@ -107,6 +108,477 @@ wire = core.encode(1, 1, 0x1234567, b"\xAA\xBB") frames = core.parser().feed(wire) + +