# tft-app — движок меню и связь с настройками
Документ описывает **реализованный** движок меню (Фаза 3.2.1: чистая модель; 3.2.2–3.2.4: рендер,
wiring, разделение на задачи) и его связь с модулем настроек `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_1 (вход/next) / BUTTON_2 (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. Навигация
Две кнопки, обе коротким нажатием (без удержания — раскладка `OLD_PROJECT_TFT8_UKL`): BUTTON_1 →
`menu_next` (следующий пункт уровня, с заворотом) когда меню открыто, **вход в меню** (app-слой,
`menu_open`) когда закрыто; BUTTON_2 → `menu_action` (по типу пункта) когда открыто, намеренный
no-op когда закрыто.
```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.3 строится из дескриптора протокола
`sul_settings_desc_t` (§8), не хардкодом: `menu_tree_refresh_protocol_section()` читает
`sul_registry_active()->p_settings` и заполняет раздел «Настройки протокола» (label/тип/
диапазон/offset/options) из него — при смене активного протокола (и при bringup) секция
перестраивается. Подробности механизма и как добавить протокол — [ADDING_PROTOCOL.md](ADDING_PROTOCOL.md).
Пошаговое «как добавить настройку» (любую — пользовательскую или протокольную, форматы значений)
— [ADDING_SETTING.md](ADDING_SETTING.md); состав/ярусы/хранение самого `settings_t` —
[SETTINGS.md](SETTINGS.md).
**Поток сохранения** (`menu_task` связывает модель и flash — см. §5):
```mermaid
sequenceDiagram
participant U as Пользователь (кнопки)
participant M as menu (модель)
participant S as settings_t (RAM)
participant MT as menu_task
participant St as settings_store
U->>M: menu_action (правка / выход)
M->>S: запись поля по offset (dirty=true)
M-->>MT: open=false, save_requested=true
MT->>St: settings_store_save()
St->>St: serialize + CRC → QSPI 0x450000
```
Адрес станции (`nku_can_set_address()`) `sul_rx_task` пере-применяет из настроек сам, на следующей
итерации — не требует отдельного сигнала (декодер читает `settings_store_get()` каждую итерацию).
---
## 5. Рендер и wiring (Фаза 3.2.2–3.2.4)
Рендер ([menu_view.c](../../firmware/tft_app/src/ui/menu/src/menu_view.c)) — отдельный слой, читает
модель запросами (`menu_current`, `menu_level_range`, `menu_read_value`) и рисует в **фиксированном
окне 480×272 @ логич.(0,0)** — одинаково на всех панелях (на больших — левый-верхний угол, остальное
чёрное). Логики навигации не содержит.
**Раскладка** (под реальные шрифты: `SystemFont`/JBMono24 h=31, `SystemFontSmall`/JBMono12 h=16):
заголовок 36 + 6 строк × 36 + футер 20 = **272**, обрамление — тонкая серая рамка 1 px по периметру.
Заголовок = подпись текущего уровня; строки «подпись слева / значение справа»; **курсор — сплошная
полоса-заливка** (`gfx_fill_rect`) + белый текст; футер — легенда кнопок + «N/M».
**Цвет — тинтингом** (`gfx_draw_string(..., color)` с альфа-блендингом): один белый шрифт рисуется
любым цветом со сглаживанием, и оно корректно ложится на полосу-курсор. Значения SELECT/BOOL — из
`options[]` дескриптора, BYTE — числом.
**Оконный рендер** (double-buffer + PXP + гибрид bpp, Фаза 3.2.4): `menu_view_render()` рисует
off-screen в альфа-поверхность AS (ARGB8888) **только окно** `MENU_VIEW_WIN_W×H` (windowed clear +
отрисовка); показ — у владельца дисплея ([task_render.c](../../firmware/tft_app/src/app/task_render.c)):
- **открытие меню** — полная очистка AS (стереть индикацию вне окна) + **два** полных
`gfx_present()` подряд: из-за double buffering ОБА framebuffer'а обязаны получить корректный
кадр вне окна (контракт `gfx_present_rect`, см. gfx.h);
- **навигация/правка** — `gfx_present_rect(0,0,окно)`: PXP перекомпоновывает только 480×272
(~27% кадра) — пропорционально дешевле полного кадра.
Рисуем вне экрана, показываем атомарным свапом → tear-free. Компоновщик — `services/gfx`
(эталон `OLD_PROJECT_TFT8_UKL/source/display/`).
**Меню и рендер — РАЗНЫЕ задачи** ([task_menu.c](../../firmware/tft_app/src/app/task_menu.c) /
[task_render.c](../../firmware/tft_app/src/app/task_render.c)). Найдено на HW-верификации Фазы
3.2.4: в объединённой задаче (Фазы 3.2.1–3.2.3, один framebuffer, без ожиданий) блокировок не было,
разделение было безвредным упущением — но `gfx_present()` (double-buffer + PXP) внёс блокирующее
ожидание кадра, и в объединённой задаче это ожидание попутно блокировало вход в меню (ноль реакции
на кнопки). Эталон разделения — `OLD_PROJECT_TFT8_UKL`: `BUTTONS_TASK`/`menu_task` отдельно от
`REFRESH_TASK`/`tft_refresh_task`. Полная картина задач/приоритетов/взаимодействия —
[TASKS.md](TASKS.md).
```mermaid
flowchart LR
BTN["bsp_button
софт-таймер 5 мс
(debounce, независимо от обеих задач)"]
MT["menu_task
модель + мгновенный вход/навигация + save
НЕ рисует"]
RX["sul_rx_task
CAN → decode → controller
WDOG безусловно"]
RT["render_task
ЕДИНСТВЕННЫЙ вызывающий gfx_present()
event-driven"]
BTN --> MT
MT -->|"xTaskNotifyGive
(любое изменение)"| RT
RX -->|"xQueueOverwrite (данные)
+ xTaskNotifyGive (сигнал)"| RT
MT -.g_menu_active.-> RX
```
**Связь — MPSC.** Два продюсера (`sul_rx_task`, `menu_task`), один консюмер (`render_task`).
Данные (какой этаж/диф) идут только по плечу `sul_rx_task`→`render_task` — однослотовая
`xQueueOverwrite`-очередь (важно только последнее). Пробуждение — `xTaskNotifyGive()`/
`ulTaskNotifyTake(pdTRUE, portMAX_DELAY)` от ОБОИХ продюсеров: `render_task` не поллит, спит между
изменениями; несколько notify схлопываются в одно пробуждение (та же семантика «важно только
последнее»). Приоритет «меню важнее индикации» не кодируется в уведомлении — `render_task`,
проснувшись, всегда СНАЧАЛА проверяет `menu_is_open()`.
**Мягкая пауза `sul_rx_task` на время меню.** Пока меню открыто, `menu_task` держит
`g_menu_active=true`; `sul_rx_task` под этим флагом пропускает decode/controller/запись в очередь —
но WDOG/heartbeat кормятся БЕЗУСЛОВНО (вне флага), задача не suspend'ится. При выходе из меню
`render_task` (по признаку «меню только что закрылось») сразу перерисовывает последнее известное
состояние индикации, не дожидаясь свежего CAN-кадра.
**Ввод.** Опрос кнопок — **софт-таймер** (`input_poll_cb`, 5 мс; демон таймеров на высшем приоритете
в системе → нажатия не теряются, пока заняты остальные задачи; в Фазе 3.4 туда же
`bsp_opto_process()`). Раскладка — как в `OLD_PROJECT_TFT8_UKL`, оба нажатия короткие, без
удержания: BUTTON_1 = вход в меню (закрыто) / следующий пункт (открыто); BUTTON_2 = выбор/инкремент
(открыто), намеренный no-op (закрыто).
**Приоритеты задач** (`app_tasks.h` — единая точка правды, `tskIDLE_PRIORITY`-относительно):
`bringup_task` (одноразовая, самый высокий из четырёх) → `menu_task` → `render_task` → `sul_rx_task`
(самый низкий). **Важно:** `render_task` НАМЕРЕННО выше `sul_rx_task`, не наоборот — `bsp_can_receive()`
busy-spin без yield (`bsp/can/src/can.c`) занимает CPU весь `CAN_RX_TIMEOUT_MS` (100 мс) при
отсутствии трафика, и `xTaskDelayUntil()` в этом случае не блокирует вовсе (дедлайн уже в прошлом —
см. `sdk/rtos/freertos/freertos-kernel/tasks.c`), т.е. `sul_rx_task` не отдаёт CPU добровольно.
Если `sul_rx_task` окажется выше `render_task`, последняя будет голодать всё время отсутствия
CAN-трафика (найдено на HW-верификации — экран не обновлялся при старте без связи и при обрыве
связи; см. PLAN.md, Фаза 3.2.4). `menu_task` по-прежнему выше `render_task` — её PXP busy-wait
(~60-100 мс) не должен придерживать ввод. Логгер (`utils/log`) под FreeRTOS — с мьютексом
(`port/log/src/log_mutex.c`, до Фазы 3.2.4 был `#if 0` и не собирался — гонка на общем static-буфере
логгера между несколькими пишущими задачами).