# tft-app — план разработки > Пофазовый план. Стратегия — **walking skeleton**: сначала тонкий сквозной срез > (CAN → модель → этаж на экране), затем наращивание слой за слоем. Архитектура — > [ARCH.md](ARCH.md). Этот файл — **источник истины по статусу фаз**. > > Каждая фаза: цель · объём · артефакты · тесты · критерий выхода. Ранние фазы детальны, > поздние укрупнены и детализируются по мере приближения. > > Легенда статуса: ⬜ не начата · 🟨 в работе · ✅ готова. --- ## Обзор фаз | # | Фаза | Статус | |---|---|---| | 0 | Каркас проекта и сборка | ✅ | | 1 | Walking skeleton (CAN → модель → этаж + стрелки) | ✅ | | 2 | Домен: полный `sul_result_t`, приоритеты, таймаут | 🟨 | | 3 | Настройки и меню, локальные входы (opto) | 🟨 | | 4 | Ассеты: TLV-бандл, паковщик, XIP-блит | ⬜ | | 5 | Layout-движок: схема, солвер, темы, injected-регион | ⬜ | | 6 | Аудио: движок над MQS, `audio_policy` | ⬜ | | 7 | Обновление в поле (SD) + provisioning-канал | ⬜ | | 8 | Мультипротокол: УИМ / SD7 / УЭЛ / УКЛ + per-client | ⬜ | | 9 | Профиль `app-tft4` (40pin LCDIF, Video PLL) | ⬜ | --- ## Фаза 0 — Каркас проекта и сборка ✅ **Итог.** Подтверждено на железе дважды (первый запуск + power cycle через переустановку с SD): `image_ok` персистит через рестарт (`before-confirm` на втором запуске читает `1`, записанный на первом), загрузчик не откатывает образ. Закрывает оба исходных симптома отчёта: (1) SWD-прошивка работала один раз, но не переживала перезапуск; (2) обновление через SD зацикливалось (запись в слот A + рестарт). Причина обоих — один и тот же баг (ниже). **Root-cause (главная находка Фазы 0) — забытый дефайн `__STARTUP_ INITIALIZE_RAMFUNCTION`.** Запись QSPI из `app` виснет в `qspi_write_fifo` (FlexSPI не дренажит IP TX FIFO), хотя весь write-путь `bsp_qspi` помечен `AT_QUICKACCESS_SECTION_CODE` (ramfunc). Причина: startup копирует секцию `.ram_function` из flash в ITCM только под `#ifdef __STARTUP_ INITIALIZE_RAMFUNCTION` — `app` его не задавал (только `__STARTUP_CLEAR_BSS`). Оба рабочих потребителя `bsp_qspi` (firmware_test, bootloader) его задают. Без него в ITCM оставался leftover ramfunc **загрузчика** (исполнялся первым) → чтения «случайно работали» на совместимом коде, запись — падала. `bsp/qspi_flash/README.md` прямо предупреждает («без него ITCM содержит нули → HardFault»). Подтверждено изолированным QSPI self-test (erase+write+readback) до возврата полной диагностики. **Прочие находки bring-up (для истории и будущих фаз — Фаза 3/settings и Фаза 7/assets тоже пишут QSPI, наступят на смежное):** - `confirm_self()`/любая `flash_area_*` требует `bsp_qspi_init()` заранее — иначе запись молча падает (независимо от ramfunc-бага). - Слот-линкер обязан иметь секцию `.ram_function` (ITCM) — `app_slot.ld` переписан по образцу боотлоадерного (все RAM-секции). - `bsp_usb_cdc_init()`/аналогичные bsp-вызовы, дёргающие `bsp_delay()`, нельзя звать до `vTaskStartScheduler()` — в FreeRTOS-режиме `bsp_tick` это `xTaskGetTickCount()`, который не идёт до старта планировщика → зависает навсегда. Любая tick-зависимая bsp-инициализация — только из задачи. - Первый потребитель `BSP_TICK_FREERTOS_MODE` в репозитории вскрыл 2 латентных бага: (1) общий `bsp_tick` — bare-metal STATIC-либа, `SysTick_Handler` конфликтует с FreeRTOS-портом — решение: компилировать `tick.c` прямо в таргет с `BSP_TICK_FREERTOS_MODE`, не через `libbsp_tick.a`; (2) `configTICK_RATE_HZ` нельзя как `((TickType_t)1000)` — `#if` в `tick.c` не разбирает cast, только `1000`. - **Диагностика — UART (LPUART1/MCU-Link VCOM), не USB CDC.** CDC под FreeRTOS (первый потребитель target-side USB device stack в RTOS-контексте) не заработал стабильно за несколько заходов на этом пути. UART — самый проверенный канал в репозитории (все `tests/target/*` HIL-образы), доступен сразу через уже подключённый для SWD кабель, без enumeration/wait. Если понадобится CDC под FreeRTOS позже (Фаза 7: provisioning-канал) — отдельная сфокусированная задача. `port_log_cdc` (переиспользуемый адаптер) оставлен в дереве, из `app` сейчас не линкуется. - `.vscode/launch.json`: у `🐛 Debug: tft_app` был `preLaunchTask` на `firmware_test` (copy-paste) — исправлено на `build:app-debug`. **Осознанное отклонение от исходного критерия выхода:** `firmware/bootloader/ test_stub` НЕ удалён (план ниже это предполагал) — оставлен как независимая A/B-регрессия загрузчика, раз `app` пока не покрывает те же сценарии (hang-классы, revert без self-confirm и т.д.). Переоценить, когда/если `app` сможет их replicate. --- **Цель.** `firmware/tft_app` собирается как ARM-таргет, грузится через bootloader, мигает heartbeat; host-тест-харнесс на месте. Пустой, но живой каркас всех слоёв. **Объём.** - `firmware/tft_app/{CMakeLists.txt, src/, README.md}`; подкаталоги `src/{domain,services,ui,menu,app}`. - `main.c`: `BOARD_ConfigMPU/InitPins/BootClock`, FreeRTOS, задача heartbeat (`bsp_led`, `bsp_tick`). - Подключить в корневой `CMakeLists.txt` (заменить `firmware/bootloader/test_stub`). - CMakePresets: buildPresets `app-big-debug/release` (профиль `app-big`). `app-tft4` — заглушка до Фазы 9. - Линковка как MCUboot-слот-образа (Direct-XIP, `flexspi_nor.ld` + slot-offset), HAB-таргет. - `tests/host/tft_app_smoke/` — пустой Unity-таргет, зелёный (проверка харнесса). - `.vscode/launch.json` — конфигурация `🐛 Debug: tft_app (FreeRTOS)` (уже упомянута в DEV_ARCH). **Тесты.** `just build::build-app-big-debug` собирает ELF; host smoke-тест зелёный. **Критерий выхода.** ELF грузится bootloader'ом из слота, heartbeat мигает (проверка — пользователь на железе). ✅ Выполнено; `test_stub` сохранён (см. отклонение выше). --- ## Фаза 1 — Walking skeleton ✅ > **Аппаратно подтверждено.** Реальный НКУ-CAN трафик на стенде (TFT8) → корректный номер > этажа и стрелка направления на экране; self-confirm пережил очередной power cycle > (`image_ok=1` уже на `before-confirm`). Host-тесты 19/19 (16 декодер + 6 контроллер + > существующие). Debug и Release оба собираются/подписываются чисто, идентичная структура > трейлера (`image_ok` UNSET до рантайм-confirm, как и в Фазе 0). Работа Фазы 1 не > закоммичена (в рабочем дереве поверх коммита `# tft_app: Phase 0`). > > ⚠️ **Известное ограничение — зафиксировано, не блокирует, чинится в Фазе 4.** Один > framebuffer, без double buffering: `ui_fallback_render()` пишет (`gfx_clear` + RLE-decode > глифов + стрелка) напрямую в буфер, который ELCDIF в этот момент сканирует по DMA. Если > перерисовка не укладывается в один кадр развёртки, видно сам процесс закраски (глиф > «набирается» по частям за несколько кадров) — не tearing/не порча пикселей (каждая запись > валидна), просто видимый процесс. Фикс — double buffering (не требует PXP отдельно; > PXP — для многослойной композиции реальных ассетов, та же Фаза 4) — см. её раздел. > > Находки по ходу реализации: > - **Root-cause не пойман, найден заранее.** Framebuffer в SDRAM обязан идти через > `AT_NONCACHEABLE_SECTION_ALIGN` (порт из `OLD_PROJECT image_cache.c`, прямая > рекомендация NXP) — MPU держит SDRAM как `Normal Write-Back Cacheable` > (`board_mpu_init()`, Region 8), ELCDIF читает по DMA напрямую; без non-cacheable > региона CPU писал бы через кэш, DMA видел бы устаревшие данные. Механизм уже > параметризован в `board_mpu_init()` через линкер-символы `__NCACHE_REGION_START/ > SIZE` (Region 9) — только переопределены в `app_slot.ld` на новый `m_sdram_ncache` > (2 МБ в начале SDRAM) вместо унаследованного из bootloader OCRAM-региона (256 КБ, > для framebuffer ~1.83 МБ не подошёл бы). При первой попытке framebuffer был сырым > указателем на константный адрес (совпадал с началом региона случайно, линкер > репортил `0 B` в `m_sdram_ncache`) — исправлено на настоящее объявление > переменной через макрос; линкер теперь честно показывает `1875 KB / 2 MB`. > - Формат шрифта — RLE ARGB8888 (lcd-image-converter, "Color A8R8G8B8"), > `tImage`/`tChar`/`tFont`. Пользователь сгенерировал `FloorFontFallback.c` > (Inter 215pt, 0-9 и "-") и `SystemFont.c` (JetBrains Mono 24pt, ASCII+кириллица) > инструментом напрямую — не пришлось изобретать плейсхолдер. RLE-декодер в > `services/gfx/gfx.c` — порт проверенного в проде алгоритма из > `OLD_PROJECT/source/fonts/fonts.c` (UNIQUE/REPEATABLE блоки, бинарный поиск по > отсортированному коду символа), не переизобретён с нуля. Символ вне таблицы > шрифта → подстановка `'-'` (по-символьный fallback, ARCH §11 — политика > подтверждена пользователем). > - Файлы сгенерированных шрифтов ожидают типы из bare-name `fonts.h` > (`SystemFont.c` — явным `#include`, `FloorFontFallback.c` — вообще без include, > более новая версия конвертера) — закрыто force-include в CMake, сами > сгенерированные файлы не редактировались (конвертер их перезапишет при > регенерации шрифта). > - Перенёс `FloorFontFallback.c/.xml`, `SystemFont.c` из `domain/` (где их положил > пользователь) в `services/gfx/fonts/` — по ARCH §4 шрифты относятся к gfx, не > к домену. **Цель.** Сквозной срез: реальный CAN-кадр НКУ → чистый декодер → контроллер → **fallback-рендер этажа и стрелки** на экране. Никаких ассетов, FS, TLV, меню. **Объём.** - `domain/elevator_model`: `sul_result_t`, `sul_status_t`, дефолт-состояние. - `domain/sul`: тип `sul_driver_t`, реестр (одна запись); **чистый декодер `sul_nku_can`** (подмножество: `pos`, `direction`) — портирован из OLD_PROJECT `msg_receiver_task` PACKET1/3. - `domain/sul/transport/can`: тонкий адаптер `bsp_can` → `sul_frame_t`. - `domain/controller`: `process()` → `indication_task_t` (diff), кэш, poll + timeout→default. - `services/gfx`: минимум над `bsp_display` — один вкомпилированный шрифт + примитивы (стрелка). - `ui/fallback`: рендер `pos` + стрелка (asset-free, FS-free) — §11 ARCH. - `app`: задачи `sul_rx` (poll CAN) и `render`; wiring. **Тесты (host).** - `tests/host/tft_app_sul_nku`: golden-векторы CAN-кадров → ожидаемый `sul_result_t`. - `tests/host/tft_app_controller`: последовательность `sul_result_t` → корректные dirty-флаги, таймаут→default. **Критерий выхода.** На стенде: подан НКУ-CAN трафик → на экране меняется номер этажа и стрелка направления; при пропадании трафика — «--». Домен-тесты зелёные в CI. ✅ Выполнено (проверено на TFT8; таймаут→«--» логически проверен host-тестами `test_feeding_default_state_after_real_data_ marks_both_pending`, аппаратно на реальном обрыве связи отдельно не гонялся — низкий риск, тот же код-путь, что и для обычного кадра). --- ## Фаза 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`). > > 🐛 **Найден и починен на реальной станции (не симулятор, 2026-07-22) — транспорт, > не декодер.** `domain/sul/nku_can.c` (decode) корректно считает адресный сдвиг > `group4=addr<<4`/`group6=addr<<6` на каждый вызов — декодер никогда не был > виноват. Баг — в `domain/sul/transport/can/` (HW, host не тестируется, отдельный > слой): (1) HW RX-фильтры FlexCAN выставлялись ОДИН РАЗ при bring-up на > захардкоженные ID адреса 0 (0x506/0x508) — правка адреса в меню обновляла только > декодер, физически кадры с другим адресом отбрасывались CAN-контроллером до > всякого софта; воспроизводилось на реальной станции (адрес 1) — heartbeat живой, > экран пуст; на симуляторе (адрес 0) работало, поэтому не всплывало раньше. (2) > Фильтровались только PACKET1/PACKET3 — PACKET2/4/5 (перегруз, сейсмоопасность, > следующий этаж) не принимались НИКОГДА, вне зависимости от адреса. Т.е. критерий > выхода Фазы 2 «все режимы НКУ-CAN корректно доходят» ни разу не выполнялся для > этих трёх пакетов на железе. **Фикс** (по образцу `OLD_PROJECT/source/main_programm.c` > `apply_nku_can_filters()`/`msg_receiver_task` — та же bsp_can-прослойка, дословно > портирован паттерн): новая `sul_transport_can_set_address(addr)` переконфигурирует > все 5 MB-фильтров под текущий адрес (diff+сентинел `0xFFU`, как в референсе — > дёшево звать каждую итерацию); `sul_transport_can_init()` фильтры больше не > трогает. `task_sul_rx.c` зовёт её рядом с `nku_can_set_address()`. Домен > (`domain/sul.h`, `nku_can.h`/`.c`) не тронут — фикс целиком внутри NKU-CAN- > транспорта; для других протоколов (Фаза 8: УИМ/SD7/УЭЛ/УКЛ, в основном UART) своя > адресация — не переиспользуется автоматически. > Побочная находка: `SystemFont.c` (сгенерирован lcd-image-converter) экспортирует tFont > под именем `JBMono24` (из .xml), а `gfx.h` обещал `SystemFont` — рассинхрон был > латентным (Фаза 1 линковала только `FloorFontFallback`), вскрылся при первом > потребителе. Закрыт алиасом-макросом в `gfx.h`, сам сгенерированный файл не тронут. **Цель.** `sul_result_t` в полном объёме (§6 ARCH), таблица приоритетов режимов, устойчивый таймаут/потеря связи. **Объём.** Все поля модели (next, сигналы, `lading_secs`, `floor_num`); полный декодер НКУ-CAN (PACKET1..5, режимы, удалённая установка адреса); `controller` разрешает приоритет режимов по **таблице-данным** (§7); валидация рендеримости позиции (renderable-множество шрифта + по-символьный fallback). **Тесты (host).** Расширенные golden-векторы НКУ (все режимы, спецсостояния); тесты таблицы приоритетов; тесты валидации/fallback символов. **Критерий выхода.** Все режимы НКУ-CAN корректно доходят до fallback-рендера; полное host-покрытие декодера и приоритетов. --- ## Фаза 3 — Настройки и меню 🟨 **Цель.** Персистентные настройки на QSPI, меню на двух кнопках, локальные входы (opto). > ## ⏸️ ТОЧКА ОСТАНОВКИ (переход в новый тред) — 2026-07-21 > > **Сделано, на железе подтверждено:** > - **3.1 `services/settings_store`** ✅ — персист ядра (magic/version/CRC32 в `0x450000` через > `bsp_qspi_flash`), `proto_slice[0]`→`nku_address` в декодер. HW: load/save, адрес переживает > power-cycle. _Латентный баг починен:_ 4-КБ `settings_page_t` на ~3-КБ стеке задачи → stack > overflow → bootloader recovery; сделал буфер `static`, стеки задач x6→x8. > - **3.2.1 модель меню** ✅ — чистая (`src/menu/menu.c`: дерево-данные + навигация + edit + > offset-привязка, `save_requested`), host-тест `tests/host/tft_app_menu` (10). > - **3.2.2 рендер + gfx** ✅ — тинтинг `gfx_draw_string(...,color)` (альфа-блендинг: белый шрифт > любым цветом со сглаживанием, корректно на цветной полосе), `gfx_fill_rect`/`gfx_draw_rect`, > шрифт `JBMono12`→алиас `SystemFontSmall`; окно 480×272 @ (0,0); боевое дерево `menu_tree.c` > (Протокол/Адрес/Логи/Выход, метки `options[]`). > - **3.2.3 wiring** ✅ — **софт-таймер опроса ввода** (`input_poll_cb`, 5 мс; демон таймеров на > высшем приоритете → нажатия не теряются во время рендера; opto добавится сюда в 3.4); > `ui_task` — потребитель событий + владелец дисплея; вход долгим BUTTON_2; save на выходе; > адрес `sul_rx_task` пере-применяет из настроек. **Макет/шрифты/тинтинг на железе — как в > макете; адрес персистится.** > > **ПОЧЕМУ ОСТАНОВИЛИСЬ — один framebuffer не годится.** Единственный буфер, который ELCDIF > сканирует по DMA пока CPU в него пишет → **tearing / частичная отрисовка**; полный `gfx_clear` > 800×600 в некэшируемую SDRAM (~40 мс) на кадр → **тормоза**; вход нестабилен. Инкрементальная > перерисовка (только изменившиеся строки) — костыль, дала баг (пропал футер). **Костыль убрать.** > > **РЕШЕНИЕ (согласовано): перенести фундамент рендера (double-buffer + PXP) из Фазы 4 в конец > Фазы 3.** Эталон — `OLD_PROJECT_TFT8_UKL/source/display/{pxp_config.c,display.c}` + > `main_programm.c` (см. ниже «3.2.4»). Ассеты/спрайты/layout остаются в Фазе 4. Дальше — под-шаг > **3.2.4** (R1→R4), затем 3.3–3.6. > > _Флеш-грабли (в памяти):_ `host::flash-swd-app-slot-debug` шьёт **подписанный** образ > `build/Debug/signed/app_slot_a.bin` → после правок обязательно `build::sign-app-debug`, иначе > прошивается старый код. Вся работа Фаз 2–3 — в рабочем дереве (ветка `tft-app-dev`), не > закоммичена (пользователь коммитит сам). > **Согласованный дизайн (решено с пользователем; референсы — все старые проекты: > settings_manager ×3, menu ×7 вкл. ALPACA, main_programm/menu_task).** > > Движущее требование: **добавлять/убирать настройки тривиально, без переписывания проекта** — > клиенты приносят уникальные параметры (боевой пример ALPACA: `ground_shift`, маска > `shown_floors` со своим редактором `BOOL_ARRAY`; кастомные метки этажей «1А»). Поэтому: > > - **Настройка = строка дескриптора-данных**, не поле монолита: `{id, подпись, тип-редактор, > хранилище/offset, диапазон, длина, видимость, ярус}`. Добавить настройку = добавить строку > (тот же принцип, что `k_mode_priority[]`). > - **Три яруса настроек:** **A. железобетонные** (вес/вместимость, громкости, серийник, год) · > **B. протокольные** (§8: выбор протокола, адрес — `sul_settings_desc_t`, регистрирует > протокол) · **C. клиентский UX-зоопарк** (лого, шаблон, сдвиги/маска этажей, метки) — > презентационные, живут с клиентским layout-конфигом. > - **Хранение — гибрид:** ядро (ярусы A/B) — фиксированная версионированная `settings_t` + > magic/version/CRC32 в **фиксированном секторе `0x450000`** (размер-независимо, §10; через > `bsp_qspi_flash`, не сырой XIP-memcpy) — чертёж из > `OLD_PROJECT_TFT8_UKL/hal/settings_manager.c`. Клиентский ярус C — > **тегированный TLV-блок рядом с layout-конфигом** (§11), добавляется без правок ядра > (эффекты — Фаза 5). > - **Меню = движок + данные:** дерево пунктов — данные (плоский массив с `parent/child`); > **редакторы подключаются по типу** (dispatch «тип → функция-редактор»), добавить причудливый > параметр = добавить редактор, не переписывать движок. Фаза 3 реализует редакторы **только > действующих** типов (выбор/`BYTE`/`BOOL`); `ARRAY/SERIAL/YEAR/BOOL_ARRAY/PRESET` — по мере > своих фаз, но API движка рассчитан на расширение. > - **Модель меню (навигация/edit/привязка) — чистый C, host-тест**; рендер — отдельно (HIL). > Разделяем то, что в легаси было слито (`draw_cursor(buffer[...])` внутри menu.h). > - **Панель — хардкод TFT8** (как Фаза 1); панель как provisioning-параметр — Фаза 9 > (`bsp_provisioning` сейчас читает только UID чипа). > > **Честная область действия Фазы 3:** реальный эффект «сейчас» дают только **протокол+адрес** > (→ декодер) и **тумблер логов**. Громкости — Фаза 6 (аудио); серийник/год/вес и весь ярус C — > Фаза 5 (layout). Остальной каталог descriptor'ится и хранится, «зажигается» в своей фазе. **Декомпозиция (walking-skeleton: тонкий срез первым).** | Под-шаг | Содержание | | --- | --- | | **3.1 `services/settings_store`** ✅ | Персист ядра (magic/version/CRC, load/save/get/defaults) через `bsp_qspi_flash`, фикс-сектор `0x450000` (§10, размер-независимо). Сразу: `proto_slice[0]` → `nku_address` в декодер (замена хардкода `=0`). | | **3.2 движок меню + рендер** ✅ (рендер — на одном буфере, см. 3.2.4) | **Чистая модель** (дерево-данные, навигация, edit, offset-привязка) — оперирует переданным `settings_t*`, **сама не сохраняет** (выставляет флаг save, app зовёт `settings_store_save()`) → host-тест без QSPI. **Рендер** (примитивы `gfx`: список/курсор/значение) в **переносимом окне 480×272 @ логич.(0,0)** (одинаково на всех панелях; на больших — левый-верхний угол, остальное чёрное). Вход — **долгое нажатие BUTTON_2** (~1.5–2 с); BUTTON_1=следующий, короткое BUTTON_2=выбор/инкремент. Опрос ввода — **софт-таймер 5 мс** (не задача; масштабируется на opto). Модальный экран. | | **3.2.4 фундамент рендера (double-buffer + PXP)** ✅ | Перенос из Фазы 4. Убирает tearing/тормоза, снимает костыль частичной отрисовки. Гибрид bpp (AS=8888/PS+FB=565) + оконный композит меню — см. раздел ниже. Подтверждено на стенде (4 цикла) + реальной СУЛ. | | **3.3 per-protocol дескрипторы** 🟨 | `sul_settings_desc_t` (§8): протокол регистрирует параметры, секция меню строится из дескриптора (НКУ-CAN: адрес 0..15). Плюс демо-протокол (второй драйвер реестра) — тест протокол-агностичности механизма. Код-комплит, host зелёный; ждёт стенда (см. раздел ниже). | | **3.4 opto-входы** 🟨 | IN1/IN2 → вызов/ответ диспетчера → презентация (в fallback — примитив/текст; иконки-спрайты — Фаза 4/5). Код-комплит; ждёт стенда (см. раздел ниже). | | **3.5 удалённая адресация NKU-CAN** ✅ | `0x4X1`/`0x5XB` → запись `nku_address` в настройки (долг Фазы 2). По спецификации `REMOTE_ADDRES_SETUP.pdf` + согласованному уточнению (X командного кадра сверяется с анонсом). Подтверждено на стенде 2026-07-23 (со второй попытки — wildcard-фильтры, см. раздел ниже). | | **3.6 тумблер логов** ✅ | `log_enabled` в настройках + пункт меню + рантайм-гейт `log_set_enabled()` поверх компайл-тайм `LOG_LEVEL`. Подтверждено на стенде 2026-07-22. NB: продакшн-сборка — с `LOG_LEVEL >= INFO`, иначе гейтить нечего (макросы вырезаны). | ### 3.2.4 — Фундамент рендера: double-buffer + PXP (✅) > **Статус: R1→R4 реализованы, Debug/Release + host (21/21) зелёные; ожидает > подтверждения на стенде.** По прецеденту Фаз 1/2 ✅ ставится только после > проверки на железе (меню/индикация без tearing, вход в меню стабилен). > > Что сделано (решения — как согласовано): > - **R1.** `add_sdk_driver(pxp fsl_pxp.c)` (+`sdk_clock`), gfx линкует `sdk_pxp`; > `m_sdram_ncache` 2→8 МБ (`m_sdram` origin/len сдвинут). `fsl_memory.h` не нужен > — `FSL_FEATURE_MEMORY_HAS_ADDRESS_OFFSET` для RT1052 не определён (identity). > - **R2.** 4 поверхности в ncache (AS+PS+2×FB, 800×600×4 ≈ 1.92 МБ, итого 7500 КБ); > порт `init_pxp`/`pxp_start_operation` в `services/gfx`; примитивы пишут в AS > (alpha 0xFF), `gfx_clear()`→memset 0 (прозрачно); `gfx_present()` = PXP-композит > AS над PS → задний FB + свап по семафору FRAME_DONE (ISR-колбэк ELCDIF, priority 2 > == max-syscall → `xSemaphoreGiveFromISR` допустим). PS-формат — `kPXP_PsPixel > FormatARGB8888` (расширенная таблица RT1052). Владение present — согласовано: > **явный `gfx_present()`, оркеструет `ui_task`** (menu_view/fallback только рисуют). > - **R3.** Костыль снят: `menu_view_render_row` + full/row-ветвление удалены, > `menu_view_render()` рисует полный кадр; `ui_task` зовёт `gfx_present()` на > изменение. Fallback уже полнокадровый. > - **R4 (первая версия).** `main.c` разбит: `app/task_sul_rx.c` + `app/task_ui.c` — > `ui_task` совмещал меню и рендер в одной задаче. > > **Первая HW-проверка (2026-07-22) вскрыла регресс от R1→R4:** меню не реагировало > на кнопки вообще (ноль реакции), индикация запаздывала на 1-2 с относительно > реального CAN-сигнала, при обрыве связи экран не сбрасывался на «--» (только > после восстановления). Root-cause — связка меню+рендер в одной задаче: до R1→R4 > (3.2.1–3.2.3, один framebuffer) блокировок в этой задаче не было, разделение было > безвредным упущением; `gfx_present()` внёс блокирующее ожидание кадра > (PXP busy-wait + semaphore-wait на FRAME_DONE), и в объединённой задаче это > ожидание попутно блокировало проверку кнопок — `menu_task`-логика (hold-детект) > физически не успевала выполняться между вызовами `gfx_present()`. Побочно > вскрылась гонка: FreeRTOS-мьютекс логгера (`port/log/src/log_mutex.c`) был под > `#if 0` и не собирался ни в один таргет — `LOG_*` из разных задач писали в общий > `static` буфер (`utils/log/log.c`) без синхронизации. > > **R4 (исправлено) — 4 задачи вместо 2**, эталон разделения — > `OLD_PROJECT_TFT8_UKL` (`BUTTONS_TASK`/`menu_task` отдельно от > `REFRESH_TASK`/`tft_refresh_task`): > - `bringup_task` — одноразовая инициализация (лог-мьютекс → UART/лог → QSPI/ > settings/self-confirm → SDRAM+gfx+CAN), создаёт три нижеследующие задачи, > удаляет себя (`vTaskDelete(NULL)`). > - `sul_rx_task` (`task_sul_rx.c`) — CAN→decode→controller. WDOG/heartbeat — > **безусловно**; сама CAN-работа — под `!g_menu_active` (**мягкая пауза**, не > `vTaskSuspend` — WDOG остаётся в безопасности по конструкции, не завязан на > suspend-состояние). > - `menu_task` (новый, `task_menu.c`) — модель меню, потребление кнопок, > hold-to-enter, save. НЕ рисует. Владеет `g_menu`, пишет `g_menu_active`. > - `render_task` (был `ui_task`, `task_render.c`) — единственный вызывающий > `gfx_present()`. **Event-driven**: `ulTaskNotifyTake(pdTRUE, portMAX_DELAY)`, > будят `sul_rx_task` и `menu_task` (MPSC) через `xTaskNotifyGive` — не поллит. > На «меню только что закрылось» — восстанавливает индикацию из последнего > известного состояния сразу, не дожидаясь свежего CAN-кадра. > - Приоритеты (`app_tasks.h`, единая точка правды, менялись — см. вторую HW- > проверку ниже): изначально `bringup` > `menu` > `sul_rx` > `render`. Демон > программных таймеров (`input_poll_cb`, debounce) — отдельно, > `configTIMER_TASK_PRIORITY` (наивысший в системе), не задет. > - `port/log/src/log_mutex.c` — снят `#if 0`, включён в сборку `app` (как > `tick.c` — компилируется прямо в таргет, FreeRTOS-специфика); `log_mutex_init()` > первой строкой `bringup_task`, до первого `LOG_*` где-либо в системе. > > **Временная диагностика** (не убрана, ждёт решения по 66 мс ниже): > `gfx_present()` логирует раздельно длительность PXP busy-wait и > semaphore-wait на FRAME_DONE (`LOG_I("gfx", "present: pxp=%u ms vsync=%u ms")`). > Замерено на стенде: **pxp≈66 мс, vsync≈0 мс** — стабильно, независимо от > содержимого кадра (похоже на фиксированную стоимость полнокадрового PXP- > композита на этом железе/тактировании, не на баг в коде отрисовки — тактовые > частоты (ARM PLL/AHB/IPG) идентичны `OLD_PROJECT_TFT8_UKL`, который использует > тот же PXP+vsync паттерн). Причина ещё не тормознее ELCDIF (65 Гц по расчёту > из `clock_config.c` — не бутылочное горлышко, vsync≈0 подтверждает). Открытый > вопрос — см. вторую HW-проверку. > > --- > > **Вторая HW-проверка (2026-07-22, тот же день) — три новых находки:** > > 1. **Обрыв связи: экран не сбрасывается на «--», залипает на старом этаже** > (по логу — НИ ОДНОГО `[gfx] present:` за 8+ с после обрыва, хотя WDOG/ > heartbeat продолжают штатно). **Root-cause найден и подтверждён кодом:** > `bsp_can_receive()` (`bsp/can/src/can.c`) — busy-spin БЕЗ единого > блокирующего FreeRTOS-вызова внутри (`for(;;){poll_rx_mailboxes(); ...}`). > Без CAN-трафика `sul_rx_task` занимает весь `CAN_RX_TIMEOUT_MS` (100 мс) > КАЖДУЮ итерацию. Проверено по вендоренному `xTaskDelayUntil()` > (`sdk/rtos/freertos/freertos-kernel/tasks.c`): если дедлайн уже в прошлом > (а он в прошлом, когда спин съедает весь период) — `xShouldDelay` остаётся > `false`, задача в delayed-list НЕ добавляется, т.е. **не блокирует вовсе**. > Более низкоприоритетная задача никогда не выполнится, пока такая задача > непрерывно READY — `render_task` (был ниже `sul_rx_task`) физически не > получал CPU ни для первого рендера при старте без связи, ни для обработки > diff'а на «--» при обрыве. При живом трафике невидимо (`receive` почти > всегда быстрый, `sul_rx_task` реально блокируется) — поэтому не всплывало > раньше ни разу (PLAN.md, Фаза 1, прямо отмечал: обрыв связи на реальном > железе отдельно не гонялся). > **Фикс:** переставлены приоритеты — `menu > render > sul_rx` > (было `menu > sul_rx > render`). `render_task` больше не может быть > голодом заморожен `sul_rx_task`; `menu_task` по-прежнему выше `render_task` > (её PXP busy-wait не должен придерживать ввод). Корень (busy-spin в > `bsp_can_receive`) НЕ тронут — общий bare-metal+FreeRTOS модуль, добавление > yield потребовало бы условной компиляции; обсуждается отдельно. > 2. **Меню: вход теперь работает** (подтверждает фикс R4). Остальное: > навигация медленная (см. pxp≈66 мс выше — при открытом меню `sul_rx_task` > не спинит, `g_menu_active` гасит её блок целиком, так что 66 мс — это > чистая стоимость одного `gfx_present()`, не голодание); выход иногда не > срабатывает (могло быть тем же голоданием, что и находка 1 — при отсутствии > связи `sul_rx_task` возобновляет спин сразу по выходу из меню и мог не > пускать `render_task` дорисовать «выход»; должно закрыться тем же фиксом > приоритетов, перепроверить на стенде); пожелание — другая кнопка входа, > мгновенно (без удержания), как в `OLD_PROJECT_TFT8_UKL`. > **Согласовано и сделано:** раскладка `OLD_PROJECT_TFT8_UKL` дословно — > короткое BUTTON_1 = вход в меню (закрыто) / следующий пункт (открыто); > короткое BUTTON_2 = выбор/действие (открыто), намеренный no-op (закрыто). > `MENU_ENTER_HOLD_MS`/`hold_start` убраны из `task_menu.c` целиком. > 3. **Пустой экран при старте без связи со станцией** (прочерки появляются > только после захода в меню) — объясняется тем же голоданием (находка 1): > `render_task` не получал CPU для самого первого рендера, пока `sul_rx_task` > непрерывно спинила. Должно закрыться тем же фиксом приоритетов. > > _Итог третьего цикла (проверено на стенде + реальной станции, адрес 1):_ > (а)-(в) подтверждены; данные на реальной СУЛ отображаются с приемлемой > задержкой (после фикса HW-фильтров транспорта, см. Фазу 2). Остались 66 мс > `gfx_present()` → следующий блок. > > --- > > **Ускорение рендера (2026-07-22, продолжение): гибрид bpp + оконный композит.** > > Диагностика 66 мс (AN12437 + расчёт): полоса SDRAM 16-бит @ ~136 МГц ≈ 272 МБ/с; > непрерывный сканаут ELCDIF 800×600×4Б×65 Гц ≈ 126 МБ/с (46%!); полнокадровый > PXP-композит двигает AS+PS+out = 5.76 МБ → ~66 мс на остатке полосы. НЕ XIP > (PXP — аппаратный DMA-мастер, FlexSPI — отдельная шина) и НЕ конфиг SEMC > (наш `bsp_sdram` — побитовый порт golden-DCD: те же тайминги, ~136 vs 133 МГц; > единственный дифф — AXI-QoS у нас мёртвый код под `__NIC301_EXPERIMENTS_`, > на скорость PXP не влияет). Перекройка загрузчика под выполнение из SDRAM > эту проблему бы НЕ решила. > > **Решение 1 (согласовано, ФИНАЛЬНОЕ): гибрид bpp.** AS остаётся ARGB8888 > (примитивы/блендинг/палитра НЕ тронуты — полные 8 бит альфы/цвета); > PS + оба FB + ELCDIF → RGB565. Форматы поверхностей PXP независимы → > 565-выход не ломает альфа-смешивание (бленд внутри PXP в ≥8 бит, квантование > только на записи). Реализация: `bsp_display` расширен АДДИТИВНО > (`bsp_display_pixel_format_t` + `bsp_display_init_ex()`; старый > `bsp_display_init()` = обёртка XRGB8888 — `firmware_test` не тронут); > `services/gfx` — PS/FB `uint16`, PXP PS/out RGB565. **HW-подтверждено:** > тест-паттерн (чистые R/G/B, белый — ровно белый, серый-рамп без ступенек, > тинты палитры) корректен; `pxp=30-32 мс` стабильно (было 66); ncache > 7.5→4.8 МБ (и 1024×600 теперь влезает в 8 МБ — было 9.8). Единственный > остаточный риск 565 — бандинг на будущих градиентах/фото-лого (dither у PXP > RT1052 нет) — закрывается host-side dithering'ом в паковщике ассетов > (**требование к Фазе 4 — паковщик обязан уметь Floyd-Steinberg при > конверсии в 565**). `bsp_pxp` как отдельный BSP-слой — обсуждён, отложен > (API прояснится в Фазе 4/5 на многопроходной композиции). > - Побочный урок для Фазы 4 (ассеты): формат ассета per-asset тегом в TLV — > авторинг всегда PNG; непрозрачные фоны → RGB565, альфа-спрайты → > ARGB8888/4444. Выходной 565 форматы ассетов не ограничивает. > > **Решение 2 (согласовано): оконный композит для меню** — 31 мс/шаг всё ещё > ощущались как прежде (бюджет: debounce 20 + tick 5 + draw ~8 + pxp 31 + > vsync ~8 ≈ 75 мс). Реализовано: `pxp_configure_rect()` (полная конфигурация > поверхностей на каждый композит, без скрытого состояния), публичные > `gfx_present_rect()`/`gfx_clear_rect()`; `menu_view_render()` рисует только > окно (`MENU_VIEW_WIN_W/H` публичны в menu_view.h); оркестрация в > `task_render.c`: открытие меню = полный clear AS + ДВА полных present'а > (double buffering: оба FB обязаны стать корректны вне окна — контракт > `gfx_present_rect`), навигация = только окно 480×272 (~27% кадра, ожидаем > ~8 мс → шаг ≈ 45 мс). Индикация — полные кадры, как была. Тест-паттерн > верификации 565 снят (отработал). > > **Четвёртый цикл подтверждён на стенде** (2026-07-22): навигация меню > заметно быстрее, открытие/закрытие корректны (контракт двух полных > present'ов на открытии — работает, мусора вне окна нет), индикация не > регрессировала. **H4-очистка сделана:** временные таймер-логи убраны из > `present_rect_internal()` (gfx.c), вместе с ними — `#include log/log.h`+ > `task.h` (были только ради диагностики) и `port_log_uart` из > `gfx/CMakeLists.txt`. Debug/Release/host (21/21) — зелёные, подписан > финальный образ. > > **Фаза 3.2.4 — ✅.** > > 🐛 **Найден и починен позже, при обычном использовании (не отдельная HW-сессия) — раздвоение > курсора при быстрой навигации.** Root-cause: `menu_view_render(&g_menu)` читает `p_ctx->cur` > заново на КАЖДОЙ итерации цикла отрисовки строк (до 6 раз за кадр), а не один раз в начале; > `menu_task` выше по приоритету и может вытеснить `render_task` ПОСРЕДИ этого цикла на любое > нажатие кнопки. Весь рендер занимает десятки мс (пункт выше — pxp≈30мс) против 5-мс каденции > `menu_task` — при быстрой навигации окно гонки большое: `.cur` меняется между итерациями, и > курсор оказывается нарисован сразу на двух строках одного кадра. **Фикс:** `menu_view_render()` > снимает копию `*p_ctx` в локальную переменную на входе и работает только с ней — окно гонки > схлопывается с длительности всего рендера до одного присваивания структуры (тот же класс фикса, > что уже применялся для `proto_slice`/CAN-фильтров — снимок один раз, не перечитывать shared > state в цикле). Не HIL-тестируется (рендер), проверяется на стенде. > **Эталон — `OLD_PROJECT_TFT8_UKL/source/display/`** (`pxp_config.c`, `display.c`) + > `main_programm.c` (refresh-цикл ~строки 1052–1095). Наш `bsp_display` уже даёт нужный API: > `bsp_display_init(type, fb, on_frame_done)` (ISR-колбэк конца кадра) + `bsp_display_set_next_buffer(addr)`. > `fsl_pxp.{c,h}` есть в SDK (`sdk/devices/MIMXRT1052/drivers/`), но **не собран** как target. **Модель (как в TFT8_UKL):** CPU рисует **весь кадр** в **AS** (`alpha_buffer`, ARGB8888, alpha значим: opaque=0xFF, «пусто»=0). **PS** (`processing_buffer`) — фон (сейчас сплошной чёрный, раз заливается; style-картинка — Фаза 5). PXP блендит **AS+PS → задний framebuffer** (`pxp_start_operation`, busy-wait Complete), затем свап: `xSemaphoreTake(frame_done)` (синх с ELCDIF) → `bsp_display_set_next_buffer(back)` → `current_buffer_index ^= 1`. Double-buffer = tear-free (рисуем off-screen, свап атомарный) → **костыль инкрементальной отрисовки удаляем, рисуем полный кадр.** **Буферы** (некэшируемая SDRAM, `AT_NONCACHEABLE_SECTION_ALIGN`): `alpha_buffer` (AS) + `processing_buffer` (PS) + `framebuffer[2]` — каждый 800×600×4 = 1.92 МБ → **растим `m_sdram_ncache` 2→8 МБ** в `cmake/linker/MIMXRT1052xxxxx_app_slot.ld` (степень 2, MPU-выравнивание через `__NCACHE_REGION_SIZE`). **Sub-шаги (собирать после каждого; сборка/host-тесты в devcontainer, флеш — пользователь):** - **R1.** SDK: добавить `fsl_pxp` в сборку (новый target либо в gfx). Линкер: `m_sdram_ncache` 2→8 МБ. - **R2. gfx → компоновщик:** AS/PS/2×FB в ncache; порт `init_pxp`/`pxp_start_operation`; gfx рисует в **AS** (примитивы пишут alpha 0xFF; `gfx_clear` → AS прозрачный, memset 0); `gfx_present()` = PXP-композит AS+PS → задний FB + свап по `frame_done`-семафору; `gfx_init` регистрирует колбэк (даёт семафор), заливает PS чёрным, стартует ELCDIF на FB[0]. - **R3. Презентация:** рисуем полный кадр в AS → `gfx_present()`. **Удалить костыль** `menu_view_render_row` / логику «full/row» в `ui_task`; `menu_view` рисует полный кадр. Fallback — так же в AS. - **R4. Разбить `main.c`.** Первая версия — `app/task_sul_rx.c` + `app/task_ui.c` (презентация+ меню+таймер ввода в одной задаче) — HW-проверка вскрыла регресс (меню не реагирует на кнопки, см. статус выше). Исправлено на 4 задачи: `bringup_task`/`sul_rx_task`/`menu_task`/`render_task` (`app/task_{bringup,sul_rx,menu,render}.c`), контракт — `app/app_tasks.h`. `main.c` = только `main()` (board init, объекты, ОДИН `bringup_task`, scheduler) + FreeRTOS-хуки. (ARCH §4: app = задачи + wiring + main.) **Решения (согласованы):** PXP-код — внутри `services/gfx` (gfx владеет компоновщиком, как `display/` в TFT8_UKL); PS сейчас — сплошной чёрный; ассеты/спрайты/PNG/layout-компоновка остаются в Фазе 4 (там же §PLAN Фаза 4 «Double buffering в gfx» — теперь закрывается здесь, в Фазе 4 остаётся только PXP-блит спрайтов и layout). ### 3.3 — Протокол-дескрипторы + демо-протокол (🟨) > **Статус: код-комплит, host-покрытие зелёное (24 host-теста tft_app_*, из них > 23 новых/тронутых за этот под-шаг); Debug ARM-сборка линкуется чисто. > Ожидает подтверждения на стенде** (по прецеденту Фаз 1/2/3.2.4 — статус ✅ > не раньше HW-проверки). **Сделано:** - **`sul_settings_desc_t`** (ARCH §8) — `domain/sul.h`: свой `sul_settings_type_t` (BYTE/SELECT/BOOL) — НЕ переиспользует `menu_item_type_t` (домен не должен знать презентацию, §4); `sul_settings_entry_t`/`sul_settings_desc_t`, поле `.p_settings` в `sul_driver_t`. НКУ-CAN регистрирует адрес 0..15. - **Реестр** (`sul_registry.c`) — `sul_registry_set_active(id)`/ `sul_registry_count()`; активный протокол — внутренний `s_active_id`, app-слой толкает его из `settings_device_t.protocol_id` (реестр settings_store НЕ читает — тот же паттерн, что `nku_can_set_address()`). Новое поле `.p_ctx` в `sul_driver_t` — контекст decode() каждого драйвера теперь статика, живёт постоянно в реестре (`sul_registry_init()`), не пересоздаётся при смене протокола. - **Меню** (`menu_tree.c`) — секция "Протокол" (`T_PROTO`/`T_PROTO_PARAM`) строится из `sul_registry_active()->p_settings` через `menu_tree_refresh_protocol_section(settings_t*)` (сигнатура с explicit параметром — как у `menu_init()`, не через глобальный `settings_store_get_ mutable()`, чтобы не тянуть QSPI-адаптер в host-тест дерева); вызывается при bringup и после каждого действия в меню. Дерево стало НЕ `const` (было `static const K_TREE[]`) — секция протокола мутируется рантаймом. - **Демо-протокол** (`domain/sul/demo/`, `domain/sul/transport/demo/`) — второй драйвер реестра. Скриптованная "поездка" по кругу (25 шагов, согласовано с пользователем): этаж 1↔11, промежуточные остановки на 7 (едет вверх) и 3 (едет вниз), гонг на остановках и на конечных этажах. Транспорт тривиален (`sul_transport_demo_receive()` — всегда успешно и мгновенно, без состояния); весь темп ведёт `demo_decode()` сам через счётчик тиков в ctx (20/10/4 тика/шаг — медленно/норма/быстро) — decode() остаётся ЧИСТОЙ функцией (host-тест по числу вызовов), содержимое кадра игнорирует целиком (referenced-, но decode-less по сути, см. HANDOFF_PHASE3_TAIL.md п.1). Даёт свой дескриптор — параметр "Скорость" (SELECT), намеренно НЕ похож формой на "адрес" НКУ-CAN (BYTE) — тест того, что дескрипторный механизм не завязан на адрес-подобный параметр. - **`task_sul_rx.c` обобщена** — раньше жёстко держала `nku_can_ctx_t` на стеке задачи и звала CAN-специфичные функции безусловно; теперь диспетчеризация транспорта + реаппликации настроек — по `sul_registry_active()->id`, ветка на 2 случая (НКУ-CAN/демо). decode()/ `.p_ctx`/меню/настройки остаются протокол-агностичными; только эта ветка трогается Фазой 8 при протоколе на новом транспорте. **Осознанный компромисс:** транспорт/apply-settings НЕ вынесены function pointer'ами в `sul_driver_t` — это заставило бы `tft_app_sul` (реестр) линковать `bsp_can`, ломая host-тестируемость реестра без bsp; транспорт остаётся app-слоем (wiring, ARCH §4), домен/реестр — чистыми. - **Найдена и закрыта реальная опасность** (не гипотетическая, вскрылась при проектировании, не на стенде): `proto_slice[0]` — общий байт для ЛЮБОГО активного протокола (адрес НКУ-CAN 0..15 vs скорость демо 0..2). При переключении протокола в меню без клампа "протухшее" значение (напр. адрес 15) читалось бы как `options[value]` за пределами массива меток нового протокола — мусорное чтение памяти в рендере menu_view.c. Клампится в `menu_tree_refresh_protocol_section()` в момент переключения — фикс в корне (момент создания рассинхрона), не защита на каждом сайте чтения. Host-тест `test_stale_value_clamped_on_protocol_switch` фиксирует. **Host-тесты (новые за этот под-шаг):** `test_sul_registry` (9 на момент 3.3 — выбор/защита от неизвестного id/дескрипторы обоих протоколов/раздельные ctx; +1 позже в 3.5, см. её раздел), `test_tft_app_menu_tree` (4 — секция строится из дескриптора для НКУ-CAN и демо + кламп протухшего значения + edit end-to-end через реальный `menu.c`), `test_sul_demo` (10 — маршрут/промежуточные остановки/цикл/скорость/игнор содержимого кадра). **Осознанно не тронуто в этом под-шаге (отдельный пункт хвоста):** 3.4 (опто) — идёт следующим под-шагом, без пересечений по файлам с этим (3.5 закрыт отдельно сразу вслед, см. ниже — тот же модуль `nku_can`, логично рядом). ### 3.4 — Опто-входы (диспетчерский вход) (🟨) > **Статус: код-комплит (вторая итерация — первая словила баг на стенде, см. > ниже), ARM Debug (111/111) линкуется и подписан > (`build/Debug/signed/app_slot_a.bin`); host-покрытие без регрессий (24/24, > новых тестов нет — см. ниже почему). Ждёт ПОВТОРНОГО подтверждения на > стенде** (нужны реальные сигналы на IN1/IN2 — по прецеденту предыдущих > под-шагов, ✅ не раньше HW-проверки). **Дизайн (согласован с пользователем ДО кода):** 1. Диспетчерские вызов/ответ — сигналы с независимого оборудования (диспетчерский пульт), не со станции управления лифтом — поэтому не идут через `controller`/ таблицу приоритетов режимов ([MODE_PRIORITY.md](../../docs/tft_app/MODE_PRIORITY.md), та — только для ортогональных сигналов СУЛ); это отдельный вход прямо в `ui.render()` (ARCH §4, §8 п.3 — «локальный вход», не `sul_result_t`). 2. Приоритет — высший из всех: безусловно перекрывает и обычную индикацию, и любой режим СУЛ (пожар/перегруз/…), работает даже без связи со станцией (dispatcher — независимое оборудование). 3. ОТВЕТ (IN2) перебивает ВЫЗОВ (IN1), если оба почему-то активны одновременно. Референс поведения (не архитектуры) — `OLD_PROJECT_TFT8_UKL/source/main_programm.c` (`tft_refresh_task`): там диспетчерская проверка идёт ПОСЛЕДНЕЙ в каждом кадре и безусловно перезаписывает `icon_img_ptr`; здесь тот же эффект достигается проверкой ПЕРВОЙ в `render()` с безусловным `return`. **Реализация:** - **`dispatcher_indication_t`** ([fallback.h](../../firmware/tft_app/src/ui/fallback/include/ui/fallback.h)) — `NONE`/`CALL`/`ANSWER`; `ui_fallback_render_initial()`/`ui_fallback_render()` берут его ОТДЕЛЬНЫМ параметром — не часть `sul_result_t`/`indication_task_t` (не данные СУЛ, другая природа изменения). - **`app/dispatcher.c`** (новый файл; НЕ задача — нет своего `while(true)`) — тонкая обвязка уже готового BSP-драйвера `bsp_opto` (level-mode + debounce на IN1/IN2, реализован заранее). `dispatcher_init()` конфигурирует оба канала (`BSP_OPTO_MODE_LEVEL`, debounce 10 мс, **без колбэков** — `callbacks = {NULL, NULL, NULL}`) и сразу захватывает стартовое состояние (`resolve_indication()`) — иначе уже висящий на старте вызов не отобразился бы до первого `dispatcher_poll()`. `dispatcher_poll()` вызывается БЕЗУСЛОВНО каждый тик `input_poll_cb` (не реактивно — см. находку ниже): заново читает `bsp_opto_read()` для обоих каналов (`resolve_indication()` — ОТВЕТ перебивает ВЫЗОВ без доп. состояния) и будит `render_task`, только если итог реально изменился. - **Найден и закрыт баг на стенде (первая итерация, реактивная схема).** Первая версия `dispatcher.c` регистрировала колбэки bsp_opto (`callbacks = {on_opto_change, on_opto_change, NULL}`) и пересчитывала `g_dispatcher_indication` ТОЛЬКО изнутри колбэка, вызываемого `bsp_opto_process()` на подтверждённую смену канала. На стенде: «ВЫЗОВ» не сбрасывался при снятии сигнала IN1 — залипал; при этом ОТВЕТ на IN2 корректно перебивал (перекрытие по приоритету не сломано), но после снятия IN2 индикация возвращалась не в NONE, а обратно в устаревший ВЫЗОВ; через какое-то время само исправлялось. **Причина — не гонка в `bsp_opto`** (пользователь подтвердил: тот же `bsp_opto` эксплуатируется в `OLD_PROJECT_TFT8_UKL` без колбэков и без единой подобной проблемы), а отсутствие самовосстановления в чисто реактивной схеме: у неё ровно один шанс заметить каждое изменение (сам колбэк), и если он почему-то не пересчитал итог заново с первой попытки — состояние виснет НАВСЕГДА, пока не прилетит случайный следующий фронт на любом канале и не пересчитает всё заново попутно. **Фикс** — как в `OLD_PROJECT_TFT8_UKL` (`tft_refresh_task`/`bsp_opto_read()` каждую итерацию, `callbacks={NULL,...}`, фронт детектирует сам потребитель): убрали колбэки, `dispatcher_poll()` безусловно опрашивает `bsp_opto_read()` каждые 5 мс сам, без ожидания уведомления от `bsp_opto` — тот же принцип, что и `bsp_button` (тоже без колбэков, тоже опрашивается каждый тик, см. `bsp_button_poll()`). Единичный сбой при этом не критичен — самовосстанавливается на следующем же тике. - **`fallback.c`** — `render()` проверяет `dispatcher_label()` ПЕРВЫМ, до `mode_label()`, с безусловным `return` при непустой метке (слова временные — «ВЫЗОВ»/«ОТВЕТ», текст `SystemFont` в слоте режимов `MODE_Y`; полноэкранная графика с иконками — Фаза 4/5). - **`task_render.c`** — третий продюсер MPSC (были sul_rx_task + menu_task). `g_dispatcher_indication` снимается в локальную переменную ОДИН раз за итерацию (тот же приём, что чинил гонку курсора меню, Фаза 3.2.4) — без снимка два обращения в теле цикла могли бы увидеть разные значения. Сравнение с `last_dispatcher` — самостоятельная ТРЕТЬЯ причина полной перерисовки (кроме старта и закрытия меню): opto может разбудить `render_task`, когда в очереди СУЛ нет ни одного нового кадра. - **Пауза на время меню** — как в `OLD_PROJECT_TFT8_UKL`: пока меню открыто, `render_task` НЕ применяет смену диспетчера к экрану (оконный композит Фазы 3.2.4 перерисовывает только окно 480×272 — применить смену немедленно значило бы либо сломать эту оптимизацию, либо рисовать поверх окна меню). Debounce и опрос при этом НЕ паузятся — `g_dispatcher_indication` просто держит последнее значение, ничего не теряется, отражается сразу по закрытии меню тем же путём, что уже восстанавливает обычную индикацию. - **`input_poll_cb`** ([task_menu.c](../../firmware/tft_app/src/app/task_menu.c)) — три вызова подряд, тем же софт-таймером (5 мс, демон программных таймеров — высший приоритет в системе): `bsp_button_poll()` → `bsp_opto_process()` (debounce IN1/IN2) → `dispatcher_poll()` (читает результат debounce, безусловно, каждый тик). - **Пины подтверждены заранее** (`bsp/generated/TFT_Board.mex`/`pin_mux.h`) — IN1/IN2 уже размечены под opto на уровне платы, доп. переразводки не потребовалось. - **Логирование строго по фронтам** (`LOG_TAG "dispatcher"`) — `dispatcher_poll()` зовёт `log_transition()` ТОЛЬКО когда индикация реально изменилась (не на каждый тик): ВЫКЛ→ВКЛ — `"mode %s appeared"`, ВКЛ→ВЫКЛ — `"mode %s disabled"` (`%s` — сырое имя enum, CALL/ANSWER/NONE, без Cyrillic-меток — по образцу остального технического текста в LOG_I, см. `task_sul_rx.c`). Прямой переход CALL↔ANSWER (оба не NONE — ОТВЕТ перебивает ВЫЗОВ или наоборот) печатает ОБЕ строки (выключение старого + появление нового) — та же семантика edge-log, что и одиночный переход в/из NONE, без частного случая. Полезно для HW-отладки именно такого класса бага, что нашёлся выше (сырые переходы каналов видны в логе независимо от того, что показывает экран). **Осознанно без новых host-тестов.** `dispatcher.c`/изменения в `task_render.c`/ `fallback.c` — app-слой (wiring задач, порядок MPSC-пробуждений, порядок отрисовки) и презентация поверх уже готового `bsp_opto` (BSP, HW-регистры — host не тестируется в принципе), не новая доменная логика: `dispatcher_label()` и приоритет ОТВЕТ>ВЫЗОВ — тривиальная чистая функция без ветвления состояния, не отдельный модуль со своим контрактом (в отличие, например, от `sul_settings_desc_t` в 3.3). Тот же паттерн, что и остальной app-слой (`task_*.c` исторически без host-тестов — ARCH.md: host-тестируем только L2 domain). 24/24 host-тестов остаются зелёными без регрессий. **Тесты не пройденные (нужен стенд):** **приоритетно — сценарий из репорта бага выше** (снять IN1 после ВЫЗОВА → должен вернуться NONE, не залипать; затем подать/снять IN2 несколько раз подряд, включая с активным IN1 — ОТВЕТ должен корректно перебивать и корректно возвращать НЕ устаревшее состояние при снятии); сигнал на IN1 → «ВЫЗОВ» на экране; сигнал на IN2 → «ОТВЕТ»; диспетчерский вход поверх активного спецрежима СУЛ (пожар/перегруз/…) — должен перекрывать; диспетчерский вход при полностью оборванной связи со станцией («--») — тоже должен отображаться; открыть меню при активном диспетчерском входе, закрыть — индикация должна восстановиться корректно; быстрое чередование IN1/IN2 — debounce не должен давать дребезг метки. ### 3.5 — Удалённая адресация НКУ-CAN (✅) > **Статус: подтверждено на стенде (2026-07-23), с реальной станцией** — после > фикса wildcard-фильтров (см. ниже; первая HW-проверка дала «ноль реакции» > именно из-за них). Host-покрытие зелёное (45 тестов decode + 10 реестра, > +9/+1 новых); ARM Debug линкуется, подписан. **Спецификация — `REMOTE_ADDRES_SETUP.pdf`** (предоставлен пользователем, `Документы/Протоколы связи СУЛ/НКУ МППЛ/`), пять пунктов: 1. Кадр `0x4X1` — X всегда = адрес станции управления; читать в ОЗУ, не в флеш. 2. Кадр `0x5XB` (X — любой по тексту документа) — команда в байте 4 (`data[3]`, 0-индексация; старший нибль). 3. Команда «2» — записать адрес из п.1 в энергонезависимую память. 4. Повторная запись — только если новый адрес отличается от сохранённого. 5. Контроллер станции держит команду «2» некоторое время, затем сбрасывает в «0». **Согласованное уточнение (расходится с буквой PDF п.2):** пользователь также прислал референсную C-реализацию (`msg_receiver_task`/`apply_remote_addr_filters` из легаси), которая ДОПОЛНИТЕЛЬНО сверяет X командного кадра `0x5XB` с X из последнего анонса `0x4X1` — PDF говорит «X может иметь любое значение». Решение (пользователь): следовать референсному коду, X должен совпадать. **Реализация:** - **`nku_can_ctx_t`** ([nku_can.h](../../firmware/tft_app/src/domain/sul/nku_can/include/domain/sul/nku_can.h)) — два новых поля: `remote_addr_candidate` (липкий, последний X из 0x4X1) и `pending_remote_write_addr` (транзитный — валиден ТОЛЬКО сразу после вызова decode(), в котором распознана команда «2» с совпавшим X; сбрасывается в `NKU_REMOTE_ADDR_NONE` в начале КАЖДОГО вызова). - **`check_remote_address()`** ([nku_can.c](../../firmware/tft_app/src/domain/sul/nku_can/src/nku_can.c)) — независимая side-проверка на сыром ID/data, не влияет на классификацию PACKET1..5. Важный нюанс: `0x5XB` при X = наш собственный адрес — это БУКВАЛЬНО тот же ID, что `PACKET4_BASE`; кадр обрабатывается в обоих качествах независимо (host-тест `test_remote_check_does_not_interfere_with_ own_packet4` это фиксирует). Кадр с `len < 4` — не падает, просто не триггерит (свой гейт, общий `PROTO_DLC`-гейт на wildcard-ID не распространяется). - **decode() НЕ пишет settings** (остаётся чистой функцией) — сигнал уходит через generic-канал `sul_take_pending_write_fn_t`/`sul_slice_write_t` (domain/sul.h) — **не** через ветку `if (id == SUL_PROTOCOL_NKU_CAN)` в `task_sul_rx.c`, как было в первой версии. Пользователь верно заметил архитектурную утечку: транспорт-диспетчеризация (§6/§7 ADDING_PROTOCOL.md) ОБЯЗАНА знать протокол (это wiring), а вот "инициировать запись settings" — чистая бизнес-логика протокола, ей в `task_sul_rx.c` не место. Рефакторинг: `nku_can_take_pending_write()` — чистая функция-переводчик (ctx → `{slice_offset, value}`), регистрируется в `sul_driver_t.take_pending_write` (`NULL` у демо); `task_sul_rx.c` теперь проверяет только указатель на `NULL`, БЕЗ единой protocol-specific строчки для этой фичи. Идемпотентность (п.4 спецификации) — общий гейт в `task_sul_rx.c`, одинаковый для любого протокола, не дублируется в каждом. Подробности и как использовать для будущих протоколов — [ADDING_PROTOCOL.md §4](../../docs/tft_app/ADDING_PROTOCOL.md). - **Заодно — протокол-агностичное логирование**: `sul_rx_task` при `have_update` логирует весь `sul_result_t` одной строкой (`LOG_I`, не `LOG_D` — тумблер §3.6 должен мочь включить это в поле без пересборки), с именем активного протокола — не только НКУ-CAN, любой (включая демо/таймаут→default). - **Найдена и закрыта гонка записи флеша**, не гипотетическая: `menu_task` переключает `g_menu_active=false` ДО своего `settings_store_save()` (сделано намеренно на 3.2.3, чтобы `sul_rx_task` не держал мягкую паузу лишние мс) — значит `sul_rx_task` мог увидеть «меню закрыто» и попытаться сохранить СВОЙ (удалённый) адрес, пока `save()` из `menu_task` ещё физически пишет QSPI: два параллельных erase+write в один сектор. Закрыто мьютексом в [settings_store.c](../../firmware/tft_app/src/services/settings_store/src/settings_store.c) вокруг тела `save()` (создаётся в `init_defaults()`/`load()` — оба гарантированно однопоточны, до `xTaskCreate()` в `bringup_task`). **Тесты (host, `test_sul_nku_can.c`):** анонс обновляет кандидата и сам классифицируется как `IGNORED`; команда «2» с совпавшим X триггерит; с несовпавшим X / командой ≠«2» — не триггерит; pending транзитен (не залипает на следующий, не относящийся к адресации, кадр); короткий кадр не падает и не триггерит; пересечение с PACKET4 не портит ни то, ни другое. **Найден и закрыт баг на стенде (первая HW-проверка, «ноль реакции»).** Первая итерация не ставила **wildcard CAN-фильтры**: `sul_transport_can_set_ address()` настраивал только 5 точных фильтров PACKET1..5 (маска `0x7FF`) под ТЕКУЩИЙ адрес — FlexCAN аппаратно отбрасывал `0x4X1` всегда (анонс не совпадает ни с одним PACKET-ID) и `0x5XB` при X ≠ наш адрес; до `check_remote_address()` кадры не доходили вовсе. Host-тесты (все зелёные!) этого не ловят принципиально — они кормят `decode()` напрямую, мимо HW-фильтров: **фильтры — transport-слой, слепая зона host-покрытия** (граница host/HIL из ARCH §13 — тут её цена). В референсном коде OLD_PROJECT эти фильтры есть (`apply_remote_addr_filters()`, MB 5/6, маска `0x70F` — проверяются биты `[10:8]`+`[3:0]` ID, адресный нибл `[7:4]` игнорируется) — при первом портировании остались за кадром, потому что жили в ДРУГОМ месте референса (не в `msg_receiver_task`, откуда портировалась логика распознавания). **Фикс** — [can_transport.c](../../firmware/tft_app/src/domain/sul/transport/can/src/can_transport.c): MB 5 (`0x401`/`0x70F`) + MB 6 (`0x50B`/`0x70F`) добавлены в `sul_transport_can_set_address()` после точных фильтров — от адреса не зависят, но живут в той же функции (все фильтры в одном месте, нет второй точки входа, которую можно забыть позвать). **HW-проверка (2026-07-23, вторая, после фикса фильтров): пройдена** — реальная станция, кадры `0x4X1`/`0x5XB`, удалённая запись адреса срабатывает. ### 3.6 — Тумблер логов (✅) > **Статус: подтверждено на стенде (2026-07-22)** — переключение пункта «Логи» > в меню включает/выключает UART-вывод на живом устройстве. Host-покрытие > зелёное (28 тестов `test_log`, +5 новых); ARM Debug линкуется, подписан. Общий бит (согласовано с пользователем — не гейт по тегам: `BOOL_ARRAY`-редактор для тегового UI ещё не реализован, а `log_enabled` уже есть в `settings_t` и пункте меню «Логи» без доп. объёма). - **`log_set_enabled()`/`log_is_enabled()`** ([utils/log/log.h](../../utils/log/log.h), [log.c](../../utils/log/log.c)) — рантайм-гейт ПОВЕРХ компайл-тайм `LOG_LEVEL` (тот вырезает макросы физически — гейтить нечего ниже уровня). Гейт — первая проверка в `log_write()`, до мьютекса/форматирования (дёшево выключенным). По умолчанию (до первого вызова) — `true`: сообщения раннего bringup (до загрузки настроек) не теряются молча. - **Wiring** — [task_bringup.c](../../firmware/tft_app/src/app/task_bringup.c) применяет из загруженных настроек один раз; [task_menu.c](../../firmware/tft_app/src/app/task_menu.c) переприменяет после каждого действия в меню (тот же паттерн, что протокол/адрес) — эффект сразу в этом сеансе меню, не только после `save()`. - **Host-тесты (новые, `test_log.c`):** включено по умолчанию; `log_is_enabled()` отражает состояние; выключенный тумблер гасит вывод и НЕ берёт мьютекс (дешёвый путь); повторное включение восстанавливает вывод. **HW-проверка (2026-07-22): пройдена** — переключение пункта «Логи» в меню включает/выключает реальный UART-вывод на живом устройстве. ### Найдено при подготовке к 3.4 (опто) — таймаут связи был один на все протоколы `CONNECTION_TIMEOUT_MS` (3000 мс) в `task_sul_rx.c` был единственной глобальной константой для ЛЮБОГО активного протокола — не учитывало, что реальный период отправки разный по станциям (где-то ~200 мс, где-то ~1 с), а некоторые протоколы шлют кадры ТОЛЬКО по изменению состояния (event-driven на станции), для которых понятие «обрыва по тишине» в принципе некорректно. **Фикс:** `connection_timeout_ms` — новое поле `sul_driver_t` ([sul.h](../../firmware/tft_app/src/domain/sul/include/domain/sul.h)), задаётся протоколом при регистрации в `sul_registry.c` (**не** пользовательская настройка — оператор не знает реальный период станции). НКУ-CAN — `3000` (как было, HW-подтверждено ранее). Демо — `SUL_CONNECTION_TIMEOUT_DISABLED` (0) — синтетический источник, обрыва не бывает. `task_sul_rx.c` проверяет `!= SUL_CONNECTION_TIMEOUT_DISABLED` перед сравнением с порогом — для протокола с отключённым таймаутом «--» по тишине не появится никогда, целиком осознанно. Host-тест `test_connection_timeout_set_per_protocol` фиксирует значения обоих протоколов. Подробности — [ADDING_PROTOCOL.md §1](../../docs/tft_app/ADDING_PROTOCOL.md) (новое обязательное поле, легко забыть — designated-initializer молча зануляет в DISABLED). **Тесты (host).** Сериализация/дефолты/CRC ядра настроек; логика навигации меню; связывание дескрипторов; распознавание команды удалённой адресации; рантайм-гейт логов. **Критерий выхода.** Меню редактирует и сохраняет настройки; выбор протокола и адреса работает и влияет на декодер (пока протокол один); opto-вход отображается (примитив в fallback); тумблер логов работает; движок настроек/меню расширяется добавлением дескриптора/редактора без правок ядра. **Документация (обязательный выход) — ✅ готово.** [`docs/tft_app/SETTINGS.md`](../../docs/tft_app/SETTINGS.md) — состав `settings_t` (таблица всех полей + что реально редактируется в меню сейчас), ярусы A/B/C и их взаимосвязь, `proto_slice`/дескрипторы (§8, + готча со staleness из 3.3), гибридное хранение (ядро-struct на QSPI + клиентский TLV — ярус C, пока только дизайн), карта QSPI (§10, размер-независимость, честно про незанятый ping-pong-сектор). Раздел 9 отдельно перечисляет, что осознанно не реализовано (ping-pong, ярус C, `reset_user_defaults()`, ярус A без UI) — по конвенции «доки по реализованному», ничего не выдаётся за готовое раньше времени. --- ## Фаза 4 — Ассеты (TLV-бандл) ⬜ **Цель.** Ассеты в упакованном индексированном бандле на QSPI, XIP-блит без рантайм-декодирования. **Объём.** Формат TLV (индекс, per-entry CRC, версия); host-утилита `tools/` (PNG→ARGB8888, WAV, пак); `services/assets` (TLV-ридер, XIP-mmap, image cache); partition-заголовок (§10). **Double buffering в `gfx`** — второй framebuffer (`m_sdram_ncache` 2→4 МБ), swap через `bsp_display_set_next_buffer()` + семафор на `FRAME_DONE` (сейчас `gfx_init()` передаёт `NULL` вместо колбэка). Закрывает известное ограничение Фазы 1 (см. её раздел) — рисуем в задний буфер, переключаем только когда кадр готов целиком. PXP-компоновщик (многослойная композиция для реальных спрайтов из этой же фазы) — следом, тот же `gfx`. **Тесты (host).** Раунд-трип паковщик↔ридер; целостность индекса/CRC; кросс-валидация id. **Критерий выхода.** Спрайты грузятся из assets-региона и блитятся; битый/отсутствующий ассет → по-виджетная деградация. --- ## Фаза 5 — Layout-движок ⬜ **Цель.** Декларативные макеты: схема TLV, якорный солвер, темы/шаблоны, injected-регион + baked default. **Объём.** Схема layout (примитивные + спрайтовые виджеты, привязка к полям модели); солвер якорь→пиксели (чистый C); загрузчик (регион QSPI → иначе baked default, §11); порт боевых макетов НКУ (style_1/style_2) на новую схему; расширение host-утилиты (компиляция layout-исходника → TLV). **Тесты (host).** Golden-render солвера на 480×272 и 1024×600; валидация схемы/версии; выбор region/baked. **Критерий выхода.** Богатый макет рисуется из данных; injected-layout валидируется на host без сборки бинаря; клиентский кастом = новая таблица данных. --- ## Фаза 6 — Аудио ⬜ **Цель.** Озвучка и музыка через MQS, отделённая политика «событие → звук». **Объём.** `services/audio_engine` (WAV-парсер, плейлист/приоритеты, воспроизведение над `bsp_mqs` + eDMA); `audio_policy` (доменное событие/`indication_task` → команды звука: озвучка этажа, гонг, тревоги, музыка); звук в TLV-бандле. **Тесты (host).** WAV-парсер; логика плейлиста/приоритетов/вытеснения; маппинг `audio_policy` (событие→очередь) на golden-сценариях. **Критерий выхода.** Полная звуковая индикация НКУ (этажи, гонги, тревоги, музыка) с приоритетами. --- ## Фаза 7 — Обновление в поле ⬜ **Цель.** Обновление ассетов/layout с microSD; замер и решение по provisioning-каналу. **Объём.** App-side updater (SD→QSPI-регион, потоковая запись, заголовок-валидатор последним, §12); интеграция в service_tui; **замер скорости** заливки через CDC/SDP → решение SD-only или CDC/SDP (§12). **Тесты.** HIL: обновление региона с SD, устойчивость к торн-записи (→ fallback). Замер скорости. **Критерий выхода.** Ассеты/layout обновляются с SD; провижининг-путь выбран по факту замера. --- ## Фаза 8 — Мультипротокол ⬜ **Цель.** Остальные протоколы + клиентская специфика. **Объём.** Чистые декодеры УИМ, НКУ-SD7, УЭЛ, УКЛ (+ UART-транспорт-адаптеры) в реестр; их per-protocol дескрипторы настроек; клиентские таблицы приоритетов и макеты. **Тесты (host).** Golden-векторы по каждому протоколу; HIL по CAN и UART транспортам. **Критерий выхода.** Любой протокол выбирается в меню и работает без правок остальных слоёв; добавление нового протокола = декодер + запись в реестр. --- ## Фаза 9 — Профиль `app-tft4` ⬜ **Цель.** Вторая сборка под 40-pin плату TFT4. **Объём.** Board-файл пин-мукса LCDIF (40pin, без ориентации); Video PLL клок (закрыть TODO в `bsp_display`); buildPreset `app-tft4`; проверка размеров буферов/RAM; макеты под 480×272. **Тесты.** HIL на плате TFT4. **Критерий выхода.** `app-tft4` собирается и работает на плате TFT4; `app-big` — на TFT7/8/10 с рантайм-выбором панели. --- ## Открытые вопросы (уточняются по мере приближения фаз) - Точная скорость provisioning-заливки через CDC/SDP → SD-only? (Фаза 7) - Формат исходника layout для host-утилиты (JSON/TOML) и позже GUI поверх компилятора. (Фаза 5) - Renderable-множество символов на протокол vs общий шрифт; политика по-символьного fallback. (Фаза 2) - Звук в бандле: WAV как есть или препарсенный PCM. (Фаза 6) - **Каталог доменных точек логгирования** (что / где / уровень: декодер, контроллер, транспорт, смена режима, таймаут связи, …) — согласовать **совместно**, затем расставить **одним выделенным проходом** по готовому домену. Инфраструктура готова (`utils/log` + `port/log_uart`, компайл-тайм `LOG_LEVEL`). Рантайм-тумблер в меню — Фаза 3 (см. её объём).