feat(protocan-transport): транспортный уровень ProtoCAN и каталог GUI

Перенесён из репозитория protocan-transport, который подключался
сабмодулем в CAN_to_RS485.

Кадрирование AA 55 с CRC16 поверх любого байтового потока (RS485, RS232,
USB CDC), разбор 29-битного идентификатора, общее адресное пространство
регистров и каталог с подпиской на поток значений для SETGUI. Состояние
живёт в структурах вызывающего, поэтому в одной прошивке поднимается
сколько угодно независимых каналов. Порт STM32F4 (USART + DMA) в комплекте.

Хостовые тесты test_transport и test_gui проходят.
This commit is contained in:
2026-08-23 01:15:35 +03:00
parent 873ac438f3
commit 3dc636e012
27 changed files with 3646 additions and 0 deletions

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` — прибор не тратит на это такты и не
теряет точность на промежуточном округлении.