# tft_app: Phase 1

This commit is contained in:
Dmitry Akimov 2026-07-21 14:39:21 +03:00
parent a9e668196a
commit 7f3170a7c0
36 changed files with 11693 additions and 67 deletions

View file

@ -168,7 +168,9 @@
"test_slot_version", "test_slot_version",
"test_update_policy", "test_update_policy",
"test_recovery", "test_recovery",
"test_tft_app_smoke" "test_tft_app_smoke",
"test_sul_nku_can",
"test_tft_app_controller"
] ]
}, },
{ {
@ -192,7 +194,9 @@
"test_slot_version", "test_slot_version",
"test_update_policy", "test_update_policy",
"test_recovery", "test_recovery",
"test_tft_app_smoke" "test_tft_app_smoke",
"test_sul_nku_can",
"test_tft_app_controller"
] ]
}, },
{ {

View file

@ -45,14 +45,22 @@ MEMORY
m_text (RX) : ORIGIN = SLOT_BASE + IMGTOOL_HDR_SZ + VECTOR_SIZE, LENGTH = SLOT_SIZE - IMGTOOL_HDR_SZ - VECTOR_SIZE m_text (RX) : ORIGIN = SLOT_BASE + IMGTOOL_HDR_SZ + VECTOR_SIZE, LENGTH = SLOT_SIZE - IMGTOOL_HDR_SZ - VECTOR_SIZE
m_qacode (RX) : ORIGIN = 0x00000000, LENGTH = 0x00020000 /* SRAM_ITC 128 КБ — ramfunc */ 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_data (RW) : ORIGIN = 0x20000000, LENGTH = 0x00020000 /* SRAM_DTC 128 КБ — .data/.bss/heap/stack */
m_data2 (RW) : ORIGIN = 0x20200000, LENGTH = 0x00040000 /* SRAM_OC 256 КБ — ncache */ m_data2 (RW) : ORIGIN = 0x20200000, LENGTH = 0x00040000 /* SRAM_OC 256 КБ */
m_sdram (RW) : ORIGIN = 0x80000000, LENGTH = 0x02000000 /* SDRAM 32 МБ — Фаза 0 не используется */ /* 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 не использует */
} }
SECTIONS SECTIONS
{ {
__NCACHE_REGION_START = ORIGIN(m_data2); __NCACHE_REGION_START = ORIGIN(m_sdram_ncache);
__NCACHE_REGION_SIZE = 0x2000; /* 8 КБ non-cacheable */ __NCACHE_REGION_SIZE = LENGTH(m_sdram_ncache); /* 2 МБ non-cacheable — framebuffer */
/* Vector table (imgtool header зарезервирован до неё в MEMORY). */ /* Vector table (imgtool header зарезервирован до неё в MEMORY). */
.interrupts : .interrupts :
@ -175,6 +183,10 @@ SECTIONS
} > m_qacode } > m_qacode
__NDATA_ROM = __ram_function_flash_start + (__ram_function_end__ - __ram_function_start__); __NDATA_ROM = __ram_function_flash_start + (__ram_function_end__ - __ram_function_start__);
/* framebuffer (gfx, AT_NONCACHEABLE_SECTION_ALIGN, без инициализатора) —
* в .ncache (bare/zero-init), НЕ .ncache.init (copy-from-flash, сейчас
* ничего не использует, оставлена под будущие initialized non-cacheable
* буферы). Обе — в m_sdram_ncache, см. MEMORY выше. */
.ncache.init : .ncache.init :
{ {
. = ALIGN(32); . = ALIGN(32);
@ -182,20 +194,21 @@ SECTIONS
*(NonCacheable.init) *(NonCacheable.init)
. = ALIGN(4); . = ALIGN(4);
__noncachedata_init_end__ = .; __noncachedata_init_end__ = .;
} > m_data2 } > m_sdram_ncache
. = __noncachedata_init_end__; . = __noncachedata_init_end__;
.ncache : .ncache :
{ {
*(NonCacheable) *(NonCacheable)
. = ALIGN(4); . = ALIGN(4);
__noncachedata_end__ = .; __noncachedata_end__ = .;
} > m_data2 } > m_sdram_ncache
__DATA_END = __NDATA_ROM; __DATA_END = __NDATA_ROM;
text_end = ORIGIN(m_text) + LENGTH(m_text); text_end = ORIGIN(m_text) + LENGTH(m_text);
ASSERT(__DATA_END <= text_end, "region m_text overflowed with text and data") ASSERT(__DATA_END <= text_end, "region m_text overflowed with text and data")
ASSERT(text_end <= SLOT_BASE + SLOT_SIZE, "app вышел за пределы слота (2 МБ)") ASSERT(text_end <= SLOT_BASE + SLOT_SIZE, "app вышел за пределы слота (2 МБ)")
ASSERT((__noncachedata_end__ - ORIGIN(m_data2)) <= LENGTH(m_data2), "m_data2 ncache overflow") ASSERT((__noncachedata_end__ - ORIGIN(m_sdram_ncache)) <= LENGTH(m_sdram_ncache),
"m_sdram_ncache overflow — framebuffer(ы) не влезли в 2 МБ")
.bss : .bss :
{ {

View file

@ -42,6 +42,14 @@ target_link_libraries(freertos_kernel PUBLIC sdk_device)
# Вендоренное ядро — не наш стиль/warnings. # Вендоренное ядро — не наш стиль/warnings.
target_compile_options(freertos_kernel PRIVATE -w) target_compile_options(freertos_kernel PRIVATE -w)
# -----------------------------------------------------------------------------
# Доменные/сервисные/презентационные слои — каждый своя статическая либа
# (ARCH.md §3, конвенция bsp/<module>). tft_app_* префикс — коллизии имён.
# -----------------------------------------------------------------------------
add_subdirectory(src/domain)
add_subdirectory(src/services)
add_subdirectory(src/ui)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# app — исполняемый образ слота # app — исполняемый образ слота
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@ -70,11 +78,15 @@ target_include_directories(
# __STARTUP_INITIALIZE_RAMFUNCTION — startup копирует .ram_function из flash в # __STARTUP_INITIALIZE_RAMFUNCTION — startup копирует .ram_function из flash в
# ITCM. БЕЗ него ramfunc-код bsp_qspi (AT_QUICKACCESS_SECTION_CODE) в ITCM не # ITCM. БЕЗ него ramfunc-код bsp_qspi (AT_QUICKACCESS_SECTION_CODE) в ITCM не
# попадает → запись QSPI виснет (см. bsp/qspi_flash/README.md). Без USB CDC # попадает → запись QSPI виснет (см. bsp/qspi_flash/README.md).
# __STARTUP_INITIALIZE_NONCACHEDATA не нужен — ни один линкуемый модуль не # __STARTUP_INITIALIZE_NONCACHEDATA — аналогично для .ncache/.ncache.init:
# кладёт данные в NonCacheable/NonCacheable.init (проверено). # framebuffer (services/gfx, AT_NONCACHEABLE_SECTION_ALIGN) размещён в SDRAM
# non-cacheable регионе (cmake/linker/..._app_slot.ld, m_sdram_ncache) — по
# рекомендации NXP для буферов, которые ELCDIF читает по DMA (см.
# bsp/sdram/README.md и OLD_PROJECT/source/display/image_cache.c).
target_compile_definitions( target_compile_definitions(
app PRIVATE APP_OWN_SLOT_ID=0 __STARTUP_CLEAR_BSS __STARTUP_INITIALIZE_RAMFUNCTION) app PRIVATE APP_OWN_SLOT_ID=0 __STARTUP_CLEAR_BSS __STARTUP_INITIALIZE_RAMFUNCTION
__STARTUP_INITIALIZE_NONCACHEDATA)
# Вендоренный bootutil_public.c — под своими warnings (как в bootloader/stub). # Вендоренный bootutil_public.c — под своими warnings (как в bootloader/stub).
set_source_files_properties( set_source_files_properties(
@ -97,9 +109,16 @@ target_link_libraries(
bsp_boot_state bsp_boot_state
bsp_qspi_flash bsp_qspi_flash
bsp_uart_host bsp_uart_host
bsp_sdram
bsp_boot_xip_no_dcd bsp_boot_xip_no_dcd
freertos_kernel freertos_kernel
port_log_uart) # UART диагностика (utils_log + bsp_uart_host, LPUART1/MCU-Link VCOM) port_log_uart # UART диагностика (utils_log + bsp_uart_host, LPUART1/MCU-Link VCOM)
# Фаза 1: walking skeleton (CAN → декодер → контроллер → fallback-рендер)
tft_app_sul
tft_app_sul_transport_can
tft_app_controller
tft_app_gfx
tft_app_ui_fallback)
target_link_options( target_link_options(
app app

View file

@ -16,7 +16,7 @@
| # | Фаза | Статус | | # | Фаза | Статус |
|---|---|---| |---|---|---|
| 0 | Каркас проекта и сборка | ✅ | | 0 | Каркас проекта и сборка | ✅ |
| 1 | Walking skeleton (CAN → модель → этаж + стрелки) | | | 1 | Walking skeleton (CAN → модель → этаж + стрелки) | |
| 2 | Домен: полный `sul_result_t`, приоритеты, таймаут | ⬜ | | 2 | Домен: полный `sul_result_t`, приоритеты, таймаут | ⬜ |
| 3 | Настройки и меню, локальные входы (opto) | ⬜ | | 3 | Настройки и меню, локальные входы (opto) | ⬜ |
| 4 | Ассеты: TLV-бандл, паковщик, XIP-блит | ⬜ | | 4 | Ассеты: TLV-бандл, паковщик, XIP-блит | ⬜ |
@ -104,7 +104,53 @@ heartbeat; host-тест-харнесс на месте. Пустой, но жи
--- ---
## Фаза 1 — Walking skeleton ⬜ ## Фаза 1 — Walking skeleton ✅
> **Аппаратно подтверждено.** Реальный НКУ-CAN трафик на стенде (TFT8) → корректный номер
> этажа и стрелка направления на экране; self-confirm пережил очередной power cycle
> (`image_ok=1` уже на `before-confirm`). Host-тесты 19/19 (16 декодер + 6 контроллер +
> существующие). Debug и Release оба собираются/подписываются чисто, идентичная структура
> трейлера (`image_ok` UNSET до рантайм-confirm, как и в Фазе 0). Работа Фазы 1 не
> закоммичена (в рабочем дереве поверх коммита `# tft_app: Phase 0`).
>
> ⚠️ **Известное ограничение — зафиксировано, не блокирует, чинится в Фазе 4.** Один
> framebuffer, без double buffering: `ui_fallback_render()` пишет (`gfx_clear` + RLE-decode
> глифов + стрелка) напрямую в буфер, который ELCDIF в этот момент сканирует по DMA. Если
> перерисовка не укладывается в один кадр развёртки, видно сам процесс закраски (глиф
> «набирается» по частям за несколько кадров) — не tearing/не порча пикселей (каждая запись
> валидна), просто видимый процесс. Фикс — double buffering (не требует PXP отдельно;
> PXP — для многослойной композиции реальных ассетов, та же Фаза 4) — см. её раздел.
>
> Находки по ходу реализации:
> - **Root-cause не пойман, найден заранее.** Framebuffer в SDRAM обязан идти через
> `AT_NONCACHEABLE_SECTION_ALIGN` (порт из `OLD_PROJECT image_cache.c`, прямая
> рекомендация NXP) — MPU держит SDRAM как `Normal Write-Back Cacheable`
> (`board_mpu_init()`, Region 8), ELCDIF читает по DMA напрямую; без non-cacheable
> региона CPU писал бы через кэш, DMA видел бы устаревшие данные. Механизм уже
> параметризован в `board_mpu_init()` через линкер-символы `__NCACHE_REGION_START/
> SIZE` (Region 9) — только переопределены в `app_slot.ld` на новый `m_sdram_ncache`
> (2 МБ в начале SDRAM) вместо унаследованного из bootloader OCRAM-региона (256 КБ,
> для framebuffer ~1.83 МБ не подошёл бы). При первой попытке framebuffer был сырым
> указателем на константный адрес (совпадал с началом региона случайно, линкер
> репортил `0 B` в `m_sdram_ncache`) — исправлено на настоящее объявление
> переменной через макрос; линкер теперь честно показывает `1875 KB / 2 MB`.
> - Формат шрифта — RLE ARGB8888 (lcd-image-converter, "Color A8R8G8B8"),
> `tImage`/`tChar`/`tFont`. Пользователь сгенерировал `FloorFontFallback.c`
> (Inter 215pt, 0-9 и "-") и `SystemFont.c` (JetBrains Mono 24pt, ASCII+кириллица)
> инструментом напрямую — не пришлось изобретать плейсхолдер. RLE-декодер в
> `services/gfx/gfx.c` — порт проверенного в проде алгоритма из
> `OLD_PROJECT/source/fonts/fonts.c` (UNIQUE/REPEATABLE блоки, бинарный поиск по
> отсортированному коду символа), не переизобретён с нуля. Символ вне таблицы
> шрифта → подстановка `'-'` (по-символьный fallback, ARCH §11 — политика
> подтверждена пользователем).
> - Файлы сгенерированных шрифтов ожидают типы из bare-name `fonts.h`
> (`SystemFont.c` — явным `#include`, `FloorFontFallback.c` — вообще без include,
> более новая версия конвертера) — закрыто force-include в CMake, сами
> сгенерированные файлы не редактировались (конвертер их перезапишет при
> регенерации шрифта).
> - Перенёс `FloorFontFallback.c/.xml`, `SystemFont.c` из `domain/` (где их положил
> пользователь) в `services/gfx/fonts/` — по ARCH §4 шрифты относятся к gfx, не
> к домену.
**Цель.** Сквозной срез: реальный CAN-кадр НКУ → чистый декодер → контроллер → **fallback-рендер **Цель.** Сквозной срез: реальный CAN-кадр НКУ → чистый декодер → контроллер → **fallback-рендер
этажа и стрелки** на экране. Никаких ассетов, FS, TLV, меню. этажа и стрелки** на экране. Никаких ассетов, FS, TLV, меню.
@ -125,7 +171,10 @@ heartbeat; host-тест-харнесс на месте. Пустой, но жи
таймаут→default. таймаут→default.
**Критерий выхода.** На стенде: подан НКУ-CAN трафик → на экране меняется номер этажа и стрелка **Критерий выхода.** На стенде: подан НКУ-CAN трафик → на экране меняется номер этажа и стрелка
направления; при пропадании трафика — «--». Домен-тесты зелёные в CI. направления; при пропадании трафика — «--». Домен-тесты зелёные в CI. ✅ Выполнено (проверено на
TFT8; таймаут→«--» логически проверен host-тестами `test_feeding_default_state_after_real_data_
marks_both_pending`, аппаратно на реальном обрыве связи отдельно не гонялся — низкий риск, тот же
код-путь, что и для обычного кадра).
--- ---
@ -169,6 +218,11 @@ per-protocol настроек (§8); `menu/` (навигация, редакти
**Объём.** Формат TLV (индекс, per-entry CRC, версия); host-утилита `tools/` (PNG→ARGB8888, WAV, **Объём.** Формат TLV (индекс, per-entry CRC, версия); host-утилита `tools/` (PNG→ARGB8888, WAV,
пак); `services/assets` (TLV-ридер, XIP-mmap, image cache); partition-заголовок (§10). пак); `services/assets` (TLV-ридер, XIP-mmap, image cache); partition-заголовок (§10).
**Double buffering в `gfx`** — второй framebuffer (`m_sdram_ncache` 2→4 МБ), swap через
`bsp_display_set_next_buffer()` + семафор на `FRAME_DONE` (сейчас `gfx_init()` передаёт `NULL`
вместо колбэка). Закрывает известное ограничение Фазы 1 (см. её раздел) — рисуем в задний буфер,
переключаем только когда кадр готов целиком. PXP-компоновщик (многослойная композиция для
реальных спрайтов из этой же фазы) — следом, тот же `gfx`.
**Тесты (host).** Раунд-трип паковщик↔ридер; целостность индекса/CRC; кросс-валидация id. **Тесты (host).** Раунд-трип паковщик↔ридер; целостность индекса/CRC; кросс-валидация id.

View file

@ -1,57 +1,47 @@
/** /**
* @file main.c * @file main.c
* @brief tft_app точка входа. Фаза 0 (каркас): FreeRTOS + heartbeat + WDOG + * @brief tft_app точка входа.
* рантайм self-confirm слота MCUboot + UART диагностика.
* *
* Каркас walking-skeleton: образ линкуется как валидный MCUboot-слот * Фаза 0 (каркас, подтверждено на железе): образ линкуется как валидный
* (Direct-XIP), bootloader в него прыгает, FreeRTOS стартует, LED мигает, * MCUboot-слот (Direct-XIP), bootloader в него прыгает, FreeRTOS стартует,
* WDOG кормится, образ ПОДТВЕРЖДАЕТ СЕБЯ в рантайме (нет revert на повторной * WDOG кормится, образ ПОДТВЕРЖДАЕТ СЕБЯ в рантайме (нет revert на повторной
* загрузке anti-brick сохранён). Бизнес-логика со следующих фаз (PLAN.md). * загрузке anti-brick сохранён). Контракт с bootloader и обоснование
* self-confirm/QSPI-ramfunc/UART-диагностики см. PLAN.md, Фаза 0.
* *
* Контракт со стороны bootloader (см. firmware/bootloader/test_stub/main.c): * Фаза 1 (walking skeleton): реальный CAN-кадр НКУ-CAN чистый декодер
* - WDOG взведён загрузчиком, WDE write-once образ ОБЯЗАН кормить его. * контроллер fallback-рендер (позиция + стрелка). Две задачи:
* - Образ подписан imgtool БЕЗ --confirm обязан подтвердить себя рантаймом * - sul_rx_task WDOG/heartbeat (унаследовано от Фазы 0) + приём CAN +
* (boot_set_next на своём слоте), иначе MCUboot откатит его. * decode + controller_process(); на изменение в очередь render_task.
* * - render_task потребляет очередь, зовёт ui_fallback_render().
* Записью QSPI из app под Direct-XIP можно пользоваться только потому, что * Очередь глубиной 1 с xQueueOverwrite() важно только ПОСЛЕДНЕЕ состояние,
* ramfunc-код bsp_qspi реально копируется в ITCM для этого потребитель * не история промежуточных кадров (render не обязан успевать за каждым).
* ОБЯЗАН задать __STARTUP_INITIALIZE_RAMFUNCTION (см. CMakeLists и
* bsp/qspi_flash/README.md). Без него запись виснет в qspi_write_fifo.
*
* Диагностика: UART (LPUART1 / MCU-Link VCOM), НЕ USB CDC
* USB CDC (target-side EHCI/PHY) под FreeRTOS не заработал стабильно
* несколько заходов, последний раз плата падала в recovery загрузчика сразу
* после добавления wait-for-host цикла (вероятная причина не сама USB, а
* инкрементально узкий стек boot_task, но целый класс неопределённости
* "первый в репозитории FreeRTOS-потребитель USB device stack" остаётся).
* LPUART1 самый проверенный канал в репозитории (используется во ВСЕХ
* tests/target/* HIL-образах), идёт через уже подключённый для SWD кабель
* MCU-Link, не требует enumeration/wait, пины уже смуксены в BOARD_InitPins().
* Если понадобится USB CDC под FreeRTOS (например, Фаза 7 provisioning)
* отдельная сфокусированная задача, не диагностика Фазы 0.
*
* main() минимальный bare-metal (board bring-up + запуск планировщика). Всё
* остальное в boot_task, после старта планировщика (унификация с QSPI/
* confirm-логикой; UART сам по себе не требует тика для init/write).
*/ */
#include "FreeRTOS.h" #include "FreeRTOS.h"
#include "board.h" #include "board.h"
#include "bootutil/bootutil_public.h" #include "bootutil/bootutil_public.h"
#include "bsp/boot_state.h" #include "bsp/boot_state.h"
#include "bsp/display.h"
#include "bsp/led.h" #include "bsp/led.h"
#include "bsp/qspi_flash.h" #include "bsp/qspi_flash.h"
#include "bsp/sdram.h"
#include "bsp/uart_host.h" #include "bsp/uart_host.h"
#include "bsp/wdog.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 "flash_map.h"
#include "log/log.h" #include "log/log.h"
#include "port/log_uart.h" #include "port/log_uart.h"
#include "queue.h"
#include "services/gfx.h"
#include "task.h" #include "task.h"
#include "ui/fallback.h"
#include <stdbool.h> #include <stdbool.h>
/* Фаза 0: диагностика self-confirm по UART (LPUART1 / MCU-Link VCOM,
* 115200). Сократить/убрать, когда подтверждено на железе многократно. */
#define LOG_TAG "app" #define LOG_TAG "app"
/* Собственный слот образа. Slot A = 0 (primary), Slot Б = 1 (secondary). */ /* Собственный слот образа. Slot A = 0 (primary), Slot Б = 1 (secondary). */
@ -59,26 +49,42 @@
#define APP_OWN_SLOT_ID 0 #define APP_OWN_SLOT_ID 0
#endif #endif
#define HEARTBEAT_PERIOD_MS 500U /* Панель текущего стенда — Фаза 1 хардкод (Фаза 9: provisioning выбирает из
#define WDOG_FEED_PERIOD_MS 100U /* кормим чаще периода мигания — таймаут WDOG >= 1 c */ * settings, см. ARCH §9 TFT7/8/10 рантайм-выбор внутри профиля app-big). */
#define STATUS_LOG_PERIOD_MS 2000U /* периодический re-log трейлера — виден независимо #define PANEL_TYPE BSP_DISPLAY_TFT8
* от момента подключения терминала (UART без
* handshake писать можно сразу, слушают или нет) */ #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 + timeoutdefault. Порядок величины как
* в OLD_PROJECT (там ~3 с на отметку потери связи) */
/* Передача render_task самого свежего состояния — не истории. */
typedef struct
{
indication_task_t task;
sul_result_t result;
} render_msg_t;
static QueueHandle_t s_render_queue;
/* ── Диагностика трейлера слота (read-only, безопасно звать многократно) ── */ /* ── Диагностика трейлера слота (read-only, безопасно звать многократно) ── */
static void log_slot_status(const char *p_when) static void log_slot_status(const char *p_when)
{ {
const struct flash_area *p_fap; const struct flash_area *p_fap;
const int RC_OPEN = flash_area_open((uint8_t) APP_OWN_SLOT_ID, &p_fap); const int RC_OPEN = flash_area_open((uint8_t) APP_OWN_SLOT_ID, &p_fap);
if (RC_OPEN != 0) if (RC_OPEN != 0)
{ {
LOG_E(LOG_TAG, "%s: flash_area_open(slot%d) rc=%d", p_when, APP_OWN_SLOT_ID, RC_OPEN); LOG_E(LOG_TAG, "%s: flash_area_open(slot%d) rc=%d", p_when, APP_OWN_SLOT_ID, RC_OPEN);
return; return;
} }
struct boot_swap_state st = {0}; struct boot_swap_state st = {0};
const int RC_RD = boot_read_swap_state(p_fap, &st); 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, 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); st.magic, st.copy_done, st.image_ok, RC_RD);
@ -98,7 +104,7 @@ static void confirm_self(void)
log_slot_status("before-confirm"); /* ожидаем magic=1(GOOD) image_ok=3(UNSET) */ log_slot_status("before-confirm"); /* ожидаем magic=1(GOOD) image_ok=3(UNSET) */
const struct flash_area *p_fap; const struct flash_area *p_fap;
const int RC_OPEN = flash_area_open((uint8_t) APP_OWN_SLOT_ID, &p_fap); const int RC_OPEN = flash_area_open((uint8_t) APP_OWN_SLOT_ID, &p_fap);
if (RC_OPEN != 0) if (RC_OPEN != 0)
{ {
LOG_E(LOG_TAG, "confirm: flash_area_open(slot%d) rc=%d", APP_OWN_SLOT_ID, RC_OPEN); LOG_E(LOG_TAG, "confirm: flash_area_open(slot%d) rc=%d", APP_OWN_SLOT_ID, RC_OPEN);
@ -113,21 +119,73 @@ static void confirm_self(void)
log_slot_status("after-confirm"); /* ожидаем image_ok=1(SET) */ log_slot_status("after-confirm"); /* ожидаем image_ok=1(SET) */
} }
static void boot_task(void *p_arg) /**
* @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(s_render_queue, &msg, portMAX_DELAY) == pdTRUE)
{
ui_fallback_render(&msg.task, &msg.result);
}
}
}
static void sul_rx_task(void *p_arg)
{ {
(void) p_arg; (void) p_arg;
/* LPUART1/MCU-Link VCOM — доступен сразу, без enumeration/wait (в отличие /* LPUART1/MCU-Link VCOM — доступен сразу, без enumeration/wait (в отличие
* от target-side USB CDC). log_mutex не нужен единственная задача. */ * от target-side USB CDC). log_mutex не нужен единственный писатель. */
(void) bsp_uart_host_init(115200U); (void) bsp_uart_host_init(115200U);
log_uart_init(); log_uart_init();
/* flash_map_backend требует bsp_qspi_init() ДО любой flash_area_*. */ /* flash_map_backend требует bsp_qspi_init() ДО любой flash_area_*. */
const bool QSPI_OK = (bsp_qspi_init() == BSP_OK); const bool QSPI_OK = (bsp_qspi_init() == BSP_OK);
LOG_I(LOG_TAG, "tft_app phase0 boot: qspi=%s", QSPI_OK ? "OK" : "FAIL"); LOG_I(LOG_TAG, "tft_app phase1 boot: qspi=%s", QSPI_OK ? "OK" : "FAIL");
/* «Дошёл до устойчивого состояния» — сбрасывает счётчик попыток загрузки /* «Дошёл до устойчивого состояния» — сбрасывает счётчик попыток загрузки
* (recovery загрузчика). SRC GPR, без flash. */ * (recovery загрузчика). SRC GPR, без flash. Безусловно, до потенциально
* рискованного bring-up дисплея/CAN ниже. */
bsp_boot_health_mark(); bsp_boot_health_mark();
if (QSPI_OK) if (QSPI_OK)
@ -139,10 +197,19 @@ static void boot_task(void *p_arg)
LOG_E(LOG_TAG, "qspi_init FAILED — self-confirm skipped, slot will revert"); 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);
controller_ctx_t ctrl_ctx;
controller_init(&ctrl_ctx);
const TickType_t FEED_PERIOD = pdMS_TO_TICKS(WDOG_FEED_PERIOD_MS); const TickType_t FEED_PERIOD = pdMS_TO_TICKS(WDOG_FEED_PERIOD_MS);
uint32_t elapsed_ms = 0U; uint32_t elapsed_ms = 0U;
uint32_t since_status_ms = 0U; uint32_t since_status_ms = 0U;
TickType_t last_wake = xTaskGetTickCount(); TickType_t last_wake = xTaskGetTickCount();
TickType_t last_frame_tick = xTaskGetTickCount();
for (;;) for (;;)
{ {
@ -162,6 +229,45 @@ static void boot_task(void *p_arg)
log_slot_status("periodic"); 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(s_render_queue, &MSG);
}
}
}
vTaskDelayUntil(&last_wake, FEED_PERIOD); vTaskDelayUntil(&last_wake, FEED_PERIOD);
} }
} }
@ -171,8 +277,15 @@ int main(void)
board_hw_init(); /* BOARD_ConfigMPU + BOARD_InitPins + BOARD_BootClockRUN */ board_hw_init(); /* BOARD_ConfigMPU + BOARD_InitPins + BOARD_BootClockRUN */
bsp_led_init(); bsp_led_init();
/* x4 (2 КБ) с запасом: логирование (vsnprintf) + чтение трейлера. */ s_render_queue = xQueueCreate(1, sizeof(render_msg_t));
(void) xTaskCreate(boot_task, "boot", configMINIMAL_STACK_SIZE * 4U, NULL, configASSERT(s_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); tskIDLE_PRIORITY + 1U, NULL);
vTaskStartScheduler(); vTaskStartScheduler();

View file

@ -0,0 +1,3 @@
add_subdirectory(elevator_model)
add_subdirectory(sul)
add_subdirectory(controller)

View file

@ -0,0 +1,5 @@
add_library(tft_app_controller STATIC src/controller.c)
target_include_directories(tft_app_controller PUBLIC include/)
target_link_libraries(tft_app_controller PUBLIC tft_app_elevator_model)

View file

@ -0,0 +1,64 @@
/**
* @file controller.h
* @brief Редьюсер: sul_result_t (+кэш) indication_task_t (diff).
*
* Чистый C, без единого HAL-вызова. НЕ владеет временем/таймаутами сам
* caller (app-level sul_rx задача, у неё есть доступ к тику) при обнаружении
* таймаута связи просто вызывает controller_process() с sul_default_state(),
* тем же путём, что и любой обычный кадр специального API для timeout не
* нужно (см. ARCH.md, поток данных: «poll + timeoutdefault» это
* обязанность вызывающей задачи, не контроллера).
*/
#ifndef DOMAIN_CONTROLLER_H_
#define DOMAIN_CONTROLLER_H_
#include "domain/elevator_model.h"
#include <stdbool.h>
#ifdef __cplusplus
extern "C"
{
#endif
/** Состояние редьюсера — кэш последнего примененного результата. */
typedef struct
{
sul_result_t cache;
} controller_ctx_t;
/**
* @brief Что изменилось с прошлого вызова presentation перерисовывает
* только помеченные поля.
*/
typedef struct
{
bool pos_pending;
bool direction_pending;
} indication_task_t;
/**
* @brief Сброс кэша к sul_default_state().
*
* Кэш засеивается дефолтом ДО первого реального кадра если первый кадр от
* СУЛ совпадёт с дефолтом (напр. пришёл со всё ещё пустой позицией), diff
* будет "ничего не изменилось". Первую отрисовку (пустой экран что-то на
* экране) presentation обязана сделать безусловно один раз при старте, не
* дожидаясь pending-флагов (см. app: render-задача).
*/
void controller_init(controller_ctx_t *p_ctx);
/**
* @brief Применить новый результат, вернуть diff относительно кэша.
*
* Обновляет кэш на p_result безусловно (следующий вызов будет сравнивать
* именно с этим состоянием).
*/
indication_task_t controller_process(controller_ctx_t *p_ctx, const sul_result_t *p_result);
#ifdef __cplusplus
}
#endif
#endif /* DOMAIN_CONTROLLER_H_ */

View file

@ -0,0 +1,20 @@
#include "domain/controller.h"
#include <string.h>
void controller_init(controller_ctx_t *p_ctx)
{
p_ctx->cache = sul_default_state();
}
indication_task_t controller_process(controller_ctx_t *p_ctx, const sul_result_t *p_result)
{
const indication_task_t task = {
.pos_pending = (strcmp(p_ctx->cache.pos, p_result->pos) != 0),
.direction_pending = (p_ctx->cache.direction != p_result->direction),
};
p_ctx->cache = *p_result;
return task;
}

View file

@ -0,0 +1,3 @@
add_library(tft_app_elevator_model STATIC src/elevator_model.c)
target_include_directories(tft_app_elevator_model PUBLIC include/)

View file

@ -0,0 +1,74 @@
/**
* @file elevator_model.h
* @brief Канонический результат декодирования СУЛ (Часть II §6 ARCH.md).
*
* Чистый C, без единого HAL-вызова host-тестируется тем же способом, что
* модули bootloader. `sul_result_t` надмножество полей для ЛЮБОГО протокола
* (НКУ-CAN, УИМ, SD7, УЭЛ, УКЛ, ); конкретный декодер заполняет то, что несёт
* его протокол, остальное остаётся дефолтным.
*
* Фаза 1 подмножество: `pos` + `direction` (см. PLAN.md). Остальные поля
* ARCH §6 (next, сигналы, lading_secs, floor_num) добавляются в Фазе 2 вместе
* с полным декодером НКУ-CAN и таблицей приоритетов режимов.
*/
#ifndef DOMAIN_ELEVATOR_MODEL_H_
#define DOMAIN_ELEVATOR_MODEL_H_
#include <stdint.h>
#ifdef __cplusplus
extern "C"
{
#endif
/** Максимум значащих символов позиции сейчас — 2; запас до 4 (см. ARCH §6). */
#define SUL_POS_MAX 4U
/** UTF-8, худший случай — 4 байта/кодпойнт + '\0'. */
#define SUL_POS_BUF_LEN (SUL_POS_MAX * 4U + 1U)
/**
* @brief Направление движения кабины.
*
* Канонический словарь НЕ протокол-специфичный набор битов. Декодер
* конкретного СУЛ транслирует свои сырые коды в эти значения (для НКУ-CAN
* PACKET1 ARROW_MASK совпадает по значению случайно не полагаться на это
* в других протоколах).
*/
typedef enum
{
SUL_DIR_NONE = 0, /**< нет движения / нет данных */
SUL_DIR_UP = 1,
SUL_DIR_DOWN = 2,
SUL_DIR_DOUBLE = 3, /**< двойная стрелка (спецрежим индикации) */
} sul_direction_t;
/**
* @brief Канонический результат декодирования кадра/пакета СУЛ.
*
* Позиция UTF-8 строка (реальные шрифты ASCII + кириллица), не число:
* универсально для всех протоколов, host-тест сравнивает строки напрямую.
* Декодер ничего не знает про шрифт/рендер валидация рендеримости
* (покрытие активным шрифтом) задача UI-слоя (ARCH §11), не decode().
*/
typedef struct
{
char pos[SUL_POS_BUF_LEN]; /**< позиция кабины, напр. "12", "-1", "П" */
sul_direction_t direction;
} sul_result_t;
/**
* @brief Состояние по умолчанию / при потере связи.
*
* pos="--" (нет данных), direction=SUL_DIR_NONE. Вызывающий (контроллер через
* sul_rx-задачу при таймауте, либо init перед первым кадром) использует это
* как отправную точку не HAL-зависимо, чистая функция.
*/
sul_result_t sul_default_state(void);
#ifdef __cplusplus
}
#endif
#endif /* DOMAIN_ELEVATOR_MODEL_H_ */

View file

@ -0,0 +1,6 @@
#include "domain/elevator_model.h"
sul_result_t sul_default_state(void)
{
return (sul_result_t){.pos = "--", .direction = SUL_DIR_NONE};
}

View file

@ -0,0 +1,16 @@
# tft_app_sul_headers — INTERFACE, только domain/sul.h (типы sul_frame_t/
# sul_status_t/sul_driver_t). Разделено от реализации реестра (ниже), чтобы
# tft_app_sul_nku_can мог использовать типы без циклической зависимости:
# nku_can нужны только типы sul.h, а sul_registry.c сам зовёт nku_can_decode.
add_library(tft_app_sul_headers INTERFACE)
target_include_directories(tft_app_sul_headers INTERFACE include/)
target_link_libraries(tft_app_sul_headers INTERFACE tft_app_elevator_model)
add_subdirectory(nku_can)
add_subdirectory(transport/can)
# tft_app_sul — реализация реестра. Явно знает обо всех драйверах (сейчас —
# один, nku_can); добавление протокола (Фаза 8) — новая запись в
# sul_registry.c + новая зависимость здесь.
add_library(tft_app_sul STATIC src/sul_registry.c)
target_link_libraries(tft_app_sul PUBLIC tft_app_sul_headers tft_app_sul_nku_can)

View file

@ -0,0 +1,95 @@
/**
* @file sul.h
* @brief Реестр драйверов СУЛ (ARCH.md §6) «одна прошивка много протоколов».
*
* Драйвер = чистый декодер (host-тестируемый, без HAL) + отдельный тонкий
* транспорт-адаптер (HW, живёт в sul/transport/<bus>). Декодер НЕ владеет
* состоянием сам состояние (например, накопленная позиция между PACKET1 и
* PACKET3 у НКУ-CAN) держит caller в ctx и передаёт указатель на каждый вызов
* decode(). Это позволяет декодеру оставаться описанным одной чистой функцией
* и не тянуть за собой выделение памяти/жизненный цикл.
*
* Фаза 1 одна запись реестра (nku_can), активный драйвер хардкожен.
* Фаза 3 добавит выбор активного протокола из настроек сигнатура
* `sul_registry_active()` не изменится, изменится только то, что она
* возвращает.
*/
#ifndef DOMAIN_SUL_H_
#define DOMAIN_SUL_H_
#include "domain/elevator_model.h"
#include <stddef.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C"
{
#endif
/** Результат decode() одного кадра/пакета. */
typedef enum
{
SUL_STATUS_OK = 0, /**< кадр распознан, ctx и *p_out обновлены */
SUL_STATUS_IGNORED, /**< кадр не для этого драйвера — *p_out не тронут */
SUL_STATUS_ERR, /**< кадр совпал по ID, но малформирован (DLC и т.п.) */
} sul_status_t;
/**
* @brief Кадр транспортного уровня, нейтральный к шине (CAN/UART/...).
*
* Транспорт-адаптер (HW) заполняет её из своего протокола (для CAN id и
* data/len из bsp_can_frame_t); декодер (чистый C) её только читает.
*/
typedef struct
{
uint32_t id; /**< CAN ID либо адрес/маркер кадра другого транспорта */
uint8_t bus; /**< на будущее — несколько шин одного типа (0 = единственная) */
const uint8_t *p_data;
uint16_t len;
} sul_frame_t;
/**
* @brief Чистая функция декодирования БЕЗ единого HAL-вызова.
*
* @param p_ctx состояние драйвера (владеет caller, см. докстрок файла)
* @param p_frame один кадр транспортного уровня
* @param p_out заполняется только при SUL_STATUS_OK
*/
typedef sul_status_t (*sul_decode_fn)(void *p_ctx, const sul_frame_t *p_frame, sul_result_t *p_out);
typedef struct
{
uint8_t id; /**< стабильный идентификатор протокола */
const char *p_name; /**< для меню (Фаза 3) */
sul_decode_fn decode;
} sul_driver_t;
/**
* @brief Идентификаторы протоколов стабильны, не переиспользовать.
*
* Список открыт (ARCH §2.2): УЭЛ, УКЛ, НКУ-SD7, УИМ добавляются в Фазе 8.
*/
enum
{
SUL_PROTOCOL_NKU_CAN = 0U,
};
/**
* @brief Активный драйвер. Фаза 1 единственная запись, хардкод.
* @return указатель на статический дескриптор, никогда NULL.
*/
const sul_driver_t *sul_registry_active(void);
/**
* @brief Найти драйвер по id (для будущего выбора по настройкам, Фаза 3).
* @return NULL, если протокол не зарегистрирован.
*/
const sul_driver_t *sul_registry_find(uint8_t id);
#ifdef __cplusplus
}
#endif
#endif /* DOMAIN_SUL_H_ */

View file

@ -0,0 +1,5 @@
add_library(tft_app_sul_nku_can STATIC src/nku_can.c)
target_include_directories(tft_app_sul_nku_can PUBLIC include/)
target_link_libraries(tft_app_sul_nku_can PUBLIC tft_app_elevator_model tft_app_sul_headers)

View file

@ -0,0 +1,59 @@
/**
* @file nku_can.h
* @brief Чистый декодер протокола НКУ-CAN (порт из OLD_PROJECT msg_receiver_task).
*
* Фаза 1 подмножество: PACKET1 (направление) + PACKET3 (позиция кабины).
* Остальные пакеты (PACKET2/4/5, режимы, гонг, погрузка, удалённая установка
* адреса) Фаза 2, см. PLAN.md.
*
* Адрес станции (nku_address) захардкожен в 0 фильтры/ID без сдвига группы.
* Фаза 3 параметризует через настройки; сигнатура decode() не изменится.
*
* Чистый C, без единого HAL-вызова host-тестируется golden-векторами
* CAN-кадров (tests/host/tft_app_sul_nku).
*/
#ifndef DOMAIN_SUL_NKU_CAN_H_
#define DOMAIN_SUL_NKU_CAN_H_
#include "domain/elevator_model.h"
#include "domain/sul.h"
#ifdef __cplusplus
extern "C"
{
#endif
/**
* @brief Состояние декодера накопленный текущий sul_result_t.
*
* PACKET1 и PACKET3 несут РАЗНЫЕ поля (направление / позицию) в разных
* кадрах decode() обновляет только своё поле в state и отдаёт наружу
* ПОЛНУЮ накопленную копию, а не только то, что пришло в этом кадре.
*
* Фаза 2 добавит сюда edge-detection состояние для гонга/погрузки/режимов
* (аналог event_edge_t в legacy) структура специально не голый sul_result_t,
* чтобы не менять сигнатуру при расширении.
*/
typedef struct
{
sul_result_t state;
} nku_can_ctx_t;
/** Сброс к состоянию по умолчанию (sul_default_state()). Вызвать перед первым decode(). */
void nku_can_init(nku_can_ctx_t *p_ctx);
/**
* @brief decode() для реестра sul (см. sul_decode_fn в domain/sul.h).
*
* @param p_ctx nku_can_ctx_t*, инициализированный nku_can_init()
* @param p_frame сырой CAN-кадр (id + до 8 байт data)
* @param p_out заполняется полной накопленной state только при SUL_STATUS_OK
*/
sul_status_t nku_can_decode(void *p_ctx, const sul_frame_t *p_frame, sul_result_t *p_out);
#ifdef __cplusplus
}
#endif
#endif /* DOMAIN_SUL_NKU_CAN_H_ */

View file

@ -0,0 +1,115 @@
#include "domain/sul/nku_can.h"
#include <stdbool.h>
#include <stdio.h>
/* Фаза 1: адрес станции захардкожен в 0 — базовые ID без сдвига группы
* (легаси: 0x506|group4, group4 = nku_address<<4; для address=0 group4=0).
* Фаза 3 параметризует через настройки. */
#define PACKET1_ID 0x506U
#define PACKET3_ID 0x508U
#define PROTO_DLC 8U
#define ARROW_MASK 0x03U
#define FLOOR_MASK 0x3FU
#define SYMBOL_SPACE 16U
#define SYMBOL_TOTAL 38U
/* Таблица символов НКУ-CAN — порт из OLD_PROJECT floor_string_composer()
* (source/main_programm.c). Индекс код символа с шины (байт & FLOOR_MASK). */
static const char *const S_SYMBOL_TABLE[SYMBOL_TOTAL] = {
"0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "A", "b", "C", "d", "E", "F", " ", "П", "Р",
"п", "H", "U", "-", "_", "u", "L", "У", "Б", "Г", "R", "V", "N", "S", "K", "Y", "G", "B", "T",
};
void nku_can_init(nku_can_ctx_t *p_ctx)
{
p_ctx->state = sul_default_state();
}
/**
* @brief PACKET1 (0x506) направление движения.
*
* ARROW_MASK=0x03 на data[6] (DATA7): 0=нет/1=вверх/2=вниз/3=двойная стрелка.
* Легаси (msg_receiver_task, PACKET1) обрабатывал только 0/1/2 case 3
* отсутствовал (направление молча не менялось). 3 = «двойная стрелка»
* (спецрежим индикации, словарь special/DisplayArrowIcon) уточнено отдельно.
*
* Маска покрывает ровно 0..3 весь диапазон sul_direction_t, прямое
* приведение типа корректно без switch/default.
*/
static void decode_packet1(nku_can_ctx_t *p_ctx, const uint8_t *p_data)
{
p_ctx->state.direction = (sul_direction_t) (p_data[6] & ARROW_MASK);
}
/**
* @brief PACKET3 (0x508) позиция кабины (left/right символы).
*
* FLOOR_MASK=0x3F на data[5] (left) и data[6] (right) порт
* floor_string_composer() из OLD_PROJECT. left==SPACE или left==0
* однозначный этаж, выводится только правый символ (легаси трактует байт
* 0x00 так же, как пробел станция может слать нулевой байт вместо явного
* кода пробела для «нет левого символа»; порт без изменений поведения).
*
* В отличие от легаси (молча оставляет буфер как есть при выходе за
* SYMBOL_TOTAL маска даёт до 63 сырых значений при 38 валидных символах),
* здесь это ошибка (false) не полагаемся на предыдущее содержимое буфера.
*
* @return false left/right вне таблицы символов (малформированный кадр).
*/
static bool decode_packet3(nku_can_ctx_t *p_ctx, const uint8_t *p_data)
{
const uint8_t LEFT = p_data[5] & FLOOR_MASK;
const uint8_t RIGHT = p_data[6] & FLOOR_MASK;
if ((LEFT >= SYMBOL_TOTAL) || (RIGHT >= SYMBOL_TOTAL))
{
return false;
}
if ((LEFT == SYMBOL_SPACE) || (LEFT == 0U))
{
(void) snprintf(p_ctx->state.pos, SUL_POS_BUF_LEN, "%s", S_SYMBOL_TABLE[RIGHT]);
}
else
{
(void) snprintf(p_ctx->state.pos, SUL_POS_BUF_LEN, "%s%s", S_SYMBOL_TABLE[LEFT],
S_SYMBOL_TABLE[RIGHT]);
}
return true;
}
sul_status_t nku_can_decode(void *p_ctx, const sul_frame_t *p_frame, sul_result_t *p_out)
{
nku_can_ctx_t *p_state = (nku_can_ctx_t *) p_ctx;
if (p_frame->id == PACKET1_ID)
{
if (p_frame->len != PROTO_DLC)
{
return SUL_STATUS_ERR;
}
decode_packet1(p_state, p_frame->p_data);
*p_out = p_state->state;
return SUL_STATUS_OK;
}
if (p_frame->id == PACKET3_ID)
{
if (p_frame->len != PROTO_DLC)
{
return SUL_STATUS_ERR;
}
if (!decode_packet3(p_state, p_frame->p_data))
{
return SUL_STATUS_ERR;
}
*p_out = p_state->state;
return SUL_STATUS_OK;
}
return SUL_STATUS_IGNORED;
}

View file

@ -0,0 +1,33 @@
#include "domain/sul.h"
#include "domain/sul/nku_can.h"
#include <stddef.h>
static const sul_driver_t s_registry[] = {
{
.id = SUL_PROTOCOL_NKU_CAN,
.p_name = "НКУ-CAN",
.decode = nku_can_decode,
},
};
#define REGISTRY_COUNT (sizeof(s_registry) / sizeof(s_registry[0]))
const sul_driver_t *sul_registry_active(void)
{
/* Фаза 1: единственная запись, хардкод. Фаза 3 выберет по настройкам. */
return &s_registry[0];
}
const sul_driver_t *sul_registry_find(uint8_t id)
{
for (size_t i = 0; i < REGISTRY_COUNT; i++)
{
if (s_registry[i].id == id)
{
return &s_registry[i];
}
}
return NULL;
}

View file

@ -0,0 +1,5 @@
add_library(tft_app_sul_transport_can STATIC src/can_transport.c)
target_include_directories(tft_app_sul_transport_can PUBLIC include/)
target_link_libraries(tft_app_sul_transport_can PUBLIC tft_app_sul_headers bsp_can)

View file

@ -0,0 +1,49 @@
/**
* @file can.h
* @brief Тонкий транспорт-адаптер bsp_can sul_frame_t (HW, не host-тестируется).
*
* Не декодирует протокол только инициализация CAN + фильтры и перевод
* bsp_can_frame_t в нейтральный sul_frame_t. Декодирование domain/sul/nku_can.h.
*/
#ifndef DOMAIN_SUL_TRANSPORT_CAN_H_
#define DOMAIN_SUL_TRANSPORT_CAN_H_
#include "bsp/status.h"
#include "domain/sul.h"
#include <stdint.h>
#ifdef __cplusplus
extern "C"
{
#endif
/**
* @brief bsp_can_init() + RX-фильтры под активные ID НКУ-CAN.
*
* Фаза 1: фильтры на PACKET1(0x506)/PACKET3(0x508), адрес станции 0
* (хардкод совпадает с decode-стороной nku_can.c). Фаза 3 параметризует
* оба конца из настроек одновременно.
*/
bsp_status_t sul_transport_can_init(void);
/**
* @brief Принять один кадр и перевести в sul_frame_t.
*
* @warning p_out->p_data валиден ТОЛЬКО до следующего вызова этой функции
* (внутренний статический буфер, единственный ожидаемый вызыватель задача
* sul_rx с последовательным receivedecode). Не сохранять sul_frame_t между
* итерациями цикла приёма.
*
* @param timeout_ms таймаут ожидания кадра, 0 = не блокироваться
* @param p_out заполняется только при возврате BSP_OK
* @return BSP_OK, BSP_ERR_TIMEOUT
*/
bsp_status_t sul_transport_can_receive(uint32_t timeout_ms, sul_frame_t *p_out);
#ifdef __cplusplus
}
#endif
#endif /* DOMAIN_SUL_TRANSPORT_CAN_H_ */

View file

@ -0,0 +1,50 @@
#include "domain/sul/transport/can.h"
#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 — проверять все биты */
/* Хранилище последнего принятого кадра — см. предупреждение в can.h про
* время жизни p_out->p_data, возвращаемого sul_transport_can_receive(). */
static bsp_can_frame_t s_last_frame;
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);
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);
}
bsp_status_t sul_transport_can_receive(uint32_t timeout_ms, sul_frame_t *p_out)
{
const bsp_status_t st = bsp_can_receive(&s_last_frame, timeout_ms);
if (st != BSP_OK)
{
return st;
}
p_out->id = s_last_frame.id;
p_out->bus = 0U;
p_out->p_data = s_last_frame.data;
p_out->len = s_last_frame.dlc;
return BSP_OK;
}

View file

@ -0,0 +1 @@
add_subdirectory(gfx)

View file

@ -0,0 +1,25 @@
# services/gfx — Фаза 1 минимальный gfx: framebuffer (SDRAM non-cacheable) +
# RLE-шрифты (lcd-image-converter) + примитивы. См. include/services/gfx.h.
add_library(
tft_app_gfx STATIC
src/gfx.c
fonts/FloorFontFallback.c
fonts/SystemFont.c)
target_include_directories(tft_app_gfx PUBLIC include/)
# Сгенерированные lcd-image-converter файлы ожидают tImage/tChar/tFont из
# bare-name "fonts.h": SystemFont.c явным #include <fonts.h> (резолвится
# через -I ниже), FloorFontFallback.c вообще без include-строки (более новая
# версия конвертера, полагается на внешний force-include). Один и тот же
# заголовок (fonts/include/fonts.h) закрывает оба случая. НЕ редактируем
# сгенерированные файлы напрямую — конвертер их перегенерирует при
# обновлении шрифта, ручная правка потеряется.
target_include_directories(tft_app_gfx PRIVATE fonts/include)
set_source_files_properties(
fonts/FloorFontFallback.c fonts/SystemFont.c
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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,41 @@
/**
* @file fonts.h
* @brief Layout типов, ожидаемый файлами lcd-image-converter (../FloorFontFallback.c,
* ../SystemFont.c) SystemFont.c явно делает #include <fonts.h> (bare name,
* так сгенерировал конвертер), FloorFontFallback.c рассчитывает на те же
* типы через force-include (см. CMakeLists.txt этой библиотеки).
*
* Порядок/типы полей НЕ менять без пересборки обоих шрифтов в конвертере:
* они определяют бинарную раскладку структур в сгенерированных .c.
*
* Публичный API поверх этого формата services/gfx.h (там те же типы
* переобъявлены под неймспейс services/, этот файл только для компиляции
* сгенерированных файлов, наружу библиотеки gfx не торчит).
*/
#ifndef FONTS_H_
#define FONTS_H_
#include <stdint.h>
typedef struct
{
const uint32_t *data;
uint16_t width;
uint16_t height;
uint8_t dataSize;
} tImage;
typedef struct
{
long int code;
const tImage *image;
} tChar;
typedef struct
{
int length;
const tChar *chars;
} tFont;
#endif /* FONTS_H_ */

View file

@ -0,0 +1,107 @@
/**
* @file gfx.h
* @brief Фаза 1 минимальный gfx: framebuffer (SDRAM, non-cacheable) +
* RLE-шрифты (lcd-image-converter) + примитив стрелки. Без PXP/
* компоновщика/альфа-слоёв прямая запись в единственный framebuffer,
* который ELCDIF сканирует по DMA (см. gfx.c про non-cacheable SDRAM).
*
* Формат tImage/tChar/tFont и RLE-декодирование порт проверенного в проде
* алгоритма из OLD_PROJECT (source/fonts/fonts.c), тот же формат данных, что
* реально экспортирует lcd-image-converter (см. services/gfx/fonts/).
*/
#ifndef SERVICES_GFX_H_
#define SERVICES_GFX_H_
#include "bsp/display.h"
#include "bsp/status.h"
#include <stdint.h>
#ifdef __cplusplus
extern "C"
{
#endif
/* Точный layout, экспортируемый lcd-image-converter — копия
* services/gfx/fonts/include/fonts.h (тот форсированно инклудится в
* сгенерированные .c, здесь то же самое для остального приложения; два
* файла держать в синхроне, формат внешний и стабилен). */
typedef struct
{
const uint32_t *data;
uint16_t width;
uint16_t height;
uint8_t dataSize;
} tImage;
typedef struct
{
long int code;
const tImage *image;
} tChar;
typedef struct
{
int length;
const tChar *chars;
} tFont;
/* Компилируемые в прошивку шрифты (services/gfx/fonts/, сгенерированы
* lcd-image-converter пользователем). */
extern const tFont FloorFontFallback; /* 0-9, "-", пробел — fallback (§11 ARCH) */
extern const tFont SystemFont; /* ASCII + кириллица — логи/меню */
/** XRGB8888 (X игнорируется ELCDIF) — X-байт значения не имеет. */
typedef uint32_t gfx_color_t;
#define GFX_COLOR_BLACK 0x00000000U
#define GFX_COLOR_WHITE 0x00FFFFFFU
/**
* @brief framebuffer (SDRAM non-cacheable) + bsp_display.
*
* SDRAM (SEMC) должна быть уже поднята вызывающим (bsp_sdram_configure() +
* bsp_sdram_init()) gfx не владеет SEMC-инициализацией, только framebuffer
* внутри уже готовой 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);
/**
* @brief Нарисовать строку.
*
* Символ вне таблицы шрифта (отсутствует в font->chars[]) подстановка '-'
* ("-" согласованный fallback-глиф, см. FloorFontFallback) по-символьный
* fallback (ARCH §11), не обрыв/пропуск на первом неизвестном символе. Если
* даже '-' не найден в переданном шрифте символ пропускается (ширина 0).
*
* Понимает 2-байтовые UTF-8 последовательности (ведущий байт 0xD0/0xD1
* кириллица) как ОДИН символ для подстановки/позиционирования как и
* lcd-image-converter кодирует code в tChar для таких шрифтов.
*
* @return суммарная ширина отрисованной строки, пиксели.
*/
uint16_t gfx_draw_string(const tFont *p_font, const char *p_str, uint16_t x, uint16_t y);
/** Ширина строки без отрисовки (для центрирования и т.п.). */
uint16_t gfx_string_width(const tFont *p_font, const char *p_str);
typedef enum
{
GFX_ARROW_UP,
GFX_ARROW_DOWN,
} gfx_arrow_dir_t;
/** Простая треугольная стрелка — примитив, без спрайтов (ARCH §11: fallback asset-free). */
void gfx_draw_arrow(gfx_arrow_dir_t dir, uint16_t x, uint16_t y, uint16_t size, gfx_color_t color);
#ifdef __cplusplus
}
#endif
#endif /* SERVICES_GFX_H_ */

View file

@ -0,0 +1,268 @@
#include "services/gfx.h"
#include "fsl_common.h" /* AT_NONCACHEABLE_SECTION_ALIGN */
#include <stddef.h>
/* ── Framebuffer (SDRAM, non-cacheable) ────────────────────────────────────
*
* AT_NONCACHEABLE_SECTION_ALIGN размещает переменную в линкер-секции
* NonCacheable/.ncache cmake/linker/..._app_slot.ld отображает её на
* m_sdram_ncache (2 МБ в начале 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,
* тот же макрос, framebuffer/alpha_buffer/processing_buffer). Без этого CPU
* писал бы через Write-Back D-Cache (Region 8: SDRAM WB Cacheable), и ELCDIF
* читал бы устаревшие данные, пока кэш-линия не вытеснится сама.
*
* Размер под ТЕКУЩУЮ панель стенда (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)
#define FALLBACK_CHAR '-'
AT_NONCACHEABLE_SECTION_ALIGN(static uint32_t s_framebuffer[FRAMEBUFFER_PIXELS], FRAMEBUFFER_ALIGN);
static uint16_t s_fb_width;
static uint16_t s_fb_height;
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;
}
/* ── Поиск глифа (бинарный — chars[] отсортирован по code, гарантия формата
* lcd-image-converter) порт draw_char()+get_character_width() из
* OLD_PROJECT source/fonts/fonts.c, унифицировано в одну функцию (там был
* бинарный поиск в draw_char() и отдельный линейный в get_character_width()
* один и тот же инвариант сортировки, лишнее дублирование). */
static const tImage *find_glyph(const tFont *p_font, long code)
{
int low = 0;
int high = p_font->length - 1;
while (low <= high)
{
const int mid = low + (high - low) / 2;
if (p_font->chars[mid].code == code)
{
return p_font->chars[mid].image;
}
if (p_font->chars[mid].code < code)
{
low = mid + 1;
}
else
{
high = mid - 1;
}
}
return NULL;
}
/* ── RLE-декодирование глифа — порт draw_char() из OLD_PROJECT
* source/fonts/fonts.c (проверенный в проде алгоритм, формат как
* реально экспортирует lcd-image-converter, "RLE compression enabled").
*
* Поток uint32_t, каждый блок начинается с заголовка:
* (header & 0xFFFFFF00) == 0xFFFFFF00 UNIQUE: len = 0x100-(header&0xFF)
* уникальных пикселей подряд следуют в потоке (по одному слову каждый).
* иначе REPEATABLE: len = header&0xFFFF
* повторений ОДНОГО пикселя (следующее слово потока, читается один раз).
* Пиксели ARGB8888, порядок row-major, перенос строки на границе width
* (advance_pixel в оригинале). */
#define UNIQUE_BLOCK_MASK 0xFFFFFF00U
static void draw_glyph(const tImage *p_image, uint16_t x_pos, uint16_t y_pos)
{
const uint32_t total = (uint32_t) p_image->width * p_image->height;
uint32_t in_idx = 0U;
uint32_t out_n = 0U;
uint32_t col = 0U;
uint32_t row = 0U;
while (out_n < total)
{
const uint32_t header = p_image->data[in_idx++];
if ((header & UNIQUE_BLOCK_MASK) == UNIQUE_BLOCK_MASK)
{
const uint32_t len = 0x100U - (header & 0xFFU);
for (uint32_t i = 0U; (i < len) && (out_n < total); i++)
{
set_pixel((uint16_t) (x_pos + col), (uint16_t) (y_pos + row), p_image->data[in_idx]);
col++;
if (col >= p_image->width)
{
col = 0U;
row++;
}
out_n++;
in_idx++;
}
}
else
{
const uint32_t len = header & 0xFFFFU;
const uint32_t pixel = p_image->data[in_idx];
for (uint32_t i = 0U; (i < len) && (out_n < total); i++)
{
set_pixel((uint16_t) (x_pos + col), (uint16_t) (y_pos + row), pixel);
col++;
if (col >= p_image->width)
{
col = 0U;
row++;
}
out_n++;
}
in_idx++;
}
}
}
/* ── Декодирование одного символа строки, включая 2-байтовый UTF-8
* (кириллица) порт логики из OLD_PROJECT draw_string(): ведущие байты
* 0xD0/0xD1 комбинируются со следующим байтом в один code, как их кодирует
* lcd-image-converter в tChar.code для таких шрифтов. */
static long next_codepoint(const char *p_str, uint8_t *p_consumed)
{
const uint8_t lead = (uint8_t) p_str[0];
if (((lead == 0xD0U) || (lead == 0xD1U)) && (p_str[1] != '\0'))
{
*p_consumed = 2U;
return ((long) lead << 8) | (uint8_t) p_str[1];
}
*p_consumed = 1U;
return (long) lead;
}
/* find_glyph() с fallback-подстановкой '-' (ARCH §11: по-символьный fallback
* на отсутствующий в шрифте символ политика подтверждена: '-' общий
* заменитель во FloorFontFallback). Общая для draw_string/string_width. */
static const tImage *find_glyph_with_fallback(const tFont *p_font, long code)
{
const tImage *p_glyph = find_glyph(p_font, code);
if (p_glyph == NULL)
{
p_glyph = find_glyph(p_font, (long) FALLBACK_CHAR);
}
return p_glyph; /* NULL, если даже '-' нет в этом шрифте */
}
uint16_t gfx_draw_string(const tFont *p_font, const char *p_str, uint16_t x, uint16_t y)
{
if ((p_font == NULL) || (p_str == NULL))
{
return 0U;
}
uint16_t offset = 0U;
while (*p_str != '\0')
{
uint8_t consumed = 1U;
const long code = next_codepoint(p_str, &consumed);
p_str += consumed;
const tImage *p_glyph = find_glyph_with_fallback(p_font, code);
if (p_glyph == NULL)
{
continue;
}
draw_glyph(p_glyph, (uint16_t) (x + offset), y);
offset = (uint16_t) (offset + p_glyph->width);
}
return offset;
}
uint16_t gfx_string_width(const tFont *p_font, const char *p_str)
{
if ((p_font == NULL) || (p_str == NULL))
{
return 0U;
}
uint16_t width = 0U;
while (*p_str != '\0')
{
uint8_t consumed = 1U;
const long code = next_codepoint(p_str, &consumed);
p_str += consumed;
const tImage *p_glyph = find_glyph_with_fallback(p_font, code);
if (p_glyph != NULL)
{
width = (uint16_t) (width + p_glyph->width);
}
}
return width;
}
/* ── Стрелка — примитив (без спрайтов, ARCH §11 fallback asset-free) ────── */
void gfx_draw_arrow(gfx_arrow_dir_t dir, uint16_t x, uint16_t y, uint16_t size, gfx_color_t color)
{
if (size < 2U)
{
return; /* вырожденный размер — не рисуем, не делим на (size-1)=0 */
}
/* Равнобедренный треугольник построчной заливкой: half_width растёт
* линейно от 0 (вершина) до size/2 (основание). */
for (uint16_t row = 0U; row < size; row++)
{
const uint16_t half_width = (uint16_t) (((uint32_t) row * (size / 2U)) / (size - 1U));
const uint16_t center = (uint16_t) (x + size / 2U);
const uint16_t py = (dir == GFX_ARROW_UP) ? (uint16_t) (y + row) : (uint16_t) (y + (size - 1U) - row);
for (uint16_t dx = 0U; dx <= half_width; dx++)
{
set_pixel((uint16_t) (center - dx), py, color);
set_pixel((uint16_t) (center + dx), py, color);
}
}
}
/* ── Framebuffer / 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);
if (st != BSP_OK)
{
return st;
}
const bsp_display_size_t *p_size = bsp_display_get_size();
s_fb_width = p_size->width;
s_fb_height = p_size->height;
return BSP_OK;
}
void gfx_clear(gfx_color_t color)
{
const uint32_t count = (uint32_t) s_fb_width * s_fb_height;
for (uint32_t i = 0U; i < count; i++)
{
s_framebuffer[i] = color;
}
}

View file

@ -0,0 +1 @@
add_subdirectory(fallback)

View file

@ -0,0 +1,8 @@
add_library(tft_app_ui_fallback STATIC src/fallback.c)
target_include_directories(tft_app_ui_fallback PUBLIC include/)
target_link_libraries(
tft_app_ui_fallback
PUBLIC tft_app_elevator_model tft_app_controller
PRIVATE tft_app_gfx)

View file

@ -0,0 +1,42 @@
/**
* @file fallback.h
* @brief Fallback-рендер (ARCH §11): позиция кабины + стрелка направления,
* asset-free, FS-free. Один вкомпилированный шрифт (FloorFontFallback),
* примитив стрелки без TLV/layout-движка (те Фаза 4/5).
*/
#ifndef UI_FALLBACK_H_
#define UI_FALLBACK_H_
#include "domain/controller.h"
#include "domain/elevator_model.h"
#ifdef __cplusplus
extern "C"
{
#endif
/**
* @brief Безусловная первая отрисовка при старте.
*
* controller_init() засеивает кэш дефолтом если первый реальный кадр
* совпадёт с дефолтом, indication_task_t придёт «ничего не изменилось», и
* экран останется пустым, если полагаться только на ui_fallback_render().
* Вызвать один раз при старте до входа в цикл получения кадров.
*/
void ui_fallback_render_initial(const sul_result_t *p_result);
/**
* @brief Инкрементальная перерисовка по diff.
*
* Фаза 1: перерисовывает весь кадр целиком при ЛЮБОМ pending-поле (нет
* partial-update gfx минимален, без dirty-rect). No-op, если ничего не
* помечено.
*/
void ui_fallback_render(const indication_task_t *p_task, const sul_result_t *p_result);
#ifdef __cplusplus
}
#endif
#endif /* UI_FALLBACK_H_ */

View file

@ -0,0 +1,44 @@
#include "ui/fallback.h"
#include "services/gfx.h"
/* Фаза 1: позиции захардкожены под TFT8 (800×600, panel текущего стенда).
* Цифра '0' FloorFontFallback 132×162 px (проверено по сгенерированным
* данным шрифта). Фаза 5 (layout-движок) заменит на якорное позиционирование,
* независимое от разрешения панели. */
#define PANEL_WIDTH 800U
#define POS_Y 150U
#define ARROW_Y 380U
#define ARROW_SIZE 80U
static void render(const sul_result_t *p_result)
{
gfx_clear(GFX_COLOR_BLACK);
const uint16_t str_width = gfx_string_width(&FloorFontFallback, p_result->pos);
const uint16_t pos_x = (uint16_t) ((PANEL_WIDTH - str_width) / 2U);
(void) gfx_draw_string(&FloorFontFallback, p_result->pos, pos_x, POS_Y);
/* SUL_DIR_NONE — без стрелки. SUL_DIR_DOUBLE — тоже (не путать с
* up/down одной стрелкой; отдельная индикация Фаза 2). */
if ((p_result->direction == SUL_DIR_UP) || (p_result->direction == SUL_DIR_DOWN))
{
const gfx_arrow_dir_t dir = (p_result->direction == SUL_DIR_UP) ? GFX_ARROW_UP : GFX_ARROW_DOWN;
const uint16_t arrow_x = (uint16_t) ((PANEL_WIDTH - ARROW_SIZE) / 2U);
gfx_draw_arrow(dir, arrow_x, ARROW_Y, ARROW_SIZE, GFX_COLOR_WHITE);
}
}
void ui_fallback_render_initial(const sul_result_t *p_result)
{
render(p_result);
}
void ui_fallback_render(const indication_task_t *p_task, const sul_result_t *p_result)
{
if (!p_task->pos_pending && !p_task->direction_pending)
{
return;
}
render(p_result);
}

View file

@ -323,3 +323,31 @@ add_host_test(
test_tft_app_smoke test_tft_app_smoke
SOURCES SOURCES
tft_app_smoke/test_tft_app_smoke.c) tft_app_smoke/test_tft_app_smoke.c)
# -----------------------------------------------------------------------------
# tft_app — domain (Фаза 1: чистый декодер НКУ-CAN + контроллер)
# -----------------------------------------------------------------------------
set(TFT_APP_DOMAIN_DIR ${PROJECT_SOURCE_DIR}/firmware/tft_app/src/domain)
add_host_test(
NAME
test_sul_nku_can
SOURCES
tft_app_sul_nku/test_sul_nku_can.c
${TFT_APP_DOMAIN_DIR}/elevator_model/src/elevator_model.c
${TFT_APP_DOMAIN_DIR}/sul/nku_can/src/nku_can.c
INCLUDES
${TFT_APP_DOMAIN_DIR}/elevator_model/include
${TFT_APP_DOMAIN_DIR}/sul/include
${TFT_APP_DOMAIN_DIR}/sul/nku_can/include)
add_host_test(
NAME
test_tft_app_controller
SOURCES
tft_app_controller/test_controller.c
${TFT_APP_DOMAIN_DIR}/elevator_model/src/elevator_model.c
${TFT_APP_DOMAIN_DIR}/controller/src/controller.c
INCLUDES
${TFT_APP_DOMAIN_DIR}/elevator_model/include
${TFT_APP_DOMAIN_DIR}/controller/include)

View file

@ -0,0 +1,124 @@
/**
* @file test_controller.c
* @brief Host unit-тесты controller.c diff между кэшем и новым sul_result_t.
*
* Чистый C, без единого HAL-вызова fff-моков не нужно.
*/
#include "unity.h"
#include "domain/controller.h"
#include <stdio.h>
void setUp(void) {}
void tearDown(void) {}
static sul_result_t make_result(const char *p_pos, sul_direction_t dir)
{
sul_result_t r = sul_default_state();
(void) snprintf(r.pos, SUL_POS_BUF_LEN, "%s", p_pos);
r.direction = dir;
return r;
}
/* Кэш засеян дефолтом при init — результат, совпадающий с дефолтом, не
* считается изменением (см. докстрок controller_init()). */
static void test_first_frame_matching_default_has_no_pending(void)
{
controller_ctx_t ctx;
controller_init(&ctx);
const sul_result_t r = sul_default_state();
const indication_task_t task = controller_process(&ctx, &r);
TEST_ASSERT_FALSE(task.pos_pending);
TEST_ASSERT_FALSE(task.direction_pending);
}
static void test_first_frame_differing_from_default_marks_both_pending(void)
{
controller_ctx_t ctx;
controller_init(&ctx);
const sul_result_t r = make_result("12", SUL_DIR_UP);
const indication_task_t task = controller_process(&ctx, &r);
TEST_ASSERT_TRUE(task.pos_pending);
TEST_ASSERT_TRUE(task.direction_pending);
}
static void test_repeated_identical_result_has_no_pending(void)
{
controller_ctx_t ctx;
controller_init(&ctx);
const sul_result_t r = make_result("12", SUL_DIR_UP);
(void) controller_process(&ctx, &r);
const indication_task_t task = controller_process(&ctx, &r);
TEST_ASSERT_FALSE(task.pos_pending);
TEST_ASSERT_FALSE(task.direction_pending);
}
static void test_only_pos_change_marks_only_pos_pending(void)
{
controller_ctx_t ctx;
controller_init(&ctx);
const sul_result_t r1 = make_result("12", SUL_DIR_UP);
(void) controller_process(&ctx, &r1);
const sul_result_t r2 = make_result("13", SUL_DIR_UP);
const indication_task_t task = controller_process(&ctx, &r2);
TEST_ASSERT_TRUE(task.pos_pending);
TEST_ASSERT_FALSE(task.direction_pending);
}
static void test_only_direction_change_marks_only_direction_pending(void)
{
controller_ctx_t ctx;
controller_init(&ctx);
const sul_result_t r1 = make_result("12", SUL_DIR_UP);
(void) controller_process(&ctx, &r1);
const sul_result_t r2 = make_result("12", SUL_DIR_DOWN);
const indication_task_t task = controller_process(&ctx, &r2);
TEST_ASSERT_FALSE(task.pos_pending);
TEST_ASSERT_TRUE(task.direction_pending);
}
/* Таймаут связи = caller зовёт controller_process(sul_default_state()) тем
* же путём, что и обычный кадр отдельного API не существует (см. header). */
static void test_feeding_default_state_after_real_data_marks_both_pending(void)
{
controller_ctx_t ctx;
controller_init(&ctx);
const sul_result_t r1 = make_result("12", SUL_DIR_UP);
(void) controller_process(&ctx, &r1);
const sul_result_t timeout_result = sul_default_state();
const indication_task_t task = controller_process(&ctx, &timeout_result);
TEST_ASSERT_TRUE(task.pos_pending);
TEST_ASSERT_TRUE(task.direction_pending);
}
int main(void)
{
UNITY_BEGIN();
RUN_TEST(test_first_frame_matching_default_has_no_pending);
RUN_TEST(test_first_frame_differing_from_default_marks_both_pending);
RUN_TEST(test_repeated_identical_result_has_no_pending);
RUN_TEST(test_only_pos_change_marks_only_pos_pending);
RUN_TEST(test_only_direction_change_marks_only_direction_pending);
RUN_TEST(test_feeding_default_state_after_real_data_marks_both_pending);
return UNITY_END();
}

View file

@ -0,0 +1,262 @@
/**
* @file test_sul_nku_can.c
* @brief Host unit-тесты декодера НКУ-CAN (Фаза 1: PACKET1 + PACKET3).
*
* Чистый декодер, без единого HAL-вызова никаких fff-моков не нужно,
* golden-векторы CAN-кадров строятся прямо в тесте.
*/
#include "unity.h"
#include "domain/sul/nku_can.h"
#include <string.h>
void setUp(void) {}
void tearDown(void) {}
/* ── Вспомогательные конструкторы кадров ─────────────────────────────── */
#define PACKET1_ID 0x506U
#define PACKET3_ID 0x508U
#define UNKNOWN_ID 0x123U
static sul_frame_t make_packet1(uint8_t arrow_bits)
{
static uint8_t s_data[8];
memset(s_data, 0, sizeof(s_data));
s_data[6] = arrow_bits; /* ARROW_MASK=0x03 на data[6] (DATA7) */
return (sul_frame_t){.id = PACKET1_ID, .bus = 0, .p_data = s_data, .len = 8U};
}
static sul_frame_t make_packet3(uint8_t left, uint8_t right)
{
static uint8_t s_data[8];
memset(s_data, 0, sizeof(s_data));
s_data[5] = left; /* FLOOR_MASK=0x3F на data[5] (left) */
s_data[6] = right; /* FLOOR_MASK=0x3F на data[6] (right) */
return (sul_frame_t){.id = PACKET3_ID, .bus = 0, .p_data = s_data, .len = 8U};
}
/* ── PACKET1 — направление ───────────────────────────────────────────── */
static void test_packet1_none(void)
{
nku_can_ctx_t ctx;
nku_can_init(&ctx);
sul_result_t out;
const sul_frame_t frame = make_packet1(0U);
TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &frame, &out));
TEST_ASSERT_EQUAL(SUL_DIR_NONE, out.direction);
}
static void test_packet1_up(void)
{
nku_can_ctx_t ctx;
nku_can_init(&ctx);
sul_result_t out;
const sul_frame_t frame = make_packet1(1U);
TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &frame, &out));
TEST_ASSERT_EQUAL(SUL_DIR_UP, out.direction);
}
static void test_packet1_down(void)
{
nku_can_ctx_t ctx;
nku_can_init(&ctx);
sul_result_t out;
const sul_frame_t frame = make_packet1(2U);
TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &frame, &out));
TEST_ASSERT_EQUAL(SUL_DIR_DOWN, out.direction);
}
static void test_packet1_double(void)
{
nku_can_ctx_t ctx;
nku_can_init(&ctx);
sul_result_t out;
const sul_frame_t frame = make_packet1(3U);
TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &frame, &out));
TEST_ASSERT_EQUAL(SUL_DIR_DOUBLE, out.direction);
}
/* Верхние биты data[6] (выше ARROW_MASK) не должны влиять на результат. */
static void test_packet1_ignores_bits_outside_mask(void)
{
nku_can_ctx_t ctx;
nku_can_init(&ctx);
sul_result_t out;
const sul_frame_t frame = make_packet1(0xFCU | 1U); /* мусор в старших битах + arrow=1 */
TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &frame, &out));
TEST_ASSERT_EQUAL(SUL_DIR_UP, out.direction);
}
/* ── PACKET3 — позиция ───────────────────────────────────────────────── */
static void test_packet3_two_digit_floor(void)
{
nku_can_ctx_t ctx;
nku_can_init(&ctx);
sul_result_t out;
const sul_frame_t frame = make_packet3(1U, 2U); /* "1","2" -> "12" */
TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &frame, &out));
TEST_ASSERT_EQUAL_STRING("12", out.pos);
}
static void test_packet3_single_digit_via_space(void)
{
nku_can_ctx_t ctx;
nku_can_init(&ctx);
sul_result_t out;
const sul_frame_t frame = make_packet3(16U, 5U); /* left=SPACE, right="5" -> "5" */
TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &frame, &out));
TEST_ASSERT_EQUAL_STRING("5", out.pos);
}
/* Легаси-квирк: left==0 трактуется как «пусто», как и SPACE — не «0» + right. */
static void test_packet3_single_digit_via_zero_byte(void)
{
nku_can_ctx_t ctx;
nku_can_init(&ctx);
sul_result_t out;
const sul_frame_t frame = make_packet3(0U, 7U);
TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &frame, &out));
TEST_ASSERT_EQUAL_STRING("7", out.pos);
}
static void test_packet3_negative_floor(void)
{
nku_can_ctx_t ctx;
nku_can_init(&ctx);
sul_result_t out;
const sul_frame_t frame = make_packet3(22U, 1U); /* '-','1' -> "-1" */
TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &frame, &out));
TEST_ASSERT_EQUAL_STRING("-1", out.pos);
}
static void test_packet3_cyrillic_single_char(void)
{
nku_can_ctx_t ctx;
nku_can_init(&ctx);
sul_result_t out;
const sul_frame_t frame = make_packet3(16U, 17U); /* SPACE, symbol_P -> "П" */
TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &frame, &out));
TEST_ASSERT_EQUAL_STRING("П", out.pos);
}
static void test_packet3_out_of_range_symbol_is_error(void)
{
nku_can_ctx_t ctx;
nku_can_init(&ctx);
sul_result_t out;
const sul_frame_t frame = make_packet3(1U, 63U); /* 63 > SYMBOL_TOTAL-1(37) */
TEST_ASSERT_EQUAL(SUL_STATUS_ERR, nku_can_decode(&ctx, &frame, &out));
}
/* ── Кадры не по протоколу ────────────────────────────────────────────── */
static void test_unknown_id_is_ignored_and_out_untouched(void)
{
nku_can_ctx_t ctx;
nku_can_init(&ctx);
sul_result_t out;
out.direction = SUL_DIR_UP; /* сентинел, decode() не должен его тронуть */
(void) memset(out.pos, 'X', sizeof(out.pos));
uint8_t data[8] = {0};
const sul_frame_t frame = {.id = UNKNOWN_ID, .bus = 0, .p_data = data, .len = 8U};
TEST_ASSERT_EQUAL(SUL_STATUS_IGNORED, nku_can_decode(&ctx, &frame, &out));
TEST_ASSERT_EQUAL(SUL_DIR_UP, out.direction); /* не тронут */
TEST_ASSERT_EQUAL_CHAR('X', out.pos[0]); /* не тронут */
}
static void test_wrong_dlc_on_packet1_id_is_error(void)
{
nku_can_ctx_t ctx;
nku_can_init(&ctx);
sul_result_t out;
uint8_t data[8] = {0};
const sul_frame_t frame = {.id = PACKET1_ID, .bus = 0, .p_data = data, .len = 6U};
TEST_ASSERT_EQUAL(SUL_STATUS_ERR, nku_can_decode(&ctx, &frame, &out));
}
static void test_wrong_dlc_on_packet3_id_is_error(void)
{
nku_can_ctx_t ctx;
nku_can_init(&ctx);
sul_result_t out;
uint8_t data[8] = {0};
const sul_frame_t frame = {.id = PACKET3_ID, .bus = 0, .p_data = data, .len = 3U};
TEST_ASSERT_EQUAL(SUL_STATUS_ERR, nku_can_decode(&ctx, &frame, &out));
}
/* ── Накопление состояния между разными пакетами ─────────────────────── */
/* PACKET1 и PACKET3 несут разные поля — второй вызов обязан вернуть ПОЛНОЕ
* накопленное состояние (и pos, и direction), а не только то, что пришло в
* последнем кадре. Это ключевое свойство stateful-ctx декодера. */
static void test_state_accumulates_across_packet_types(void)
{
nku_can_ctx_t ctx;
nku_can_init(&ctx);
sul_result_t out;
const sul_frame_t f1 = make_packet1(1U); /* UP */
TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &f1, &out));
const sul_frame_t f3 = make_packet3(1U, 2U); /* "12" */
TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &f3, &out));
TEST_ASSERT_EQUAL_STRING("12", out.pos);
TEST_ASSERT_EQUAL(SUL_DIR_UP, out.direction); /* пережило кадр PACKET3 */
}
static void test_init_resets_to_default(void)
{
nku_can_ctx_t ctx;
nku_can_init(&ctx);
TEST_ASSERT_EQUAL_STRING("--", ctx.state.pos);
TEST_ASSERT_EQUAL(SUL_DIR_NONE, ctx.state.direction);
}
int main(void)
{
UNITY_BEGIN();
RUN_TEST(test_packet1_none);
RUN_TEST(test_packet1_up);
RUN_TEST(test_packet1_down);
RUN_TEST(test_packet1_double);
RUN_TEST(test_packet1_ignores_bits_outside_mask);
RUN_TEST(test_packet3_two_digit_floor);
RUN_TEST(test_packet3_single_digit_via_space);
RUN_TEST(test_packet3_single_digit_via_zero_byte);
RUN_TEST(test_packet3_negative_floor);
RUN_TEST(test_packet3_cyrillic_single_char);
RUN_TEST(test_packet3_out_of_range_symbol_is_error);
RUN_TEST(test_unknown_id_is_ignored_and_out_untouched);
RUN_TEST(test_wrong_dlc_on_packet1_id_is_error);
RUN_TEST(test_wrong_dlc_on_packet3_id_is_error);
RUN_TEST(test_state_accumulates_across_packet_types);
RUN_TEST(test_init_resets_to_default);
return UNITY_END();
}