diff --git a/cmake/linker/MIMXRT1052xxxxx_app_slot.ld b/cmake/linker/MIMXRT1052xxxxx_app_slot.ld
index d53beab..3fd896e 100644
--- a/cmake/linker/MIMXRT1052xxxxx_app_slot.ld
+++ b/cmake/linker/MIMXRT1052xxxxx_app_slot.ld
@@ -46,21 +46,24 @@ MEMORY
m_qacode (RX) : ORIGIN = 0x00000000, LENGTH = 0x00020000 /* SRAM_ITC 128 КБ — ramfunc */
m_data (RW) : ORIGIN = 0x20000000, LENGTH = 0x00020000 /* SRAM_DTC 128 КБ — .data/.bss/heap/stack */
m_data2 (RW) : ORIGIN = 0x20200000, LENGTH = 0x00040000 /* SRAM_OC 256 КБ */
- /* SDRAM 32 МБ, разбита на 2: некэшируемый framebuffer (ELCDIF DMA читает
- * его напрямую — MPU Region 9 в board_mpu_init() параметризован ЭТИМИ
- * __NCACHE_REGION_* символами, C-код не трогаем) + остаток под кэшируемые
- * буферы будущих фаз (ассеты, аудио). Framebuffer TFT8 800×600×4 ≈ 1.83 МБ
- * — 2 МБ (степень двойки, MPU-регион обязан быть степенью двойки и
- * выровнен по своему размеру) с запасом. См. bsp/sdram/README.md про NXP-
- * рекомендацию некэшируемых DMA-буферов в SDRAM. */
- m_sdram_ncache (RW) : ORIGIN = 0x80000000, LENGTH = 0x00200000 /* 2 МБ — framebuffer (non-cacheable) */
- m_sdram (RW) : ORIGIN = 0x80200000, LENGTH = 0x01E00000 /* ~30 МБ — кэшируемый остаток, Фаза 1 не использует */
+ /* SDRAM 32 МБ, разбита на 2: некэшируемые DMA-буферы компоновщика (ELCDIF
+ * читает framebuffer, PXP — AS/PS напрямую; MPU Region 9 в board_mpu_init()
+ * параметризован ЭТИМИ __NCACHE_REGION_* символами, C-код не трогаем) +
+ * остаток под кэшируемые буферы будущих фаз (ассеты, аудио).
+ *
+ * Фаза 3.2.4 (double-buffer + PXP): 4 буфера TFT8 800×600×4 ≈ 1.92 МБ каждый
+ * — AS (alpha_buffer) + PS (processing_buffer) + framebuffer[2] = 7.68 МБ
+ * → 8 МБ (степень двойки; MPU-регион обязан быть степенью двойки и выровнен
+ * по своему размеру — 8 МБ @ 0x80000000 удовлетворяет). См. bsp/sdram/
+ * README.md про NXP-рекомендацию некэшируемых DMA-буферов в SDRAM. */
+ m_sdram_ncache (RW) : ORIGIN = 0x80000000, LENGTH = 0x00800000 /* 8 МБ — AS+PS+2×FB (non-cacheable) */
+ m_sdram (RW) : ORIGIN = 0x80800000, LENGTH = 0x01800000 /* 24 МБ — кэшируемый остаток, пока не используется */
}
SECTIONS
{
__NCACHE_REGION_START = ORIGIN(m_sdram_ncache);
- __NCACHE_REGION_SIZE = LENGTH(m_sdram_ncache); /* 2 МБ non-cacheable — framebuffer */
+ __NCACHE_REGION_SIZE = LENGTH(m_sdram_ncache); /* 8 МБ non-cacheable — AS+PS+2×FB */
/* Vector table (imgtool header зарезервирован до неё в MEMORY). */
.interrupts :
@@ -208,7 +211,7 @@ SECTIONS
ASSERT(__DATA_END <= text_end, "region m_text overflowed with text and data")
ASSERT(text_end <= SLOT_BASE + SLOT_SIZE, "app вышел за пределы слота (2 МБ)")
ASSERT((__noncachedata_end__ - ORIGIN(m_sdram_ncache)) <= LENGTH(m_sdram_ncache),
- "m_sdram_ncache overflow — framebuffer(ы) не влезли в 2 МБ")
+ "m_sdram_ncache overflow — AS+PS+2×FB не влезли в 8 МБ")
.bss :
{
diff --git a/docs/tft_app/MENU.md b/docs/tft_app/MENU.md
index 35f9581..7f113fd 100644
--- a/docs/tft_app/MENU.md
+++ b/docs/tft_app/MENU.md
@@ -1,8 +1,9 @@
# tft-app — движок меню и связь с настройками
-Документ описывает **реализованный** движок меню (Фаза 3.2.1: чистая модель) и его
-связь с модулем настроек `settings_store`. Проектная основа — [ARCH.md §8](../../firmware/tft_app/ARCH.md);
-реализация — [menu.c](../../firmware/tft_app/src/menu/src/menu.c),
+Документ описывает **реализованный** движок меню (Фаза 3.2.1: чистая модель; 3.2.2–3.2.4: рендер,
+wiring, разделение на задачи) и его связь с модулем настроек `settings_store`. Проектная основа —
+[ARCH.md §8](../../firmware/tft_app/ARCH.md); реализация —
+[menu.c](../../firmware/tft_app/src/menu/src/menu.c),
[settings_store](../../firmware/tft_app/src/services/settings_store/).
Движущее требование (§8): клиент приносит уникальные настройки, и добавление их **не должно
@@ -17,7 +18,7 @@
```mermaid
flowchart TB
- BTN["bsp_button
долгое BUTTON_2 / next / action"]
+ BTN["bsp_button
короткое BUTTON_1 (вход/next) / BUTTON_2 (action)"]
subgraph MODEL["menu (чистая модель, host-тест)"]
TREE["дерево-данные
menu_item_desc_t[]"]
NAV["навигация + редактор
menu_next / menu_action"]
@@ -68,8 +69,10 @@ typedef struct {
## 3. Навигация
-Две кнопки: BUTTON_1 → `menu_next` (следующий пункт уровня, с заворотом); короткое BUTTON_2 →
-`menu_action` (по типу пункта). Вход в меню — долгое BUTTON_2 (app-слой).
+Две кнопки, обе коротким нажатием (без удержания — раскладка `OLD_PROJECT_TFT8_UKL`): BUTTON_1 →
+`menu_next` (следующий пункт уровня, с заворотом) когда меню открыто, **вход в меню** (app-слой,
+`menu_open`) когда закрыто; BUTTON_2 → `menu_action` (по типу пункта) когда открыто, намеренный
+no-op когда закрыто.
```mermaid
flowchart TD
@@ -101,32 +104,100 @@ flowchart TD
на дескриптор протокола `sul_settings_desc_t` (§8) — меню строит раздел «Настройки протокола» из
дескриптора активного протокола, не хардкодом.
-**Поток сохранения** (app-слой связывает модель и flash):
+**Поток сохранения** (`menu_task` связывает модель и flash — см. §5):
```mermaid
sequenceDiagram
participant U as Пользователь (кнопки)
participant M as menu (модель)
participant S as settings_t (RAM)
- participant A as app
+ participant MT as menu_task
participant St as settings_store
U->>M: menu_action (правка / выход)
M->>S: запись поля по offset (dirty=true)
- M-->>A: open=false, save_requested=true
- A->>St: settings_store_save()
+ M-->>MT: open=false, save_requested=true
+ MT->>St: settings_store_save()
St->>St: serialize + CRC → QSPI 0x450000
- A->>A: пере-применить настройки (напр. адрес → декодер)
```
-После сохранения app пере-применяет изменившиеся настройки к рантайму (например, новый адрес —
-в `nku_can_set_address()`), т.к. декодер держит свою копию адреса.
+Адрес станции (`nku_can_set_address()`) `sul_rx_task` пере-применяет из настроек сам, на следующей
+итерации — не требует отдельного сигнала (декодер читает `settings_store_get()` каждую итерацию).
---
-## 5. Рендер (Фаза 3.2.2)
+## 5. Рендер и wiring (Фаза 3.2.2–3.2.4)
-Рендер — отдельный слой, читает модель запросами (`menu_current`, `menu_level_range`,
-`menu_read_value`) и рисует в **фиксированном окне 480×272 в логических (0,0)** — одинаково на всех
-панелях (на больших — левый-верхний угол, остальное чёрное). Меню модально: пока `menu_is_open()`,
-индикация под ним не рисуется. Детали — [PLAN.md, Фаза 3.2](../../firmware/tft_app/PLAN.md).
+Рендер ([menu_view.c](../../firmware/tft_app/src/ui/menu/src/menu_view.c)) — отдельный слой, читает
+модель запросами (`menu_current`, `menu_level_range`, `menu_read_value`) и рисует в **фиксированном
+окне 480×272 @ логич.(0,0)** — одинаково на всех панелях (на больших — левый-верхний угол, остальное
+чёрное). Логики навигации не содержит.
+
+**Раскладка** (под реальные шрифты: `SystemFont`/JBMono24 h=31, `SystemFontSmall`/JBMono12 h=16):
+заголовок 36 + 6 строк × 36 + футер 20 = **272**, обрамление — тонкая серая рамка 1 px по периметру.
+Заголовок = подпись текущего уровня; строки «подпись слева / значение справа»; **курсор — сплошная
+полоса-заливка** (`gfx_fill_rect`) + белый текст; футер — легенда кнопок + «N/M».
+
+**Цвет — тинтингом** (`gfx_draw_string(..., color)` с альфа-блендингом): один белый шрифт рисуется
+любым цветом со сглаживанием, и оно корректно ложится на полосу-курсор. Значения SELECT/BOOL — из
+`options[]` дескриптора, BYTE — числом.
+
+**Полнокадровый рендер** (double-buffer + PXP, Фаза 3.2.4): любое изменение — `menu_view_render()`
+рисует **весь кадр** off-screen в альфа-поверхность AS (обнуление + окно), затем владелец дисплея
+зовёт `gfx_present()` (PXP-композит AS над чёрным PS → задний framebuffer + атомарный свап). Рисуем
+вне экрана, показываем атомарно → tear-free. Компоновщик — `services/gfx` (эталон
+`OLD_PROJECT_TFT8_UKL/source/display/`).
+
+**Меню и рендер — РАЗНЫЕ задачи** ([task_menu.c](../../firmware/tft_app/src/app/task_menu.c) /
+[task_render.c](../../firmware/tft_app/src/app/task_render.c)). Найдено на HW-верификации Фазы
+3.2.4: в объединённой задаче (Фазы 3.2.1–3.2.3, один framebuffer, без ожиданий) блокировок не было,
+разделение было безвредным упущением — но `gfx_present()` (double-buffer + PXP) внёс блокирующее
+ожидание кадра, и в объединённой задаче это ожидание попутно блокировало вход в меню (ноль реакции
+на кнопки). Эталон разделения — `OLD_PROJECT_TFT8_UKL`: `BUTTONS_TASK`/`menu_task` отдельно от
+`REFRESH_TASK`/`tft_refresh_task`.
+
+```mermaid
+flowchart LR
+ BTN["bsp_button
софт-таймер 5 мс
(debounce, независимо от обеих задач)"]
+ MT["menu_task
модель + мгновенный вход/навигация + save
НЕ рисует"]
+ RX["sul_rx_task
CAN → decode → controller
WDOG безусловно"]
+ RT["render_task
ЕДИНСТВЕННЫЙ вызывающий gfx_present()
event-driven"]
+
+ BTN --> MT
+ MT -->|"xTaskNotifyGive
(любое изменение)"| RT
+ RX -->|"xQueueOverwrite (данные)
+ xTaskNotifyGive (сигнал)"| RT
+ MT -.g_menu_active.-> RX
+```
+
+**Связь — MPSC.** Два продюсера (`sul_rx_task`, `menu_task`), один консюмер (`render_task`).
+Данные (какой этаж/диф) идут только по плечу `sul_rx_task`→`render_task` — однослотовая
+`xQueueOverwrite`-очередь (важно только последнее). Пробуждение — `xTaskNotifyGive()`/
+`ulTaskNotifyTake(pdTRUE, portMAX_DELAY)` от ОБОИХ продюсеров: `render_task` не поллит, спит между
+изменениями; несколько notify схлопываются в одно пробуждение (та же семантика «важно только
+последнее»). Приоритет «меню важнее индикации» не кодируется в уведомлении — `render_task`,
+проснувшись, всегда СНАЧАЛА проверяет `menu_is_open()`.
+
+**Мягкая пауза `sul_rx_task` на время меню.** Пока меню открыто, `menu_task` держит
+`g_menu_active=true`; `sul_rx_task` под этим флагом пропускает decode/controller/запись в очередь —
+но WDOG/heartbeat кормятся БЕЗУСЛОВНО (вне флага), задача не suspend'ится. При выходе из меню
+`render_task` (по признаку «меню только что закрылось») сразу перерисовывает последнее известное
+состояние индикации, не дожидаясь свежего CAN-кадра.
+
+**Ввод.** Опрос кнопок — **софт-таймер** (`input_poll_cb`, 5 мс; демон таймеров на высшем приоритете
+в системе → нажатия не теряются, пока заняты остальные задачи; в Фазе 3.4 туда же
+`bsp_opto_process()`). Раскладка — как в `OLD_PROJECT_TFT8_UKL`, оба нажатия короткие, без
+удержания: BUTTON_1 = вход в меню (закрыто) / следующий пункт (открыто); BUTTON_2 = выбор/инкремент
+(открыто), намеренный no-op (закрыто).
+
+**Приоритеты задач** (`app_tasks.h` — единая точка правды, `tskIDLE_PRIORITY`-относительно):
+`bringup_task` (одноразовая, самый высокий из четырёх) → `menu_task` → `render_task` → `sul_rx_task`
+(самый низкий). **Важно:** `render_task` НАМЕРЕННО выше `sul_rx_task`, не наоборот — `bsp_can_receive()`
+busy-spin без yield (`bsp/can/src/can.c`) занимает CPU весь `CAN_RX_TIMEOUT_MS` (100 мс) при
+отсутствии трафика, и `xTaskDelayUntil()` в этом случае не блокирует вовсе (дедлайн уже в прошлом —
+см. `sdk/rtos/freertos/freertos-kernel/tasks.c`), т.е. `sul_rx_task` не отдаёт CPU добровольно.
+Если `sul_rx_task` окажется выше `render_task`, последняя будет голодать всё время отсутствия
+CAN-трафика (найдено на HW-верификации — экран не обновлялся при старте без связи и при обрыве
+связи; см. PLAN.md, Фаза 3.2.4). `menu_task` по-прежнему выше `render_task` — её PXP busy-wait
+(~60-100 мс) не должен придерживать ввод. Логгер (`utils/log`) под FreeRTOS — с мьютексом
+(`port/log/src/log_mutex.c`, до Фазы 3.2.4 был `#if 0` и не собирался — гонка на общем static-буфере
+логгера между несколькими пишущими задачами).
diff --git a/firmware/tft_app/CMakeLists.txt b/firmware/tft_app/CMakeLists.txt
index 985e867..219ec44 100644
--- a/firmware/tft_app/CMakeLists.txt
+++ b/firmware/tft_app/CMakeLists.txt
@@ -58,7 +58,11 @@ include(${CMAKE_SOURCE_DIR}/firmware/bootloader/mcuboot_port/bootutil_sources.cm
add_executable(
app
- src/app/main.c
+ src/app/main.c # main() + wiring + FreeRTOS-хуки
+ src/app/task_bringup.c # одноразовый bring-up (QSPI/settings/self-confirm/SDRAM+gfx+CAN) → создаёт остальные задачи
+ src/app/task_sul_rx.c # приём CAN → decode → controller (WDOG/heartbeat безусловно, CAN-работа под !g_menu_active)
+ src/app/task_menu.c # модель меню: кнопки/hold-to-enter/nav/edit/save — НЕ рисует
+ src/app/task_render.c # презентация: единственный вызывающий gfx_present(), event-driven
${BSP_GENERATED}/clock_config.c # BOARD_BootClockRUN (зовётся board_hw_init)
${BSP_STARTUP_FILE}
${BSP_SYSCALLS_FILE}
@@ -70,6 +74,12 @@ add_executable(
# ниже (BSP_TICK_FREERTOS_MODE), чтобы не тянуть SysTick_Handler,
# конфликтующий с FreeRTOS-портом. Приходит транзитивно через bsp_uart_host.
${CMAKE_SOURCE_DIR}/bsp/tick/src/tick.c
+ # log_mutex.c — FreeRTOS strong-override весов log_mutex_{init,lock,unlock}
+ # (weak NOP в utils/log/log.c). Компилируется ПРЯМО в app (не отдельной
+ # либой) — как tick.c: FreeRTOS-специфика только этой цели, нужна во ВСЕХ
+ # сборках tft_app (без неё — гонка на общем static-буфере логгера между
+ # bringup_task/sul_rx_task/menu_task/render_task, см. PLAN.md).
+ ${CMAKE_SOURCE_DIR}/port/log/src/log_mutex.c
${CMAKE_SOURCE_DIR}/firmware/bootloader/mcuboot_port/flash_map_backend.c
${MCUBOOT_BOOTUTIL_DIR}/src/bootutil_public.c)
@@ -106,6 +116,7 @@ target_link_libraries(
app
PRIVATE bsp_board
bsp_led
+ bsp_button
bsp_wdog
bsp_boot_state
bsp_qspi_flash
diff --git a/firmware/tft_app/PLAN.md b/firmware/tft_app/PLAN.md
index 20cf114..1f639ee 100644
--- a/firmware/tft_app/PLAN.md
+++ b/firmware/tft_app/PLAN.md
@@ -210,6 +210,27 @@ marks_both_pending`, аппаратно на реальном обрыве св
> подстановка «-»); недостаёт выделенной чистой функции проверки покрытия и её теста —
> требует небольшого выноса из `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`), вскрылся при первом
@@ -231,10 +252,44 @@ marks_both_pending`, аппаратно на реальном обрыве св
---
-## Фаза 3 — Настройки и меню ⬜
+## Фаза 3 — Настройки и меню 🟨
**Цель.** Персистентные настройки на QSPI, меню на двух кнопках, локальные входы (opto).
+> ## ⏸️ ТОЧКА ОСТАНОВКИ (переход в новый тред) — 2026-07-21
+>
+> **Сделано, на железе подтверждено:**
+> - **3.1 `services/settings_store`** ✅ — персист ядра (magic/version/CRC32 в `0x450000` через
+> `bsp_qspi_flash`), `proto_slice[0]`→`nku_address` в декодер. HW: load/save, адрес переживает
+> power-cycle. _Латентный баг починен:_ 4-КБ `settings_page_t` на ~3-КБ стеке задачи → stack
+> overflow → bootloader recovery; сделал буфер `static`, стеки задач x6→x8.
+> - **3.2.1 модель меню** ✅ — чистая (`src/menu/menu.c`: дерево-данные + навигация + edit +
+> offset-привязка, `save_requested`), host-тест `tests/host/tft_app_menu` (10).
+> - **3.2.2 рендер + gfx** ✅ — тинтинг `gfx_draw_string(...,color)` (альфа-блендинг: белый шрифт
+> любым цветом со сглаживанием, корректно на цветной полосе), `gfx_fill_rect`/`gfx_draw_rect`,
+> шрифт `JBMono12`→алиас `SystemFontSmall`; окно 480×272 @ (0,0); боевое дерево `menu_tree.c`
+> (Протокол/Адрес/Логи/Выход, метки `options[]`).
+> - **3.2.3 wiring** ✅ — **софт-таймер опроса ввода** (`input_poll_cb`, 5 мс; демон таймеров на
+> высшем приоритете → нажатия не теряются во время рендера; opto добавится сюда в 3.4);
+> `ui_task` — потребитель событий + владелец дисплея; вход долгим BUTTON_2; save на выходе;
+> адрес `sul_rx_task` пере-применяет из настроек. **Макет/шрифты/тинтинг на железе — как в
+> макете; адрес персистится.**
+>
+> **ПОЧЕМУ ОСТАНОВИЛИСЬ — один framebuffer не годится.** Единственный буфер, который ELCDIF
+> сканирует по DMA пока CPU в него пишет → **tearing / частичная отрисовка**; полный `gfx_clear`
+> 800×600 в некэшируемую SDRAM (~40 мс) на кадр → **тормоза**; вход нестабилен. Инкрементальная
+> перерисовка (только изменившиеся строки) — костыль, дала баг (пропал футер). **Костыль убрать.**
+>
+> **РЕШЕНИЕ (согласовано): перенести фундамент рендера (double-buffer + PXP) из Фазы 4 в конец
+> Фазы 3.** Эталон — `OLD_PROJECT_TFT8_UKL/source/display/{pxp_config.c,display.c}` +
+> `main_programm.c` (см. ниже «3.2.4»). Ассеты/спрайты/layout остаются в Фазе 4. Дальше — под-шаг
+> **3.2.4** (R1→R4), затем 3.3–3.6.
+>
+> _Флеш-грабли (в памяти):_ `host::flash-swd-app-slot-debug` шьёт **подписанный** образ
+> `build/Debug/signed/app_slot_a.bin` → после правок обязательно `build::sign-app-debug`, иначе
+> прошивается старый код. Вся работа Фаз 2–3 — в рабочем дереве (ветка `tft-app-dev`), не
+> закоммичена (пользователь коммитит сам).
+
> **Согласованный дизайн (решено с пользователем; референсы — все старые проекты:
> settings_manager ×3, menu ×7 вкл. ALPACA, main_programm/menu_task).**
>
@@ -273,13 +328,180 @@ marks_both_pending`, аппаратно на реальном обрыве св
| Под-шаг | Содержание |
| --- | --- |
-| **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 движок меню + рендер** | **Чистая модель** (дерево-данные, навигация, 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 мс. Модальный экран. |
+| **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/тормоза, снимает костыль частичной отрисовки. Эталон — TFT8_UKL. R1→R4 реализованы (ниже), Debug/Release + host зелёные; ✅ после проверки на стенде. |
| **3.3 per-protocol дескрипторы** | `sul_settings_desc_t`: протокол регистрирует параметры (NKU-CAN: адрес 0..15); секция меню строится из дескриптора. |
| **3.4 opto-входы** | IN1/IN2 → вызов/ответ диспетчера → презентация (в fallback — примитив/текст; иконки-спрайты — Фаза 4/5). |
| **3.5 удалённая адресация NKU-CAN** | `0x4X1`/`0x5XB` → запись `nku_address` в настройки (долг Фазы 2). |
| **3.6 тумблер логов** | `log_enabled` в настройках + пункт меню + рантайм-гейт `log_set_enabled()` поверх компайл-тайм `LOG_LEVEL`. NB: продакшн-сборка — с `LOG_LEVEL >= INFO`, иначе гейтить нечего (макросы вырезаны). |
+### 3.2.4 — Фундамент рендера: double-buffer + PXP (🟨 код-комплит, ожидает железо)
+
+> **Статус: R1→R4 реализованы, Debug/Release + host (21/21) зелёные; ожидает
+> подтверждения на стенде.** По прецеденту Фаз 1/2 ✅ ставится только после
+> проверки на железе (меню/индикация без tearing, вход в меню стабилен).
+>
+> Что сделано (решения — как согласовано):
+> - **R1.** `add_sdk_driver(pxp fsl_pxp.c)` (+`sdk_clock`), gfx линкует `sdk_pxp`;
+> `m_sdram_ncache` 2→8 МБ (`m_sdram` origin/len сдвинут). `fsl_memory.h` не нужен
+> — `FSL_FEATURE_MEMORY_HAS_ADDRESS_OFFSET` для RT1052 не определён (identity).
+> - **R2.** 4 поверхности в ncache (AS+PS+2×FB, 800×600×4 ≈ 1.92 МБ, итого 7500 КБ);
+> порт `init_pxp`/`pxp_start_operation` в `services/gfx`; примитивы пишут в AS
+> (alpha 0xFF), `gfx_clear()`→memset 0 (прозрачно); `gfx_present()` = PXP-композит
+> AS над PS → задний FB + свап по семафору FRAME_DONE (ISR-колбэк ELCDIF, priority 2
+> == max-syscall → `xSemaphoreGiveFromISR` допустим). PS-формат — `kPXP_PsPixel
+> FormatARGB8888` (расширенная таблица RT1052). Владение present — согласовано:
+> **явный `gfx_present()`, оркеструет `ui_task`** (menu_view/fallback только рисуют).
+> - **R3.** Костыль снят: `menu_view_render_row` + full/row-ветвление удалены,
+> `menu_view_render()` рисует полный кадр; `ui_task` зовёт `gfx_present()` на
+> изменение. Fallback уже полнокадровый.
+> - **R4 (первая версия).** `main.c` разбит: `app/task_sul_rx.c` + `app/task_ui.c` —
+> `ui_task` совмещал меню и рендер в одной задаче.
+>
+> **Первая HW-проверка (2026-07-22) вскрыла регресс от R1→R4:** меню не реагировало
+> на кнопки вообще (ноль реакции), индикация запаздывала на 1-2 с относительно
+> реального CAN-сигнала, при обрыве связи экран не сбрасывался на «--» (только
+> после восстановления). Root-cause — связка меню+рендер в одной задаче: до R1→R4
+> (3.2.1–3.2.3, один framebuffer) блокировок в этой задаче не было, разделение было
+> безвредным упущением; `gfx_present()` внёс блокирующее ожидание кадра
+> (PXP busy-wait + semaphore-wait на FRAME_DONE), и в объединённой задаче это
+> ожидание попутно блокировало проверку кнопок — `menu_task`-логика (hold-детект)
+> физически не успевала выполняться между вызовами `gfx_present()`. Побочно
+> вскрылась гонка: FreeRTOS-мьютекс логгера (`port/log/src/log_mutex.c`) был под
+> `#if 0` и не собирался ни в один таргет — `LOG_*` из разных задач писали в общий
+> `static` буфер (`utils/log/log.c`) без синхронизации.
+>
+> **R4 (исправлено) — 4 задачи вместо 2**, эталон разделения —
+> `OLD_PROJECT_TFT8_UKL` (`BUTTONS_TASK`/`menu_task` отдельно от
+> `REFRESH_TASK`/`tft_refresh_task`):
+> - `bringup_task` — одноразовая инициализация (лог-мьютекс → UART/лог → QSPI/
+> settings/self-confirm → SDRAM+gfx+CAN), создаёт три нижеследующие задачи,
+> удаляет себя (`vTaskDelete(NULL)`).
+> - `sul_rx_task` (`task_sul_rx.c`) — CAN→decode→controller. WDOG/heartbeat —
+> **безусловно**; сама CAN-работа — под `!g_menu_active` (**мягкая пауза**, не
+> `vTaskSuspend` — WDOG остаётся в безопасности по конструкции, не завязан на
+> suspend-состояние).
+> - `menu_task` (новый, `task_menu.c`) — модель меню, потребление кнопок,
+> hold-to-enter, save. НЕ рисует. Владеет `g_menu`, пишет `g_menu_active`.
+> - `render_task` (был `ui_task`, `task_render.c`) — единственный вызывающий
+> `gfx_present()`. **Event-driven**: `ulTaskNotifyTake(pdTRUE, portMAX_DELAY)`,
+> будят `sul_rx_task` и `menu_task` (MPSC) через `xTaskNotifyGive` — не поллит.
+> На «меню только что закрылось» — восстанавливает индикацию из последнего
+> известного состояния сразу, не дожидаясь свежего CAN-кадра.
+> - Приоритеты (`app_tasks.h`, единая точка правды, менялись — см. вторую HW-
+> проверку ниже): изначально `bringup` > `menu` > `sul_rx` > `render`. Демон
+> программных таймеров (`input_poll_cb`, debounce) — отдельно,
+> `configTIMER_TASK_PRIORITY` (наивысший в системе), не задет.
+> - `port/log/src/log_mutex.c` — снят `#if 0`, включён в сборку `app` (как
+> `tick.c` — компилируется прямо в таргет, FreeRTOS-специфика); `log_mutex_init()`
+> первой строкой `bringup_task`, до первого `LOG_*` где-либо в системе.
+>
+> **Временная диагностика** (не убрана, ждёт решения по 66 мс ниже):
+> `gfx_present()` логирует раздельно длительность PXP busy-wait и
+> semaphore-wait на FRAME_DONE (`LOG_I("gfx", "present: pxp=%u ms vsync=%u ms")`).
+> Замерено на стенде: **pxp≈66 мс, vsync≈0 мс** — стабильно, независимо от
+> содержимого кадра (похоже на фиксированную стоимость полнокадрового PXP-
+> композита на этом железе/тактировании, не на баг в коде отрисовки — тактовые
+> частоты (ARM PLL/AHB/IPG) идентичны `OLD_PROJECT_TFT8_UKL`, который использует
+> тот же PXP+vsync паттерн). Причина ещё не тормознее ELCDIF (65 Гц по расчёту
+> из `clock_config.c` — не бутылочное горлышко, vsync≈0 подтверждает). Открытый
+> вопрос — см. вторую HW-проверку.
+>
+> ---
+>
+> **Вторая HW-проверка (2026-07-22, тот же день) — три новых находки:**
+>
+> 1. **Обрыв связи: экран не сбрасывается на «--», залипает на старом этаже**
+> (по логу — НИ ОДНОГО `[gfx] present:` за 8+ с после обрыва, хотя WDOG/
+> heartbeat продолжают штатно). **Root-cause найден и подтверждён кодом:**
+> `bsp_can_receive()` (`bsp/can/src/can.c`) — busy-spin БЕЗ единого
+> блокирующего FreeRTOS-вызова внутри (`for(;;){poll_rx_mailboxes(); ...}`).
+> Без CAN-трафика `sul_rx_task` занимает весь `CAN_RX_TIMEOUT_MS` (100 мс)
+> КАЖДУЮ итерацию. Проверено по вендоренному `xTaskDelayUntil()`
+> (`sdk/rtos/freertos/freertos-kernel/tasks.c`): если дедлайн уже в прошлом
+> (а он в прошлом, когда спин съедает весь период) — `xShouldDelay` остаётся
+> `false`, задача в delayed-list НЕ добавляется, т.е. **не блокирует вовсе**.
+> Более низкоприоритетная задача никогда не выполнится, пока такая задача
+> непрерывно READY — `render_task` (был ниже `sul_rx_task`) физически не
+> получал CPU ни для первого рендера при старте без связи, ни для обработки
+> diff'а на «--» при обрыве. При живом трафике невидимо (`receive` почти
+> всегда быстрый, `sul_rx_task` реально блокируется) — поэтому не всплывало
+> раньше ни разу (PLAN.md, Фаза 1, прямо отмечал: обрыв связи на реальном
+> железе отдельно не гонялся).
+> **Фикс:** переставлены приоритеты — `menu > render > sul_rx`
+> (было `menu > sul_rx > render`). `render_task` больше не может быть
+> голодом заморожен `sul_rx_task`; `menu_task` по-прежнему выше `render_task`
+> (её PXP busy-wait не должен придерживать ввод). Корень (busy-spin в
+> `bsp_can_receive`) НЕ тронут — общий bare-metal+FreeRTOS модуль, добавление
+> yield потребовало бы условной компиляции; обсуждается отдельно.
+> 2. **Меню: вход теперь работает** (подтверждает фикс R4). Остальное:
+> навигация медленная (см. pxp≈66 мс выше — при открытом меню `sul_rx_task`
+> не спинит, `g_menu_active` гасит её блок целиком, так что 66 мс — это
+> чистая стоимость одного `gfx_present()`, не голодание); выход иногда не
+> срабатывает (могло быть тем же голоданием, что и находка 1 — при отсутствии
+> связи `sul_rx_task` возобновляет спин сразу по выходу из меню и мог не
+> пускать `render_task` дорисовать «выход»; должно закрыться тем же фиксом
+> приоритетов, перепроверить на стенде); пожелание — другая кнопка входа,
+> мгновенно (без удержания), как в `OLD_PROJECT_TFT8_UKL`.
+> **Согласовано и сделано:** раскладка `OLD_PROJECT_TFT8_UKL` дословно —
+> короткое BUTTON_1 = вход в меню (закрыто) / следующий пункт (открыто);
+> короткое BUTTON_2 = выбор/действие (открыто), намеренный no-op (закрыто).
+> `MENU_ENTER_HOLD_MS`/`hold_start` убраны из `task_menu.c` целиком.
+> 3. **Пустой экран при старте без связи со станцией** (прочерки появляются
+> только после захода в меню) — объясняется тем же голоданием (находка 1):
+> `render_task` не получал CPU для самого первого рендера, пока `sul_rx_task`
+> непрерывно спинила. Должно закрыться тем же фиксом приоритетов.
+>
+> _Осталось для ✅ (третий цикл проверки):_ прошить подписанный
+> `build/Debug/signed/app_slot_a.bin` и на TFT8: (а) старт без связи со
+> станцией — прочерки видны сразу, без захода в меню; (б) обрыв связи
+> мид-сессии → «--» сразу, без залипания; восстановление — сразу; (в) вход и
+> выход из меню — короткими BUTTON_1/действием, срабатывают стабильно, в т.ч.
+> без связи со станцией. Плюс субъективно оценить навигацию в меню (66 мс/шаг
+> — приемлемо или нужно отдельно разбираться со снижением стоимости
+> `gfx_present()` для модального окна меню) — открытый вопрос, решить по
+> итогам этой проверки.
+
+
+
+> **Эталон — `OLD_PROJECT_TFT8_UKL/source/display/`** (`pxp_config.c`, `display.c`) +
+> `main_programm.c` (refresh-цикл ~строки 1052–1095). Наш `bsp_display` уже даёт нужный API:
+> `bsp_display_init(type, fb, on_frame_done)` (ISR-колбэк конца кадра) + `bsp_display_set_next_buffer(addr)`.
+> `fsl_pxp.{c,h}` есть в SDK (`sdk/devices/MIMXRT1052/drivers/`), но **не собран** как target.
+
+**Модель (как в TFT8_UKL):** CPU рисует **весь кадр** в **AS** (`alpha_buffer`, ARGB8888, alpha
+значим: opaque=0xFF, «пусто»=0). **PS** (`processing_buffer`) — фон (сейчас сплошной чёрный, раз
+заливается; style-картинка — Фаза 5). PXP блендит **AS+PS → задний framebuffer** (`pxp_start_operation`,
+busy-wait Complete), затем свап: `xSemaphoreTake(frame_done)` (синх с ELCDIF) → `bsp_display_set_next_buffer(back)` →
+`current_buffer_index ^= 1`. Double-buffer = tear-free (рисуем off-screen, свап атомарный) → **костыль
+инкрементальной отрисовки удаляем, рисуем полный кадр.**
+
+**Буферы** (некэшируемая SDRAM, `AT_NONCACHEABLE_SECTION_ALIGN`): `alpha_buffer` (AS) +
+`processing_buffer` (PS) + `framebuffer[2]` — каждый 800×600×4 = 1.92 МБ → **растим `m_sdram_ncache`
+2→8 МБ** в `cmake/linker/MIMXRT1052xxxxx_app_slot.ld` (степень 2, MPU-выравнивание через
+`__NCACHE_REGION_SIZE`).
+
+**Sub-шаги (собирать после каждого; сборка/host-тесты в devcontainer, флеш — пользователь):**
+- **R1.** SDK: добавить `fsl_pxp` в сборку (новый target либо в gfx). Линкер: `m_sdram_ncache` 2→8 МБ.
+- **R2. gfx → компоновщик:** AS/PS/2×FB в ncache; порт `init_pxp`/`pxp_start_operation`; gfx рисует
+ в **AS** (примитивы пишут alpha 0xFF; `gfx_clear` → AS прозрачный, memset 0); `gfx_present()` =
+ PXP-композит AS+PS → задний FB + свап по `frame_done`-семафору; `gfx_init` регистрирует колбэк
+ (даёт семафор), заливает PS чёрным, стартует ELCDIF на FB[0].
+- **R3. Презентация:** рисуем полный кадр в AS → `gfx_present()`. **Удалить костыль** `menu_view_render_row`
+ / логику «full/row» в `ui_task`; `menu_view` рисует полный кадр. Fallback — так же в AS.
+- **R4. Разбить `main.c`.** Первая версия — `app/task_sul_rx.c` + `app/task_ui.c` (презентация+
+ меню+таймер ввода в одной задаче) — HW-проверка вскрыла регресс (меню не реагирует на кнопки,
+ см. статус выше). Исправлено на 4 задачи: `bringup_task`/`sul_rx_task`/`menu_task`/`render_task`
+ (`app/task_{bringup,sul_rx,menu,render}.c`), контракт — `app/app_tasks.h`. `main.c` = только
+ `main()` (board init, объекты, ОДИН `bringup_task`, scheduler) + FreeRTOS-хуки. (ARCH §4: app =
+ задачи + wiring + main.)
+
+**Решения (согласованы):** PXP-код — внутри `services/gfx` (gfx владеет компоновщиком, как `display/`
+в TFT8_UKL); PS сейчас — сплошной чёрный; ассеты/спрайты/PNG/layout-компоновка остаются в Фазе 4
+(там же §PLAN Фаза 4 «Double buffering в gfx» — теперь закрывается здесь, в Фазе 4 остаётся только
+PXP-блит спрайтов и layout).
+
**Тесты (host).** Сериализация/дефолты/CRC ядра настроек; логика навигации меню; связывание
дескрипторов; распознавание команды удалённой адресации; рантайм-гейт логов.
diff --git a/firmware/tft_app/src/app/app_tasks.h b/firmware/tft_app/src/app/app_tasks.h
new file mode 100644
index 0000000..0bad698
--- /dev/null
+++ b/firmware/tft_app/src/app/app_tasks.h
@@ -0,0 +1,127 @@
+/**
+ * @file app_tasks.h
+ * @brief Контракт между задачами app-слоя (wiring, ARCH §4).
+ *
+ * Четыре задачи (найдено на HW-верификации Фазы 3.2.4 — меню и render в одной
+ * задаче оказались взаимно неотзывчивы, см. PLAN.md):
+ * - bringup_task — одноразовая инициализация (UART/QSPI/settings/self-confirm/
+ * SDRAM+gfx+CAN), затем создаёт три нижеследующие задачи и удаляет себя.
+ * - sul_rx_task — приём CAN → decode → controller. WDOG/heartbeat кормятся
+ * БЕЗУСЛОВНО; сама CAN-работа — под !g_menu_active (мягкая пауза на время
+ * меню — WDOG остаётся в безопасности по конструкции).
+ * - menu_task — модель меню: потребление кнопок, hold-to-enter,
+ * навигация/edit/exit+save. НЕ рисует.
+ * - render_task — единственный владелец дисплея/вызывающий gfx_present().
+ * Event-driven (будится xTaskNotifyGive от sul_rx_task И menu_task —
+ * MPSC), не поллит, не содержит кнопочной логики.
+ *
+ * main.c создаёt только очередь/софт-таймер ввода/bringup_task — сами задачи
+ * друг друга создают/не создают по схеме выше, main.c про это не знает.
+ */
+
+#ifndef APP_TASKS_H_
+#define APP_TASKS_H_
+
+#include "FreeRTOS.h"
+#include "domain/controller.h" /* indication_task_t */
+#include "domain/elevator_model.h" /* sul_result_t */
+#include "menu/menu.h" /* menu_ctx_t */
+#include "queue.h"
+#include "task.h"
+#include "timers.h"
+
+#include
+
+/* ── Приоритеты (единая точка правды, tskIDLE_PRIORITY-относительно) ────────
+ *
+ * menu_task > render_task > sul_rx_task. Найдено на HW-верификации (см.
+ * PLAN.md): `bsp_can_receive()` — busy-spin БЕЗ yield (bsp/can/src/can.c,
+ * никаких блокирующих FreeRTOS-вызовов внутри `for(;;){poll;...}`); пока нет
+ * CAN-трафика, sul_rx_task занимает весь CAN_RX_TIMEOUT_MS каждую итерацию, и
+ * xTaskDelayUntil() в этом случае НЕ блокирует вовсе (дедлайн уже в прошлом —
+ * см. tasks.c, xShouldDelay остаётся false). Более низкоприоритетная задача
+ * никогда не получает CPU, пока такая задача продолжает быть READY —
+ * render_task, будучи ниже sul_rx_task, банально не мог выполниться (ни
+ * первый рендер при старте без связи, ни обработка «--» при обрыве связи).
+ * При живом трафике это было невидимо (receive почти всегда быстрый,
+ * sul_rx_task реально блокируется), поэтому не всплывало раньше.
+ *
+ * menu_task остаётся ВЫШЕ render_task: её собственный busy-wait внутри PXP
+ * (gfx_pxp_run, ~60-100 мс по замерам) не должен придерживать ввод — то же
+ * свойство, ради которого меню и рендер разведены по разным задачам.
+ * bringup_task — выше всех троих (монополизирует CPU на время одноразовой
+ * инициализации, пока остальные задачи ещё не созданы). Демон программных
+ * таймеров (bsp_button debounce, input_poll_cb) — отдельно, на
+ * configTIMER_TASK_PRIORITY (см. FreeRTOSConfig.h — наивысший в системе), не
+ * отсюда.
+ *
+ * Корень (busy-spin в bsp_can_receive без yield) НЕ тронут — это отдельный,
+ * более рискованный шаг (bsp_can — общий модуль, используется и bare-metal
+ * таргетами; добавить FreeRTOS-yield внутрь потребует условной компиляции,
+ * как bsp_tick/log_mutex). Обсудить отдельно, если приоритетов недостаточно. */
+#define APP_PRIORITY_BRINGUP (tskIDLE_PRIORITY + 4U)
+#define APP_PRIORITY_MENU (tskIDLE_PRIORITY + 3U)
+#define APP_PRIORITY_RENDER (tskIDLE_PRIORITY + 2U)
+#define APP_PRIORITY_SUL_RX (tskIDLE_PRIORITY + 1U)
+
+/** Общий стек-бюджет app-тасков (см. PLAN.md, Фаза 0 — тонкий стек уже
+ * маскировался под похожий на зависание симптом; x8 — с запасом,
+ * проверено на всех четырёх ролях). */
+#define APP_TASK_STACK_WORDS (configMINIMAL_STACK_SIZE * 8U)
+
+/**
+ * @brief Самое свежее состояние индикации (не история): sul_rx_task → render_task.
+ *
+ * Очередь глубины 1 с xQueueOverwrite() — важно только ПОСЛЕДНЕЕ состояние,
+ * не промежуточные кадры (рендер не обязан успевать за каждым). Пробуждение
+ * render_task — отдельно, через xTaskNotifyGive (см. g_render_task_handle).
+ */
+typedef struct
+{
+ indication_task_t task;
+ sul_result_t result;
+} render_msg_t;
+
+/** Очередь глубины 1 (создаётся в main). Продюсер — sul_rx_task, консюмер — render_task. */
+extern QueueHandle_t g_render_queue;
+
+/** Дисплей+CAN подняты (bring-up в bringup_task) — sul_rx_task/render_task ждут этого. */
+extern volatile bool g_display_ready;
+
+/** true, пока меню открыто. Мягкая пауза sul_rx_task (CAN-decode/controller/
+ * очередь пропускаются, WDOG/heartbeat — нет, см. task_sul_rx.c). Единственный
+ * писатель — menu_task, единственный читатель — sul_rx_task. */
+extern volatile bool g_menu_active;
+
+/** Модель меню. Владеет и мутирует ТОЛЬКО menu_task; render_task только читает
+ * для отрисовки (после xTaskNotifyGive — happens-before через нотификацию,
+ * как у g_render_queue). */
+extern menu_ctx_t g_menu;
+
+/** Хэндл render_task — sul_rx_task и menu_task (MPSC-продюсеры) будят его
+ * xTaskNotifyGive() на любое изменение состояния. Устанавливается
+ * bringup_task ДО создания sul_rx_task/menu_task. */
+extern TaskHandle_t g_render_task_handle;
+
+/** Одноразовая инициализация (UART/QSPI/settings/confirm_self/SDRAM+gfx+CAN),
+ * затем создаёт sul_rx_task/menu_task/render_task и удаляет себя. */
+void bringup_task(void *p_arg);
+
+/** Приём CAN + WDOG/heartbeat (безусловно) + decode/controller (под !g_menu_active). */
+void sul_rx_task(void *p_arg);
+
+/** Модель меню: потребление кнопок, hold-to-enter, навигация/edit/exit+save. */
+void menu_task(void *p_arg);
+
+/** Презентация: владелец дисплея, единственный вызывающий gfx_present(). */
+void render_task(void *p_arg);
+
+/** Колбэк софт-таймера debounce (bsp_button_poll) — main создаёт таймер, каденция в main. */
+void input_poll_cb(TimerHandle_t x_timer);
+
+/** Диагностика трейлера слота (read-only, безопасно звать многократно) —
+ * общая для bringup_task (before/after-confirm) и sul_rx_task (периодический
+ * re-log). Определена в task_bringup.c. */
+void log_slot_status(const char *p_when);
+
+#endif /* APP_TASKS_H_ */
diff --git a/firmware/tft_app/src/app/main.c b/firmware/tft_app/src/app/main.c
index 45bfa74..f7ee900 100644
--- a/firmware/tft_app/src/app/main.c
+++ b/firmware/tft_app/src/app/main.c
@@ -1,336 +1,59 @@
/**
* @file main.c
- * @brief tft_app — точка входа.
+ * @brief tft_app — точка входа: board init, объекты, bringup_task, планировщик, хуки.
+ *
+ * ARCH §4: app-слой = задачи + wiring + main. main() создаёт только очередь,
+ * софт-таймер ввода и ОДНУ задачу (bringup_task) — она сама создаёт
+ * sul_rx_task/menu_task/render_task после одноразовой инициализации и
+ * удаляет себя (см. app_tasks.h — полный контракт между задачами).
*
* Фаза 0 (каркас, подтверждено на железе): образ линкуется как валидный
* MCUboot-слот (Direct-XIP), bootloader в него прыгает, FreeRTOS стартует,
- * WDOG кормится, образ ПОДТВЕРЖДАЕТ СЕБЯ в рантайме (нет revert на повторной
- * загрузке — anti-brick сохранён). Контракт с bootloader и обоснование
- * self-confirm/QSPI-ramfunc/UART-диагностики — см. PLAN.md, Фаза 0.
- *
- * Фаза 1 (walking skeleton): реальный CAN-кадр НКУ-CAN → чистый декодер →
- * контроллер → fallback-рендер (позиция + стрелка). Две задачи:
- * - sul_rx_task — WDOG/heartbeat (унаследовано от Фазы 0) + приём CAN +
- * decode + controller_process(); на изменение — в очередь render_task.
- * - render_task — потребляет очередь, зовёт ui_fallback_render().
- * Очередь глубиной 1 с xQueueOverwrite() — важно только ПОСЛЕДНЕЕ состояние,
- * не история промежуточных кадров (render не обязан успевать за каждым).
+ * WDOG кормится, образ подтверждает себя в рантайме — см. PLAN.md, Фаза 0.
*/
#include "FreeRTOS.h"
+#include "app_tasks.h"
#include "board.h"
-#include "bootutil/bootutil_public.h"
-#include "bsp/boot_state.h"
-#include "bsp/display.h"
+#include "bsp/button.h"
#include "bsp/led.h"
-#include "bsp/qspi_flash.h"
-#include "bsp/sdram.h"
-#include "bsp/uart_host.h"
-#include "bsp/wdog.h"
-#include "domain/controller.h"
-#include "domain/elevator_model.h"
-#include "domain/sul.h"
-#include "domain/sul/nku_can.h"
-#include "domain/sul/transport/can.h"
-#include "flash_map.h"
-#include "log/log.h"
-#include "port/log_uart.h"
#include "queue.h"
-#include "services/gfx.h"
-#include "services/settings_store.h"
#include "task.h"
-#include "ui/fallback.h"
+#include "timers.h"
#include
-#define LOG_TAG "app"
+#define INPUT_POLL_PERIOD_MS 5U /* софт-таймер опроса ввода (bsp_button_poll; opto — Фаза 3.4) */
-/* Собственный слот образа. Slot A = 0 (primary), Slot Б = 1 (secondary). */
-#ifndef APP_OWN_SLOT_ID
-#define APP_OWN_SLOT_ID 0
-#endif
+/* ── Разделяемое состояние задач (объявления — app_tasks.h) ──────────────── */
-/* Панель текущего стенда — Фаза 1 хардкод (Фаза 9: provisioning выбирает из
- * settings, см. ARCH §9 — TFT7/8/10 рантайм-выбор внутри профиля app-big). */
-#define PANEL_TYPE BSP_DISPLAY_TFT8
-
-#define HEARTBEAT_PERIOD_MS 500U
-#define WDOG_FEED_PERIOD_MS 100U /* кормим чаще периода мигания — таймаут WDOG >= 1 c */
-#define STATUS_LOG_PERIOD_MS \
- 2000U /* периодический re-log трейлера — виден независимо
- * от момента подключения терминала */
-#define CAN_RX_TIMEOUT_MS 100U /* держит цикл отзывчивым к WDOG/heartbeat-каденции */
-#define CONNECTION_TIMEOUT_MS \
- 3000U /* «пропадание трафика» — см. ARCH, поток данных:
- * poll + timeout→default. Порядок величины — как
- * в OLD_PROJECT (там ~3 с на отметку потери связи) */
-
-/* TEMP (Фаза 3.1): двухзагрузочная HW-проверка пути save/персист. УБРАТЬ в 3.2,
- * когда save начнёт вызываться из меню. Сентинел в ИНЕРТНОМ поле max_load_kg
- * (рендер грузоподъёмности — Фаза 5), proto_slice/адрес НЕ трогаем — индикатор
- * продолжает работать с адресом 0. */
-#define SETTINGS_SELFTEST_SENTINEL 4242U
-
-/* Передача render_task самого свежего состояния — не истории. */
-typedef struct
-{
- indication_task_t task;
- sul_result_t result;
-} render_msg_t;
-
-static QueueHandle_t g_s_render_queue;
-
-/* ── Диагностика трейлера слота (read-only, безопасно звать многократно) ── */
-
-static void log_slot_status(const char *p_when)
-{
- const struct flash_area *p_fap;
- const int RC_OPEN = flash_area_open((uint8_t) APP_OWN_SLOT_ID, &p_fap);
- if (RC_OPEN != 0)
- {
- LOG_E(LOG_TAG, "%s: flash_area_open(slot%d) rc=%d", p_when, APP_OWN_SLOT_ID, RC_OPEN);
- return;
- }
-
- struct boot_swap_state st = { 0 };
- const int RC_RD = boot_read_swap_state(p_fap, &st);
- LOG_I(LOG_TAG, "%s: slot%d magic=%d copy_done=%d image_ok=%d (rd=%d)", p_when, APP_OWN_SLOT_ID,
- st.magic, st.copy_done, st.image_ok, RC_RD);
-
- flash_area_close(p_fap);
-}
-
-/**
- * @brief Подтвердить СОБСТВЕННЫЙ слот (APP_OWN_SLOT_ID).
- *
- * boot_set_next(fap, active=true, confirm=true), НЕ boot_set_confirmed(): та
- * жёстко пишет в FLASH_AREA_IMAGE_PRIMARY (Slot A) независимо от исполняемого
- * слота — для Direct-XIP с двумя слотами это подтвердило бы не тот при
- * исполнении из Slot Б.
- */
-static void confirm_self(void)
-{
- log_slot_status("before-confirm"); /* ожидаем magic=1(GOOD) image_ok=3(UNSET) */
-
- const struct flash_area *p_fap;
- const int RC_OPEN = flash_area_open((uint8_t) APP_OWN_SLOT_ID, &p_fap);
- if (RC_OPEN != 0)
- {
- LOG_E(LOG_TAG, "confirm: flash_area_open(slot%d) rc=%d", APP_OWN_SLOT_ID, RC_OPEN);
- return;
- }
-
- const int RC_SET = boot_set_next(p_fap, true, true);
- LOG_I(LOG_TAG, "confirm: boot_set_next rc=%d", RC_SET);
-
- flash_area_close(p_fap);
-
- log_slot_status("after-confirm"); /* ожидаем image_ok=1(SET) */
-}
-
-/**
- * @brief SDRAM (SEMC, без DCD) + framebuffer + CAN — до создания render_task.
- *
- * bsp_sdram_configure()+init() — та же документированная в bsp/sdram.h связка
- * для «прошивок без DCD», которую использует и bootloader-smoke-test.
- */
-static bool bring_up_display_and_can(void)
-{
- if (bsp_sdram_configure() != BSP_OK)
- {
- LOG_E(LOG_TAG, "bsp_sdram_configure FAILED");
- return false;
- }
- if (bsp_sdram_init() != BSP_OK)
- {
- LOG_E(LOG_TAG, "bsp_sdram_init FAILED");
- return false;
- }
- if (gfx_init(PANEL_TYPE) != BSP_OK)
- {
- LOG_E(LOG_TAG, "gfx_init FAILED");
- return false;
- }
- if (sul_transport_can_init() != BSP_OK)
- {
- LOG_E(LOG_TAG, "sul_transport_can_init FAILED");
- return false;
- }
-
- LOG_I(LOG_TAG, "display+CAN bring-up OK");
- return true;
-}
-
-static void render_task(void *p_arg)
-{
- (void) p_arg;
-
- /* Безусловная первая отрисовка — не ждём первого diff (см. ui/fallback.h). */
- const sul_result_t INITIAL = sul_default_state();
- ui_fallback_render_initial(&INITIAL);
-
- render_msg_t msg;
- for (;;)
- {
- if (xQueueReceive(g_s_render_queue, &msg, portMAX_DELAY) == pdTRUE)
- {
- ui_fallback_render(&msg.task, &msg.result);
- }
- }
-}
-
-/* TEMP (Фаза 3.1): проверка save/персист через power cycle. Первый запуск —
- * пишет сентинел и просит перезагрузку; после перезагрузки load() читает его с
- * флеша → «PERSIST OK». Сентинел остаётся (инертен), затирается первым
- * сохранением из меню (3.2) или factory-reset. УБРАТЬ вместе с #define в 3.2. */
-static void settings_selftest(void)
-{
- settings_t *p_s = settings_store_get_mutable();
- if (p_s->user.max_load_kg == SETTINGS_SELFTEST_SENTINEL)
- {
- LOG_I(LOG_TAG, "settings self-test: PERSIST OK (сентинел пережил перезагрузку)");
- return;
- }
-
- p_s->user.max_load_kg = SETTINGS_SELFTEST_SENTINEL;
- const bsp_status_t RC = settings_store_save();
- LOG_I(LOG_TAG,
- "settings self-test: записан сентинел rc=%d — СДЕЛАЙТЕ POWER-CYCLE для проверки персиста",
- RC);
-}
-
-static void sul_rx_task(void *p_arg)
-{
- (void) p_arg;
-
- /* LPUART1/MCU-Link VCOM — доступен сразу, без enumeration/wait (в отличие
- * от target-side USB CDC). log_mutex не нужен — единственный писатель. */
- (void) bsp_uart_host_init(115200U);
- log_uart_init();
-
- /* flash_map_backend требует bsp_qspi_init() ДО любой flash_area_*. */
- const bool QSPI_OK = (bsp_qspi_init() == BSP_OK);
- LOG_I(LOG_TAG, "tft_app phase1 boot: qspi=%s", QSPI_OK ? "OK" : "FAIL");
-
- /* Настройки ядра (§8, §10): с флеша если QSPI поднялся, иначе дефолты. */
- if (QSPI_OK)
- {
- const bsp_status_t S_RC = settings_store_load();
- LOG_I(LOG_TAG, "settings: load rc=%d proto_addr=%u", S_RC,
- settings_store_get()->user.proto_slice[0]);
- settings_selftest(); /* TEMP (3.1): HW-проверка save/персист, убрать в 3.2 */
- }
- else
- {
- settings_store_init_defaults();
- }
-
- /* «Дошёл до устойчивого состояния» — сбрасывает счётчик попыток загрузки
- * (recovery загрузчика). SRC GPR, без flash. Безусловно, до потенциально
- * рискованного bring-up дисплея/CAN ниже. */
- bsp_boot_health_mark();
-
- if (QSPI_OK)
- {
- confirm_self();
- }
- else
- {
- LOG_E(LOG_TAG, "qspi_init FAILED — self-confirm skipped, slot will revert");
- }
-
- const bool DISPLAY_CAN_OK = bring_up_display_and_can();
-
- nku_can_ctx_t nku_ctx;
- nku_can_init(&nku_ctx);
- /* Адрес станции из настроек (§8, proto_slice[0]) → сдвиг ID пакетов. */
- nku_can_set_address(&nku_ctx, settings_store_get()->user.proto_slice[0]);
-
- controller_ctx_t ctrl_ctx;
- controller_init(&ctrl_ctx);
-
- const TickType_t FEED_PERIOD = pdMS_TO_TICKS(WDOG_FEED_PERIOD_MS);
- uint32_t elapsed_ms = 0U;
- uint32_t since_status_ms = 0U;
- TickType_t last_wake = xTaskGetTickCount();
- TickType_t last_frame_tick = xTaskGetTickCount();
-
- for (;;)
- {
- bsp_wdog_refresh();
-
- elapsed_ms += WDOG_FEED_PERIOD_MS;
- if (elapsed_ms >= HEARTBEAT_PERIOD_MS)
- {
- elapsed_ms = 0U;
- bsp_led_toggle(LED_APP);
- }
-
- since_status_ms += WDOG_FEED_PERIOD_MS;
- if (since_status_ms >= STATUS_LOG_PERIOD_MS)
- {
- since_status_ms = 0U;
- log_slot_status("periodic");
- }
-
- if (DISPLAY_CAN_OK)
- {
- sul_result_t decoded;
- bool have_update = false;
-
- sul_frame_t frame;
- if (sul_transport_can_receive(CAN_RX_TIMEOUT_MS, &frame) == BSP_OK)
- {
- const sul_driver_t *p_driver = sul_registry_active();
- if (p_driver->decode(&nku_ctx, &frame, &decoded) == SUL_STATUS_OK)
- {
- last_frame_tick = xTaskGetTickCount();
- have_update = true;
- }
- /* IGNORED/ERR — Фаза 1 их отдельно не обрабатывает, следующая итерация. */
- }
-
- const uint32_t SINCE_FRAME_MS =
- (uint32_t) (xTaskGetTickCount() - last_frame_tick) * portTICK_PERIOD_MS;
- if (SINCE_FRAME_MS >= CONNECTION_TIMEOUT_MS)
- {
- /* poll + timeout→default (ARCH, поток данных) — controller сам
- * определит, реальное ли это изменение (не сработает повторно
- * на каждой итерации после первого перехода в default). */
- decoded = sul_default_state();
- have_update = true;
- }
-
- if (have_update)
- {
- const indication_task_t DIFF = controller_process(&ctrl_ctx, &decoded);
- if (DIFF.pos_pending || DIFF.direction_pending)
- {
- const render_msg_t MSG = { .task = DIFF, .result = decoded };
- (void) xQueueOverwrite(g_s_render_queue, &MSG);
- }
- }
- }
-
- vTaskDelayUntil(&last_wake, FEED_PERIOD);
- }
-}
+volatile bool g_display_ready = false;
+volatile bool g_menu_active = false;
+QueueHandle_t g_render_queue = NULL;
+TaskHandle_t g_render_task_handle = NULL;
int main(void)
{
board_hw_init(); /* BOARD_ConfigMPU + BOARD_InitPins + BOARD_BootClockRUN */
bsp_led_init();
+ (void) bsp_button_init(); /* GPIO настроен в BOARD_InitPins; сброс debounce */
- g_s_render_queue = xQueueCreate(1, sizeof(render_msg_t));
- configASSERT(g_s_render_queue != NULL);
+ g_render_queue = xQueueCreate(1, sizeof(render_msg_t));
+ configASSERT(g_render_queue != NULL);
- /* sul_rx выше приоритетом render — приём CAN/WDOG важнее своевременности
- * перерисовки. Стеки x6 (3 КБ) с запасом (см. PLAN.md, Фаза 0 — тонкий
- * стек уже маскировался под похожий на зависание симптом). */
- (void) xTaskCreate(sul_rx_task, "sul_rx", configMINIMAL_STACK_SIZE * 6U, NULL,
- tskIDLE_PRIORITY + 2U, NULL);
- (void) xTaskCreate(render_task, "render", configMINIMAL_STACK_SIZE * 6U, NULL,
- tskIDLE_PRIORITY + 1U, NULL);
+ /* Опрос ввода — софт-таймер (демон на высшем приоритете в системе вытесняет
+ * всё остальное — нажатия не теряются). Масштабируется на opto (Фаза 3.4)
+ * тем же колбэком. */
+ TimerHandle_t input_timer =
+ xTimerCreate("input", pdMS_TO_TICKS(INPUT_POLL_PERIOD_MS), pdTRUE, NULL, input_poll_cb);
+ configASSERT(input_timer != NULL);
+ (void) xTimerStart(input_timer, 0);
+
+ /* Единственная задача, которую создаёт main — bringup_task сама создаст
+ * sul_rx_task/menu_task/render_task после инициализации и удалит себя
+ * (см. app_tasks.h про приоритеты/порядок). */
+ (void) xTaskCreate(bringup_task, "bringup", APP_TASK_STACK_WORDS, NULL, APP_PRIORITY_BRINGUP,
+ NULL);
vTaskStartScheduler();
diff --git a/firmware/tft_app/src/app/task_bringup.c b/firmware/tft_app/src/app/task_bringup.c
new file mode 100644
index 0000000..b47409b
--- /dev/null
+++ b/firmware/tft_app/src/app/task_bringup.c
@@ -0,0 +1,186 @@
+/**
+ * @file task_bringup.c
+ * @brief Одноразовое bring-up: лог-мьютекс, UART/лог, QSPI+settings+self-confirm,
+ * SDRAM+gfx+CAN. Затем создаёт steady-state задачи и удаляет себя.
+ *
+ * Слот образа ПОДТВЕРЖДАЕТ СЕБЯ в рантайме (нет revert на повторной загрузке —
+ * anti-brick). Контракт с bootloader и обоснование self-confirm/QSPI-ramfunc/
+ * UART-диагностики — см. PLAN.md, Фаза 0.
+ *
+ * Выделено в отдельную задачу на Фазе 3.2.4 (было частью sul_rx_task) — по
+ * итогам HW-верификации: одноразовая init-последовательность архитектурно не
+ * то же самое, что вечный CAN-цикл, и разделение делает обе задачи проще
+ * контролировать по отдельности.
+ */
+
+#include "app_tasks.h"
+
+#include "FreeRTOS.h"
+#include "bootutil/bootutil_public.h"
+#include "bsp/boot_state.h"
+#include "bsp/display.h"
+#include "bsp/qspi_flash.h"
+#include "bsp/sdram.h"
+#include "bsp/uart_host.h"
+#include "domain/sul/transport/can.h"
+#include "flash_map.h"
+#include "log/log.h"
+#include "port/log_uart.h"
+#include "services/gfx.h"
+#include "services/settings_store.h"
+#include "task.h"
+
+#include
+
+#define LOG_TAG "bringup"
+
+/* Собственный слот образа. Slot A = 0 (primary), Slot Б = 1 (secondary). */
+#ifndef APP_OWN_SLOT_ID
+#define APP_OWN_SLOT_ID 0
+#endif
+
+/* Панель текущего стенда — Фаза 1 хардкод (Фаза 9: provisioning выбирает из
+ * settings, см. ARCH §9 — TFT7/8/10 рантайм-выбор внутри профиля app-big). */
+#define PANEL_TYPE BSP_DISPLAY_TFT8
+
+/* ── Диагностика трейлера слота (read-only, безопасно звать многократно).
+ * Общая для bringup_task (before/after-confirm) и sul_rx_task (periodic) —
+ * прототип в app_tasks.h. ──────────────────────────────────────────────── */
+void log_slot_status(const char *p_when)
+{
+ const struct flash_area *p_fap;
+ const int RC_OPEN = flash_area_open((uint8_t) APP_OWN_SLOT_ID, &p_fap);
+ if (RC_OPEN != 0)
+ {
+ LOG_E(LOG_TAG, "%s: flash_area_open(slot%d) rc=%d", p_when, APP_OWN_SLOT_ID, RC_OPEN);
+ return;
+ }
+
+ struct boot_swap_state st = { 0 };
+ const int RC_RD = boot_read_swap_state(p_fap, &st);
+ LOG_I(LOG_TAG, "%s: slot%d magic=%d copy_done=%d image_ok=%d (rd=%d)", p_when, APP_OWN_SLOT_ID,
+ st.magic, st.copy_done, st.image_ok, RC_RD);
+
+ flash_area_close(p_fap);
+}
+
+/**
+ * @brief Подтвердить СОБСТВЕННЫЙ слот (APP_OWN_SLOT_ID).
+ *
+ * boot_set_next(fap, active=true, confirm=true), НЕ boot_set_confirmed(): та
+ * жёстко пишет в FLASH_AREA_IMAGE_PRIMARY (Slot A) независимо от исполняемого
+ * слота — для Direct-XIP с двумя слотами это подтвердило бы не тот при
+ * исполнении из Slot Б.
+ */
+static void confirm_self(void)
+{
+ log_slot_status("before-confirm"); /* ожидаем magic=1(GOOD) image_ok=3(UNSET) */
+
+ const struct flash_area *p_fap;
+ const int RC_OPEN = flash_area_open((uint8_t) APP_OWN_SLOT_ID, &p_fap);
+ if (RC_OPEN != 0)
+ {
+ LOG_E(LOG_TAG, "confirm: flash_area_open(slot%d) rc=%d", APP_OWN_SLOT_ID, RC_OPEN);
+ return;
+ }
+
+ const int RC_SET = boot_set_next(p_fap, true, true);
+ LOG_I(LOG_TAG, "confirm: boot_set_next rc=%d", RC_SET);
+
+ flash_area_close(p_fap);
+
+ log_slot_status("after-confirm"); /* ожидаем image_ok=1(SET) */
+}
+
+/**
+ * @brief SDRAM (SEMC, без DCD) + компоновщик gfx + CAN — перед steady-state задачами.
+ *
+ * bsp_sdram_configure()+init() — та же документированная в bsp/sdram.h связка
+ * для «прошивок без DCD», которую использует и bootloader-smoke-test.
+ */
+static bool bring_up_display_and_can(void)
+{
+ if (bsp_sdram_configure() != BSP_OK)
+ {
+ LOG_E(LOG_TAG, "bsp_sdram_configure FAILED");
+ return false;
+ }
+ if (bsp_sdram_init() != BSP_OK)
+ {
+ LOG_E(LOG_TAG, "bsp_sdram_init FAILED");
+ return false;
+ }
+ if (gfx_init(PANEL_TYPE) != BSP_OK)
+ {
+ LOG_E(LOG_TAG, "gfx_init FAILED");
+ return false;
+ }
+ if (sul_transport_can_init() != BSP_OK)
+ {
+ LOG_E(LOG_TAG, "sul_transport_can_init FAILED");
+ return false;
+ }
+
+ LOG_I(LOG_TAG, "display+CAN bring-up OK");
+ return true;
+}
+
+void bringup_task(void *p_arg)
+{
+ (void) p_arg;
+
+ /* ДО первого LOG_* где-либо в системе — log_write() берёт мьютекс
+ * безусловно (port/log/src/log_mutex.c, strong-override weak-NOP).
+ * bringup_task — первая и пока единственная запущенная задача, так что
+ * порядок гарантирован конструкцией (main создаёт только её). */
+ log_mutex_init();
+
+ /* LPUART1/MCU-Link VCOM — доступен сразу, без enumeration/wait (в отличие
+ * от target-side USB CDC). */
+ (void) bsp_uart_host_init(115200U);
+ log_uart_init();
+
+ /* flash_map_backend требует bsp_qspi_init() ДО любой flash_area_*. */
+ const bool QSPI_OK = (bsp_qspi_init() == BSP_OK);
+ LOG_I(LOG_TAG, "tft_app boot: qspi=%s", QSPI_OK ? "OK" : "FAIL");
+
+ /* Настройки ядра (§8, §10): с флеша если QSPI поднялся, иначе дефолты. */
+ if (QSPI_OK)
+ {
+ const bsp_status_t S_RC = settings_store_load();
+ LOG_I(LOG_TAG, "settings: load rc=%d proto_addr=%u", S_RC,
+ settings_store_get()->user.proto_slice[0]);
+ }
+ else
+ {
+ settings_store_init_defaults();
+ }
+
+ /* «Дошёл до устойчивого состояния» — сбрасывает счётчик попыток загрузки
+ * (recovery загрузчика). SRC GPR, без flash. Безусловно, до потенциально
+ * рискованного bring-up дисплея/CAN ниже. */
+ bsp_boot_health_mark();
+
+ if (QSPI_OK)
+ {
+ confirm_self();
+ }
+ else
+ {
+ LOG_E(LOG_TAG, "qspi_init FAILED — self-confirm skipped, slot will revert");
+ }
+
+ g_display_ready = bring_up_display_and_can(); /* sul_rx_task/render_task ждут этого */
+
+ /* render_task — первым: его хэндл нужен sul_rx_task/menu_task для
+ * xTaskNotifyGive. Порядок формально некритичен (bringup_task —
+ * наивысший приоритет из четырёх и монополизирует CPU до своего
+ * удаления — никто из троих не может выполниться раньше, чем все три
+ * xTaskCreate() ниже отработают), но документирует зависимость явно. */
+ (void) xTaskCreate(render_task, "render", APP_TASK_STACK_WORDS, NULL, APP_PRIORITY_RENDER,
+ &g_render_task_handle);
+ (void) xTaskCreate(sul_rx_task, "sul_rx", APP_TASK_STACK_WORDS, NULL, APP_PRIORITY_SUL_RX, NULL);
+ (void) xTaskCreate(menu_task, "menu", APP_TASK_STACK_WORDS, NULL, APP_PRIORITY_MENU, NULL);
+
+ vTaskDelete(NULL); /* одноразовая задача — дальше нечего делать */
+}
diff --git a/firmware/tft_app/src/app/task_menu.c b/firmware/tft_app/src/app/task_menu.c
new file mode 100644
index 0000000..a8ae835
--- /dev/null
+++ b/firmware/tft_app/src/app/task_menu.c
@@ -0,0 +1,114 @@
+/**
+ * @file task_menu.c
+ * @brief Модель меню: потребление кнопок (debounce — bsp_button/софт-таймер,
+ * независимо от этой задачи), мгновенные вход/навигация/edit/exit+save.
+ *
+ * Раскладка — как в OLD_PROJECT_TFT8_UKL (без удержания, короткие нажатия):
+ * короткое BUTTON_1 = вход в меню (когда закрыто) / следующий пункт (когда
+ * открыто); короткое BUTTON_2 = выбор/действие (когда открыто), намеренный
+ * no-op вне меню.
+ *
+ * НЕ рисует — мутирует `g_menu` и будит render_task (xTaskNotifyGive) на любое
+ * изменение состояния. Разделено от render_task на Фазе 3.2.4 (HW-находка):
+ * раньше (3.2.1–3.2.3, один framebuffer, без ожиданий) потребление кнопок и
+ * рендер жили в одной задаче безвредно — блокировок не было. gfx_present()
+ * (double-buffer + PXP) внёс блокирующее ожидание кадра; в объединённой
+ * задаче это ожидание попутно блокировало вход в меню (ноль реакции на
+ * кнопки — см. PLAN.md). Эталон разделения — OLD_PROJECT_TFT8_UKL:
+ * BUTTONS_TASK/menu_task отдельно от REFRESH_TASK/tft_refresh_task.
+ */
+
+#include "app_tasks.h"
+
+#include "FreeRTOS.h"
+#include "bsp/button.h"
+#include "log/log.h"
+#include "menu/menu.h"
+#include "menu/menu_tree.h"
+#include "services/settings_store.h"
+#include "task.h"
+#include "timers.h"
+
+#include
+
+#define LOG_TAG "menu"
+
+#define MENU_TICK_MS 5U /* каденция потребления кнопок */
+
+menu_ctx_t g_menu;
+
+/* Софт-таймер (высший приоритет демона) опрашивает debounce независимо от
+ * menu_task/render_task — нажатия не теряются, пока кто-то из них занят.
+ * Колбэк короткий, без блокировок. */
+void input_poll_cb(TimerHandle_t x_timer)
+{
+ (void) x_timer;
+ bsp_button_poll();
+ /* Фаза 3.4: bsp_opto_process(); (диспетчерские вход/ответ) */
+}
+
+void menu_task(void *p_arg)
+{
+ (void) p_arg;
+
+ menu_init(&g_menu, menu_tree_items(), menu_tree_count(), settings_store_get_mutable());
+
+ TickType_t last_wake = xTaskGetTickCount();
+
+ for (;;)
+ {
+ bool changed = false;
+
+ /* Порядок и структура — как в OLD_PROJECT_TFT8_UKL menu_task():
+ * BUTTON_2 читается и обрабатывается только "внутри", BUTTON_1 —
+ * читается всегда (вход ИЛИ навигация в зависимости от open). Оба
+ * события дренируются безусловно (один get_event_pressed на кнопку
+ * за итерацию) — не копится устаревшее состояние. */
+ if (bsp_button_get_event_pressed(BSP_BUTTON_2))
+ {
+ if (menu_is_open(&g_menu))
+ {
+ menu_action(&g_menu);
+
+ /* Обновить ДО settings_store_save() (флеш-запись, не
+ * мгновенная) — иначе sul_rx_task ещё несколько мс видел бы
+ * устаревший g_menu_active=true и держал бы мягкую паузу
+ * дольше нужного. */
+ g_menu_active = menu_is_open(&g_menu);
+
+ if (!g_menu_active && g_menu.save_requested)
+ {
+ /* Выход: сохранить (если менялось). Адрес подхватит
+ * sul_rx_task из настроек на следующей итерации. */
+ const bsp_status_t RC = settings_store_save();
+ LOG_I(LOG_TAG, "settings saved rc=%d", RC);
+ }
+ changed = true;
+ }
+ /* иначе — намеренный no-op вне меню (как в референсе) */
+ }
+ else if (bsp_button_get_event_pressed(BSP_BUTTON_1))
+ {
+ if (menu_is_open(&g_menu))
+ {
+ menu_next(&g_menu);
+ }
+ else
+ {
+ menu_open(&g_menu); /* мгновенный вход, без удержания */
+ }
+ changed = true;
+ }
+
+ /* Финальная синхронизация флага мягкой паузы с моделью — покрывает
+ * вход/навигацию (выход уже обновил его выше, до save()). */
+ g_menu_active = menu_is_open(&g_menu);
+
+ if (changed && (g_render_task_handle != NULL))
+ {
+ (void) xTaskNotifyGive(g_render_task_handle);
+ }
+
+ vTaskDelayUntil(&last_wake, pdMS_TO_TICKS(MENU_TICK_MS));
+ }
+}
diff --git a/firmware/tft_app/src/app/task_render.c b/firmware/tft_app/src/app/task_render.c
new file mode 100644
index 0000000..1feec4d
--- /dev/null
+++ b/firmware/tft_app/src/app/task_render.c
@@ -0,0 +1,86 @@
+/**
+ * @file task_render.c
+ * @brief Презентация: единственный владелец дисплея и вызывающий gfx_present().
+ *
+ * Event-driven (xTaskNotifyGive от sul_rx_task И menu_task — MPSC, будят оба
+ * продюсера, ulTaskNotifyTake(pdTRUE,...) схлопывает несколько notify в одно
+ * пробуждение — важно только «есть свежее состояние», не сколько раз оно
+ * менялось). НЕ содержит кнопочной логики — разделено от menu_task на Фазе
+ * 3.2.4 (см. task_menu.c про причину).
+ *
+ * На каждое пробуждение: меню открыто → рисует меню; иначе — если меню ТОЛЬКО
+ * ЧТО закрылось, сразу восстанавливает последнее известное состояние индикации
+ * (не дожидаясь свежего сообщения — sul_rx_task мог простаивать под
+ * g_menu_active), и дренирует g_render_queue, если там свежий diff.
+ */
+
+#include "app_tasks.h"
+
+#include "FreeRTOS.h"
+#include "domain/elevator_model.h"
+#include "queue.h"
+#include "services/gfx.h"
+#include "task.h"
+#include "ui/fallback.h"
+#include "ui/menu_view.h"
+
+#include
+
+#define DISPLAY_WAIT_POLL_MS 5U /* пока g_display_ready не выставлен bringup_task'ом */
+
+void render_task(void *p_arg)
+{
+ (void) p_arg;
+
+ while (!g_display_ready)
+ {
+ vTaskDelay(pdMS_TO_TICKS(DISPLAY_WAIT_POLL_MS));
+ }
+
+ sul_result_t last = sul_default_state();
+ ui_fallback_render_initial(&last);
+ gfx_present();
+
+ bool was_menu_open = false;
+
+ for (;;)
+ {
+ (void) ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
+
+ const bool MENU_OPEN_NOW = menu_is_open(&g_menu);
+ bool present_needed = false;
+
+ if (MENU_OPEN_NOW)
+ {
+ menu_view_render(&g_menu);
+ present_needed = true;
+ }
+ else
+ {
+ if (was_menu_open)
+ {
+ /* Меню только что закрылось — восстановить индикацию
+ * последним известным состоянием немедленно, не дожидаясь
+ * свежего сообщения (sul_rx_task мог простаивать под
+ * g_menu_active — очередь пока пуста). */
+ ui_fallback_render_initial(&last);
+ present_needed = true;
+ }
+
+ render_msg_t msg;
+ if (xQueueReceive(g_render_queue, &msg, 0) == pdTRUE)
+ {
+ last = msg.result;
+ ui_fallback_render(&msg.task, &msg.result);
+ present_needed = true;
+ }
+ }
+
+ if (present_needed)
+ {
+ gfx_present(); /* PXP-композит AS+PS → задний FB + свап (tear-free) */
+ }
+
+ was_menu_open = MENU_OPEN_NOW;
+ }
+}
diff --git a/firmware/tft_app/src/app/task_sul_rx.c b/firmware/tft_app/src/app/task_sul_rx.c
new file mode 100644
index 0000000..156d5d8
--- /dev/null
+++ b/firmware/tft_app/src/app/task_sul_rx.c
@@ -0,0 +1,135 @@
+/**
+ * @file task_sul_rx.c
+ * @brief Приём CAN → decode → controller → уведомление render_task.
+ *
+ * WDOG/heartbeat/периодический re-log трейлера — БЕЗУСЛОВНО (housekeeping,
+ * не CAN-специфика). Сама CAN-работа (decode/controller/очередь) — под
+ * `!g_menu_active`: мягкая пауза на время меню (см. app_tasks.h) — задача НЕ
+ * suspend'ится, поэтому WDOG остаётся в безопасности по конструкции.
+ *
+ * Bring-up (QSPI/settings/self-confirm/SDRAM+gfx+CAN) — bringup_task
+ * (task_bringup.c); эта задача стартует уже после него, `g_display_ready`
+ * решает, делать ли CAN-работу вообще.
+ */
+
+#include "app_tasks.h"
+
+#include "FreeRTOS.h"
+#include "bsp/led.h"
+#include "bsp/wdog.h"
+#include "domain/controller.h"
+#include "domain/elevator_model.h"
+#include "domain/sul.h"
+#include "domain/sul/nku_can.h"
+#include "domain/sul/transport/can.h"
+#include "queue.h"
+#include "services/settings_store.h"
+#include "task.h"
+
+#include
+
+#define HEARTBEAT_PERIOD_MS 500U
+#define WDOG_FEED_PERIOD_MS 100U /* кормим чаще периода мигания — таймаут WDOG >= 1 c */
+#define STATUS_LOG_PERIOD_MS \
+ 2000U /* периодический re-log трейлера — виден независимо
+ * от момента подключения терминала */
+#define CAN_RX_TIMEOUT_MS 100U /* держит цикл отзывчивым к WDOG/heartbeat-каденции */
+#define CONNECTION_TIMEOUT_MS \
+ 3000U /* «пропадание трафика» — см. ARCH, поток данных:
+ * poll + timeout→default. Порядок величины — как
+ * в OLD_PROJECT (там ~3 с на отметку потери связи).
+ * Меряется от last_frame_tick — пауза меню не портит
+ * логику: если трафик реально стоял, «--» появится
+ * сразу по возврату из меню; если шёл — SINCE_FRAME_MS
+ * обнулится первым же принятым кадром. */
+
+void sul_rx_task(void *p_arg)
+{
+ (void) p_arg;
+
+ nku_can_ctx_t nku_ctx;
+ nku_can_init(&nku_ctx);
+
+ controller_ctx_t ctrl_ctx;
+ controller_init(&ctrl_ctx);
+
+ const TickType_t FEED_PERIOD = pdMS_TO_TICKS(WDOG_FEED_PERIOD_MS);
+ uint32_t elapsed_ms = 0U;
+ uint32_t since_status_ms = 0U;
+ TickType_t last_wake = xTaskGetTickCount();
+ TickType_t last_frame_tick = xTaskGetTickCount();
+
+ for (;;)
+ {
+ bsp_wdog_refresh();
+
+ elapsed_ms += WDOG_FEED_PERIOD_MS;
+ if (elapsed_ms >= HEARTBEAT_PERIOD_MS)
+ {
+ elapsed_ms = 0U;
+ bsp_led_toggle(LED_APP);
+ }
+
+ since_status_ms += WDOG_FEED_PERIOD_MS;
+ if (since_status_ms >= STATUS_LOG_PERIOD_MS)
+ {
+ since_status_ms = 0U;
+ log_slot_status("periodic");
+ }
+
+ if (g_display_ready && !g_menu_active)
+ {
+ /* Адрес станции из настроек (proto_slice[0]) — подхватывает правку из
+ * меню без межзадачного сигнала (запись/чтение uint8 атомарны).
+ * Обе стороны: decode (nku_ctx) И HW RX-фильтры FlexCAN — вторая
+ * без переприменения фильтров под реальный адрес станции кадры с
+ * адресом != 0 отбрасывались бы на уровне CAN-контроллера, ещё до
+ * decode (см. PLAN.md — найдено на реальной станции, адрес 1). */
+ const uint8_t NKU_ADDR = settings_store_get()->user.proto_slice[0];
+ nku_can_set_address(&nku_ctx, NKU_ADDR);
+ (void) sul_transport_can_set_address(NKU_ADDR); /* no-op, если адрес не менялся */
+
+ sul_result_t decoded;
+ bool have_update = false;
+
+ sul_frame_t frame;
+ if (sul_transport_can_receive(CAN_RX_TIMEOUT_MS, &frame) == BSP_OK)
+ {
+ const sul_driver_t *p_driver = sul_registry_active();
+ if (p_driver->decode(&nku_ctx, &frame, &decoded) == SUL_STATUS_OK)
+ {
+ last_frame_tick = xTaskGetTickCount();
+ have_update = true;
+ }
+ /* IGNORED/ERR — Фаза 1 их отдельно не обрабатывает, следующая итерация. */
+ }
+
+ const uint32_t SINCE_FRAME_MS =
+ (uint32_t) (xTaskGetTickCount() - last_frame_tick) * portTICK_PERIOD_MS;
+ if (SINCE_FRAME_MS >= CONNECTION_TIMEOUT_MS)
+ {
+ /* poll + timeout→default (ARCH, поток данных) — controller сам
+ * определит, реальное ли это изменение (не сработает повторно
+ * на каждой итерации после первого перехода в default). */
+ decoded = sul_default_state();
+ have_update = true;
+ }
+
+ if (have_update)
+ {
+ const indication_task_t DIFF = controller_process(&ctrl_ctx, &decoded);
+ if (DIFF.pos_pending || DIFF.direction_pending || DIFF.mode_pending)
+ {
+ const render_msg_t MSG = { .task = DIFF, .result = decoded };
+ (void) xQueueOverwrite(g_render_queue, &MSG);
+ if (g_render_task_handle != NULL)
+ {
+ (void) xTaskNotifyGive(g_render_task_handle);
+ }
+ }
+ }
+ }
+
+ vTaskDelayUntil(&last_wake, FEED_PERIOD);
+ }
+}
diff --git a/firmware/tft_app/src/domain/sul/transport/can/include/domain/sul/transport/can.h b/firmware/tft_app/src/domain/sul/transport/can/include/domain/sul/transport/can.h
index 96982ae..4a1ad4f 100644
--- a/firmware/tft_app/src/domain/sul/transport/can/include/domain/sul/transport/can.h
+++ b/firmware/tft_app/src/domain/sul/transport/can/include/domain/sul/transport/can.h
@@ -20,14 +20,28 @@ extern "C"
#endif
/**
- * @brief bsp_can_init() + RX-фильтры под активные ID НКУ-CAN.
+ * @brief bsp_can_init(). RX-фильтры НЕ настраивает.
*
- * Фаза 1: фильтры на PACKET1(0x506)/PACKET3(0x508), адрес станции 0
- * (хардкод — совпадает с decode-стороной nku_can.c). Фаза 3 параметризует
- * оба конца из настроек одновременно.
+ * Вызывающий обязан сразу после этого позвать sul_transport_can_set_address()
+ * — внутренний сентинел форсирует первое применение фильтров независимо от
+ * переданного адреса (как в OLD_PROJECT msg_receiver_task).
*/
bsp_status_t sul_transport_can_init(void);
+/**
+ * @brief (Пере)настроить RX-фильтры (PACKET1..5) под адрес станции.
+ *
+ * НКУ-CAN кодирует адрес станции в ID: PACKET1..4 — биты [7:4] (group4 =
+ * addr<<4), PACKET5 — биты [8:6] (group6 = addr<<6, протокол отводит под
+ * него только 3 бита). Дёшево звать на каждой итерации приёма — реальная
+ * переконфигурация Message Buffer'ов FlexCAN происходит только при
+ * фактическом изменении адреса (внутренний diff, сентинел на первый вызов).
+ * Эталон — OLD_PROJECT msg_receiver_task/apply_nku_can_filters().
+ *
+ * @param nku_address 0..15; вне диапазона — приводится к 15.
+ */
+bsp_status_t sul_transport_can_set_address(uint8_t nku_address);
+
/**
* @brief Принять один кадр и перевести в sul_frame_t.
*
diff --git a/firmware/tft_app/src/domain/sul/transport/can/src/can_transport.c b/firmware/tft_app/src/domain/sul/transport/can/src/can_transport.c
index e95f35b..0f9acc8 100644
--- a/firmware/tft_app/src/domain/sul/transport/can/src/can_transport.c
+++ b/firmware/tft_app/src/domain/sul/transport/can/src/can_transport.c
@@ -2,35 +2,82 @@
#include "bsp/can.h"
-/* Фаза 1: адрес станции захардкожен в 0 — совпадает с базовыми ID в
- * domain/sul/nku_can/src/nku_can.c (0x506|group4, group4=address<<4=0).
- * Фаза 3 параметризует оба конца одновременно из настроек. */
-#define PACKET1_ID 0x506U
-#define PACKET3_ID 0x508U
-#define STD_ID_MASK 0x7FFU /* 11-bit STD — проверять все биты */
+/* Базовые ID НКУ-CAN (адрес станции 0) — та же протокольная раскладка, что и
+ * decode-сторона (domain/sul/nku_can/src/nku_can.c): PACKET1..4 кодируют
+ * адрес в битах [7:4] ID (group4 = addr<<4), PACKET5 — в битах [8:6]
+ * (group6 = addr<<6, протокол отводит под него только 3 бита). Decode и
+ * transport намеренно НЕ шарят общий заголовок с этими константами — каждый
+ * владеет своей копией протокольного факта (декодер ничего не знает про
+ * транспорт и наоборот, ARCH). */
+#define PACKET1_BASE 0x506U
+#define PACKET2_BASE 0x408U
+#define PACKET3_BASE 0x508U
+#define PACKET4_BASE 0x50BU
+#define PACKET5_BASE 0x606U
+#define STD_ID_MASK 0x7FFU /* 11-bit STD — проверять все биты */
+
+#define NKU_ADDRESS_MAX 15U /* 4-битный адрес, group4 = addr<<4 */
+
+/* MB index 0..4 — PACKET1..5 соответственно (см. sul_transport_can_set_address). */
/* Хранилище последнего принятого кадра — см. предупреждение в can.h про
* время жизни p_out->p_data, возвращаемого sul_transport_can_receive(). */
static bsp_can_frame_t s_last_frame;
+/* Сентинел вне диапазона 0..15 — форсирует применение фильтров на первый
+ * вызов sul_transport_can_set_address(), независимо от переданного адреса
+ * (порт OLD_PROJECT msg_receiver_task: last_nku_address = 0xFFU). */
+static uint8_t s_last_applied_address = 0xFFU;
+
bsp_status_t sul_transport_can_init(void)
{
const bsp_can_config_t cfg = {.bitrate = 125000U}; /* см. OLD_PROJECT msg_receiver_task */
- bsp_status_t st = bsp_can_init(&cfg);
+ return bsp_can_init(&cfg);
+ /* Фильтры не настраиваем здесь — вызывающий (sul_rx_task) обязан сразу
+ * позвать sul_transport_can_set_address(), см. can.h. */
+}
+
+bsp_status_t sul_transport_can_set_address(uint8_t nku_address)
+{
+ const uint8_t ADDR = (nku_address <= NKU_ADDRESS_MAX) ? nku_address : NKU_ADDRESS_MAX;
+
+ if (ADDR == s_last_applied_address)
+ {
+ return BSP_OK; /* не менялось — переконфигурация MB не нужна */
+ }
+
+ const uint32_t GROUP4 = (uint32_t) ADDR << 4U;
+ const uint32_t GROUP6 = (uint32_t) ADDR << 6U;
+
+ bsp_status_t st = bsp_can_set_filter(0U, PACKET1_BASE | GROUP4, STD_ID_MASK, false);
+ if (st != BSP_OK)
+ {
+ return st;
+ }
+ st = bsp_can_set_filter(1U, PACKET2_BASE | GROUP4, STD_ID_MASK, false);
+ if (st != BSP_OK)
+ {
+ return st;
+ }
+ st = bsp_can_set_filter(2U, PACKET3_BASE | GROUP4, STD_ID_MASK, false);
+ if (st != BSP_OK)
+ {
+ return st;
+ }
+ st = bsp_can_set_filter(3U, PACKET4_BASE | GROUP4, STD_ID_MASK, false);
+ if (st != BSP_OK)
+ {
+ return st;
+ }
+ st = bsp_can_set_filter(4U, PACKET5_BASE | GROUP6, STD_ID_MASK, false);
if (st != BSP_OK)
{
return st;
}
- st = bsp_can_set_filter(0U, PACKET1_ID, STD_ID_MASK, false);
- if (st != BSP_OK)
- {
- return st;
- }
-
- /* Остальные MB (2..15) свободны под Фазу 2 (PACKET2/4/5, remote-address). */
- return bsp_can_set_filter(1U, PACKET3_ID, STD_ID_MASK, false);
+ s_last_applied_address = ADDR;
+ return BSP_OK;
}
bsp_status_t sul_transport_can_receive(uint32_t timeout_ms, sul_frame_t *p_out)
diff --git a/firmware/tft_app/src/menu/include/menu/menu.h b/firmware/tft_app/src/menu/include/menu/menu.h
index 6ac1eb3..e4666f6 100644
--- a/firmware/tft_app/src/menu/include/menu/menu.h
+++ b/firmware/tft_app/src/menu/include/menu/menu.h
@@ -14,7 +14,7 @@
*
* Навигация: BUTTON_1 → menu_next() (следующий пункт уровня, с заворотом);
* короткое BUTTON_2 → menu_action() (вход в подменю / инкремент значения /
- * выход-с-сохранением). Вход в меню (долгое BUTTON_2) и модальность — уровень app.
+ * выход-с-сохранением). Вход в меню (короткое BUTTON_1, вне меню) и модальность — уровень app.
*
* Соглашение: items[MENU_ROOT_INDEX] — корневое SUBMENU; его дети — верхний
* уровень (их parent == MENU_ROOT_INDEX). Дети одного уровня — непрерывный
diff --git a/firmware/tft_app/src/services/gfx/CMakeLists.txt b/firmware/tft_app/src/services/gfx/CMakeLists.txt
index cd4f323..33d41a1 100644
--- a/firmware/tft_app/src/services/gfx/CMakeLists.txt
+++ b/firmware/tft_app/src/services/gfx/CMakeLists.txt
@@ -23,4 +23,9 @@ set_source_files_properties(
PROPERTIES
COMPILE_OPTIONS "-include;${CMAKE_CURRENT_SOURCE_DIR}/fonts/include/fonts.h;-w")
-target_link_libraries(tft_app_gfx PUBLIC bsp_display bsp_sdram sdk_common) # fsl_common.h — AT_NONCACHEABLE_SECTION_ALIGN
+# sdk_common — fsl_common.h (AT_NONCACHEABLE_SECTION_ALIGN); sdk_pxp — PXP-компоновщик;
+# freertos_kernel — семафор FRAME_DONE в gfx_present/ISR-колбэке (Фаза 3.2.4);
+# port_log_uart — ВРЕМЕННО, тайминг-диагностика в gfx_present() (см. её тело) —
+# убрать вместе с диагностикой, когда причина лага индикации найдена.
+target_link_libraries(tft_app_gfx PUBLIC bsp_display bsp_sdram sdk_common PRIVATE sdk_pxp
+ freertos_kernel port_log_uart)
diff --git a/firmware/tft_app/src/services/gfx/include/services/gfx.h b/firmware/tft_app/src/services/gfx/include/services/gfx.h
index 7ce2fff..4cc775e 100644
--- a/firmware/tft_app/src/services/gfx/include/services/gfx.h
+++ b/firmware/tft_app/src/services/gfx/include/services/gfx.h
@@ -1,9 +1,15 @@
/**
* @file gfx.h
- * @brief Фаза 1 — минимальный gfx: framebuffer (SDRAM, non-cacheable) +
- * RLE-шрифты (lcd-image-converter) + примитив стрелки. Без PXP/
- * компоновщика/альфа-слоёв — прямая запись в единственный framebuffer,
- * который ELCDIF сканирует по DMA (см. gfx.c про non-cacheable SDRAM).
+ * @brief Фаза 3.2.4 — gfx как компоновщик: double-buffer + PXP.
+ *
+ * Модель (эталон OLD_PROJECT_TFT8_UKL/source/display/): CPU рисует ВЕСЬ кадр в
+ * альфа-поверхность **AS** (`alpha_buffer`, ARGB8888; примитивы пишут alpha
+ * 0xFF, `gfx_clear` обнуляет → прозрачно). PXP блендит AS над фоновой
+ * поверхностью **PS** (`processing_buffer`, сейчас сплошной чёрный) в один из
+ * двух задних framebuffer'ов, затем свап синхронно с ELCDIF (семафор FRAME_DONE).
+ * Рисуем off-screen, показываем атомарным свапом → tear-free. Стиль-картинка в
+ * PS и спрайты в AS — Фаза 4/5; сейчас PS чёрный, композиция = чёрный фон +
+ * нарисованное в AS.
*
* Формат tImage/tChar/tFont и RLE-декодирование — порт проверенного в проде
* алгоритма из OLD_PROJECT (source/fonts/fonts.c), тот же формат данных, что
@@ -73,18 +79,32 @@ typedef uint32_t gfx_color_t;
#define GFX_COLOR_WHITE 0x00FFFFFFU
/**
- * @brief framebuffer (SDRAM non-cacheable) + bsp_display.
+ * @brief Поднять компоновщик: AS/PS/2×FB (SDRAM non-cacheable) + PXP + ELCDIF.
+ *
+ * Создаёт семафор FRAME_DONE и регистрирует ISR-колбэк ELCDIF (даёт семафор),
+ * заливает PS сплошным чёрным, инициализирует PXP (AS над PS → выходной FB) и
+ * стартует ELCDIF на FB[0]. До первого gfx_present() экран чёрный.
*
* SDRAM (SEMC) должна быть уже поднята вызывающим (bsp_sdram_configure() +
- * bsp_sdram_init()) — gfx не владеет SEMC-инициализацией, только framebuffer
+ * bsp_sdram_init()) — gfx не владеет SEMC-инициализацией, только буферами
* внутри уже готовой SDRAM.
*
* @param type тип панели (Фаза 1 — хардкод из app; Фаза 9 — provisioning)
*/
bsp_status_t gfx_init(bsp_display_type_t type);
-/** Залить весь кадр цветом (обычно GFX_COLOR_BLACK перед перерисовкой). */
-void gfx_clear(gfx_color_t color);
+/** Обнулить AS (весь кадр становится прозрачным). Вызывать перед отрисовкой
+ * нового полного кадра; непрорисованные области покажут фон PS (чёрный). */
+void gfx_clear(void);
+
+/**
+ * @brief Показать нарисованный в AS кадр: PXP-композит AS над PS → задний FB,
+ * затем свап синхронно с ELCDIF (tear-free).
+ *
+ * Блокирующий (busy-wait завершения PXP + ожидание FRAME_DONE) — звать из
+ * задачи-владельца дисплея после того, как полный кадр нарисован в AS.
+ */
+void gfx_present(void);
/**
* @brief Нарисовать строку заданным цветом (тинтинг с альфа-сглаживанием).
diff --git a/firmware/tft_app/src/services/gfx/src/gfx.c b/firmware/tft_app/src/services/gfx/src/gfx.c
index 1062894..d186cd3 100644
--- a/firmware/tft_app/src/services/gfx/src/gfx.c
+++ b/firmware/tft_app/src/services/gfx/src/gfx.c
@@ -1,47 +1,69 @@
#include "services/gfx.h"
+#include "FreeRTOS.h"
#include "fsl_common.h" /* AT_NONCACHEABLE_SECTION_ALIGN */
+#include "fsl_pxp.h"
+#include "log/log.h" /* ВРЕМЕННО (Фаза 3.2.4 HW-расследование лага) — см. gfx_present() */
+#include "semphr.h"
+#include "task.h" /* xTaskGetTickCount — тайминг-инструментация ниже */
#include
+#include
-/* ── Framebuffer (SDRAM, non-cacheable) ────────────────────────────────────
+#define LOG_TAG "gfx"
+
+/* ── Поверхности компоновщика (SDRAM, non-cacheable) ────────────────────────
*
* AT_NONCACHEABLE_SECTION_ALIGN размещает переменную в линкер-секции
* NonCacheable/.ncache → cmake/linker/..._app_slot.ld отображает её на
- * m_sdram_ncache (2 МБ в начале SDRAM), а board_mpu_init() (bsp/generated/
+ * m_sdram_ncache (8 МБ в начале SDRAM), а board_mpu_init() (bsp/generated/
* board.c, Region 9) конфигурирует ЭТОТ ЖЕ диапазон как non-cacheable через
* линкер-символы __NCACHE_REGION_START/SIZE — рекомендация NXP для буферов,
- * которые ELCDIF читает по DMA (см. OLD_PROJECT source/display/image_cache.c,
+ * которые ELCDIF/PXP читают по DMA (см. OLD_PROJECT source/display/image_cache.c,
* тот же макрос, framebuffer/alpha_buffer/processing_buffer). Без этого CPU
- * писал бы через Write-Back D-Cache (Region 8: SDRAM WB Cacheable), и ELCDIF
+ * писал бы через Write-Back D-Cache (Region 8: SDRAM WB Cacheable), и DMA
* читал бы устаревшие данные, пока кэш-линия не вытеснится сама.
*
- * Размер — под ТЕКУЩУЮ панель стенда (TFT8, 800×600, известна на Фазе 1), не
- * под BSP_DISPLAY_MAX_*: когда app-big (ARCH §9) станет рантайм-выбирать
- * между TFT7/8/10 в одном бинарнике, размер и m_sdram_ncache (сейчас 2 МБ)
- * придётся поднять до максимума (1024×600×4 ≈ 2.34 МБ → 4 МБ регион). */
-#define FRAMEBUFFER_ALIGN 64U /* см. OLD_PROJECT FRAME_BUFFER_ALIGN — типичное ELCDIF/AXI выравнивание */
-#define FRAMEBUFFER_PIXELS (800U * 600U)
+ * Четыре поверхности (эталон TFT8_UKL): AS (рисует CPU), PS (фон, чёрный),
+ * FB[2] (выход PXP = вход ELCDIF, double buffer). Размер — под ТЕКУЩУЮ панель
+ * стенда (TFT8, 800×600), не под BSP_DISPLAY_MAX_*: когда app-big (ARCH §9)
+ * станет рантайм-выбирать между TFT7/8/10 в одном бинарнике, размер поверхностей
+ * и m_sdram_ncache придётся поднять до максимума панели. */
+#define FRAMEBUFFER_ALIGN 64U /* см. OLD_PROJECT FRAME_BUFFER_ALIGN — типичное ELCDIF/PXP/AXI выравнивание */
+#define SURFACE_PIXELS (800U * 600U)
+#define BYTES_PER_PIXEL 4U
+#define ALPHA_OPAQUE 0xFF000000U /* AS: alpha=0xFF → пиксель непрозрачен для PXP-блендинга */
#define FALLBACK_CHAR '-'
-AT_NONCACHEABLE_SECTION_ALIGN(static uint32_t s_framebuffer[FRAMEBUFFER_PIXELS], FRAMEBUFFER_ALIGN);
+/* AS — CPU рисует сюда (ARGB8888, alpha значим). */
+AT_NONCACHEABLE_SECTION_ALIGN(static uint32_t s_alpha_buffer[SURFACE_PIXELS], FRAMEBUFFER_ALIGN);
+/* PS — фон под AS (сейчас сплошной чёрный, заливается однократно в gfx_init). */
+AT_NONCACHEABLE_SECTION_ALIGN(static uint32_t s_processing_buffer[SURFACE_PIXELS], FRAMEBUFFER_ALIGN);
+/* Выходные буферы PXP = сканируемые ELCDIF (double buffer, свап в gfx_present). */
+AT_NONCACHEABLE_SECTION_ALIGN(static uint32_t s_framebuffer[2][SURFACE_PIXELS], FRAMEBUFFER_ALIGN);
static uint16_t s_fb_width;
static uint16_t s_fb_height;
+static uint8_t s_back_index; /* индекс FB, в который PXP компонует следующий кадр */
+static SemaphoreHandle_t s_frame_done; /* даётся из ELCDIF ISR по завершении кадра */
+static pxp_output_buffer_config_t s_output_cfg; /* хранится: gfx_present меняет buffer0Addr */
+
static inline void set_pixel(uint16_t x, uint16_t y, gfx_color_t color)
{
if ((x >= s_fb_width) || (y >= s_fb_height))
{
return; /* примитивы могут частично выходить за экран — не UB, просто обрезка */
}
- s_framebuffer[(uint32_t) y * s_fb_width + x] = color;
+ /* AS: пишем непрозрачно (alpha 0xFF), RGB из color (X-байт игнорируем). */
+ s_alpha_buffer[(uint32_t) y * s_fb_width + x] = (color & 0x00FFFFFFU) | ALPHA_OPAQUE;
}
-/* Альфа-блендинг: наложить @p color с покрытием @p a (0..255) на фон
- * framebuffer'а. a=0 — фон нетронут (прозрачный край глифа), a=255 — полная
- * замена. Так сглаживание глифа корректно ложится на любой фон (в т.ч.
- * полосу-курсор), а не штампует чёрный бокс. */
+/* Альфа-блендинг: наложить @p color с покрытием @p a (0..255) на текущее
+ * содержимое AS. a=0 — пиксель нетронут (прозрачный край глифа), a=255 —
+ * полная замена. Так сглаживание глифа корректно ложится на уже нарисованный
+ * фон (в т.ч. полосу-курсор), а не штампует чёрный бокс. Результат всегда
+ * непрозрачен (alpha 0xFF): PXP покажет его поверх PS. */
static inline void blend_pixel(uint16_t x, uint16_t y, gfx_color_t color, uint8_t a)
{
if ((x >= s_fb_width) || (y >= s_fb_height) || (a == 0U))
@@ -52,16 +74,16 @@ static inline void blend_pixel(uint16_t x, uint16_t y, gfx_color_t color, uint8_
const uint32_t idx = (uint32_t) y * s_fb_width + x;
if (a == 0xFFU)
{
- s_framebuffer[idx] = color;
+ s_alpha_buffer[idx] = (color & 0x00FFFFFFU) | ALPHA_OPAQUE;
return;
}
- const uint32_t bg = s_framebuffer[idx];
+ const uint32_t bg = s_alpha_buffer[idx];
const uint32_t inv = 255U - a;
const uint32_t r = (((color >> 16) & 0xFFU) * a + ((bg >> 16) & 0xFFU) * inv) / 255U;
const uint32_t g = (((color >> 8) & 0xFFU) * a + ((bg >> 8) & 0xFFU) * inv) / 255U;
const uint32_t b = (((color) & 0xFFU) * a + ((bg) & 0xFFU) * inv) / 255U;
- s_framebuffer[idx] = (r << 16) | (g << 8) | b;
+ s_alpha_buffer[idx] = (r << 16) | (g << 8) | b | ALPHA_OPAQUE;
}
/* ── Поиск глифа (бинарный — chars[] отсортирован по code, гарантия формата
@@ -302,13 +324,101 @@ void gfx_draw_rect(uint16_t x, uint16_t y, uint16_t w, uint16_t h, gfx_color_t c
}
}
-/* ── Framebuffer / init ──────────────────────────────────────────────────── */
+/* ── PXP-компоновщик (порт OLD_PROJECT_TFT8_UKL/source/display/pxp_config.c) ── */
+
+/* PS-формат: для RT1052 (расширенная таблица форматов, FSL_FEATURE_PXP_HAS_NO_
+ * EXTEND_PIXEL_FORMAT не определён) 32-битный формат без реального альфа-канала
+ * называется kPXP_PsPixelFormatARGB8888 (0x4) — фон, альфа PS в блендинге не
+ * участвует (значима альфа AS, kPXP_AlphaEmbedded). */
+static void gfx_pxp_init(void)
+{
+ PXP_Init(PXP);
+
+ const uint16_t PITCH = (uint16_t) (s_fb_width * BYTES_PER_PIXEL);
+
+ const pxp_ps_buffer_config_t ps_cfg = {
+ .pixelFormat = kPXP_PsPixelFormatARGB8888,
+ .swapByte = false,
+ .bufferAddr = (uint32_t) s_processing_buffer,
+ .bufferAddrU = 0U,
+ .bufferAddrV = 0U,
+ .pitchBytes = PITCH,
+ };
+ PXP_SetProcessSurfaceBufferConfig(PXP, &ps_cfg);
+
+ const pxp_as_buffer_config_t as_cfg = {
+ .pixelFormat = kPXP_AsPixelFormatARGB8888,
+ .bufferAddr = (uint32_t) s_alpha_buffer,
+ .pitchBytes = PITCH,
+ };
+ PXP_SetAlphaSurfaceBufferConfig(PXP, &as_cfg);
+
+ /* Embedded alpha: доля AS-пикселя над PS берётся из его альфа-байта. */
+ const pxp_as_blend_config_t blend_cfg = {
+ .alpha = 0xFFU,
+ .invertAlpha = false,
+ .alphaMode = kPXP_AlphaEmbedded,
+ .ropMode = kPXP_RopMaskAs,
+ };
+ PXP_SetAlphaSurfaceBlendConfig(PXP, &blend_cfg);
+
+ s_output_cfg.pixelFormat = kPXP_OutputPixelFormatARGB8888;
+ s_output_cfg.interlacedMode = kPXP_OutputProgressive;
+ s_output_cfg.buffer0Addr = (uint32_t) s_framebuffer[0];
+ s_output_cfg.buffer1Addr = 0U;
+ s_output_cfg.pitchBytes = PITCH;
+ s_output_cfg.width = s_fb_width;
+ s_output_cfg.height = s_fb_height;
+ PXP_SetOutputBufferConfig(PXP, &s_output_cfg);
+
+ PXP_EnableCsc1(PXP, false); /* включён по умолчанию — фон RGB, конверсия не нужна */
+
+ PXP_SetProcessSurfacePosition(PXP, 0U, 0U, s_fb_width, s_fb_height);
+ PXP_SetAlphaSurfacePosition(PXP, 0U, 0U, s_fb_width, s_fb_height);
+}
+
+/* Запустить PXP и дождаться завершения композиции (busy-wait, как в эталоне). */
+static void gfx_pxp_run(void)
+{
+ PXP_ClearStatusFlags(PXP, kPXP_CommandLoadFlag);
+ PXP_ClearStatusFlags(PXP, kPXP_Axi0ReadErrorFlag);
+ PXP_ClearStatusFlags(PXP, kPXP_Axi0WriteErrorFlag);
+ PXP_ClearStatusFlags(PXP, kPXP_CompleteFlag);
+
+ PXP_Start(PXP);
+ while ((kPXP_CompleteFlag & PXP_GetStatusFlags(PXP)) == 0U)
+ {
+ }
+}
+
+/* ISR-safe: конец кадра ELCDIF → отпустить семафор (синхронизация свапа). */
+static void on_frame_done(void)
+{
+ BaseType_t hp_task_woken = pdFALSE;
+ (void) xSemaphoreGiveFromISR(s_frame_done, &hp_task_woken);
+ portYIELD_FROM_ISR(hp_task_woken);
+}
+
+/* ── Компоновщик / init ──────────────────────────────────────────────────── */
bsp_status_t gfx_init(bsp_display_type_t type)
{
/* SDRAM (SEMC) — забота вызывающего (bsp_sdram_configure()+init()), gfx
- * владеет только framebuffer'ом внутри уже готовой SDRAM. */
- const bsp_status_t st = bsp_display_init(type, (uint32_t) s_framebuffer, NULL);
+ * владеет только поверхностями внутри уже готовой SDRAM. */
+ s_frame_done = xSemaphoreCreateBinary();
+ if (s_frame_done == NULL)
+ {
+ return BSP_ERR_INIT;
+ }
+
+ /* PS — чёрный фон; AS — прозрачно; оба выходных FB — чёрные (ELCDIF стартует
+ * на FB[0] ещё до первого gfx_present, иначе на экране был бы мусор). */
+ (void) memset(s_processing_buffer, 0, sizeof(s_processing_buffer));
+ (void) memset(s_alpha_buffer, 0, sizeof(s_alpha_buffer));
+ (void) memset(s_framebuffer, 0, sizeof(s_framebuffer));
+
+ /* Семафор создан и колбэк готов ДО включения IRQ внутри bsp_display_init. */
+ const bsp_status_t st = bsp_display_init(type, (uint32_t) s_framebuffer[0], on_frame_done);
if (st != BSP_OK)
{
return st;
@@ -317,16 +427,47 @@ bsp_status_t gfx_init(bsp_display_type_t type)
const bsp_display_size_t *p_size = bsp_display_get_size();
s_fb_width = p_size->width;
s_fb_height = p_size->height;
+ s_back_index = 0U; /* FB[0] показывается; первый present уйдёт в FB[1] */
+
+ gfx_pxp_init();
return BSP_OK;
}
-void gfx_clear(gfx_color_t color)
+void gfx_clear(void)
{
- const uint32_t count = (uint32_t) s_fb_width * s_fb_height;
-
- for (uint32_t i = 0U; i < count; i++)
- {
- s_framebuffer[i] = color;
- }
+ /* Прозрачно (alpha 0) → непрорисованные области покажут фон PS (чёрный). */
+ (void) memset(s_alpha_buffer, 0, (size_t) s_fb_width * s_fb_height * BYTES_PER_PIXEL);
+}
+
+void gfx_present(void)
+{
+ /* Компонуем в НЕ показываемый сейчас буфер, показываем атомарным свапом. */
+ s_back_index ^= 1U;
+
+ s_output_cfg.buffer0Addr = (uint32_t) s_framebuffer[s_back_index];
+ PXP_SetOutputBufferConfig(PXP, &s_output_cfg);
+
+ /* ВРЕМЕННО (Фаза 3.2.4, HW-расследование лага индикации ~1-2 c —
+ * PLAN.md): раздельный замер PXP busy-wait и ожидания FRAME_DONE. ELCDIF
+ * по clock_config.c должен давать ~65 Гц (528 МГц PLL2 / 12 / кадр) →
+ * ожидаем pxp единицы мс, vsync до ~15 мс. Если на железе один из них
+ * систематически большой — вот прямой ответ, что именно тормозит. Убрать
+ * после диагностики (см. include log/log.h и task.h выше — тоже под снос
+ * вместе с этим). */
+ const TickType_t T0 = xTaskGetTickCount();
+
+ gfx_pxp_run(); /* AS над PS → s_framebuffer[s_back_index] */
+
+ const TickType_t T1 = xTaskGetTickCount();
+
+ /* Синхронизация с развёрткой: дождаться конца кадра, затем отдать ELCDIF
+ * новый буфер — он переключится аппаратно на границе кадра (tear-free). */
+ (void) xSemaphoreTake(s_frame_done, portMAX_DELAY);
+
+ const TickType_t T2 = xTaskGetTickCount();
+
+ bsp_display_set_next_buffer((uint32_t) s_framebuffer[s_back_index]);
+
+ LOG_I(LOG_TAG, "present: pxp=%u ms vsync=%u ms", (unsigned) (T1 - T0), (unsigned) (T2 - T1));
}
diff --git a/firmware/tft_app/src/services/settings_store/src/settings_store.c b/firmware/tft_app/src/services/settings_store/src/settings_store.c
index 4369052..b7c085f 100644
--- a/firmware/tft_app/src/services/settings_store/src/settings_store.c
+++ b/firmware/tft_app/src/services/settings_store/src/settings_store.c
@@ -14,15 +14,17 @@ void settings_store_init_defaults(void)
bsp_status_t settings_store_load(void)
{
- settings_page_t page;
+ /* static: страница = сектор (4 КБ) — на стеке задачи (~3 КБ) переполнит его.
+ * Однократный вызов при старте, не реентерабельно (как s_page в save()). */
+ static settings_page_t s_page;
- if (bsp_qspi_read(TFT_APP_QSPI_SETTINGS_OFFSET, (uint8_t *) &page, sizeof(page)) != BSP_OK)
+ if (bsp_qspi_read(TFT_APP_QSPI_SETTINGS_OFFSET, (uint8_t *) &s_page, sizeof(s_page)) != BSP_OK)
{
g_settings = settings_defaults();
return BSP_ERR_HW;
}
- if (!settings_deserialize(&page, &g_settings))
+ if (!settings_deserialize(&s_page, &g_settings))
{
g_settings = settings_defaults();
return BSP_ERR_INVALID;
diff --git a/firmware/tft_app/src/ui/fallback/src/fallback.c b/firmware/tft_app/src/ui/fallback/src/fallback.c
index bd552f5..8af0ac6 100644
--- a/firmware/tft_app/src/ui/fallback/src/fallback.c
+++ b/firmware/tft_app/src/ui/fallback/src/fallback.c
@@ -60,7 +60,7 @@ static void render_normal(const sul_result_t *p_result)
static void render(sul_mode_t mode, const sul_result_t *p_result)
{
- gfx_clear(GFX_COLOR_BLACK);
+ gfx_clear();
const char *label = mode_label(mode);
if (label == NULL)
diff --git a/firmware/tft_app/src/ui/menu/include/ui/menu_view.h b/firmware/tft_app/src/ui/menu/include/ui/menu_view.h
index 3f3b497..2479689 100644
--- a/firmware/tft_app/src/ui/menu/include/ui/menu_view.h
+++ b/firmware/tft_app/src/ui/menu/include/ui/menu_view.h
@@ -6,6 +6,10 @@
* заголовок уровня, список пунктов с полосой-курсором, значения, футер-подсказка.
* Читает только модель (menu_ctx_t) через её запросы; логики навигации не содержит.
* HIL — проверяется на железе.
+ *
+ * Рисует ПОЛНЫЙ кадр в AS (double-buffer + PXP, Фаза 3.2.4) — инкрементальной
+ * построчной отрисовки больше нет: off-screen перерисовка полного кадра +
+ * атомарный свап дёшевы и tear-free. Показ — gfx_present() у владельца дисплея.
*/
#ifndef UI_MENU_VIEW_H_
@@ -18,7 +22,11 @@ extern "C"
{
#endif
-/** Отрисовать текущий кадр меню по состоянию модели. Меню должно быть открыто. */
+/**
+ * @brief Отрисовать полный кадр меню в AS (очистка + заголовок + строки + футер).
+ *
+ * Рисует off-screen (AS); на экран попадёт после gfx_present() владельца дисплея.
+ */
void menu_view_render(const menu_ctx_t *p_ctx);
#ifdef __cplusplus
diff --git a/firmware/tft_app/src/ui/menu/src/menu_view.c b/firmware/tft_app/src/ui/menu/src/menu_view.c
index 8fcf057..af69e09 100644
--- a/firmware/tft_app/src/ui/menu/src/menu_view.c
+++ b/firmware/tft_app/src/ui/menu/src/menu_view.c
@@ -7,17 +7,23 @@
/* Окно 480×272 @ (0,0). Метрики под реальные шрифты: JBMono24 h=31 (заголовок/
* строки), JBMono12 h=16 (футер). 36 + 6×36 + 20 = 272. */
#define WIN_W 480U
+#define WIN_H 272U
#define TITLE_H 36U
#define ROW_H 36U
#define FOOTER_H 20U
#define ROWS_Y0 TITLE_H
-#define FOOTER_Y (272U - FOOTER_H) /* 252 */
-#define TEXT_DY24 2U /* (36-31)/2 — центрирование JBMono24 в полосе 36 */
-#define TEXT_DY12 2U /* (20-16)/2 — JBMono12 в футере 20 */
+#define FOOTER_Y (WIN_H - FOOTER_H) /* 252 */
+#define TEXT_DY24 2U /* центрирование JBMono24 (h31) в полосе 36 */
+#define TEXT_DY12 2U /* JBMono12 (h16) в футере 20 */
#define LEFT_MARGIN 16U
#define RIGHT_MARGIN 16U
#define EDGE_MARGIN 8U
+/* Рамка 1 px по периметру — строки/футер вписаны внутрь неё (не затирают). */
+#define BORDER 1U
+#define CONTENT_X BORDER
+#define CONTENT_W (WIN_W - 2U * BORDER)
+
/* Палитра (XRGB). Собирается тинтингом из белых шрифтов. */
#define COL_BG 0x000000U
#define COL_TITLE 0x00CFE0FFU
@@ -26,7 +32,7 @@
#define COL_SEL_BG 0x0017406BU
#define COL_SEL_TEXT 0x00FFFFFFU
#define COL_FOOTER 0x006F6F6FU
-#define COL_SEP 0x00333333U
+#define COL_SEP 0x00333333U /* разделители + рамка */
/* Строка значения пункта. @return false — у пункта нет значения (BACK). */
static bool format_value(const menu_ctx_t *p_ctx, uint8_t idx, char *p_buf, size_t buf_len)
@@ -39,7 +45,7 @@ static bool format_value(const menu_ctx_t *p_ctx, uint8_t idx, char *p_buf, size
(void) snprintf(p_buf, buf_len, ">");
return true;
case MENU_BYTE:
- (void) snprintf(p_buf, buf_len, "%u", menu_read_value(p_ctx, idx));
+ (void) snprintf(p_buf, buf_len, "%u", (unsigned) menu_read_value(p_ctx, idx));
return true;
case MENU_SELECT:
case MENU_BOOL:
@@ -51,7 +57,7 @@ static bool format_value(const menu_ctx_t *p_ctx, uint8_t idx, char *p_buf, size
}
else
{
- (void) snprintf(p_buf, buf_len, "%u", V);
+ (void) snprintf(p_buf, buf_len, "%u", (unsigned) V);
}
return true;
}
@@ -61,14 +67,14 @@ static bool format_value(const menu_ctx_t *p_ctx, uint8_t idx, char *p_buf, size
}
}
+/* Отрисовать одну строку по её экранному y (fill фона + подпись + значение).
+ * Заливает CONTENT_X..CONTENT_W — рамку (x=0, x=WIN_W-1) не трогает. */
static void draw_row(const menu_ctx_t *p_ctx, uint8_t idx, uint16_t row_y, bool selected)
{
- if (selected)
- {
- gfx_fill_rect(0U, row_y, WIN_W, ROW_H, COL_SEL_BG);
- }
-
+ const gfx_color_t BG_COL = selected ? COL_SEL_BG : COL_BG;
const gfx_color_t TEXT_COL = selected ? COL_SEL_TEXT : COL_LABEL;
+ gfx_fill_rect(CONTENT_X, row_y, CONTENT_W, ROW_H, BG_COL);
+
(void) gfx_draw_string(&SystemFont, p_ctx->items[idx].label, LEFT_MARGIN,
(uint16_t) (row_y + TEXT_DY24), TEXT_COL);
@@ -84,9 +90,9 @@ static void draw_row(const menu_ctx_t *p_ctx, uint8_t idx, uint16_t row_y, bool
static void draw_footer(const menu_ctx_t *p_ctx, uint8_t level_first, uint8_t level_last)
{
- gfx_fill_rect(0U, (uint16_t) (FOOTER_Y - 1U), WIN_W, 1U, COL_SEP);
+ gfx_fill_rect(CONTENT_X, (uint16_t) (FOOTER_Y - 1U), CONTENT_W, 1U, COL_SEP);
- (void) gfx_draw_string(&SystemFontSmall, "Кн.1 - далее Кн.2 - выбор", EDGE_MARGIN,
+ (void) gfx_draw_string(&SystemFontSmall, "Кн.1 - далее Кн.2 - выбор", LEFT_MARGIN,
(uint16_t) (FOOTER_Y + TEXT_DY12), COL_FOOTER);
const uint8_t TOTAL = (uint8_t) (level_last - level_first + 1U);
@@ -100,8 +106,10 @@ static void draw_footer(const menu_ctx_t *p_ctx, uint8_t level_first, uint8_t le
void menu_view_render(const menu_ctx_t *p_ctx)
{
- /* Меню модально и переносимо: чёрный весь экран, окно — верхний-левый 480×272. */
- gfx_clear(COL_BG);
+ /* Полный кадр off-screen: обнулить AS (вне окна 480×272 → чёрный фон PS),
+ * затем нарисовать окно. Свап — gfx_present() у владельца дисплея. */
+ gfx_clear();
+
if (!p_ctx->open)
{
return;
@@ -111,9 +119,8 @@ void menu_view_render(const menu_ctx_t *p_ctx)
const char *p_title = p_ctx->items[p_ctx->items[p_ctx->cur].parent].label;
const uint16_t TW = gfx_string_width(&SystemFont, p_title);
(void) gfx_draw_string(&SystemFont, p_title, (uint16_t) ((WIN_W - TW) / 2U), TEXT_DY24, COL_TITLE);
- gfx_fill_rect(0U, (uint16_t) (TITLE_H - 1U), WIN_W, 1U, COL_SEP);
+ gfx_fill_rect(CONTENT_X, (uint16_t) (TITLE_H - 1U), CONTENT_W, 1U, COL_SEP);
- /* Список пунктов текущего уровня, окно страницы. */
uint8_t first;
uint8_t last;
menu_level_range(p_ctx, &first, &last);
@@ -126,9 +133,11 @@ void menu_view_render(const menu_ctx_t *p_ctx)
{
break;
}
- const uint16_t ROW_Y = (uint16_t) (ROWS_Y0 + slot * ROW_H);
- draw_row(p_ctx, IDX, ROW_Y, (IDX == p_ctx->cur));
+ draw_row(p_ctx, IDX, (uint16_t) (ROWS_Y0 + slot * ROW_H), (IDX == p_ctx->cur));
}
draw_footer(p_ctx, first, last);
+
+ /* Рамка — последней, поверх содержимого. */
+ gfx_draw_rect(0U, 0U, WIN_W, WIN_H, COL_SEP);
}
diff --git a/port/log/src/log_mutex.c b/port/log/src/log_mutex.c
index cccdbc5..cd3637b 100644
--- a/port/log/src/log_mutex.c
+++ b/port/log/src/log_mutex.c
@@ -15,26 +15,24 @@
* @note Вызов LOG_* из ISR запрещён: xSemaphoreTake с portMAX_DELAY
* недопустим в ISR-контексте.
*/
-#if 0
#include "FreeRTOS.h"
#include "log/log.h"
#include "semphr.h"
-
+
static SemaphoreHandle_t s_log_mutex;
-
+
void log_mutex_init(void)
{
s_log_mutex = xSemaphoreCreateMutex();
configASSERT(s_log_mutex != NULL);
}
-
+
void log_mutex_lock(void)
{
xSemaphoreTake(s_log_mutex, portMAX_DELAY);
}
-
+
void log_mutex_unlock(void)
{
xSemaphoreGive(s_log_mutex);
-}
-#endif
\ No newline at end of file
+}
\ No newline at end of file
diff --git a/sdk/CMakeLists.txt b/sdk/CMakeLists.txt
index 9661186..f8d7bd1 100644
--- a/sdk/CMakeLists.txt
+++ b/sdk/CMakeLists.txt
@@ -56,6 +56,7 @@ add_sdk_driver(lpi2c fsl_lpi2c.c)
add_sdk_driver(pwm fsl_pwm.c)
add_sdk_driver(adc fsl_adc.c)
add_sdk_driver(elcdif fsl_elcdif.c)
+add_sdk_driver(pxp fsl_pxp.c)
add_sdk_driver(cache fsl_cache.c)
add_sdk_driver(sai fsl_sai.c)
add_sdk_driver(sai_edma fsl_sai_edma.c)
@@ -73,6 +74,7 @@ target_link_libraries(sdk_lpi2c PUBLIC sdk_clock)
target_link_libraries(sdk_pwm PUBLIC sdk_clock)
target_link_libraries(sdk_adc PUBLIC sdk_clock)
target_link_libraries(sdk_elcdif PUBLIC sdk_clock)
+target_link_libraries(sdk_pxp PUBLIC sdk_clock) # PXP_Init → CLOCK_EnableClock(kCLOCK_Pxp)
target_link_libraries(sdk_sai PUBLIC sdk_clock)
target_link_libraries(sdk_sai_edma PUBLIC sdk_sai sdk_edma)
target_link_libraries(sdk_edma PUBLIC sdk_common)