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,374 @@
/**
* Тесты GUI-протокола и каталога общего адресного пространства.
* Эталонные байты совпадают с SETGUI/tests/test_gas_catalog.py.
*
* clang -std=c99 -Wall -Wextra -Iinclude tests/test_gui.c src/pcan_*.c src/gui_*.c -o test_gui
*/
#include <stdio.h>
#include <string.h>
#include "gui_catalog.h"
#include "gui_frame.h"
#include "pcan_gas.h"
static int g_fail;
#define CHECK(cond) \
do { \
if (!(cond)) { \
printf("FAIL %s:%d %s\n", __FILE__, __LINE__, #cond); \
g_fail++; \
} \
} while (0)
static void dump(const char *tag, const uint8_t *data, size_t len)
{
printf("%s:", tag);
for (size_t i = 0; i < len; i++) {
printf(" %02X", data[i]);
}
printf("\n");
}
/* -- Транспорт ------------------------------------------------------------- */
static void test_crc32(void)
{
/* Контрольное значение CRC32/ISO-HDLC, оно же zlib.crc32. */
CHECK(gui_crc32((const uint8_t *)"123456789", 9) == 0xCBF43926UL);
CHECK(gui_crc32((const uint8_t *)"", 0) == 0U);
}
static void test_ping_reference(void)
{
/* Эталон из PROJECT_RELATIONS.md:
A5 5A 01 01 12 34 00 00 EE 89 8C 9E */
static const uint8_t want[] = {
0xA5, 0x5A, 0x01, 0x01, 0x12, 0x34, 0x00, 0x00, 0xEE, 0x89, 0x8C, 0x9E
};
uint8_t out[GUI_FRAME_MAX];
size_t n = gui_frame_encode(GUI_MSG_PING, 0x1234, NULL, 0, out, sizeof out);
if ((n != sizeof want) || (memcmp(out, want, sizeof want) != 0)) {
dump("получено", out, n);
dump("ожидалось", want, sizeof want);
}
CHECK(n == sizeof want);
CHECK(memcmp(out, want, sizeof want) == 0);
}
static int s_rx_count;
static uint8_t s_rx_type;
static uint16_t s_rx_seq;
static uint16_t s_rx_size;
static uint8_t s_rx_payload[64];
static void on_frame(const gui_frame_t *frame, void *user)
{
(void)user;
s_rx_count++;
s_rx_type = frame->type;
s_rx_seq = frame->sequence;
s_rx_size = frame->size;
if (frame->size <= sizeof s_rx_payload) {
memcpy(s_rx_payload, frame->payload, frame->size);
}
}
static void test_parser(void)
{
uint8_t payload[5] = { 1, 2, 3, 4, 5 };
uint8_t out[GUI_FRAME_MAX];
size_t n = gui_frame_encode(GUI_MSG_GAS_WATCH_DATA, 0, payload, 5, out, sizeof out);
gui_parser_t p;
gui_parser_init(&p);
s_rx_count = 0;
/* Мусор и оборванная сигнатура перед настоящим кадром. */
static const uint8_t junk[] = { 0x00, 0xFF, 0xA5, 0xA5, 0x5A, 0x02 };
gui_parser_feed(&p, junk, sizeof junk, on_frame, NULL);
gui_parser_feed(&p, out, n, on_frame, NULL);
CHECK(s_rx_count == 1);
CHECK(s_rx_type == GUI_MSG_GAS_WATCH_DATA);
CHECK(s_rx_size == 5U);
CHECK(memcmp(s_rx_payload, payload, 5) == 0);
CHECK(p.stats.version_errors == 1U); /* 0x02 вместо версии 0x01 */
CHECK(p.stats.frames == 1U);
/* Битый CRC отвергается. */
out[n - 1U] ^= 0xFFU;
gui_parser_init(&p);
s_rx_count = 0;
gui_parser_feed(&p, out, n, on_frame, NULL);
CHECK(s_rx_count == 0);
CHECK(p.stats.crc_errors == 1U);
}
static void test_max_payload(void)
{
static uint8_t payload[GUI_MAX_PAYLOAD];
for (size_t i = 0; i < sizeof payload; i++) {
payload[i] = (uint8_t)i;
}
static uint8_t out[GUI_FRAME_MAX];
size_t n = gui_frame_encode(GUI_MSG_WRITE_REGISTERS, 0xFFFF, payload,
GUI_MAX_PAYLOAD, out, sizeof out);
CHECK(n == GUI_FRAME_MAX);
CHECK(out[6] == 0x02U && out[7] == 0x00U); /* size = 512 big-endian */
/* Больше максимума не кодируем и буфер не переполняем. */
CHECK(gui_frame_encode(GUI_MSG_PING, 0, payload, GUI_MAX_PAYLOAD + 1U,
out, sizeof out) == 0U);
CHECK(gui_frame_encode(GUI_MSG_PING, 0, payload, 100U, out, 50U) == 0U);
}
/* -- Каталог --------------------------------------------------------------- */
static uint16_t s_settings[4];
static uint16_t s_diag[4] = { 0x0100, 0x003C, 0x0000, 0x0002 };
static const pcan_gas_region_t s_regions[] = {
{ 0x0000U, 4U, s_settings, NULL, NULL, 0U, NULL, "settings" },
{ 0xFF00U, 4U, s_diag, NULL, NULL, PCAN_GAS_RDONLY, NULL, "diag" },
};
static const pcan_gas_map_t s_map = { s_regions, 2U };
static const gui_object_t s_objects[] = {
{ 0xFF01U, GUI_OBJ_U16, GUI_OBJ_READABLE | GUI_OBJ_DEFAULT_WATCH,
0, GUI_UNIT_SECOND, "Uptime" },
{ 0x0000U, GUI_OBJ_I16, GUI_OBJ_READABLE | GUI_OBJ_WRITABLE,
-2, GUI_UNIT_VOLT, "Uставка" },
{ 0xFF02U, GUI_OBJ_U32, GUI_OBJ_READABLE, 0, GUI_UNIT_COUNT, "Кадров" },
};
static const gui_catalog_t s_catalog = { s_objects, 3U };
static void test_catalog_validate(void)
{
CHECK(gui_catalog_validate(&s_catalog, &s_map));
/* Адрес вне карты GAS. */
static const gui_object_t bad_addr[] = {
{ 0x8000U, GUI_OBJ_U16, GUI_OBJ_READABLE, 0, 0, "нет" },
};
const gui_catalog_t c1 = { bad_addr, 1U };
CHECK(!gui_catalog_validate(&c1, &s_map));
/* U32 требует двух подряд идущих адресов; 0xFF03 есть, 0xFF04 - нет. */
static const gui_object_t split[] = {
{ 0xFF03U, GUI_OBJ_U32, GUI_OBJ_READABLE, 0, 0, "хвост" },
};
const gui_catalog_t c2 = { split, 1U };
CHECK(!gui_catalog_validate(&c2, &s_map));
/* Имя длиннее поля: 13 кириллических символов - это 26 байт. */
static const gui_object_t long_name[] = {
{ 0x0000U, GUI_OBJ_U16, GUI_OBJ_READABLE, 0, 0, "тринадцать сим" },
};
const gui_catalog_t c3 = { long_name, 1U };
CHECK(!gui_catalog_validate(&c3, &s_map));
}
static void test_catalog_encode(void)
{
uint8_t out[GUI_MAX_PAYLOAD];
size_t n = gui_catalog_encode(&s_catalog, 0U, 1U, out, sizeof out);
CHECK(n == GUI_CATALOG_HEADER + GUI_ENTRY_SIZE);
CHECK(gui_get_u16(&out[0]) == 3U); /* total */
CHECK(gui_get_u16(&out[2]) == 0U); /* start_index */
CHECK(gui_get_u16(&out[4]) == 1U); /* count */
/* Запись Uptime совпадает с эталоном из Python-тестов. */
static const uint8_t want[] = {
0x01, 0xFF, 0x00, 0x05, 0x00, 0x07, 0x00, 0x00,
'U', 'p', 't', 'i', 'm', 'e', 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
};
if (memcmp(&out[GUI_CATALOG_HEADER], want, sizeof want) != 0) {
dump("запись", &out[GUI_CATALOG_HEADER], GUI_ENTRY_SIZE);
}
CHECK(sizeof want == GUI_ENTRY_SIZE);
CHECK(memcmp(&out[GUI_CATALOG_HEADER], want, sizeof want) == 0);
/* Отрицательный масштаб кодируется как int8. */
n = gui_catalog_encode(&s_catalog, 1U, 1U, out, sizeof out);
CHECK(n == GUI_CATALOG_HEADER + GUI_ENTRY_SIZE);
CHECK(out[GUI_CATALOG_HEADER + 4U] == 0xFEU); /* -2 */
/* max_count = 0 отдаёт всё, что влезает. */
n = gui_catalog_encode(&s_catalog, 0U, 0U, out, sizeof out);
CHECK(n == GUI_CATALOG_HEADER + (3U * GUI_ENTRY_SIZE));
CHECK(gui_get_u16(&out[4]) == 3U);
/* Индекс за концом каталога - пустой ответ, а не мусор. */
CHECK(gui_catalog_encode(&s_catalog, 3U, 0U, out, sizeof out) == 0U);
/* Маленький буфер отвергается. */
CHECK(gui_catalog_encode(&s_catalog, 0U, 1U, out, 8U) == 0U);
CHECK(GUI_ENTRIES_PER_FRAME == 15U);
}
/* -- Подписка -------------------------------------------------------------- */
static void test_watch(void)
{
gui_watch_t watch;
gui_watch_init(&watch);
/* period = 100, два адреса: один есть в карте, второй нет. */
static const uint8_t request[] = {
0x64, 0x00, 0x02, 0x00, 0x01, 0xFF, 0x00, 0x80
};
uint16_t accepted = gui_watch_apply(&watch, &s_map, request, sizeof request);
CHECK(accepted == 1U);
CHECK(watch.count == 1U);
CHECK(watch.address[0] == 0xFF01U);
CHECK(watch.period_ms == 100U);
uint8_t ack[4];
CHECK(gui_watch_encode_ack(&watch, ack, sizeof ack) == 4U);
CHECK(gui_get_u16(&ack[0]) == 100U);
CHECK(gui_get_u16(&ack[2]) == 1U);
/* Значение берётся из карты GAS. */
uint8_t data[GUI_MAX_PAYLOAD];
size_t n = gui_watch_encode_data(&watch, &s_map, 0x12345678UL, data, sizeof data);
CHECK(n == 8U);
CHECK(data[0] == 0x78U && data[3] == 0x12U); /* время little-endian */
CHECK(gui_get_u16(&data[4]) == 1U);
CHECK(gui_get_u16(&data[6]) == 0x003CU); /* s_diag[1] = uptime */
/* Битый payload не должен ломать действующую подписку. */
static const uint8_t broken[] = { 0x64, 0x00, 0x05, 0x00, 0x01, 0xFF };
CHECK(gui_watch_apply(&watch, &s_map, broken, sizeof broken) == 0xFFFFU);
CHECK(watch.count == 1U);
CHECK(watch.period_ms == 100U);
/* Слишком длинная подписка отвергается целиком. */
static uint8_t huge[4U + ((GUI_WATCH_MAX + 1U) * 2U)];
gui_put_u16(&huge[0], 10U);
gui_put_u16(&huge[2], GUI_WATCH_MAX + 1U);
CHECK(gui_watch_apply(&watch, &s_map, huge, sizeof huge) == 0xFFFFU);
CHECK(watch.count == 1U);
/* period = 0 останавливает поток. */
static const uint8_t stop[] = { 0x00, 0x00, 0x00, 0x00 };
CHECK(gui_watch_apply(&watch, &s_map, stop, sizeof stop) == 0U);
CHECK(watch.period_ms == 0U);
CHECK(!gui_watch_due(&watch, 1000U));
CHECK(gui_watch_encode_data(&watch, &s_map, 0U, data, sizeof data) == 0U);
}
static void test_watch_timing(void)
{
gui_watch_t watch;
gui_watch_init(&watch);
static const uint8_t request[] = { 0x0A, 0x00, 0x01, 0x00, 0x01, 0xFF };
CHECK(gui_watch_apply(&watch, &s_map, request, sizeof request) == 1U);
watch.next_ms = 1000U;
CHECK(!gui_watch_due(&watch, 999U));
CHECK(gui_watch_due(&watch, 1000U));
CHECK(gui_watch_due(&watch, 1500U));
/* Идём в такт, пока успеваем. */
gui_watch_advance(&watch, 1000U);
CHECK(watch.next_ms == 1010U);
/* Отстали сильно - не нагоняем пачкой, а идём от текущего момента. */
watch.next_ms = 1000U;
gui_watch_advance(&watch, 5000U);
CHECK(watch.next_ms == 5010U);
/* Переполнение счётчика миллисекунд не должно вешать поток. */
watch.next_ms = 0xFFFFFFF0UL;
CHECK(!gui_watch_due(&watch, 0xFFFFFFE0UL));
CHECK(gui_watch_due(&watch, 0x00000010UL));
}
/* -- Канал ----------------------------------------------------------------- */
static uint8_t s_line[1024];
static size_t s_line_len;
static size_t s_line_space = sizeof s_line;
static size_t fake_write(void *ctx, const uint8_t *data, size_t len)
{
(void)ctx;
if ((s_line_len + len) > s_line_space) {
return 0U;
}
memcpy(&s_line[s_line_len], data, len);
s_line_len += len;
return len;
}
static size_t fake_space(void *ctx)
{
(void)ctx;
return s_line_space - s_line_len;
}
static void test_link(void)
{
pcan_io_t io = { fake_write, fake_space, NULL };
static gui_link_t link;
CHECK(gui_link_init(&link, &io, on_frame, NULL));
s_line_len = 0U;
s_rx_count = 0;
uint8_t payload[3] = { 0xDE, 0xAD, 0xBE };
CHECK(gui_link_send(&link, GUI_MSG_GAS_CATALOG, 7U, payload, 3U));
CHECK(link.tx_frames == 1U);
/* Поток обратно кусками произвольной длины. */
size_t off = 0U;
while (off < s_line_len) {
size_t chunk = ((s_line_len - off) > 4U) ? 4U : (s_line_len - off);
gui_link_feed(&link, &s_line[off], chunk);
off += chunk;
}
CHECK(s_rx_count == 1);
CHECK(s_rx_type == GUI_MSG_GAS_CATALOG);
CHECK(s_rx_seq == 7U);
CHECK(s_rx_size == 3U);
/* NACK - два байта кода результата. */
s_line_len = 0U;
s_rx_count = 0;
CHECK(gui_link_nack(&link, 9U, GUI_RESULT_NOT_FOUND));
gui_link_feed(&link, s_line, s_line_len);
CHECK(s_rx_count == 1);
CHECK(s_rx_type == GUI_MSG_NACK);
CHECK(gui_get_u16(s_rx_payload) == GUI_RESULT_NOT_FOUND);
/* Не хватило места - кадр не уходит обрывком. */
s_line_space = 8U;
s_line_len = 0U;
CHECK(!gui_link_can_send(&link, 100U));
CHECK(!gui_link_send(&link, GUI_MSG_PING, 1U, payload, 3U));
CHECK(s_line_len == 0U);
CHECK(link.tx_dropped == 1U);
s_line_space = sizeof s_line;
}
int main(void)
{
test_crc32();
test_ping_reference();
test_parser();
test_max_payload();
test_catalog_validate();
test_catalog_encode();
test_watch();
test_watch_timing();
test_link();
if (g_fail == 0) {
printf("all gui tests passed\n");
return 0;
}
printf("%d check(s) failed\n", g_fail);
return 1;
}