# tft-app — задачи FreeRTOS и их взаимодействие
Документ описывает **реализованную** (Фаза 3.2.4) многозадачную структуру `tft_app`: какие
задачи существуют, кто кого создаёт, как они обмениваются данными и почему приоритеты именно
такие. Контракт между задачами — [app_tasks.h](../../firmware/tft_app/src/app/app_tasks.h)
(единственный источник истины по приоритетам/разделяемому состоянию); тела задач —
`firmware/tft_app/src/app/task_*.c`.
Структура выстрадана на HW-верификации (два боевых регресса — см. PLAN.md, Фаза 3.2.4):
объединение меню и рендера в одну задачу блокировало ввод, а неверный относительный приоритет
`sul_rx`/`render` морозил экран при отсутствии CAN-трафика. Эталон разделения —
`OLD_PROJECT_TFT8_UKL` (`BUTTONS_TASK` отдельно от `REFRESH_TASK`).
---
## 1. Состав
| Задача | Файл | Приоритет (`app_tasks.h`) | Роль | Блокировки |
| --- | --- | --- | --- | --- |
| `bringup_task` | task_bringup.c | `+4` (высший, недолгоживущая) | одноразовая инициализация → создаёт три остальные → `vTaskDelete(NULL)` | QSPI/flash-операции |
| `menu_task` | task_menu.c | `+3` | модель меню: кнопки, вход/навигация/правка/сохранение. **НЕ рисует** | `settings_store_save()` (flash) на выходе из меню |
| `render_task` | task_render.c | `+2` | **единственный** владелец дисплея и вызывающий `gfx_present*()` | PXP busy-wait + ожидание FRAME_DONE |
| `sul_rx_task` | task_sul_rx.c | `+1` (низший) | приём CAN → decode → controller; WDOG/heartbeat | busy-spin в `bsp_can_receive()` до 100 мс без трафика |
| — демон таймеров | (FreeRTOS) | `configTIMER_TASK_PRIORITY` (высший в системе) | `input_poll_cb` каждые 5 мс: `bsp_button_poll()` (debounce) → `bsp_opto_process()` (debounce IN1/IN2) → `dispatcher_poll()` (§3.4, `app/dispatcher.c` — безусловный опрос `bsp_opto_read()`, НЕ колбэк, см. PLAN.md про баг реактивной версии; пишет `g_dispatcher_indication` и будит `render_task` прямо отсюда при изменении) | нет (все три коротких) |
`main()` создаёт только очередь, софт-таймер ввода и `bringup_task` — остальное wiring делает
сам `bringup_task`.
---
## 2. Старт системы
```mermaid
sequenceDiagram
participant M as main()
participant B as bringup_task (+4)
participant R as render_task (+2)
participant S as sul_rx_task (+1)
participant U as menu_task (+3)
M->>M: board_hw_init, очередь, таймер ввода
M->>B: xTaskCreate + vTaskStartScheduler
B->>B: log_mutex → UART/лог → QSPI → settings → self-confirm
B->>B: SDRAM → gfx (PXP+ELCDIF) → CAN
B->>B: g_display_ready = true
B->>R: xTaskCreate (хэндл → g_render_task_handle)
B->>S: xTaskCreate
B->>U: xTaskCreate
B->>B: vTaskDelete(NULL)
R->>R: первый кадр («--») + gfx_present()
```
Порядок создания (render первым) документирует зависимость: его хэндл нужен продюсерам для
`xTaskNotifyGive`. Формально гонки нет — `bringup_task` выше всех по приоритету и монополизирует
CPU, пока не создаст всех троих.
---
## 3. Взаимодействие (MPSC)
Три продюсера, один консюмер (третий — dispatcher opto, §3.4). **Данные** и **сигнал
пробуждения** разделены:
```mermaid
flowchart LR
TMR["демон таймеров
input_poll_cb 5 мс
bsp_button_poll + bsp_opto_process"]
MT["menu_task (+3)
модель меню g_menu"]
RX["sul_rx_task (+1)
CAN→decode→controller
WDOG безусловно"]
RT["render_task (+2)
gfx_present*()"]
DSP["dispatcher_poll()
(app/dispatcher.c)
g_dispatcher_indication"]
TMR -."залатанные события кнопок".-> MT
TMR -."безусловный опрос, каждый тик".-> DSP
MT -->|"xTaskNotifyGive
(любое изменение)"| RT
RX -->|"xQueueOverwrite (render_msg_t)
+ xTaskNotifyGive"| RT
DSP -->|"xTaskNotifyGive
(изменение вызов/ответ)"| RT
MT -."g_menu_active (мягкая пауза)".-> RX
```
- **Очередь `g_render_queue`** (глубина 1, `xQueueOverwrite`) — только плечо
`sul_rx→render`, несёт `render_msg_t` (diff + результат). Семантика «важно только
последнее состояние»: рендер не обязан успевать за каждым кадром CAN. У dispatcher СВОЕЙ
очереди нет — состояние (`g_dispatcher_indication`) не история, читается напрямую (как
`g_menu`), очередь тут не нужна (сравнение с прошлым значением — внутри `render_task`).
- **`ulTaskNotifyTake(pdTRUE, portMAX_DELAY)`** в `render_task` — event-driven, без
поллинга; несколько notify от ЛЮБОГО из трёх продюсеров схлопываются в одно пробуждение
(та же семантика «важно только последнее»). `render_task` не различает, КТО его разбудил —
каждую итерацию просто проверяет все три источника состояния заново.
- **Приоритет «меню важнее индикации» не кодируется в уведомлении**: проснувшись,
`render_task` первым делом проверяет `menu_is_open(&g_menu)` — если меню открыто,
очередь индикации и dispatcher даже не читаются (см. §3.1 ниже — dispatcher копится, не
теряется).
- **Модель меню `g_menu`** мутирует только `menu_task`; `render_task` читает её для
отрисовки после notify (happens-before через нотификацию — как у очереди).
- **`g_dispatcher_indication`** мутирует только `dispatcher_poll()` (`app/dispatcher.c`),
вызываемый БЕЗУСЛОВНО каждый тик из контекста демона таймеров — приоритет ВЫШЕ
`menu_task` (не реактивно на колбэк bsp_opto — см. PLAN.md §3.4 про баг первой,
реактивной версии). `render_task` снимает копию в
локальную переменную ОДИН раз за итерацию, а не перечитывает несколько раз (иначе
возможна гонка того же класса, что чинили для курсора меню при быстрой навигации, см.
PLAN.md, Фаза 3.2.4).
### 3.1 Диспетчерский вход и пауза меню (§3.4)
Приоритет диспетчерского сигнала («вызов»/«ответ») — **высший из всех режимов индикации**,
безусловно перекрывает и обычную позицию, и любой режим СУЛ; работает независимо от связи со
станцией (ARCH §8 п.3 — это «локальный вход», не данные СУЛ). Тем не менее, пока меню открыто,
`render_task` НЕ применяет его к экрану — как в `OLD_PROJECT_TFT8_UKL` (`tft_refresh_task`
целиком пропускает кадр, пока `in_menu_screen`), тот же принцип, что уже даёт мягкая пауза
`sul_rx_task`:
- **Debounce и опрос не паузятся** — `bsp_opto_process()` и `dispatcher_poll()` в
`input_poll_cb` работают независимо от `g_menu_active`, дёшево (как и button).
- **Применение к экрану — только на закрытии меню.** Пока меню открыто, оконный композит
перерисовывает ТОЛЬКО окно 480×272 (Фаза 3.2.4) — полный кадр индикации вне окна заморожен;
применить смену диспетчера немедленно значило бы либо сломать оконную оптимизацию (полный
редрав ради маленькой иконки), либо рисовать поверх окна меню. `g_dispatcher_indication`
тем временем просто держит ПОСЛЕДНЕЕ значение — ничего не теряется, отражается сразу по
закрытии меню тем же путём, что уже восстанавливает индикацию (`ui_fallback_render_initial()`).
### Мягкая пауза `sul_rx_task` на время меню
`menu_task` держит `g_menu_active=true`, пока меню открыто; `sul_rx_task` под этим флагом
пропускает CAN-работу (decode/controller/очередь), но **WDOG/heartbeat кормит безусловно** —
задача не suspend'ится, поэтому сторожевой таймер в безопасности по конструкции, что бы ни
происходило с меню. Флаг обновляется **до** `settings_store_save()` (flash-запись небыстрая —
иначе пауза держалась бы дольше нужного). На закрытие меню `render_task` немедленно
восстанавливает индикацию из последнего известного состояния, не дожидаясь свежего CAN-кадра.
---
## 4. Почему приоритеты именно такие
`menu (+3) > render (+2) > sul_rx (+1)` — оба соотношения выстраданы на железе:
1. **`render` ВЫШЕ `sul_rx`.** `bsp_can_receive()` — busy-spin без единого блокирующего
FreeRTOS-вызова: без CAN-трафика `sul_rx_task` занимает CPU весь таймаут (100 мс) каждую
итерацию, и `xTaskDelayUntil()` при просроченном дедлайне не блокирует вовсе — задача
непрерывно READY. Более низкоприоритетный `render_task` в этой ситуации голодал:
пустой экран при старте без связи, залипание индикации при обрыве. Обратный порядок
приоритетов чинит это, не трогая общий bare-metal модуль `bsp_can`.
2. **`menu` ВЫШЕ `render`.** Блокировки рендера (PXP busy-wait + ожидание кадра) не должны
придерживать обработку кнопок — то самое свойство, ради которого меню и рендер разведены
по задачам (в объединённой задаче ввод был мёртв).
3. **`bringup` выше всех** — монополизирует CPU на время одноразовой инициализации.
4. **Демон таймеров — наивысший в системе** (`configTIMER_TASK_PRIORITY`): debounce-сэмплы
не теряются, чем бы ни были заняты остальные.
---
## 5. Разделяемое состояние (`app_tasks.h`)
| Объект | Пишет | Читает | Синхронизация |
| --- | --- | --- | --- |
| `g_render_queue` | sul_rx | render | FreeRTOS queue |
| `g_render_task_handle` | bringup (до создания продюсеров) | sul_rx, menu | создание-до-использования |
| `g_display_ready` | bringup | render, sul_rx | `volatile bool`, одно-writer |
| `g_menu_active` | menu | sul_rx | `volatile bool`, одно-writer |
| `g_menu` | menu | render | notify (happens-before) |
| `g_dispatcher_indication` | dispatcher_poll() (демон таймеров, каждый тик) | render | `volatile enum`, один writer; render снимает копию один раз за итерацию (см. §3) |
| лог-буфер `utils/log` | все задачи | — | мьютекс `port/log/src/log_mutex.c` (FreeRTOS strong-override; включён в сборку `app` — до Фазы 3.2.4 был `#if 0`, гонка) |
---
## 6. Кадр на экран (сводка)
Рендер-пайплайн (детали — [FALLBACK.md](FALLBACK.md) §5, [MENU.md](MENU.md) §5): CPU рисует в
AS (ARGB8888) → `gfx_present*()` в `render_task` = PXP-композит AS над чёрным PS → задний
framebuffer **RGB565** → tear-free свап по FRAME_DONE. Гибрид bpp держит сканаут ELCDIF на
половинной полосе SDRAM (63 МБ/с вместо 126). Индикация — всегда полный кадр (~31 мс PXP);
меню после открытия — только окно 480×272 (~8 мс расчётно).