diff --git a/firmware/tft_app/ARCH.md b/firmware/tft_app/ARCH.md index 6a39370..a62ff46 100644 --- a/firmware/tft_app/ARCH.md +++ b/firmware/tft_app/ARCH.md @@ -200,19 +200,33 @@ typedef struct { (пишется service_tui), не пользовательская настройка. Матрица сборки — два buildPreset (`app-tft4`, `app-big`), как `bootloader`/`firmware-test`. -### 10. Карта QSPI (W25Q128, 16 МБ) +### 10. Карта QSPI (размер-независимая) -| Регион | Смещение | Размер | Владелец | -| ----------------- | ---------- | ------- | ---------- | -| bootloader | `0x000000` | 256 КБ | bootloader | -| slot A (tft_app) | `0x040000` | 2 МБ | MCUboot | -| slot Б (tft_app) | `0x240000` | 2 МБ | MCUboot | -| **layout-регион** | `0x440000` | 64 КБ | tft_app | -| **assets-регион** | `0x450000` | ≈ 11 МБ | tft_app | -| settings | `0xFFE000` | 4 КБ | tft_app | +Прошивка работает на разных QSPI NOR (W25Q128 16 МБ / 256 / 512 …). Поэтому **все +критичные регионы — по фиксированным смещениям** (компайл-тайм, без рантайм-детекта), а +**размер-зависим только регион ассетов**: он стартует с фиксированного адреса и тянется до +конца чипа (`длина = ёмкость − assets_start`; ёмкость — по JEDEC-ID). -layout- и assets-регионы — **вне** flash-area загрузчика (bootutil про них не знает). Разметка -фиксируется в **едином partition-заголовке**, из которого читают app и генератор для service_tui. +| Регион | Смещение | Размер | Владелец | +| ----------------- | ---------- | ----------------- | ---------- | +| bootloader | `0x000000` | 256 КБ | bootloader | +| slot A (tft_app) | `0x040000` | 2 МБ | MCUboot | +| slot Б (tft_app) | `0x240000` | 2 МБ | MCUboot | +| **layout-регион** | `0x440000` | 64 КБ | tft_app | +| **settings** | `0x450000` | 8 КБ (2 сектора) | tft_app | +| **assets-регион** | `0x452000` | остаток до конца | tft_app | + +- **settings — фиксированный `0x450000`, размер-независимо** (общий адрес для app и + service_tui, без вычислений из ёмкости). Отведено 2 сектора: первый рабочий, второй — задел + под power-safe ping-pong (erase+write настроек не атомарен; ping-pong защищает от обрыва + питания). Клиентский UX-зоопарк (ярус C настроек, §8) хранится в TLV в **layout-регионе**, + не здесь. +- **assets — остаток до конца чипа**: единственный размер-зависимый регион; больше флеш = + больше места под ассеты, без правок карты. + +layout-, settings- и assets-регионы — **вне** flash-area загрузчика (bootutil про них не знает). +Разметка фиксируется в **едином partition-заголовке**, из которого читают app и генератор для +service_tui. ### 11. Ассеты, layout и fallback diff --git a/firmware/tft_app/CMakeLists.txt b/firmware/tft_app/CMakeLists.txt index 5be557c..e56c841 100644 --- a/firmware/tft_app/CMakeLists.txt +++ b/firmware/tft_app/CMakeLists.txt @@ -118,7 +118,9 @@ target_link_libraries( tft_app_sul_transport_can tft_app_controller tft_app_gfx - tft_app_ui_fallback) + tft_app_ui_fallback + # Фаза 3.1: персист ядра настроек (QSPI 0x450000) + tft_app_settings_store) target_link_options( app diff --git a/firmware/tft_app/PLAN.md b/firmware/tft_app/PLAN.md index e09894c..e1cc3e9 100644 --- a/firmware/tft_app/PLAN.md +++ b/firmware/tft_app/PLAN.md @@ -235,20 +235,61 @@ marks_both_pending`, аппаратно на реальном обрыве св **Цель.** Персистентные настройки на QSPI, меню на двух кнопках, локальные входы (opto). -**Объём.** `services/settings_store` (магик/версия/CRC, сектор настроек §10); дескрипторы -per-protocol настроек (§8); `menu/` (навигация, редактирование, привязка к дескрипторам); -`bsp_button` + `bsp_opto` (диспетчерский вызов/ответ) → контроллер/презентация. -**Рантайм-тумблер логгирования:** флаг `log_enabled` в настройках + пункт меню «включить -логгирование» + рантайм-гейт в `utils/log` (`log_set_enabled()`, проверка в начале `log_write()`) -поверх компайл-тайм `LOG_LEVEL`. Инфраструктура (`utils/log`, `port/log_uart`, `log_mutex`) уже -готова — здесь только рантайм-гейт и его привязка к настройке/меню. NB: продакшн-сборка должна -идти с `LOG_LEVEL >= INFO` (не `OFF`), иначе тумблеру нечего гейтить — макросы вырезаны компилятором. +> **Согласованный дизайн (решено с пользователем; референсы — все старые проекты: +> settings_manager ×3, menu ×7 вкл. ALPACA, main_programm/menu_task).** +> +> Движущее требование: **добавлять/убирать настройки тривиально, без переписывания проекта** — +> клиенты приносят уникальные параметры (боевой пример ALPACA: `ground_shift`, маска +> `shown_floors` со своим редактором `BOOL_ARRAY`; кастомные метки этажей «1А»). Поэтому: +> +> - **Настройка = строка дескриптора-данных**, не поле монолита: `{id, подпись, тип-редактор, +> хранилище/offset, диапазон, длина, видимость, ярус}`. Добавить настройку = добавить строку +> (тот же принцип, что `k_mode_priority[]`). +> - **Три яруса настроек:** **A. железобетонные** (вес/вместимость, громкости, серийник, год) · +> **B. протокольные** (§8: выбор протокола, адрес — `sul_settings_desc_t`, регистрирует +> протокол) · **C. клиентский UX-зоопарк** (лого, шаблон, сдвиги/маска этажей, метки) — +> презентационные, живут с клиентским layout-конфигом. +> - **Хранение — гибрид:** ядро (ярусы A/B) — фиксированная версионированная `settings_t` + +> magic/version/CRC32 в **фиксированном секторе `0x450000`** (размер-независимо, §10; через +> `bsp_qspi_flash`, не сырой XIP-memcpy) — чертёж из +> `OLD_PROJECT_TFT8_UKL/hal/settings_manager.c`. Клиентский ярус C — +> **тегированный TLV-блок рядом с layout-конфигом** (§11), добавляется без правок ядра +> (эффекты — Фаза 5). +> - **Меню = движок + данные:** дерево пунктов — данные (плоский массив с `parent/child`); +> **редакторы подключаются по типу** (dispatch «тип → функция-редактор»), добавить причудливый +> параметр = добавить редактор, не переписывать движок. Фаза 3 реализует редакторы **только +> действующих** типов (выбор/`BYTE`/`BOOL`); `ARRAY/SERIAL/YEAR/BOOL_ARRAY/PRESET` — по мере +> своих фаз, но API движка рассчитан на расширение. +> - **Модель меню (навигация/edit/привязка) — чистый C, host-тест**; рендер — отдельно (HIL). +> Разделяем то, что в легаси было слито (`draw_cursor(buffer[...])` внутри menu.h). +> - **Панель — хардкод TFT8** (как Фаза 1); панель как provisioning-параметр — Фаза 9 +> (`bsp_provisioning` сейчас читает только UID чипа). +> +> **Честная область действия Фазы 3:** реальный эффект «сейчас» дают только **протокол+адрес** +> (→ декодер) и **тумблер логов**. Громкости — Фаза 6 (аудио); серийник/год/вес и весь ярус C — +> Фаза 5 (layout). Остальной каталог descriptor'ится и хранится, «зажигается» в своей фазе. -**Тесты (host).** Сериализация/дефолты/CRC настроек; логика навигации меню; связывание -дескрипторов. +**Декомпозиция (walking-skeleton: тонкий срез первым).** -**Критерий выхода.** Меню редактирует и сохраняет настройки; opto-иконки отображаются; выбор -протокола и его параметров работает (пока протокол один). +| Под-шаг | Содержание | +| --- | --- | +| **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-привязка) — host-тест; рендер (примитивы `gfx`: список/курсор/значение) + `bsp_button`; модальный экран. | +| **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`, иначе гейтить нечего (макросы вырезаны). | + +**Тесты (host).** Сериализация/дефолты/CRC ядра настроек; логика навигации меню; связывание +дескрипторов; распознавание команды удалённой адресации; рантайм-гейт логов. + +**Критерий выхода.** Меню редактирует и сохраняет настройки; выбор протокола и адреса работает и +влияет на декодер (пока протокол один); opto-вход отображается (примитив в fallback); тумблер логов +работает; движок настроек/меню расширяется добавлением дескриптора/редактора без правок ядра. + +**Документация (обязательный выход).** `docs/tft_app/SETTINGS.md` — без воды: состав `settings_t`, +ярусы A/B/C и их взаимосвязи, `proto_slice`/дескрипторы (§8), гибридное хранение (ядро-struct + +клиентский TLV), карта QSPI (§10, размер-независимость). По паттерну «доки по реализованному». --- diff --git a/firmware/tft_app/src/app/main.c b/firmware/tft_app/src/app/main.c index b5e2a99..6b970cd 100644 --- a/firmware/tft_app/src/app/main.c +++ b/firmware/tft_app/src/app/main.c @@ -37,6 +37,7 @@ #include "port/log_uart.h" #include "queue.h" #include "services/gfx.h" +#include "services/settings_store.h" #include "task.h" #include "ui/fallback.h" @@ -183,6 +184,18 @@ static void sul_rx_task(void *p_arg) 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]); + } + else + { + settings_store_init_defaults(); + } + /* «Дошёл до устойчивого состояния» — сбрасывает счётчик попыток загрузки * (recovery загрузчика). SRC GPR, без flash. Безусловно, до потенциально * рискованного bring-up дисплея/CAN ниже. */ @@ -201,6 +214,8 @@ static void sul_rx_task(void *p_arg) 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); diff --git a/firmware/tft_app/src/domain/sul/nku_can/include/domain/sul/nku_can.h b/firmware/tft_app/src/domain/sul/nku_can/include/domain/sul/nku_can.h index 7447c9e..c5e3455 100644 --- a/firmware/tft_app/src/domain/sul/nku_can/include/domain/sul/nku_can.h +++ b/firmware/tft_app/src/domain/sul/nku_can/include/domain/sul/nku_can.h @@ -51,11 +51,20 @@ typedef struct bool lading_instr; /**< PACKET1: код режима 0x10 (инструментальная) */ uint8_t current_level; /**< PACKET1: data[3] & 0x3F — числовой уровень остановки, для гейта «следующего этажа» в PACKET5 */ + uint8_t nku_address; /**< адрес станции 0..15 — сдвиг ID пакетов (из настроек) */ } nku_can_ctx_t; -/** Сброс к состоянию по умолчанию (sul_default_state()). Вызвать перед первым decode(). */ +/** Сброс к состоянию по умолчанию (sul_default_state()); адрес станции = 0. */ void nku_can_init(nku_can_ctx_t *p_ctx); +/** + * @brief Задать адрес станции (0..15) — сдвиг ID пакетов (group4/group6). + * + * Вызывать после nku_can_init(), значение из настроек (proto_slice[0], Фаза 3.1). + * Значения > 15 клампятся к 15. Для адреса 0 поведение как в Фазах 1/2. + */ +void nku_can_set_address(nku_can_ctx_t *p_ctx, uint8_t nku_address); + /** * @brief decode() для реестра sul (см. sul_decode_fn в domain/sul.h). * diff --git a/firmware/tft_app/src/domain/sul/nku_can/src/nku_can.c b/firmware/tft_app/src/domain/sul/nku_can/src/nku_can.c index 63b2c6e..49778eb 100644 --- a/firmware/tft_app/src/domain/sul/nku_can/src/nku_can.c +++ b/firmware/tft_app/src/domain/sul/nku_can/src/nku_can.c @@ -3,15 +3,19 @@ #include #include -/* Адрес станции захардкожен в 0 (Фаза 1/2) — базовые ID без сдвига группы - * (легаси: 0x506|group4, group4 = nku_address<<4; для address=0 group4=0; - * PACKET5 — group6 = nku_address<<6). Фаза 3 параметризует через настройки. */ -#define PACKET1_ID 0x506U /* направление, режимы, начало движения, двери */ -#define PACKET2_ID 0x408U /* перегруз (вариант 1) */ -#define PACKET3_ID 0x508U /* позиция кабины, гонг, временная погрузка */ -#define PACKET4_ID 0x50BU /* перегруз (вариант 2), сейсмоопасность */ -#define PACKET5_ID 0x606U /* следующий этаж */ -#define PROTO_DLC 8U +/* Базовые ID (для адреса станции 0). Реальный ID = base | сдвиг группы адреса: + * PACKET1..4 — group4 = nku_address<<4 (биты [7:4]); PACKET5 — group6 = + * nku_address<<6 (биты [8:6]). Адрес приходит из настроек (Фаза 3.1, + * proto_slice[0]); nku_can_set_address(). Для адреса 0 сдвиг нулевой — поведение + * идентично Фазам 1/2. */ +#define PACKET1_BASE 0x506U /* направление, режимы, начало движения, двери */ +#define PACKET2_BASE 0x408U /* перегруз (вариант 1) */ +#define PACKET3_BASE 0x508U /* позиция кабины, гонг, временная погрузка */ +#define PACKET4_BASE 0x50BU /* перегруз (вариант 2), сейсмоопасность */ +#define PACKET5_BASE 0x606U /* следующий этаж */ +#define PROTO_DLC 8U + +#define NKU_ADDRESS_MAX 15U /* адрес 0..15; group4 = addr<<4 */ #define ARROW_MASK 0x03U /* PACKET1 data[6][1:0] — стрелка */ #define MOVEMENT_MASK 0x0CU /* PACKET1 data[6][3:2] — начало движения */ @@ -57,6 +61,12 @@ void nku_can_init(nku_can_ctx_t *p_ctx) p_ctx->overload_p4 = false; p_ctx->lading_instr = false; p_ctx->current_level = 0U; + p_ctx->nku_address = 0U; /* Фаза 3.1: caller задаёт из настроек через set_address() */ +} + +void nku_can_set_address(nku_can_ctx_t *p_ctx, uint8_t nku_address) +{ + p_ctx->nku_address = (nku_address <= NKU_ADDRESS_MAX) ? nku_address : NKU_ADDRESS_MAX; } /* Пересчёт выходных полей, кормящихся несколькими пакетами (см. nku_can.h). */ @@ -227,56 +237,51 @@ sul_status_t nku_can_decode(void *p_ctx, const sul_frame_t *p_frame, sul_result_ { nku_can_ctx_t *p_state = (nku_can_ctx_t *) p_ctx; - switch (p_frame->id) + /* Сдвиг ID по адресу станции (id пакетов адресно-зависим). */ + const uint32_t G4 = (uint32_t) p_state->nku_address << 4U; + const uint32_t G6 = (uint32_t) p_state->nku_address << 6U; + const uint32_t ID = p_frame->id; + + /* ID известного пакета совпал, но DLC не тот — малформированный кадр. */ + if ((ID == (PACKET1_BASE | G4)) || (ID == (PACKET2_BASE | G4)) || + (ID == (PACKET3_BASE | G4)) || (ID == (PACKET4_BASE | G4)) || + (ID == (PACKET5_BASE | G6))) { - case PACKET1_ID: if (p_frame->len != PROTO_DLC) { return SUL_STATUS_ERR; } + } + + if (ID == (PACKET1_BASE | G4)) + { decode_packet1(p_state, p_frame->p_data); - break; - - case PACKET2_ID: - if (p_frame->len != PROTO_DLC) - { - return SUL_STATUS_ERR; - } + } + else if (ID == (PACKET2_BASE | G4)) + { p_state->overload_p2 = ((p_frame->p_data[7] & WEIGHT_MASK) == WEIGHT_MASK); - break; - - case PACKET3_ID: - if (p_frame->len != PROTO_DLC) - { - return SUL_STATUS_ERR; - } + } + else if (ID == (PACKET3_BASE | G4)) + { if (!decode_packet3(p_state, p_frame->p_data)) { return SUL_STATUS_ERR; } - break; - - case PACKET4_ID: - if (p_frame->len != PROTO_DLC) - { - return SUL_STATUS_ERR; - } + } + else if (ID == (PACKET4_BASE | G4)) + { p_state->overload_p4 = ((p_frame->p_data[5] & WEIGHT_MASK) == WEIGHT_MASK); p_state->state.seismic = ((p_frame->p_data[0] & SEISMIC_MASK) == SEISMIC_MASK); - break; - - case PACKET5_ID: - if (p_frame->len != PROTO_DLC) - { - return SUL_STATUS_ERR; - } + } + else if (ID == (PACKET5_BASE | G6)) + { if (!decode_packet5(p_state, p_frame->p_data)) { return SUL_STATUS_ERR; } - break; - - default: + } + else + { return SUL_STATUS_IGNORED; } diff --git a/firmware/tft_app/src/services/CMakeLists.txt b/firmware/tft_app/src/services/CMakeLists.txt index a48f24a..0698f95 100644 --- a/firmware/tft_app/src/services/CMakeLists.txt +++ b/firmware/tft_app/src/services/CMakeLists.txt @@ -1 +1,3 @@ add_subdirectory(gfx) +add_subdirectory(partition) +add_subdirectory(settings_store) diff --git a/firmware/tft_app/src/services/partition/CMakeLists.txt b/firmware/tft_app/src/services/partition/CMakeLists.txt new file mode 100644 index 0000000..4dd8be9 --- /dev/null +++ b/firmware/tft_app/src/services/partition/CMakeLists.txt @@ -0,0 +1,3 @@ +# Заголовочный модуль карты разделов QSPI (§10) — только константы, без кода. +add_library(tft_app_partition INTERFACE) +target_include_directories(tft_app_partition INTERFACE include/) diff --git a/firmware/tft_app/src/services/partition/include/services/partition.h b/firmware/tft_app/src/services/partition/include/services/partition.h new file mode 100644 index 0000000..31a65ba --- /dev/null +++ b/firmware/tft_app/src/services/partition/include/services/partition.h @@ -0,0 +1,40 @@ +/** + * @file partition.h + * @brief Карта разделов QSPI NOR для tft_app (ARCH.md §10) — единый источник + * смещений для app и (в перспективе) генератора service_tui. + * + * Все смещения — БАЙТОВЫЕ, flash-относительные (0-based), как принимает + * `bsp_qspi_*`. Размер-независимость: критичные регионы (загрузчик, слоты, + * layout, settings) — по фиксированным смещениям; размер-зависим ТОЛЬКО регион + * ассетов — стартует с фикс-адреса и тянется до конца чипа + * (`длина = bsp_qspi_flash_size() - TFT_APP_QSPI_ASSETS_OFFSET`). + * + * Работает на W25Q128/256/512 без пересчёта: меняется лишь длина ассетов. + */ + +#ifndef SERVICES_PARTITION_H_ +#define SERVICES_PARTITION_H_ + +/* ── Фиксированные регионы (размер-независимо) ───────────────────────────── */ + +#define TFT_APP_QSPI_BOOTLOADER_OFFSET 0x000000U +#define TFT_APP_QSPI_BOOTLOADER_SIZE 0x040000U /* 256 КБ */ + +#define TFT_APP_QSPI_SLOT_A_OFFSET 0x040000U +#define TFT_APP_QSPI_SLOT_B_OFFSET 0x240000U +#define TFT_APP_QSPI_SLOT_SIZE 0x200000U /* 2 МБ на слот */ + +#define TFT_APP_QSPI_LAYOUT_OFFSET 0x440000U +#define TFT_APP_QSPI_LAYOUT_SIZE 0x010000U /* 64 КБ (+ client-UX TLV, ярус C) */ + +#define TFT_APP_QSPI_SETTINGS_OFFSET 0x450000U +#define TFT_APP_QSPI_SETTINGS_SIZE 0x002000U /* 8 КБ = 2 сектора: + сектор 0 — рабочий, + сектор 1 — задел под ping-pong */ + +/* ── Размер-зависимый регион ─────────────────────────────────────────────── */ + +#define TFT_APP_QSPI_ASSETS_OFFSET 0x452000U +/* Длина ассетов — рантайм: bsp_qspi_flash_size() - TFT_APP_QSPI_ASSETS_OFFSET. */ + +#endif /* SERVICES_PARTITION_H_ */ diff --git a/firmware/tft_app/src/services/settings_store/CMakeLists.txt b/firmware/tft_app/src/services/settings_store/CMakeLists.txt new file mode 100644 index 0000000..557b0e2 --- /dev/null +++ b/firmware/tft_app/src/services/settings_store/CMakeLists.txt @@ -0,0 +1,17 @@ +# services/settings_store — персист ядра настроек (§8, §10). +# settings_codec.c — чистая сериализация/CRC/дефолты (host-тест); +# settings_store.c — тонкий flash-адаптер поверх bsp_qspi_flash. +add_library( + tft_app_settings_store STATIC + src/settings_store.c + src/settings_codec.c) + +target_include_directories( + tft_app_settings_store + PUBLIC include/ + PRIVATE src/) + +target_link_libraries( + tft_app_settings_store + PUBLIC bsp_qspi_flash # bsp/qspi_flash.h (API + геометрия), bsp/status.h транзитивно + tft_app_partition) # смещения регионов (§10) diff --git a/firmware/tft_app/src/services/settings_store/include/services/settings_store.h b/firmware/tft_app/src/services/settings_store/include/services/settings_store.h new file mode 100644 index 0000000..201056e --- /dev/null +++ b/firmware/tft_app/src/services/settings_store/include/services/settings_store.h @@ -0,0 +1,121 @@ +/** + * @file settings_store.h + * @brief Персистентные настройки индикатора (ARCH.md §8, §10) — ЯДРО. + * + * Хранит **ядро** настроек (ярусы A «железобетонные» + B «протокольные») в + * фиксированном секторе QSPI `0x450000` (§10) с magic/version/CRC32. Клиентский + * UX-«зоопарк» (ярус C: лого, сдвиги/маска этажей, метки) сюда НЕ входит — он в + * TLV рядом с layout-конфигом (§11), добавляется без правок этого ядра. + * + * Ярусы: + * A. Железобетонные (все клиенты): вес/вместимость, громкости, серийник, год. + * B. Протокольные (§8): активный протокол трактует `proto_slice` через + * свой дескриптор `sul_settings_desc_t` (НКУ-CAN: proto_slice[0] = адрес). + * + * Формат/сериализация (magic/version/CRC, дефолты) — чистые функции в + * settings_codec.c, host-тестируются без QSPI. Этот модуль — тонкий flash-адаптер + * поверх них (`bsp_qspi_flash`). + */ + +#ifndef SERVICES_SETTINGS_STORE_H_ +#define SERVICES_SETTINGS_STORE_H_ + +#include "bsp/status.h" + +#include + +#ifdef __cplusplus +extern "C" +{ +#endif + +/* ── Константы ───────────────────────────────────────────────────────────── */ + +#define SETTINGS_SERIAL_LEN 10U /* серийный номер, ASCII + '\0' */ +#define SETTINGS_PROTO_SLICE_LEN 8U /* запас под протокольные параметры (§8) */ +#define SETTINGS_CORE_RESERVED 8U /* задел под будущие ядровые поля A/B */ +#define SETTINGS_VOLUME_LEVELS 5U /* индексы громкости: OFF/25/50/75/100 */ + +/* ── Устройство/провиженинг (меняется редко, не в пользовательском меню) ──── */ + +typedef struct +{ + uint8_t panel_type; /**< bsp_display_type_t; хардкод TFT8, provisioning — Фаза 9 */ + uint8_t protocol_id; /**< активный протокол реестра sul (NKU_CAN = 0) */ + uint8_t log_enabled; /**< рантайм-тумблер логов (под-шаг 3.6) */ + uint8_t _pad; +} settings_device_t; + +/* ── Пользовательские (редактируются в меню на объекте) ──────────────────── */ + +typedef struct +{ + /* Ярус A — железобетонные (хранятся сейчас; редактор/эффект — Фазы 5/6). */ + uint16_t max_load_kg; /**< грузоподъёмность, кг 0..9999 */ + uint8_t max_cap_persons; /**< вместимость, чел 0..99 */ + uint8_t sound_volume_idx; /**< 0..SETTINGS_VOLUME_LEVELS-1 */ + uint8_t music_volume_idx; + uint8_t year_production; /**< 0 = скрыть, иначе 2000+N (25 = 2025) */ + char serial[SETTINGS_SERIAL_LEN]; /**< ASCII, '\0'-терминирован */ + + /* Ярус B — протокольные: активный протокол трактует slice через + sul_settings_desc_t (§8). НКУ-CAN: proto_slice[0] = адрес 0..15. */ + uint8_t proto_slice[SETTINGS_PROTO_SLICE_LEN]; +} settings_user_t; + +/* ── Ядро настроек ───────────────────────────────────────────────────────── */ + +typedef struct +{ + settings_device_t device; + settings_user_t user; + uint8_t _reserved[SETTINGS_CORE_RESERVED]; /**< размер фикс — offset'ы полей стабильны */ +} settings_t; + +/* ── API ─────────────────────────────────────────────────────────────────── */ + +/** + * @brief Инициализировать активные настройки значениями по умолчанию (без QSPI). + * + * Вызывать, когда QSPI недоступен (или до settings_store_load()) — get() после + * этого всегда валиден. settings_store_load() при валидном флеше перезапишет. + */ +void settings_store_init_defaults(void); + +/** + * @brief Загрузить настройки из QSPI (сектор §10). + * + * Предусловие: bsp_qspi_init() уже вызван. Читает сектор, проверяет + * magic/version/CRC32; при любой невалидности — загружает дефолты (настройки + * всё равно валидны). + * + * @retval BSP_OK загружено с флеша. + * @retval BSP_ERR_INVALID magic/version/CRC не совпал — загружены дефолты. + * @retval BSP_ERR_HW ошибка чтения флеша — загружены дефолты. + */ +bsp_status_t settings_store_load(void); + +/** + * @brief Сохранить текущие настройки на QSPI. + * + * Стирает рабочий сектор, пересчитывает CRC32, пишет постранично. Вызывать по + * подтверждению в меню (3.2). Предусловие: bsp_qspi_init(). + * + * @retval BSP_OK / BSP_ERR_HW. + */ +bsp_status_t settings_store_save(void); + +/** @brief Указатель на активные настройки (только чтение). Валиден после init/load. */ +const settings_t *settings_store_get(void); + +/** @brief Указатель на активные настройки для изменения (меню). save() — отдельно. */ +settings_t *settings_store_get_mutable(void); + +/** @brief Сбросить пользовательский ярус к дефолтам (device не трогает). save() — отдельно. */ +void settings_store_reset_user_defaults(void); + +#ifdef __cplusplus +} +#endif + +#endif /* SERVICES_SETTINGS_STORE_H_ */ diff --git a/firmware/tft_app/src/services/settings_store/src/settings_codec.c b/firmware/tft_app/src/services/settings_store/src/settings_codec.c new file mode 100644 index 0000000..915b4df --- /dev/null +++ b/firmware/tft_app/src/services/settings_store/src/settings_codec.c @@ -0,0 +1,81 @@ +#include "settings_codec.h" + +#include + +_Static_assert(sizeof(settings_page_t) == BSP_QSPI_SECTOR_SIZE, + "settings_page_t must be exactly one QSPI sector"); + +/* ── Дефолты ─────────────────────────────────────────────────────────────── */ + +/* panel_type = BSP_DISPLAY_TFT8. В bsp_display_type_t это значение 2 + * (TFT4=0, TFT7=1, TFT8=2); здесь не тянем bsp/display.h в чистый модуль, + * значение фиксируем числом (см. ARCH §9, панель — provisioning Фазы 9). */ +#define SETTINGS_DEFAULT_PANEL 2U /* BSP_DISPLAY_TFT8 */ + +static const settings_t K_DEFAULTS = { + .device = + { + .panel_type = SETTINGS_DEFAULT_PANEL, + .protocol_id = 0U, /* NKU_CAN */ + .log_enabled = 1U, /* bring-up логи из коробки; меню выключит */ + }, + .user = + { + .max_load_kg = 0U, /* «не задано» → скрыто в UI */ + .max_cap_persons = 0U, + .sound_volume_idx = 2U, + .music_volume_idx = 1U, + .year_production = 0U, /* скрыть */ + .serial = "", + .proto_slice = {0}, /* НКУ: адрес 0 */ + }, +}; + +settings_t settings_defaults(void) +{ + return K_DEFAULTS; +} + +/* ── CRC-32 (MSB-first, poly 0x04C11DB7, init 0xFFFFFFFF, без финального xor) ─ */ + +uint32_t settings_crc32(const uint8_t *p_data, size_t len) +{ + uint32_t crc = 0xFFFFFFFFU; + for (size_t i = 0U; i < len; ++i) + { + crc ^= (uint32_t) p_data[i] << 24U; + for (uint8_t b = 0U; b < 8U; ++b) + { + crc = (crc & 0x80000000U) ? ((crc << 1U) ^ 0x04C11DB7U) : (crc << 1U); + } + } + return crc; +} + +/* ── Сериализация ────────────────────────────────────────────────────────── */ + +void settings_serialize(const settings_t *p_in, settings_page_t *p_out) +{ + memset(p_out, 0xFFU, sizeof(*p_out)); /* _pad/_reserved = 0xFF (стёртый флеш) */ + p_out->magic = SETTINGS_MAGIC; + p_out->version = SETTINGS_VERSION; + p_out->data = *p_in; + p_out->crc32 = settings_crc32((const uint8_t *) p_out, sizeof(*p_out) - sizeof(uint32_t)); +} + +bool settings_deserialize(const settings_page_t *p_page, settings_t *p_out) +{ + if ((p_page->magic != SETTINGS_MAGIC) || (p_page->version != SETTINGS_VERSION)) + { + return false; + } + + const uint32_t CRC = settings_crc32((const uint8_t *) p_page, sizeof(*p_page) - sizeof(uint32_t)); + if (CRC != p_page->crc32) + { + return false; + } + + *p_out = p_page->data; + return true; +} diff --git a/firmware/tft_app/src/services/settings_store/src/settings_codec.h b/firmware/tft_app/src/services/settings_store/src/settings_codec.h new file mode 100644 index 0000000..8744bfb --- /dev/null +++ b/firmware/tft_app/src/services/settings_store/src/settings_codec.h @@ -0,0 +1,58 @@ +/** + * @file settings_codec.h + * @brief Чистая сериализация/валидация ядра настроек (без QSPI) — host-тест. + * + * Раскладка страницы на флеше, magic/version/CRC32 и дефолты. Отделено от + * flash-адаптера (settings_store.c), чтобы тестировать формат на хосте без + * железа (по образцу доменных чистых модулей). + */ + +#ifndef SETTINGS_CODEC_H_ +#define SETTINGS_CODEC_H_ + +#include "bsp/qspi_flash.h" /* BSP_QSPI_SECTOR_SIZE */ +#include "services/settings_store.h" + +#include +#include +#include + +#define SETTINGS_MAGIC 0x54465453U /* 'STFT' (little-endian) */ +#define SETTINGS_VERSION 1U + +/** + * @brief Страница настроек — ровно один сектор QSPI. + * + * CRC32 считается по всем байтам страницы КРОМЕ самого поля crc32. + * _pad и _reserved при сохранении = 0xFF (стёртый флеш). + */ +typedef struct +{ + uint32_t magic; + uint8_t version; + uint8_t _pad[3]; + settings_t data; + uint8_t _reserved[BSP_QSPI_SECTOR_SIZE - sizeof(uint32_t) /* magic */ + - sizeof(uint8_t) /* version */ + - 3U /* _pad */ + - sizeof(settings_t) - sizeof(uint32_t)]; /* crc32 */ + uint32_t crc32; +} settings_page_t; + +/** @brief Настройки по умолчанию (при пустом/битом флеше). */ +settings_t settings_defaults(void); + +/** @brief Собрать страницу из настроек: magic/version/data/reserved(0xFF)/crc32. */ +void settings_serialize(const settings_t *p_in, settings_page_t *p_out); + +/** + * @brief Проверить страницу и извлечь настройки. + * @return true — magic/version/CRC валидны, *p_out заполнен; false — иначе + * (*p_out не тронут; caller подставляет дефолты). + */ +bool settings_deserialize(const settings_page_t *p_page, settings_t *p_out); + +/** @brief CRC-32 (poly 0x04C11DB7, MSB-first, init 0xFFFFFFFF) — как в style_updater. */ +uint32_t settings_crc32(const uint8_t *p_data, size_t len); + +#endif /* SETTINGS_CODEC_H_ */ 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 new file mode 100644 index 0000000..4369052 --- /dev/null +++ b/firmware/tft_app/src/services/settings_store/src/settings_store.c @@ -0,0 +1,73 @@ +#include "services/settings_store.h" + +#include "bsp/qspi_flash.h" +#include "services/partition.h" +#include "settings_codec.h" + +/* Активные настройки — единственный экземпляр модуля. */ +static settings_t g_settings; + +void settings_store_init_defaults(void) +{ + g_settings = settings_defaults(); +} + +bsp_status_t settings_store_load(void) +{ + settings_page_t page; + + if (bsp_qspi_read(TFT_APP_QSPI_SETTINGS_OFFSET, (uint8_t *) &page, sizeof(page)) != BSP_OK) + { + g_settings = settings_defaults(); + return BSP_ERR_HW; + } + + if (!settings_deserialize(&page, &g_settings)) + { + g_settings = settings_defaults(); + return BSP_ERR_INVALID; + } + + return BSP_OK; +} + +bsp_status_t settings_store_save(void) +{ + /* static: не 4 КБ на стеке + гарантия выравнивания под постраничную запись. */ + static settings_page_t s_page; + settings_serialize(&g_settings, &s_page); + + if (bsp_qspi_erase_sector(TFT_APP_QSPI_SETTINGS_OFFSET) != BSP_OK) + { + return BSP_ERR_HW; + } + + const uint32_t PAGES = BSP_QSPI_SECTOR_SIZE / BSP_QSPI_PAGE_SIZE; + for (uint32_t i = 0U; i < PAGES; ++i) + { + const uint32_t ADDR = TFT_APP_QSPI_SETTINGS_OFFSET + i * BSP_QSPI_PAGE_SIZE; + const uint8_t *p_page_src = (const uint8_t *) &s_page + i * BSP_QSPI_PAGE_SIZE; + if (bsp_qspi_write_page(ADDR, p_page_src) != BSP_OK) + { + return BSP_ERR_HW; + } + } + + return BSP_OK; +} + +const settings_t *settings_store_get(void) +{ + return &g_settings; +} + +settings_t *settings_store_get_mutable(void) +{ + return &g_settings; +} + +void settings_store_reset_user_defaults(void) +{ + const settings_t DEFAULTS = settings_defaults(); + g_settings.user = DEFAULTS.user; +} diff --git a/tests/host/CMakeLists.txt b/tests/host/CMakeLists.txt index db97424..0314db5 100644 --- a/tests/host/CMakeLists.txt +++ b/tests/host/CMakeLists.txt @@ -352,3 +352,18 @@ add_host_test( INCLUDES ${TFT_APP_DOMAIN_DIR}/elevator_model/include ${TFT_APP_DOMAIN_DIR}/controller/include) + +# tft_app — settings_store (Фаза 3.1: чистый кодек настроек magic/version/CRC) +set(TFT_APP_SETTINGS_DIR ${PROJECT_SOURCE_DIR}/firmware/tft_app/src/services/settings_store) + +add_host_test( + NAME + test_tft_app_settings_store + SOURCES + tft_app_settings_store/test_settings_codec.c + ${TFT_APP_SETTINGS_DIR}/src/settings_codec.c + INCLUDES + ${TFT_APP_SETTINGS_DIR}/include + ${TFT_APP_SETTINGS_DIR}/src + ${PROJECT_SOURCE_DIR}/bsp/qspi_flash/include + ${PROJECT_SOURCE_DIR}/bsp/common/include) diff --git a/tests/host/tft_app_settings_store/test_settings_codec.c b/tests/host/tft_app_settings_store/test_settings_codec.c new file mode 100644 index 0000000..733e72e --- /dev/null +++ b/tests/host/tft_app_settings_store/test_settings_codec.c @@ -0,0 +1,129 @@ +/** + * @file test_settings_codec.c + * @brief Host-тесты чистой сериализации настроек (settings_codec.c) — без QSPI. + * + * Формат страницы (magic/version/CRC32), дефолты, round-trip, отбраковка + * битых страниц. Flash-адаптер (settings_store.c) тестируется на железе. + */ + +#include "settings_codec.h" +#include "unity.h" + +#include +#include + +void setUp(void) +{ +} +void tearDown(void) +{ +} + +/* Страница обязана быть ровно одним сектором (дублирует _Static_assert в .c). */ +static void test_page_is_one_sector(void) +{ + TEST_ASSERT_EQUAL_UINT(BSP_QSPI_SECTOR_SIZE, sizeof(settings_page_t)); +} + +static void test_defaults_sane(void) +{ + const settings_t D = settings_defaults(); + TEST_ASSERT_EQUAL_UINT8(0U, D.device.protocol_id); /* NKU_CAN */ + TEST_ASSERT_EQUAL_UINT8(1U, D.device.log_enabled); /* логи вкл из коробки */ + TEST_ASSERT_EQUAL_UINT8(2U, D.user.sound_volume_idx); + TEST_ASSERT_EQUAL_UINT8(1U, D.user.music_volume_idx); + TEST_ASSERT_EQUAL_UINT16(0U, D.user.max_load_kg); + TEST_ASSERT_EQUAL_UINT8(0U, D.user.proto_slice[0]); /* адрес НКУ = 0 */ +} + +static void test_serialize_sets_magic_version(void) +{ + settings_t in = settings_defaults(); + settings_page_t page; + settings_serialize(&in, &page); + TEST_ASSERT_EQUAL_HEX32(SETTINGS_MAGIC, page.magic); + TEST_ASSERT_EQUAL_UINT8(SETTINGS_VERSION, page.version); +} + +static void test_roundtrip_preserves_fields(void) +{ + settings_t in = settings_defaults(); + in.user.max_load_kg = 1000U; + in.user.max_cap_persons = 8U; + in.user.year_production = 25U; + in.user.proto_slice[0] = 7U; /* адрес станции 7 */ + in.device.log_enabled = 0U; + (void) snprintf(in.user.serial, SETTINGS_SERIAL_LEN, "AB1234"); + + settings_page_t page; + settings_serialize(&in, &page); + + settings_t out; + TEST_ASSERT_TRUE(settings_deserialize(&page, &out)); + TEST_ASSERT_EQUAL_UINT16(1000U, out.user.max_load_kg); + TEST_ASSERT_EQUAL_UINT8(8U, out.user.max_cap_persons); + TEST_ASSERT_EQUAL_UINT8(25U, out.user.year_production); + TEST_ASSERT_EQUAL_UINT8(7U, out.user.proto_slice[0]); + TEST_ASSERT_EQUAL_UINT8(0U, out.device.log_enabled); + TEST_ASSERT_EQUAL_STRING("AB1234", out.user.serial); +} + +static void test_bad_magic_rejected(void) +{ + settings_t in = settings_defaults(); + settings_page_t page; + settings_serialize(&in, &page); + page.magic ^= 0xFFFFFFFFU; + + settings_t out; + TEST_ASSERT_FALSE(settings_deserialize(&page, &out)); +} + +static void test_bad_version_rejected(void) +{ + settings_t in = settings_defaults(); + settings_page_t page; + settings_serialize(&in, &page); + page.version = (uint8_t) (SETTINGS_VERSION + 1U); + + settings_t out; + TEST_ASSERT_FALSE(settings_deserialize(&page, &out)); +} + +/* Магик/версия валидны, но байт данных испорчен → расходится только CRC. */ +static void test_bad_crc_rejected(void) +{ + settings_t in = settings_defaults(); + settings_page_t page; + settings_serialize(&in, &page); + page.data.user.max_cap_persons ^= 0x01U; + + settings_t out; + TEST_ASSERT_FALSE(settings_deserialize(&page, &out)); +} + +/* Свежестёртый флеш (весь 0xFF) — невалиден (магик != 'STFT') → дефолты. */ +static void test_erased_page_is_invalid(void) +{ + settings_page_t page; + memset(&page, 0xFFU, sizeof(page)); + + settings_t out; + TEST_ASSERT_FALSE(settings_deserialize(&page, &out)); +} + +int main(void) +{ + UNITY_BEGIN(); + + RUN_TEST(test_page_is_one_sector); + RUN_TEST(test_defaults_sane); + RUN_TEST(test_serialize_sets_magic_version); + RUN_TEST(test_roundtrip_preserves_fields); + RUN_TEST(test_bad_magic_rejected); + RUN_TEST(test_bad_version_rejected); + RUN_TEST(test_bad_crc_rejected); + RUN_TEST(test_erased_page_is_invalid); + + return UNITY_END(); +} diff --git a/tests/host/tft_app_sul_nku/test_sul_nku_can.c b/tests/host/tft_app_sul_nku/test_sul_nku_can.c index 4c305e7..f02e4ef 100644 --- a/tests/host/tft_app_sul_nku/test_sul_nku_can.c +++ b/tests/host/tft_app_sul_nku/test_sul_nku_can.c @@ -577,6 +577,45 @@ static void test_wrong_dlc_on_packet2_4_5_is_error(void) TEST_ASSERT_EQUAL(SUL_STATUS_ERR, nku_can_decode(&ctx, &f5, &out)); } +/* ── Адрес станции (Фаза 3.1): сдвиг ID пакетов ──────────────────────────── */ + +/* Адрес N сдвигает ID: PACKET1 = 0x506 | (N<<4). Базовый ID адреса 0 при этом + * становится чужим (IGNORED). */ +static void test_address_shifts_packet_ids(void) +{ + nku_can_ctx_t ctx; + nku_can_init(&ctx); + nku_can_set_address(&ctx, 1U); /* group4 = 0x10 */ + sul_result_t out; + + uint8_t data[8] = { 0 }; + data[6] = 1U; /* стрелка вверх */ + + const sul_frame_t shifted = { .id = 0x506U | 0x10U, .bus = 0, .p_data = data, .len = 8U }; + TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &shifted, &out)); + TEST_ASSERT_EQUAL(SUL_DIR_UP, out.direction); + + /* Базовый ID адреса 0 теперь не наш. */ + const sul_frame_t base = { .id = 0x506U, .bus = 0, .p_data = data, .len = 8U }; + TEST_ASSERT_EQUAL(SUL_STATUS_IGNORED, nku_can_decode(&ctx, &base, &out)); +} + +/* Адрес > 15 клампится к 15 (не выходит за 4-битный group4). */ +static void test_address_clamped_to_max(void) +{ + nku_can_ctx_t ctx; + nku_can_init(&ctx); + nku_can_set_address(&ctx, 200U); /* клампится к 15 → group4 = 0xF0 */ + sul_result_t out; + + uint8_t data[8] = { 0 }; + data[6] = 2U; /* вниз */ + + const sul_frame_t f = { .id = 0x506U | 0xF0U, .bus = 0, .p_data = data, .len = 8U }; + TEST_ASSERT_EQUAL(SUL_STATUS_OK, nku_can_decode(&ctx, &f, &out)); + TEST_ASSERT_EQUAL(SUL_DIR_DOWN, out.direction); +} + int main(void) { UNITY_BEGIN(); @@ -624,5 +663,8 @@ int main(void) RUN_TEST(test_wrong_dlc_on_packet2_4_5_is_error); + RUN_TEST(test_address_shifts_packet_ids); + RUN_TEST(test_address_clamped_to_max); + return UNITY_END(); }