diff --git a/CMakePresets.json b/CMakePresets.json
index 5fea8c8..8233cb8 100644
--- a/CMakePresets.json
+++ b/CMakePresets.json
@@ -170,7 +170,9 @@
"test_recovery",
"test_tft_app_smoke",
"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_tft_app_smoke",
"test_sul_nku_can",
- "test_tft_app_controller"
+ "test_tft_app_controller",
+ "test_tft_app_settings_store",
+ "test_tft_app_menu"
]
},
{
diff --git a/docs/tft_app/MENU.md b/docs/tft_app/MENU.md
new file mode 100644
index 0000000..35f9581
--- /dev/null
+++ b/docs/tft_app/MENU.md
@@ -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
долгое BUTTON_2 / next / action"]
+ subgraph MODEL["menu (чистая модель, host-тест)"]
+ TREE["дерево-данные
menu_item_desc_t[]"]
+ NAV["навигация + редактор
menu_next / menu_action"]
+ end
+ SET["settings_t
(RAM, get_mutable)"]
+ APP["app-слой
(проверяет save_requested)"]
+ STORE["settings_store
save() → QSPI 0x450000"]
+ REND["рендер меню
окно 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_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
меню закрыто"]
+ T -->|"BACK, в подменю"| UP["курсор → пункт-подменю (возврат)"]
+ T -->|SELECT/BYTE/BOOL| EDIT["значение min..max с заворотом
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).
diff --git a/docs/tft_app/README.md b/docs/tft_app/README.md
index 0beab30..f13897b 100644
--- a/docs/tft_app/README.md
+++ b/docs/tft_app/README.md
@@ -9,6 +9,7 @@ Dev-документация по **реализованному** доменн
| [DOMAIN_DATAFLOW.md](DOMAIN_DATAFLOW.md) | Путь данных: транспорт → декодер → контроллер → презентация. Слои, контракт `decode()`, карта пакетов НКУ-CAN → поля модели, таймаут→default. |
| [MODE_PRIORITY.md](MODE_PRIORITY.md) | Свёртка ортогональных сигналов в один экранный режим. Таблица приоритетов как данные, резолвер, как менять/кастомизировать. |
| [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),
-ассеты и layout-движок со спрайтами (Фазы 4–5), аудио (Фаза 6), мультипротокол (Фаза 8). Удалённая
-установка адреса НКУ-CAN отложена в Фазу 3.
+Описаны домен, fallback-презентация, ядро настроек (`settings_store`) и модель меню.
+В процессе (Фаза 3): рендер меню + wiring кнопок (3.2.2/3.2.3), per-protocol дескрипторы (3.3),
+opto-входы (3.4), удалённая адресация НКУ-CAN (3.5). Ещё не реализовано (см. PLAN.md): ассеты и
+layout-движок со спрайтами (Фазы 4–5), аудио (Фаза 6), мультипротокол (Фаза 8).
diff --git a/firmware/tft_app/CMakeLists.txt b/firmware/tft_app/CMakeLists.txt
index e56c841..17ba1b0 100644
--- a/firmware/tft_app/CMakeLists.txt
+++ b/firmware/tft_app/CMakeLists.txt
@@ -49,6 +49,7 @@ target_compile_options(freertos_kernel PRIVATE -w)
add_subdirectory(src/domain)
add_subdirectory(src/services)
add_subdirectory(src/ui)
+add_subdirectory(src/menu)
# -----------------------------------------------------------------------------
# app — исполняемый образ слота
@@ -120,7 +121,9 @@ target_link_libraries(
tft_app_gfx
tft_app_ui_fallback
# Фаза 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(
app
diff --git a/firmware/tft_app/PLAN.md b/firmware/tft_app/PLAN.md
index e1cc3e9..20cf114 100644
--- a/firmware/tft_app/PLAN.md
+++ b/firmware/tft_app/PLAN.md
@@ -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.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.5–2 с); BUTTON_1=следующий, короткое BUTTON_2=выбор/инкремент. Опрос кнопок — задача 5 мс. Модальный экран. |
| **3.3 per-protocol дескрипторы** | `sul_settings_desc_t`: протокол регистрирует параметры (NKU-CAN: адрес 0..15); секция меню строится из дескриптора. |
| **3.4 opto-входы** | IN1/IN2 → вызов/ответ диспетчера → презентация (в fallback — примитив/текст; иконки-спрайты — Фаза 4/5). |
| **3.5 удалённая адресация NKU-CAN** | `0x4X1`/`0x5XB` → запись `nku_address` в настройки (долг Фазы 2). |
diff --git a/firmware/tft_app/src/app/main.c b/firmware/tft_app/src/app/main.c
index 6b970cd..45bfa74 100644
--- a/firmware/tft_app/src/app/main.c
+++ b/firmware/tft_app/src/app/main.c
@@ -54,15 +54,23 @@
* settings, см. ARCH §9 — TFT7/8/10 рантайм-выбор внутри профиля app-big). */
#define PANEL_TYPE BSP_DISPLAY_TFT8
-#define HEARTBEAT_PERIOD_MS 500U
-#define WDOG_FEED_PERIOD_MS 100U /* кормим чаще периода мигания — таймаут WDOG >= 1 c */
-#define STATUS_LOG_PERIOD_MS 2000U /* периодический re-log трейлера — виден независимо
+#define HEARTBEAT_PERIOD_MS 500U
+#define WDOG_FEED_PERIOD_MS 100U /* кормим чаще периода мигания — таймаут WDOG >= 1 c */
+#define STATUS_LOG_PERIOD_MS \
+ 2000U /* периодический re-log трейлера — виден независимо
* от момента подключения терминала */
-#define CAN_RX_TIMEOUT_MS 100U /* держит цикл отзывчивым к WDOG/heartbeat-каденции */
-#define CONNECTION_TIMEOUT_MS 3000U /* «пропадание трафика» — см. ARCH, поток данных:
+#define CAN_RX_TIMEOUT_MS 100U /* держит цикл отзывчивым к WDOG/heartbeat-каденции */
+#define CONNECTION_TIMEOUT_MS \
+ 3000U /* «пропадание трафика» — см. ARCH, поток данных:
* poll + timeout→default. Порядок величины — как
* в 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 самого свежего состояния — не истории. */
typedef struct
{
@@ -70,7 +78,7 @@ typedef struct
sul_result_t result;
} render_msg_t;
-static QueueHandle_t s_render_queue;
+static QueueHandle_t g_s_render_queue;
/* ── Диагностика трейлера слота (read-only, безопасно звать многократно) ── */
@@ -84,8 +92,8 @@ static void log_slot_status(const char *p_when)
return;
}
- struct boot_swap_state st = {0};
- const int RC_RD = boot_read_swap_state(p_fap, &st);
+ struct boot_swap_state st = { 0 };
+ 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,
st.magic, st.copy_done, st.image_ok, RC_RD);
@@ -158,19 +166,39 @@ static void render_task(void *p_arg)
(void) p_arg;
/* Безусловная первая отрисовка — не ждём первого diff (см. ui/fallback.h). */
- const sul_result_t initial = sul_default_state();
- ui_fallback_render_initial(&initial);
+ const sul_result_t INITIAL = sul_default_state();
+ ui_fallback_render_initial(&INITIAL);
render_msg_t msg;
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);
}
}
}
+/* 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)
{
(void) p_arg;
@@ -190,6 +218,7 @@ static void sul_rx_task(void *p_arg)
const bsp_status_t S_RC = settings_store_load();
LOG_I(LOG_TAG, "settings: load rc=%d proto_addr=%u", S_RC,
settings_store_get()->user.proto_slice[0]);
+ settings_selftest(); /* TEMP (3.1): HW-проверка save/персист, убрать в 3.2 */
}
else
{
@@ -220,11 +249,11 @@ static void sul_rx_task(void *p_arg)
controller_ctx_t ctrl_ctx;
controller_init(&ctrl_ctx);
- const TickType_t FEED_PERIOD = pdMS_TO_TICKS(WDOG_FEED_PERIOD_MS);
- uint32_t elapsed_ms = 0U;
- uint32_t since_status_ms = 0U;
- TickType_t last_wake = xTaskGetTickCount();
- TickType_t last_frame_tick = xTaskGetTickCount();
+ const TickType_t FEED_PERIOD = pdMS_TO_TICKS(WDOG_FEED_PERIOD_MS);
+ uint32_t elapsed_ms = 0U;
+ uint32_t since_status_ms = 0U;
+ TickType_t last_wake = xTaskGetTickCount();
+ TickType_t last_frame_tick = xTaskGetTickCount();
for (;;)
{
@@ -277,8 +306,8 @@ static void sul_rx_task(void *p_arg)
const indication_task_t DIFF = controller_process(&ctrl_ctx, &decoded);
if (DIFF.pos_pending || DIFF.direction_pending)
{
- const render_msg_t MSG = {.task = DIFF, .result = decoded};
- (void) xQueueOverwrite(s_render_queue, &MSG);
+ const render_msg_t MSG = { .task = DIFF, .result = decoded };
+ (void) xQueueOverwrite(g_s_render_queue, &MSG);
}
}
}
@@ -292,8 +321,8 @@ int main(void)
board_hw_init(); /* BOARD_ConfigMPU + BOARD_InitPins + BOARD_BootClockRUN */
bsp_led_init();
- s_render_queue = xQueueCreate(1, sizeof(render_msg_t));
- configASSERT(s_render_queue != NULL);
+ g_s_render_queue = xQueueCreate(1, sizeof(render_msg_t));
+ configASSERT(g_s_render_queue != NULL);
/* sul_rx выше приоритетом render — приём CAN/WDOG важнее своевременности
* перерисовки. Стеки x6 (3 КБ) с запасом (см. PLAN.md, Фаза 0 — тонкий
diff --git a/firmware/tft_app/src/menu/CMakeLists.txt b/firmware/tft_app/src/menu/CMakeLists.txt
new file mode 100644
index 0000000..b3d9a8c
--- /dev/null
+++ b/firmware/tft_app/src/menu/CMakeLists.txt
@@ -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)
diff --git a/firmware/tft_app/src/menu/include/menu/menu.h b/firmware/tft_app/src/menu/include/menu/menu.h
new file mode 100644
index 0000000..37d55b3
--- /dev/null
+++ b/firmware/tft_app/src/menu/include/menu/menu.h
@@ -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
+#include
+
+#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_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_ */
diff --git a/firmware/tft_app/src/menu/src/menu.c b/firmware/tft_app/src/menu/src/menu.c
new file mode 100644
index 0000000..24c177b
--- /dev/null
+++ b/firmware/tft_app/src/menu/src/menu.c
@@ -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;
+ }
+}
diff --git a/tests/host/CMakeLists.txt b/tests/host/CMakeLists.txt
index 0314db5..b06008c 100644
--- a/tests/host/CMakeLists.txt
+++ b/tests/host/CMakeLists.txt
@@ -367,3 +367,15 @@ add_host_test(
${TFT_APP_SETTINGS_DIR}/src
${PROJECT_SOURCE_DIR}/bsp/qspi_flash/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)
diff --git a/tests/host/tft_app_controller/README.md b/tests/host/tft_app_controller/README.md
new file mode 100644
index 0000000..082e496
--- /dev/null
+++ b/tests/host/tft_app_controller/README.md
@@ -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
+```
diff --git a/tests/host/tft_app_controller/test_controller.c b/tests/host/tft_app_controller/test_controller.c
index fc28696..8cddfdf 100644
--- a/tests/host/tft_app_controller/test_controller.c
+++ b/tests/host/tft_app_controller/test_controller.c
@@ -1,8 +1,6 @@
/**
* @file test_controller.c
- * @brief Host unit-тесты controller.c — diff между кэшем и новым sul_result_t.
- *
- * Чистый C, без единого HAL-вызова — fff-моков не нужно.
+ * @brief Host unit-тесты controller.c + mode_priority.c. См. README.md.
*/
#include "unity.h"
@@ -23,14 +21,14 @@ static sul_result_t make_result(const char *p_pos, sul_direction_t dir)
return r;
}
-/* Кэш засеян дефолтом при init — результат, совпадающий с дефолтом, не
- * считается изменением (см. докстрок controller_init()). */
+/* ── Diff контроллера ────────────────────────────────────────────────── */
+
static void test_first_frame_matching_default_has_no_pending(void)
{
controller_ctx_t 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);
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);
}
-/* Таймаут связи = caller зовёт controller_process(sul_default_state()) тем
- * же путём, что и обычный кадр — отдельного API не существует (см. header). */
static void test_feeding_default_state_after_real_data_marks_both_pending(void)
{
controller_ctx_t ctx;
@@ -122,60 +118,58 @@ static void test_resolve_mode_single_signals(void)
{
sul_result_t r;
- r = sul_default_state();
- r.fireman = true;
+ r = sul_default_state();
+ r.fireman = true;
TEST_ASSERT_EQUAL(SUL_MODE_FIREMAN, sul_resolve_mode(&r));
- r = sul_default_state();
+ r = sul_default_state();
r.fire_alarm = true;
TEST_ASSERT_EQUAL(SUL_MODE_FIRE_ALARM, sul_resolve_mode(&r));
- r = sul_default_state();
+ r = sul_default_state();
r.overload = true;
TEST_ASSERT_EQUAL(SUL_MODE_OVERLOAD, sul_resolve_mode(&r));
- r = sul_default_state();
+ r = sul_default_state();
r.seismic = true;
TEST_ASSERT_EQUAL(SUL_MODE_SEISMIC, sul_resolve_mode(&r));
- r = sul_default_state();
+ r = sul_default_state();
r.maintenance = true;
TEST_ASSERT_EQUAL(SUL_MODE_MAINTENANCE, sul_resolve_mode(&r));
- r = sul_default_state();
+ r = sul_default_state();
r.lading = true;
TEST_ASSERT_EQUAL(SUL_MODE_LADING, sul_resolve_mode(&r));
}
-/* Согласованный порядок: fireman > пожар > перегруз > сейсмо > сервис >
- * погрузка. Проверяем каждую соседнюю пару при одновременной активности. */
static void test_resolve_mode_priority_ordering(void)
{
sul_result_t r;
- r = sul_default_state();
+ r = sul_default_state();
r.fireman = true;
- r.fire_alarm = true; /* fireman выигрывает у пожара */
+ r.fire_alarm = true;
TEST_ASSERT_EQUAL(SUL_MODE_FIREMAN, sul_resolve_mode(&r));
- r = sul_default_state();
+ r = sul_default_state();
r.fire_alarm = true;
- r.overload = true; /* пожар выигрывает у перегруза */
+ r.overload = true;
TEST_ASSERT_EQUAL(SUL_MODE_FIRE_ALARM, sul_resolve_mode(&r));
- r = sul_default_state();
+ r = sul_default_state();
r.overload = true;
- r.seismic = true; /* перегруз выигрывает у сейсмо */
+ r.seismic = true;
TEST_ASSERT_EQUAL(SUL_MODE_OVERLOAD, sul_resolve_mode(&r));
- r = sul_default_state();
+ r = sul_default_state();
r.seismic = true;
- r.maintenance = true; /* сейсмо выигрывает у сервиса */
+ r.maintenance = true;
TEST_ASSERT_EQUAL(SUL_MODE_SEISMIC, sul_resolve_mode(&r));
- r = sul_default_state();
+ r = sul_default_state();
r.maintenance = true;
- r.lading = true; /* сервис выигрывает у погрузки */
+ r.lading = true;
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) controller_process(&ctx, &normal);
- sul_result_t fire = normal;
- fire.fire_alarm = true;
+ sul_result_t fire = normal;
+ fire.fire_alarm = true;
const indication_task_t task = controller_process(&ctx, &fire);
TEST_ASSERT_TRUE(task.mode_pending);
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)
{
controller_ctx_t ctx;
@@ -211,8 +203,8 @@ static void test_mode_pending_stable_when_resolved_mode_unchanged(void)
(void) controller_process(&ctx, &fire);
sul_result_t fire_plus_overload = fire;
- fire_plus_overload.overload = true; /* режим остаётся FIRE_ALARM */
- const indication_task_t task = controller_process(&ctx, &fire_plus_overload);
+ fire_plus_overload.overload = true;
+ const indication_task_t task = controller_process(&ctx, &fire_plus_overload);
TEST_ASSERT_FALSE(task.mode_pending);
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();
(void) controller_process(&ctx, &quiet);
- sul_result_t event = sul_default_state();
- event.arrival = true;
- event.movement = true;
+ sul_result_t event = sul_default_state();
+ event.arrival = true;
+ event.movement = true;
indication_task_t task = controller_process(&ctx, &event);
TEST_ASSERT_TRUE(task.arrival_pending);
TEST_ASSERT_TRUE(task.movement_pending);
- /* Уровень держится — фронта нет, pending не выставляется повторно. */
task = controller_process(&ctx, &event);
TEST_ASSERT_FALSE(task.arrival_pending);
TEST_ASSERT_FALSE(task.movement_pending);
diff --git a/tests/host/tft_app_menu/README.md b/tests/host/tft_app_menu/README.md
new file mode 100644
index 0000000..958b774
--- /dev/null
+++ b/tests/host/tft_app_menu/README.md
@@ -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
+```
diff --git a/tests/host/tft_app_menu/test_menu.c b/tests/host/tft_app_menu/test_menu.c
new file mode 100644
index 0000000..9af85a0
--- /dev/null
+++ b/tests/host/tft_app_menu/test_menu.c
@@ -0,0 +1,249 @@
+/**
+ * @file test_menu.c
+ * @brief Host-тесты чистой модели меню (menu.c). См. README.md.
+ */
+
+#include "menu/menu.h"
+#include "unity.h"
+
+#include
+#include
+
+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();
+}
diff --git a/tests/host/tft_app_settings_store/README.md b/tests/host/tft_app_settings_store/README.md
new file mode 100644
index 0000000..0be879b
--- /dev/null
+++ b/tests/host/tft_app_settings_store/README.md
@@ -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
+```
diff --git a/tests/host/tft_app_settings_store/test_settings_codec.c b/tests/host/tft_app_settings_store/test_settings_codec.c
index 733e72e..17cbdfb 100644
--- a/tests/host/tft_app_settings_store/test_settings_codec.c
+++ b/tests/host/tft_app_settings_store/test_settings_codec.c
@@ -1,9 +1,6 @@
/**
* @file test_settings_codec.c
- * @brief Host-тесты чистой сериализации настроек (settings_codec.c) — без QSPI.
- *
- * Формат страницы (magic/version/CRC32), дефолты, round-trip, отбраковка
- * битых страниц. Flash-адаптер (settings_store.c) тестируется на железе.
+ * @brief Host-тесты settings_codec.c (сериализация ядра настроек). См. README.md.
*/
#include "settings_codec.h"
@@ -12,14 +9,9 @@
#include
#include
-void setUp(void)
-{
-}
-void tearDown(void)
-{
-}
+void setUp(void) {}
+void tearDown(void) {}
-/* Страница обязана быть ровно одним сектором (дублирует _Static_assert в .c). */
static void test_page_is_one_sector(void)
{
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)
{
const settings_t D = settings_defaults();
- TEST_ASSERT_EQUAL_UINT8(0U, D.device.protocol_id); /* NKU_CAN */
- TEST_ASSERT_EQUAL_UINT8(1U, D.device.log_enabled); /* логи вкл из коробки */
+ TEST_ASSERT_EQUAL_UINT8(0U, D.device.protocol_id);
+ TEST_ASSERT_EQUAL_UINT8(1U, D.device.log_enabled);
TEST_ASSERT_EQUAL_UINT8(2U, D.user.sound_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_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)
@@ -47,12 +39,12 @@ static void test_serialize_sets_magic_version(void)
static void test_roundtrip_preserves_fields(void)
{
- settings_t in = settings_defaults();
- in.user.max_load_kg = 1000U;
- in.user.max_cap_persons = 8U;
+ settings_t in = settings_defaults();
+ in.user.max_load_kg = 1000U;
+ in.user.max_cap_persons = 8U;
in.user.year_production = 25U;
- in.user.proto_slice[0] = 7U; /* адрес станции 7 */
- in.device.log_enabled = 0U;
+ in.user.proto_slice[0] = 7U;
+ in.device.log_enabled = 0U;
(void) snprintf(in.user.serial, SETTINGS_SERIAL_LEN, "AB1234");
settings_page_t page;
@@ -90,7 +82,6 @@ static void test_bad_version_rejected(void)
TEST_ASSERT_FALSE(settings_deserialize(&page, &out));
}
-/* Магик/версия валидны, но байт данных испорчен → расходится только CRC. */
static void test_bad_crc_rejected(void)
{
settings_t in = settings_defaults();
@@ -102,7 +93,6 @@ static void test_bad_crc_rejected(void)
TEST_ASSERT_FALSE(settings_deserialize(&page, &out));
}
-/* Свежестёртый флеш (весь 0xFF) — невалиден (магик != 'STFT') → дефолты. */
static void test_erased_page_is_invalid(void)
{
settings_page_t page;
diff --git a/tests/host/tft_app_sul_nku/README.md b/tests/host/tft_app_sul_nku/README.md
new file mode 100644
index 0000000..ec2c0ef
--- /dev/null
+++ b/tests/host/tft_app_sul_nku/README.md
@@ -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
+```
diff --git a/tests/host/tft_app_sul_nku/test_sul_nku_can.c b/tests/host/tft_app_sul_nku/test_sul_nku_can.c
index f02e4ef..5ee645e 100644
--- a/tests/host/tft_app_sul_nku/test_sul_nku_can.c
+++ b/tests/host/tft_app_sul_nku/test_sul_nku_can.c
@@ -1,9 +1,6 @@
/**
* @file test_sul_nku_can.c
- * @brief Host unit-тесты декодера НКУ-CAN (Фаза 1: PACKET1 + PACKET3).
- *
- * Чистый декодер, без единого HAL-вызова — никаких fff-моков не нужно,
- * golden-векторы CAN-кадров строятся прямо в тесте.
+ * @brief Host unit-тесты декодера НКУ-CAN (PACKET1..5 + адрес). См. README.md.
*/
#include "domain/sul/nku_can.h"
@@ -11,24 +8,23 @@
#include
-void setUp(void)
-{
-}
-void tearDown(void)
-{
-}
+void setUp(void) {}
+void tearDown(void) {}
-/* ── Вспомогательные конструкторы кадров ─────────────────────────────── */
+/* ── Конструкторы кадров ─────────────────────────────────────────────────── */
#define PACKET1_ID 0x506U
+#define PACKET2_ID 0x408U
#define PACKET3_ID 0x508U
+#define PACKET4_ID 0x50BU
+#define PACKET5_ID 0x606U
#define UNKNOWN_ID 0x123U
static sul_frame_t make_packet1(uint8_t arrow_bits)
{
static uint8_t s_data[8];
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 };
}
@@ -36,17 +32,11 @@ static sul_frame_t make_packet3(uint8_t left, uint8_t right)
{
static uint8_t s_data[8];
memset(s_data, 0, sizeof(s_data));
- s_data[5] = left; /* FLOOR_MASK=0x3F на data[5] (left) */
- s_data[6] = right; /* FLOOR_MASK=0x3F на data[6] (right) */
+ s_data[5] = left;
+ s_data[6] = right;
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,
uint8_t level)
{
@@ -61,12 +51,10 @@ static sul_frame_t make_packet2(bool overload)
{
static uint8_t s_data[8];
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 };
}
-/* 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,
uint8_t mins)
{
@@ -83,8 +71,8 @@ static sul_frame_t make_packet4(bool overload, bool seismic)
{
static uint8_t s_data[8];
memset(s_data, 0, sizeof(s_data));
- s_data[5] = overload ? 0x40U : 0x00U; /* WEIGHT_MASK */
- s_data[0] = seismic ? 0x80U : 0x00U; /* SEISMIC_MASK */
+ s_data[5] = overload ? 0x40U : 0x00U;
+ s_data[0] = seismic ? 0x80U : 0x00U;
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 };
}
-/* ── PACKET1 — направление ───────────────────────────────────────────── */
+/* ── PACKET1 — направление ───────────────────────────────────────────────── */
static void test_packet1_none(void)
{
nku_can_ctx_t ctx;
nku_can_init(&ctx);
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);
}
@@ -116,9 +104,9 @@ static void test_packet1_up(void)
nku_can_ctx_t ctx;
nku_can_init(&ctx);
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);
}
@@ -127,9 +115,9 @@ static void test_packet1_down(void)
nku_can_ctx_t ctx;
nku_can_init(&ctx);
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);
}
@@ -138,34 +126,33 @@ static void test_packet1_double(void)
nku_can_ctx_t ctx;
nku_can_init(&ctx);
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);
}
-/* Верхние биты data[6] (выше ARROW_MASK) не должны влиять на результат. */
static void test_packet1_ignores_bits_outside_mask(void)
{
nku_can_ctx_t ctx;
nku_can_init(&ctx);
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);
}
-/* ── PACKET3 — позиция ───────────────────────────────────────────────── */
+/* ── PACKET3 — позиция ───────────────────────────────────────────────────── */
static void test_packet3_two_digit_floor(void)
{
nku_can_ctx_t ctx;
nku_can_init(&ctx);
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);
}
@@ -174,13 +161,12 @@ static void test_packet3_single_digit_via_space(void)
nku_can_ctx_t ctx;
nku_can_init(&ctx);
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);
}
-/* Легаси-квирк: left==0 трактуется как «пусто», как и SPACE — не «0» + right. */
static void test_packet3_single_digit_via_zero_byte(void)
{
nku_can_ctx_t ctx;
@@ -197,7 +183,7 @@ static void test_packet3_negative_floor(void)
nku_can_ctx_t ctx;
nku_can_init(&ctx);
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_STRING("-1", out.pos);
@@ -208,7 +194,7 @@ static void test_packet3_cyrillic_single_char(void)
nku_can_ctx_t ctx;
nku_can_init(&ctx);
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_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_init(&ctx);
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));
}
-/* ── Кадры не по протоколу ────────────────────────────────────────────── */
+/* ── Кадры не по протоколу / малформированные ────────────────────────────── */
static void test_unknown_id_is_ignored_and_out_untouched(void)
{
nku_can_ctx_t ctx;
nku_can_init(&ctx);
sul_result_t out;
- out.direction = SUL_DIR_UP; /* сентинел, decode() не должен его тронуть */
+ out.direction = SUL_DIR_UP;
(void) memset(out.pos, 'X', sizeof(out.pos));
uint8_t data[8] = { 0 };
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_DIR_UP, out.direction); /* не тронут */
- TEST_ASSERT_EQUAL_CHAR('X', out.pos[0]); /* не тронут */
+ TEST_ASSERT_EQUAL(SUL_DIR_UP, out.direction);
+ TEST_ASSERT_EQUAL_CHAR('X', out.pos[0]);
}
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));
}
-/* ── Накопление состояния между разными пакетами ─────────────────────── */
+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)
{
nku_can_ctx_t ctx;
nku_can_init(&ctx);
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));
- 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_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)
@@ -294,7 +292,7 @@ static void test_init_resets_to_default(void)
TEST_ASSERT_EQUAL(SUL_DIR_NONE, ctx.state.direction);
}
-/* ── PACKET1 — режимы (код нибла data[6][7:4]) и начало движения ──────── */
+/* ── PACKET1 — режимы и начало движения ──────────────────────────────────── */
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)
{
- 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)
{
nku_can_ctx_t ctx;
@@ -343,7 +341,6 @@ static void test_packet1_mode_fireman(void)
TEST_ASSERT_TRUE(out.fireman);
}
-/* Пакет без кода режима гасит устаревший режим (PACKET1-владелец). */
static void test_packet1_normal_clears_previous_mode(void)
{
nku_can_ctx_t ctx;
@@ -374,7 +371,7 @@ static void test_packet1_movement_bits(void)
TEST_ASSERT_FALSE(out.movement);
}
-/* ── PACKET2 / PACKET4 — перегруз (мультиисточник) и сейсмо ───────────── */
+/* ── PACKET2 / PACKET4 — перегруз (мультиисточник) и сейсмо ───────────────── */
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);
}
-/* Ключевой мультиисточник: перегруз из PACKET4 не должен сбрасываться
- * пакетом PACKET2 без перегруза (и наоборот) — выход = OR под-источников. */
static void test_overload_multisource_independence(void)
{
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);
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);
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)
@@ -427,7 +422,7 @@ static void test_packet4_seismic_set_and_clear(void)
TEST_ASSERT_FALSE(out.seismic);
}
-/* ── PACKET3 — гонг, временная погрузка, floor_num ───────────────────── */
+/* ── PACKET3 — гонг, временная погрузка, floor_num ───────────────────────── */
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);
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_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)
{
nku_can_ctx_t ctx;
nku_can_init(&ctx);
sul_result_t out;
- /* Временная погрузка идёт, PACKET1 без режима — lading остаётся. */
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_TRUE(out.lading);
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_TRUE(out.lading); /* временная погрузка пережила «нормальный» PACKET1 */
+ TEST_ASSERT_TRUE(out.lading);
- /* Отсчёт истёк — lading гаснет. */
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_FALSE(out.lading);
@@ -485,22 +476,22 @@ static void test_packet3_floor_num_derivation(void)
sul_result_t out;
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_UINT8(12U, out.floor_num);
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_UINT8(41U, out.floor_num);
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_UINT8(51U, out.floor_num);
}
-/* ── PACKET5 — следующий этаж (гейт: только пока едет и назначение ≠ уровень) */
+/* ── PACKET5 — следующий этаж ────────────────────────────────────────────── */
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);
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));
- 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_STRING("16", out.next);
}
@@ -522,7 +513,7 @@ static void test_packet5_next_suppressed_when_not_moving(void)
nku_can_init(&ctx);
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));
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);
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_STRING("", out.next);
}
-/* Следующий этаж гаснет на прибытии (PACKET1 сообщает «нет движения»). */
static void test_packet5_next_cleared_on_arrival(void)
{
nku_can_ctx_t ctx;
@@ -562,54 +552,35 @@ static void test_packet5_next_cleared_on_arrival(void)
TEST_ASSERT_EQUAL_STRING("", out.next);
}
-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 };
+/* ── Адрес станции: сдвиг ID пакетов ─────────────────────────────────────── */
- 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)
{
nku_can_ctx_t ctx;
nku_can_init(&ctx);
- nku_can_set_address(&ctx, 1U); /* group4 = 0x10 */
+ nku_can_set_address(&ctx, 1U);
sul_result_t out;
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 };
TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &shifted, &out));
TEST_ASSERT_EQUAL(SUL_DIR_UP, out.direction);
- /* Базовый ID адреса 0 теперь не наш. */
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));
}
-/* Адрес > 15 клампится к 15 (не выходит за 4-битный group4). */
static void test_address_clamped_to_max(void)
{
nku_can_ctx_t ctx;
nku_can_init(&ctx);
- nku_can_set_address(&ctx, 200U); /* клампится к 15 → group4 = 0xF0 */
+ nku_can_set_address(&ctx, 200U);
sul_result_t out;
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 };
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_wrong_dlc_on_packet1_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_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_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_clamped_to_max);