lift_indicator_suite/firmware/tft_app/PLAN.md

97 KiB
Raw Permalink Blame History

tft-app — план разработки

Пофазовый план. Стратегия — walking skeleton: сначала тонкий сквозной срез (CAN → модель → этаж на экране), затем наращивание слой за слоем. Архитектура — 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_RAMFUNCTIONapp его не задавал (только __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_cansul_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.33.6.

Флеш-грабли (в памяти): host::flash-swd-app-slot-debug шьёт подписанный образ build/Debug/signed/app_slot_a.bin → после правок обязательно build::sign-app-debug, иначе прошивается старый код. Вся работа Фаз 23 — в рабочем дереве (ветка 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.52 с); 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.cui_task совмещал меню и рендер в одной задаче.

Первая HW-проверка (2026-07-22) вскрыла регресс от R1→R4: меню не реагировало на кнопки вообще (ноль реакции), индикация запаздывала на 1-2 с относительно реального CAN-сигнала, при обрыве связи экран не сбрасывался на «--» (только после восстановления). Root-cause — связка меню+рендер в одной задаче: до R1→R4 (3.2.13.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××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-цикл ~строки 10521095). Наш 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, та — только для ортогональных сигналов СУЛ); это отдельный вход прямо в 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) — 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.crender() проверяет 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) — три вызова подряд, тем же софт-таймером (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) — два новых поля: remote_addr_candidate (липкий, последний X из 0x4X1) и pending_remote_write_addr (транзитный — валиден ТОЛЬКО сразу после вызова decode(), в котором распознана команда «2» с совпавшим X; сбрасывается в NKU_REMOTE_ADDR_NONE в начале КАЖДОГО вызова).
  • check_remote_address() (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.
  • Заодно — протокол-агностичное логирование: 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 вокруг тела 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: 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, log.c) — рантайм-гейт ПОВЕРХ компайл-тайм LOG_LEVEL (тот вырезает макросы физически — гейтить нечего ниже уровня). Гейт — первая проверка в log_write(), до мьютекса/форматирования (дёшево выключенным). По умолчанию (до первого вызова) — true: сообщения раннего bringup (до загрузки настроек) не теряются молча.
  • Wiringtask_bringup.c применяет из загруженных настроек один раз; 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), задаётся протоколом при регистрации в 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 (новое обязательное поле, легко забыть — designated-initializer молча зануляет в DISABLED).

Тесты (host). Сериализация/дефолты/CRC ядра настроек; логика навигации меню; связывание дескрипторов; распознавание команды удалённой адресации; рантайм-гейт логов.

Критерий выхода. Меню редактирует и сохраняет настройки; выбор протокола и адреса работает и влияет на декодер (пока протокол один); opto-вход отображается (примитив в fallback); тумблер логов работает; движок настроек/меню расширяется добавлением дескриптора/редактора без правок ядра.

Документация (обязательный выход) — готово. 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 (см. её объём).