diff --git a/docs/tft_app/DOMAIN_DATAFLOW.md b/docs/tft_app/DOMAIN_DATAFLOW.md
new file mode 100644
index 0000000..b8b056a
--- /dev/null
+++ b/docs/tft_app/DOMAIN_DATAFLOW.md
@@ -0,0 +1,185 @@
+# tft-app — путь данных домена (транспорт → экран)
+
+Документ описывает **реализованный** доменный слой (Фазы 1–2): как кадр от СУЛ проходит
+от шины до презентации, какие модули за что отвечают и каков контракт между ними. Проектное
+обоснование слоёв — в [ARCH.md](../../firmware/tft_app/ARCH.md); статус фаз —
+в [PLAN.md](../../firmware/tft_app/PLAN.md). Здесь — «как это работает в коде сейчас».
+
+Сейчас реализован один протокол — **НКУ-CAN**. Архитектура рассчитана на много протоколов
+(УЭЛ/УКЛ/SD7/УИМ — Фаза 8): добавление протокола = новый декодер + запись в реестр, без правок
+остальных слоёв.
+
+---
+
+## 1. Обзор слоёв
+
+Каждый слой — отдельная статическая библиотека CMake. **Домен — чистый C без единого
+HAL-вызова** (host-тестируется), железо изолировано в `bsp/*` и тонком транспорт-адаптере.
+
+```mermaid
+flowchart TB
+ subgraph L0["L0 · железо (bsp, не трогаем)"]
+ CAN["bsp_can
приём CAN-кадров"]
+ end
+ subgraph L1["L1 · транспорт (HW-адаптер)"]
+ TR["sul/transport/can
bsp_can_frame_t → sul_frame_t"]
+ end
+ subgraph L2["L2 · домен (чистый C, host-тесты)"]
+ REG["sul (реестр)
sul_registry_active()"]
+ DEC["sul/nku_can
чистый декодер PACKET1..5"]
+ MODEL["elevator_model
sul_result_t, sul_mode_t"]
+ CTRL["controller
diff + свёртка режима"]
+ PRIO["mode_priority
таблица приоритетов"]
+ end
+ subgraph L3["L3 · презентация"]
+ UI["ui/fallback
этаж/стрелка/метка режима"]
+ AUD["audio_policy
(Фаза 6)"]
+ end
+
+ CAN --> TR --> REG --> DEC
+ DEC -->|"sul_result_t"| CTRL
+ MODEL -.->|"тип"| DEC
+ MODEL -.->|"тип"| CTRL
+ PRIO -.->|"свёртка"| CTRL
+ CTRL -->|"indication_task_t"| UI
+ CTRL -->|"indication_task_t"| AUD
+```
+
+Ключевой инвариант: **декодер не знает про шрифт/экран, презентация не знает про биты
+протокола**. Единственная валюта между ними — канонический `sul_result_t`
+([elevator_model.h](../../firmware/tft_app/src/domain/elevator_model/include/domain/elevator_model.h)).
+
+---
+
+## 2. Путь одного кадра
+
+`sul_rx`-задача приложения опрашивает CAN, прогоняет кадр через активный декодер и передаёт
+результат контроллеру; `render`-задача применяет diff. Очередь между ними — глубины 1
+(`xQueueOverwrite`: важно только последнее состояние, не история).
+
+```mermaid
+sequenceDiagram
+ participant CAN as bsp_can
+ participant TR as transport/can
+ participant REG as sul_registry
+ participant DEC as nku_can_decode()
+ participant CTX as nku_can_ctx_t
+ participant CTRL as controller_process()
+ participant UI as ui_fallback_render()
+
+ CAN->>TR: bsp_can_frame_t (id, data, dlc)
+ TR->>REG: sul_frame_t
+ REG->>DEC: active().decode(ctx, frame, out)
+ DEC->>CTX: обновить поле(я) пришедшего пакета
+ CTX-->>DEC: полная накопленная state
+ DEC-->>CTRL: sul_result_t (при SUL_STATUS_OK)
+ CTRL->>CTRL: diff с кэшем + sul_resolve_mode()
+ CTRL-->>UI: indication_task_t (pending-флаги + mode)
+ UI->>UI: перерисовать изменившееся
+```
+
+---
+
+## 3. Контракт декодера
+
+**Кадр транспортного уровня** — нейтрален к шине (CAN/UART/…); транспорт заполняет, декодер
+только читает ([sul.h](../../firmware/tft_app/src/domain/sul/include/domain/sul.h)):
+
+```c
+typedef struct { uint32_t id; uint8_t bus; const uint8_t *p_data; uint16_t len; } sul_frame_t;
+typedef sul_status_t (*sul_decode_fn)(void *p_ctx, const sul_frame_t *p_frame, sul_result_t *p_out);
+```
+
+**Три исхода** `decode()` (`sul_status_t`):
+
+| Статус | Когда | Что с `*p_out` |
+| --- | --- | --- |
+| `SUL_STATUS_OK` | кадр распознан | заполнен полной накопленной `state` |
+| `SUL_STATUS_IGNORED` | ID не этого протокола | **не тронут** |
+| `SUL_STATUS_ERR` | ID совпал, но кадр малформирован (неверный DLC / код символа вне таблицы) | не тронут |
+
+**Декодер stateful через ctx, но остаётся чистой функцией.** Разные пакеты несут разные поля
+в разных кадрах — `decode()` обновляет только пришедшее и отдаёт наружу **полную** копию
+накопленного `state`, а не дельту. Состояние держит caller в `nku_can_ctx_t` и передаёт
+указатель на каждый вызов — декодер не владеет памятью/жизненным циклом.
+
+```mermaid
+flowchart LR
+ P1["PACKET1
направление, режим"] --> ST["nku_can_ctx_t.state
(полный sul_result_t)"]
+ P3["PACKET3
позиция, гонг, погрузка"] --> ST
+ P4["PACKET4
перегруз, сейсмо"] --> ST
+ ST -->|"копия целиком"| OUT["*p_out"]
+```
+
+---
+
+## 4. НКУ-CAN: пакет → поля модели
+
+Порт боевого декодера `OLD_PROJECT/source/main_programm.c` (`msg_receiver_task`) в чистую
+функцию. Адрес станции = 0 (базовые ID без сдвига группы; параметризация из настроек — Фаза 3).
+Реализация — [nku_can.c](../../firmware/tft_app/src/domain/sul/nku_can/src/nku_can.c).
+
+| Пакет | ID | Байты/маски → поля `sul_result_t` |
+| --- | --- | --- |
+| **PACKET1** | `0x506` | `d6[1:0]` → `direction` · `d6[3:2]` → `movement` · `d6[7:4]` код режима → `fire_alarm`/`maintenance`/`fireman`/погрузка-инстр. · `d3[5:0]` → уровень остановки (внутр., гейт PACKET5) |
+| **PACKET2** | `0x408` | `d7&0x40` → перегруз (источник 1) |
+| **PACKET3** | `0x508` | `d5/d6 &0x3F` → `pos`, `floor_num` · `d3&0x40==0` → `arrival` (гонг) · `d2&0x3F` сек + `d3&0x0F` мин → `lading_secs` |
+| **PACKET4** | `0x50B` | `d5&0x40` → перегруз (источник 2) · `d0&0x80` → `seismic` |
+| **PACKET5** | `0x606` | `d3/d4 &0x3F` → `next` (только пока едет ↑/↓ и назначение ≠ уровень; иначе `next=""`) |
+
+**Мультиисточниковые поля.** Некоторые выходные поля кормятся несколькими пакетами. Они держатся
+в `ctx` раздельными латчами и на выход идут как **OR** — иначе пакет-без-сигнала сбросил бы флаг,
+выставленный другим пакетом (боевой баг-класс):
+
+- `overload = overload_p2 || overload_p4` (PACKET2 и PACKET4 независимы);
+- `lading = lading_instr || (lading_secs > 0)` (инструментальная из PACKET1, временная из PACKET3).
+
+Режимными флагами, которыми владеет **один** пакет (fire/maintenance/fireman — PACKET1;
+seismic — PACKET4), пакет-владелец распоряжается напрямую: сбрасывает в начале и выставляет по
+условию, гася устаревший режим.
+
+Удалённая установка адреса (кадры `0x4X1`/`0x5XB`) — **отложена в Фазу 3** (нужен `settings_store`
+для записи `nku_address`; чистый декодер не пишет настройки). Сейчас эти ID игнорируются.
+
+---
+
+## 5. Контроллер: diff и таймаут
+
+`controller_process()` — редьюсер: сравнивает новый `sul_result_t` с кэшем, выдаёт
+`indication_task_t` (что перерисовать/озвучить) и обновляет кэш
+([controller.c](../../firmware/tft_app/src/domain/controller/src/controller.c)).
+
+| Поле `indication_task_t` | Условие | Потребитель |
+| --- | --- | --- |
+| `pos_pending` | изменилась строка позиции | презентация |
+| `next_pending` | изменился следующий этаж | презентация (Фаза 5) |
+| `direction_pending` | изменилось направление | презентация |
+| `mode_pending` | сменился **разрешённый** режим (см. [MODE_PRIORITY.md](MODE_PRIORITY.md)) | презентация |
+| `arrival_pending` | фронт `false→true` гонга | аудио (Фаза 6) |
+| `movement_pending` | фронт `false→true` начала движения | аудио (Фаза 6) |
+| `mode` | разрешённый режим нового результата | презентация читает при `mode_pending` |
+
+**Таймаут связи — не отдельный API.** При пропадании кадров дольше таймаута `sul_rx`-задача
+просто вызывает `controller_process()` с `sul_default_state()` — тем же путём, что и обычный кадр
+(`pos="--"`, режимы сброшены). Diff корректно пометит изменения, презентация покажет «--».
+
+```mermaid
+flowchart TD
+ POLL["sul_rx: опрос CAN"] --> GOT{"кадр за таймаут?"}
+ GOT -->|да| DEC["decode → sul_result_t"]
+ GOT -->|нет| DEF["sul_default_state()"]
+ DEC --> CTRL["controller_process()"]
+ DEF --> CTRL
+ CTRL --> Q["очередь глубины 1"]
+ Q --> RENDER["render-задача"]
+```
+
+---
+
+## 6. Дальше — презентация
+
+Как `indication_task_t` превращается в пиксели и звук:
+
+- Свёртка сигналов в один экранный режим — [MODE_PRIORITY.md](MODE_PRIORITY.md).
+- Безопасная (asset-free) отрисовка этажа/стрелки/режима — [FALLBACK.md](FALLBACK.md).
+- Богатый layout со спрайтами и звук — Фазы 4–6 ([PLAN.md](../../firmware/tft_app/PLAN.md)).
diff --git a/docs/tft_app/FALLBACK.md b/docs/tft_app/FALLBACK.md
new file mode 100644
index 0000000..6918a04
--- /dev/null
+++ b/docs/tft_app/FALLBACK.md
@@ -0,0 +1,98 @@
+# tft-app — fallback-рендер (safe-mode)
+
+«Аварийный» режим индикации: минимум зависимостей, никаких ассетов и файловой системы.
+Это же — первый экран walking-skeleton (Фаза 1) и гарантированная деградация, когда ассеты/layout
+недоступны. Проектная основа — [ARCH.md §11](../../firmware/tft_app/ARCH.md); реализация —
+[fallback.c](../../firmware/tft_app/src/ui/fallback/src/fallback.c).
+
+---
+
+## 1. Что такое fallback
+
+`default`-layout **asset-free и FS-free**: не монтирует QSPI-FAT/SD, не трогает TLV-ридер ассетов.
+Зависимости — только `bsp_display` + `gfx` (framebuffer, вкомпилированные шрифты, примитив стрелки)
++ домен. Рисует на чёрном фоне, без звука. Устройство никогда не «гаснет»: даже при полностью
+битых ассетах есть чем показать этаж.
+
+Принцип degradation, а не отказа (ARCH §3): сбой ассетов/layout/связи ведёт к безопасному
+**упрощению** индикации, а не к отказу.
+
+---
+
+## 2. Что рисуется
+
+Презентация решает по разрешённому режиму (`indication_task_t.mode`, см.
+[MODE_PRIORITY.md](MODE_PRIORITY.md)):
+
+```mermaid
+flowchart TD
+ START([render mode, result]) --> CLR["gfx_clear(BLACK)"]
+ CLR --> Q{"mode == NORMAL?"}
+ Q -->|да| POS["этаж (FloorFontFallback), по центру"]
+ POS --> ARR{"direction UP/DOWN?"}
+ ARR -->|да| DRAWARR["примитив-стрелка"]
+ ARR -->|нет| DONE([готово])
+ DRAWARR --> DONE
+ Q -->|нет| LBL["метка режима (SystemFont)
ПОЖАР/ПЕРЕГРУЗ/СЕРВИС/…"]
+ LBL --> LAD{"погрузка и lading_secs>0?"}
+ LAD -->|да| CNT["обратный отсчёт (крупно)"]
+ LAD -->|нет| DONE
+ CNT --> DONE
+```
+
+- **Обычный режим** — номер этажа (`FloorFontFallback`) + стрелка вверх/вниз (примитив, без
+ спрайтов). Двойная стрелка (`SUL_DIR_DOUBLE`) в fallback не рисуется — это спрайтовая индикация
+ Фазы 4/5.
+- **Спецрежим** — короткая текстовая метка системным шрифтом (`SystemFont`). Это **safe-mode**
+ индикация: богатая полноэкранная графика режимов (фон+спрайты) появится в Фазах 4–5. Для
+ временной погрузки дополнительно рисуется обратный отсчёт (`lading_secs`).
+
+Следующий этаж (`next`) в fallback **не** показывается — это элемент богатого layout (Фаза 5),
+поэтому на `next_pending` перерисовки нет.
+
+---
+
+## 3. По-символьный fallback шрифта
+
+Второй уровень деградации — на уровне отдельного символа. Активный шрифт может не покрывать
+кодпойнт, который выдал декодер (напр. `FloorFontFallback` знает только `0–9`, `-`, пробел, а
+позиция пришла кириллицей). `gfx_draw_string()` подставляет `-` вместо отсутствующего глифа —
+по-символьно, не обрывая всю строку на первом неизвестном символе. Если даже `-` нет в шрифте —
+символ пропускается (нулевая ширина).
+
+```mermaid
+flowchart LR
+ CH["символ строки"] --> HAS{"есть глиф
в активном шрифте?"}
+ HAS -->|да| DRAW["нарисовать глиф"]
+ HAS -->|нет| SUB{"есть глиф '-'?"}
+ SUB -->|да| DASH["нарисовать '-'"]
+ SUB -->|нет| SKIP["пропустить (ширина 0)"]
+```
+
+Механизм есть с Фазы 1. Выделенная чистая функция проверки покрытия шрифта + её host-тест —
+остаток Фазы 2 (см. [PLAN.md](../../firmware/tft_app/PLAN.md)).
+
+---
+
+## 4. Когда перерисовываем
+
+`ui_fallback_render()` перерисовывает кадр целиком (без dirty-rect — `gfx` минимален) при
+изменении того, что fallback реально показывает: **позиция, стрелка или режим**
+(`pos_pending || direction_pending || mode_pending`). Иначе — no-op.
+
+Первую отрисовку презентация делает **безусловно** один раз при старте
+(`ui_fallback_render_initial()`): кэш контроллера засеян дефолтом, и если первый реальный кадр
+совпадёт с дефолтом, diff придёт «ничего не изменилось» — без безусловного старта экран остался бы
+пустым.
+
+---
+
+## 5. Известное ограничение — один framebuffer
+
+Сейчас один буфер, без double buffering: `render()` пишет напрямую в буфер, который ELCDIF в этот
+момент сканирует по DMA. Если перерисовка не укладывается в кадр развёртки, виден сам процесс
+закраски (глиф «набирается» за несколько кадров) — **не** tearing и **не** порча пикселей (каждая
+запись валидна), просто заметен процесс.
+
+Фикс — double buffering (второй буфер + swap по `FRAME_DONE`), запланирован в **Фазе 4** вместе с
+PXP-компоновщиком. Подробнее — [PLAN.md, Фаза 4](../../firmware/tft_app/PLAN.md).
diff --git a/docs/tft_app/MODE_PRIORITY.md b/docs/tft_app/MODE_PRIORITY.md
new file mode 100644
index 0000000..10081e4
--- /dev/null
+++ b/docs/tft_app/MODE_PRIORITY.md
@@ -0,0 +1,103 @@
+# tft-app — приоритеты режимов
+
+Как множество одновременных сигналов от СУЛ сворачивается в **один** экранный режим.
+Проектная основа — [ARCH.md §7](../../firmware/tft_app/ARCH.md); реализация —
+[mode_priority.c](../../firmware/tft_app/src/domain/controller/src/mode_priority.c).
+
+---
+
+## 1. Задача
+
+Сигналы в `sul_result_t` **ортогональны** — могут быть активны одновременно (перегруз во время
+сервиса, пожар при погрузке и т.п.). Но экран показывает **один** режим. Нужно правило: какой
+режим побеждает, когда активны несколько.
+
+```mermaid
+flowchart LR
+ subgraph SIG["сигналы sul_result_t (булевы, независимы)"]
+ F["fireman"]
+ FA["fire_alarm"]
+ OV["overload"]
+ SE["seismic"]
+ MA["maintenance"]
+ LA["lading"]
+ end
+ SIG --> R["sul_resolve_mode()"]
+ R --> M["sul_mode_t
(ровно один режим)"]
+```
+
+---
+
+## 2. Таблица — это данные, а не код
+
+Приоритет задан **порядком строк** таблицы-массива, а не значениями enum. Резолвер таблицу не
+знает «в лицо» — читает булев сигнал по смещению поля (`offsetof`), что делает каждую строку
+голыми данными:
+
+```c
+typedef struct { size_t flag_offset; sul_mode_t mode; } mode_rule_t;
+
+static const mode_rule_t k_mode_priority[] = {
+ {offsetof(sul_result_t, fireman), SUL_MODE_FIREMAN}, // высший
+ {offsetof(sul_result_t, fire_alarm), SUL_MODE_FIRE_ALARM},
+ {offsetof(sul_result_t, overload), SUL_MODE_OVERLOAD},
+ {offsetof(sul_result_t, seismic), SUL_MODE_SEISMIC},
+ {offsetof(sul_result_t, maintenance), SUL_MODE_MAINTENANCE},
+ {offsetof(sul_result_t, lading), SUL_MODE_LADING}, // низший спецрежим
+};
+```
+
+Согласованный порядок: **пожарный › пожар › перегруз › сейсмо › сервис › погрузка**. Если ни одна
+строка не сработала — `SUL_MODE_NORMAL` (обычная индикация этажа/стрелки).
+
+> ⚠️ Числовые значения `sul_mode_t` — **просто идентификаторы**, они не задают приоритет. Приоритет
+> живёт только в порядке строк `k_mode_priority[]`.
+
+---
+
+## 3. Резолвер
+
+Проход таблицы сверху вниз; первая строка с активным сигналом выигрывает.
+
+```mermaid
+flowchart TD
+ START([sul_resolve_mode]) --> I["i = 0"]
+ I --> CHK{"строка i:
сигнал по offset активен?"}
+ CHK -->|да| RET["вернуть mode строки i"]
+ CHK -->|нет| NEXT["i++"]
+ NEXT --> MORE{"строки ещё есть?"}
+ MORE -->|да| CHK
+ MORE -->|нет| NORM["вернуть SUL_MODE_NORMAL"]
+```
+
+Резолвер вызывается из `controller_process()` дважды за кадр (для прошлого и нового состояния) —
+их сравнение даёт `mode_pending`. Смена **сырого** сигнала, не меняющая разрешённый режим (напр.
+добавился перегруз при уже активном пожаре), `mode_pending` не поднимает.
+
+---
+
+## 4. Как менять
+
+Всё — правки одного массива, резолвер не трогается:
+
+- **Изменить приоритет** — переставить строки.
+- **Добавить режим** — дописать строку: `offsetof` нового булева сигнала + его `sul_mode_t`
+ (сам сигнал добавляется в `sul_result_t` и заполняется декодером).
+- **Убрать режим** — удалить строку (сигнал в модели может остаться, просто не влияет на экран).
+
+---
+
+## 5. Развязка от протокола и клиентская кастомизация
+
+Таблица оперирует **каноническими** сигналами `sul_result_t`, а не битами НКУ-CAN. Обзор боевых
+декодеров (УИМ/УКЛ/УЭЛ/SD7) подтвердил: набор сигналов один на все протоколы — различаются лишь
+битовые кодировки (живут в каждом декодере). Поэтому одна таблица работает для любого протокола.
+
+Приоритеты «потенциально клиентские» (§7): клиентская кастомизация = подмена массива
+`k_mode_priority[]` (в перспективе — загружаемая таблица рядом с layout/конфигом), **без правок
+кода** резолвера или декодеров.
+
+---
+
+Куда встраивается результат: `mode` едет в `indication_task_t` и читается презентацией при
+`mode_pending` — см. [DOMAIN_DATAFLOW.md §5](DOMAIN_DATAFLOW.md) и [FALLBACK.md](FALLBACK.md).
diff --git a/docs/tft_app/README.md b/docs/tft_app/README.md
new file mode 100644
index 0000000..0beab30
--- /dev/null
+++ b/docs/tft_app/README.md
@@ -0,0 +1,23 @@
+# tft-app — документация домена
+
+Dev-документация по **реализованному** доменному слою `firmware/tft_app` (Фазы 1–2): как данные
+СУЛ проходят от протокола до экрана. Технические доки «как это работает в коде», в дополнение к
+проектным ARCH/PLAN.
+
+| Документ | О чём |
+| --- | --- |
+| [DOMAIN_DATAFLOW.md](DOMAIN_DATAFLOW.md) | Путь данных: транспорт → декодер → контроллер → презентация. Слои, контракт `decode()`, карта пакетов НКУ-CAN → поля модели, таймаут→default. |
+| [MODE_PRIORITY.md](MODE_PRIORITY.md) | Свёртка ортогональных сигналов в один экранный режим. Таблица приоритетов как данные, резолвер, как менять/кастомизировать. |
+| [FALLBACK.md](FALLBACK.md) | Safe-mode рендер: этаж/стрелка/метка режима, по-символьный fallback шрифта, триггеры перерисовки, ограничение single-buffer. |
+
+## Связанные документы
+
+- [ARCH.md](../../firmware/tft_app/ARCH.md) — архитектура и проектное обоснование (источник истины по дизайну).
+- [PLAN.md](../../firmware/tft_app/PLAN.md) — статус фаз разработки (источник истины по статусу).
+- [DEV_ARCH.md](../DEV_ARCH.md) — устройство репозитория и сборки.
+
+## Границы
+
+Описан домен и fallback-презентация. Ещё не реализовано (см. PLAN.md): настройки/меню (Фаза 3),
+ассеты и layout-движок со спрайтами (Фазы 4–5), аудио (Фаза 6), мультипротокол (Фаза 8). Удалённая
+установка адреса НКУ-CAN отложена в Фазу 3.
diff --git a/firmware/tft_app/ARCH.md b/firmware/tft_app/ARCH.md
index 437d89e..6a39370 100644
--- a/firmware/tft_app/ARCH.md
+++ b/firmware/tft_app/ARCH.md
@@ -266,6 +266,21 @@ layout- и assets-регионы — **вне** flash-area загрузчика
- **HIL:** gfx/PXP/ELCDIF, audio/MQS, SD/QSPI, реальные транспорты CAN/UART (по образцу
`tests/target/*` и `06_test_firmware_*`).
+### 14. Errata-вотчлист (IMXRT1050 Chip Errata Rev. 2.1)
+
+> Риски кремния, привязанные к фазам. Многие пункты — **только для ревизии A0**
+> (исправлены в A1) → на A1-кремнии неактуальны. **Открытый вопрос: уточнить ревизию
+> кремния стендовых плат (A0/A1)** — определяет применимость половины списка. Источник —
+> `DOCS/MANUALS/ERRATA.pdf` (IMXRT1050CE, Rev. 2.1, 08/2019).
+
+| Errata | Суть | Фаза | Ревизия |
+| --- | --- | --- | --- |
+| **ERR011138** | LCDIF: последовательное программирование LUT может сбоить, если две записи идут близко. | 4/9 — актуально **только если** используем LUT ELCDIF для Index8BPP-фреймбуфера (экономия RAM ~4× vs ARGB8888). | A0, исправлено в A1 |
+| **ERR011207** | FlexSPI: при `FLEXSPI_AHBCR[PREFETCHEN]=1` в редком случае возвращаются неверные данные. | 4 — XIP-mmap блит ассетов из QSPI. Проверить конфиг prefetch в `bsp` FlexSPI. | A0, исправлено в A1 |
+| **ERR011377** | FlexSPI: бит статуса блокировки DLL неточен из-за тайминга. | 4/7 — запись/чтение QSPI-ассетов. | Без фикса |
+| **ERR011572** | Cortex-M7: write-through чтения/записи могут вернуть неверные данные. | Все — наш фреймбуфер `AT_NONCACHEABLE` обходит проблему; но любой write-through-регион под подозрением. | Без фикса |
+| **ERR006032 / ERR009527 / ERR009595** | FlexCAN: баги TX-пути (кадр с неверным ID при abort/deactivate в bus-idle; сбой abort; порча кадра при freeze/low-power в bus-off). | 2 — **если** «удалённая установка адреса» требует CAN **TX**; иначе 8. Пока только RX — низкий риск. | Без фикса |
+
---
## Приложение. Заимствования из проектов
diff --git a/firmware/tft_app/PLAN.md b/firmware/tft_app/PLAN.md
index 2a46064..e09894c 100644
--- a/firmware/tft_app/PLAN.md
+++ b/firmware/tft_app/PLAN.md
@@ -17,7 +17,7 @@
|---|---|---|
| 0 | Каркас проекта и сборка | ✅ |
| 1 | Walking skeleton (CAN → модель → этаж + стрелки) | ✅ |
-| 2 | Домен: полный `sul_result_t`, приоритеты, таймаут | ⬜ |
+| 2 | Домен: полный `sul_result_t`, приоритеты, таймаут | 🟨 |
| 3 | Настройки и меню, локальные входы (opto) | ⬜ |
| 4 | Ассеты: TLV-бандл, паковщик, XIP-блит | ⬜ |
| 5 | Layout-движок: схема, солвер, темы, injected-регион | ⬜ |
@@ -178,7 +178,42 @@ marks_both_pending`, аппаратно на реальном обрыве св
---
-## Фаза 2 — Домен: полный контракт ⬜
+## Фаза 2 — Домен: полный контракт 🟨
+
+> **Статус: код-комплит, host-покрытие зелёное; ожидает подтверждения на стенде.**
+> Домен (модель/декодер/приоритеты) и fallback-презентация написаны, host-тесты
+> **47** (34 декодер + 13 контроллер/приоритеты) зелёные, ARM Debug и Release
+> собираются/линкуются чисто. По прецеденту Фазы 1 статус ✅ ставится только после
+> проверки на железе (реальный НКУ-CAN трафик → режимы/следующий этаж на экране).
+>
+> Решения, принятые с пользователем по ходу:
+> - **Приоритет режимов** (таблица `k_mode_priority[]`, чистые данные, offset-массив):
+> fireman > пожар > перегруз > сейсмо > сервис > погрузка > норма. Менять приоритет =
+> переставить строки; добавить режим = дописать строку; резолвер не трогается (§7).
+> Таблица намеренно развязана от НКУ-CAN — обзор UIM/UKL/UEL/SD7 подтвердил, что
+> набор булевых сигналов `sul_result_t` §6 покрывает все протоколы (различаются лишь
+> битовые кодировки, живут в декодерах), а `cop_mode`/`display_id` — это провиженинг,
+> не данные СУЛ.
+> - **Удалённая установка адреса — отложена целиком в Фазу 3** (нужен `settings_store`
+> для записи `nku_address`; чистый декодер не должен писать настройки). Кадры
+> 0x4X1/0x5XB пока игнорируются как чужие ID. Адрес станции = 0 (хардкод, как в Фазе 1).
+> - **Декодер выдаёт УРОВНИ, не события.** Гонг/двери/начало движения — булевы уровни;
+> edge-детекция (→ звук) — Фаза 6 (audio_policy поверх diff контроллера). Двери в §6
+> не входят (только звук) — пересмотр в Фазе 6.
+> - **Мультиисточниковые поля** (overload из PACKET2+PACKET4; lading инструментальный
+> PACKET1 + временной PACKET3) — внутренние латчи в `nku_can_ctx_t`, на выход их OR:
+> пакет-без-сигнала не сбрасывает флаг, выставленный другим пакетом (боевой баг-класс).
+>
+> ⚠️ **Остаток Фазы 2 (не блокирует HW-проверку домена):** явный host-тест валидации
+> рендеримости позиции (покрытие активного шрифта + по-символьный fallback). Сам
+> МЕХАНИЗМ per-char fallback уже есть в `gfx_draw_string` с Фазы 1 (неизвестный глиф →
+> подстановка «-»); недостаёт выделенной чистой функции проверки покрытия и её теста —
+> требует небольшого выноса из `gfx.c` (зависит от `bsp_display`).
+>
+> Побочная находка: `SystemFont.c` (сгенерирован lcd-image-converter) экспортирует tFont
+> под именем `JBMono24` (из .xml), а `gfx.h` обещал `SystemFont` — рассинхрон был
+> латентным (Фаза 1 линковала только `FloorFontFallback`), вскрылся при первом
+> потребителе. Закрыт алиасом-макросом в `gfx.h`, сам сгенерированный файл не тронут.
**Цель.** `sul_result_t` в полном объёме (§6 ARCH), таблица приоритетов режимов, устойчивый
таймаут/потеря связи.
@@ -203,6 +238,11 @@ marks_both_pending`, аппаратно на реальном обрыве св
**Объём.** `services/settings_store` (магик/версия/CRC, сектор настроек §10); дескрипторы
per-protocol настроек (§8); `menu/` (навигация, редактирование, привязка к дескрипторам);
`bsp_button` + `bsp_opto` (диспетчерский вызов/ответ) → контроллер/презентация.
+**Рантайм-тумблер логгирования:** флаг `log_enabled` в настройках + пункт меню «включить
+логгирование» + рантайм-гейт в `utils/log` (`log_set_enabled()`, проверка в начале `log_write()`)
+поверх компайл-тайм `LOG_LEVEL`. Инфраструктура (`utils/log`, `port/log_uart`, `log_mutex`) уже
+готова — здесь только рантайм-гейт и его привязка к настройке/меню. NB: продакшн-сборка должна
+идти с `LOG_LEVEL >= INFO` (не `OFF`), иначе тумблеру нечего гейтить — макросы вырезаны компилятором.
**Тесты (host).** Сериализация/дефолты/CRC настроек; логика навигации меню; связывание
дескрипторов.
@@ -311,3 +351,7 @@ per-protocol дескрипторы настроек; клиентские та
- Формат исходника layout для host-утилиты (JSON/TOML) и позже GUI поверх компилятора. (Фаза 5)
- Renderable-множество символов на протокол vs общий шрифт; политика по-символьного fallback. (Фаза 2)
- Звук в бандле: WAV как есть или препарсенный PCM. (Фаза 6)
+- **Каталог доменных точек логгирования** (что / где / уровень: декодер, контроллер, транспорт,
+ смена режима, таймаут связи, …) — согласовать **совместно**, затем расставить **одним
+ выделенным проходом** по готовому домену. Инфраструктура готова (`utils/log` + `port/log_uart`,
+ компайл-тайм `LOG_LEVEL`). Рантайм-тумблер в меню — Фаза 3 (см. её объём).
diff --git a/firmware/tft_app/REPORT.md b/firmware/tft_app/REPORT.md
new file mode 100644
index 0000000..18dbd16
--- /dev/null
+++ b/firmware/tft_app/REPORT.md
@@ -0,0 +1,189 @@
+Готово. Полная повторная верификация:
+
+| Проверка | Результат |
+| ---------------------- | ------------------------------------------------------ |
+| Host-тесты | **19/19** зелёные |
+| Debug сборка+подпись | чисто, `image_ok` UNSET |
+| Release сборка+подпись | чисто, `image_ok` UNSET |
+| Оба бинарника | 2 097 152 байт (ровно размер слота) |
+| Аппаратно (ваш лог) | self-confirm пережил перезагрузку, CAN→рендер работает |
+
+PLAN.md обновлён: Фаза 1 → ✅, известное ограничение (single-buffer) зафиксировано с честной формулировкой механизма, явно вписано в объём Фазы 4 вместе с PXP. Также отметил, что критерий «timeout→--» проверен только логически (host-тестом), не на реальном обрыве связи — не стал завышать степень подтверждённости.
+
+Отчёт — [tft_app_phase_0_1_report.md](../../../private/tmp/claude-501/-Users-von-akimow-Desktop-TFT-ENV-tft-manufacture-test/93af6316-b6c7-4b2a-9063-169ca896d1ff/scratchpad/tft_app_phase_0_1_report.md). Полный текст ниже — можно скопировать целиком в новый тред:
+
+---
+
+```markdown
+# tft_app — отчёт по Фазам 0–1
+
+> Канонические источники — `firmware/tft_app/ARCH.md` (архитектура) и `firmware/tft_app/PLAN.md`
+> (статус фаз, живой документ, обновляется каждой фазой). Этот файл — снимок на момент завершения
+> Фазы 1, для быстрого ввода в контекст новой сессии. При расхождении — верить PLAN.md/ARCH.md, не
+> этому файлу.
+
+**Статус:** Фаза 0 ✅ и Фаза 1 ✅ — обе аппаратно подтверждены. Работа Фазы 1 **не закоммичена**
+(в рабочем дереве поверх коммита `# tft_app: Phase 0`, `a9e6681`).
+
+---
+
+## Фаза 0 — Каркас (FreeRTOS + self-confirm слота MCUboot)
+
+**Итог.** `firmware/tft_app` собирается как MCUboot-слот-образ (Direct-XIP), bootloader в него
+прыгает, FreeRTOS стартует, образ подтверждает себя в рантайме (`boot_set_next`) и переживает
+power cycle без отката — anti-brick сохранён. Подтверждено дважды на реальном железе (в т.ч.
+через переустановку с microSD).
+
+**Путь до рабочего состояния был не прямым — три независимых бага, все найдены и закрыты:**
+
+1. **`bsp_qspi_init()` обязателен до любого `flash_area_*`.** Без него запись `image_ok` в трейлер
+ слота молча падала — `boot_set_next()` возвращал успех, но ничего не персистило.
+2. **Корневая причина (самая коварная): забытый дефайн `__STARTUP_INITIALIZE_RAMFUNCTION`.**
+ Запись QSPI из app виснет в `qspi_write_fifo` — FlexSPI не дренирует IP TX FIFO. Причина:
+ startup копирует секцию `.ram_function` (код `bsp_qspi`, помеченный
+ `AT_QUICKACCESS_SECTION_CODE`) из flash в ITCM только под этим дефайном; `app` его не задавал
+ — в ITCM оставался leftover ramfunc **загрузчика** (тот исполнялся первым), поэтому чтения
+ «случайно работали», а запись — нет. Оба рабочих потребителя `bsp_qspi` (firmware_test,
+ bootloader) этот дефайн задают; `app` был первым, кто его пропустил. Диагностировано под
+ отладчиком (зависание конкретно в `qspi_write_fifo`), подтверждено изолированным
+ erase+write+readback self-test (частота мигания LED = индикатор результата) до восстановления
+ полной диагностики.
+3. **UART, не USB CDC, для диагностики/логов.** USB CDC (target-side EHCI/PHY) под FreeRTOS не
+ заработал стабильно за несколько заходов — первый в репозитории FreeRTOS-потребитель target
+ USB device stack, класс неопределённости остался непроверенным. LPUART1/MCU-Link VCOM — самый
+ проверенный канал в репозитории (все `tests/target/*` HIL-образы), доступен сразу через уже
+ подключённый для SWD кабель, без enumeration/wait. Решение принято явно с пользователем.
+
+**Побочные, но важные для будущих фаз находки:**
+- Любая bsp-инициализация, зависящая от тика (`bsp_delay()` внутри `bsp_usb_cdc_init()` и т.п.),
+ нельзя звать **до** `vTaskStartScheduler()` — в FreeRTOS-режиме `bsp_tick` это
+ `xTaskGetTickCount()`, который не идёт до старта планировщика → вызов зависает навсегда молча.
+ `main()` в итоге — минимальный bare-metal (board bring-up + запуск планировщика), вся
+ инициализация — из задачи.
+- Первый в репозитории потребитель `BSP_TICK_FREERTOS_MODE` вскрыл 2 латентных бага: (1) общий
+ `bsp_tick` — bare-metal STATIC-либа → `SysTick_Handler` конфликтует с FreeRTOS-портом (решение:
+ компилировать `tick.c` прямо в `app`, не через `libbsp_tick.a`); (2) `configTICK_RATE_HZ`
+ нельзя как `((TickType_t)1000)` — `#if` в `tick.c` не разбирает cast, только `1000`.
+- `.vscode/launch.json`: у `🐛 Debug: tft_app` был `preLaunchTask` на `firmware_test`
+ (copy-paste) — исправлено на `build:app-debug`.
+- Тонкий стек задачи (изначально x2=1 КБ) маскировался под тот же класс симптома, что и другие
+ баги (зависание/reset-loop) — увеличен с запасом (x4-x6, 2-3 КБ) везде, где заводятся новые
+ задачи.
+
+**Осознанное отклонение от исходного плана:** `firmware/bootloader/test_stub` НЕ удалён (план
+предполагал) — оставлен как независимая A/B-регрессия загрузчика, `app` пока не покрывает те же
+сценарии (hang-классы, revert без self-confirm).
+
+---
+
+## Фаза 1 — Walking skeleton (CAN → декодер → контроллер → fallback-рендер)
+
+**Итог.** Полный вертикальный срез: реальный CAN-кадр НКУ-CAN → чистый декодер → контроллер
+(diff) → fallback-рендер (позиция + стрелка) на экране. Подтверждено на TFT8 с реальным
+CAN-трафиком станции.
+
+### Архитектура (по слоям, каждый — своя CMake-либа, конвенция `bsp/`)
+
+```
+domain/elevator_model sul_result_t (pos: UTF-8 строка, direction: none/up/down/double),
+ sul_default_state()
+domain/sul sul_frame_t, sul_driver_t, sul_status_t, реестр драйверов
+ (заголовок и реализация реестра — РАЗНЫЕ CMake-таргеты,
+ иначе цикл: декодеру нужны только типы sul.h, реестру — decode() декодера)
+domain/sul/nku_can чистый декодер PACKET1 (направление) / PACKET3 (позиция) —
+ порт OLD_PROJECT msg_receiver_task, БЕЗ единого HAL-вызова
+domain/sul/transport/can тонкий bsp_can → sul_frame_t адаптер (HW, не host-тестируется)
+domain/controller process(sul_result_t) → indication_task_t (diff), кэш
+services/gfx framebuffer (SDRAM non-cacheable) + RLE-декодер шрифтов
+ (lcd-image-converter формат) + примитив стрелки
+ui/fallback рендер pos+стрелка, asset-free/FS-free (ARCH §11)
+app задачи sul_rx (WDOG+CAN+decode+controller) и render,
+ связаны очередью глубины 1 (xQueueOverwrite — важно
+ только последнее состояние, не история)
+```
+
+**Host-тесты (все зелёные, домен полностью протестирован до касания железа):**
+- `tests/host/tft_app_sul_nku` — 16 тестов, golden-векторы CAN-кадров (все direction, позиции,
+ легаси-квирк `left==0`≈пробел, кириллица, IGNORED/ERR на чужой ID/DLC/вне таблицы символов,
+ накопление состояния между PACKET1 и PACKET3).
+- `tests/host/tft_app_controller` — 6 тестов (diff-логика, timeout→default тем же путём, что и
+ обычный кадр).
+- Полный пакет: **19/19** (включая существующие тесты bootloader/bsp).
+
+### Ключевые находки/решения по ходу
+
+1. **Framebuffer в SDRAM обязан идти через `AT_NONCACHEABLE_SECTION_ALIGN`** (прямая рекомендация
+ NXP, порт паттерна из `OLD_PROJECT/source/display/image_cache.c`). MPU держит SDRAM как
+ `Normal Write-Back Cacheable` (`board_mpu_init()`, Region 8); ELCDIF читает framebuffer
+ напрямую по DMA — без non-cacheable региона CPU писал бы через кэш, DMA видел бы устаревшие
+ данные (мусор/частичные кадры на экране, выглядело бы как загадочный баг железа). Механизм уже
+ был параметризован в `board_mpu_init()` через линкер-символы `__NCACHE_REGION_START/SIZE`
+ (Region 9, унаследовано из bootloader) — переопределены в `app_slot.ld` на новый
+ `m_sdram_ncache` (2 МБ в начале SDRAM) вместо OCRAM (256 КБ, для framebuffer ~1.83 МБ не
+ влезло бы). На первой попытке framebuffer был сырым указателем на константный адрес (работало
+ случайно, линкер честно репортил `0 B` в секции) — исправлено на настоящее объявление
+ переменной через макрос.
+2. **RLE-декодер шрифтов — порт проверенного в проде алгоритма** из `OLD_PROJECT/source/fonts/
+ fonts.c` (UNIQUE/REPEATABLE блоки, бинарный поиск по отсортированному коду символа), не
+ переизобретён с нуля. Формат — то, что реально экспортирует lcd-image-converter (`tImage`/
+ `tChar`/`tFont`, ARGB8888, "Color A8R8G8B8"). Пользователь сгенерировал `FloorFontFallback.c`
+ (Inter 215pt, символы 0-9 и "-") и `SystemFont.c` (JetBrains Mono 24pt, ASCII+кириллица)
+ инструментом напрямую.
+3. **По-символьный fallback** (ARCH §11): символ вне таблицы шрифта → подстановка `'-'`
+ (согласованная политика — "-" общий заменитель во `FloorFontFallback`).
+4. Сгенерированные шрифтовые файлы ожидают типы из bare-name `fonts.h` по-разному
+ (`SystemFont.c` — явный `#include`, `FloorFontFallback.c` — вообще без include, разные версии
+ конвертера) — закрыто через `-include`/`-I` в CMake; сами сгенерированные файлы не
+ редактировались (конвертер их перезапишет при регенерации).
+5. Файлы шрифтов перенесены из `domain/` (где их положил пользователь) в `services/gfx/fonts/` —
+ по ARCH §4 шрифты относятся к gfx, не к домену (домен — чистый C без рендеринга).
+
+### ⚠️ Известное ограничение (зафиксировано, чинится в Фазе 4)
+
+Один framebuffer, без double buffering — `ui_fallback_render()` пишет напрямую в буфер, который
+ELCDIF в этот момент сканирует по DMA. Если перерисовка (`gfx_clear` + RLE-decode глифов +
+стрелка) не укладывается в один кадр развёртки, видно сам процесс закраски (глиф «набирается» по
+частям за несколько кадров) — **не tearing и не порча пикселей** (каждая отдельная запись всегда
+валидна), просто заметен сам процесс. Подтверждено на железе пользователем ("рвано, но без
+артефактов" — совпадает с механизмом).
+
+**Фикс — double buffering**, НЕ требует PXP отдельно (это вопрос буферизации, не композиции):
+второй framebuffer (`m_sdram_ncache` 2→4 МБ), swap через `bsp_display_set_next_buffer()` +
+семафор на `FRAME_DONE` (сейчас `gfx_init()` передаёт `NULL` вместо колбэка). Явно вписано в
+объём Фазы 4 (PLAN.md) вместе с PXP-компоновщиком (для многослойной композиции реальных
+ассетов — фон+спрайты+текст, та же фаза, естественно смежная работа).
+
+### Прочее
+
+- Критерий выхода выполнен на TFT8: реальный CAN-трафик → корректный номер этажа и стрелка.
+ Таймаут→"--" логически проверен host-тестом, аппаратно на реальном обрыве связи отдельно не
+ гонялся (низкий риск — тот же код-путь, что у обычного кадра).
+- Debug и Release оба собираются и подписываются чисто (2 097 152 байта — ровно размер слота,
+ `image_ok` UNSET в обоих — рантайм self-confirm, не sign-time `--confirm`).
+
+---
+
+## Состояние репозитория на конец Фазы 1
+
+- Ветка: `tft-app-dev`.
+- Коммит `a9e6681` (# tft_app: Phase 0) — последний закоммиченный чекпоинт.
+- Вся Фаза 1 — в рабочем дереве, не закоммичена: `firmware/tft_app/src/{domain,services/gfx,
+ ui/fallback}/`, `firmware/tft_app/src/app/main.c` (переписан), `tests/host/tft_app_sul_nku/`,
+ `tests/host/tft_app_controller/`, изменения в `CMakePresets.json`,
+ `cmake/linker/MIMXRT1052xxxxx_app_slot.ld`, `firmware/tft_app/CMakeLists.txt`,
+ `tests/host/CMakeLists.txt`, `firmware/tft_app/PLAN.md`.
+- Стоит рассмотреть коммит перед длительным перерывом/переключением контекста — на усмотрение
+ пользователя (не коммичу без явной просьбы).
+
+## Дальше — Фаза 2
+
+Полный контракт `sul_result_t` (next, сигналы, `lading_secs`, `floor_num`), полный декодер
+НКУ-CAN (PACKET1..5, режимы, удалённая установка адреса), таблица приоритетов режимов
+(данные, потенциально клиентские), валидация рендеримости позиции (renderable-множество шрифта +
+по-символьный fallback — механизм уже есть в `gfx`, п.3 выше). Подробности — PLAN.md, раздел
+«Фаза 2».
+```
+
+---
+
+Одно практическое замечание, раз вы переключаетесь в новый тред: вся Фаза 1 сейчас **не закоммичена** — сидит в рабочем дереве поверх коммита Фазы 0. Хотите, чтобы я закоммитил перед переключением (стандартный `git add` + сообщение по Фазе 1), или оставляете как есть?
\ No newline at end of file
diff --git a/firmware/tft_app/src/domain/controller/CMakeLists.txt b/firmware/tft_app/src/domain/controller/CMakeLists.txt
index 871029f..f9d94af 100644
--- a/firmware/tft_app/src/domain/controller/CMakeLists.txt
+++ b/firmware/tft_app/src/domain/controller/CMakeLists.txt
@@ -1,4 +1,4 @@
-add_library(tft_app_controller STATIC src/controller.c)
+add_library(tft_app_controller STATIC src/controller.c src/mode_priority.c)
target_include_directories(tft_app_controller PUBLIC include/)
diff --git a/firmware/tft_app/src/domain/controller/include/domain/controller.h b/firmware/tft_app/src/domain/controller/include/domain/controller.h
index 6686031..4958e7a 100644
--- a/firmware/tft_app/src/domain/controller/include/domain/controller.h
+++ b/firmware/tft_app/src/domain/controller/include/domain/controller.h
@@ -30,12 +30,24 @@ typedef struct
/**
* @brief Что изменилось с прошлого вызова — presentation перерисовывает
- * только помеченные поля.
+ * только помеченные поля, audio_policy (Фаза 6) озвучивает изменившиеся
+ * события поверх этого diff.
+ *
+ * mode_pending — сменился РАЗРЕШЁННЫЙ экранный режим (свёртка сигналов по
+ * таблице приоритетов, §7), а не отдельный сырой сигнал. arrival/movement —
+ * уровни из декодера; их edge (false→true) — сигнал к озвучке (гонг, старт).
*/
typedef struct
{
bool pos_pending;
+ bool next_pending;
bool direction_pending;
+ bool mode_pending;
+ bool arrival_pending;
+ bool movement_pending;
+
+ sul_mode_t mode; /**< разрешённый режим нового результата — presentation
+ читает его при mode_pending (не нужен отдельный getter) */
} indication_task_t;
/**
diff --git a/firmware/tft_app/src/domain/controller/include/domain/mode_priority.h b/firmware/tft_app/src/domain/controller/include/domain/mode_priority.h
new file mode 100644
index 0000000..f5d53f8
--- /dev/null
+++ b/firmware/tft_app/src/domain/controller/include/domain/mode_priority.h
@@ -0,0 +1,37 @@
+/**
+ * @file mode_priority.h
+ * @brief Свёртка ортогональных сигналов sul_result_t в один экранный режим
+ * по таблице приоритетов (ARCH.md §7).
+ *
+ * Таблица — ЧИСТЫЕ ДАННЫЕ (offset-массив в mode_priority.c), «потенциально
+ * клиентские» (§7): приоритет задаётся ПОРЯДКОМ строк, не значениями enum.
+ * Менять приоритет = переставить строки; добавить режим = дописать строку;
+ * резолвер при этом не трогается. Контроллер применяет активную таблицу.
+ *
+ * Чистый C, host-тестируется (tests/host/tft_app_controller).
+ */
+
+#ifndef DOMAIN_MODE_PRIORITY_H_
+#define DOMAIN_MODE_PRIORITY_H_
+
+#include "domain/elevator_model.h"
+
+#ifdef __cplusplus
+extern "C"
+{
+#endif
+
+/**
+ * @brief Разрешить экранный режим по активной таблице приоритетов.
+ *
+ * Проходит таблицу сверху вниз, возвращает режим первой строки, чей булев
+ * сигнал в @p p_result выставлен. Если ни один спецрежим не активен —
+ * SUL_MODE_NORMAL.
+ */
+sul_mode_t sul_resolve_mode(const sul_result_t *p_result);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* DOMAIN_MODE_PRIORITY_H_ */
diff --git a/firmware/tft_app/src/domain/controller/src/controller.c b/firmware/tft_app/src/domain/controller/src/controller.c
index d0d5f40..79c1ccb 100644
--- a/firmware/tft_app/src/domain/controller/src/controller.c
+++ b/firmware/tft_app/src/domain/controller/src/controller.c
@@ -1,5 +1,7 @@
#include "domain/controller.h"
+#include "domain/mode_priority.h"
+
#include
void controller_init(controller_ctx_t *p_ctx)
@@ -9,12 +11,21 @@ void controller_init(controller_ctx_t *p_ctx)
indication_task_t controller_process(controller_ctx_t *p_ctx, const sul_result_t *p_result)
{
- const indication_task_t task = {
+ const sul_mode_t PREV_MODE = sul_resolve_mode(&p_ctx->cache);
+ const sul_mode_t NEW_MODE = sul_resolve_mode(p_result);
+
+ const indication_task_t TASK = {
.pos_pending = (strcmp(p_ctx->cache.pos, p_result->pos) != 0),
+ .next_pending = (strcmp(p_ctx->cache.next, p_result->next) != 0),
.direction_pending = (p_ctx->cache.direction != p_result->direction),
+ .mode_pending = (PREV_MODE != NEW_MODE),
+ /* Событийные уровни — presentation/audio реагируют на фронт false→true. */
+ .arrival_pending = (!p_ctx->cache.arrival && p_result->arrival),
+ .movement_pending = (!p_ctx->cache.movement && p_result->movement),
+ .mode = NEW_MODE,
};
p_ctx->cache = *p_result;
- return task;
+ return TASK;
}
diff --git a/firmware/tft_app/src/domain/controller/src/mode_priority.c b/firmware/tft_app/src/domain/controller/src/mode_priority.c
new file mode 100644
index 0000000..53fdde5
--- /dev/null
+++ b/firmware/tft_app/src/domain/controller/src/mode_priority.c
@@ -0,0 +1,52 @@
+#include "domain/mode_priority.h"
+
+#include
+
+/**
+ * @brief Строка таблицы приоритетов: булев сигнал (по смещению в sul_result_t)
+ * → экранный режим.
+ *
+ * Смещение вместо указателя-на-член (в C его нет) — читаем bool по
+ * offsetof, что делает таблицу голыми данными без кода на строку.
+ */
+typedef struct
+{
+ size_t flag_offset; /**< offsetof(sul_result_t, ) */
+ sul_mode_t mode;
+} mode_rule_t;
+
+/**
+ * ТАБЛИЦА ПРИОРИТЕТОВ (ARCH §7). Порядок = приоритет, сверху вниз.
+ * Согласовано: fireman (высший) > пожар > перегруз > сейсмо > сервис >
+ * погрузка > (норма — когда ни одна строка не сработала).
+ *
+ * Изменить приоритет — переставить строки. Добавить режим — дописать строку
+ * (offset нового bool-сигнала + его sul_mode_t). Резолвер ниже не меняется.
+ * Клиентская кастомизация (§7) — подмена этого массива, без правок кода.
+ */
+static const mode_rule_t k_mode_priority[] = {
+ { offsetof(sul_result_t, fireman), SUL_MODE_FIREMAN },
+ { offsetof(sul_result_t, fire_alarm), SUL_MODE_FIRE_ALARM },
+ { offsetof(sul_result_t, overload), SUL_MODE_OVERLOAD },
+ { offsetof(sul_result_t, seismic), SUL_MODE_SEISMIC },
+ { offsetof(sul_result_t, maintenance), SUL_MODE_MAINTENANCE },
+ { offsetof(sul_result_t, lading), SUL_MODE_LADING },
+};
+
+#define MODE_PRIORITY_COUNT (sizeof(k_mode_priority) / sizeof(k_mode_priority[0]))
+
+sul_mode_t sul_resolve_mode(const sul_result_t *p_result)
+{
+ const uint8_t *p_base = (const uint8_t *) p_result;
+
+ for (size_t i = 0U; i < MODE_PRIORITY_COUNT; ++i)
+ {
+ const bool ACTIVE = *(const bool *) (p_base + k_mode_priority[i].flag_offset);
+ if (ACTIVE)
+ {
+ return k_mode_priority[i].mode;
+ }
+ }
+
+ return SUL_MODE_NORMAL;
+}
diff --git a/firmware/tft_app/src/domain/elevator_model/include/domain/elevator_model.h b/firmware/tft_app/src/domain/elevator_model/include/domain/elevator_model.h
index 7ef9d20..21bd96d 100644
--- a/firmware/tft_app/src/domain/elevator_model/include/domain/elevator_model.h
+++ b/firmware/tft_app/src/domain/elevator_model/include/domain/elevator_model.h
@@ -7,14 +7,17 @@
* (НКУ-CAN, УИМ, SD7, УЭЛ, УКЛ, …); конкретный декодер заполняет то, что несёт
* его протокол, остальное остаётся дефолтным.
*
- * Фаза 1 — подмножество: `pos` + `direction` (см. PLAN.md). Остальные поля
- * ARCH §6 (next, сигналы, lading_secs, floor_num) добавляются в Фазе 2 вместе
- * с полным декодером НКУ-CAN и таблицей приоритетов режимов.
+ * Обзор боевых декодеров (NKU-CAN, UIM, UKL, UEL, SD7) подтвердил: набор
+ * сигналов ниже покрывает все протоколы — различаются лишь битовые кодировки
+ * (живут в каждом декодере), не сам словарь сигналов. Провиженинг-концепты
+ * (напр. `cop_mode`/`display_id`) — это настройки устройства (§8), НЕ данные
+ * СУЛ, и в этой структуре им не место.
*/
#ifndef DOMAIN_ELEVATOR_MODEL_H_
#define DOMAIN_ELEVATOR_MODEL_H_
+#include
#include
#ifdef __cplusplus
@@ -45,25 +48,67 @@ typedef enum
} sul_direction_t;
/**
- * @brief Канонический результат декодирования кадра/пакета СУЛ.
+ * @brief Канонический экранный режим — результат свёртки ортогональных булевых
+ * сигналов `sul_result_t` через таблицу приоритетов (ARCH §7).
+ *
+ * ВАЖНО: числовые значения enum — просто идентификаторы, они НЕ задают
+ * приоритет. Приоритет определяется ТОЛЬКО порядком строк в таблице
+ * (domain/controller, `k_mode_priority[]`). Менять приоритет = переставлять
+ * строки таблицы, не трогая ни этот enum, ни резолвер.
+ */
+typedef enum
+{
+ SUL_MODE_NORMAL = 0, /**< обычная индикация этажа/стрелки */
+ SUL_MODE_LADING, /**< погрузка (инструментальная или временная) */
+ SUL_MODE_MAINTENANCE,/**< сервис / ревизия / МП1 / МП2 */
+ SUL_MODE_SEISMIC, /**< сейсмоопасность */
+ SUL_MODE_OVERLOAD, /**< перегруз */
+ SUL_MODE_FIRE_ALARM, /**< пожарная тревога */
+ SUL_MODE_FIREMAN, /**< режим пожарного */
+} sul_mode_t;
+
+/**
+ * @brief Канонический результат декодирования кадра/пакета СУЛ (ARCH §6).
*
* Позиция — UTF-8 строка (реальные шрифты ASCII + кириллица), не число:
* универсально для всех протоколов, host-тест сравнивает строки напрямую.
* Декодер ничего не знает про шрифт/рендер — валидация рендеримости
* (покрытие активным шрифтом) — задача UI-слоя (ARCH §11), не decode().
+ *
+ * Сигналы ОРТОГОНАЛЬНЫ (могут сосуществовать) — в один экранный режим их
+ * сводит controller по таблице приоритетов (§7), не декодер. Декодер выдаёт
+ * УРОВНИ (сигнал активен сейчас), а не события: edge-детекцию (гонг→звук,
+ * начало движения→звук) делает presentation/audio_policy поверх diff
+ * контроллера (ARCH §4, Фаза 6).
*/
typedef struct
{
- char pos[SUL_POS_BUF_LEN]; /**< позиция кабины, напр. "12", "-1", "П" */
+ char pos[SUL_POS_BUF_LEN]; /**< позиция кабины, напр. "12", "-1", "П" */
+ char next[SUL_POS_BUF_LEN]; /**< следующий этаж (пусто "" = нет данных) */
sul_direction_t direction;
+
+ /* Ортогональные сигналы (приоритет разрешает controller, §7): */
+ bool arrival; /**< гонг (прибытие) */
+ bool movement; /**< начало движения */
+ bool overload; /**< перегруз */
+ bool fire_alarm; /**< пожарная тревога */
+ bool lading; /**< погрузка */
+ bool maintenance; /**< сервисный режим */
+ bool fireman; /**< режим пожарного */
+ bool seismic; /**< сейсмоопасность */
+ bool error; /**< авария */
+
+ uint16_t lading_secs; /**< обратный отсчёт временной погрузки, сек; 0 = нет */
+ uint8_t floor_num; /**< производный числовой этаж для озвучки (0 = н/д) */
} sul_result_t;
/**
* @brief Состояние по умолчанию / при потере связи.
*
- * pos="--" (нет данных), direction=SUL_DIR_NONE. Вызывающий (контроллер через
- * sul_rx-задачу при таймауте, либо init перед первым кадром) использует это
- * как отправную точку — не HAL-зависимо, чистая функция.
+ * pos="--" (нет данных), next="", direction=SUL_DIR_NONE, все сигналы false,
+ * lading_secs=0, floor_num=0. Вызывающий (контроллер через sul_rx-задачу при
+ * таймауте, либо init перед первым кадром) использует это как отправную точку —
+ * не HAL-зависимо, чистая функция.
*/
sul_result_t sul_default_state(void);
diff --git a/firmware/tft_app/src/domain/sul/nku_can/include/domain/sul/nku_can.h b/firmware/tft_app/src/domain/sul/nku_can/include/domain/sul/nku_can.h
index 93ab7aa..7447c9e 100644
--- a/firmware/tft_app/src/domain/sul/nku_can/include/domain/sul/nku_can.h
+++ b/firmware/tft_app/src/domain/sul/nku_can/include/domain/sul/nku_can.h
@@ -2,9 +2,11 @@
* @file nku_can.h
* @brief Чистый декодер протокола НКУ-CAN (порт из OLD_PROJECT msg_receiver_task).
*
- * Фаза 1 — подмножество: PACKET1 (направление) + PACKET3 (позиция кабины).
- * Остальные пакеты (PACKET2/4/5, режимы, гонг, погрузка, удалённая установка
- * адреса) — Фаза 2, см. PLAN.md.
+ * Фаза 2 — полный разбор индикации: PACKET1 (направление, режимы, начало
+ * движения), PACKET2 (перегруз), PACKET3 (позиция, гонг, временная погрузка),
+ * PACKET4 (перегруз-вариант, сейсмо), PACKET5 (следующий этаж). Удалённая
+ * установка адреса (0x4X1/0x5XB) — Фаза 3 (нужен settings_store для записи);
+ * здесь эти кадры игнорируются как чужие ID.
*
* Адрес станции (nku_address) захардкожен в 0 — фильтры/ID без сдвига группы.
* Фаза 3 параметризует через настройки; сигнатура decode() не изменится.
@@ -25,19 +27,30 @@ extern "C"
#endif
/**
- * @brief Состояние декодера — накопленный текущий sul_result_t.
+ * @brief Состояние декодера — накопленный текущий sul_result_t + внутренние
+ * латчи для полей с несколькими источниками.
*
- * PACKET1 и PACKET3 несут РАЗНЫЕ поля (направление / позицию) в разных
- * кадрах — decode() обновляет только своё поле в state и отдаёт наружу
- * ПОЛНУЮ накопленную копию, а не только то, что пришло в этом кадре.
+ * Разные пакеты несут РАЗНЫЕ поля в разных кадрах — decode() обновляет только
+ * пришедшее и отдаёт наружу ПОЛНУЮ накопленную копию state, а не дельту.
*
- * Фаза 2 добавит сюда edge-detection состояние для гонга/погрузки/режимов
- * (аналог event_edge_t в legacy) — структура специально не голый sul_result_t,
- * чтобы не менять сигнатуру при расширении.
+ * Некоторые ВЫХОДНЫЕ поля кормятся НЕСКОЛЬКИМИ пакетами и должны быть их OR,
+ * иначе пакет-без-сигнала сбросил бы флаг, выставленный другим пакетом (боевой
+ * баг-класс из legacy — там держались parallel-флаги overload_flag/_new,
+ * lading_flag/time_lading). Держим эти под-источники раздельно в ctx и
+ * пересчитываем выход как OR после каждого пакета:
+ * state.overload = overload_p2 || overload_p4;
+ * state.lading = lading_instr || (lading_secs > 0).
*/
typedef struct
{
sul_result_t state;
+
+ /* Раздельные под-источники (см. докстроку выше). */
+ bool overload_p2; /**< PACKET2: data[7] & 0x40 */
+ bool overload_p4; /**< PACKET4: data[5] & 0x40 */
+ bool lading_instr; /**< PACKET1: код режима 0x10 (инструментальная) */
+ uint8_t current_level; /**< PACKET1: data[3] & 0x3F — числовой уровень остановки,
+ для гейта «следующего этажа» в PACKET5 */
} nku_can_ctx_t;
/** Сброс к состоянию по умолчанию (sul_default_state()). Вызвать перед первым decode(). */
diff --git a/firmware/tft_app/src/domain/sul/nku_can/src/nku_can.c b/firmware/tft_app/src/domain/sul/nku_can/src/nku_can.c
index b2ca756..63b2c6e 100644
--- a/firmware/tft_app/src/domain/sul/nku_can/src/nku_can.c
+++ b/firmware/tft_app/src/domain/sul/nku_can/src/nku_can.c
@@ -3,18 +3,45 @@
#include
#include
-/* Фаза 1: адрес станции захардкожен в 0 — базовые ID без сдвига группы
- * (легаси: 0x506|group4, group4 = nku_address<<4; для address=0 group4=0).
- * Фаза 3 параметризует через настройки. */
-#define PACKET1_ID 0x506U
-#define PACKET3_ID 0x508U
+/* Адрес станции захардкожен в 0 (Фаза 1/2) — базовые ID без сдвига группы
+ * (легаси: 0x506|group4, group4 = nku_address<<4; для address=0 group4=0;
+ * PACKET5 — group6 = nku_address<<6). Фаза 3 параметризует через настройки. */
+#define PACKET1_ID 0x506U /* направление, режимы, начало движения, двери */
+#define PACKET2_ID 0x408U /* перегруз (вариант 1) */
+#define PACKET3_ID 0x508U /* позиция кабины, гонг, временная погрузка */
+#define PACKET4_ID 0x50BU /* перегруз (вариант 2), сейсмоопасность */
+#define PACKET5_ID 0x606U /* следующий этаж */
#define PROTO_DLC 8U
-#define ARROW_MASK 0x03U
-#define FLOOR_MASK 0x3FU
+#define ARROW_MASK 0x03U /* PACKET1 data[6][1:0] — стрелка */
+#define MOVEMENT_MASK 0x0CU /* PACKET1 data[6][3:2] — начало движения */
+#define ICON_MASK 0xF0U /* PACKET1 data[6][7:4] — код режима */
+#define FLOOR_MASK 0x3FU /* символ этажа / числовой уровень */
+/* Двери (PACKET1 data[4]: откр. 0x10 / закр. 0x20) — только озвучка, не поле
+ * §6; декодируются в Фазе 6 (audio_policy). Здесь намеренно не разбираются. */
-#define SYMBOL_SPACE 16U
-#define SYMBOL_TOTAL 38U
+/* Коды режима в нибле data[6] & ICON_MASK (PACKET1). Точные значения нибла. */
+#define ICON_LADING 0x10U /* инструментальная погрузка */
+#define ICON_MP1 0x30U /* сервис / МП1 */
+#define ICON_REVISION 0x40U /* ревизия */
+#define ICON_MP2 0x50U /* сервис / МП2 */
+#define ICON_FIRE 0x70U /* пожарная тревога */
+#define ICON_FIREMAN 0xF0U /* режим пожарного */
+
+#define WEIGHT_MASK 0x40U /* PACKET2 data[7] / PACKET4 data[5] — перегруз */
+#define GONG_MASK 0x40U /* PACKET3 data[3] — гонг активен при БИТЕ == 0 */
+#define LADING_SEC_MASK 0x3FU /* PACKET3 data[2] — секунды погрузки */
+#define LADING_MIN_MASK 0x0FU /* PACKET3 data[3] — минуты погрузки */
+#define SEISMIC_MASK 0x80U /* PACKET4 data[0] — сейсмоопасность */
+
+#define SYMBOL_SPACE 16U
+#define SYMBOL_A 10U
+#define SYMBOL_P 17U /* "П" */
+#define SYMBOL_p 19U /* "п" */
+#define SYMBOL_HYPHEN 22U /* "-" */
+#define SYMBOL_TOTAL 38U
+
+#define FLOOR_NUM_UNKNOWN 60U /* «н/д» для озвучки — как в legacy floor_number_parser */
/* Таблица символов НКУ-CAN — порт из OLD_PROJECT floor_string_composer()
* (source/main_programm.c). Индекс — код символа с шины (байт & FLOOR_MASK). */
@@ -25,60 +52,174 @@ static const char *const S_SYMBOL_TABLE[SYMBOL_TOTAL] = {
void nku_can_init(nku_can_ctx_t *p_ctx)
{
- p_ctx->state = sul_default_state();
+ p_ctx->state = sul_default_state();
+ p_ctx->overload_p2 = false;
+ p_ctx->overload_p4 = false;
+ p_ctx->lading_instr = false;
+ p_ctx->current_level = 0U;
+}
+
+/* Пересчёт выходных полей, кормящихся несколькими пакетами (см. nku_can.h). */
+static void recompute_multi_source(nku_can_ctx_t *p_ctx)
+{
+ p_ctx->state.overload = p_ctx->overload_p2 || p_ctx->overload_p4;
+ p_ctx->state.lading = p_ctx->lading_instr || (p_ctx->state.lading_secs > 0U);
}
/**
- * @brief PACKET1 (0x506) — направление движения.
+ * @brief Составить UTF-8 строку этажа из кодов левого/правого символа.
*
- * ARROW_MASK=0x03 на data[6] (DATA7): 0=нет/1=вверх/2=вниз/3=двойная стрелка.
- * Легаси (msg_receiver_task, PACKET1) обрабатывал только 0/1/2 — case 3
- * отсутствовал (направление молча не менялось). 3 = «двойная стрелка»
- * (спецрежим индикации, словарь special/DisplayArrowIcon) — уточнено отдельно.
+ * Порт floor_string_composer(): left==SPACE или left==0 → однозначный этаж
+ * (только правый символ; станция шлёт 0x00 как «нет левого символа», трактуем
+ * как пробел). Возврат false — код вне таблицы (малформированный кадр).
+ */
+static bool compose_chars(uint8_t left, uint8_t right, char *p_out)
+{
+ if ((left >= SYMBOL_TOTAL) || (right >= SYMBOL_TOTAL))
+ {
+ return false;
+ }
+
+ if ((left == SYMBOL_SPACE) || (left == 0U))
+ {
+ (void) snprintf(p_out, SUL_POS_BUF_LEN, "%s", S_SYMBOL_TABLE[right]);
+ }
+ else
+ {
+ (void) snprintf(p_out, SUL_POS_BUF_LEN, "%s%s", S_SYMBOL_TABLE[left],
+ S_SYMBOL_TABLE[right]);
+ }
+
+ return true;
+}
+
+/**
+ * @brief Производный числовой этаж для озвучки — порт floor_number_parser().
*
- * Маска покрывает ровно 0..3 — весь диапазон sul_direction_t, прямое
- * приведение типа корректно без switch/default.
+ * Стандартные (0..40): left*10+right (или только right при пробеле).
+ * Отрицательные ("-" слева): 40+right. Подвальные ("П"/"п" слева): 50+right.
+ * Всё нераспознанное / вне диапазона → FLOOR_NUM_UNKNOWN (60).
+ */
+static uint8_t floor_number_parser(uint8_t left, uint8_t right)
+{
+ if (((left < SYMBOL_A) || (left == SYMBOL_SPACE)) && (right < SYMBOL_A))
+ {
+ const uint8_t FLOOR = (left == SYMBOL_SPACE) ? right : (uint8_t) (left * 10U + right);
+ return (FLOOR < 41U) ? FLOOR : FLOOR_NUM_UNKNOWN;
+ }
+
+ if ((left == SYMBOL_HYPHEN) && (right < SYMBOL_A))
+ {
+ return (right > 0U) ? (uint8_t) (40U + right) : FLOOR_NUM_UNKNOWN;
+ }
+
+ if (((left == SYMBOL_P) || (left == SYMBOL_p)) && (right < SYMBOL_A))
+ {
+ return (uint8_t) (50U + right);
+ }
+
+ return FLOOR_NUM_UNKNOWN;
+}
+
+/**
+ * @brief PACKET1 (0x506) — направление, режим, начало движения, уровень остановки.
+ *
+ * data[6]: [1:0] стрелка · [3:2] начало движения · [7:4] код режима.
+ * data[3][5:0]: числовой уровень остановки (гейт «следующего этажа», PACKET5).
+ *
+ * Режимные флаги, которыми владеет ТОЛЬКО PACKET1 (fire/maintenance/fireman и
+ * инструментальная погрузка), сбрасываются в начале и выставляются по нибле —
+ * так пакет-без-режима гасит устаревший режим (как в legacy). overload и
+ * seismic PACKET1 НЕ трогает (ими владеют PACKET2/4).
*/
static void decode_packet1(nku_can_ctx_t *p_ctx, const uint8_t *p_data)
{
p_ctx->state.direction = (sul_direction_t) (p_data[6] & ARROW_MASK);
+ p_ctx->state.movement = ((p_data[6] & MOVEMENT_MASK) != 0U);
+ p_ctx->current_level = p_data[3] & FLOOR_MASK;
+
+ /* Стрелка «нет движения» → сбрасываем следующий этаж (появляется только
+ * пока кабина едет, гаснет на прибытии — см. PACKET5). */
+ if (p_ctx->state.direction == SUL_DIR_NONE)
+ {
+ p_ctx->state.next[0] = '\0';
+ }
+
+ p_ctx->state.fire_alarm = false;
+ p_ctx->state.maintenance = false;
+ p_ctx->state.fireman = false;
+ p_ctx->lading_instr = false;
+
+ switch (p_data[6] & ICON_MASK)
+ {
+ case ICON_FIRE:
+ p_ctx->state.fire_alarm = true;
+ break;
+ case ICON_MP1:
+ case ICON_MP2:
+ case ICON_REVISION:
+ p_ctx->state.maintenance = true;
+ break;
+ case ICON_LADING:
+ p_ctx->lading_instr = true;
+ break;
+ case ICON_FIREMAN:
+ p_ctx->state.fireman = true;
+ break;
+ default:
+ break;
+ }
}
/**
- * @brief PACKET3 (0x508) — позиция кабины (left/right символы).
+ * @brief PACKET3 (0x508) — позиция кабины, гонг, временная погрузка.
*
- * FLOOR_MASK=0x3F на data[5] (left) и data[6] (right) — порт
- * floor_string_composer() из OLD_PROJECT. left==SPACE или left==0 →
- * однозначный этаж, выводится только правый символ (легаси трактует байт
- * 0x00 так же, как пробел — станция может слать нулевой байт вместо явного
- * кода пробела для «нет левого символа»; порт без изменений поведения).
- *
- * В отличие от легаси (молча оставляет буфер как есть при выходе за
- * SYMBOL_TOTAL — маска даёт до 63 сырых значений при 38 валидных символах),
- * здесь это ошибка (false) — не полагаемся на предыдущее содержимое буфера.
- *
- * @return false — left/right вне таблицы символов (малформированный кадр).
+ * @return false — код символа позиции вне таблицы (малформированный кадр).
*/
static bool decode_packet3(nku_can_ctx_t *p_ctx, const uint8_t *p_data)
{
const uint8_t LEFT = p_data[5] & FLOOR_MASK;
const uint8_t RIGHT = p_data[6] & FLOOR_MASK;
- if ((LEFT >= SYMBOL_TOTAL) || (RIGHT >= SYMBOL_TOTAL))
+ if (!compose_chars(LEFT, RIGHT, p_ctx->state.pos))
{
return false;
}
+ p_ctx->state.floor_num = floor_number_parser(LEFT, RIGHT);
- if ((LEFT == SYMBOL_SPACE) || (LEFT == 0U))
+ /* Гонг активен, когда БИТ 0x40 в data[3] СБРОШЕН (инверсная кодировка). */
+ p_ctx->state.arrival = ((p_data[3] & GONG_MASK) == 0U);
+
+ /* Временная погрузка: остаток = минуты*60 + секунды. */
+ const uint8_t SECS = p_data[2] & LADING_SEC_MASK;
+ const uint8_t MINS = p_data[3] & LADING_MIN_MASK;
+ p_ctx->state.lading_secs = (uint16_t) ((uint16_t) MINS * 60U + SECS);
+
+ return true;
+}
+
+/**
+ * @brief PACKET5 (0x606) — следующий этаж.
+ *
+ * Доверяем «следующему этажу» только пока кабина реально едет (↑/↓) и байт
+ * назначения (data[0]) отличается от текущего уровня остановки — 0x606 и 0x506
+ * приходят асинхронно, устаревший байт назначения на стоянке порождал бы
+ * ложную индикацию (боевая заметка legacy). Иначе — очищаем next.
+ *
+ * @return false — код символа вне таблицы (малформированный кадр); при этом
+ * поле next не трогаем.
+ */
+static bool decode_packet5(nku_can_ctx_t *p_ctx, const uint8_t *p_data)
+{
+ const bool MOVING =
+ (p_ctx->state.direction == SUL_DIR_UP) || (p_ctx->state.direction == SUL_DIR_DOWN);
+
+ if (MOVING && (p_data[0] != p_ctx->current_level))
{
- (void) snprintf(p_ctx->state.pos, SUL_POS_BUF_LEN, "%s", S_SYMBOL_TABLE[RIGHT]);
- }
- else
- {
- (void) snprintf(p_ctx->state.pos, SUL_POS_BUF_LEN, "%s%s", S_SYMBOL_TABLE[LEFT],
- S_SYMBOL_TABLE[RIGHT]);
+ return compose_chars(p_data[3] & FLOOR_MASK, p_data[4] & FLOOR_MASK, p_ctx->state.next);
}
+ p_ctx->state.next[0] = '\0';
return true;
}
@@ -86,19 +227,25 @@ sul_status_t nku_can_decode(void *p_ctx, const sul_frame_t *p_frame, sul_result_
{
nku_can_ctx_t *p_state = (nku_can_ctx_t *) p_ctx;
- if (p_frame->id == PACKET1_ID)
+ switch (p_frame->id)
{
+ case PACKET1_ID:
if (p_frame->len != PROTO_DLC)
{
return SUL_STATUS_ERR;
}
decode_packet1(p_state, p_frame->p_data);
- *p_out = p_state->state;
- return SUL_STATUS_OK;
- }
+ break;
- if (p_frame->id == PACKET3_ID)
- {
+ case PACKET2_ID:
+ if (p_frame->len != PROTO_DLC)
+ {
+ return SUL_STATUS_ERR;
+ }
+ p_state->overload_p2 = ((p_frame->p_data[7] & WEIGHT_MASK) == WEIGHT_MASK);
+ break;
+
+ case PACKET3_ID:
if (p_frame->len != PROTO_DLC)
{
return SUL_STATUS_ERR;
@@ -107,9 +254,33 @@ sul_status_t nku_can_decode(void *p_ctx, const sul_frame_t *p_frame, sul_result_
{
return SUL_STATUS_ERR;
}
- *p_out = p_state->state;
- return SUL_STATUS_OK;
+ break;
+
+ case PACKET4_ID:
+ if (p_frame->len != PROTO_DLC)
+ {
+ return SUL_STATUS_ERR;
+ }
+ p_state->overload_p4 = ((p_frame->p_data[5] & WEIGHT_MASK) == WEIGHT_MASK);
+ p_state->state.seismic = ((p_frame->p_data[0] & SEISMIC_MASK) == SEISMIC_MASK);
+ break;
+
+ case PACKET5_ID:
+ if (p_frame->len != PROTO_DLC)
+ {
+ return SUL_STATUS_ERR;
+ }
+ if (!decode_packet5(p_state, p_frame->p_data))
+ {
+ return SUL_STATUS_ERR;
+ }
+ break;
+
+ default:
+ return SUL_STATUS_IGNORED;
}
- return SUL_STATUS_IGNORED;
+ recompute_multi_source(p_state);
+ *p_out = p_state->state;
+ return SUL_STATUS_OK;
}
diff --git a/firmware/tft_app/src/services/gfx/include/services/gfx.h b/firmware/tft_app/src/services/gfx/include/services/gfx.h
index 1ce4a19..e5738c0 100644
--- a/firmware/tft_app/src/services/gfx/include/services/gfx.h
+++ b/firmware/tft_app/src/services/gfx/include/services/gfx.h
@@ -50,7 +50,15 @@ typedef struct
/* Компилируемые в прошивку шрифты (services/gfx/fonts/, сгенерированы
* lcd-image-converter пользователем). */
extern const tFont FloorFontFallback; /* 0-9, "-", пробел — fallback (§11 ARCH) */
-extern const tFont SystemFont; /* ASCII + кириллица — логи/меню */
+
+/* SystemFont — ASCII + кириллица (логи/меню, метки режимов в fallback).
+ * Сгенерированный SystemFont.c экспортирует tFont под именем из .xml
+ * конвертера — JBMono24 (JetBrains Mono 24pt). Публичное имя API — SystemFont;
+ * связываем алиасом-макросом, не редактируя сгенерированный файл (он
+ * перезапишется при регенерации шрифта). Использовать как объект: &SystemFont —
+ * симметрично FloorFontFallback. */
+extern const tFont JBMono24;
+#define SystemFont JBMono24
/** XRGB8888 (X игнорируется ELCDIF) — X-байт значения не имеет. */
typedef uint32_t gfx_color_t;
diff --git a/firmware/tft_app/src/ui/fallback/src/fallback.c b/firmware/tft_app/src/ui/fallback/src/fallback.c
index 391bfd9..c958357 100644
--- a/firmware/tft_app/src/ui/fallback/src/fallback.c
+++ b/firmware/tft_app/src/ui/fallback/src/fallback.c
@@ -1,8 +1,11 @@
#include "ui/fallback.h"
+#include "domain/mode_priority.h"
#include "services/gfx.h"
-/* Фаза 1: позиции захардкожены под TFT8 (800×600, panel текущего стенда).
+#include
+
+/* Фаза 1/2: позиции захардкожены под TFT8 (800×600, panel текущего стенда).
* Цифра '0' FloorFontFallback — 132×162 px (проверено по сгенерированным
* данным шрифта). Фаза 5 (layout-движок) заменит на якорное позиционирование,
* независимое от разрешения панели. */
@@ -10,17 +13,43 @@
#define POS_Y 150U
#define ARROW_Y 380U
#define ARROW_SIZE 80U
+#define MODE_Y 260U /* строка режима (SystemFont), safe-mode индикация */
+#define LADING_Y 330U /* обратный отсчёт погрузки (крупным шрифтом) */
-static void render(const sul_result_t *p_result)
+/**
+ * @brief Короткая asset-free метка спецрежима (ARCH §11: fallback без
+ * спрайтов/FS). Богатая полноэкранная графика режимов — Фаза 4/5;
+ * здесь — только текст системным шрифтом, чтобы режим был виден и в
+ * safe-mode (без ассетов). NORMAL метки не имеет.
+ */
+static const char *mode_label(sul_mode_t mode)
{
- gfx_clear(GFX_COLOR_BLACK);
+ switch (mode)
+ {
+ case SUL_MODE_FIREMAN: return "ПОЖАРНЫЙ";
+ case SUL_MODE_FIRE_ALARM: return "ПОЖАР";
+ case SUL_MODE_OVERLOAD: return "ПЕРЕГРУЗ";
+ case SUL_MODE_SEISMIC: return "СЕЙСМО";
+ case SUL_MODE_MAINTENANCE: return "СЕРВИС";
+ case SUL_MODE_LADING: return "ПОГРУЗКА";
+ case SUL_MODE_NORMAL:
+ default: return NULL;
+ }
+}
- const uint16_t str_width = gfx_string_width(&FloorFontFallback, p_result->pos);
- const uint16_t pos_x = (uint16_t) ((PANEL_WIDTH - str_width) / 2U);
- (void) gfx_draw_string(&FloorFontFallback, p_result->pos, pos_x, POS_Y);
+static void draw_centered(const tFont *p_font, const char *p_str, uint16_t y)
+{
+ const uint16_t width = gfx_string_width(p_font, p_str);
+ const uint16_t x = (uint16_t) ((PANEL_WIDTH - width) / 2U);
+ (void) gfx_draw_string(p_font, p_str, x, y);
+}
- /* SUL_DIR_NONE — без стрелки. SUL_DIR_DOUBLE — тоже (не путать с
- * up/down одной стрелкой; отдельная индикация — Фаза 2). */
+static void render_normal(const sul_result_t *p_result)
+{
+ draw_centered(&FloorFontFallback, p_result->pos, POS_Y);
+
+ /* SUL_DIR_NONE — без стрелки. SUL_DIR_DOUBLE — тоже (двойная стрелка —
+ * отдельная спрайтовая индикация Фазы 4/5, в fallback не рисуем). */
if ((p_result->direction == SUL_DIR_UP) || (p_result->direction == SUL_DIR_DOWN))
{
const gfx_arrow_dir_t dir = (p_result->direction == SUL_DIR_UP) ? GFX_ARROW_UP : GFX_ARROW_DOWN;
@@ -29,16 +58,44 @@ static void render(const sul_result_t *p_result)
}
}
+static void render(sul_mode_t mode, const sul_result_t *p_result)
+{
+ gfx_clear(GFX_COLOR_BLACK);
+
+ const char *label = mode_label(mode);
+ if (label == NULL)
+ {
+ render_normal(p_result);
+ return;
+ }
+
+ /* Спецрежим: safe-mode текст. Для временной погрузки — ещё и обратный
+ * отсчёт крупным шрифтом (lading_secs>0 отличает временную от
+ * инструментальной). */
+ draw_centered(&SystemFont, label, MODE_Y);
+
+ if ((mode == SUL_MODE_LADING) && (p_result->lading_secs > 0U))
+ {
+ char secs[6];
+ (void) snprintf(secs, sizeof(secs), "%u", (unsigned) p_result->lading_secs);
+ draw_centered(&FloorFontFallback, secs, LADING_Y);
+ }
+}
+
void ui_fallback_render_initial(const sul_result_t *p_result)
{
- render(p_result);
+ render(sul_resolve_mode(p_result), p_result);
}
void ui_fallback_render(const indication_task_t *p_task, const sul_result_t *p_result)
{
- if (!p_task->pos_pending && !p_task->direction_pending)
+ /* Перерисовываем на изменение того, что fallback реально показывает:
+ * позиция, стрелка, режим (+ отсчёт погрузки идёт вместе с mode/pos).
+ * next-этаж в safe-mode не рисуется (богатый layout — Фаза 5), поэтому
+ * на next_pending не будим. */
+ if (!p_task->pos_pending && !p_task->direction_pending && !p_task->mode_pending)
{
return;
}
- render(p_result);
+ render(p_task->mode, p_result);
}
diff --git a/tests/host/CMakeLists.txt b/tests/host/CMakeLists.txt
index 8acc72e..db97424 100644
--- a/tests/host/CMakeLists.txt
+++ b/tests/host/CMakeLists.txt
@@ -325,7 +325,7 @@ add_host_test(
tft_app_smoke/test_tft_app_smoke.c)
# -----------------------------------------------------------------------------
-# tft_app — domain (Фаза 1: чистый декодер НКУ-CAN + контроллер)
+# tft_app — domain (Фаза 1/2: чистый декодер НКУ-CAN + контроллер + приоритеты)
# -----------------------------------------------------------------------------
set(TFT_APP_DOMAIN_DIR ${PROJECT_SOURCE_DIR}/firmware/tft_app/src/domain)
@@ -348,6 +348,7 @@ add_host_test(
tft_app_controller/test_controller.c
${TFT_APP_DOMAIN_DIR}/elevator_model/src/elevator_model.c
${TFT_APP_DOMAIN_DIR}/controller/src/controller.c
+ ${TFT_APP_DOMAIN_DIR}/controller/src/mode_priority.c
INCLUDES
${TFT_APP_DOMAIN_DIR}/elevator_model/include
${TFT_APP_DOMAIN_DIR}/controller/include)
diff --git a/tests/host/tft_app_controller/test_controller.c b/tests/host/tft_app_controller/test_controller.c
index da9e087..fc28696 100644
--- a/tests/host/tft_app_controller/test_controller.c
+++ b/tests/host/tft_app_controller/test_controller.c
@@ -8,6 +8,7 @@
#include "unity.h"
#include "domain/controller.h"
+#include "domain/mode_priority.h"
#include
@@ -109,6 +110,149 @@ static void test_feeding_default_state_after_real_data_marks_both_pending(void)
TEST_ASSERT_TRUE(task.direction_pending);
}
+/* ── Таблица приоритетов режимов (sul_resolve_mode) ──────────────────── */
+
+static void test_resolve_mode_normal_when_no_signals(void)
+{
+ const sul_result_t r = sul_default_state();
+ TEST_ASSERT_EQUAL(SUL_MODE_NORMAL, sul_resolve_mode(&r));
+}
+
+static void test_resolve_mode_single_signals(void)
+{
+ sul_result_t r;
+
+ r = sul_default_state();
+ r.fireman = true;
+ TEST_ASSERT_EQUAL(SUL_MODE_FIREMAN, sul_resolve_mode(&r));
+
+ r = sul_default_state();
+ r.fire_alarm = true;
+ TEST_ASSERT_EQUAL(SUL_MODE_FIRE_ALARM, sul_resolve_mode(&r));
+
+ r = sul_default_state();
+ r.overload = true;
+ TEST_ASSERT_EQUAL(SUL_MODE_OVERLOAD, sul_resolve_mode(&r));
+
+ r = sul_default_state();
+ r.seismic = true;
+ TEST_ASSERT_EQUAL(SUL_MODE_SEISMIC, sul_resolve_mode(&r));
+
+ r = sul_default_state();
+ r.maintenance = true;
+ TEST_ASSERT_EQUAL(SUL_MODE_MAINTENANCE, sul_resolve_mode(&r));
+
+ 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.fireman = true;
+ r.fire_alarm = true; /* fireman выигрывает у пожара */
+ TEST_ASSERT_EQUAL(SUL_MODE_FIREMAN, sul_resolve_mode(&r));
+
+ r = sul_default_state();
+ r.fire_alarm = true;
+ r.overload = true; /* пожар выигрывает у перегруза */
+ TEST_ASSERT_EQUAL(SUL_MODE_FIRE_ALARM, sul_resolve_mode(&r));
+
+ r = sul_default_state();
+ r.overload = true;
+ r.seismic = true; /* перегруз выигрывает у сейсмо */
+ TEST_ASSERT_EQUAL(SUL_MODE_OVERLOAD, sul_resolve_mode(&r));
+
+ r = sul_default_state();
+ r.seismic = true;
+ r.maintenance = true; /* сейсмо выигрывает у сервиса */
+ TEST_ASSERT_EQUAL(SUL_MODE_SEISMIC, sul_resolve_mode(&r));
+
+ r = sul_default_state();
+ r.maintenance = true;
+ r.lading = true; /* сервис выигрывает у погрузки */
+ TEST_ASSERT_EQUAL(SUL_MODE_MAINTENANCE, sul_resolve_mode(&r));
+}
+
+/* ── Diff новых полей контроллера ────────────────────────────────────── */
+
+static void test_mode_pending_on_mode_change(void)
+{
+ controller_ctx_t ctx;
+ controller_init(&ctx);
+
+ sul_result_t normal = sul_default_state();
+ (void) snprintf(normal.pos, SUL_POS_BUF_LEN, "5");
+ (void) controller_process(&ctx, &normal);
+
+ 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); /* позиция не менялась */
+}
+
+/* Смена сырого сигнала, не меняющая РАЗРЕШЁННЫЙ режим (перегруз при активном
+ * пожаре), не должна поднимать mode_pending. */
+static void test_mode_pending_stable_when_resolved_mode_unchanged(void)
+{
+ controller_ctx_t ctx;
+ controller_init(&ctx);
+
+ sul_result_t fire = sul_default_state();
+ fire.fire_alarm = true;
+ (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);
+
+ TEST_ASSERT_FALSE(task.mode_pending);
+ TEST_ASSERT_EQUAL(SUL_MODE_FIRE_ALARM, task.mode);
+}
+
+static void test_arrival_and_movement_pending_on_rising_edge(void)
+{
+ controller_ctx_t ctx;
+ controller_init(&ctx);
+
+ 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;
+ 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);
+}
+
+static void test_next_pending_on_next_floor_change(void)
+{
+ controller_ctx_t ctx;
+ controller_init(&ctx);
+
+ sul_result_t r = sul_default_state();
+ (void) controller_process(&ctx, &r);
+
+ (void) snprintf(r.next, SUL_POS_BUF_LEN, "16");
+ const indication_task_t task = controller_process(&ctx, &r);
+ TEST_ASSERT_TRUE(task.next_pending);
+ TEST_ASSERT_FALSE(task.pos_pending);
+}
+
int main(void)
{
UNITY_BEGIN();
@@ -120,5 +264,13 @@ int main(void)
RUN_TEST(test_only_direction_change_marks_only_direction_pending);
RUN_TEST(test_feeding_default_state_after_real_data_marks_both_pending);
+ RUN_TEST(test_resolve_mode_normal_when_no_signals);
+ RUN_TEST(test_resolve_mode_single_signals);
+ RUN_TEST(test_resolve_mode_priority_ordering);
+ RUN_TEST(test_mode_pending_on_mode_change);
+ RUN_TEST(test_mode_pending_stable_when_resolved_mode_unchanged);
+ RUN_TEST(test_arrival_and_movement_pending_on_rising_edge);
+ RUN_TEST(test_next_pending_on_next_floor_change);
+
return UNITY_END();
}
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 f420cab..4c305e7 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
@@ -6,14 +6,17 @@
* golden-векторы CAN-кадров строятся прямо в тесте.
*/
-#include "unity.h"
-
#include "domain/sul/nku_can.h"
+#include "unity.h"
#include
-void setUp(void) {}
-void tearDown(void) {}
+void setUp(void)
+{
+}
+void tearDown(void)
+{
+}
/* ── Вспомогательные конструкторы кадров ─────────────────────────────── */
@@ -26,7 +29,7 @@ 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) */
- 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 };
}
static sul_frame_t make_packet3(uint8_t left, uint8_t right)
@@ -35,7 +38,64 @@ static sul_frame_t make_packet3(uint8_t left, uint8_t right)
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) */
- 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,
+ uint8_t level)
+{
+ static uint8_t s_data[8];
+ memset(s_data, 0, sizeof(s_data));
+ s_data[6] = (uint8_t) ((icon_nibble & 0xF0U) | ((movement & 0x03U) << 2) | (arrow & 0x03U));
+ s_data[3] = level & 0x3FU;
+ return (sul_frame_t){ .id = PACKET1_ID, .bus = 0, .p_data = s_data, .len = 8U };
+}
+
+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 */
+ 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)
+{
+ static uint8_t s_data[8];
+ memset(s_data, 0, sizeof(s_data));
+ s_data[5] = left;
+ s_data[6] = right;
+ s_data[2] = secs & 0x3FU;
+ s_data[3] = (uint8_t) ((mins & 0x0FU) | (gong ? 0x00U : 0x40U));
+ return (sul_frame_t){ .id = PACKET3_ID, .bus = 0, .p_data = s_data, .len = 8U };
+}
+
+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 */
+ return (sul_frame_t){ .id = PACKET4_ID, .bus = 0, .p_data = s_data, .len = 8U };
+}
+
+static sul_frame_t make_packet5(uint8_t next_left, uint8_t next_right, uint8_t dest_level)
+{
+ static uint8_t s_data[8];
+ memset(s_data, 0, sizeof(s_data));
+ s_data[0] = dest_level;
+ s_data[3] = next_left;
+ s_data[4] = next_right;
+ return (sul_frame_t){ .id = PACKET5_ID, .bus = 0, .p_data = s_data, .len = 8U };
}
/* ── PACKET1 — направление ───────────────────────────────────────────── */
@@ -45,9 +105,9 @@ 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);
}
@@ -56,9 +116,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);
}
@@ -67,9 +127,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);
}
@@ -78,9 +138,9 @@ 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);
}
@@ -90,9 +150,9 @@ 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); /* мусор в старших битах + arrow=1 */
- 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);
}
@@ -103,9 +163,9 @@ 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); /* "1","2" -> "12" */
- 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);
}
@@ -114,9 +174,9 @@ 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); /* left=SPACE, right="5" -> "5" */
- 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);
}
@@ -171,11 +231,11 @@ 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; /* сентинел, decode() не должен его тронуть */
(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};
+ 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); /* не тронут */
@@ -187,8 +247,8 @@ static void test_wrong_dlc_on_packet1_id_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 frame = {.id = PACKET1_ID, .bus = 0, .p_data = data, .len = 6U};
+ uint8_t data[8] = { 0 };
+ const sul_frame_t frame = { .id = PACKET1_ID, .bus = 0, .p_data = data, .len = 6U };
TEST_ASSERT_EQUAL(SUL_STATUS_ERR, nku_can_decode(&ctx, &frame, &out));
}
@@ -198,8 +258,8 @@ static void test_wrong_dlc_on_packet3_id_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 frame = {.id = PACKET3_ID, .bus = 0, .p_data = data, .len = 3U};
+ uint8_t data[8] = { 0 };
+ const sul_frame_t frame = { .id = PACKET3_ID, .bus = 0, .p_data = data, .len = 3U };
TEST_ASSERT_EQUAL(SUL_STATUS_ERR, nku_can_decode(&ctx, &frame, &out));
}
@@ -234,6 +294,289 @@ static void test_init_resets_to_default(void)
TEST_ASSERT_EQUAL(SUL_DIR_NONE, ctx.state.direction);
}
+/* ── PACKET1 — режимы (код нибла data[6][7:4]) и начало движения ──────── */
+
+static void test_packet1_mode_fire(void)
+{
+ nku_can_ctx_t ctx;
+ nku_can_init(&ctx);
+ sul_result_t out;
+ const sul_frame_t frame = make_packet1_full(0U, 0U, 0x70U, 0U);
+
+ TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &frame, &out));
+ TEST_ASSERT_TRUE(out.fire_alarm);
+}
+
+static void test_packet1_mode_maintenance_variants(void)
+{
+ const uint8_t icons[] = { 0x30U, 0x50U, 0x40U }; /* МП1 / МП2 / ревизия */
+ for (size_t i = 0U; i < sizeof(icons); ++i)
+ {
+ nku_can_ctx_t ctx;
+ nku_can_init(&ctx);
+ sul_result_t out;
+ const sul_frame_t frame = make_packet1_full(0U, 0U, icons[i], 0U);
+ TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &frame, &out));
+ TEST_ASSERT_TRUE(out.maintenance);
+ }
+}
+
+static void test_packet1_mode_lading_instrument(void)
+{
+ nku_can_ctx_t ctx;
+ nku_can_init(&ctx);
+ sul_result_t out;
+ const sul_frame_t frame = make_packet1_full(0U, 0U, 0x10U, 0U);
+
+ TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &frame, &out));
+ TEST_ASSERT_TRUE(out.lading);
+}
+
+static void test_packet1_mode_fireman(void)
+{
+ nku_can_ctx_t ctx;
+ nku_can_init(&ctx);
+ sul_result_t out;
+ const sul_frame_t frame = make_packet1_full(0U, 0U, 0xF0U, 0U);
+
+ TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &frame, &out));
+ TEST_ASSERT_TRUE(out.fireman);
+}
+
+/* Пакет без кода режима гасит устаревший режим (PACKET1-владелец). */
+static void test_packet1_normal_clears_previous_mode(void)
+{
+ nku_can_ctx_t ctx;
+ nku_can_init(&ctx);
+ sul_result_t out;
+
+ const sul_frame_t fire = make_packet1_full(0U, 0U, 0x70U, 0U);
+ TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &fire, &out));
+ TEST_ASSERT_TRUE(out.fire_alarm);
+
+ const sul_frame_t normal = make_packet1_full(1U, 0U, 0x00U, 0U);
+ TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &normal, &out));
+ TEST_ASSERT_FALSE(out.fire_alarm);
+}
+
+static void test_packet1_movement_bits(void)
+{
+ nku_can_ctx_t ctx;
+ nku_can_init(&ctx);
+ sul_result_t out;
+
+ const sul_frame_t moving = make_packet1_full(1U, 1U, 0x00U, 0U);
+ TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &moving, &out));
+ TEST_ASSERT_TRUE(out.movement);
+
+ const sul_frame_t still = make_packet1_full(1U, 0U, 0x00U, 0U);
+ TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &still, &out));
+ TEST_ASSERT_FALSE(out.movement);
+}
+
+/* ── PACKET2 / PACKET4 — перегруз (мультиисточник) и сейсмо ───────────── */
+
+static void test_packet2_overload_set_and_clear(void)
+{
+ nku_can_ctx_t ctx;
+ nku_can_init(&ctx);
+ sul_result_t out;
+
+ const sul_frame_t on = make_packet2(true);
+ TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &on, &out));
+ TEST_ASSERT_TRUE(out.overload);
+
+ const sul_frame_t off = make_packet2(false);
+ TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &off, &out));
+ TEST_ASSERT_FALSE(out.overload);
+}
+
+/* Ключевой мультиисточник: перегруз из PACKET4 не должен сбрасываться
+ * пакетом PACKET2 без перегруза (и наоборот) — выход = OR под-источников. */
+static void test_overload_multisource_independence(void)
+{
+ nku_can_ctx_t ctx;
+ nku_can_init(&ctx);
+ sul_result_t out;
+
+ const sul_frame_t p4_on = make_packet4(true, false);
+ TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &p4_on, &out));
+ TEST_ASSERT_TRUE(out.overload);
+
+ 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-без-перегруза */
+
+ 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); /* оба источника сняты */
+}
+
+static void test_packet4_seismic_set_and_clear(void)
+{
+ nku_can_ctx_t ctx;
+ nku_can_init(&ctx);
+ sul_result_t out;
+
+ const sul_frame_t on = make_packet4(false, true);
+ TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &on, &out));
+ TEST_ASSERT_TRUE(out.seismic);
+
+ const sul_frame_t off = make_packet4(false, false);
+ TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &off, &out));
+ TEST_ASSERT_FALSE(out.seismic);
+}
+
+/* ── PACKET3 — гонг, временная погрузка, floor_num ───────────────────── */
+
+static void test_packet3_gong_active_when_bit_clear(void)
+{
+ nku_can_ctx_t ctx;
+ nku_can_init(&ctx);
+ sul_result_t out;
+
+ const sul_frame_t gong = make_packet3_full(1U, 2U, true, 0U, 0U);
+ TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &gong, &out));
+ TEST_ASSERT_TRUE(out.arrival);
+
+ const sul_frame_t no_gong = make_packet3_full(1U, 2U, false, 0U, 0U);
+ TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &no_gong, &out));
+ TEST_ASSERT_FALSE(out.arrival);
+}
+
+static void test_packet3_lading_time_seconds(void)
+{
+ nku_can_ctx_t ctx;
+ nku_can_init(&ctx);
+ sul_result_t out;
+
+ const sul_frame_t frame = make_packet3_full(1U, 2U, false, 30U, 1U); /* 1*60+30 */
+ 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 */
+}
+
+/* Мультиисточник 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 */
+
+ /* Отсчёт истёк — 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);
+}
+
+static void test_packet3_floor_num_derivation(void)
+{
+ nku_can_ctx_t ctx;
+ sul_result_t out;
+
+ nku_can_init(&ctx);
+ const sul_frame_t two_digit = make_packet3_full(1U, 2U, false, 0U, 0U); /* "12" */
+ 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 */
+ 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 */
+ TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &basement, &out));
+ TEST_ASSERT_EQUAL_UINT8(51U, out.floor_num);
+}
+
+/* ── PACKET5 — следующий этаж (гейт: только пока едет и назначение ≠ уровень) */
+
+static void test_packet5_next_shown_while_moving(void)
+{
+ nku_can_ctx_t ctx;
+ nku_can_init(&ctx);
+ sul_result_t out;
+
+ const sul_frame_t moving = make_packet1_full(1U, 1U, 0x00U, 5U); /* UP, уровень 5 */
+ 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 */
+ TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &next, &out));
+ TEST_ASSERT_EQUAL_STRING("16", out.next);
+}
+
+static void test_packet5_next_suppressed_when_not_moving(void)
+{
+ nku_can_ctx_t ctx;
+ nku_can_init(&ctx);
+ sul_result_t out;
+
+ 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);
+ TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &next, &out));
+ TEST_ASSERT_EQUAL_STRING("", out.next);
+}
+
+static void test_packet5_next_suppressed_when_dest_equals_level(void)
+{
+ nku_can_ctx_t ctx;
+ nku_can_init(&ctx);
+ sul_result_t out;
+
+ 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); /* назначение == уровень */
+ 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;
+ nku_can_init(&ctx);
+ sul_result_t out;
+
+ 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);
+ TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &next, &out));
+ TEST_ASSERT_EQUAL_STRING("16", out.next);
+
+ const sul_frame_t arrived = make_packet1_full(0U, 0U, 0x00U, 16U);
+ TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &arrived, &out));
+ 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 };
+
+ 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));
+}
+
int main(void)
{
UNITY_BEGIN();
@@ -258,5 +601,28 @@ int main(void)
RUN_TEST(test_state_accumulates_across_packet_types);
RUN_TEST(test_init_resets_to_default);
+ RUN_TEST(test_packet1_mode_fire);
+ RUN_TEST(test_packet1_mode_maintenance_variants);
+ RUN_TEST(test_packet1_mode_lading_instrument);
+ RUN_TEST(test_packet1_mode_fireman);
+ RUN_TEST(test_packet1_normal_clears_previous_mode);
+ RUN_TEST(test_packet1_movement_bits);
+
+ RUN_TEST(test_packet2_overload_set_and_clear);
+ RUN_TEST(test_overload_multisource_independence);
+ RUN_TEST(test_packet4_seismic_set_and_clear);
+
+ RUN_TEST(test_packet3_gong_active_when_bit_clear);
+ RUN_TEST(test_packet3_lading_time_seconds);
+ RUN_TEST(test_lading_multisource_independence);
+ RUN_TEST(test_packet3_floor_num_derivation);
+
+ RUN_TEST(test_packet5_next_shown_while_moving);
+ RUN_TEST(test_packet5_next_suppressed_when_not_moving);
+ 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);
+
return UNITY_END();
}