# tft_app: Phase 3.2.1

This commit is contained in:
Dmitry Akimov 2026-07-21 17:19:49 +03:00
parent 50043bf46a
commit 4f84a173a5
18 changed files with 977 additions and 194 deletions

View file

@ -170,7 +170,9 @@
"test_recovery", "test_recovery",
"test_tft_app_smoke", "test_tft_app_smoke",
"test_sul_nku_can", "test_sul_nku_can",
"test_tft_app_controller" "test_tft_app_controller",
"test_tft_app_settings_store",
"test_tft_app_menu"
] ]
}, },
{ {
@ -196,7 +198,9 @@
"test_recovery", "test_recovery",
"test_tft_app_smoke", "test_tft_app_smoke",
"test_sul_nku_can", "test_sul_nku_can",
"test_tft_app_controller" "test_tft_app_controller",
"test_tft_app_settings_store",
"test_tft_app_menu"
] ]
}, },
{ {

132
docs/tft_app/MENU.md Normal file
View file

@ -0,0 +1,132 @@
# tft-app — движок меню и связь с настройками
Документ описывает **реализованный** движок меню (Фаза 3.2.1: чистая модель) и его
связь с модулем настроек `settings_store`. Проектная основа — [ARCH.md §8](../../firmware/tft_app/ARCH.md);
реализация — [menu.c](../../firmware/tft_app/src/menu/src/menu.c),
[settings_store](../../firmware/tft_app/src/services/settings_store/).
Движущее требование (§8): клиент приносит уникальные настройки, и добавление их **не должно
требовать переписывания** движка. Отсюда два принципа: меню — **данные**, редакторы —
**подключаемые по типу**.
---
## 1. Разделение слоёв
Модель меню — чистый C (host-тест), отделена от рендера (HIL) и от записи на флеш.
```mermaid
flowchart TB
BTN["bsp_button<br/>долгое BUTTON_2 / next / action"]
subgraph MODEL["menu (чистая модель, host-тест)"]
TREE["дерево-данные<br/>menu_item_desc_t[]"]
NAV["навигация + редактор<br/>menu_next / menu_action"]
end
SET["settings_t<br/>(RAM, get_mutable)"]
APP["app-слой<br/>(проверяет save_requested)"]
STORE["settings_store<br/>save() → QSPI 0x450000"]
REND["рендер меню<br/>окно 480×272 @ (0,0)"]
BTN --> NAV
TREE --> NAV
NAV -->|"мутация по offset"| SET
NAV -->|"save_requested"| APP
APP --> STORE
NAV -.запросы.-> REND
SET -.значения.-> REND
```
Ключ: **модель сама не сохраняет и не рисует**. Она мутирует переданный `settings_t*` и на
выходе-с-сохранением выставляет `save_requested`; фактический `settings_store_save()` вызывает
app-слой. Это держит навигацию/редактирование host-тестируемыми без QSPI и без рендера.
---
## 2. Меню — данные
Пункт меню — строка-дескриптор ([menu.h](../../firmware/tft_app/src/menu/include/menu/menu.h)):
```c
typedef struct {
const char *label;
menu_item_type_t type; /* редактор: SUBMENU/BACK/SELECT/BYTE/BOOL */
uint16_t value_offset; /* offsetof(settings_t, <uint8-поле>) */
uint8_t min, max; /* диапазон для SELECT/BYTE/BOOL */
uint8_t parent, first_child, last_child; /* дерево (плоский массив+индексы) */
} menu_item_desc_t;
```
Дерево — плоский массив; уровень = непрерывный диапазон детей `[first_child..last_child]` одного
родителя. `items[0]` — корневое `SUBMENU`, его дети — верхний уровень.
**Добавить пункт = добавить строку** массива (тот же принцип, что `k_mode_priority[]`). Добавить
причудливый редактор = добавить значение в `menu_item_type_t` + ветку в `menu_action` — движок
навигации не меняется. Фаза 3 использует `SELECT/BYTE/BOOL`; `ARRAY/SERIAL/YEAR/PERCENT/BOOL_ARRAY`
придут со своими фазами (5/6).
---
## 3. Навигация
Две кнопки: BUTTON_1 → `menu_next` (следующий пункт уровня, с заворотом); короткое BUTTON_2 →
`menu_action` (по типу пункта). Вход в меню — долгое BUTTON_2 (app-слой).
```mermaid
flowchart TD
START([menu_action на текущем пункте]) --> T{тип пункта}
T -->|SUBMENU| ENTER["курсор → first_child (вход)"]
T -->|"BACK, parent=корень"| EXIT["save_requested = dirty<br/>меню закрыто"]
T -->|"BACK, в подменю"| UP["курсор → пункт-подменю (возврат)"]
T -->|SELECT/BYTE/BOOL| EDIT["значение min..max с заворотом<br/>dirty = true"]
```
`dirty` взводится любой правкой; при выходе из корня `save_requested = dirty` (сохраняем только
если что-то менялось). Отдельного «отменить» нет — правки живут в RAM, на флеш попадают лишь при
выходе-с-сохранением.
---
## 4. Связь с настройками
Модель привязана к `settings_t` (ядро настроек, [SETTINGS](../../firmware/tft_app/src/services/settings_store/include/services/settings_store.h))
через **байтовый offset** — читает/пишет `uint8`-поле по `value_offset`:
| Ярус настройки | Пример пункта | Привязка |
| --- | --- | --- |
| **A** железобетонные | громкость, год | `offsetof(settings_t, user.<поле>)` |
| device/провиженинг | тумблер логов | `offsetof(settings_t, device.log_enabled)` |
| **B** протокольные | адрес НКУ | `offsetof(settings_t, user.proto_slice[0])` |
Ярус B (протокольные параметры) в 3.2 привязан прямым offset к `proto_slice`; в 3.3 это обобщается
на дескриптор протокола `sul_settings_desc_t` (§8) — меню строит раздел «Настройки протокола» из
дескриптора активного протокола, не хардкодом.
**Поток сохранения** (app-слой связывает модель и flash):
```mermaid
sequenceDiagram
participant U as Пользователь (кнопки)
participant M as menu (модель)
participant S as settings_t (RAM)
participant A as app
participant St as settings_store
U->>M: menu_action (правка / выход)
M->>S: запись поля по offset (dirty=true)
M-->>A: open=false, save_requested=true
A->>St: settings_store_save()
St->>St: serialize + CRC → QSPI 0x450000
A->>A: пере-применить настройки (напр. адрес → декодер)
```
После сохранения app пере-применяет изменившиеся настройки к рантайму (например, новый адрес —
в `nku_can_set_address()`), т.к. декодер держит свою копию адреса.
---
## 5. Рендер (Фаза 3.2.2)
Рендер — отдельный слой, читает модель запросами (`menu_current`, `menu_level_range`,
`menu_read_value`) и рисует в **фиксированном окне 480×272 в логических (0,0)** — одинаково на всех
панелях (на больших — левый-верхний угол, остальное чёрное). Меню модально: пока `menu_is_open()`,
индикация под ним не рисуется. Детали — [PLAN.md, Фаза 3.2](../../firmware/tft_app/PLAN.md).

View file

@ -9,6 +9,7 @@ Dev-документация по **реализованному** доменн
| [DOMAIN_DATAFLOW.md](DOMAIN_DATAFLOW.md) | Путь данных: транспорт → декодер → контроллер → презентация. Слои, контракт `decode()`, карта пакетов НКУ-CAN → поля модели, таймаут→default. | | [DOMAIN_DATAFLOW.md](DOMAIN_DATAFLOW.md) | Путь данных: транспорт → декодер → контроллер → презентация. Слои, контракт `decode()`, карта пакетов НКУ-CAN → поля модели, таймаут→default. |
| [MODE_PRIORITY.md](MODE_PRIORITY.md) | Свёртка ортогональных сигналов в один экранный режим. Таблица приоритетов как данные, резолвер, как менять/кастомизировать. | | [MODE_PRIORITY.md](MODE_PRIORITY.md) | Свёртка ортогональных сигналов в один экранный режим. Таблица приоритетов как данные, резолвер, как менять/кастомизировать. |
| [FALLBACK.md](FALLBACK.md) | Safe-mode рендер: этаж/стрелка/метка режима, по-символьный fallback шрифта, триггеры перерисовки, ограничение single-buffer. | | [FALLBACK.md](FALLBACK.md) | Safe-mode рендер: этаж/стрелка/метка режима, по-символьный fallback шрифта, триггеры перерисовки, ограничение single-buffer. |
| [MENU.md](MENU.md) | Движок меню (Фаза 3.2): дерево-данные, редакторы по типу, чистая модель, связь с `settings_store` (offset-привязка, `proto_slice`, поток сохранения). |
## Связанные документы ## Связанные документы
@ -18,6 +19,7 @@ Dev-документация по **реализованному** доменн
## Границы ## Границы
Описан домен и fallback-презентация. Ещё не реализовано (см. PLAN.md): настройки/меню (Фаза 3), Описаны домен, fallback-презентация, ядро настроек (`settings_store`) и модель меню.
ассеты и layout-движок со спрайтами (Фазы 45), аудио (Фаза 6), мультипротокол (Фаза 8). Удалённая В процессе (Фаза 3): рендер меню + wiring кнопок (3.2.2/3.2.3), per-protocol дескрипторы (3.3),
установка адреса НКУ-CAN отложена в Фазу 3. opto-входы (3.4), удалённая адресация НКУ-CAN (3.5). Ещё не реализовано (см. PLAN.md): ассеты и
layout-движок со спрайтами (Фазы 45), аудио (Фаза 6), мультипротокол (Фаза 8).

View file

@ -49,6 +49,7 @@ target_compile_options(freertos_kernel PRIVATE -w)
add_subdirectory(src/domain) add_subdirectory(src/domain)
add_subdirectory(src/services) add_subdirectory(src/services)
add_subdirectory(src/ui) add_subdirectory(src/ui)
add_subdirectory(src/menu)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# app — исполняемый образ слота # app — исполняемый образ слота
@ -120,7 +121,9 @@ target_link_libraries(
tft_app_gfx tft_app_gfx
tft_app_ui_fallback tft_app_ui_fallback
# Фаза 3.1: персист ядра настроек (QSPI 0x450000) # Фаза 3.1: персист ядра настроек (QSPI 0x450000)
tft_app_settings_store) tft_app_settings_store
# Фаза 3.2.1: движок меню (чистая модель; рендер/wiring — 3.2.2/3.2.3)
tft_app_menu)
target_link_options( target_link_options(
app app

View file

@ -274,7 +274,7 @@ marks_both_pending`, аппаратно на реальном обрыве св
| Под-шаг | Содержание | | Под-шаг | Содержание |
| --- | --- | | --- | --- |
| **3.1 `services/settings_store`** | Персист ядра (magic/version/CRC, load/save/get/defaults) через `bsp_qspi_flash`, фикс-сектор `0x450000` (§10, размер-независимо). Сразу: `proto_slice[0]``nku_address` в декодер (замена хардкода `=0`). | | **3.1 `services/settings_store`** | Персист ядра (magic/version/CRC, load/save/get/defaults) через `bsp_qspi_flash`, фикс-сектор `0x450000` (§10, размер-независимо). Сразу: `proto_slice[0]``nku_address` в декодер (замена хардкода `=0`). |
| **3.2 движок меню + рендер** | Чистая модель (дерево-данные, навигация, edit, offset-привязка) — host-тест; рендер (примитивы `gfx`: список/курсор/значение) + `bsp_button`; модальный экран. | | **3.2 движок меню + рендер** | **Чистая модель** (дерево-данные, навигация, edit, offset-привязка) — оперирует переданным `settings_t*`, **сама не сохраняет** (выставляет флаг save, app зовёт `settings_store_save()`) → host-тест без QSPI. **Рендер** (примитивы `gfx`: список/курсор/значение) в **переносимом окне 480×272 @ логич.(0,0)** (одинаково на всех панелях; на больших — левый-верхний угол, остальное чёрное). Вход — **долгое нажатие BUTTON_2** (~1.52 с); BUTTON_1=следующий, короткое BUTTON_2=выбор/инкремент. Опрос кнопок — задача 5 мс. Модальный экран. |
| **3.3 per-protocol дескрипторы** | `sul_settings_desc_t`: протокол регистрирует параметры (NKU-CAN: адрес 0..15); секция меню строится из дескриптора. | | **3.3 per-protocol дескрипторы** | `sul_settings_desc_t`: протокол регистрирует параметры (NKU-CAN: адрес 0..15); секция меню строится из дескриптора. |
| **3.4 opto-входы** | IN1/IN2 → вызов/ответ диспетчера → презентация (в fallback — примитив/текст; иконки-спрайты — Фаза 4/5). | | **3.4 opto-входы** | IN1/IN2 → вызов/ответ диспетчера → презентация (в fallback — примитив/текст; иконки-спрайты — Фаза 4/5). |
| **3.5 удалённая адресация NKU-CAN** | `0x4X1`/`0x5XB` → запись `nku_address` в настройки (долг Фазы 2). | | **3.5 удалённая адресация NKU-CAN** | `0x4X1`/`0x5XB` → запись `nku_address` в настройки (долг Фазы 2). |

View file

@ -54,15 +54,23 @@
* settings, см. ARCH §9 TFT7/8/10 рантайм-выбор внутри профиля app-big). */ * settings, см. ARCH §9 TFT7/8/10 рантайм-выбор внутри профиля app-big). */
#define PANEL_TYPE BSP_DISPLAY_TFT8 #define PANEL_TYPE BSP_DISPLAY_TFT8
#define HEARTBEAT_PERIOD_MS 500U #define HEARTBEAT_PERIOD_MS 500U
#define WDOG_FEED_PERIOD_MS 100U /* кормим чаще периода мигания — таймаут WDOG >= 1 c */ #define WDOG_FEED_PERIOD_MS 100U /* кормим чаще периода мигания — таймаут WDOG >= 1 c */
#define STATUS_LOG_PERIOD_MS 2000U /* периодический re-log трейлера — виден независимо #define STATUS_LOG_PERIOD_MS \
2000U /* периодический re-log трейлера — виден независимо
* от момента подключения терминала */ * от момента подключения терминала */
#define CAN_RX_TIMEOUT_MS 100U /* держит цикл отзывчивым к WDOG/heartbeat-каденции */ #define CAN_RX_TIMEOUT_MS 100U /* держит цикл отзывчивым к WDOG/heartbeat-каденции */
#define CONNECTION_TIMEOUT_MS 3000U /* «пропадание трафика» — см. ARCH, поток данных: #define CONNECTION_TIMEOUT_MS \
3000U /* «пропадание трафика» — см. ARCH, поток данных:
* poll + timeoutdefault. Порядок величины как * poll + timeoutdefault. Порядок величины как
* в OLD_PROJECT (там ~3 с на отметку потери связи) */ * в OLD_PROJECT (там ~3 с на отметку потери связи) */
/* TEMP (Фаза 3.1): двухзагрузочная HW-проверка пути save/персист. УБРАТЬ в 3.2,
* когда save начнёт вызываться из меню. Сентинел в ИНЕРТНОМ поле max_load_kg
* (рендер грузоподъёмности Фаза 5), proto_slice/адрес НЕ трогаем индикатор
* продолжает работать с адресом 0. */
#define SETTINGS_SELFTEST_SENTINEL 4242U
/* Передача render_task самого свежего состояния — не истории. */ /* Передача render_task самого свежего состояния — не истории. */
typedef struct typedef struct
{ {
@ -70,7 +78,7 @@ typedef struct
sul_result_t result; sul_result_t result;
} render_msg_t; } render_msg_t;
static QueueHandle_t s_render_queue; static QueueHandle_t g_s_render_queue;
/* ── Диагностика трейлера слота (read-only, безопасно звать многократно) ── */ /* ── Диагностика трейлера слота (read-only, безопасно звать многократно) ── */
@ -84,8 +92,8 @@ static void log_slot_status(const char *p_when)
return; return;
} }
struct boot_swap_state st = {0}; struct boot_swap_state st = { 0 };
const int RC_RD = boot_read_swap_state(p_fap, &st); const int RC_RD = boot_read_swap_state(p_fap, &st);
LOG_I(LOG_TAG, "%s: slot%d magic=%d copy_done=%d image_ok=%d (rd=%d)", p_when, APP_OWN_SLOT_ID, LOG_I(LOG_TAG, "%s: slot%d magic=%d copy_done=%d image_ok=%d (rd=%d)", p_when, APP_OWN_SLOT_ID,
st.magic, st.copy_done, st.image_ok, RC_RD); st.magic, st.copy_done, st.image_ok, RC_RD);
@ -158,19 +166,39 @@ static void render_task(void *p_arg)
(void) p_arg; (void) p_arg;
/* Безусловная первая отрисовка — не ждём первого diff (см. ui/fallback.h). */ /* Безусловная первая отрисовка — не ждём первого diff (см. ui/fallback.h). */
const sul_result_t initial = sul_default_state(); const sul_result_t INITIAL = sul_default_state();
ui_fallback_render_initial(&initial); ui_fallback_render_initial(&INITIAL);
render_msg_t msg; render_msg_t msg;
for (;;) for (;;)
{ {
if (xQueueReceive(s_render_queue, &msg, portMAX_DELAY) == pdTRUE) if (xQueueReceive(g_s_render_queue, &msg, portMAX_DELAY) == pdTRUE)
{ {
ui_fallback_render(&msg.task, &msg.result); ui_fallback_render(&msg.task, &msg.result);
} }
} }
} }
/* TEMP (Фаза 3.1): проверка save/персист через power cycle. Первый запуск —
* пишет сентинел и просит перезагрузку; после перезагрузки load() читает его с
* флеша «PERSIST OK». Сентинел остаётся (инертен), затирается первым
* сохранением из меню (3.2) или factory-reset. УБРАТЬ вместе с #define в 3.2. */
static void settings_selftest(void)
{
settings_t *p_s = settings_store_get_mutable();
if (p_s->user.max_load_kg == SETTINGS_SELFTEST_SENTINEL)
{
LOG_I(LOG_TAG, "settings self-test: PERSIST OK (сентинел пережил перезагрузку)");
return;
}
p_s->user.max_load_kg = SETTINGS_SELFTEST_SENTINEL;
const bsp_status_t RC = settings_store_save();
LOG_I(LOG_TAG,
"settings self-test: записан сентинел rc=%d — СДЕЛАЙТЕ POWER-CYCLE для проверки персиста",
RC);
}
static void sul_rx_task(void *p_arg) static void sul_rx_task(void *p_arg)
{ {
(void) p_arg; (void) p_arg;
@ -190,6 +218,7 @@ static void sul_rx_task(void *p_arg)
const bsp_status_t S_RC = settings_store_load(); const bsp_status_t S_RC = settings_store_load();
LOG_I(LOG_TAG, "settings: load rc=%d proto_addr=%u", S_RC, LOG_I(LOG_TAG, "settings: load rc=%d proto_addr=%u", S_RC,
settings_store_get()->user.proto_slice[0]); settings_store_get()->user.proto_slice[0]);
settings_selftest(); /* TEMP (3.1): HW-проверка save/персист, убрать в 3.2 */
} }
else else
{ {
@ -220,11 +249,11 @@ static void sul_rx_task(void *p_arg)
controller_ctx_t ctrl_ctx; controller_ctx_t ctrl_ctx;
controller_init(&ctrl_ctx); controller_init(&ctrl_ctx);
const TickType_t FEED_PERIOD = pdMS_TO_TICKS(WDOG_FEED_PERIOD_MS); const TickType_t FEED_PERIOD = pdMS_TO_TICKS(WDOG_FEED_PERIOD_MS);
uint32_t elapsed_ms = 0U; uint32_t elapsed_ms = 0U;
uint32_t since_status_ms = 0U; uint32_t since_status_ms = 0U;
TickType_t last_wake = xTaskGetTickCount(); TickType_t last_wake = xTaskGetTickCount();
TickType_t last_frame_tick = xTaskGetTickCount(); TickType_t last_frame_tick = xTaskGetTickCount();
for (;;) for (;;)
{ {
@ -277,8 +306,8 @@ static void sul_rx_task(void *p_arg)
const indication_task_t DIFF = controller_process(&ctrl_ctx, &decoded); const indication_task_t DIFF = controller_process(&ctrl_ctx, &decoded);
if (DIFF.pos_pending || DIFF.direction_pending) if (DIFF.pos_pending || DIFF.direction_pending)
{ {
const render_msg_t MSG = {.task = DIFF, .result = decoded}; const render_msg_t MSG = { .task = DIFF, .result = decoded };
(void) xQueueOverwrite(s_render_queue, &MSG); (void) xQueueOverwrite(g_s_render_queue, &MSG);
} }
} }
} }
@ -292,8 +321,8 @@ int main(void)
board_hw_init(); /* BOARD_ConfigMPU + BOARD_InitPins + BOARD_BootClockRUN */ board_hw_init(); /* BOARD_ConfigMPU + BOARD_InitPins + BOARD_BootClockRUN */
bsp_led_init(); bsp_led_init();
s_render_queue = xQueueCreate(1, sizeof(render_msg_t)); g_s_render_queue = xQueueCreate(1, sizeof(render_msg_t));
configASSERT(s_render_queue != NULL); configASSERT(g_s_render_queue != NULL);
/* sul_rx выше приоритетом render — приём CAN/WDOG важнее своевременности /* sul_rx выше приоритетом render — приём CAN/WDOG важнее своевременности
* перерисовки. Стеки x6 (3 КБ) с запасом (см. PLAN.md, Фаза 0 тонкий * перерисовки. Стеки x6 (3 КБ) с запасом (см. PLAN.md, Фаза 0 тонкий

View file

@ -0,0 +1,7 @@
# menu — движок меню настроек (§8). Фаза 3.2.1: чистая модель (без рендера/QSPI).
add_library(tft_app_menu STATIC src/menu.c)
target_include_directories(tft_app_menu PUBLIC include/)
# settings_store — только типы settings_t (заголовок чистый, без QSPI-кода).
target_link_libraries(tft_app_menu PUBLIC tft_app_settings_store)

View file

@ -0,0 +1,110 @@
/**
* @file menu.h
* @brief Движок меню настроек (ARCH.md §8) ЧИСТАЯ модель, без рендера и QSPI.
*
* Дерево пунктов ДАННЫЕ (плоский массив + индексы parent/child). Редакторы
* подключаются по типу (menu_item_type_t) добавить причудливый параметр =
* добавить тип + строку дерева, движок не меняется (§8, требование
* расширяемости под клиента).
*
* Модель ОПЕРИРУЕТ ПЕРЕДАННЫМ `settings_t*` (мутирует RAM на месте) и **сама не
* сохраняет** на выходе-с-сохранением выставляет `save_requested`, а
* `settings_store_save()` вызывает app-слой. Это держит модель host-тестируемой
* (без QSPI) и разделяет логику/side-effect по слоям.
*
* Навигация: BUTTON_1 menu_next() (следующий пункт уровня, с заворотом);
* короткое BUTTON_2 menu_action() (вход в подменю / инкремент значения /
* выход-с-сохранением). Вход в меню (долгое BUTTON_2) и модальность уровень app.
*
* Соглашение: items[MENU_ROOT_INDEX] корневое SUBMENU; его дети верхний
* уровень (их parent == MENU_ROOT_INDEX). Дети одного уровня непрерывный
* диапазон [first_child..last_child].
*/
#ifndef MENU_MENU_H_
#define MENU_MENU_H_
#include "services/settings_store.h"
#include <stdbool.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C"
{
#endif
#define MENU_ITEMS_PER_PAGE 6U /* строк на экране (окно 480×272, §PLAN 3.2) */
#define MENU_ROOT_INDEX 0U /* items[0] — корневое SUBMENU */
/** Тип пункта = редактор. Расширяется добавлением значения (Фазы 5/6:
* ARRAY/SERIAL/YEAR/PERCENT/BOOL_ARRAY) движок не меняется. */
typedef enum
{
MENU_SUBMENU, /**< вход в подменю [first_child..last_child] */
MENU_BACK, /**< возврат уровнем выше; в корне — выход (+save) */
MENU_SELECT, /**< выбор из списка 0..max (напр. протокол) */
MENU_BYTE, /**< число min..max (напр. адрес) */
MENU_BOOL, /**< да/нет */
} menu_item_type_t;
/**
* @brief Пункт меню данные. Привязка к настройке байтовый offset uint8-поля
* в settings_t (для SELECT/BYTE/BOOL). Для SUBMENU/BACK offset игнорируется.
*/
typedef struct
{
const char *label;
menu_item_type_t type;
uint16_t value_offset; /**< offsetof(settings_t, <uint8-поле>) */
uint8_t min; /**< для SELECT/BYTE/BOOL */
uint8_t max;
uint8_t parent; /**< индекс родителя (MENU_ROOT_INDEX — верхний уровень) */
uint8_t first_child; /**< для SUBMENU — диапазон детей */
uint8_t last_child;
} menu_item_desc_t;
/** Состояние навигации — чистое. */
typedef struct
{
const menu_item_desc_t *items;
uint8_t count;
settings_t *settings; /**< мутируется на месте */
uint8_t cur; /**< индекс выделенного пункта */
uint8_t page; /**< страница для рендера */
bool open;
bool dirty; /**< значение менялось */
bool save_requested; /**< выход-с-сохранением: app зовёт save() */
} menu_ctx_t;
/** Инициализировать (меню закрыто). @p items[MENU_ROOT_INDEX] — корневое SUBMENU. */
void menu_init(menu_ctx_t *p_ctx, const menu_item_desc_t *p_items, uint8_t count,
settings_t *p_settings);
/** Открыть меню: курсор на первый пункт верхнего уровня, флаги сброшены. */
void menu_open(menu_ctx_t *p_ctx);
/** BUTTON_1: следующий пункт текущего уровня (заворот с последнего на первый). */
void menu_next(menu_ctx_t *p_ctx);
/** Короткое BUTTON_2: вход в подменю / инкремент значения / выход-с-сохранением. */
void menu_action(menu_ctx_t *p_ctx);
bool menu_is_open(const menu_ctx_t *p_ctx);
/* ── Запросы для рендера ─────────────────────────────────────────────────── */
/** Индекс выделенного пункта. */
uint8_t menu_current(const menu_ctx_t *p_ctx);
/** Диапазон пунктов текущего уровня (соседи выделенного) — для отрисовки списка. */
void menu_level_range(const menu_ctx_t *p_ctx, uint8_t *p_first, uint8_t *p_last);
/** Текущее uint8-значение editable-пункта (SELECT/BYTE/BOOL) для отрисовки. */
uint8_t menu_read_value(const menu_ctx_t *p_ctx, uint8_t idx);
#ifdef __cplusplus
}
#endif
#endif /* MENU_MENU_H_ */

View file

@ -0,0 +1,123 @@
#include "menu/menu.h"
/* Указатель на uint8-поле настройки, к которому привязан пункт idx. */
static uint8_t *field_ptr(const menu_ctx_t *p_ctx, uint8_t idx)
{
return (uint8_t *) p_ctx->settings + p_ctx->items[idx].value_offset;
}
static uint8_t page_of(const menu_ctx_t *p_ctx, uint8_t idx, uint8_t first)
{
return (uint8_t) ((idx - first) / MENU_ITEMS_PER_PAGE);
}
void menu_init(menu_ctx_t *p_ctx, const menu_item_desc_t *p_items, uint8_t count,
settings_t *p_settings)
{
p_ctx->items = p_items;
p_ctx->count = count;
p_ctx->settings = p_settings;
p_ctx->cur = MENU_ROOT_INDEX;
p_ctx->page = 0U;
p_ctx->open = false;
p_ctx->dirty = false;
p_ctx->save_requested = false;
}
void menu_open(menu_ctx_t *p_ctx)
{
p_ctx->open = true;
p_ctx->dirty = false;
p_ctx->save_requested = false;
p_ctx->cur = p_ctx->items[MENU_ROOT_INDEX].first_child; /* первый пункт верхнего уровня */
p_ctx->page = 0U;
}
bool menu_is_open(const menu_ctx_t *p_ctx)
{
return p_ctx->open;
}
uint8_t menu_current(const menu_ctx_t *p_ctx)
{
return p_ctx->cur;
}
void menu_level_range(const menu_ctx_t *p_ctx, uint8_t *p_first, uint8_t *p_last)
{
const uint8_t PARENT = p_ctx->items[p_ctx->cur].parent;
*p_first = p_ctx->items[PARENT].first_child;
*p_last = p_ctx->items[PARENT].last_child;
}
uint8_t menu_read_value(const menu_ctx_t *p_ctx, uint8_t idx)
{
return *field_ptr(p_ctx, idx);
}
void menu_next(menu_ctx_t *p_ctx)
{
if (!p_ctx->open)
{
return;
}
uint8_t first;
uint8_t last;
menu_level_range(p_ctx, &first, &last);
p_ctx->cur = (p_ctx->cur >= last) ? first : (uint8_t) (p_ctx->cur + 1U); /* заворот */
p_ctx->page = page_of(p_ctx, p_ctx->cur, first);
}
/* Инкремент editable-значения с заворотом min→max→min. */
static void cycle_value(menu_ctx_t *p_ctx)
{
const menu_item_desc_t *p_it = &p_ctx->items[p_ctx->cur];
uint8_t *p_v = field_ptr(p_ctx, p_ctx->cur);
*p_v = (*p_v >= p_it->max) ? p_it->min : (uint8_t) (*p_v + 1U);
p_ctx->dirty = true;
}
void menu_action(menu_ctx_t *p_ctx)
{
if (!p_ctx->open)
{
return;
}
const menu_item_desc_t *p_it = &p_ctx->items[p_ctx->cur];
switch (p_it->type)
{
case MENU_SUBMENU:
p_ctx->cur = p_it->first_child;
p_ctx->page = 0U;
break;
case MENU_BACK:
if (p_it->parent == MENU_ROOT_INDEX)
{
/* Корневой выход — сохранить, если что-то менялось. */
p_ctx->save_requested = p_ctx->dirty;
p_ctx->open = false;
}
else
{
/* Вернуться к пункту-подменю, из которого вошли. */
p_ctx->cur = p_it->parent;
uint8_t first;
uint8_t last;
menu_level_range(p_ctx, &first, &last);
p_ctx->page = page_of(p_ctx, p_ctx->cur, first);
}
break;
case MENU_SELECT:
case MENU_BYTE:
case MENU_BOOL:
cycle_value(p_ctx);
break;
}
}

View file

@ -367,3 +367,15 @@ add_host_test(
${TFT_APP_SETTINGS_DIR}/src ${TFT_APP_SETTINGS_DIR}/src
${PROJECT_SOURCE_DIR}/bsp/qspi_flash/include ${PROJECT_SOURCE_DIR}/bsp/qspi_flash/include
${PROJECT_SOURCE_DIR}/bsp/common/include) ${PROJECT_SOURCE_DIR}/bsp/common/include)
# tft_app — menu (Фаза 3.2.1: чистая модель навигации/редактирования, без QSPI)
add_host_test(
NAME
test_tft_app_menu
SOURCES
tft_app_menu/test_menu.c
${PROJECT_SOURCE_DIR}/firmware/tft_app/src/menu/src/menu.c
INCLUDES
${PROJECT_SOURCE_DIR}/firmware/tft_app/src/menu/include
${TFT_APP_SETTINGS_DIR}/include
${PROJECT_SOURCE_DIR}/bsp/common/include)

View file

@ -0,0 +1,43 @@
# test_tft_app_controller
## Модуль под тестом
`firmware/tft_app/src/domain/controller/src/controller.c` — редьюсер
`sul_result_t` (+кэш) → `indication_task_t` (diff). Плюс
`controller/src/mode_priority.c` (свёртка сигналов в экранный режим) и
`elevator_model.c`.
## Категория
A — платформонезависимый, без HAL-вызовов и моков.
## Что проверяется
**Diff контроллера:**
- Кэш засеян дефолтом при init: результат, равный дефолту, — не изменение.
- Раздельные pending-флаги (`pos`, `next`, `direction`, `mode`) — помечается
только реально изменившееся поле.
- Таймаут связи — тем же путём, что обычный кадр (`controller_process()` с
`sul_default_state()`), без отдельного API.
- `arrival_pending` / `movement_pending` — по **фронту** `false→true` (уровень
держится → повторно не взводится).
**Таблица приоритетов (`sul_resolve_mode`):**
- Нет сигналов → `SUL_MODE_NORMAL`.
- Каждый одиночный сигнал → свой режим.
- Порядок при одновременной активности: fireman > пожар > перегруз > сейсмо >
сервис > погрузка (проверка каждой соседней пары).
- `mode_pending` поднимается на смену **разрешённого** режима, а не сырого
сигнала (перегруз при активном пожаре режим не меняет → `mode_pending` = false).
## Гарантии
- Приоритет определяется порядком строк таблицы-данных, не значениями enum.
- `indication_task_t.mode` несёт разрешённый режим нового результата (презентация
читает его при `mode_pending`).
## Запуск
```bash
ctest --preset host-debug-test -R test_tft_app_controller -V
```

View file

@ -1,8 +1,6 @@
/** /**
* @file test_controller.c * @file test_controller.c
* @brief Host unit-тесты controller.c diff между кэшем и новым sul_result_t. * @brief Host unit-тесты controller.c + mode_priority.c. См. README.md.
*
* Чистый C, без единого HAL-вызова fff-моков не нужно.
*/ */
#include "unity.h" #include "unity.h"
@ -23,14 +21,14 @@ static sul_result_t make_result(const char *p_pos, sul_direction_t dir)
return r; return r;
} }
/* Кэш засеян дефолтом при init — результат, совпадающий с дефолтом, не /* ── Diff контроллера ────────────────────────────────────────────────── */
* считается изменением (см. докстрок controller_init()). */
static void test_first_frame_matching_default_has_no_pending(void) static void test_first_frame_matching_default_has_no_pending(void)
{ {
controller_ctx_t ctx; controller_ctx_t ctx;
controller_init(&ctx); controller_init(&ctx);
const sul_result_t r = sul_default_state(); const sul_result_t r = sul_default_state();
const indication_task_t task = controller_process(&ctx, &r); const indication_task_t task = controller_process(&ctx, &r);
TEST_ASSERT_FALSE(task.pos_pending); TEST_ASSERT_FALSE(task.pos_pending);
@ -93,8 +91,6 @@ static void test_only_direction_change_marks_only_direction_pending(void)
TEST_ASSERT_TRUE(task.direction_pending); TEST_ASSERT_TRUE(task.direction_pending);
} }
/* Таймаут связи = caller зовёт controller_process(sul_default_state()) тем
* же путём, что и обычный кадр отдельного API не существует (см. header). */
static void test_feeding_default_state_after_real_data_marks_both_pending(void) static void test_feeding_default_state_after_real_data_marks_both_pending(void)
{ {
controller_ctx_t ctx; controller_ctx_t ctx;
@ -122,60 +118,58 @@ static void test_resolve_mode_single_signals(void)
{ {
sul_result_t r; sul_result_t r;
r = sul_default_state(); r = sul_default_state();
r.fireman = true; r.fireman = true;
TEST_ASSERT_EQUAL(SUL_MODE_FIREMAN, sul_resolve_mode(&r)); TEST_ASSERT_EQUAL(SUL_MODE_FIREMAN, sul_resolve_mode(&r));
r = sul_default_state(); r = sul_default_state();
r.fire_alarm = true; r.fire_alarm = true;
TEST_ASSERT_EQUAL(SUL_MODE_FIRE_ALARM, sul_resolve_mode(&r)); TEST_ASSERT_EQUAL(SUL_MODE_FIRE_ALARM, sul_resolve_mode(&r));
r = sul_default_state(); r = sul_default_state();
r.overload = true; r.overload = true;
TEST_ASSERT_EQUAL(SUL_MODE_OVERLOAD, sul_resolve_mode(&r)); TEST_ASSERT_EQUAL(SUL_MODE_OVERLOAD, sul_resolve_mode(&r));
r = sul_default_state(); r = sul_default_state();
r.seismic = true; r.seismic = true;
TEST_ASSERT_EQUAL(SUL_MODE_SEISMIC, sul_resolve_mode(&r)); TEST_ASSERT_EQUAL(SUL_MODE_SEISMIC, sul_resolve_mode(&r));
r = sul_default_state(); r = sul_default_state();
r.maintenance = true; r.maintenance = true;
TEST_ASSERT_EQUAL(SUL_MODE_MAINTENANCE, sul_resolve_mode(&r)); TEST_ASSERT_EQUAL(SUL_MODE_MAINTENANCE, sul_resolve_mode(&r));
r = sul_default_state(); r = sul_default_state();
r.lading = true; r.lading = true;
TEST_ASSERT_EQUAL(SUL_MODE_LADING, sul_resolve_mode(&r)); TEST_ASSERT_EQUAL(SUL_MODE_LADING, sul_resolve_mode(&r));
} }
/* Согласованный порядок: fireman > пожар > перегруз > сейсмо > сервис >
* погрузка. Проверяем каждую соседнюю пару при одновременной активности. */
static void test_resolve_mode_priority_ordering(void) static void test_resolve_mode_priority_ordering(void)
{ {
sul_result_t r; sul_result_t r;
r = sul_default_state(); r = sul_default_state();
r.fireman = true; r.fireman = true;
r.fire_alarm = true; /* fireman выигрывает у пожара */ r.fire_alarm = true;
TEST_ASSERT_EQUAL(SUL_MODE_FIREMAN, sul_resolve_mode(&r)); TEST_ASSERT_EQUAL(SUL_MODE_FIREMAN, sul_resolve_mode(&r));
r = sul_default_state(); r = sul_default_state();
r.fire_alarm = true; r.fire_alarm = true;
r.overload = true; /* пожар выигрывает у перегруза */ r.overload = true;
TEST_ASSERT_EQUAL(SUL_MODE_FIRE_ALARM, sul_resolve_mode(&r)); TEST_ASSERT_EQUAL(SUL_MODE_FIRE_ALARM, sul_resolve_mode(&r));
r = sul_default_state(); r = sul_default_state();
r.overload = true; r.overload = true;
r.seismic = true; /* перегруз выигрывает у сейсмо */ r.seismic = true;
TEST_ASSERT_EQUAL(SUL_MODE_OVERLOAD, sul_resolve_mode(&r)); TEST_ASSERT_EQUAL(SUL_MODE_OVERLOAD, sul_resolve_mode(&r));
r = sul_default_state(); r = sul_default_state();
r.seismic = true; r.seismic = true;
r.maintenance = true; /* сейсмо выигрывает у сервиса */ r.maintenance = true;
TEST_ASSERT_EQUAL(SUL_MODE_SEISMIC, sul_resolve_mode(&r)); TEST_ASSERT_EQUAL(SUL_MODE_SEISMIC, sul_resolve_mode(&r));
r = sul_default_state(); r = sul_default_state();
r.maintenance = true; r.maintenance = true;
r.lading = true; /* сервис выигрывает у погрузки */ r.lading = true;
TEST_ASSERT_EQUAL(SUL_MODE_MAINTENANCE, sul_resolve_mode(&r)); TEST_ASSERT_EQUAL(SUL_MODE_MAINTENANCE, sul_resolve_mode(&r));
} }
@ -190,17 +184,15 @@ static void test_mode_pending_on_mode_change(void)
(void) snprintf(normal.pos, SUL_POS_BUF_LEN, "5"); (void) snprintf(normal.pos, SUL_POS_BUF_LEN, "5");
(void) controller_process(&ctx, &normal); (void) controller_process(&ctx, &normal);
sul_result_t fire = normal; sul_result_t fire = normal;
fire.fire_alarm = true; fire.fire_alarm = true;
const indication_task_t task = controller_process(&ctx, &fire); const indication_task_t task = controller_process(&ctx, &fire);
TEST_ASSERT_TRUE(task.mode_pending); TEST_ASSERT_TRUE(task.mode_pending);
TEST_ASSERT_EQUAL(SUL_MODE_FIRE_ALARM, task.mode); TEST_ASSERT_EQUAL(SUL_MODE_FIRE_ALARM, task.mode);
TEST_ASSERT_FALSE(task.pos_pending); /* позиция не менялась */ TEST_ASSERT_FALSE(task.pos_pending);
} }
/* Смена сырого сигнала, не меняющая РАЗРЕШЁННЫЙ режим (перегруз при активном
* пожаре), не должна поднимать mode_pending. */
static void test_mode_pending_stable_when_resolved_mode_unchanged(void) static void test_mode_pending_stable_when_resolved_mode_unchanged(void)
{ {
controller_ctx_t ctx; controller_ctx_t ctx;
@ -211,8 +203,8 @@ static void test_mode_pending_stable_when_resolved_mode_unchanged(void)
(void) controller_process(&ctx, &fire); (void) controller_process(&ctx, &fire);
sul_result_t fire_plus_overload = fire; sul_result_t fire_plus_overload = fire;
fire_plus_overload.overload = true; /* режим остаётся FIRE_ALARM */ fire_plus_overload.overload = true;
const indication_task_t task = controller_process(&ctx, &fire_plus_overload); const indication_task_t task = controller_process(&ctx, &fire_plus_overload);
TEST_ASSERT_FALSE(task.mode_pending); TEST_ASSERT_FALSE(task.mode_pending);
TEST_ASSERT_EQUAL(SUL_MODE_FIRE_ALARM, task.mode); TEST_ASSERT_EQUAL(SUL_MODE_FIRE_ALARM, task.mode);
@ -226,14 +218,13 @@ static void test_arrival_and_movement_pending_on_rising_edge(void)
sul_result_t quiet = sul_default_state(); sul_result_t quiet = sul_default_state();
(void) controller_process(&ctx, &quiet); (void) controller_process(&ctx, &quiet);
sul_result_t event = sul_default_state(); sul_result_t event = sul_default_state();
event.arrival = true; event.arrival = true;
event.movement = true; event.movement = true;
indication_task_t task = controller_process(&ctx, &event); indication_task_t task = controller_process(&ctx, &event);
TEST_ASSERT_TRUE(task.arrival_pending); TEST_ASSERT_TRUE(task.arrival_pending);
TEST_ASSERT_TRUE(task.movement_pending); TEST_ASSERT_TRUE(task.movement_pending);
/* Уровень держится — фронта нет, pending не выставляется повторно. */
task = controller_process(&ctx, &event); task = controller_process(&ctx, &event);
TEST_ASSERT_FALSE(task.arrival_pending); TEST_ASSERT_FALSE(task.arrival_pending);
TEST_ASSERT_FALSE(task.movement_pending); TEST_ASSERT_FALSE(task.movement_pending);

View file

@ -0,0 +1,38 @@
# test_tft_app_menu
## Модуль под тестом
`firmware/tft_app/src/menu/src/menu.c`**чистая** модель меню (навигация,
редактирование, дерево-данные). Рендер и wiring кнопок — отдельно (не здесь).
## Категория
A — платформонезависимый. Движок оперирует переданным `settings_t*` (мутирует
RAM), QSPI не трогает — моки не нужны. Деревья меню строятся прямо в тесте.
## Что проверяется
**Плоское дерево** `[Протокол(SELECT), Адрес(BYTE), Логи(BOOL), Выход(BACK)]`:
- `open` ставит курсор на первый пункт верхнего уровня.
- `menu_next` идёт по уровню с **заворотом** (последний → первый).
- Инкремент `BYTE` (адрес 0..15) с заворотом `max→min`; `BOOL` — тумблер.
- Выход-с-сохранением: `save_requested` = `dirty` (true после правки, false без).
- `menu_next`/`menu_action` — no-op при закрытом меню.
**Дерево с подменю:**
- Вход в `SUBMENU` → первый ребёнок.
- `BACK` в подменю → возврат к пункту-подменю (не закрытие).
- Правка в подменю сохраняется в `settings_t`; выход из корня → `save_requested`.
## Гарантии
- Модель **сама не сохраняет** — только выставляет `save_requested`; фактический
`settings_store_save()` вызывает app-слой (разделение логики и side-effect).
- Правки применяются к переданному `settings_t` немедленно (RAM); на флеш —
единожды при выходе-с-сохранением.
## Запуск
```bash
ctest --preset host-debug-test -R test_tft_app_menu -V
```

View file

@ -0,0 +1,249 @@
/**
* @file test_menu.c
* @brief Host-тесты чистой модели меню (menu.c). См. README.md.
*/
#include "menu/menu.h"
#include "unity.h"
#include <stddef.h>
#include <string.h>
void setUp(void) {}
void tearDown(void) {}
/* ── Плоское дерево: Протокол / Адрес / Логи / Выход ─────────────────────── */
enum
{
A_ROOT = 0,
A_PROTO,
A_ADDR,
A_LOG,
A_EXIT,
A_COUNT,
};
static const menu_item_desc_t K_TREE_FLAT[A_COUNT] = {
[A_ROOT] = { "Настройки", MENU_SUBMENU, 0, 0, 0, MENU_ROOT_INDEX, A_PROTO, A_EXIT },
[A_PROTO] = { "Протокол", MENU_SELECT, offsetof(settings_t, device.protocol_id), 0, 1,
MENU_ROOT_INDEX, 0, 0 },
[A_ADDR] = { "Адрес", MENU_BYTE, offsetof(settings_t, user.proto_slice[0]), 0, 15,
MENU_ROOT_INDEX, 0, 0 },
[A_LOG] = { "Логи", MENU_BOOL, offsetof(settings_t, device.log_enabled), 0, 1,
MENU_ROOT_INDEX, 0, 0 },
[A_EXIT] = { "Выход", MENU_BACK, 0, 0, 0, MENU_ROOT_INDEX, 0, 0 },
};
static void open_flat(menu_ctx_t *p_ctx, settings_t *p_s)
{
memset(p_s, 0, sizeof(*p_s));
menu_init(p_ctx, K_TREE_FLAT, A_COUNT, p_s);
menu_open(p_ctx);
}
static void test_open_selects_first_toplevel(void)
{
menu_ctx_t ctx;
settings_t s;
open_flat(&ctx, &s);
TEST_ASSERT_TRUE(menu_is_open(&ctx));
TEST_ASSERT_EQUAL_UINT8(A_PROTO, menu_current(&ctx));
}
static void test_next_wraps_around_level(void)
{
menu_ctx_t ctx;
settings_t s;
open_flat(&ctx, &s);
menu_next(&ctx);
TEST_ASSERT_EQUAL_UINT8(A_ADDR, menu_current(&ctx));
menu_next(&ctx);
TEST_ASSERT_EQUAL_UINT8(A_LOG, menu_current(&ctx));
menu_next(&ctx);
TEST_ASSERT_EQUAL_UINT8(A_EXIT, menu_current(&ctx));
menu_next(&ctx);
TEST_ASSERT_EQUAL_UINT8(A_PROTO, menu_current(&ctx));
}
static void test_byte_increment_wraps(void)
{
menu_ctx_t ctx;
settings_t s;
open_flat(&ctx, &s);
menu_next(&ctx);
TEST_ASSERT_EQUAL_UINT8(A_ADDR, menu_current(&ctx));
for (uint8_t i = 0U; i < 15U; ++i)
{
menu_action(&ctx);
}
TEST_ASSERT_EQUAL_UINT8(15U, s.user.proto_slice[0]);
TEST_ASSERT_EQUAL_UINT8(15U, menu_read_value(&ctx, A_ADDR));
TEST_ASSERT_TRUE(ctx.dirty);
menu_action(&ctx);
TEST_ASSERT_EQUAL_UINT8(0U, s.user.proto_slice[0]);
}
static void test_bool_toggle(void)
{
menu_ctx_t ctx;
settings_t s;
open_flat(&ctx, &s);
s.device.log_enabled = 1U;
menu_next(&ctx);
menu_next(&ctx);
TEST_ASSERT_EQUAL_UINT8(A_LOG, menu_current(&ctx));
menu_action(&ctx);
TEST_ASSERT_EQUAL_UINT8(0U, s.device.log_enabled);
menu_action(&ctx);
TEST_ASSERT_EQUAL_UINT8(1U, s.device.log_enabled);
}
static void test_exit_saves_when_dirty(void)
{
menu_ctx_t ctx;
settings_t s;
open_flat(&ctx, &s);
menu_next(&ctx);
menu_action(&ctx);
menu_next(&ctx);
menu_next(&ctx);
TEST_ASSERT_EQUAL_UINT8(A_EXIT, menu_current(&ctx));
menu_action(&ctx);
TEST_ASSERT_FALSE(menu_is_open(&ctx));
TEST_ASSERT_TRUE(ctx.save_requested);
}
static void test_exit_no_save_when_clean(void)
{
menu_ctx_t ctx;
settings_t s;
open_flat(&ctx, &s);
menu_next(&ctx);
menu_next(&ctx);
menu_next(&ctx);
menu_action(&ctx);
TEST_ASSERT_FALSE(menu_is_open(&ctx));
TEST_ASSERT_FALSE(ctx.save_requested);
}
static void test_ops_noop_when_closed(void)
{
menu_ctx_t ctx;
settings_t s;
memset(&s, 0, sizeof(s));
menu_init(&ctx, K_TREE_FLAT, A_COUNT, &s);
menu_next(&ctx);
menu_action(&ctx);
TEST_ASSERT_FALSE(menu_is_open(&ctx));
TEST_ASSERT_FALSE(ctx.dirty);
}
/* ── Дерево с подменю ────────────────────────────────────────────────────── */
enum
{
B_ROOT = 0,
B_A,
B_SUB,
B_EXIT,
B_B,
B_BACK,
B_COUNT,
};
static const menu_item_desc_t K_TREE_SUB[B_COUNT] = {
[B_ROOT] = { "Настройки", MENU_SUBMENU, 0, 0, 0, MENU_ROOT_INDEX, B_A, B_EXIT },
[B_A] = { "A", MENU_BYTE, offsetof(settings_t, user.proto_slice[0]), 0, 9, MENU_ROOT_INDEX, 0,
0 },
[B_SUB] = { "Подменю", MENU_SUBMENU, 0, 0, 0, MENU_ROOT_INDEX, B_B, B_BACK },
[B_EXIT] = { "Выход", MENU_BACK, 0, 0, 0, MENU_ROOT_INDEX, 0, 0 },
[B_B] = { "B", MENU_BOOL, offsetof(settings_t, device.log_enabled), 0, 1, B_SUB, 0, 0 },
[B_BACK] = { "Назад", MENU_BACK, 0, 0, 0, B_SUB, 0, 0 },
};
static void open_sub(menu_ctx_t *p_ctx, settings_t *p_s)
{
memset(p_s, 0, sizeof(*p_s));
menu_init(p_ctx, K_TREE_SUB, B_COUNT, p_s);
menu_open(p_ctx);
}
static void test_enter_submenu(void)
{
menu_ctx_t ctx;
settings_t s;
open_sub(&ctx, &s);
menu_next(&ctx);
TEST_ASSERT_EQUAL_UINT8(B_SUB, menu_current(&ctx));
menu_action(&ctx);
TEST_ASSERT_EQUAL_UINT8(B_B, menu_current(&ctx));
}
static void test_back_from_submenu_returns_to_parent_item(void)
{
menu_ctx_t ctx;
settings_t s;
open_sub(&ctx, &s);
menu_next(&ctx);
menu_action(&ctx);
menu_next(&ctx);
TEST_ASSERT_EQUAL_UINT8(B_BACK, menu_current(&ctx));
menu_action(&ctx);
TEST_ASSERT_TRUE(menu_is_open(&ctx));
TEST_ASSERT_EQUAL_UINT8(B_SUB, menu_current(&ctx));
}
static void test_submenu_edit_persists_and_root_exit_saves(void)
{
menu_ctx_t ctx;
settings_t s;
open_sub(&ctx, &s);
s.device.log_enabled = 0U;
menu_next(&ctx);
menu_action(&ctx);
menu_action(&ctx);
TEST_ASSERT_EQUAL_UINT8(1U, s.device.log_enabled);
menu_next(&ctx);
menu_action(&ctx);
menu_next(&ctx);
TEST_ASSERT_EQUAL_UINT8(B_EXIT, menu_current(&ctx));
menu_action(&ctx);
TEST_ASSERT_FALSE(menu_is_open(&ctx));
TEST_ASSERT_TRUE(ctx.save_requested);
}
int main(void)
{
UNITY_BEGIN();
RUN_TEST(test_open_selects_first_toplevel);
RUN_TEST(test_next_wraps_around_level);
RUN_TEST(test_byte_increment_wraps);
RUN_TEST(test_bool_toggle);
RUN_TEST(test_exit_saves_when_dirty);
RUN_TEST(test_exit_no_save_when_clean);
RUN_TEST(test_ops_noop_when_closed);
RUN_TEST(test_enter_submenu);
RUN_TEST(test_back_from_submenu_returns_to_parent_item);
RUN_TEST(test_submenu_edit_persists_and_root_exit_saves);
return UNITY_END();
}

View file

@ -0,0 +1,36 @@
# test_tft_app_settings_store
## Модуль под тестом
`firmware/tft_app/src/services/settings_store/src/settings_codec.c` — **чистая**
сериализация ядра настроек (страница `magic`/`version`/`CRC32`, дефолты).
Flash-адаптер `settings_store.c` (load/save через `bsp_qspi_flash`) — не здесь,
он проверяется на железе (HIL).
## Категория
A — платформонезависимый. `bsp/qspi_flash.h` подключается только ради
геометрии (`BSP_QSPI_SECTOR_SIZE`) — это чистый заголовок без SDK, моки не нужны.
## Что проверяется
- Размер `settings_page_t` — ровно один сектор QSPI (дублирует `_Static_assert`).
- Дефолты вменяемы (протокол NKU, логи вкл, громкости, адрес `proto_slice[0]`=0).
- `serialize` проставляет `magic` (`STFT`) и `version`.
- **Round-trip**: `serialize`→`deserialize` сохраняет поля (вес, вместимость, год,
адрес, тумблер логов, серийник).
- **Отбраковка**: битый `magic` / несовпадающая `version` / испорченный байт
данных (расходится CRC) → `deserialize` возвращает false.
- Свежестёртая страница (вся `0xFF`) → невалидна.
## Гарантии
- CRC32 считается по всем байтам страницы, кроме самого поля `crc32`.
- Любая невалидность (`magic`/`version`/`CRC`) → false; вызывающий (`settings_store.c`)
подставляет дефолты, устройство не «кирпичится».
## Запуск
```bash
ctest --preset host-debug-test -R test_tft_app_settings_store -V
```

View file

@ -1,9 +1,6 @@
/** /**
* @file test_settings_codec.c * @file test_settings_codec.c
* @brief Host-тесты чистой сериализации настроек (settings_codec.c) без QSPI. * @brief Host-тесты settings_codec.c (сериализация ядра настроек). См. README.md.
*
* Формат страницы (magic/version/CRC32), дефолты, round-trip, отбраковка
* битых страниц. Flash-адаптер (settings_store.c) тестируется на железе.
*/ */
#include "settings_codec.h" #include "settings_codec.h"
@ -12,14 +9,9 @@
#include <stdio.h> #include <stdio.h>
#include <string.h> #include <string.h>
void setUp(void) void setUp(void) {}
{ void tearDown(void) {}
}
void tearDown(void)
{
}
/* Страница обязана быть ровно одним сектором (дублирует _Static_assert в .c). */
static void test_page_is_one_sector(void) static void test_page_is_one_sector(void)
{ {
TEST_ASSERT_EQUAL_UINT(BSP_QSPI_SECTOR_SIZE, sizeof(settings_page_t)); TEST_ASSERT_EQUAL_UINT(BSP_QSPI_SECTOR_SIZE, sizeof(settings_page_t));
@ -28,12 +20,12 @@ static void test_page_is_one_sector(void)
static void test_defaults_sane(void) static void test_defaults_sane(void)
{ {
const settings_t D = settings_defaults(); const settings_t D = settings_defaults();
TEST_ASSERT_EQUAL_UINT8(0U, D.device.protocol_id); /* NKU_CAN */ TEST_ASSERT_EQUAL_UINT8(0U, D.device.protocol_id);
TEST_ASSERT_EQUAL_UINT8(1U, D.device.log_enabled); /* логи вкл из коробки */ TEST_ASSERT_EQUAL_UINT8(1U, D.device.log_enabled);
TEST_ASSERT_EQUAL_UINT8(2U, D.user.sound_volume_idx); TEST_ASSERT_EQUAL_UINT8(2U, D.user.sound_volume_idx);
TEST_ASSERT_EQUAL_UINT8(1U, D.user.music_volume_idx); TEST_ASSERT_EQUAL_UINT8(1U, D.user.music_volume_idx);
TEST_ASSERT_EQUAL_UINT16(0U, D.user.max_load_kg); TEST_ASSERT_EQUAL_UINT16(0U, D.user.max_load_kg);
TEST_ASSERT_EQUAL_UINT8(0U, D.user.proto_slice[0]); /* адрес НКУ = 0 */ TEST_ASSERT_EQUAL_UINT8(0U, D.user.proto_slice[0]);
} }
static void test_serialize_sets_magic_version(void) static void test_serialize_sets_magic_version(void)
@ -47,12 +39,12 @@ static void test_serialize_sets_magic_version(void)
static void test_roundtrip_preserves_fields(void) static void test_roundtrip_preserves_fields(void)
{ {
settings_t in = settings_defaults(); settings_t in = settings_defaults();
in.user.max_load_kg = 1000U; in.user.max_load_kg = 1000U;
in.user.max_cap_persons = 8U; in.user.max_cap_persons = 8U;
in.user.year_production = 25U; in.user.year_production = 25U;
in.user.proto_slice[0] = 7U; /* адрес станции 7 */ in.user.proto_slice[0] = 7U;
in.device.log_enabled = 0U; in.device.log_enabled = 0U;
(void) snprintf(in.user.serial, SETTINGS_SERIAL_LEN, "AB1234"); (void) snprintf(in.user.serial, SETTINGS_SERIAL_LEN, "AB1234");
settings_page_t page; settings_page_t page;
@ -90,7 +82,6 @@ static void test_bad_version_rejected(void)
TEST_ASSERT_FALSE(settings_deserialize(&page, &out)); TEST_ASSERT_FALSE(settings_deserialize(&page, &out));
} }
/* Магик/версия валидны, но байт данных испорчен → расходится только CRC. */
static void test_bad_crc_rejected(void) static void test_bad_crc_rejected(void)
{ {
settings_t in = settings_defaults(); settings_t in = settings_defaults();
@ -102,7 +93,6 @@ static void test_bad_crc_rejected(void)
TEST_ASSERT_FALSE(settings_deserialize(&page, &out)); TEST_ASSERT_FALSE(settings_deserialize(&page, &out));
} }
/* Свежестёртый флеш (весь 0xFF) — невалиден (магик != 'STFT') → дефолты. */
static void test_erased_page_is_invalid(void) static void test_erased_page_is_invalid(void)
{ {
settings_page_t page; settings_page_t page;

View file

@ -0,0 +1,44 @@
# test_sul_nku_can
## Модуль под тестом
`firmware/tft_app/src/domain/sul/nku_can/src/nku_can.c` — чистый декодер
протокола НКУ-CAN (PACKET1..5 → `sul_result_t`). Плюс `elevator_model.c`
(`sul_default_state()`).
## Категория
A — платформонезависимый, без единого HAL-вызова. Golden-векторы CAN-кадров
строятся прямо в тесте, моки не нужны.
## Что проверяется
- **PACKET1** — направление (none/up/down/double), начало движения, коды режима
(пожар / сервис-МП1-МП2-ревизия / инструментальная погрузка / пожарный),
сброс устаревшего режима «нормальным» пакетом.
- **PACKET2 / PACKET4** — перегруз как **мультиисточник** (OR под-источников:
перегруз из PACKET4 переживает PACKET2-без-перегруза); сейсмоопасность (PACKET4).
- **PACKET3** — позиция кабины (двузначная, однозначная через пробел и через
байт 0, отрицательная, кириллица), `floor_num` (стандартный/отрицательный/
подвальный), гонг (активен при сброшенном бите), временная погрузка
(`lading_secs`), мультиисточник `lading` (временная + инструментальная).
- **PACKET5** — следующий этаж с гейтом: показывается только пока кабина едет и
назначение ≠ текущий уровень; гаснет на прибытии.
- **Адрес станции** — сдвиг ID пакетов (`base | addr<<4/<<6`); базовый ID адреса 0
становится чужим; клампинг адреса к 15.
- **Малформированные / чужие кадры** — неверный DLC → `SUL_STATUS_ERR`; код
символа вне таблицы → `ERR`; чужой ID → `SUL_STATUS_IGNORED` (выход не тронут).
- **Накопление состояния** между пакетами разных типов.
## Гарантии
- Декодер выдаёт **уровни, не события** (edge-детекция — забота презентации/аудио).
- Поля с несколькими источниками (overload, lading) — всегда OR под-источников:
пакет-без-сигнала не сбрасывает флаг, выставленный другим пакетом.
- `IGNORED`/`ERR` не трогают выходной `sul_result_t`.
## Запуск
```bash
ctest --preset host-debug-test -R test_sul_nku_can -V
```

View file

@ -1,9 +1,6 @@
/** /**
* @file test_sul_nku_can.c * @file test_sul_nku_can.c
* @brief Host unit-тесты декодера НКУ-CAN (Фаза 1: PACKET1 + PACKET3). * @brief Host unit-тесты декодера НКУ-CAN (PACKET1..5 + адрес). См. README.md.
*
* Чистый декодер, без единого HAL-вызова никаких fff-моков не нужно,
* golden-векторы CAN-кадров строятся прямо в тесте.
*/ */
#include "domain/sul/nku_can.h" #include "domain/sul/nku_can.h"
@ -11,24 +8,23 @@
#include <string.h> #include <string.h>
void setUp(void) void setUp(void) {}
{ void tearDown(void) {}
}
void tearDown(void)
{
}
/* ── Вспомогательные конструкторы кадров ─────────────────────────────── */ /* ── Конструкторы кадров ─────────────────────────────────────────────────── */
#define PACKET1_ID 0x506U #define PACKET1_ID 0x506U
#define PACKET2_ID 0x408U
#define PACKET3_ID 0x508U #define PACKET3_ID 0x508U
#define PACKET4_ID 0x50BU
#define PACKET5_ID 0x606U
#define UNKNOWN_ID 0x123U #define UNKNOWN_ID 0x123U
static sul_frame_t make_packet1(uint8_t arrow_bits) static sul_frame_t make_packet1(uint8_t arrow_bits)
{ {
static uint8_t s_data[8]; static uint8_t s_data[8];
memset(s_data, 0, sizeof(s_data)); memset(s_data, 0, sizeof(s_data));
s_data[6] = arrow_bits; /* ARROW_MASK=0x03 на data[6] (DATA7) */ s_data[6] = arrow_bits;
return (sul_frame_t){ .id = PACKET1_ID, .bus = 0, .p_data = s_data, .len = 8U }; return (sul_frame_t){ .id = PACKET1_ID, .bus = 0, .p_data = s_data, .len = 8U };
} }
@ -36,17 +32,11 @@ static sul_frame_t make_packet3(uint8_t left, uint8_t right)
{ {
static uint8_t s_data[8]; static uint8_t s_data[8];
memset(s_data, 0, sizeof(s_data)); memset(s_data, 0, sizeof(s_data));
s_data[5] = left; /* FLOOR_MASK=0x3F на data[5] (left) */ s_data[5] = left;
s_data[6] = right; /* FLOOR_MASK=0x3F на data[6] (right) */ s_data[6] = right;
return (sul_frame_t){ .id = PACKET3_ID, .bus = 0, .p_data = s_data, .len = 8U }; return (sul_frame_t){ .id = PACKET3_ID, .bus = 0, .p_data = s_data, .len = 8U };
} }
#define PACKET2_ID 0x408U
#define PACKET4_ID 0x50BU
#define PACKET5_ID 0x606U
/* PACKET1 полный: стрелка [1:0], начало движения [3:2], код режима [7:4] в
* data[6]; числовой уровень остановки data[3]&0x3F. */
static sul_frame_t make_packet1_full(uint8_t arrow, uint8_t movement, uint8_t icon_nibble, static sul_frame_t make_packet1_full(uint8_t arrow, uint8_t movement, uint8_t icon_nibble,
uint8_t level) uint8_t level)
{ {
@ -61,12 +51,10 @@ static sul_frame_t make_packet2(bool overload)
{ {
static uint8_t s_data[8]; static uint8_t s_data[8];
memset(s_data, 0, sizeof(s_data)); memset(s_data, 0, sizeof(s_data));
s_data[7] = overload ? 0x40U : 0x00U; /* WEIGHT_MASK */ s_data[7] = overload ? 0x40U : 0x00U;
return (sul_frame_t){ .id = PACKET2_ID, .bus = 0, .p_data = s_data, .len = 8U }; return (sul_frame_t){ .id = PACKET2_ID, .bus = 0, .p_data = s_data, .len = 8U };
} }
/* PACKET3 полный: символы этажа + гонг (активен при СБРОШЕННОМ бите 0x40 в
* data[3]) + временная погрузка (data[2] сек, data[3] низкий нибл мин). */
static sul_frame_t make_packet3_full(uint8_t left, uint8_t right, bool gong, uint8_t secs, static sul_frame_t make_packet3_full(uint8_t left, uint8_t right, bool gong, uint8_t secs,
uint8_t mins) uint8_t mins)
{ {
@ -83,8 +71,8 @@ static sul_frame_t make_packet4(bool overload, bool seismic)
{ {
static uint8_t s_data[8]; static uint8_t s_data[8];
memset(s_data, 0, sizeof(s_data)); memset(s_data, 0, sizeof(s_data));
s_data[5] = overload ? 0x40U : 0x00U; /* WEIGHT_MASK */ s_data[5] = overload ? 0x40U : 0x00U;
s_data[0] = seismic ? 0x80U : 0x00U; /* SEISMIC_MASK */ s_data[0] = seismic ? 0x80U : 0x00U;
return (sul_frame_t){ .id = PACKET4_ID, .bus = 0, .p_data = s_data, .len = 8U }; return (sul_frame_t){ .id = PACKET4_ID, .bus = 0, .p_data = s_data, .len = 8U };
} }
@ -98,16 +86,16 @@ static sul_frame_t make_packet5(uint8_t next_left, uint8_t next_right, uint8_t d
return (sul_frame_t){ .id = PACKET5_ID, .bus = 0, .p_data = s_data, .len = 8U }; return (sul_frame_t){ .id = PACKET5_ID, .bus = 0, .p_data = s_data, .len = 8U };
} }
/* ── PACKET1 — направление ───────────────────────────────────────────── */ /* ── PACKET1 — направление ───────────────────────────────────────────────── */
static void test_packet1_none(void) static void test_packet1_none(void)
{ {
nku_can_ctx_t ctx; nku_can_ctx_t ctx;
nku_can_init(&ctx); nku_can_init(&ctx);
sul_result_t out; sul_result_t out;
const sul_frame_t FRAME = make_packet1(0U); const sul_frame_t frame = make_packet1(0U);
TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &FRAME, &out)); TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &frame, &out));
TEST_ASSERT_EQUAL(SUL_DIR_NONE, out.direction); TEST_ASSERT_EQUAL(SUL_DIR_NONE, out.direction);
} }
@ -116,9 +104,9 @@ static void test_packet1_up(void)
nku_can_ctx_t ctx; nku_can_ctx_t ctx;
nku_can_init(&ctx); nku_can_init(&ctx);
sul_result_t out; sul_result_t out;
const sul_frame_t FRAME = make_packet1(1U); const sul_frame_t frame = make_packet1(1U);
TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &FRAME, &out)); TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &frame, &out));
TEST_ASSERT_EQUAL(SUL_DIR_UP, out.direction); TEST_ASSERT_EQUAL(SUL_DIR_UP, out.direction);
} }
@ -127,9 +115,9 @@ static void test_packet1_down(void)
nku_can_ctx_t ctx; nku_can_ctx_t ctx;
nku_can_init(&ctx); nku_can_init(&ctx);
sul_result_t out; sul_result_t out;
const sul_frame_t FRAME = make_packet1(2U); const sul_frame_t frame = make_packet1(2U);
TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &FRAME, &out)); TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &frame, &out));
TEST_ASSERT_EQUAL(SUL_DIR_DOWN, out.direction); TEST_ASSERT_EQUAL(SUL_DIR_DOWN, out.direction);
} }
@ -138,34 +126,33 @@ static void test_packet1_double(void)
nku_can_ctx_t ctx; nku_can_ctx_t ctx;
nku_can_init(&ctx); nku_can_init(&ctx);
sul_result_t out; sul_result_t out;
const sul_frame_t FRAME = make_packet1(3U); const sul_frame_t frame = make_packet1(3U);
TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &FRAME, &out)); TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &frame, &out));
TEST_ASSERT_EQUAL(SUL_DIR_DOUBLE, out.direction); TEST_ASSERT_EQUAL(SUL_DIR_DOUBLE, out.direction);
} }
/* Верхние биты data[6] (выше ARROW_MASK) не должны влиять на результат. */
static void test_packet1_ignores_bits_outside_mask(void) static void test_packet1_ignores_bits_outside_mask(void)
{ {
nku_can_ctx_t ctx; nku_can_ctx_t ctx;
nku_can_init(&ctx); nku_can_init(&ctx);
sul_result_t out; sul_result_t out;
const sul_frame_t FRAME = make_packet1(0xFCU | 1U); /* мусор в старших битах + arrow=1 */ const sul_frame_t frame = make_packet1(0xFCU | 1U);
TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &FRAME, &out)); TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &frame, &out));
TEST_ASSERT_EQUAL(SUL_DIR_UP, out.direction); TEST_ASSERT_EQUAL(SUL_DIR_UP, out.direction);
} }
/* ── PACKET3 — позиция ───────────────────────────────────────────────── */ /* ── PACKET3 — позиция ───────────────────────────────────────────────────── */
static void test_packet3_two_digit_floor(void) static void test_packet3_two_digit_floor(void)
{ {
nku_can_ctx_t ctx; nku_can_ctx_t ctx;
nku_can_init(&ctx); nku_can_init(&ctx);
sul_result_t out; sul_result_t out;
const sul_frame_t FRAME = make_packet3(1U, 2U); /* "1","2" -> "12" */ const sul_frame_t frame = make_packet3(1U, 2U);
TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &FRAME, &out)); TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &frame, &out));
TEST_ASSERT_EQUAL_STRING("12", out.pos); TEST_ASSERT_EQUAL_STRING("12", out.pos);
} }
@ -174,13 +161,12 @@ static void test_packet3_single_digit_via_space(void)
nku_can_ctx_t ctx; nku_can_ctx_t ctx;
nku_can_init(&ctx); nku_can_init(&ctx);
sul_result_t out; sul_result_t out;
const sul_frame_t FRAME = make_packet3(16U, 5U); /* left=SPACE, right="5" -> "5" */ const sul_frame_t frame = make_packet3(16U, 5U);
TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &FRAME, &out)); TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &frame, &out));
TEST_ASSERT_EQUAL_STRING("5", out.pos); TEST_ASSERT_EQUAL_STRING("5", out.pos);
} }
/* Легаси-квирк: left==0 трактуется как «пусто», как и SPACE — не «0» + right. */
static void test_packet3_single_digit_via_zero_byte(void) static void test_packet3_single_digit_via_zero_byte(void)
{ {
nku_can_ctx_t ctx; nku_can_ctx_t ctx;
@ -197,7 +183,7 @@ static void test_packet3_negative_floor(void)
nku_can_ctx_t ctx; nku_can_ctx_t ctx;
nku_can_init(&ctx); nku_can_init(&ctx);
sul_result_t out; sul_result_t out;
const sul_frame_t frame = make_packet3(22U, 1U); /* '-','1' -> "-1" */ const sul_frame_t frame = make_packet3(22U, 1U);
TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &frame, &out)); TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &frame, &out));
TEST_ASSERT_EQUAL_STRING("-1", out.pos); TEST_ASSERT_EQUAL_STRING("-1", out.pos);
@ -208,7 +194,7 @@ static void test_packet3_cyrillic_single_char(void)
nku_can_ctx_t ctx; nku_can_ctx_t ctx;
nku_can_init(&ctx); nku_can_init(&ctx);
sul_result_t out; sul_result_t out;
const sul_frame_t frame = make_packet3(16U, 17U); /* SPACE, symbol_P -> "П" */ const sul_frame_t frame = make_packet3(16U, 17U);
TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &frame, &out)); TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &frame, &out));
TEST_ASSERT_EQUAL_STRING("П", out.pos); TEST_ASSERT_EQUAL_STRING("П", out.pos);
@ -219,27 +205,27 @@ static void test_packet3_out_of_range_symbol_is_error(void)
nku_can_ctx_t ctx; nku_can_ctx_t ctx;
nku_can_init(&ctx); nku_can_init(&ctx);
sul_result_t out; sul_result_t out;
const sul_frame_t frame = make_packet3(1U, 63U); /* 63 > SYMBOL_TOTAL-1(37) */ const sul_frame_t frame = make_packet3(1U, 63U);
TEST_ASSERT_EQUAL(SUL_STATUS_ERR, nku_can_decode(&ctx, &frame, &out)); TEST_ASSERT_EQUAL(SUL_STATUS_ERR, nku_can_decode(&ctx, &frame, &out));
} }
/* ── Кадры не по протоколу ────────────────────────────────────────────── */ /* ── Кадры не по протоколу / малформированные ────────────────────────────── */
static void test_unknown_id_is_ignored_and_out_untouched(void) static void test_unknown_id_is_ignored_and_out_untouched(void)
{ {
nku_can_ctx_t ctx; nku_can_ctx_t ctx;
nku_can_init(&ctx); nku_can_init(&ctx);
sul_result_t out; sul_result_t out;
out.direction = SUL_DIR_UP; /* сентинел, decode() не должен его тронуть */ out.direction = SUL_DIR_UP;
(void) memset(out.pos, 'X', sizeof(out.pos)); (void) memset(out.pos, 'X', sizeof(out.pos));
uint8_t data[8] = { 0 }; uint8_t data[8] = { 0 };
const sul_frame_t frame = { .id = UNKNOWN_ID, .bus = 0, .p_data = data, .len = 8U }; const sul_frame_t frame = { .id = UNKNOWN_ID, .bus = 0, .p_data = data, .len = 8U };
TEST_ASSERT_EQUAL(SUL_STATUS_IGNORED, nku_can_decode(&ctx, &frame, &out)); TEST_ASSERT_EQUAL(SUL_STATUS_IGNORED, nku_can_decode(&ctx, &frame, &out));
TEST_ASSERT_EQUAL(SUL_DIR_UP, out.direction); /* не тронут */ TEST_ASSERT_EQUAL(SUL_DIR_UP, out.direction);
TEST_ASSERT_EQUAL_CHAR('X', out.pos[0]); /* не тронут */ TEST_ASSERT_EQUAL_CHAR('X', out.pos[0]);
} }
static void test_wrong_dlc_on_packet1_id_is_error(void) static void test_wrong_dlc_on_packet1_id_is_error(void)
@ -264,25 +250,37 @@ static void test_wrong_dlc_on_packet3_id_is_error(void)
TEST_ASSERT_EQUAL(SUL_STATUS_ERR, nku_can_decode(&ctx, &frame, &out)); TEST_ASSERT_EQUAL(SUL_STATUS_ERR, nku_can_decode(&ctx, &frame, &out));
} }
/* ── Накопление состояния между разными пакетами ─────────────────────── */ static void test_wrong_dlc_on_packet2_4_5_is_error(void)
{
nku_can_ctx_t ctx;
nku_can_init(&ctx);
sul_result_t out;
uint8_t data[8] = { 0 };
const sul_frame_t f2 = { .id = PACKET2_ID, .bus = 0, .p_data = data, .len = 6U };
TEST_ASSERT_EQUAL(SUL_STATUS_ERR, nku_can_decode(&ctx, &f2, &out));
const sul_frame_t f4 = { .id = PACKET4_ID, .bus = 0, .p_data = data, .len = 7U };
TEST_ASSERT_EQUAL(SUL_STATUS_ERR, nku_can_decode(&ctx, &f4, &out));
const sul_frame_t f5 = { .id = PACKET5_ID, .bus = 0, .p_data = data, .len = 4U };
TEST_ASSERT_EQUAL(SUL_STATUS_ERR, nku_can_decode(&ctx, &f5, &out));
}
/* ── Накопление состояния ────────────────────────────────────────────────── */
/* PACKET1 и PACKET3 несут разные поля — второй вызов обязан вернуть ПОЛНОЕ
* накопленное состояние (и pos, и direction), а не только то, что пришло в
* последнем кадре. Это ключевое свойство stateful-ctx декодера. */
static void test_state_accumulates_across_packet_types(void) static void test_state_accumulates_across_packet_types(void)
{ {
nku_can_ctx_t ctx; nku_can_ctx_t ctx;
nku_can_init(&ctx); nku_can_init(&ctx);
sul_result_t out; sul_result_t out;
const sul_frame_t f1 = make_packet1(1U); /* UP */ const sul_frame_t f1 = make_packet1(1U);
TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &f1, &out)); TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &f1, &out));
const sul_frame_t f3 = make_packet3(1U, 2U); /* "12" */ const sul_frame_t f3 = make_packet3(1U, 2U);
TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &f3, &out)); TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &f3, &out));
TEST_ASSERT_EQUAL_STRING("12", out.pos); TEST_ASSERT_EQUAL_STRING("12", out.pos);
TEST_ASSERT_EQUAL(SUL_DIR_UP, out.direction); /* пережило кадр PACKET3 */ TEST_ASSERT_EQUAL(SUL_DIR_UP, out.direction);
} }
static void test_init_resets_to_default(void) static void test_init_resets_to_default(void)
@ -294,7 +292,7 @@ static void test_init_resets_to_default(void)
TEST_ASSERT_EQUAL(SUL_DIR_NONE, ctx.state.direction); TEST_ASSERT_EQUAL(SUL_DIR_NONE, ctx.state.direction);
} }
/* ── PACKET1 — режимы (код нибла data[6][7:4]) и начало движения ──────── */ /* ── PACKET1 — режимы и начало движения ──────────────────────────────────── */
static void test_packet1_mode_fire(void) static void test_packet1_mode_fire(void)
{ {
@ -309,7 +307,7 @@ static void test_packet1_mode_fire(void)
static void test_packet1_mode_maintenance_variants(void) static void test_packet1_mode_maintenance_variants(void)
{ {
const uint8_t icons[] = { 0x30U, 0x50U, 0x40U }; /* МП1 / МП2 / ревизия */ const uint8_t icons[] = { 0x30U, 0x50U, 0x40U };
for (size_t i = 0U; i < sizeof(icons); ++i) for (size_t i = 0U; i < sizeof(icons); ++i)
{ {
nku_can_ctx_t ctx; nku_can_ctx_t ctx;
@ -343,7 +341,6 @@ static void test_packet1_mode_fireman(void)
TEST_ASSERT_TRUE(out.fireman); TEST_ASSERT_TRUE(out.fireman);
} }
/* Пакет без кода режима гасит устаревший режим (PACKET1-владелец). */
static void test_packet1_normal_clears_previous_mode(void) static void test_packet1_normal_clears_previous_mode(void)
{ {
nku_can_ctx_t ctx; nku_can_ctx_t ctx;
@ -374,7 +371,7 @@ static void test_packet1_movement_bits(void)
TEST_ASSERT_FALSE(out.movement); TEST_ASSERT_FALSE(out.movement);
} }
/* ── PACKET2 / PACKET4 — перегруз (мультиисточник) и сейсмо ───────────── */ /* ── PACKET2 / PACKET4 — перегруз (мультиисточник) и сейсмо ───────────────── */
static void test_packet2_overload_set_and_clear(void) static void test_packet2_overload_set_and_clear(void)
{ {
@ -391,8 +388,6 @@ static void test_packet2_overload_set_and_clear(void)
TEST_ASSERT_FALSE(out.overload); TEST_ASSERT_FALSE(out.overload);
} }
/* Ключевой мультиисточник: перегруз из PACKET4 не должен сбрасываться
* пакетом PACKET2 без перегруза (и наоборот) выход = OR под-источников. */
static void test_overload_multisource_independence(void) static void test_overload_multisource_independence(void)
{ {
nku_can_ctx_t ctx; nku_can_ctx_t ctx;
@ -405,11 +400,11 @@ static void test_overload_multisource_independence(void)
const sul_frame_t p2_off = make_packet2(false); const sul_frame_t p2_off = make_packet2(false);
TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &p2_off, &out)); TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &p2_off, &out));
TEST_ASSERT_TRUE(out.overload); /* PACKET4-перегруз пережил PACKET2-без-перегруза */ TEST_ASSERT_TRUE(out.overload);
const sul_frame_t p4_off = make_packet4(false, false); const sul_frame_t p4_off = make_packet4(false, false);
TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &p4_off, &out)); TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &p4_off, &out));
TEST_ASSERT_FALSE(out.overload); /* оба источника сняты */ TEST_ASSERT_FALSE(out.overload);
} }
static void test_packet4_seismic_set_and_clear(void) static void test_packet4_seismic_set_and_clear(void)
@ -427,7 +422,7 @@ static void test_packet4_seismic_set_and_clear(void)
TEST_ASSERT_FALSE(out.seismic); TEST_ASSERT_FALSE(out.seismic);
} }
/* ── PACKET3 — гонг, временная погрузка, floor_num ───────────────────── */ /* ── PACKET3 — гонг, временная погрузка, floor_num ───────────────────────── */
static void test_packet3_gong_active_when_bit_clear(void) static void test_packet3_gong_active_when_bit_clear(void)
{ {
@ -450,30 +445,26 @@ static void test_packet3_lading_time_seconds(void)
nku_can_init(&ctx); nku_can_init(&ctx);
sul_result_t out; sul_result_t out;
const sul_frame_t frame = make_packet3_full(1U, 2U, false, 30U, 1U); /* 1*60+30 */ const sul_frame_t frame = make_packet3_full(1U, 2U, false, 30U, 1U);
TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &frame, &out)); TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &frame, &out));
TEST_ASSERT_EQUAL_UINT16(90U, out.lading_secs); TEST_ASSERT_EQUAL_UINT16(90U, out.lading_secs);
TEST_ASSERT_TRUE(out.lading); /* временная погрузка тоже поднимает lading */ TEST_ASSERT_TRUE(out.lading);
} }
/* Мультиисточник lading: временная (PACKET3) держит режим, даже когда PACKET1
* не несёт инструментальной; и наоборот. */
static void test_lading_multisource_independence(void) static void test_lading_multisource_independence(void)
{ {
nku_can_ctx_t ctx; nku_can_ctx_t ctx;
nku_can_init(&ctx); nku_can_init(&ctx);
sul_result_t out; sul_result_t out;
/* Временная погрузка идёт, PACKET1 без режима — lading остаётся. */
const sul_frame_t timed = make_packet3_full(1U, 2U, false, 10U, 0U); const sul_frame_t timed = make_packet3_full(1U, 2U, false, 10U, 0U);
TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &timed, &out)); TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &timed, &out));
TEST_ASSERT_TRUE(out.lading); TEST_ASSERT_TRUE(out.lading);
const sul_frame_t p1_normal = make_packet1_full(0U, 0U, 0x00U, 0U); const sul_frame_t p1_normal = make_packet1_full(0U, 0U, 0x00U, 0U);
TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &p1_normal, &out)); TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &p1_normal, &out));
TEST_ASSERT_TRUE(out.lading); /* временная погрузка пережила «нормальный» PACKET1 */ TEST_ASSERT_TRUE(out.lading);
/* Отсчёт истёк — lading гаснет. */
const sul_frame_t timed_zero = make_packet3_full(1U, 2U, false, 0U, 0U); const sul_frame_t timed_zero = make_packet3_full(1U, 2U, false, 0U, 0U);
TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &timed_zero, &out)); TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &timed_zero, &out));
TEST_ASSERT_FALSE(out.lading); TEST_ASSERT_FALSE(out.lading);
@ -485,22 +476,22 @@ static void test_packet3_floor_num_derivation(void)
sul_result_t out; sul_result_t out;
nku_can_init(&ctx); nku_can_init(&ctx);
const sul_frame_t two_digit = make_packet3_full(1U, 2U, false, 0U, 0U); /* "12" */ const sul_frame_t two_digit = make_packet3_full(1U, 2U, false, 0U, 0U);
TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &two_digit, &out)); TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &two_digit, &out));
TEST_ASSERT_EQUAL_UINT8(12U, out.floor_num); TEST_ASSERT_EQUAL_UINT8(12U, out.floor_num);
nku_can_init(&ctx); nku_can_init(&ctx);
const sul_frame_t negative = make_packet3_full(22U, 1U, false, 0U, 0U); /* "-1" -> 41 */ const sul_frame_t negative = make_packet3_full(22U, 1U, false, 0U, 0U);
TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &negative, &out)); TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &negative, &out));
TEST_ASSERT_EQUAL_UINT8(41U, out.floor_num); TEST_ASSERT_EQUAL_UINT8(41U, out.floor_num);
nku_can_init(&ctx); nku_can_init(&ctx);
const sul_frame_t basement = make_packet3_full(17U, 1U, false, 0U, 0U); /* "П1" -> 51 */ const sul_frame_t basement = make_packet3_full(17U, 1U, false, 0U, 0U);
TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &basement, &out)); TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &basement, &out));
TEST_ASSERT_EQUAL_UINT8(51U, out.floor_num); TEST_ASSERT_EQUAL_UINT8(51U, out.floor_num);
} }
/* ── PACKET5 — следующий этаж (гейт: только пока едет и назначение ≠ уровень) */ /* ── PACKET5 — следующий этаж ────────────────────────────────────────────── */
static void test_packet5_next_shown_while_moving(void) static void test_packet5_next_shown_while_moving(void)
{ {
@ -508,10 +499,10 @@ static void test_packet5_next_shown_while_moving(void)
nku_can_init(&ctx); nku_can_init(&ctx);
sul_result_t out; sul_result_t out;
const sul_frame_t moving = make_packet1_full(1U, 1U, 0x00U, 5U); /* UP, уровень 5 */ const sul_frame_t moving = make_packet1_full(1U, 1U, 0x00U, 5U);
TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &moving, &out)); TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &moving, &out));
const sul_frame_t next = make_packet5(1U, 6U, 16U); /* "16", назначение 16 ≠ 5 */ const sul_frame_t next = make_packet5(1U, 6U, 16U);
TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &next, &out)); TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &next, &out));
TEST_ASSERT_EQUAL_STRING("16", out.next); TEST_ASSERT_EQUAL_STRING("16", out.next);
} }
@ -522,7 +513,7 @@ static void test_packet5_next_suppressed_when_not_moving(void)
nku_can_init(&ctx); nku_can_init(&ctx);
sul_result_t out; sul_result_t out;
const sul_frame_t still = make_packet1_full(0U, 0U, 0x00U, 5U); /* нет движения */ const sul_frame_t still = make_packet1_full(0U, 0U, 0x00U, 5U);
TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &still, &out)); TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &still, &out));
const sul_frame_t next = make_packet5(1U, 6U, 16U); const sul_frame_t next = make_packet5(1U, 6U, 16U);
@ -539,12 +530,11 @@ static void test_packet5_next_suppressed_when_dest_equals_level(void)
const sul_frame_t moving = make_packet1_full(1U, 1U, 0x00U, 5U); const sul_frame_t moving = make_packet1_full(1U, 1U, 0x00U, 5U);
TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &moving, &out)); TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &moving, &out));
const sul_frame_t next = make_packet5(1U, 6U, 5U); /* назначение == уровень */ const sul_frame_t next = make_packet5(1U, 6U, 5U);
TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &next, &out)); TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &next, &out));
TEST_ASSERT_EQUAL_STRING("", out.next); TEST_ASSERT_EQUAL_STRING("", out.next);
} }
/* Следующий этаж гаснет на прибытии (PACKET1 сообщает «нет движения»). */
static void test_packet5_next_cleared_on_arrival(void) static void test_packet5_next_cleared_on_arrival(void)
{ {
nku_can_ctx_t ctx; nku_can_ctx_t ctx;
@ -562,54 +552,35 @@ static void test_packet5_next_cleared_on_arrival(void)
TEST_ASSERT_EQUAL_STRING("", out.next); TEST_ASSERT_EQUAL_STRING("", out.next);
} }
static void test_wrong_dlc_on_packet2_4_5_is_error(void) /* ── Адрес станции: сдвиг ID пакетов ─────────────────────────────────────── */
{
nku_can_ctx_t ctx;
nku_can_init(&ctx);
sul_result_t out;
uint8_t data[8] = { 0 };
const sul_frame_t f2 = { .id = PACKET2_ID, .bus = 0, .p_data = data, .len = 6U };
TEST_ASSERT_EQUAL(SUL_STATUS_ERR, nku_can_decode(&ctx, &f2, &out));
const sul_frame_t f4 = { .id = PACKET4_ID, .bus = 0, .p_data = data, .len = 7U };
TEST_ASSERT_EQUAL(SUL_STATUS_ERR, nku_can_decode(&ctx, &f4, &out));
const sul_frame_t f5 = { .id = PACKET5_ID, .bus = 0, .p_data = data, .len = 4U };
TEST_ASSERT_EQUAL(SUL_STATUS_ERR, nku_can_decode(&ctx, &f5, &out));
}
/* ── Адрес станции (Фаза 3.1): сдвиг ID пакетов ──────────────────────────── */
/* Адрес N сдвигает ID: PACKET1 = 0x506 | (N<<4). Базовый ID адреса 0 при этом
* становится чужим (IGNORED). */
static void test_address_shifts_packet_ids(void) static void test_address_shifts_packet_ids(void)
{ {
nku_can_ctx_t ctx; nku_can_ctx_t ctx;
nku_can_init(&ctx); nku_can_init(&ctx);
nku_can_set_address(&ctx, 1U); /* group4 = 0x10 */ nku_can_set_address(&ctx, 1U);
sul_result_t out; sul_result_t out;
uint8_t data[8] = { 0 }; uint8_t data[8] = { 0 };
data[6] = 1U; /* стрелка вверх */ data[6] = 1U;
const sul_frame_t shifted = { .id = 0x506U | 0x10U, .bus = 0, .p_data = data, .len = 8U }; const sul_frame_t shifted = { .id = 0x506U | 0x10U, .bus = 0, .p_data = data, .len = 8U };
TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &shifted, &out)); TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &shifted, &out));
TEST_ASSERT_EQUAL(SUL_DIR_UP, out.direction); TEST_ASSERT_EQUAL(SUL_DIR_UP, out.direction);
/* Базовый ID адреса 0 теперь не наш. */
const sul_frame_t base = { .id = 0x506U, .bus = 0, .p_data = data, .len = 8U }; const sul_frame_t base = { .id = 0x506U, .bus = 0, .p_data = data, .len = 8U };
TEST_ASSERT_EQUAL(SUL_STATUS_IGNORED, nku_can_decode(&ctx, &base, &out)); TEST_ASSERT_EQUAL(SUL_STATUS_IGNORED, nku_can_decode(&ctx, &base, &out));
} }
/* Адрес > 15 клампится к 15 (не выходит за 4-битный group4). */
static void test_address_clamped_to_max(void) static void test_address_clamped_to_max(void)
{ {
nku_can_ctx_t ctx; nku_can_ctx_t ctx;
nku_can_init(&ctx); nku_can_init(&ctx);
nku_can_set_address(&ctx, 200U); /* клампится к 15 → group4 = 0xF0 */ nku_can_set_address(&ctx, 200U);
sul_result_t out; sul_result_t out;
uint8_t data[8] = { 0 }; uint8_t data[8] = { 0 };
data[6] = 2U; /* вниз */ data[6] = 2U;
const sul_frame_t f = { .id = 0x506U | 0xF0U, .bus = 0, .p_data = data, .len = 8U }; const sul_frame_t f = { .id = 0x506U | 0xF0U, .bus = 0, .p_data = data, .len = 8U };
TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &f, &out)); TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &f, &out));
@ -636,6 +607,7 @@ int main(void)
RUN_TEST(test_unknown_id_is_ignored_and_out_untouched); RUN_TEST(test_unknown_id_is_ignored_and_out_untouched);
RUN_TEST(test_wrong_dlc_on_packet1_id_is_error); RUN_TEST(test_wrong_dlc_on_packet1_id_is_error);
RUN_TEST(test_wrong_dlc_on_packet3_id_is_error); RUN_TEST(test_wrong_dlc_on_packet3_id_is_error);
RUN_TEST(test_wrong_dlc_on_packet2_4_5_is_error);
RUN_TEST(test_state_accumulates_across_packet_types); RUN_TEST(test_state_accumulates_across_packet_types);
RUN_TEST(test_init_resets_to_default); RUN_TEST(test_init_resets_to_default);
@ -661,8 +633,6 @@ int main(void)
RUN_TEST(test_packet5_next_suppressed_when_dest_equals_level); RUN_TEST(test_packet5_next_suppressed_when_dest_equals_level);
RUN_TEST(test_packet5_next_cleared_on_arrival); RUN_TEST(test_packet5_next_cleared_on_arrival);
RUN_TEST(test_wrong_dlc_on_packet2_4_5_is_error);
RUN_TEST(test_address_shifts_packet_ids); RUN_TEST(test_address_shifts_packet_ids);
RUN_TEST(test_address_clamped_to_max); RUN_TEST(test_address_clamped_to_max);