refactor: merge protocol cores as SETProtocol

This commit is contained in:
2026-09-01 09:58:09 +03:00
parent 5504104cc5
commit 19becd7b8c
56 changed files with 1256 additions and 359 deletions

View File

@@ -0,0 +1,21 @@
# История изменений ProtoCAN
Формат основан на Keep a Changelog. Версия относится к спецификации, а не к
версии прошивки отдельного прибора.
## [Unreleased]
### Added
- Структурированный комплект документации `doc/protocan`.
- Загрузочный сервис `MsgType=0x9…0xD`.
- Два логических слота по 512 КиБ, 65 536 блоков по 8 байт каждый.
- Машинные эталоны CAN ID в `examples/test-vectors.json`.
## [1.0] — 2026-08-29
### Added
- Зафиксирована 29-битная структура ProtoCAN ID.
- Зафиксирована адресация 8 типов по 16 экземпляров.
- Существующие сообщения `0x0…0x8`, `0xE`, `0xF` сохранены.

View File

@@ -0,0 +1,78 @@
# Транспортный кадр
## Формат
```
+------+------+-----+-----+-------+----------------+-------------+-------+-------+
| 0xAA | 0x55 | LEN | SEQ | FLAGS | ID0 ID1 ID2 ID3| DATA[0..8] | CRC_L | CRC_H |
+------+------+-----+-----+-------+----------------+-------------+-------+-------+
```
| Поле | Байт | Описание |
|---|---:|---|
| SOF | 2 | сигнатура `0xAA 0x55` |
| `LEN` | 1 | длина участка `SEQ..DATA` = `6 + DLC`, диапазон 6..14 |
| `SEQ` | 1 | счётчик кадров 0..255, инкремент на каждый успешно отданный кадр |
| `FLAGS` | 1 | см. ниже |
| `ID` | 4 | 29-битный CAN-идентификатор, little-endian (старшие 3 бита = 0) |
| `DATA` | 0..8 | `DLC = LEN - 6` байт данных CAN |
| `CRC` | 2 | CRC-16/CCITT-FALSE, little-endian |
CRC считается по байтам от `LEN` до последнего байта `DATA` включительно;
сигнатура SOF в расчёт не входит. Полином `0x1021`, начальное значение
`0xFFFF`, без рефлексии и без финального XOR — контрольное значение для
строки `123456789` равно `0x29B1`.
Максимальный размер кадра — 19 байт (`PCAN_FRAME_MAX`).
## FLAGS
| Бит | Имя | Значение |
|---:|---|---|
| 0 | `IDE` | 1 = расширенный ID (29 бит), 0 = стандартный (11 бит) |
| 1 | `RTR` | 1 = remote frame |
| 2 | `DIR` | 0 = кадр пришёл из CAN, 1 = кадр надо передать в CAN |
| 3 | `ERR` | 1 = служебный кадр моста (диагностика), не трафик шины |
| 7..4 | — | резерв, передавать нулями |
## Синхронизация
Приёмник ищет `0xAA 0x55`, читает `LEN`, проверяет диапазон `6..14`,
набирает `LEN + 2` байт и сверяет CRC. При неверном `LEN` или несовпадении
CRC разборщик возвращается к поиску сигнатуры, причём байт, оборвавший
разбор, сам проверяется на `0xAA` — последовательность `AA AA 55` тоже
распознаётся. Потеря синхронизации стоит не больше одного кадра.
Счётчики разбора (`pcan_parse_stats_t`) отдельно считают кадры, ошибки CRC,
неверные `LEN` и байты вне кадров — по ним видно, шумит линия или сбоит
источник.
## SEQ
`SEQ` инкрементируется только на успешно отданном кадре: если кадр не влез
в очередь передачи, счётчик не двигается, и приёмник не засчитает потерю
там, где кадра просто не было. Разрыв в `SEQ` на приёме означает реальную
потерю в линии.
## Почему так
- **Длина плюс CRC, без байт-стаффинга.** Стаффинг раздувает кадр
непредсказуемо и усложняет расчёт таймингов на полудуплексной линии.
Фиксированный заголовок даёт заранее известный максимум 19 байт.
- **Сигнатура из двух байт.** Один байт слишком часто встречается в
случайных данных; два дают приемлемую вероятность ложного старта,
который всё равно отсеет CRC.
- **`LEN` в начале.** Приёмник сразу знает, сколько байт набирать, и не
зависит от содержимого данных.
- **Little-endian везде.** Совпадает с порядком регистров в
`PROTOCAN_SEND_GENERAL_ADDRESS_SPACE()` и с обоими целевыми МК.
## Пример
CAN-кадр: ID `0x1234567`, DLC 2, данные `AA BB`, `SEQ = 1`, флаг `IDE`.
```
AA 55 08 01 01 67 45 23 01 AA BB FE 14
```
`LEN = 6 + 2 = 8`, `FLAGS = 0x01`, CRC = `0x14FE`.

View File

@@ -0,0 +1,87 @@
# Общее адресное пространство (GAS)
Плоское пространство 16-битных регистров с адресом `0x0000..0xFFFF`.
Пространство собирается из **регионов**; регионы не перекрываются и
хранятся отсортированными по адресу, поиск — двоичный.
## Регион
```c
typedef struct pcan_gas_region {
uint16_t base; /* адрес первого регистра */
uint16_t count; /* число регистров */
uint16_t *storage; /* массив либо NULL */
pcan_gas_read_fn read;
pcan_gas_write_fn write;
uint8_t flags; /* PCAN_GAS_RDONLY / PCAN_GAS_WRONLY */
void *user;
const char *name;
} pcan_gas_region_t;
```
Если `storage != NULL`, чтение и запись идут прямо в массив — это самый
дешёвый вариант для обычных уставок. Если нужен вычисляемый регистр
(счётчик, состояние периферии, время работы), задайте `read`/`write`:
колбэк получает смещение внутри региона и указатель `user`.
`pcan_gas_map_validate()` проверяет карту на этапе старта: нулевые регионы,
выход за `0xFFFF`, перекрытие, нарушение порядка и регион без источника
данных. Вызывайте её один раз при инициализации — ошибка в таблице ловится
сразу, а не через месяц в поле.
## Доступ
```c
uint16_t v;
pcan_gas_read(&map, 0x0002, &v);
pcan_gas_write(&map, 0x0002, 0x1234);
uint16_t block[4];
uint16_t n = pcan_gas_read_block(&map, 0x0000, block, 4);
```
Блочное чтение обрывается на первом адресе, которого нет в карте, поэтому
вызывающий всегда получает непрерывный кусок и знает его длину.
## Отображение на кадры ProtoCAN
Тип сообщения `PCAN_MSG_GAS` (`0b0011`). Адрес первого регистра лежит
в `MsgBody`, данные — до 4 регистров подряд, младшим байтом вперёд.
Это ровно то, что делает `PROTOCAN_SEND_GENERAL_ADDRESS_SPACE()`
в `SETCAN/Src/protocan.c`, поэтому обмен совместим с существующими
устройствами.
| Кадр | DLC | Смысл |
|---|---:|---|
| GAS, `MsgBody = addr` | 0 | **запрос на чтение** |
| GAS, `MsgBody = addr` | 2..8 | значения регистров начиная с `addr` |
Запрос на чтение с `DLC = 0` — **расширение**: в исходном коде SETCAN
такой кодировки нет, там ответы на GAS формировала заглушка `ProtoCanMsgToGeneralAddressSpace()`,
возвращавшая строку `GAS-XXXX`. Кодировка выбрана так, чтобы не занимать
новых типов сообщений и не конфликтовать с существующим форматом ответа.
```c
pcan_frame_t rsp;
if (pcan_gas_handle(&map, &incoming, &rsp)) {
pcan_link_send(&link, &rsp); /* был запрос на чтение */
}
```
`pcan_gas_handle()`:
- на запрос чтения кладёт в `rsp` до 4 регистров и переключает `Route`
на `FROM_DEVICE`, возвращает `true`;
- на запись пишет регистры в карту и возвращает `false` — ответа нет;
- если адреса нет в карте, возвращает `false`: отвечать нечем, а молчание
честнее, чем ответ с нулями.
## Ограничения
- В один кадр помещается не больше 4 регистров (`PCAN_GAS_REGS_PER_FRAME`).
Длинные блоки разбивайте на несколько кадров.
- Частичная запись: если в середине блока попался адрес вне карты или
регион только для чтения, запись обрывается на нём. `pcan_gas_write_block()`
возвращает число фактически записанных регистров.
- Атомарности между регистрами нет. Если два регистра обязаны меняться
вместе, заведите колбэк, который применяет их по записи второго.

View File

@@ -0,0 +1,158 @@
# Каталог общего адресного пространства и поток значений
Расширение GUI-протокола SETGUI (`A5 5A`, см. `gui_desktop/core/protocol.py`)
для работы с общим адресным пространством: прибор сам объявляет, какие
регистры у него есть и как они называются, а оператор выбирает, что
показывать. Схема повторяет то, как устроен реестр регистров в
ST Motor Control Workbench / Motor Pilot.
Транспорт не меняется: `A5 5A | ver | type | seq(BE) | size(BE) | payload | CRC32(LE)`,
payload до 512 байт. Добавлены только три типа сообщений.
```
прибор GUI
│ ── GAS_CATALOG (seq = 0) ──────────► │ каталог приходит сам
│ ── GAS_CATALOG (seq = 0) ──────────► │ при инициализации
│ ── GAS_CATALOG (seq = 0) ──────────► │
│ │ оператор отмечает нужное
│ ◄──────────────── GAS_WATCH_SET ──── │
│ ── GAS_WATCH_SET (эхо) ────────────► │
│ ── GAS_WATCH_DATA (seq = 0) ───────► │ поток значений
│ ── GAS_WATCH_DATA (seq = 0) ───────► │
```
## Типы сообщений
Заняты из свободного диапазона `0x11..0x1F` (между `READ_LOGS = 0x10`
и `SENSOR_SCAN = 0x20`). Существующие значения не перенумерованы.
| Код | Имя | Направление |
|---:|---|---|
| `0x11` | `GAS_CATALOG` | запрос GUI → прибор; записи прибор → GUI |
| `0x12` | `GAS_WATCH_SET` | GUI → прибор, прибор отвечает эхом |
| `0x13` | `GAS_WATCH_DATA` | прибор → GUI, без запроса, `sequence = 0` |
Незапрошенные кадры прибор публикует с `sequence = 0` — так же, как уже
устроены `SENSOR_DATA` и `UI_STATE`.
## `GAS_CATALOG` (0x11)
### Запрос (GUI → прибор), 4 байта
| Смещение | Тип | Поле |
|---:|---|---|
| 0 | u16 LE | `start_index` — порядковый номер записи, **не адрес** |
| 2 | u16 LE | `max_count` — сколько записей вернуть; 0 = сколько влезет |
### Ответ и автопубликация (прибор → GUI)
| Смещение | Тип | Поле |
|---:|---|---|
| 0 | u16 LE | `total` — всего записей в каталоге |
| 2 | u16 LE | `start_index` — индекс первой записи в пакете |
| 4 | u16 LE | `count` — записей в пакете |
| 6 | | `count` записей по 32 байта |
Одна запись — 32 байта:
| Смещение | Тип | Поле |
|---:|---|---|
| 0 | u16 LE | `address` — адрес в общем адресном пространстве |
| 2 | u8 | `type` — формат значения |
| 3 | u8 | `flags` — доступ |
| 4 | i8 | `scale_pow10` — значение = `raw * 10^scale` |
| 5 | u8 | `unit` — код единицы измерения |
| 6 | u16 LE | резерв, нули |
| 8 | 24 байта | `name` — UTF-8, дополнено нулями |
При payload 512 байт в один пакет входит `(512 - 6) / 32 = 15` записей.
Под имя отведено 24 байта — это 12 кириллических символов в UTF-8.
На 16 байтах не помещалось даже «Температура», поэтому поле шире,
чем кажется нужным для латиницы.
### `type`
| Код | Значение | Регистров |
|---:|---|---:|
| 0 | `U16` — беззнаковое | 1 |
| 1 | `I16` — знаковое | 1 |
| 2 | `U32` — беззнаковое, младшее слово первым | 2 |
| 3 | `I32` — знаковое, младшее слово первым | 2 |
| 4 | `BITS` — битовое поле | 1 |
Многословные значения занимают подряд идущие адреса; в потоке они
приходят отдельными словами, GUI собирает их сам.
### `flags`
| Бит | Смысл |
|---:|---|
| 0 | доступно на чтение |
| 1 | доступно на запись |
| 2 | включить в подписку по умолчанию |
### `unit`
| Код | Единица | Код | Единица |
|---:|---|---:|---|
| 0 | — | 6 | мс |
| 1 | В | 7 | с |
| 2 | А | 8 | кбит/с |
| 3 | °C | 9 | шт. |
| 4 | % | 10 | об/мин |
| 5 | Гц | 11 | Вт |
## `GAS_WATCH_SET` (0x12)
### Запрос (GUI → прибор)
| Смещение | Тип | Поле |
|---:|---|---|
| 0 | u16 LE | `period_ms` — период потока; **0 останавливает поток** |
| 2 | u16 LE | `count` — число адресов, не больше `GAS_WATCH_MAX` (64) |
| 4 | | `count` × u16 LE — адреса в нужном порядке |
Порядок адресов сохраняется: значения в `GAS_WATCH_DATA` приходят
ровно в том же порядке, без повторной передачи адресов.
### Ответ (прибор → GUI), 4 байта
| Смещение | Тип | Поле |
|---:|---|---|
| 0 | u16 LE | `period_ms` — период, который прибор реально установил |
| 2 | u16 LE | `count` — сколько адресов принято |
Прибор может принять меньше, чем попросили: адрес вне карты в подписку
не берётся. Расхождение `count` с запросом — сигнал GUI, что часть
адресов отвергнута.
## `GAS_WATCH_DATA` (0x13)
Прибор → GUI, `sequence = 0`, без запроса.
| Смещение | Тип | Поле |
|---:|---|---|
| 0 | u32 LE | `timestamp_ms` — время прибора от старта |
| 4 | u16 LE | `count` |
| 6 | | `count` × u16 LE — значения в порядке подписки |
`timestamp_ms` берётся у прибора, а не у хоста: по нему видно реальный
период и провалы, которые иначе замаскировала бы буферизация UART.
## Замечания по реализации
* **Каталог статичен.** Он описывает прошивку, а не состояние, поэтому
публикуется один раз при инициализации периферии. GUI может перечитать
его запросом в любой момент.
* **Подписка живёт до переподключения.** Прибор не сохраняет её в
энергонезависимой памяти: после сброса поток молчит, пока GUI не
пришлёт `GAS_WATCH_SET` снова.
* **Поток не должен забивать линию.** При периоде 10 мс и 64 адресах
выходит 134 байта на пакет и 13.4 кбайт/с — половина пропускной
способности 256000 бод. Прибор пропускает такт, если в очереди
передачи нет места для целого пакета, и это видно по разрыву
`timestamp_ms`.
* **Значения передаются сырыми.** Пересчёт в физические величины делает
GUI по `scale_pow10` и `unit` — прибор не тратит на это такты и не
теряет точность на промежуточном округлении.

View File

@@ -0,0 +1,56 @@
# Общее адресное пространство
Статус: **Stable, данные ведутся в XLSX**
Порядок значений: **16-битные регистры, little-endian в CAN payload**
Редактируемый источник реестра:
[`Протокол CAN и ОАП.xlsx`](../../Протокол%20CAN%20и%20ОАП.xlsx).
Просматриваемая большая таблица находится в
[`Протокол CAN и ОАП.html`](../../Протокол%20CAN%20и%20ОАП.html) и
[`Протокол CAN и ОАП.md`](../../Протокол%20CAN%20и%20ОАП.md).
## Назначение
ОАП связывает 16-битный адрес регистра с его типом, назначением, доступом и
масштабом. В ProtoCAN используется `MsgType=0x3`, а `MsgBody` содержит адрес
первого регистра.
## Обязательные поля реестра
| Поле | Требование |
|---|---|
| AddressHex | `0x0000…0xFFFF`, уникальное значение |
| AddressDec | десятичный эквивалент AddressHex |
| Group | функциональная группа |
| Name | однозначное имя параметра |
| Type | `u16`, `i16`, `u32`, `i32`, `float32`, bitmap или массив |
| Registers | число занятых 16-битных регистров |
| Access | `R`, `W` или `RW` |
| Unit | физическая единица либо `—` |
| Scale | множитель/делитель представления |
| Default | значение после сброса, если применимо |
| Description | семантика, диапазон и особые значения |
## Правила ведения
- Адрес не переиспользуется с другим смыслом после выпуска стабильной версии.
- Многорегистровое значение занимает непрерывный диапазон.
- Порядок 16-битных слов для 32-битного значения фиксируется в строке типа.
- Резервные диапазоны явно отмечаются и не используются без изменения версии.
- Удалённый параметр помечается deprecated, а не исчезает молча.
- Изменение адреса, типа или масштаба отражается в `CHANGELOG.md`.
## Экспорт
Для программной генерации каталог следует экспортировать из XLSX в CSV с
UTF-8 и фиксированными английскими именами колонок. CSV должен проверяться на:
- уникальность адресов;
- пересечение многорегистровых значений;
- допустимые типы и права доступа;
- равенство шестнадцатеричного и десятичного адреса;
- попадание адреса в диапазон `0x0000…0xFFFF`.
До появления автоматического экспортёра нормативным источником адресов
остаётся XLSX, а HTML/Markdown считаются представлением.

View File

@@ -0,0 +1,114 @@
# ProtoCAN — базовый протокол
Статус: **Stable с зарезервированным загрузочным расширением**
Версия: **1.0**
Порядок байтов payload: **little-endian**, если явно не указано иное
## Назначение
ProtoCAN — прикладной протокол поверх classic CAN 2.0B. Используются только
расширенные 29-битные идентификаторы (`IDE=1`) и payload длиной 0…8 байт.
## Термины
| Термин | Значение |
|---|---|
| ПМ | управляющий модуль |
| прибор | адресуемый узел на шине |
| `DeviceType` | тип прибора, 0…7 |
| `DeviceID` | экземпляр прибора данного типа, 0…15 |
| `MsgType` | класс сообщения или сервис |
| `MsgBody` | 16-битное поле, формат которого зависит от `MsgType` |
Пара `DeviceType/DeviceID` задаёт до `8 × 16 = 128` уникальных адресов.
## Расширенный CAN ID
```text
28 27 26...24 23...20 19...16 15........0
Priority Route DeviceType DeviceID MsgType MsgBody
1 бит 1 бит 3 бита 4 бита 4 бита 16 бит
```
```c
can_id =
((uint32_t)priority << 28) |
((uint32_t)route << 27) |
((uint32_t)device_type << 24) |
((uint32_t)device_id << 20) |
((uint32_t)msg_type << 16) |
msg_body;
```
| Поле | Значения | Назначение |
|---|---|---|
| `Priority` | `0` critical, `1` standard | CAN-арбитраж |
| `Route` | `0` от ПМ, `1` от прибора | логическое направление |
| `DeviceType` | `0…7` | тип прибора |
| `DeviceID` | `0…15` | номер экземпляра |
| `MsgType` | `0…15` | тип сообщения |
| `MsgBody` | `0…65535` | команда, адрес или номер блока |
`Route` не является направлением физического трансивера. Ответ прибора
сохраняет адрес `DeviceType/DeviceID` и устанавливает `Route=1`.
## Реестр `MsgType`
| Код | Имя | Основное направление | DLC | Статус |
|---:|---|---|---:|---|
| `0x0` | `BROADCAST` | ПМ → все | зависит от команды | stable |
| `0x1` | `DISCRETE` | оба | 0…8 | stable |
| `0x2` | `ANALOG` | оба | 0…8 | stable |
| `0x3` | `GAS` | оба | 0/2/4/6/8 | stable |
| `0x4` | `MODBUS_COIL` | оба | 0…8 | stable |
| `0x5` | `MODBUS_DISCRETE` | оба | 0…8 | stable |
| `0x6` | `MODBUS_HOLDING` | оба | 0…8 | stable |
| `0x7` | `MODBUS_INPUT` | оба | 0…8 | stable |
| `0x8` | `ERROR` | прибор → ПМ | 0 | stable |
| `0x9` | `BOOT_CONTROL` | ПМ → прибор | 0/8 | draft |
| `0xA` | `BOOT_DATA_A` | ПМ → прибор | 8 | draft |
| `0xB` | `BOOT_DATA_B` | ПМ → прибор | 8 | draft |
| `0xC` | `BOOT_STATUS` | прибор → ПМ | 8 | draft |
| `0xD` | `BOOT_DISCOVERY` | прибор → ПМ | 8 | draft |
| `0xE` | `SETTINGS` | оба | 0/1/8 | stable |
| `0xF` | `PULSE` | прибор → сеть | 1 | stable |
Подробный формат `0x9…0xD` находится в [BOOTLOADER.md](BOOTLOADER.md).
## Разметки `MsgBody`
| `MsgType` | Биты `MsgBody` |
|---|---|
| broadcast | команда `[15:4]`, параметр `[3:0]` |
| discrete/analog | подтип `[15:12]`, значение/адрес `[11:0]` |
| Modbus | начальный адрес `[15:4]`, количество `[3:0]` |
| GAS | адрес первого 16-битного регистра `[15:0]` |
| error | дополнительная информация `[15:8]`, код `[7:0]` |
| settings | номер сборки `[15:8]`, позиция `[7:0]` |
| boot control/status | `SessionID[15:8]`, команда `[7:0]` |
| boot data | `BlockIndex[15:0]` |
## Общие правила обмена
- Многобайтовые значения в `DATA` передаются little-endian.
- Узел игнорирует адресованные кадры с чужим `DeviceType/DeviceID`.
- Прибор принимает команды ПМ с `Route=0`; ПМ принимает ответы с `Route=1`.
- Стандартные 11-битные CAN ID не являются кадрами ProtoCAN.
- RTR для загрузочного сервиса запрещён.
- Неописанные комбинации `MsgType/MsgBody/DLC` должны отвергаться.
## Эталон упаковки ID
```text
Priority = 1
Route = 0
DeviceType = 3
DeviceID = 5
MsgType = 0x9
MsgBody = 0x0702
CAN ID = 0x13590702
```
Этот пример соответствует `ENTER_BOOT`, `SessionID=7`. Машинные варианты
находятся в [examples/test-vectors.json](examples/test-vectors.json).