# tft_app: Phase 3.1

This commit is contained in:
Dmitry Akimov 2026-07-21 16:46:41 +03:00
parent 0482c08272
commit 50043bf46a
17 changed files with 734 additions and 67 deletions

View file

@ -200,19 +200,33 @@ typedef struct {
(пишется service_tui), не пользовательская настройка. Матрица сборки — два buildPreset
(`app-tft4`, `app-big`), как `bootloader`/`firmware-test`.
### 10. Карта QSPI (W25Q128, 16 МБ)
### 10. Карта QSPI (размер-независимая)
Прошивка работает на разных QSPI NOR (W25Q128 16 МБ / 256 / 512 …). Поэтому **все
критичные регионы — по фиксированным смещениям** (компайл-тайм, без рантайм-детекта), а
**размер-зависим только регион ассетов**: он стартует с фиксированного адреса и тянется до
конца чипа (`длина = ёмкость assets_start`; ёмкость — по JEDEC-ID).
| Регион | Смещение | Размер | Владелец |
| ----------------- | ---------- | ------- | ---------- |
| ----------------- | ---------- | ----------------- | ---------- |
| 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 |
| **settings** | `0x450000` | 8 КБ (2 сектора) | tft_app |
| **assets-регион** | `0x452000` | остаток до конца | tft_app |
layout- и assets-регионы — **вне** flash-area загрузчика (bootutil про них не знает). Разметка
фиксируется в **едином partition-заголовке**, из которого читают app и генератор для service_tui.
- **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

View file

@ -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

View file

@ -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, размер-независимость). По паттерну «доки по реализованному».
---

View file

@ -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);

View file

@ -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).
*

View file

@ -3,16 +3,20 @@
#include <stdbool.h>
#include <stdio.h>
/* Адрес станции захардкожен в 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 /* следующий этаж */
/* Базовые 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] — начало движения */
#define ICON_MASK 0xF0U /* PACKET1 data[6][7:4] — код режима */
@ -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;
}

View file

@ -1 +1,3 @@
add_subdirectory(gfx)
add_subdirectory(partition)
add_subdirectory(settings_store)

View file

@ -0,0 +1,3 @@
# Заголовочный модуль карты разделов QSPI (§10) — только константы, без кода.
add_library(tft_app_partition INTERFACE)
target_include_directories(tft_app_partition INTERFACE include/)

View file

@ -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_ */

View file

@ -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)

View file

@ -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 <stdint.h>
#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_ */

View file

@ -0,0 +1,81 @@
#include "settings_codec.h"
#include <string.h>
_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;
}

View file

@ -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 <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#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_ */

View file

@ -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;
}

View file

@ -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)

View file

@ -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 <stdio.h>
#include <string.h>
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();
}

View file

@ -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();
}