97 KiB
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_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_okUNSET до рантайм-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_PROJECTmsg_receiver_taskPACKET1/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.capply_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_clear800×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_ncache2→8 МБ (m_sdramorigin/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, тот же день) — три новых находки:
- Обрыв связи: экран не сбрасывается на «--», залипает на старом этаже (по логу — НИ ОДНОГО
[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 потребовало бы условной компиляции; обсуждается отдельно.- Меню: вход теперь работает (подтверждает фикс 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целиком.- Пустой экран при старте без связи со станцией (прочерки появляются только после захода в меню) — объясняется тем же голоданием (находка 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/FBuint16, 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_ncache2→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-проверки).
Дизайн (согласован с пользователем ДО кода):
- Диспетчерские вызов/ответ — сигналы с независимого оборудования (диспетчерский
пульт), не со станции управления лифтом — поэтому не идут через
controller/ таблицу приоритетов режимов (MODE_PRIORITY.md, та — только для ортогональных сигналов СУЛ); это отдельный вход прямо вui.render()(ARCH §4, §8 п.3 — «локальный вход», неsul_result_t). - Приоритет — высший из всех: безусловно перекрывает и обычную индикацию, и любой режим СУЛ (пожар/перегруз/…), работает даже без связи со станцией (dispatcher — независимое оборудование).
- ОТВЕТ (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.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) — три вызова подряд, тем же софт-таймером (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 (предоставлен пользователем,
Документы/Протоколы связи СУЛ/НКУ МППЛ/), пять пунктов:
- Кадр
0x4X1— X всегда = адрес станции управления; читать в ОЗУ, не в флеш. - Кадр
0x5XB(X — любой по тексту документа) — команда в байте 4 (data[3], 0-индексация; старший нибль). - Команда «2» — записать адрес из п.1 в энергонезависимую память.
- Повторная запись — только если новый адрес отличается от сохранённого.
- Контроллер станции держит команду «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 (до загрузки настроек) не теряются молча.- Wiring — task_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 (см. её объём).