# tft_app: Phase 3.4 NKU Remote, Opto Inputs

This commit is contained in:
Dmitry Akimov 2026-07-23 14:53:42 +03:00
parent 958fd9d5e5
commit 8c72be8708
53 changed files with 8739 additions and 251 deletions

View file

@ -172,7 +172,10 @@
"test_sul_nku_can",
"test_tft_app_controller",
"test_tft_app_settings_store",
"test_tft_app_menu"
"test_tft_app_menu",
"test_sul_registry",
"test_tft_app_menu_tree",
"test_sul_demo"
]
},
{
@ -200,7 +203,10 @@
"test_sul_nku_can",
"test_tft_app_controller",
"test_tft_app_settings_store",
"test_tft_app_menu"
"test_tft_app_menu",
"test_sul_registry",
"test_tft_app_menu_tree",
"test_sul_demo"
]
},
{

View file

@ -0,0 +1,341 @@
# tft-app — как добавить протокол СУЛ
Пошаговое руководство по добавлению нового протокола в реестр `sul` (ARCH.md §6, §8). Разбор —
на двух реальных драйверах: [nku_can](../../firmware/tft_app/src/domain/sul/nku_can/) (реальная
шина, CAN) и [demo](../../firmware/tft_app/src/domain/sul/demo/) (синтетический источник) —
ссылки на конкретные файлы вместо абстрактных описаний.
Проектная основа — [ARCH.md §6, §8](../../firmware/tft_app/ARCH.md); путь данных decode()→экран —
[DOMAIN_DATAFLOW.md](DOMAIN_DATAFLOW.md); меню/настройки — [MENU.md](MENU.md). Здесь — «что
конкретно создать и куда положить», по опыту Фазы 3.3 (первый протокол, добавленный ПОСЛЕ того,
как дескрипторный механизм и реестр перестали быть однопротокольными) и Фазы 3.5 (удалённая
адресация НКУ-CAN — первый случай, когда протокол сам инициирует запись в settings).
---
## 1. Что строим — 5 частей (2 опциональны)
```mermaid
flowchart TB
subgraph NEW["новое, на каждый протокол"]
DEC["decode() + ctx<br/>чистый C, host-тест"]
DESC["sul_settings_desc_t<br/>параметры для меню (опц.)"]
PW["take_pending_write<br/>протокол сам пишет settings (опц.)"]
TR["transport<br/>HW-адаптер ИЛИ синтетика"]
end
subgraph SHARED["правится точечно"]
REG["запись в sul_registry.c<br/>id + decode + p_settings + p_ctx + take_pending_write"]
RX["ветка в task_sul_rx.c<br/>только если НОВЫЙ вид транспорта"]
end
DEC --> REG
DESC --> REG
PW --> REG
TR --> RX
REG --> RX
```
| Часть | Обязательна? | НКУ-CAN | Демо |
| --- | --- | --- | --- |
| **decode() + ctx** | да, всегда | [nku_can.c](../../firmware/tft_app/src/domain/sul/nku_can/src/nku_can.c) | [demo.c](../../firmware/tft_app/src/domain/sul/demo/src/demo.c) |
| **connection_timeout_ms** | да, всегда (см. §5) — но может быть `DISABLED` | `3000` мс | `SUL_CONNECTION_TIMEOUT_DISABLED` |
| **sul_settings_desc_t** | нет — только если есть настраиваемый параметр в меню | адрес 0..15 (BYTE) | скорость (SELECT) |
| **take_pending_write** | нет — только если протокол сам инициирует запись (не через меню) | удалённая адресация (§3.5) | нет |
| **transport** | да, но может переиспользовать уже подключённый вид (см. §6) | `transport/can` — реальная шина | `transport/demo` — пустышка, кадр не несёт содержимого |
| **ветка в `task_sul_rx.c`** | только если transport — новый ВИД (не переиспользует уже подключённый) | уже была (Фаза 1) | добавлена в Фазе 3.3 |
**Важное разграничение** (легко перепутать): `sul_settings_desc_t` — это ПОЛЬЗОВАТЕЛЬ редактирует
значение в меню; `take_pending_write`САМ ПРОТОКОЛ решает записать значение (по команде с шины,
без участия пользователя). Оба пишут в один и тот же `proto_slice[]` (§8), но с разных сторон.
**`connection_timeout_ms` — НЕ пользовательская настройка.** Реальный период отправки у станции
(раз в ~200 мс, раз в ~1 с — по семействам сильно разное) — знание протокола, оператор его не
знает и не должен настраивать; задаётся жёстко здесь, при регистрации. Если протокол шлёт кадры
ТОЛЬКО по изменению состояния на станции (event-driven, не периодически) — детекция обрыва по
тишине для него в принципе некорректна, ставить `SUL_CONNECTION_TIMEOUT_DISABLED` (0): таймаут в
`task_sul_rx.c` для этого протокола выключается целиком, «--» по тишине не появится никогда.
---
## 2. Шаг 1 — decode() + ctx (чистый C, домен)
Живёт в `domain/sul/<protocol>/`:
- `include/domain/sul/<protocol>.h` — публичный контракт: тип ctx, `<protocol>_init()`, decode(),
и любые `<protocol>_set_xxx()` для параметров, которые двигает app-слой (см. §4, §7).
- `src/<protocol>.c` — реализация. **Ни одного HAL-вызова** — весь ввод только через
`sul_frame_t*`, весь вывод только через `sul_result_t*`.
Контракт ([sul.h](../../firmware/tft_app/src/domain/sul/include/domain/sul.h)):
```c
typedef sul_status_t (*sul_decode_fn_t)(void *p_ctx, const sul_frame_t *p_frame, sul_result_t *p_out);
```
- `p_ctx` — состояние протокола между кадрами (напр. позиция, накапливаемая между пакетами у
НКУ-CAN). **Владеет caller** — в итоге `sul_registry.c` (см. §4), decode() его не создаёт и не
освобождает.
- Возврат: `SUL_STATUS_OK` (кадр распознан, `*p_out` = полная накопленная `sul_result_t`, НЕ
дельта) / `SUL_STATUS_IGNORED` (кадр не для этого протокола) / `SUL_STATUS_ERR` (ID совпал, но
кадр малформирован).
- Протокол может быть **decode-less** (нет настоящего кадра, как демо) — `p_frame` тогда можно
игнорировать целиком; сам факт вызова decode() трактуется как «тик» (см.
[demo.c](../../firmware/tft_app/src/domain/sul/demo/src/demo.c) — темп ведёт счётчик в ctx, а
не содержимое кадра).
- Если у протокола есть сколько-нибудь большая ТАБЛИЦА-ДАННЫЕ (карта символов, скриптованный
маршрут и т.п.) — выносить в свой файл рядом (напр. `demo_route.h/.c`), не смешивать с
decode()-логикой: правка данных не должна требовать вычитывать логику декодера.
**Host-тест обязателен** — golden-векторы кадров → ожидаемый `sul_result_t`
(`tests/host/tft_app_sul_<protocol>/`), по образцу `test_sul_nku_can.c`/`test_sul_demo.c`.
---
## 3. Шаг 2 — sul_settings_desc_t (только если есть параметр в меню)
Нужен, если у протокола есть значение, редактируемое ПОЛЬЗОВАТЕЛЕМ в меню (адрес, скорость,
канал…). Живёт **в `sul_registry.c`**, не в модуле протокола — реестр уже знает про меню-факинг
метаданные (`p_name` там же):
```c
static const sul_settings_entry_t K_<PROTO>_SETTINGS_ENTRIES[] = {
{ .p_label = "...", .type = SUL_SETTINGS_BYTE /* | _SELECT | _BOOL */,
.slice_offset = 0U, .min = ..., .max = ..., .p_options = NULL /* или массив меток */ },
};
static const sul_settings_desc_t K_<PROTO>_SETTINGS = {
.p_entries = K_<PROTO>_SETTINGS_ENTRIES,
.count = sizeof(K_<PROTO>_SETTINGS_ENTRIES) / sizeof(K_<PROTO>_SETTINGS_ENTRIES[0]),
};
```
- `slice_offset` — смещение ВНУТРИ `settings_t.user.proto_slice[]` (0..`SETTINGS_PROTO_SLICE_LEN`-1
из `settings_store.h`), НЕ внутри всего `settings_t` — домен (реестр) не включает
`settings_store.h`; реальный `offsetof()` считает `menu/menu_tree.c` (единственный слой, знающий
оба типа).
- **Сейчас движок меню рассчитан ровно на 1 параметр на протокол** (`menu_tree.c`,
`T_PROTO_PARAM` — один зарезервированный слот). `count > 1` пока физически не отрисуется —
используется только `p_entries[0]`. Протоколу с несколькими параметрами понадобится сначала
расширить `menu_tree.c` (несколько слотов + скрытие неиспользуемых для протоколов с меньшим
числом параметров) — сознательно не сделано заранее (YAGNI, см. PLAN.md Фаза 3.3).
- Нет ни одного параметра — не создавать дескриптор, оставить `.p_settings = NULL` у драйвера.
---
## 4. Шаг 3 — take_pending_write (только если протокол сам пишет settings)
Нужен, если протокол получает команду **с шины** (не от пользователя через меню), которая должна
записать значение в его же `proto_slice[]` — напр. удалённая установка адреса у НКУ-CAN (§3.5,
станция объявляет адрес кадром `0x4X1` + командой `0x5XB`, см.
[nku_can.c](../../firmware/tft_app/src/domain/sul/nku_can/src/nku_can.c)).
**Правило, ради которого этот механизм вообще существует:** decode() и логика распознавания
команды — чистые, **не трогают `settings_store` напрямую** (домен не пишет настройки, ARCH §1).
Вместо прямой записи — отдельный generic-канал:
```c
/* sul.h */
typedef struct { uint8_t slice_offset; uint8_t value; } sul_slice_write_t;
typedef bool (*sul_take_pending_write_fn_t)(void *p_ctx, sul_slice_write_t *p_out);
```
Реализация в модуле протокола — чистая функция, читает НАКОПЛЕННОЕ состояние ctx после последнего
decode(), настройки не трогает вообще:
```c
bool <protocol>_take_pending_write(void *p_ctx, sul_slice_write_t *p_out)
{
const <protocol>_ctx_t *p_state = (const <protocol>_ctx_t *) p_ctx;
if (/* нет запроса в этом ctx */) { return false; }
p_out->slice_offset = ...;
p_out->value = ...;
return true;
}
```
Регистрируется как поле `sul_driver_t.take_pending_write` (§5) — `NULL`, если протокол никогда
этого не делает (подавляющее большинство). **`task_sul_rx.c` полностью generic** — ветки по
id/протоколу для этого НЕТ, только `if (p_driver->take_pending_write != NULL)`. Идемпотентность
(писать только если значение реально отличается от сохранённого) — тоже generic, живёт в
`task_sul_rx.c`, а не в каждом протоколе отдельно: протокол просто говорит «вот что нужно
записать», сравнение с текущим — не его забота.
**Как отличить от `sul_settings_desc_t` (§3):** дескриптор — это ЧТО МОЖНО отредактировать в
меню; `take_pending_write` — это протокол САМ РЕШИЛ записать (пользователь не участвует). У
НКУ-CAN есть оба одновременно на **один и тот же** `proto_slice[0]` (адрес можно и вручную в
меню, и удалённо с шины) — это два независимых входа в одно и то же поле, не конфликт.
---
## 5. Шаг 4 — реестр (`sul_registry.c`)
Правки в одном файле:
1. `#include "domain/sul/<protocol>.h"`.
2. Добавить id в `enum` ([sul.h](../../firmware/tft_app/src/domain/sul/include/domain/sul.h)) —
**стабильный, не переиспользовать** уже выданные значения.
3. Статический ctx + запись в `s_registry[]`:
```c
static <protocol>_ctx_t s_<protocol>_ctx;
static const sul_driver_t s_registry[] = {
/* ...существующие... */
{
.id = SUL_PROTOCOL_<PROTO>,
.p_name = "<как в меню>",
.decode = <protocol>_decode,
.p_settings = &K_<PROTO>_SETTINGS, /* или не указывать = NULL */
.p_ctx = &s_<protocol>_ctx,
.take_pending_write = <protocol>_take_pending_write, /* или не указывать = NULL */
.connection_timeout_ms = <мс> /* или SUL_CONNECTION_TIMEOUT_DISABLED — ОБЯЗАТЕЛЬНО, не забыть! */,
},
};
```
`connection_timeout_ms` легко забыть — designated-initializer молча зануляет пропущенное поле в
`SUL_CONNECTION_TIMEOUT_DISABLED` (0), т.е. протокол ТИХО никогда не покажет «--» при обрыве
связи вместо явной ошибки сборки. Сверяйтесь с этим полем при код-ревью нового протокола.
4. `sul_registry_init()` — добавить `<protocol>_init(&s_<protocol>_ctx);`.
**Ctx живёт постоянно**, не пересоздаётся при переключении активного протокола (`sul_registry_
set_active()` только меняет, какая запись реестра активна — все ctx проинициализированы заранее).
Дальше **само меню не трогается**: `menu_tree_refresh_protocol_section()` подхватывает новый
протокол из реестра автоматически (имя в списке выбора, диапазон, параметр из дескриптора) —
см. [MENU.md §4](MENU.md).
---
## 6. Шаг 5 — transport (только если новый вид шины)
**Архитектурное решение, найденное на демо-протоколе (Фаза 3.3):** transport **не входит** в
`sul_driver_t`/реестр. Если бы входил, `tft_app_sul` пришлось бы линковать `bsp_can`/`bsp_uart` и
т.п., ломая host-тестируемость реестра без железа (реестр и декодеры — чистый C, транспорт —
HW/HIL). Transport остаётся **wiring'ом app-слоя** (`task_sul_rx.c`) — тонкий HW-адаптер
`sul_frame_t` живёт в `domain/sul/transport/<bus>/` (собственная CMake-библиотека, не
host-тестируется), но **вызывается только из `task_sul_rx.c`**, не из `sul_registry.c`.
Отсюда развилка:
- **Протокол на уже подключённом виде шины** (напр. второй CAN-протокол) — новый transport не
нужен; `task_sul_rx.c` может не тронуться вообще, если оба протокола используют один и тот же
`sul_transport_can_receive()`. Если конкретному протоколу всё же нужна другая
инициализация/фильтры — по образцу ветки НКУ-CAN (реаппликация параметров внутри существующей
ветки, см. §7).
- **Протокол на новом виде шины** (напр. первый UART-протокол Фазы 8 — УИМ/SD7/УЭЛ/УКЛ,
большинство из них не CAN, см. ARCH §14) — создать `domain/sul/transport/<bus>/` (по образцу
`transport/can`/`transport/demo`) **и** добавить ветку в `task_sul_rx.c` (§7). Второй и
последующие протоколы на ТОМ ЖЕ новом виде шины эту ветку уже не трогают.
**Готча: HW-фильтры — слепая зона host-тестов** (боевая, Фаза 3.5). Если decode() должен видеть
кадры ВНЕ основного набора ID протокола (широковещательные команды, кадры с чужим адресом — как
удалённая адресация НКУ-CAN, `0x4X1`/`0x5XB` с любым X), под них нужны СВОИ фильтры в транспорте
(wildcard-маска / отдельные MB — см. [can_transport.c](../../firmware/tft_app/src/domain/sul/transport/can/src/can_transport.c),
MB 5/6). Точные фильтры под «свои» ID отбрасывают такие кадры аппаратно, и **host-тесты декодера
этого не поймают** — они кормят decode() напрямую, мимо HW-фильтров: всё зелёное, на железе —
ноль реакции. Проверяйте соответствие «какие кадры decode() ОЖИДАЕТ увидеть» ↔ «какие кадры
фильтры транспорта ПРОПУСКАЮТ» глазами, при код-ревью транспорта.
---
## 7. Шаг 6 — диспетчеризация в `task_sul_rx.c`
Только если шаг 6 завёл новый вид транспорта. Это **единственное** оставшееся место, которое
знает про конкретные протоколы/транспорты — везде остальное протокол-агностично (в т.ч.
`take_pending_write`, §4 — тот генерик, ветки по id для него уже НЕТ):
```c
const sul_driver_t *p_driver = sul_registry_active();
sul_frame_t frame;
bsp_status_t rx_rc;
if (p_driver->id == SUL_PROTOCOL_NKU_CAN)
{
/* существующая ветка: адрес из настроек -> ctx И CAN-фильтры, sul_transport_can_receive() */
}
else if (p_driver->id == SUL_PROTOCOL_<НОВЫЙ>)
{
/* реаппликация параметров протокола (если есть, из proto_slice[0]) + приём с нового transport */
}
else /* демо и т.д. — оставшиеся протоколы без своей ветки */
{
rx_rc = sul_transport_demo_receive(CAN_RX_TIMEOUT_MS, &frame);
}
if (rx_rc == BSP_OK)
{
if (p_driver->decode(p_driver->p_ctx, &frame, &decoded) == SUL_STATUS_OK) { /* ... */ }
/* Generic, без ветки по id — см. §4. */
if (p_driver->take_pending_write != NULL)
{
sul_slice_write_t write;
if (p_driver->take_pending_write(p_driver->p_ctx, &write)) { /* ...идемпотентная запись... */ }
}
}
```
`p_driver->p_ctx` уже правильного типа для активного протокола — просто скастовать
(`(protocol_ctx_t *) p_driver->p_ctx`), диспетчеризация по типу ctx отдельно писать не нужно.
---
## 8. Шаг 7 — CMake
- `domain/sul/<protocol>/CMakeLists.txt``add_library(tft_app_sul_<protocol> STATIC src/<protocol>.c ...)`,
линкует `tft_app_elevator_model` + `tft_app_sul_headers` (**не** `tft_app_sul` — реестр сам
линкует протокол, обратная зависимость была бы циклической).
- `domain/sul/CMakeLists.txt``add_subdirectory(<protocol>)`, линкует `tft_app_sul_<protocol>` в
`tft_app_sul`.
- Если завели новый transport (§6) — свой `domain/sul/transport/<bus>/CMakeLists.txt`
(`tft_app_sul_transport_<bus>`, линкует `tft_app_sul_headers` + нужный `bsp_*`/`bsp_status`), и
явная зависимость `app``tft_app_sul_transport_<bus>` в `firmware/tft_app/CMakeLists.txt`
(транспорт НЕ идёт транзитивно через `tft_app_sul`, см. §6).
---
## 9. Шаг 8 — тесты
- **Декодер**`tests/host/tft_app_sul_<protocol>/test_sul_<protocol>.c`, golden-векторы (+ тесты
на `take_pending_write()`, если есть, §4). Зарегистрировать `add_host_test(...)` в
`tests/host/CMakeLists.txt` **и** добавить имя таргета в список `targets` ОБОИХ build-пресетов
`host-debug-build`/`host-release-build` в `CMakePresets.json` — иначе `ctest` не найдёт
исполняемый файл (`Not Run`, а не `FAIL`, легко пропустить); наступили на эти грабли в Фазе 3.3
(см. PLAN.md).
- **Реестр** — расширить `tests/host/tft_app_sul_registry/test_sul_registry.c`: новый протокол
находится по id, `sul_registry_count()` вырос, дескриптор (если есть) присутствует и содержит
ожидаемое, `take_pending_write``NULL`/не-`NULL` как задумано.
- **Меню** — обычно ничего менять не нужно в `test_tft_app_menu_tree.c` (он уже проверяет
протокол-агностичность механизма); добавить кейс только если новый протокол — интересный
крайний случай (напр. 0 параметров, ещё не встречалось ни у одного зарегистрированного
протокола).
---
## 10. Чего НЕ нужно трогать
Это и есть проверка того, что дескрипторный принцип (ARCH §8) реально работает, а не только на
бумаге:
- **Меню** (`menu_tree.c`, `menu.c`) — секция «Протокол» строится из дескриптора автоматически.
- **Controller / mode_priority** — работают с каноническим `sul_result_t`, протокол им не виден
вообще (см. [MODE_PRIORITY.md §5](MODE_PRIORITY.md)).
- **UI/fallback, render_task** — читают `indication_task_t`, о протоколах не знают.
- **Settings store**`proto_slice[]` уже общий, под любой протокол (по одному активному за раз).
- **`task_sul_rx.c` для `take_pending_write`** (§4/§7) — generic, ветка по id нужна ТОЛЬКО для
транспорта (§6/§7), не для записи settings.
Если правка одного из этих слоёв кажется необходимой для нового протокола — вероятно, протокол
пытается пронести через `sul_result_t` что-то непротокольное (см. ARCH §6 — «провиженинг-концепты
типа `cop_mode`/`display_id` — это настройки устройства, не данные СУЛ»).
---
## 11. Референсы
| Хочу... | Смотреть |
| --- | --- |
| Протокол на реальной шине, с параметром | [nku_can/](../../firmware/tft_app/src/domain/sul/nku_can/), [transport/can/](../../firmware/tft_app/src/domain/sul/transport/can/) |
| Синтетический / decode-less протокол | [demo/](../../firmware/tft_app/src/domain/sul/demo/), [transport/demo/](../../firmware/tft_app/src/domain/sul/transport/demo/) |
| Данные декодера отдельно от логики | [demo_route.h/.c](../../firmware/tft_app/src/domain/sul/demo/src/) |
| Протокол сам пишет settings (без меню) | [nku_can_take_pending_write()](../../firmware/tft_app/src/domain/sul/nku_can/src/nku_can.c) — удалённая адресация, §4 выше |
| Полная связка настройки → меню → протокол | [MENU.md §4](MENU.md), [SETTINGS.md](SETTINGS.md) |
| Пошагово: как добавить НЕпротокольную настройку | [ADDING_SETTING.md](ADDING_SETTING.md) |

View file

@ -0,0 +1,196 @@
# tft-app — как добавить настройку
Пошаговое руководство по добавлению нового поля в `settings_t` и пункта меню под него (ARCH.md
§8). Не про протоколы как таковые — это [ADDING_PROTOCOL.md](ADDING_PROTOCOL.md); не про то, что
такое `settings_t`/ярусы/QSPI — это [SETTINGS.md](SETTINGS.md); не про движок меню как таковой —
это [MENU.md](MENU.md). Здесь — практическая связка «хочу новый пункт в меню» → «что конкретно
трогать», с одной реальной сквозной сверкой (тумблер логов, §7).
---
## 1. Первая развилка — пользовательская или протокольная
```mermaid
flowchart TD
START([Новая настройка]) --> Q{Имеет смысл ТОЛЬКО<br/>для одного протокола?<br/>(напр. адрес станции)}
Q -->|да| PROTO["Протокольная<br/>→ proto_slice + sul_settings_desc_t<br/>см. §5"]
Q -->|нет| USER["Пользовательская/device<br/>→ своё поле в settings_t<br/>см. §4"]
```
| | Пользовательская/device | Протокольная |
| --- | --- | --- |
| Где хранится | своё именованное поле `settings_t.user`/`.device` | общий `proto_slice[]`, трактует активный протокол |
| Кто описывает пункт меню | статическая строка в `menu_tree.c` (руками, один раз) | `sul_settings_desc_t` протокола (реестр строит пункт САМ при каждом переключении) |
| Видна в меню | всегда | только когда этот протокол активен |
| Пример | тумблер логов (§7), выбор протокола | адрес НКУ-CAN, скорость демо |
| Документ с деталями | этот, §4 | [ADDING_PROTOCOL.md §3](ADDING_PROTOCOL.md) |
Признак протокольной — значение теряет смысл, если сменить протокол (адрес станции НКУ-CAN не
значит ничего для демо-протокола). Если значение осмысленно всегда и для всех — пользовательская,
даже если физически лежит в `settings_device_t` (пример — `log_enabled`: «device» по имени поля,
но полноценный пункт меню уже сегодня).
---
## 2. Три слоя, которые всегда участвуют
```mermaid
flowchart LR
F["Поле в settings_t<br/>(SETTINGS.md §2)"] --> T["Строка menu_item_desc_t<br/>offset/type/min/max/options<br/>(MENU.md §2)"]
T --> E["Редактор по типу<br/>menu_action() — generic,<br/>не меняется под новый пункт"]
T -.только протокольная.-> D["sul_settings_desc_t<br/>ADDING_PROTOCOL.md §3"]
```
Пользовательская настройка трогает ТОЛЬКО первые два слоя (поле + строка дерева) — движок
редактирования (`menu.c`) и рендер (`menu_view.c`) уже generic, под конкретный пункт не меняются.
Протокольная добавляет третий слой (дескриптор), а строку дерева `T_PROTO_PARAM` строит
`menu_tree_refresh_protocol_section()` за вас — руками её не пишете.
---
## 3. Форматы значений — что есть сейчас, что заготовка
Движок хранит любое editable-значение как **один `uint8`** по `value_offset`, редактируется
ОДНИМ generic-механизмом — инкремент с заворотом `min→max→min`
([menu.c](../../firmware/tft_app/src/menu/src/menu.c), `cycle_value()`):
| `menu_item_type_t` | Реализован? | Чем отличается от соседей | Пример |
| --- | --- | --- | --- |
| `MENU_BYTE` | ✅ | `options = NULL` → рендер числом | адрес НКУ-CAN 0..15 |
| `MENU_SELECT` | ✅ | `options[value]` → рендер меткой | протокол, скорость демо |
| `MENU_BOOL` | ✅ | `min=0, max=1`, `options` — 2 метки (обычно Вкл/Выкл) | тумблер логов |
| `MENU_SUBMENU` / `MENU_BACK` | ✅ | навигация, не значение (`value_offset` игнорируется) | «Настройки», «Выход» |
| `ARRAY` / `SERIAL` / `YEAR` / `PERCENT` / `BOOL_ARRAY` | ⬜ **нет** | под многобайтовые/составные значения (серийник, набор битов, год с иной раскладкой…) | — Фазы 5/6 |
**Важный вывод:** `MENU_BYTE`/`MENU_SELECT`/`MENU_BOOL` — механически ОДНО И ТО ЖЕ (один uint8,
инкремент с заворотом); разница только презентационная — есть ли `options[]` и какой смысл у
диапазона. Если ваша настройка укладывается в «одно число 0..N» — **новый код в движке не нужен
вообще**, только строка дерева (§4). Настоящая новая работа начинается, только если нужен ТИП из
нижней строки таблицы (⬜) — тогда это не «добавить настройку», а «добавить редактор»: новое
значение в `menu_item_type_t`, ветка в `menu_action()` (menu.c) и в рендере (`menu_view.c`) —
двигатель спроектирован под расширение (см. докстрок `menu.h`), но пока ни разу не расширялся.
`sul_settings_desc_t` (протокольные, [sul.h](../../firmware/tft_app/src/domain/sul/include/domain/sul.h))
использует СВОЙ `sul_settings_type_t` — подмножество ровно из трёх реализованных
(`SUL_SETTINGS_BYTE/_SELECT/_BOOL`), без `SUBMENU`/`BACK` (протокольный параметр никогда не
подменю) и без незаведённых типов. `menu_type_from_sul()` в `menu_tree.c` — единственное место
перевода между двумя enum'ами.
---
## 4. Шаг за шагом — пользовательская/device настройка
1. **Поле** — [settings_store.h](../../firmware/tft_app/src/services/settings_store/include/services/settings_store.h):
добавить `uint8_t`/`uint16_t` в `settings_user_t` (ярус A — общее для всех) или
`settings_device_t` (провиженинг/то, что не про конкретного пользователя-оператора). Прокомментировать
ярус и диапазон, как соседние поля. Значение ДОЛЖНО умещаться в `uint8_t`, если пойдёт в меню
через `value_offset` (движок читает/пишет ровно один байт, см. §3) — `max_load_kg` (`uint16_t`)
поэтому и не редактируется до появления типа `ARRAY`/составного редактора.
2. **Дефолт**`K_DEFAULTS` в [settings_codec.c](../../firmware/tft_app/src/services/settings_store/src/settings_codec.c).
Паттерн «0 = не задано/скрыто» уже используется (`max_load_kg`, `year_production`) — держитесь
его для новых необязательных полей, чтобы UI мог единообразно решать «показывать ли».
3. **Пункт дерева** — [menu_tree.c](../../firmware/tft_app/src/menu/src/menu_tree.c):
- новое имя в `enum { T_ROOT, T_PROTO, ..., T_EXIT, T_COUNT }` — вставить ГДЕ УГОДНО между
текущими первым и последним пунктом уровня (порядок в enum = физическая позиция в плоском
массиве `s_tree[]` = порядок в списке на экране); `.first_child`/`.last_child` родителя
(`T_ROOT`) ссылаются на ИМЕНА констант, не на числа — перенумеровывать соседей руками не
нужно, если новый пункт не становится НОВЫМ первым/последним (обычный случай — вставить перед
`T_EXIT`, чтобы «Выход» остался последним, как сейчас);
- строка в `s_tree[]`:
```c
[T_NEW] = { .label = "...",
.type = MENU_BYTE /* | _SELECT | _BOOL */,
.value_offset = offsetof(settings_t, user.<поле> /* или device.<поле> */),
.min = ...,
.max = ...,
.parent = MENU_ROOT_INDEX,
.options = NULL /* или массив меток, как K_BOOL_LABELS */ },
```
- `options` — только для `SELECT`/`BOOL`, длиной ровно `max - min + 1`, иначе `menu_view.c`
прочитает `options[value]` за границей массива (та же готча, что клампится для протокольных
значений при смене протокола, см. [SETTINGS.md §4](SETTINGS.md) — здесь она НЕ актуальна:
диапазон статический, не переключается рантаймом, поэтому клампить нечего).
4. **Если настройка должна на что-то влиять** — это отдельный шаг, не автоматический, см. §6.
5. **Host-тест**`tests/host/tft_app_menu/test_menu_tree.c`: по образцу
`test_protocol_param_edits_correct_settings_field` (init/open/next/action/assert) — открыть
меню, дойти до нового пункта (`menu_next()` до совпадения `label`, не хардкодить числовой
индекс — тот же приём, что `proto_index()` в этом файле), `menu_action()`, проверить, что
изменилось именно то поле `settings_t`, которое назвал `value_offset`.
6. **При необходимости**`tests/host/tft_app_settings_store/test_settings_codec.c`:
строка в `test_defaults_sane` (если дефолт важен) и/или в `test_roundtrip_preserves_fields`
(сериализация копирует `settings_t` целиком, новое поле переживёт round-trip и без явной
проверки — строка в тесте нужна для регрессионной сигнализации, не потому что иначе сломается).
**Вложенные подменю.** Дерево сейчас **плоское** — все пункты (`T_PROTO`, `T_PROTO_PARAM`, `T_LOG`,
`T_EXIT`) на одном уровне, дети `MENU_ROOT_INDEX`. Движок поддерживает `MENU_SUBMENU`
(свой `first_child..last_child`, см. `menu.h`) — если пунктов станет много и захочется сгруппировать
(«Диагностика», «Звук»…), группировка возможна уже сегодня, просто пока не понадобилась.
---
## 5. Шаг за шагом — протокольная настройка
Не дублируется здесь — полностью [ADDING_PROTOCOL.md §3](ADDING_PROTOCOL.md) («Шаг 2 —
sul_settings_desc_t»). Коротко: дескриптор живёт в `sul_registry.c` (не в модуле протокола),
`slice_offset` считается ВНУТРИ `proto_slice[]`, реальный `offsetof(settings_t, ...)` считает
`menu_tree_refresh_protocol_section()` — руками строку `s_tree[T_PROTO_PARAM]` не пишете.
**Ограничение, актуальное сегодня** (SETTINGS.md §4): движок меню рассчитан РОВНО на один параметр
на протокол (`T_PROTO_PARAM` — один слот). Если новому протоколу нужно больше одного —
сначала расширяется `menu_tree.c` (несколько слотов + скрытие лишних у протоколов с меньшим
числом параметров), это отдельная, более крупная задача, не «добавить настройку».
---
## 6. Если настройка должна на что-то влиять — эффект (отдельно от хранения)
Поле в `settings_t` само по себе ничего не делает — это только хранение. Если нужен рантайм-эффект
(не просто «лежит и переживает перезагрузку»), пишете обработчик и вызываете его в ДВУХ местах —
паттерн уже дважды использован (`protocol_id`, `log_enabled`):
```c
/* task_bringup.c — один раз при старте, из загруженных настроек */
log_set_enabled(settings_store_get()->device.log_enabled != 0U);
/* task_menu.c — после КАЖДОГО menu_action(), тем же вызовом */
log_set_enabled(g_menu.settings->device.log_enabled != 0U);
```
Почему в обоих местах: `task_bringup.c` — эффект должен быть виден с самого старта (не только
после первого захода в меню); `task_menu.c` — эффект должен быть виден СРАЗУ в этом же сеансе
меню, не только после `settings_store_save()` (пользователь может листать значения долго, прежде
чем выйти-с-сохранением, или вообще не сохранить, а эффект уже должен сработать — см. TASKS.md про
`sul_registry_set_active()`, тот же приём).
Если эффекта не нужно (настройка просто хранится, как весь ярус A сейчас, см.
[SETTINGS.md §9](SETTINGS.md)) — это ОК, легитимное промежуточное состояние (Фазы 5/6 добавят
эффект отдельно, без переделки хранения/меню).
---
## 7. Пример целиком — тумблер логов (эталон пользовательской BOOL-настройки)
| Шаг | Файл | Что именно |
| --- | --- | --- |
| Поле | [settings_store.h](../../firmware/tft_app/src/services/settings_store/include/services/settings_store.h) | `settings_device_t.log_enabled` (`uint8_t`) |
| Дефолт | [settings_codec.c](../../firmware/tft_app/src/services/settings_store/src/settings_codec.c) | `.log_enabled = 1U` — логи включены из коробки |
| Пункт дерева | [menu_tree.c](../../firmware/tft_app/src/menu/src/menu_tree.c) | `T_LOG`: `MENU_BOOL`, `offsetof(settings_t, device.log_enabled)`, `min=0/max=1`, `options=K_BOOL_LABELS` |
| Эффект | [log.h](../../utils/log/log.h)/[log.c](../../utils/log/log.c) | `log_set_enabled()` — рантайм-гейт ПОВЕРХ компайл-тайм `LOG_LEVEL`, первая проверка в `log_write()` |
| Вызов эффекта | `task_bringup.c` + `task_menu.c` | оба места, см. §6 |
| Host-тест хранения | `tests/host/log/test_log.c` | вкл/выкл по умолчанию, гейт реально давит вывод, повторное включение восстанавливает |
| Host-тест меню | — | пункт `T_LOG` статический (не из дескриптора) — отдельного edit-теста нет, при желании — по образцу §4 п.5 |
Полная деталь и история (почему бит, а не пер-тег гейт) — PLAN.md, Фаза 3.6.
---
## 8. Референсы
| Хочу... | Смотреть |
| --- | --- |
| Состав `settings_t`, ярусы, QSPI | [SETTINGS.md](SETTINGS.md) |
| Механика offset/движок меню/навигация | [MENU.md](MENU.md) |
| Протокольный параметр (адрес, скорость…) | [ADDING_PROTOCOL.md §3](ADDING_PROTOCOL.md) |
| Протокол сам пишет settings без меню | [ADDING_PROTOCOL.md §4](ADDING_PROTOCOL.md) (`take_pending_write`) |
| Готовый пример BOOL-настройки | §7 выше (тумблер логов) |
| Готовый пример SELECT/BYTE | адрес НКУ-CAN / скорость демо, [sul_registry.c](../../firmware/tft_app/src/domain/sul/src/sul_registry.c) |

View file

@ -5,9 +5,11 @@
обоснование слоёв — в [ARCH.md](../../firmware/tft_app/ARCH.md); статус фаз —
в [PLAN.md](../../firmware/tft_app/PLAN.md). Здесь — «как это работает в коде сейчас».
Сейчас реализован один протокол — **НКУ-CAN**. Архитектура рассчитана на много протоколов
(УЭЛ/УКЛ/SD7/УИМ — Фаза 8): добавление протокола = новый декодер + запись в реестр, без правок
остальных слоёв.
Сейчас в реестре два протокола — **НКУ-CAN** (реальная шина) и **демо** (синтетический источник,
Фаза 3.3, витрина возможностей устройства без СУЛ на другом конце). Архитектура рассчитана на
много протоколов (ещё УЭЛ/УКЛ/SD7/УИМ — Фаза 8): добавление протокола = новый декодер + запись
в реестр (+ transport, если новый вид шины) — см. [ADDING_PROTOCOL.md](ADDING_PROTOCOL.md) для
пошагового разбора.
---
@ -90,7 +92,7 @@ sequenceDiagram
```c
typedef struct { uint32_t id; uint8_t bus; const uint8_t *p_data; uint16_t len; } sul_frame_t;
typedef sul_status_t (*sul_decode_fn)(void *p_ctx, const sul_frame_t *p_frame, sul_result_t *p_out);
typedef sul_status_t (*sul_decode_fn_t)(void *p_ctx, const sul_frame_t *p_frame, sul_result_t *p_out);
```
**Три исхода** `decode()` (`sul_status_t`):
@ -152,8 +154,16 @@ flowchart LR
seismic — PACKET4), пакет-владелец распоряжается напрямую: сбрасывает в начале и выставляет по
условию, гася устаревший режим.
Удалённая установка адреса (кадры `0x4X1`/`0x5XB`) — **под-шаг 3.5** (см. PLAN.md). Сейчас эти
ID игнорируются (wildcard-фильтры под них ещё не настраиваются).
Удалённая установка адреса (кадры `0x4X1`/`0x5XB`, спецификация `REMOTE_ADDRES_SETUP.pdf`) —
**Фаза 3.5**: `nku_can_decode()` распознаёт анонс/команду независимо от классификации PACKET1..5
выше (один и тот же кадр может быть и своим PACKET4, и несущей команды) и выставляет запрос в
ctx; саму запись в `settings_store` делает `task_sul_rx.c` (decode() settings не пишет).
**Транспорт-сторона обязательна** (боевая находка, тот же класс, что адресные фильтры выше): кадры
`0x4X1`/`0x5XB` с любым X проходят HW-фильтры только благодаря двум **wildcard-фильтрам** (MB 5/6,
маска `0x70F` — адресный нибл `[7:4]` игнорируется) в `sul_transport_can_set_address()` — точные
фильтры PACKET1..5 их аппаратно отбрасывают, и decode() до них не доходит; host-тесты
этого не ловят (кормят decode() напрямую, мимо фильтров). Детали —
[PLAN.md §3.5](../../firmware/tft_app/PLAN.md).
---

View file

@ -22,16 +22,20 @@
## 2. Что рисуется
Презентация решает по разрешённому режиму (`indication_task_t.mode`, см.
[MODE_PRIORITY.md](MODE_PRIORITY.md)):
[MODE_PRIORITY.md](MODE_PRIORITY.md)) — **и по диспетчерскому входу** (§2.1), который
проверяется первым и безусловно перекрывает всё остальное:
```mermaid
flowchart TD
START([render mode, result]) --> CLR["gfx_clear() — AS прозрачен,<br/>фон даёт чёрный PS"]
CLR --> Q{"mode == NORMAL?"}
START([render mode, result, dispatcher]) --> CLR["gfx_clear() — AS прозрачен,<br/>фон даёт чёрный PS"]
CLR --> DSP{"dispatcher != NONE?"}
DSP -->|да| DLBL["метка ВЫЗОВ/ОТВЕТ (SystemFont)"]
DLBL --> DONE([готово])
DSP -->|нет| Q{"mode == NORMAL?"}
Q -->|да| POS["этаж (FloorFontFallback), по центру"]
POS --> ARR{"direction UP/DOWN?"}
ARR -->|да| DRAWARR["примитив-стрелка"]
ARR -->|нет| DONE([готово])
ARR -->|нет| DONE
DRAWARR --> DONE
Q -->|нет| LBL["метка режима (SystemFont)<br/>ПОЖАР/ПЕРЕГРУЗ/СЕРВИС/…"]
LBL --> LAD{"погрузка и lading_secs>0?"}
@ -46,10 +50,34 @@ flowchart TD
- **Спецрежим** — короткая текстовая метка системным шрифтом (`SystemFont`). Это **safe-mode**
индикация: богатая полноэкранная графика режимов (фон+спрайты) появится в Фазах 45. Для
временной погрузки дополнительно рисуется обратный отсчёт (`lading_secs`).
- **Диспетчерский вход** (§3.4) — та же safe-mode текстовая метка (переиспользует слот режимов:
`MODE_Y`/`SystemFont`), но проверяется ПЕРВЫМ, до `mode`. Слова временные, «ВЫЗОВ»/«ОТВЕТ».
Следующий этаж (`next`) в fallback **не** показывается — это элемент богатого layout (Фаза 5),
поэтому на `next_pending` перерисовки нет.
### 2.1 Диспетчерский вход — не данные СУЛ, отдельный вход в рендер
`opto` IN1 («вызов подан») / IN2 («вызов принят») — **локальный вход** (ARCH §8 п.3), не
`sul_result_t`: не идёт через `controller`/таблицу приоритетов режимов (эта таблица — только
для ортогональных сигналов СУЛ, см. [MODE_PRIORITY.md](MODE_PRIORITY.md)). Дизайн-диаграмма
ARCH §4 рисует его именно так — отдельным входом прямо в `ui.render(task, model, settings)`, не
через `controller`:
```mermaid
flowchart LR
OPTO["bsp_opto IN1/IN2<br/>(app/dispatcher.c)"] -->|"g_dispatcher_indication"| RENDER["ui_fallback_render*()"]
CTRL["controller.process()<br/>(только sul_result_t)"] -->|"indication_task_t"| RENDER
```
Приоритет — **высший из всех**, согласовано с пользователем: перекрывает обычную индикацию И
любой режим СУЛ (пожар/перегруз/…), работает даже без связи со станцией (dispatcher — независимое
оборудование, не станция). ОТВЕТ (IN2) перебивает ВЫЗОВ (IN1), если оба почему-то активны
одновременно. Референс поведения (не архитектуры) —
`OLD_PROJECT_TFT8_UKL/source/main_programm.c` (`tft_refresh_task`): там `icon_img_ptr`
перезаписывается диспетчерской проверкой ПОСЛЕДНЕЙ в каждом кадре, безусловно — тот же эффект,
что здесь достигается проверкой ПЕРВОЙ в `render()` с `return`.
---
## 3. По-символьный fallback шрифта
@ -78,12 +106,19 @@ flowchart LR
`ui_fallback_render()` перерисовывает кадр целиком (без dirty-rect — `gfx` минимален) при
изменении того, что fallback реально показывает: **позиция, стрелка или режим**
(`pos_pending || direction_pending || mode_pending`). Иначе — no-op.
(`pos_pending || direction_pending || mode_pending`). Иначе — no-op. И этот путь, и
безусловный старт (ниже) принимают диспетчерский вход (§2.1) отдельным параметром — сам он
НЕ входит в `pos_pending`/`direction_pending`/`mode_pending` (это diff `sul_result_t`, opto —
локальный вход, другая природа изменения).
Первую отрисовку презентация делает **безусловно** один раз при старте
(`ui_fallback_render_initial()`): кэш контроллера засеян дефолтом, и если первый реальный кадр
совпадёт с дефолтом, diff придёт «ничего не изменилось» — без безусловного старта экран остался бы
пустым.
пустым. Тот же безусловный вызов переиспользуется как ТРЕТЬЯ причина полной перерисовки (кроме
старта и закрытия меню) — смена диспетчерского входа САМА ПО СЕБЕ, без нового кадра СУЛ в
очереди: `render_task` (см. [TASKS.md §3](TASKS.md)) сравнивает текущее
`g_dispatcher_indication` с прошлым и, если отличается, зовёт `ui_fallback_render_initial()`
из последнего известного `sul_result_t` + нового диспетчерского значения.
---

View file

@ -100,9 +100,14 @@ flowchart TD
| device/провиженинг | тумблер логов | `offsetof(settings_t, device.log_enabled)` |
| **B** протокольные | адрес НКУ | `offsetof(settings_t, user.proto_slice[0])` |
Ярус B (протокольные параметры) в 3.2 привязан прямым offset к `proto_slice`; в 3.3 это обобщается
на дескриптор протокола `sul_settings_desc_t` (§8) — меню строит раздел «Настройки протокола» из
дескриптора активного протокола, не хардкодом.
Ярус B (протокольные параметры) с Фазы 3.3 строится из дескриптора протокола
`sul_settings_desc_t` (§8), не хардкодом: `menu_tree_refresh_protocol_section()` читает
`sul_registry_active()->p_settings` и заполняет раздел «Настройки протокола» (label/тип/
диапазон/offset/options) из него — при смене активного протокола (и при bringup) секция
перестраивается. Подробности механизма и как добавить протокол — [ADDING_PROTOCOL.md](ADDING_PROTOCOL.md).
Пошаговое «как добавить настройку» (любую — пользовательскую или протокольную, форматы значений)
— [ADDING_SETTING.md](ADDING_SETTING.md); состав/ярусы/хранение самого `settings_t`
[SETTINGS.md](SETTINGS.md).
**Поток сохранения** (`menu_task` связывает модель и flash — см. §5):

View file

@ -7,9 +7,12 @@ Dev-документация по **реализованному** слою `fir
| Документ | О чём |
| --- | --- |
| [DOMAIN_DATAFLOW.md](DOMAIN_DATAFLOW.md) | Путь данных: транспорт → декодер → контроллер → презентация. Слои, контракт `decode()`, карта пакетов НКУ-CAN → поля модели, адресация (decode + HW-фильтры), таймаут→default. |
| [ADDING_PROTOCOL.md](ADDING_PROTOCOL.md) | Как добавить протокол в реестр `sul`: decode()+ctx, дескриптор настроек, transport, реестр, диспетчеризация в `task_sul_rx.c`, тесты. Разобрано на НКУ-CAN и демо-протоколе. |
| [ADDING_SETTING.md](ADDING_SETTING.md) | Как добавить настройку: пользовательская vs протокольная, форматы значений (BYTE/SELECT/BOOL — что реализовано, что заготовка), поле → пункт меню → эффект. Эталон — тумблер логов. |
| [MODE_PRIORITY.md](MODE_PRIORITY.md) | Свёртка ортогональных сигналов в один экранный режим. Таблица приоритетов как данные, резолвер, как менять/кастомизировать. |
| [FALLBACK.md](FALLBACK.md) | Safe-mode рендер: этаж/стрелка/метка режима, по-символьный fallback шрифта, триггеры перерисовки, путь кадра через компоновщик. |
| [FALLBACK.md](FALLBACK.md) | Safe-mode рендер: этаж/стрелка/метка режима, диспетчерский вход (высший приоритет, opto), по-символьный fallback шрифта, триггеры перерисовки, путь кадра через компоновщик. |
| [MENU.md](MENU.md) | Движок меню (Фаза 3.2): дерево-данные, редакторы по типу, чистая модель, связь с `settings_store`, оконный рендер, раскладка кнопок. |
| [SETTINGS.md](SETTINGS.md) | Модуль настроек: состав `settings_t`, ярусы A/B/C и их связь, `proto_slice`/дескрипторы, гибридное хранение (ядро + клиентский TLV), карта QSPI, что осознанно не реализовано. |
| [TASKS.md](TASKS.md) | Задачи FreeRTOS: состав/приоритеты (и почему такие), старт системы, MPSC-взаимодействие, мягкая пауза, разделяемое состояние. |
## Связанные документы
@ -21,8 +24,10 @@ Dev-документация по **реализованному** слою `fir
## Границы
Описаны домен, fallback-презентация, ядро настроек (`settings_store`), меню (модель + оконный
рендер + задачи) и фундамент рендера (double-buffer + PXP + гибрид bpp, Фаза 3.2.4). Остаток
Фазы 3: per-protocol дескрипторы (3.3), opto-входы (3.4), удалённая адресация НКУ-CAN (3.5),
тумблер логов (3.6). Ещё не реализовано (см. PLAN.md): ассеты и layout-движок со спрайтами
(Фазы 45), аудио (Фаза 6), мультипротокол (Фаза 8).
Описаны домен, fallback-презентация (+ диспетчерский вход, Фаза 3.4), ядро настроек
(`settings_store` + состав/ярусы/QSPI-карта, [SETTINGS.md](SETTINGS.md)), меню (модель + оконный
рендер + задачи), фундамент рендера (double-buffer + PXP + гибрид bpp, Фаза 3.2.4), per-protocol
дескрипторы + реестр из двух протоколов (Фаза 3.3), удалённая адресация НКУ-CAN (Фаза 3.5),
рантайм-тумблер логов (Фаза 3.6). **Фаза 3 полностью закрыта документацией.** Ещё не реализовано
(см. PLAN.md): ассеты и layout-движок со спрайтами (Фазы 45, включая ярус C настроек — клиентский
TLV, см. SETTINGS.md §3/§9), аудио (Фаза 6), остальные протоколы — УИМ/SD7/УЭЛ/УКЛ (Фаза 8).

275
docs/tft_app/SETTINGS.md Normal file
View file

@ -0,0 +1,275 @@
# tft-app — настройки: состав, ярусы, хранение
Документ описывает **реализованный** модуль `settings_store` — состав `settings_t`, ярусы A/B/C
и их взаимосвязь, гибридную модель хранения (ядро на QSPI + клиентский TLV) и карту QSPI. Проектная
основа — [ARCH.md §8](../../firmware/tft_app/ARCH.md) (настройки) и
[§10](../../firmware/tft_app/ARCH.md) (карта QSPI); реализация —
[settings_store.h](../../firmware/tft_app/src/services/settings_store/include/services/settings_store.h),
[settings_codec.c](../../firmware/tft_app/src/services/settings_store/src/settings_codec.c),
[partition.h](../../firmware/tft_app/src/services/partition/include/services/partition.h).
Не дублирует: механику дескрипторов протоколов ([ADDING_PROTOCOL.md](ADDING_PROTOCOL.md)) и связь
меню↔offset ([MENU.md](MENU.md) §4) — здесь только то, что уникально для самого модуля настроек:
что хранится, как устроены ярусы, как это лежит на флеше. Практическое «как добавить свою
настройку, шаг за шагом» — [ADDING_SETTING.md](ADDING_SETTING.md).
---
## 1. Три источника — settings сюда НЕ смешивает чужое
ARCH §8 разделяет три независимых источника данных приложения:
1. **`sul_result_t`** — только то, что прислала станция (см. [DOMAIN_DATAFLOW.md](DOMAIN_DATAFLOW.md)).
2. **`settings_t`** (этот документ) — конфиг устройства/пользователя, живёт на QSPI.
3. **Локальные входы** — opto (диспетчерский вызов/ответ), кнопки/меню — свои
`volatile`-переменные в `app_tasks.h` (см. [TASKS.md](TASKS.md) §5), в `settings_t` не попадают
вообще (это не конфигурация, а рантайм-состояние оборудования).
`settings_t` — ЕДИНСТВЕННЫЙ персистентный источник из трёх; два остальных живут только в RAM.
---
## 2. Состав `settings_t`
```c
typedef struct {
settings_device_t device; /* провиженинг, не в пользовательском меню */
settings_user_t user; /* ярусы A + B */
uint8_t _reserved[8]; /* фикс размера — offset'ы полей стабильны */
} settings_t;
```
| Поле | Тип/размер | Ярус | Диапазон/дефолт | Редактируется в меню сейчас? |
| --- | --- | --- | --- | --- |
| `device.panel_type` | `uint8_t` | provisioning | `2` (TFT8), Фаза 9 | нет — provisioning-параметр (ARCH §9), не пользовательский |
| `device.protocol_id` | `uint8_t` | provisioning/B | `0` (NKU_CAN) | да — пункт «Протокол» (`T_PROTO`) |
| `device.log_enabled` | `uint8_t` | provisioning | `1` (вкл.) | да — пункт «Логи» (`T_LOG`, Фаза 3.6) |
| `user.max_load_kg` | `uint16_t` | A | `0` = скрыто | **нет** — хранится и сериализуется, редактор — Фаза 5/6 |
| `user.max_cap_persons` | `uint8_t` | A | `0` = скрыто | **нет** — Фаза 5/6 |
| `user.sound_volume_idx` | `uint8_t` | A | `2` из 0..4 | **нет** — эффект (звук) только с Фазы 6 |
| `user.music_volume_idx` | `uint8_t` | A | `1` из 0..4 | **нет** — Фаза 6 |
| `user.year_production` | `uint8_t` | A | `0` = скрыто, иначе `2000+N` | **нет** — Фаза 5/6 |
| `user.serial[10]` | ASCII+`\0` | A | `""` | **нет** — Фаза 5/6 |
| `user.proto_slice[8]` | `uint8_t[8]` | B | зависит от протокола (§4) | да — единственный параметр активного протокола (`T_PROTO_PARAM`) |
| `_reserved[8]` | padding | — | `0xFF` при сохранении | не поле, задел |
Ярус A хранится/сериализуется/проходит CRC уже сейчас (см. `K_DEFAULTS` в `settings_codec.c`) —
это НЕ означает наличие UI: полей ещё нет ни в одном пункте `menu_tree.c` (проверяемо — grep по
`max_load_kg`/`serial`/… в дереве не находит ничего). Раздел 9 держит это явно, чтобы не решить
по коду наоборот.
---
## 3. Ярусы A/B/C — назначение и связь
```mermaid
flowchart TB
subgraph ST["settings_t (QSPI 0x450000, ядро)"]
A["Ярус A — железобетонные<br/>вес/вместимость/громкости/серийник/год<br/>одинаковы для ЛЮБОГО клиента"]
B["Ярус B — протокольные<br/>proto_slice[8], трактует АКТИВНЫЙ протокол<br/>через sul_settings_desc_t"]
DEV["device — provisioning<br/>панель/протокол/лог-тумблер"]
end
C["Ярус C — клиентский UX-зоопарк<br/>лого, сдвиги/маска этажей, метки<br/>TLV в layout-регионе 0x440000<br/>⚠ ЕЩЁ НЕ РЕАЛИЗОВАНО (Фаза 4/5)"]
ST -.разные регионы, разный формат.-> C
```
- **A — железобетонные.** Одинаковый смысл для любого клиента/протокола: вес, вместимость,
громкости, серийник, год выпуска. Не зависят от того, какая станция подключена.
- **B — протокольные.** `proto_slice[8]` — ОБЩИЙ буфер под параметры активного протокола;
какой байт что значит, решает **дескриптор** протокола (`sul_settings_desc_t`, ARCH §8) —
см. §4 ниже. Слайс один на всех — при смене протокола он **переинтерпретируется**, не
расширяется (готча со staleness — тоже в §4).
- **device (provisioning).** Не входит ни в A, ни в B по смыслу ARCH §8, но физически хранится
рядом (структура `settings_device_t`): панель — provisioning (Фаза 9, пишет `service_tui`,
не пользователь), протокол и тумблер логов — фактически пользовательские пункты меню уже
сейчас, несмотря на «device» в имени поля.
- **C — клиентский UX-зоопарк.** Логотип, сдвиги/маска номеров этажей, произвольные текстовые
метки — то, что каждый клиент хочет по-своему. **НЕ входит в `settings_t` вообще** — ни
структурно, ни по QSPI-региону (§6). Живёт как TLV в layout-регионе (`0x440000`), пишется
`service_tui` (Model 2 «injected», ARCH §11), схема версионируется отдельно от ядра. На
сегодня это **только дизайн** (ARCH §11) — ни кода, ни формата TLV ещё нет (Фазы 45).
**Почему C не влезает в ядро, а не потому что «забыли»:** ядро (A+B) — маленькое, с фиксированным
`offsetof`, редактируется **прошивкой** через меню, должно быть всегда валидно (magic/CRC + дефолт
на любую порчу, §7). Зоопарк C — открытый по составу (у каждого клиента свой набор виджетов),
редактируется **внешним инструментом** без пересборки прошивки, требует версионируемой схемы —
принципиально другой формат и жизненный цикл, поэтому и другой регион (§6).
---
## 4. `proto_slice` и дескрипторы протоколов (кратко — детали в ADDING_PROTOCOL.md)
Активный протокол описывает свои параметры дескриптором
([sul.h](../../firmware/tft_app/src/domain/sul/include/domain/sul.h)):
```c
typedef struct {
const char *p_label; sul_settings_type_t type; /* BYTE / SELECT / BOOL */
uint8_t slice_offset; /* смещение ВНУТРИ proto_slice[] */
uint8_t min, max;
const char *const *p_options; /* только для SELECT/BOOL */
} sul_settings_entry_t;
```
Два зарегистрированных сейчас протокола ([sul_registry.c](../../firmware/tft_app/src/domain/sul/src/sul_registry.c)):
| Протокол | `slice_offset` | Тип | Диапазон | Смысл |
| --- | --- | --- | --- | --- |
| НКУ-CAN | `proto_slice[0]` | BYTE | 0..15 | адрес станции |
| Демо | `proto_slice[0]` | SELECT | 0..2 | скорость скриптованного маршрута |
Домен не включает `settings_store.h` (ARCH §4, домен не знает презентацию/хранение) — `slice_offset`
считается ВНУТРИ `proto_slice[]`, а не внутри `settings_t`. Перевод в реальный
`offsetof(settings_t, user.proto_slice) + slice_offset` — единственная привилегия `menu/`
(`menu_tree_refresh_protocol_section()`, [MENU.md](MENU.md) §4) — только этот слой включает и
`domain/sul.h`, и `settings_store.h` одновременно.
**Готча (реальная, найдена и закрыта в Фазе 3.3, не гипотетическая).** Один и тот же байт
`proto_slice[0]` при переключении протокола в меню означает РАЗНОЕ (адрес 0..15 → скорость 0..2).
Без клампа «протухшее» значение (напр. адрес 15) читалось бы как `options[15]` за границей
2-элементного массива меток демо — мусорное чтение в рендере. `menu_tree_refresh_protocol_section()`
клампит значение под НОВЫЙ `max` в момент переключения (фикс в корне, не на каждом сайте чтения).
Практическое следствие: **у протокола с несколькими параметрами** второй/третий байт слайса будет
не инициализирован осмысленно при первом переключении на него — сейчас не актуально (у обоих
протоколов ровно один параметр), станет вопросом Фазы 8 (см. `ADDING_PROTOCOL.md` §3).
Как зарегистрировать параметр для НОВОГО протокола — пошагово в
[ADDING_PROTOCOL.md §3](ADDING_PROTOCOL.md).
---
## 5. Хранение — гибридная модель (ядро-struct + клиентский TLV)
| | Ядро (A + B + device) | Клиентский зоопарк (C) |
| --- | --- | --- |
| Формат | фикс. `struct` + magic/version/CRC32 | TLV, версионируемая схема |
| Регион QSPI | `0x450000`, 8 КБ (§6) | `0x440000`, 64 КБ (layout-регион, §6) |
| Кто пишет | прошивка (меню, `settings_store_save()`) | `service_tui` (внешний инструмент) |
| Когда меняется | в поле, через экранное меню | при заказе/переконфигурации клиента, без пересборки |
| Валидация | magic+version+CRC32, авто-дефолт при любой невалидности | схема+версия+CRC, на host (golden-render) |
| Реализовано | ✅ да (этот документ) | ⬜ нет — только дизайн ARCH §11, Фазы 45 |
Ядро сознательно маленькое и жёсткое (offset-стабильность нужна декодерам/меню прямо сейчас);
зоопарк сознательно гибкий и внешний (открытый список клиентских причуд, ARCH §2.2). Один
`settings_t` на оба смысла означал бы либо раздувать ядро под гипотетические виджеты (нарушает
YAGNI и offset-стабильность), либо тащить версионируемый TLV-парсер в код, который должен быть
простым и всегда-валидным. Два формата, два региона, две скорости изменения.
---
## 6. Карта QSPI (ARCH §10) — размер-независимая
| Регион | Смещение | Размер | Владелец |
| --- | --- | --- | --- |
| bootloader | `0x000000` | 256 КБ | bootloader |
| slot A (tft_app) | `0x040000` | 2 МБ | MCUboot |
| slot Б (tft_app) | `0x240000` | 2 МБ | MCUboot |
| **layout** (+ ярус C, TLV) | `0x440000` | 64 КБ | tft_app / `service_tui` |
| **settings** (ядро, этот документ) | `0x450000` | 8 КБ = 2 сектора × 4 КБ | tft_app |
| **assets** | `0x452000` | остаток до конца чипа | tft_app |
Все константы — [partition.h](../../firmware/tft_app/src/services/partition/include/services/partition.h),
общий источник для `app` и (в перспективе) генератора `service_tui`.
**Размер-независимость.** Прошивка работает на разных QSPI NOR (W25Q128/256/512 …). Все критичные
регионы — по ФИКСИРОВАННЫМ смещениям (компайл-тайм, без рантайм-детекта); размер-зависим ТОЛЬКО
регион ассетов — стартует с фикс-адреса и тянется до конца чипа
(`длина = bsp_qspi_flash_size() 0x452000`). Больше флеш = больше места под ассеты, карта не
меняется.
**Settings — 2 сектора, сейчас используется ОДИН.** `TFT_APP_QSPI_SETTINGS_OFFSET` (сектор 0) —
рабочий, туда пишет `settings_store_save()`. Сектор 1 (`+0x1000`) — **зарезервирован под будущий
power-safe ping-pong** (erase+write не атомарен — обрыв питания посреди `save()` сейчас МОЖЕТ
испортить единственную копию; см. §9). Сектор 1 сегодня не читается и не пишется НИКЕМ — это
задел по карте, не реализованная защита.
---
## 7. Формат страницы (magic/version/CRC32)
Ровно один сектор QSPI (`BSP_QSPI_SECTOR_SIZE` = 4 КБ), проверяется статически
(`_Static_assert(sizeof(settings_page_t) == BSP_QSPI_SECTOR_SIZE)`):
```c
typedef struct {
uint32_t magic; /* 0x54465453 'STFT' */
uint8_t version; /* 1 */
uint8_t _pad[3];
settings_t data;
uint8_t _reserved[/* до конца сектора */];
uint32_t crc32; /* по всей странице КРОМЕ этого поля */
} settings_page_t;
```
- **CRC32** — poly `0x04C11DB7`, MSB-first, init `0xFFFFFFFF`, без финального xor (как
`style_updater` в OLD_PROJECT) — [`settings_crc32()`](../../firmware/tft_app/src/services/settings_store/src/settings_codec.c).
- **`_pad`/`_reserved` = `0xFF`** при сериализации — паттерн стёртого флеша, не нули (те же
причины, что 0xFF-заполнение в других TLV этого репозитория).
- **Чтение — всегда успешно с точки зрения вызывающего.** `settings_deserialize()` возвращает
`false` на любое несовпадение magic/version/CRC — `settings_store_load()` в этом случае просто
подставляет `settings_defaults()`; настройки валидны ВСЕГДА после `load()`/`init_defaults()`,
ошибка не пробрасывается выше как fatal (аналог поведения `default`-layout, ARCH §11 — деградация,
не отказ).
- Сериализация/валидация — **чистые функции** в `settings_codec.c` (без единого обращения к QSPI),
host-тестируются без железа; `settings_store.c` — тонкий flash-адаптер поверх них.
---
## 8. Жизненный цикл (bringup → меню → save)
```mermaid
sequenceDiagram
participant B as bringup_task
participant Q as QSPI 0x450000
participant M as menu_task
participant S as sul_rx_task
B->>Q: settings_store_load()
alt magic/version/CRC валидны
Q-->>B: g_settings = данные с флеша
else невалидны/ошибка чтения
B->>B: settings_store_init_defaults()
end
B->>B: sul_registry_set_active(protocol_id)<br/>menu_tree_refresh_protocol_section()<br/>log_set_enabled(log_enabled)
Note over M,S: рантайм — RAM-мутации через settings_store_get_mutable()
M->>M: правка в меню (offset, dirty=true)
M->>Q: save() ТОЛЬКО если save_requested (=dirty на выходе)
S->>Q: save() — удалённый адрес НКУ-CAN (§3.5), свой путь записи
```
- **Один экземпляр в RAM** (`static settings_t g_settings` в `settings_store.c`) — `get()`/
`get_mutable()` возвращают указатель на него же, копий нет.
- **Сохранение — не на каждую правку.** `menu.c` взводит `dirty` любой правкой, `save_requested`
выставляется РАВНЫМ `dirty` только на выходе из корня меню — если пользователь ничего не менял,
`settings_store_save()` не вызывается вообще (нет лишних erase-циклов флеша).
- **Два независимых писателя `save()`**`menu_task` (выход из меню) и `sul_rx_task` (удалённый
адрес НКУ-CAN, §3.5) — защищены `g_save_mutex` (создаётся в `init_defaults()`/`load()`, оба
вызываются один раз из `bringup_task` ДО создания остальных задач, гонки при создании нет по
конструкции). Без мьютекса — два параллельных `erase+write` в один сектор, порча настроек
(найдено при проектировании 3.5, не на стенде — см. PLAN.md).
- **Реаппликация — не только после `save()`.** Активный протокол/тумблер логов переприменяются
СРАЗУ на каждое действие меню (`sul_registry_set_active()`, `menu_tree_refresh_protocol_section()`,
`log_set_enabled()`) — эффект виден в том же сеансе, независимо от того, дойдёт ли пользователь
до сохранения.
---
## 9. Что осознанно НЕ реализовано (честно)
- **Power-safe ping-pong** — сектор 1 занят под задел (§6), не используется. `save()` сегодня:
erase → постраничная запись ОДНОГО сектора; обрыв питания между erase и завершением записи
оставит страницу с невалидным CRC → следующий `load()` откатится на дефолты (не hardfault, но
и не восстановит последнее сохранённое — пользовательские правки будут потеряны).
- **Ярус C / клиентский TLV** — ноль кода, только дизайн ARCH §11. Layout-регион (`0x440000`)
сегодня не читается и не пишется вообще (нет layout-движка, Фазы 45).
- **`settings_store_reset_user_defaults()`** — функция объявлена и реализована, но НЕ вызывается
ниоткуда в `app`-слое — нет пункта меню «сброс настроек». Задел под Фазу 5/6.
- **Ярус A без UI** — весь список из §2 («нет» в последней колонке) хранится/сериализуется/проходит
CRC уже сейчас, но нет ни одного пункта меню, который бы это редактировал — только `device.protocol_id`
и `device.log_enabled` (провиженинговые по имени поля) и `user.proto_slice` реально управляемы
оператором сегодня.
- **`device.panel_type`** — provisioning-параметр (ARCH §9), пишется `service_tui`, а не
пользовательским меню; сейчас хардкод-дефолт `BSP_DISPLAY_TFT8`, реальный provisioning-путь —
Фаза 9.

View file

@ -21,7 +21,7 @@
| `menu_task` | task_menu.c | `+3` | модель меню: кнопки, вход/навигация/правка/сохранение. **НЕ рисует** | `settings_store_save()` (flash) на выходе из меню |
| `render_task` | task_render.c | `+2` | **единственный** владелец дисплея и вызывающий `gfx_present*()` | PXP busy-wait + ожидание FRAME_DONE |
| `sul_rx_task` | task_sul_rx.c | `+1` (низший) | приём CAN → decode → controller; WDOG/heartbeat | busy-spin в `bsp_can_receive()` до 100 мс без трафика |
| — демон таймеров | (FreeRTOS) | `configTIMER_TASK_PRIORITY` (высший в системе) | `input_poll_cb` каждые 5 мс: `bsp_button_poll()` (debounce); Фаза 3.4 — сюда же opto | нет (колбэк короткий) |
| — демон таймеров | (FreeRTOS) | `configTIMER_TASK_PRIORITY` (высший в системе) | `input_poll_cb` каждые 5 мс: `bsp_button_poll()` (debounce)`bsp_opto_process()` (debounce IN1/IN2) → `dispatcher_poll()` (§3.4, `app/dispatcher.c` — безусловный опрос `bsp_opto_read()`, НЕ колбэк, см. PLAN.md про баг реактивной версии; пишет `g_dispatcher_indication` и будит `render_task` прямо отсюда при изменении) | нет (все три коротких) |
`main()` создаёт только очередь, софт-таймер ввода и `bringup_task` — остальное wiring делает
сам `bringup_task`.
@ -58,32 +58,65 @@ CPU, пока не создаст всех троих.
## 3. Взаимодействие (MPSC)
Два продюсера, один консюмер. **Данные** и **сигнал пробуждения** разделены:
Три продюсера, один консюмер (третий — dispatcher opto, §3.4). **Данные** и **сигнал
пробуждения** разделены:
```mermaid
flowchart LR
TMR["демон таймеров<br/>input_poll_cb 5 мс<br/>bsp_button_poll (debounce)"]
TMR["демон таймеров<br/>input_poll_cb 5 мс<br/>bsp_button_poll + bsp_opto_process"]
MT["menu_task (+3)<br/>модель меню g_menu"]
RX["sul_rx_task (+1)<br/>CAN→decode→controller<br/>WDOG безусловно"]
RT["render_task (+2)<br/>gfx_present*()"]
DSP["dispatcher_poll()<br/>(app/dispatcher.c)<br/>g_dispatcher_indication"]
TMR -."залатанные события кнопок".-> MT
TMR -."безусловный опрос, каждый тик".-> DSP
MT -->|"xTaskNotifyGive<br/>(любое изменение)"| RT
RX -->|"xQueueOverwrite (render_msg_t)<br/>+ xTaskNotifyGive"| RT
DSP -->|"xTaskNotifyGive<br/>(изменение вызов/ответ)"| RT
MT -."g_menu_active (мягкая пауза)".-> RX
```
- **Очередь `g_render_queue`** (глубина 1, `xQueueOverwrite`) — только плечо
`sul_rx→render`, несёт `render_msg_t` (diff + результат). Семантика «важно только
последнее состояние»: рендер не обязан успевать за каждым кадром CAN.
последнее состояние»: рендер не обязан успевать за каждым кадром CAN. У dispatcher СВОЕЙ
очереди нет — состояние (`g_dispatcher_indication`) не история, читается напрямую (как
`g_menu`), очередь тут не нужна (сравнение с прошлым значением — внутри `render_task`).
- **`ulTaskNotifyTake(pdTRUE, portMAX_DELAY)`** в `render_task` — event-driven, без
поллинга; несколько notify от обоих продюсеров схлопываются в одно пробуждение (та же
семантика «важно только последнее»).
поллинга; несколько notify от ЛЮБОГО из трёх продюсеров схлопываются в одно пробуждение
(та же семантика «важно только последнее»). `render_task` не различает, КТО его разбудил —
каждую итерацию просто проверяет все три источника состояния заново.
- **Приоритет «меню важнее индикации» не кодируется в уведомлении**: проснувшись,
`render_task` первым делом проверяет `menu_is_open(&g_menu)` — если меню открыто,
очередь индикации даже не читается.
очередь индикации и dispatcher даже не читаются (см. §3.1 ниже — dispatcher копится, не
теряется).
- **Модель меню `g_menu`** мутирует только `menu_task`; `render_task` читает её для
отрисовки после notify (happens-before через нотификацию — как у очереди).
- **`g_dispatcher_indication`** мутирует только `dispatcher_poll()` (`app/dispatcher.c`),
вызываемый БЕЗУСЛОВНО каждый тик из контекста демона таймеров — приоритет ВЫШЕ
`menu_task` (не реактивно на колбэк bsp_opto — см. PLAN.md §3.4 про баг первой,
реактивной версии). `render_task` снимает копию в
локальную переменную ОДИН раз за итерацию, а не перечитывает несколько раз (иначе
возможна гонка того же класса, что чинили для курсора меню при быстрой навигации, см.
PLAN.md, Фаза 3.2.4).
### 3.1 Диспетчерский вход и пауза меню (§3.4)
Приоритет диспетчерского сигнала («вызов»/«ответ») — **высший из всех режимов индикации**,
безусловно перекрывает и обычную позицию, и любой режим СУЛ; работает независимо от связи со
станцией (ARCH §8 п.3 — это «локальный вход», не данные СУЛ). Тем не менее, пока меню открыто,
`render_task` НЕ применяет его к экрану — как в `OLD_PROJECT_TFT8_UKL` (`tft_refresh_task`
целиком пропускает кадр, пока `in_menu_screen`), тот же принцип, что уже даёт мягкая пауза
`sul_rx_task`:
- **Debounce и опрос не паузятся**`bsp_opto_process()` и `dispatcher_poll()` в
`input_poll_cb` работают независимо от `g_menu_active`, дёшево (как и button).
- **Применение к экрану — только на закрытии меню.** Пока меню открыто, оконный композит
перерисовывает ТОЛЬКО окно 480×272 (Фаза 3.2.4) — полный кадр индикации вне окна заморожен;
применить смену диспетчера немедленно значило бы либо сломать оконную оптимизацию (полный
редрав ради маленькой иконки), либо рисовать поверх окна меню. `g_dispatcher_indication`
тем временем просто держит ПОСЛЕДНЕЕ значение — ничего не теряется, отражается сразу по
закрытии меню тем же путём, что уже восстанавливает индикацию (`ui_fallback_render_initial()`).
### Мягкая пауза `sul_rx_task` на время меню
@ -124,6 +157,7 @@ flowchart LR
| `g_display_ready` | bringup | render, sul_rx | `volatile bool`, одно-writer |
| `g_menu_active` | menu | sul_rx | `volatile bool`, одно-writer |
| `g_menu` | menu | render | notify (happens-before) |
| `g_dispatcher_indication` | dispatcher_poll() (демон таймеров, каждый тик) | render | `volatile enum`, один writer; render снимает копию один раз за итерацию (см. §3) |
| лог-буфер `utils/log` | все задачи | — | мьютекс `port/log/src/log_mutex.c` (FreeRTOS strong-override; включён в сборку `app` — до Фазы 3.2.4 был `#if 0`, гонка) |
---

View file

@ -63,6 +63,7 @@ add_executable(
src/app/task_sul_rx.c # приём CAN → decode → controller (WDOG/heartbeat безусловно, CAN-работа под !g_menu_active)
src/app/task_menu.c # модель меню: кнопки/hold-to-enter/nav/edit/save — НЕ рисует
src/app/task_render.c # презентация: единственный вызывающий gfx_present(), event-driven
src/app/dispatcher.c # диспетчерский вход opto IN1/IN2 (§3.4) — не задача, обвязка bsp_opto
${BSP_GENERATED}/clock_config.c # BOARD_BootClockRUN (зовётся board_hw_init)
${BSP_STARTUP_FILE}
${BSP_SYSCALLS_FILE}
@ -117,6 +118,7 @@ target_link_libraries(
PRIVATE bsp_board
bsp_led
bsp_button
bsp_opto # диспетчерский вход IN1/IN2 (§3.4), app/dispatcher.c
bsp_wdog
bsp_boot_state
bsp_qspi_flash
@ -128,6 +130,8 @@ target_link_libraries(
# Фаза 1: walking skeleton (CAN → декодер → контроллер → fallback-рендер)
tft_app_sul
tft_app_sul_transport_can
# Фаза 3.3: демо-протокол (транспорт — синтетический, без bsp)
tft_app_sul_transport_demo
tft_app_controller
tft_app_gfx
tft_app_ui_fallback

View file

@ -331,10 +331,10 @@ marks_both_pending`, аппаратно на реальном обрыве св
| **3.1 `services/settings_store`** ✅ | Персист ядра (magic/version/CRC, load/save/get/defaults) через `bsp_qspi_flash`, фикс-сектор `0x450000` (§10, размер-независимо). Сразу: `proto_slice[0]``nku_address` в декодер (замена хардкода `=0`). |
| **3.2 движок меню + рендер** ✅ (рендер — на одном буфере, см. 3.2.4) | **Чистая модель** (дерево-данные, навигация, edit, offset-привязка) — оперирует переданным `settings_t*`, **сама не сохраняет** (выставляет флаг save, app зовёт `settings_store_save()`) → host-тест без QSPI. **Рендер** (примитивы `gfx`: список/курсор/значение) в **переносимом окне 480×272 @ логич.(0,0)** (одинаково на всех панелях; на больших — левый-верхний угол, остальное чёрное). Вход — **долгое нажатие BUTTON_2** (~1.52 с); BUTTON_1=следующий, короткое BUTTON_2=выбор/инкремент. Опрос ввода — **софт-таймер 5 мс** (не задача; масштабируется на opto). Модальный экран. |
| **3.2.4 фундамент рендера (double-buffer + PXP)** ✅ | Перенос из Фазы 4. Убирает tearing/тормоза, снимает костыль частичной отрисовки. Гибрид bpp (AS=8888/PS+FB=565) + оконный композит меню — см. раздел ниже. Подтверждено на стенде (4 цикла) + реальной СУЛ. |
| **3.3 per-protocol дескрипторы** | `sul_settings_desc_t`: протокол регистрирует параметры (NKU-CAN: адрес 0..15); секция меню строится из дескриптора. |
| **3.4 opto-входы** | IN1/IN2 → вызов/ответ диспетчера → презентация (в fallback — примитив/текст; иконки-спрайты — Фаза 4/5). |
| **3.5 удалённая адресация NKU-CAN** | `0x4X1`/`0x5XB` → запись `nku_address` в настройки (долг Фазы 2). |
| **3.6 тумблер логов** | `log_enabled` в настройках + пункт меню + рантайм-гейт `log_set_enabled()` поверх компайл-тайм `LOG_LEVEL`. NB: продакшн-сборка — с `LOG_LEVEL >= INFO`, иначе гейтить нечего (макросы вырезаны). |
| **3.3 per-protocol дескрипторы** 🟨 | `sul_settings_desc_t` (§8): протокол регистрирует параметры, секция меню строится из дескриптора (НКУ-CAN: адрес 0..15). Плюс демо-протокол (второй драйвер реестра) — тест протокол-агностичности механизма. Код-комплит, host зелёный; ждёт стенда (см. раздел ниже). |
| **3.4 opto-входы** 🟨 | IN1/IN2 → вызов/ответ диспетчера → презентация (в fallback — примитив/текст; иконки-спрайты — Фаза 4/5). Код-комплит; ждёт стенда (см. раздел ниже). |
| **3.5 удалённая адресация NKU-CAN** | `0x4X1`/`0x5XB` → запись `nku_address` в настройки (долг Фазы 2). По спецификации `REMOTE_ADDRES_SETUP.pdf` + согласованному уточнению (X командного кадра сверяется с анонсом). Подтверждено на стенде 2026-07-23 (со второй попытки — wildcard-фильтры, см. раздел ниже). |
| **3.6 тумблер логов** | `log_enabled` в настройках + пункт меню + рантайм-гейт `log_set_enabled()` поверх компайл-тайм `LOG_LEVEL`. Подтверждено на стенде 2026-07-22. NB: продакшн-сборка — с `LOG_LEVEL >= INFO`, иначе гейтить нечего (макросы вырезаны). |
### 3.2.4 — Фундамент рендера: double-buffer + PXP (✅)
@ -513,6 +513,18 @@ marks_both_pending`, аппаратно на реальном обрыве св
> финальный образ.
>
> **Фаза 3.2.4 — ✅.**
>
> 🐛 **Найден и починен позже, при обычном использовании (не отдельная HW-сессия) — раздвоение
> курсора при быстрой навигации.** Root-cause: `menu_view_render(&g_menu)` читает `p_ctx->cur`
> заново на КАЖДОЙ итерации цикла отрисовки строк (до 6 раз за кадр), а не один раз в начале;
> `menu_task` выше по приоритету и может вытеснить `render_task` ПОСРЕДИ этого цикла на любое
> нажатие кнопки. Весь рендер занимает десятки мс (пункт выше — pxp≈30мс) против 5-мс каденции
> `menu_task` — при быстрой навигации окно гонки большое: `.cur` меняется между итерациями, и
> курсор оказывается нарисован сразу на двух строках одного кадра. **Фикс:** `menu_view_render()`
> снимает копию `*p_ctx` в локальную переменную на входе и работает только с ней — окно гонки
> схлопывается с длительности всего рендера до одного присваивания структуры (тот же класс фикса,
> что уже применялся для `proto_slice`/CAN-фильтров — снимок один раз, не перечитывать shared
> state в цикле). Не HIL-тестируется (рендер), проверяется на стенде.
@ -553,6 +565,332 @@ busy-wait Complete), затем свап: `xSemaphoreTake(frame_done)` (синх
(там же §PLAN Фаза 4 «Double buffering в gfx» — теперь закрывается здесь, в Фазе 4 остаётся только
PXP-блит спрайтов и layout).
### 3.3 — Протокол-дескрипторы + демо-протокол (🟨)
> **Статус: код-комплит, host-покрытие зелёное (24 host-теста tft_app_*, из них
> 23 новых/тронутых за этот под-шаг); Debug ARM-сборка линкуется чисто.
> Ожидает подтверждения на стенде** (по прецеденту Фаз 1/2/3.2.4 — статус ✅
> не раньше HW-проверки).
**Сделано:**
- **`sul_settings_desc_t`** (ARCH §8) — `domain/sul.h`: свой `sul_settings_type_t`
(BYTE/SELECT/BOOL) — НЕ переиспользует `menu_item_type_t` (домен не должен
знать презентацию, §4); `sul_settings_entry_t`/`sul_settings_desc_t`, поле
`.p_settings` в `sul_driver_t`. НКУ-CAN регистрирует адрес 0..15.
- **Реестр** (`sul_registry.c`) — `sul_registry_set_active(id)`/
`sul_registry_count()`; активный протокол — внутренний `s_active_id`,
app-слой толкает его из `settings_device_t.protocol_id` (реестр
settings_store НЕ читает — тот же паттерн, что `nku_can_set_address()`).
Новое поле `.p_ctx` в `sul_driver_t` — контекст decode() каждого драйвера
теперь статика, живёт постоянно в реестре (`sul_registry_init()`), не
пересоздаётся при смене протокола.
- **Меню** (`menu_tree.c`) — секция "Протокол" (`T_PROTO`/`T_PROTO_PARAM`)
строится из `sul_registry_active()->p_settings` через
`menu_tree_refresh_protocol_section(settings_t*)` (сигнатура с explicit
параметром — как у `menu_init()`, не через глобальный `settings_store_get_
mutable()`, чтобы не тянуть QSPI-адаптер в host-тест дерева); вызывается
при bringup и после каждого действия в меню. Дерево стало НЕ `const`
(было `static const K_TREE[]`) — секция протокола мутируется рантаймом.
- **Демо-протокол** (`domain/sul/demo/`, `domain/sul/transport/demo/`) —
второй драйвер реестра. Скриптованная "поездка" по кругу (25 шагов,
согласовано с пользователем): этаж 1↔11, промежуточные остановки на 7
(едет вверх) и 3 (едет вниз), гонг на остановках и на конечных этажах.
Транспорт тривиален (`sul_transport_demo_receive()` — всегда успешно и
мгновенно, без состояния); весь темп ведёт `demo_decode()` сам через
счётчик тиков в ctx (20/10/4 тика/шаг — медленно/норма/быстро) — decode()
остаётся ЧИСТОЙ функцией (host-тест по числу вызовов), содержимое кадра
игнорирует целиком (referenced-, но decode-less по сути, см.
HANDOFF_PHASE3_TAIL.md п.1). Даёт свой дескриптор — параметр "Скорость"
(SELECT), намеренно НЕ похож формой на "адрес" НКУ-CAN (BYTE) — тест того,
что дескрипторный механизм не завязан на адрес-подобный параметр.
- **`task_sul_rx.c` обобщена** — раньше жёстко держала `nku_can_ctx_t` на
стеке задачи и звала CAN-специфичные функции безусловно; теперь
диспетчеризация транспорта + реаппликации настроек — по
`sul_registry_active()->id`, ветка на 2 случая (НКУ-CAN/демо). decode()/
`.p_ctx`/меню/настройки остаются протокол-агностичными; только эта ветка
трогается Фазой 8 при протоколе на новом транспорте. **Осознанный
компромисс:** транспорт/apply-settings НЕ вынесены function pointer'ами в
`sul_driver_t` — это заставило бы `tft_app_sul` (реестр) линковать
`bsp_can`, ломая host-тестируемость реестра без bsp; транспорт остаётся
app-слоем (wiring, ARCH §4), домен/реестр — чистыми.
- **Найдена и закрыта реальная опасность** (не гипотетическая, вскрылась
при проектировании, не на стенде): `proto_slice[0]` — общий байт для
ЛЮБОГО активного протокола (адрес НКУ-CAN 0..15 vs скорость демо 0..2).
При переключении протокола в меню без клампа "протухшее" значение (напр.
адрес 15) читалось бы как `options[value]` за пределами массива меток
нового протокола — мусорное чтение памяти в рендере menu_view.c. Клампится
в `menu_tree_refresh_protocol_section()` в момент переключения — фикс в
корне (момент создания рассинхрона), не защита на каждом сайте чтения.
Host-тест `test_stale_value_clamped_on_protocol_switch` фиксирует.
**Host-тесты (новые за этот под-шаг):** `test_sul_registry` (9 на момент 3.3 —
выбор/защита от неизвестного id/дескрипторы обоих протоколов/раздельные ctx;
+1 позже в 3.5, см. её раздел),
`test_tft_app_menu_tree` (4 — секция строится из дескриптора для НКУ-CAN и
демо + кламп протухшего значения + edit end-to-end через реальный `menu.c`),
`test_sul_demo` (10 — маршрут/промежуточные остановки/цикл/скорость/игнор
содержимого кадра).
**Осознанно не тронуто в этом под-шаге (отдельный пункт хвоста):** 3.4 (опто)
— идёт следующим под-шагом, без пересечений по файлам с этим (3.5 закрыт
отдельно сразу вслед, см. ниже — тот же модуль `nku_can`, логично рядом).
### 3.4 — Опто-входы (диспетчерский вход) (🟨)
> **Статус: код-комплит (вторая итерация — первая словила баг на стенде, см.
> ниже), ARM Debug (111/111) линкуется и подписан
> (`build/Debug/signed/app_slot_a.bin`); host-покрытие без регрессий (24/24,
> новых тестов нет — см. ниже почему). Ждёт ПОВТОРНОГО подтверждения на
> стенде** (нужны реальные сигналы на IN1/IN2 — по прецеденту предыдущих
> под-шагов, ✅ не раньше HW-проверки).
**Дизайн (согласован с пользователем ДО кода):**
1. Диспетчерские вызов/ответ — сигналы с независимого оборудования (диспетчерский
пульт), не со станции управления лифтом — поэтому не идут через `controller`/
таблицу приоритетов режимов ([MODE_PRIORITY.md](../../docs/tft_app/MODE_PRIORITY.md),
та — только для ортогональных сигналов СУЛ); это отдельный вход прямо в
`ui.render()` (ARCH §4, §8 п.3 — «локальный вход», не `sul_result_t`).
2. Приоритет — высший из всех: безусловно перекрывает и обычную индикацию, и
любой режим СУЛ (пожар/перегруз/…), работает даже без связи со станцией
(dispatcher — независимое оборудование).
3. ОТВЕТ (IN2) перебивает ВЫЗОВ (IN1), если оба почему-то активны одновременно.
Референс поведения (не архитектуры) — `OLD_PROJECT_TFT8_UKL/source/main_programm.c`
(`tft_refresh_task`): там диспетчерская проверка идёт ПОСЛЕДНЕЙ в каждом кадре и
безусловно перезаписывает `icon_img_ptr`; здесь тот же эффект достигается проверкой
ПЕРВОЙ в `render()` с безусловным `return`.
**Реализация:**
- **`dispatcher_indication_t`** ([fallback.h](../../firmware/tft_app/src/ui/fallback/include/ui/fallback.h))
`NONE`/`CALL`/`ANSWER`; `ui_fallback_render_initial()`/`ui_fallback_render()`
берут его ОТДЕЛЬНЫМ параметром — не часть `sul_result_t`/`indication_task_t`
(не данные СУЛ, другая природа изменения).
- **`app/dispatcher.c`** (новый файл; НЕ задача — нет своего `while(true)`) —
тонкая обвязка уже готового BSP-драйвера `bsp_opto` (level-mode + debounce на
IN1/IN2, реализован заранее). `dispatcher_init()` конфигурирует оба канала
(`BSP_OPTO_MODE_LEVEL`, debounce 10 мс, **без колбэков**
`callbacks = {NULL, NULL, NULL}`) и сразу захватывает стартовое состояние
(`resolve_indication()`) — иначе уже висящий на старте вызов не отобразился
бы до первого `dispatcher_poll()`. `dispatcher_poll()` вызывается БЕЗУСЛОВНО
каждый тик `input_poll_cb` (не реактивно — см. находку ниже): заново читает
`bsp_opto_read()` для обоих каналов (`resolve_indication()` — ОТВЕТ
перебивает ВЫЗОВ без доп. состояния) и будит `render_task`, только если
итог реально изменился.
- **Найден и закрыт баг на стенде (первая итерация, реактивная схема).**
Первая версия `dispatcher.c` регистрировала колбэки bsp_opto
(`callbacks = {on_opto_change, on_opto_change, NULL}`) и пересчитывала
`g_dispatcher_indication` ТОЛЬКО изнутри колбэка, вызываемого
`bsp_opto_process()` на подтверждённую смену канала. На стенде: «ВЫЗОВ» не
сбрасывался при снятии сигнала IN1 — залипал; при этом ОТВЕТ на IN2
корректно перебивал (перекрытие по приоритету не сломано), но после снятия
IN2 индикация возвращалась не в NONE, а обратно в устаревший ВЫЗОВ; через
какое-то время само исправлялось. **Причина — не гонка в `bsp_opto`**
(пользователь подтвердил: тот же `bsp_opto` эксплуатируется в
`OLD_PROJECT_TFT8_UKL` без колбэков и без единой подобной проблемы), а
отсутствие самовосстановления в чисто реактивной схеме: у неё ровно один
шанс заметить каждое изменение (сам колбэк), и если он почему-то не
пересчитал итог заново с первой попытки — состояние виснет НАВСЕГДА, пока
не прилетит случайный следующий фронт на любом канале и не пересчитает всё
заново попутно. **Фикс** — как в `OLD_PROJECT_TFT8_UKL`
(`tft_refresh_task`/`bsp_opto_read()` каждую итерацию, `callbacks={NULL,...}`,
фронт детектирует сам потребитель): убрали колбэки, `dispatcher_poll()`
безусловно опрашивает `bsp_opto_read()` каждые 5 мс сам, без ожидания
уведомления от `bsp_opto` — тот же принцип, что и `bsp_button` (тоже без
колбэков, тоже опрашивается каждый тик, см. `bsp_button_poll()`). Единичный
сбой при этом не критичен — самовосстанавливается на следующем же тике.
- **`fallback.c`** — `render()` проверяет `dispatcher_label()` ПЕРВЫМ, до
`mode_label()`, с безусловным `return` при непустой метке (слова временные —
«ВЫЗОВ»/«ОТВЕТ», текст `SystemFont` в слоте режимов `MODE_Y`; полноэкранная
графика с иконками — Фаза 4/5).
- **`task_render.c`** — третий продюсер MPSC (были sul_rx_task + menu_task).
`g_dispatcher_indication` снимается в локальную переменную ОДИН раз за
итерацию (тот же приём, что чинил гонку курсора меню, Фаза 3.2.4) — без
снимка два обращения в теле цикла могли бы увидеть разные значения. Сравнение
с `last_dispatcher` — самостоятельная ТРЕТЬЯ причина полной перерисовки
(кроме старта и закрытия меню): opto может разбудить `render_task`, когда в
очереди СУЛ нет ни одного нового кадра.
- **Пауза на время меню** — как в `OLD_PROJECT_TFT8_UKL`: пока меню открыто,
`render_task` НЕ применяет смену диспетчера к экрану (оконный композит Фазы
3.2.4 перерисовывает только окно 480×272 — применить смену немедленно значило
бы либо сломать эту оптимизацию, либо рисовать поверх окна меню). Debounce и
опрос при этом НЕ паузятся — `g_dispatcher_indication` просто держит
последнее значение, ничего не теряется, отражается сразу по закрытии меню тем
же путём, что уже восстанавливает обычную индикацию.
- **`input_poll_cb`** ([task_menu.c](../../firmware/tft_app/src/app/task_menu.c))
— три вызова подряд, тем же софт-таймером (5 мс, демон программных таймеров
— высший приоритет в системе): `bsp_button_poll()`
`bsp_opto_process()` (debounce IN1/IN2) → `dispatcher_poll()` (читает
результат debounce, безусловно, каждый тик).
- **Пины подтверждены заранее** (`bsp/generated/TFT_Board.mex`/`pin_mux.h`) —
IN1/IN2 уже размечены под opto на уровне платы, доп. переразводки не
потребовалось.
- **Логирование строго по фронтам** (`LOG_TAG "dispatcher"`) — `dispatcher_poll()`
зовёт `log_transition()` ТОЛЬКО когда индикация реально изменилась (не на
каждый тик): ВЫКЛ→ВКЛ — `"mode %s appeared"`, ВКЛ→ВЫКЛ — `"mode %s disabled"`
(`%s` — сырое имя enum, CALL/ANSWER/NONE, без Cyrillic-меток — по образцу
остального технического текста в LOG_I, см. `task_sul_rx.c`). Прямой переход
CALL↔ANSWER (оба не NONE — ОТВЕТ перебивает ВЫЗОВ или наоборот) печатает ОБЕ
строки (выключение старого + появление нового) — та же семантика edge-log,
что и одиночный переход в/из NONE, без частного случая. Полезно для HW-отладки
именно такого класса бага, что нашёлся выше (сырые переходы каналов видны в
логе независимо от того, что показывает экран).
**Осознанно без новых host-тестов.** `dispatcher.c`/изменения в `task_render.c`/
`fallback.c` — app-слой (wiring задач, порядок MPSC-пробуждений, порядок
отрисовки) и презентация поверх уже готового `bsp_opto` (BSP, HW-регистры —
host не тестируется в принципе), не новая доменная логика: `dispatcher_label()`
и приоритет ОТВЕТ>ВЫЗОВ — тривиальная чистая функция без ветвления состояния, не
отдельный модуль со своим контрактом (в отличие, например, от
`sul_settings_desc_t` в 3.3). Тот же паттерн, что и остальной app-слой
(`task_*.c` исторически без host-тестов — ARCH.md: host-тестируем только L2
domain). 24/24 host-тестов остаются зелёными без регрессий.
**Тесты не пройденные (нужен стенд):** **приоритетно — сценарий из репорта
бага выше** (снять IN1 после ВЫЗОВА → должен вернуться NONE, не залипать;
затем подать/снять IN2 несколько раз подряд, включая с активным IN1 — ОТВЕТ
должен корректно перебивать и корректно возвращать НЕ устаревшее состояние
при снятии); сигнал на IN1 → «ВЫЗОВ» на экране; сигнал на IN2 → «ОТВЕТ»;
диспетчерский вход поверх активного спецрежима СУЛ (пожар/перегруз/…) — должен
перекрывать; диспетчерский вход при полностью оборванной связи со станцией
(«--») — тоже должен отображаться; открыть меню при активном диспетчерском
входе, закрыть — индикация должна восстановиться корректно; быстрое
чередование IN1/IN2 — debounce не должен давать дребезг метки.
### 3.5 — Удалённая адресация НКУ-CAN (✅)
> **Статус: подтверждено на стенде (2026-07-23), с реальной станцией** — после
> фикса wildcard-фильтров (см. ниже; первая HW-проверка дала «ноль реакции»
> именно из-за них). Host-покрытие зелёное (45 тестов decode + 10 реестра,
> +9/+1 новых); ARM Debug линкуется, подписан.
**Спецификация — `REMOTE_ADDRES_SETUP.pdf`** (предоставлен пользователем,
`Документы/Протоколы связи СУЛ/НКУ МППЛ/`), пять пунктов:
1. Кадр `0x4X1` — X всегда = адрес станции управления; читать в ОЗУ, не в флеш.
2. Кадр `0x5XB` (X — любой по тексту документа) — команда в байте 4 (`data[3]`,
0-индексация; старший нибль).
3. Команда «2» — записать адрес из п.1 в энергонезависимую память.
4. Повторная запись — только если новый адрес отличается от сохранённого.
5. Контроллер станции держит команду «2» некоторое время, затем сбрасывает в «0».
**Согласованное уточнение (расходится с буквой PDF п.2):** пользователь также
прислал референсную C-реализацию (`msg_receiver_task`/`apply_remote_addr_filters`
из легаси), которая ДОПОЛНИТЕЛЬНО сверяет X командного кадра `0x5XB` с X из
последнего анонса `0x4X1` — PDF говорит «X может иметь любое значение».
Решение (пользователь): следовать референсному коду, X должен совпадать.
**Реализация:**
- **`nku_can_ctx_t`** ([nku_can.h](../../firmware/tft_app/src/domain/sul/nku_can/include/domain/sul/nku_can.h))
— два новых поля: `remote_addr_candidate` (липкий, последний X из 0x4X1) и
`pending_remote_write_addr` (транзитный — валиден ТОЛЬКО сразу после вызова
decode(), в котором распознана команда «2» с совпавшим X; сбрасывается в
`NKU_REMOTE_ADDR_NONE` в начале КАЖДОГО вызова).
- **`check_remote_address()`** ([nku_can.c](../../firmware/tft_app/src/domain/sul/nku_can/src/nku_can.c))
— независимая side-проверка на сыром ID/data, не влияет на классификацию
PACKET1..5. Важный нюанс: `0x5XB` при X = наш собственный адрес — это
БУКВАЛЬНО тот же ID, что `PACKET4_BASE`; кадр обрабатывается в обоих
качествах независимо (host-тест `test_remote_check_does_not_interfere_with_
own_packet4` это фиксирует). Кадр с `len < 4` — не падает, просто не
триггерит (свой гейт, общий `PROTO_DLC`-гейт на wildcard-ID не распространяется).
- **decode() НЕ пишет settings** (остаётся чистой функцией) — сигнал уходит
через generic-канал `sul_take_pending_write_fn_t`/`sul_slice_write_t`
(domain/sul.h) — **не** через ветку `if (id == SUL_PROTOCOL_NKU_CAN)` в
`task_sul_rx.c`, как было в первой версии. Пользователь верно заметил
архитектурную утечку: транспорт-диспетчеризация (§6/§7 ADDING_PROTOCOL.md)
ОБЯЗАНА знать протокол (это wiring), а вот "инициировать запись settings"
— чистая бизнес-логика протокола, ей в `task_sul_rx.c` не место. Рефакторинг:
`nku_can_take_pending_write()` — чистая функция-переводчик (ctx →
`{slice_offset, value}`), регистрируется в `sul_driver_t.take_pending_write`
(`NULL` у демо); `task_sul_rx.c` теперь проверяет только указатель на `NULL`,
БЕЗ единой protocol-specific строчки для этой фичи. Идемпотентность (п.4
спецификации) — общий гейт в `task_sul_rx.c`, одинаковый для любого
протокола, не дублируется в каждом. Подробности и как использовать для
будущих протоколов — [ADDING_PROTOCOL.md §4](../../docs/tft_app/ADDING_PROTOCOL.md).
- **Заодно — протокол-агностичное логирование**: `sul_rx_task` при `have_update`
логирует весь `sul_result_t` одной строкой (`LOG_I`, не `LOG_D` — тумблер §3.6
должен мочь включить это в поле без пересборки), с именем активного протокола
— не только НКУ-CAN, любой (включая демо/таймаут→default).
- **Найдена и закрыта гонка записи флеша**, не гипотетическая: `menu_task`
переключает `g_menu_active=false` ДО своего `settings_store_save()` (сделано
намеренно на 3.2.3, чтобы `sul_rx_task` не держал мягкую паузу лишние мс) —
значит `sul_rx_task` мог увидеть «меню закрыто» и попытаться сохранить СВОЙ
(удалённый) адрес, пока `save()` из `menu_task` ещё физически пишет QSPI:
два параллельных erase+write в один сектор. Закрыто мьютексом в
[settings_store.c](../../firmware/tft_app/src/services/settings_store/src/settings_store.c)
вокруг тела `save()` (создаётся в `init_defaults()`/`load()` — оба
гарантированно однопоточны, до `xTaskCreate()` в `bringup_task`).
**Тесты (host, `test_sul_nku_can.c`):** анонс обновляет кандидата и сам
классифицируется как `IGNORED`; команда «2» с совпавшим X триггерит; с
несовпавшим X / командой ≠«2» — не триггерит; pending транзитен (не залипает
на следующий, не относящийся к адресации, кадр); короткий кадр не падает и не
триггерит; пересечение с PACKET4 не портит ни то, ни другое.
**Найден и закрыт баг на стенде (первая HW-проверка, «ноль реакции»).**
Первая итерация не ставила **wildcard CAN-фильтры**: `sul_transport_can_set_
address()` настраивал только 5 точных фильтров PACKET1..5 (маска `0x7FF`) под
ТЕКУЩИЙ адрес — FlexCAN аппаратно отбрасывал `0x4X1` всегда (анонс не
совпадает ни с одним PACKET-ID) и `0x5XB` при X ≠ наш адрес; до
`check_remote_address()` кадры не доходили вовсе. Host-тесты (все зелёные!)
этого не ловят принципиально — они кормят `decode()` напрямую, мимо
HW-фильтров: **фильтры — transport-слой, слепая зона host-покрытия** (граница
host/HIL из ARCH §13 — тут её цена). В референсном коде OLD_PROJECT эти
фильтры есть (`apply_remote_addr_filters()`, MB 5/6, маска `0x70F`
проверяются биты `[10:8]`+`[3:0]` ID, адресный нибл `[7:4]` игнорируется) —
при первом портировании остались за кадром, потому что жили в ДРУГОМ месте
референса (не в `msg_receiver_task`, откуда портировалась логика распознавания).
**Фикс** — [can_transport.c](../../firmware/tft_app/src/domain/sul/transport/can/src/can_transport.c):
MB 5 (`0x401`/`0x70F`) + MB 6 (`0x50B`/`0x70F`) добавлены в
`sul_transport_can_set_address()` после точных фильтров — от адреса не
зависят, но живут в той же функции (все фильтры в одном месте, нет второй
точки входа, которую можно забыть позвать).
**HW-проверка (2026-07-23, вторая, после фикса фильтров): пройдена** —
реальная станция, кадры `0x4X1`/`0x5XB`, удалённая запись адреса срабатывает.
### 3.6 — Тумблер логов (✅)
> **Статус: подтверждено на стенде (2026-07-22)** — переключение пункта «Логи»
> в меню включает/выключает UART-вывод на живом устройстве. Host-покрытие
> зелёное (28 тестов `test_log`, +5 новых); ARM Debug линкуется, подписан.
Общий бит (согласовано с пользователем — не гейт по тегам: `BOOL_ARRAY`-редактор
для тегового UI ещё не реализован, а `log_enabled` уже есть в `settings_t` и
пункте меню «Логи» без доп. объёма).
- **`log_set_enabled()`/`log_is_enabled()`** ([utils/log/log.h](../../utils/log/log.h),
[log.c](../../utils/log/log.c)) — рантайм-гейт ПОВЕРХ компайл-тайм `LOG_LEVEL`
(тот вырезает макросы физически — гейтить нечего ниже уровня). Гейт — первая
проверка в `log_write()`, до мьютекса/форматирования (дёшево выключенным).
По умолчанию (до первого вызова) — `true`: сообщения раннего bringup (до
загрузки настроек) не теряются молча.
- **Wiring** — [task_bringup.c](../../firmware/tft_app/src/app/task_bringup.c)
применяет из загруженных настроек один раз; [task_menu.c](../../firmware/tft_app/src/app/task_menu.c)
переприменяет после каждого действия в меню (тот же паттерн, что
протокол/адрес) — эффект сразу в этом сеансе меню, не только после `save()`.
- **Host-тесты (новые, `test_log.c`):** включено по умолчанию; `log_is_enabled()`
отражает состояние; выключенный тумблер гасит вывод и НЕ берёт мьютекс
(дешёвый путь); повторное включение восстанавливает вывод.
**HW-проверка (2026-07-22): пройдена** — переключение пункта «Логи» в меню
включает/выключает реальный UART-вывод на живом устройстве.
### Найдено при подготовке к 3.4 (опто) — таймаут связи был один на все протоколы
`CONNECTION_TIMEOUT_MS` (3000 мс) в `task_sul_rx.c` был единственной глобальной константой для
ЛЮБОГО активного протокола — не учитывало, что реальный период отправки разный по станциям
(где-то ~200 мс, где-то ~1 с), а некоторые протоколы шлют кадры ТОЛЬКО по изменению состояния
(event-driven на станции), для которых понятие «обрыва по тишине» в принципе некорректно.
**Фикс:** `connection_timeout_ms` — новое поле `sul_driver_t` ([sul.h](../../firmware/tft_app/src/domain/sul/include/domain/sul.h)),
задаётся протоколом при регистрации в `sul_registry.c` (**не** пользовательская настройка — оператор
не знает реальный период станции). НКУ-CAN — `3000` (как было, HW-подтверждено ранее). Демо —
`SUL_CONNECTION_TIMEOUT_DISABLED` (0) — синтетический источник, обрыва не бывает. `task_sul_rx.c`
проверяет `!= SUL_CONNECTION_TIMEOUT_DISABLED` перед сравнением с порогом — для протокола с
отключённым таймаутом «--» по тишине не появится никогда, целиком осознанно. Host-тест
`test_connection_timeout_set_per_protocol` фиксирует значения обоих протоколов. Подробности —
[ADDING_PROTOCOL.md §1](../../docs/tft_app/ADDING_PROTOCOL.md) (новое обязательное поле,
легко забыть — designated-initializer молча зануляет в DISABLED).
**Тесты (host).** Сериализация/дефолты/CRC ядра настроек; логика навигации меню; связывание
дескрипторов; распознавание команды удалённой адресации; рантайм-гейт логов.
@ -560,9 +898,13 @@ PXP-блит спрайтов и layout).
влияет на декодер (пока протокол один); opto-вход отображается (примитив в fallback); тумблер логов
работает; движок настроек/меню расширяется добавлением дескриптора/редактора без правок ядра.
**Документация (обязательный выход).** `docs/tft_app/SETTINGS.md` — без воды: состав `settings_t`,
ярусы A/B/C и их взаимосвязи, `proto_slice`/дескрипторы (§8), гибридное хранение (ядро-struct +
клиентский TLV), карта QSPI (§10, размер-независимость). По паттерну «доки по реализованному».
**Документация (обязательный выход) — ✅ готово.** [`docs/tft_app/SETTINGS.md`](../../docs/tft_app/SETTINGS.md) —
состав `settings_t` (таблица всех полей + что реально редактируется в меню сейчас), ярусы A/B/C и
их взаимосвязь, `proto_slice`/дескрипторы (§8, + готча со staleness из 3.3), гибридное хранение
(ядро-struct на QSPI + клиентский TLV — ярус C, пока только дизайн), карта QSPI (§10,
размер-независимость, честно про незанятый ping-pong-сектор). Раздел 9 отдельно перечисляет, что
осознанно не реализовано (ping-pong, ярус C, `reset_user_defaults()`, ярус A без UI) — по
конвенции «доки по реализованному», ничего не выдаётся за готовое раньше времени.
---

View file

@ -12,8 +12,17 @@
* - menu_task модель меню: потребление кнопок, hold-to-enter,
* навигация/edit/exit+save. НЕ рисует.
* - render_task единственный владелец дисплея/вызывающий gfx_present().
* Event-driven (будится xTaskNotifyGive от sul_rx_task И menu_task
* MPSC), не поллит, не содержит кнопочной логики.
* Event-driven (будится xTaskNotifyGive от sul_rx_task, menu_task И
* dispatcher-колбэка opto, §3.4 MPSC, три продюсера), не поллит, не
* содержит кнопочной логики.
*
* dispatcher (диспетчерские опто-входы, app/dispatcher.c) НЕ задача (нет
* своего while(true)): dispatcher_poll() зовётся из input_poll_cb (тот же
* софт-таймер, что button, см. task_menu.c) на КАЖДОМ тике, безусловно
* непрерывный опрос bsp_opto_read(), не реакция на колбэк (см. докстрок
* dispatcher.c про баг реактивной версии на стенде). Пишет
* g_dispatcher_indication и будит render_task напрямую из контекста демона
* программных таймеров, если итог изменился.
*
* main.c создаёt только очередь/софт-таймер ввода/bringup_task сами задачи
* друг друга создают/не создают по схеме выше, main.c про это не знает.
@ -29,6 +38,7 @@
#include "queue.h"
#include "task.h"
#include "timers.h"
#include "ui/fallback.h" /* dispatcher_indication_t */
#include <stdbool.h>
@ -98,11 +108,23 @@ extern volatile bool g_menu_active;
* как у g_render_queue). */
extern menu_ctx_t g_menu;
/** Хэндл render_task — sul_rx_task и menu_task (MPSC-продюсеры) будят его
* xTaskNotifyGive() на любое изменение состояния. Устанавливается
* bringup_task ДО создания sul_rx_task/menu_task. */
/** Хэндл render_task — sul_rx_task, menu_task и dispatcher-колбэк opto
* (MPSC-продюсеры, §3.4) будят его xTaskNotifyGive() на любое изменение
* состояния. Устанавливается bringup_task ДО создания sul_rx_task/menu_task. */
extern TaskHandle_t g_render_task_handle;
/**
* @brief Диспетчерский вход (opto IN1/IN2, §3.4) «локальный вход» (ARCH §8
* п.3, не данные СУЛ), высший приоритет из всех режимов индикации.
*
* Единственный писатель dispatcher_poll() (app/dispatcher.c), вызывается
* БЕЗУСЛОВНО на каждом тике input_poll_cb контекст демона программных
* таймеров, наивысший приоритет в системе; единственный читатель
* render_task (см. task_render.c снимок в локальную переменную один раз за
* итерацию, тот же приём, что чинили для гонки курсора меню, см. PLAN.md).
*/
extern volatile dispatcher_indication_t g_dispatcher_indication;
/** Одноразовая инициализация (UART/QSPI/settings/confirm_self/SDRAM+gfx+CAN),
* затем создаёт sul_rx_task/menu_task/render_task и удаляет себя. */
void bringup_task(void *p_arg);
@ -116,9 +138,33 @@ void menu_task(void *p_arg);
/** Презентация: владелец дисплея, единственный вызывающий gfx_present(). */
void render_task(void *p_arg);
/** Колбэк софт-таймера debounce (bsp_button_poll) — main создаёт таймер, каденция в main. */
/** Колбэк софт-таймера debounce (bsp_button_poll + bsp_opto_process, §3.4) —
* main создаёт таймер, каденция в main. */
void input_poll_cb(TimerHandle_t x_timer);
/**
* @brief Инициализировать диспетчерский вход (opto IN1/IN2, §3.4).
*
* bsp_opto_init() (без колбэков см. dispatcher.c) + захват начального
* состояния пинов в g_dispatcher_indication (иначе если вызов уже активен на
* момент старта устройства первый dispatcher_poll() ещё не случился, а до
* него экран не должен показывать NONE поверх уже висящего сигнала).
* Вызывать из main(), сразу после bsp_button_init() (пины уже настроены в
* BOARD_InitPins, как и у button) до старта планировщика.
*/
void dispatcher_init(void);
/**
* @brief Опросить диспетчерский вход (opto IN1/IN2, §3.4) и обновить
* g_dispatcher_indication.
*
* Вызывать БЕЗУСЛОВНО на каждом тике input_poll_cb, после bsp_opto_process()
* непрерывный опрос bsp_opto_read(), не реакция на колбэк bsp_opto (см.
* докстрок dispatcher.c: реактивная версия залипала на стенде). Будит
* render_task, только если итоговая индикация реально изменилась.
*/
void dispatcher_poll(void);
/** Диагностика трейлера слота (read-only, безопасно звать многократно) —
* общая для bringup_task (before/after-confirm) и sul_rx_task (периодический
* re-log). Определена в task_bringup.c. */

View file

@ -0,0 +1,124 @@
/**
* @file dispatcher.c
* @brief Диспетчерский вход (opto IN1/IN2, §3.4) «Вызов подан» / «Вызов
* принят». НЕ задача (нет своего while(true)) тонкая обвязка
* bsp_opto поверх уже готового BSP-драйвера (bsp/opto): каждый тик
* софт-таймера (dispatcher_poll(), см. input_poll_cb в task_menu.c)
* заново читает оба канала, пишет g_dispatcher_indication и будит
* render_task при изменении.
*
* ARCH §8 п.3: опто «локальный вход», не данные СУЛ; вливается на уровне
* презентации (ARCH §4, dataflow opto отдельным входом прямо в
* ui.render()), не через controller. Приоритет высший из всех режимов
* индикации (согласовано с пользователем), безусловно перекрывает и обычную
* позицию, и любой режим СУЛ; работает независимо от связи со станцией.
*
* НЕПРЕРЫВНЫЙ ОПРОС, БЕЗ колбэков bsp_opto (callbacks = {NULL,...}) как
* в OLD_PROJECT_TFT8_UKL (`tft_refresh_task`): там `bsp_opto_read()`
* читается каждую итерацию задачи, а фронт детектируется самим потребителем
* через пару state[0]/state[1], колбэк вообще не регистрируется. Первая
* версия этого файла была реактивной: пересчитывала состояние ТОЛЬКО изнутри
* колбэка `bsp_opto_process()`, вызываемого на подтверждённую смену канала.
* На стенде это дало баг (см. PLAN.md §3.4): «ВЫЗОВ» не сбрасывался при
* снятии физического сигнала залипал до случайного следующего фронта на
* ЛЮБОМ канале, который наконец пересчитывал состояние с нуля. Причина
* не гонка в bsp_opto (там её и не было, судя по истории OLD_PROJECT), а
* отсутствие самовосстановления: чисто реактивная схема имеет ровно один
* шанс заметить каждое изменение, и если он почему-то пропадает состояние
* замирает НАВСЕГДА. Непрерывный опрос вместо этого переспрашивает
* `bsp_opto_read()` заново каждые 5 мс единичный сбой (где бы он ни
* случился) чинится на следующем же тике, тот же принцип, что и у
* bsp_button (тоже без колбэков, тоже опрашивается каждый тик).
*
* RS_RX (bsp_opto третий канал, бинарный протокол) здесь не используется
* остаётся под LPUART3 (rs_as_gpio=false), это для другого будущего сценария.
*/
#include "app_tasks.h"
#include "bsp/opto.h"
#include "log/log.h"
#define LOG_TAG "dispatcher"
/**
* @brief Вычислить индикацию из ТЕКУЩЕГО состояния обоих каналов. ОТВЕТ
* (IN2) перебивает ВЫЗОВ (IN1), если оба почему-то активны
* одновременно (согласовано с пользователем).
*/
static dispatcher_indication_t resolve_indication(void)
{
const bool ANSWER_ACTIVE = (bsp_opto_read(BSP_OPTO_CH_IN2) == BSP_OPTO_STATE_ACTIVE);
const bool CALL_ACTIVE = (bsp_opto_read(BSP_OPTO_CH_IN1) == BSP_OPTO_STATE_ACTIVE);
return ANSWER_ACTIVE ? DISPATCHER_INDICATION_ANSWER
: CALL_ACTIVE ? DISPATCHER_INDICATION_CALL
: DISPATCHER_INDICATION_NONE;
}
static const char *indication_name(dispatcher_indication_t v)
{
switch (v)
{
case DISPATCHER_INDICATION_CALL: return "CALL";
case DISPATCHER_INDICATION_ANSWER: return "ANSWER";
case DISPATCHER_INDICATION_NONE:
default: return "NONE";
}
}
/**
* @brief Залогировать переход строго по фронтам (не по значению каждый
* тик dispatcher_poll() зовёт это ТОЛЬКО когда индикация реально
* изменилась). ВЫКЛ->ВКЛ и ВКЛ->ВЫКЛ раздельные строки, каждая по
* своему "режиму" (CALL/ANSWER); прямой переход CALL<->ANSWER (оба
* не NONE) это одновременно выключение старого И появление нового,
* печатаются обе строки.
*/
static void log_transition(dispatcher_indication_t old_state, dispatcher_indication_t new_state)
{
if (old_state != DISPATCHER_INDICATION_NONE)
{
LOG_I(LOG_TAG, "mode %s disabled", indication_name(old_state));
}
if (new_state != DISPATCHER_INDICATION_NONE)
{
LOG_I(LOG_TAG, "mode %s appeared", indication_name(new_state));
}
}
static const bsp_opto_config_t K_OPTO_CONFIG = {
.callbacks = { NULL, NULL, NULL }, /* непрерывный опрос — см. докстрок файла */
.modes = { BSP_OPTO_MODE_LEVEL, BSP_OPTO_MODE_LEVEL, BSP_OPTO_MODE_LEVEL },
.edges = { BSP_OPTO_EDGE_RISING, BSP_OPTO_EDGE_RISING, BSP_OPTO_EDGE_RISING },
.rs_as_gpio = false, /* RS_RX остаётся под LPUART3 — не наш случай (см. докстрок файла) */
.debounce_ms = 10U, /* середина рекомендованного диапазона 5-10 мс, bsp/opto/opto.h */
};
void dispatcher_init(void)
{
(void) bsp_opto_init(&K_OPTO_CONFIG);
/* Захват состояния СРАЗУ — до первого dispatcher_poll() (следующий тик
* софт-таймера) экран не должен показывать NONE, если вызов уже висит на
* момент старта устройства (станция держит сигнал постоянно, не
* импульсом). */
g_dispatcher_indication = resolve_indication();
}
void dispatcher_poll(void)
{
const dispatcher_indication_t NEW_STATE = resolve_indication();
if (NEW_STATE != g_dispatcher_indication)
{
log_transition(g_dispatcher_indication, NEW_STATE);
g_dispatcher_indication = NEW_STATE;
if (g_render_task_handle != NULL)
{
(void) xTaskNotifyGive(g_render_task_handle);
}
}
}

View file

@ -31,12 +31,14 @@ volatile bool g_display_ready = false;
volatile bool g_menu_active = false;
QueueHandle_t g_render_queue = NULL;
TaskHandle_t g_render_task_handle = NULL;
volatile dispatcher_indication_t g_dispatcher_indication = DISPATCHER_INDICATION_NONE;
int main(void)
{
board_hw_init(); /* BOARD_ConfigMPU + BOARD_InitPins + BOARD_BootClockRUN */
bsp_led_init();
(void) bsp_button_init(); /* GPIO настроен в BOARD_InitPins; сброс debounce */
dispatcher_init(); /* opto IN1/IN2 (§3.4) — пины тоже уже в BOARD_InitPins */
g_render_queue = xQueueCreate(1, sizeof(render_msg_t));
configASSERT(g_render_queue != NULL);

View file

@ -22,9 +22,11 @@
#include "bsp/qspi_flash.h"
#include "bsp/sdram.h"
#include "bsp/uart_host.h"
#include "domain/sul.h"
#include "domain/sul/transport/can.h"
#include "flash_map.h"
#include "log/log.h"
#include "menu/menu_tree.h"
#include "port/log_uart.h"
#include "services/gfx.h"
#include "services/settings_store.h"
@ -156,6 +158,16 @@ void bringup_task(void *p_arg)
settings_store_init_defaults();
}
/* Активный протокол + меню-секция "Протокол" — из загруженных/дефолтных
* настроек (§8), до первого возможного открытия меню в menu_task(). */
sul_registry_set_active(settings_store_get()->device.protocol_id);
menu_tree_refresh_protocol_section(settings_store_get_mutable());
/* Рантайм-тумблер логов (§3.6) — из настроек; ДО этой строки действует
* дефолт log.c (включено), чтобы сообщения выше (qspi/settings) не
* терялись молча, пока реальное значение ещё не загружено. */
log_set_enabled(settings_store_get()->device.log_enabled != 0U);
/* «Дошёл до устойчивого состояния» — сбрасывает счётчик попыток загрузки
* (recovery загрузчика). SRC GPR, без flash. Безусловно, до потенциально
* рискованного bring-up дисплея/CAN ниже. */

View file

@ -22,6 +22,8 @@
#include "FreeRTOS.h"
#include "bsp/button.h"
#include "bsp/opto.h"
#include "domain/sul.h"
#include "log/log.h"
#include "menu/menu.h"
#include "menu/menu_tree.h"
@ -44,7 +46,8 @@ void input_poll_cb(TimerHandle_t x_timer)
{
(void) x_timer;
bsp_button_poll();
/* Фаза 3.4: bsp_opto_process(); (диспетчерские вход/ответ) */
bsp_opto_process(); /* debounce IN1/IN2 (§3.4) — короткая, как button */
dispatcher_poll(); /* непрерывный опрос bsp_opto_read(), не колбэк — см. dispatcher.c */
}
void menu_task(void *p_arg)
@ -70,6 +73,18 @@ void menu_task(void *p_arg)
{
menu_action(&g_menu);
/* Протокол мог смениться этим действием (T_PROTO) — дёшево
* переприменить оба (§8), тот же паттерн, что адрес/CAN-
* фильтры в sul_rx_task. Секция T_PROTO_PARAM должна отражать
* НОВЫЙ активный протокол уже в этом сеансе меню, не только
* после save(). */
sul_registry_set_active(g_menu.settings->device.protocol_id);
menu_tree_refresh_protocol_section(g_menu.settings);
/* Тумблер логов (§3.6) — тот же паттерн: эффект сразу в этом
* сеансе меню, не только после save(). */
log_set_enabled(g_menu.settings->device.log_enabled != 0U);
/* Обновить ДО settings_store_save() (флеш-запись, не
* мгновенная) иначе sul_rx_task ещё несколько мс видел бы
* устаревший g_menu_active=true и держал бы мягкую паузу

View file

@ -2,11 +2,11 @@
* @file task_render.c
* @brief Презентация: единственный владелец дисплея и вызывающий gfx_present*().
*
* Event-driven (xTaskNotifyGive от sul_rx_task И menu_task MPSC, будят оба
* продюсера, ulTaskNotifyTake(pdTRUE,...) схлопывает несколько notify в одно
* пробуждение важно только «есть свежее состояние», не сколько раз оно
* менялось). НЕ содержит кнопочной логики разделено от menu_task на Фазе
* 3.2.4 (см. task_menu.c про причину).
* Event-driven (xTaskNotifyGive от sul_rx_task, menu_task И dispatcher-колбэка
* opto §3.4 MPSC, три продюсера, ulTaskNotifyTake(pdTRUE,...) схлопывает
* несколько notify в одно пробуждение важно только «есть свежее состояние»,
* не сколько раз оно менялось). НЕ содержит кнопочной логики разделено от
* menu_task на Фазе 3.2.4 (см. task_menu.c про причину).
*
* Меню оконный рендер (Фаза 3.2.4, ускорение навигации): на ОТКРЫТИИ полная
* очистка AS (стереть индикацию) + два полных gfx_present() подряд (double
@ -14,6 +14,13 @@
* gfx_present_rect); дальше НАВИГАЦИЯ перерисовка и композит только окна
* 480×272 (~27% кадра, пропорционально дешевле). Индикация полные кадры,
* как и была.
*
* Диспетчерский вход (§3.4) как в OLD_PROJECT_TFT8_UKL: пока меню открыто,
* индикация вообще не трогается (dispatcher копится в g_dispatcher_indication,
* применяется одним махом при закрытии тем же путём, что уже восстанавливает
* индикацию после меню). Вне меню САМОСТОЯТЕЛЬНАЯ третья причина перерисовки
* (не только новое сообщение в очереди/закрытие меню): opto может разбудить
* render_task без единого нового кадра СУЛ.
*/
#include "app_tasks.h"
@ -40,10 +47,11 @@ void render_task(void *p_arg)
}
sul_result_t last = sul_default_state();
ui_fallback_render_initial(&last);
ui_fallback_render_initial(&last, g_dispatcher_indication);
gfx_present();
bool was_menu_open = false;
dispatcher_indication_t last_dispatcher = g_dispatcher_indication;
for (;;)
{
@ -74,13 +82,22 @@ void render_task(void *p_arg)
{
bool present_needed = false;
if (was_menu_open)
/* Снимок один раз за итерацию — g_dispatcher_indication пишет
* колбэк opto из СВОЕГО контекста (демон таймеров, выше по
* приоритету, чем render_task); без снимка два обращения ниже
* могли бы увидеть РАЗНЫЕ значения за одну итерацию (тот же
* класс гонки, что чинили для курсора меню, см. PLAN.md). */
const dispatcher_indication_t DISPATCHER_NOW = g_dispatcher_indication;
if (was_menu_open || (DISPATCHER_NOW != last_dispatcher))
{
/* Меню только что закрылось — восстановить индикацию
* последним известным состоянием немедленно, не дожидаясь
* свежего сообщения (sul_rx_task мог простаивать под
* g_menu_active очередь пока пуста). */
ui_fallback_render_initial(&last);
/* Меню только что закрылось, ИЛИ диспетчерский вход
* изменился без нового кадра СУЛ в очереди (свой продюсер,
* не sul_rx_task) восстановить индикацию последним
* известным состоянием СУЛ + ТЕКУЩИМ dispatcher немедленно,
* не дожидаясь свежего кадра (sul_rx_task мог простаивать
* под g_menu_active очередь пока пуста). */
ui_fallback_render_initial(&last, DISPATCHER_NOW);
present_needed = true;
}
@ -88,7 +105,7 @@ void render_task(void *p_arg)
if (xQueueReceive(g_render_queue, &msg, 0) == pdTRUE)
{
last = msg.result;
ui_fallback_render(&msg.task, &msg.result);
ui_fallback_render(&msg.task, &msg.result, DISPATCHER_NOW);
present_needed = true;
}
@ -96,6 +113,8 @@ void render_task(void *p_arg)
{
gfx_present(); /* индикация — всегда полный кадр */
}
last_dispatcher = DISPATCHER_NOW;
}
was_menu_open = MENU_OPEN_NOW;

View file

@ -1,9 +1,12 @@
/**
* @file task_sul_rx.c
* @brief Приём CAN decode controller уведомление render_task.
* @brief Приём активного протокола (реестр sul, §8) decode controller
* уведомление render_task. Протокол-специфичный транспорт (CAN/демо)
* выбирается здесь по id активного драйвера единственное место,
* которое трогает Фаза 8 при добавлении протокола на новом транспорте.
*
* WDOG/heartbeat/периодический re-log трейлера БЕЗУСЛОВНО (housekeeping,
* не CAN-специфика). Сама CAN-работа (decode/controller/очередь) под
* не протокол-специфика). Сам приём (decode/controller/очередь) под
* `!g_menu_active`: мягкая пауза на время меню (см. app_tasks.h) задача НЕ
* suspend'ится, поэтому WDOG остаётся в безопасности по конструкции.
*
@ -12,43 +15,49 @@
* решает, делать ли CAN-работу вообще.
*/
#include "app_tasks.h"
#include "FreeRTOS.h"
#include "app_tasks.h"
#include "bsp/led.h"
#include "bsp/wdog.h"
#include "domain/controller.h"
#include "domain/elevator_model.h"
#include "domain/sul.h"
#include "domain/sul/demo.h"
#include "domain/sul/nku_can.h"
#include "domain/sul/transport/can.h"
#include "domain/sul/transport/demo.h"
#include "log/log.h"
#include "queue.h"
#include "services/settings_store.h"
#include "task.h"
#include <stdbool.h>
#define LOG_TAG "sul_rx"
#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 с на отметку потери связи).
* Меряется от last_frame_tick пауза меню не портит
* логику: если трафик реально стоял, «--» появится
* сразу по возврату из меню; если шёл SINCE_FRAME_MS
* обнулится первым же принятым кадром. */
/* «Пропадание трафика» — см. ARCH, поток данных: poll + timeout→default.
* Порог СВОЙСТВО ПРОТОКОЛА (p_driver->connection_timeout_ms, domain/sul.h),
* не константа здесь: разные станции шлют раз в ~200 мс или раз в ~1 с, а
* некоторые только по изменению состояния (тогда таймаут вообще отключён,
* SUL_CONNECTION_TIMEOUT_DISABLED) не пользовательская настройка, задаётся
* протоколом при регистрации в sul_registry.c. Меряется от last_frame_tick
* пауза меню не портит логику: если трафик реально стоял, «--» появится сразу
* по возврату из меню; если шёл SINCE_FRAME_MS обнулится первым же кадром. */
void sul_rx_task(void *p_arg)
{
(void) p_arg;
nku_can_ctx_t nku_ctx;
nku_can_init(&nku_ctx);
/* Ctx каждого зарегистрированного драйвера — постоянно, живёт в
* sul_registry.c (domain/sul.h, .p_ctx); переключение протокола не
* пересоздаёт его, только меняет, какой из них активен (§8). */
sul_registry_init();
controller_ctx_t ctrl_ctx;
controller_init(&ctrl_ctx);
@ -79,47 +88,112 @@ void sul_rx_task(void *p_arg)
if (g_display_ready && !g_menu_active)
{
/* Адрес станции из настроек (proto_slice[0]) — подхватывает правку из
* меню без межзадачного сигнала (запись/чтение uint8 атомарны).
* Обе стороны: decode (nku_ctx) И HW RX-фильтры FlexCAN вторая
* без переприменения фильтров под реальный адрес станции кадры с
* адресом != 0 отбрасывались бы на уровне CAN-контроллера, ещё до
const sul_driver_t *p_driver = sul_registry_active();
/* Транспорт + реаппликация настроек протокола — ЗДЕСЬ (app-слой =
* wiring, ARCH §4), не в домене/реестре: держит sul_driver_t.p_ctx
* и sul_registry_* host-тестируемыми без bsp. Новый протокол на
* новом транспорте (Фаза 8: УИМ/SD7/УЭЛ/УКЛ, в основном UART)
* новая ветка здесь; decode/меню/настройки не трогаются.
*
* НКУ-CAN: адрес из настроек (proto_slice[0]) подхватывает
* правку из меню без межзадачного сигнала (запись/чтение uint8
* атомарны). Обе стороны: decode-ctx И HW RX-фильтры FlexCAN
* вторая без переприменения фильтров под реальный адрес станции
* кадры с адресом != 0 отбрасывались бы CAN-контроллером ещё до
* decode (см. PLAN.md найдено на реальной станции, адрес 1). */
sul_frame_t frame;
bsp_status_t rx_rc;
if (p_driver->id == SUL_PROTOCOL_NKU_CAN)
{
const uint8_t NKU_ADDR = settings_store_get()->user.proto_slice[0];
nku_can_set_address(&nku_ctx, NKU_ADDR);
nku_can_set_address((nku_can_ctx_t *) p_driver->p_ctx, NKU_ADDR);
(void) sul_transport_can_set_address(NKU_ADDR); /* no-op, если адрес не менялся */
rx_rc = sul_transport_can_receive(CAN_RX_TIMEOUT_MS, &frame);
}
else /* SUL_PROTOCOL_DEMO — без реальной шины, всегда успешно */
{
const uint8_t SPEED_IDX = settings_store_get()->user.proto_slice[0];
demo_set_speed((demo_ctx_t *) p_driver->p_ctx, SPEED_IDX);
rx_rc = sul_transport_demo_receive(CAN_RX_TIMEOUT_MS, &frame);
}
sul_result_t decoded;
bool have_update = false;
sul_frame_t frame;
if (sul_transport_can_receive(CAN_RX_TIMEOUT_MS, &frame) == BSP_OK)
if (rx_rc == BSP_OK)
{
const sul_driver_t *p_driver = sul_registry_active();
if (p_driver->decode(&nku_ctx, &frame, &decoded) == SUL_STATUS_OK)
if (p_driver->decode(p_driver->p_ctx, &frame, &decoded) == SUL_STATUS_OK)
{
last_frame_tick = xTaskGetTickCount();
have_update = true;
}
/* IGNORED/ERR — Фаза 1 их отдельно не обрабатывает, следующая итерация. */
/* Протокол сам инициирует запись в свой proto_slice (§8) — напр.
* удалённая адресация НКУ-CAN (§3.5). Полностью generic: НЕТ
* ветки по id/протоколу take_pending_write() либо NULL
* (протокол никогда этого не делает), либо сообщает готовый
* {offset, value}, домен settings не касается вообще. Вызывается
* НЕЗАВИСИМО от SUL_STATUS_OK/IGNORED/ERR выше протокол мог
* распознать команду в кадре, который не подошёл ни под одну
* "индикационную" классификацию (напр. кадр от станции с чужим
* адресом, см. nku_can.c check_remote_address()). */
if (p_driver->take_pending_write != NULL)
{
sul_slice_write_t write;
if (p_driver->take_pending_write(p_driver->p_ctx, &write))
{
uint8_t *p_field =
&settings_store_get_mutable()->user.proto_slice[write.slice_offset];
/* Идемпотентность — ОБЩИЙ гейт для любого протокола, не
* его забота: без сравнения контроллер станции, держащий
* команду записи некоторое время (см. PDF НКУ-CAN п.5),
* писал бы флеш на КАЖДЫЙ повторный кадр. */
if (*p_field != write.value)
{
*p_field = write.value;
const bsp_status_t SAVE_RC = settings_store_save();
LOG_I(LOG_TAG, "%s: proto_slice[%u]=%u (rc=%d)", p_driver->p_name,
write.slice_offset, write.value, SAVE_RC);
}
}
}
}
if (p_driver->connection_timeout_ms != SUL_CONNECTION_TIMEOUT_DISABLED)
{
const uint32_t SINCE_FRAME_MS =
(uint32_t) (xTaskGetTickCount() - last_frame_tick) * portTICK_PERIOD_MS;
if (SINCE_FRAME_MS >= CONNECTION_TIMEOUT_MS)
if (SINCE_FRAME_MS >= p_driver->connection_timeout_ms)
{
/* poll + timeout→default (ARCH, поток данных) — controller сам
* определит, реальное ли это изменение (не сработает повторно
* на каждой итерации после первого перехода в default). */
/* poll + timeout→default (ARCH, поток данных) — controller
* сам определит, реальное ли это изменение (не сработает
* повторно на каждой итерации после перехода в default). */
decoded = sul_default_state();
have_update = true;
}
}
/* иначе — протокол event-driven на станции (шлёт только по
* изменению), детекция обрыва по тишине для него некорректна
* last_frame_tick тем не менее продолжает обновляться выше на
* каждый валидный кадр, просто здесь не используется. */
if (have_update)
{
const indication_task_t DIFF = controller_process(&ctrl_ctx, &decoded);
if (DIFF.pos_pending || DIFF.direction_pending || DIFF.mode_pending)
{
LOG_I(LOG_TAG,
"update from %s: pos=%s next=%s dir=%u arrival=%d move=%d overload=%d "
"fire=%d lading=%d maint=%d fireman=%d seismic=%d err=%d floor=%u",
p_driver->p_name, decoded.pos, decoded.next, (unsigned) decoded.direction,
decoded.arrival, decoded.movement, decoded.overload, decoded.fire_alarm,
decoded.lading, decoded.maintenance, decoded.fireman, decoded.seismic,
decoded.error, decoded.floor_num);
const render_msg_t MSG = { .task = DIFF, .result = decoded };
(void) xQueueOverwrite(g_render_queue, &MSG);
if (g_render_task_handle != NULL)

View file

@ -7,10 +7,14 @@ 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(demo)
add_subdirectory(transport/can)
add_subdirectory(transport/demo)
# tft_app_sul — реализация реестра. Явно знает обо всех драйверах (сейчас —
# один, nku_can); добавление протокола (Фаза 8) — новая запись в
# sul_registry.c + новая зависимость здесь.
# tft_app_sul — реализация реестра. Явно знает обо всех драйверах (nku_can,
# demo); добавление протокола (Фаза 8) — новая запись в sul_registry.c +
# новая зависимость здесь. Транспорты (transport/*) сюда НЕ линкуются —
# держит реестр host-тестируемым без bsp; wiring транспорт<->протокол —
# app-слой (task_sul_rx.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)
target_link_libraries(tft_app_sul PUBLIC tft_app_sul_headers tft_app_sul_nku_can tft_app_sul_demo)

View file

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

View file

@ -0,0 +1,58 @@
/**
* @file demo.h
* @brief Демо-протокол синтетический источник данных, не связан с реальной
* шиной/станцией (ARCH §8, Фаза 3.3). Витрина возможностей устройства
* без СУЛ на другом конце; заодно тест того, что дескрипторный
* механизм (§8) и реестр `sul` протокол-агностичны не только для
* реального (НКУ-CAN), но и для синтетического источника.
*
* Маршрут скриптованная "поездка" по кругу (согласовано с пользователем):
* этаж 1 11 с промежуточной остановкой на 7 (едет вверх), затем назад
* 11 1 с промежуточной остановкой на 3 (едет вниз), повтор.
*
* "Кадр" здесь формальность, не несёт содержимого (см.
* sul_transport_demo_receive(), transport/demo.h): decode() САМ ведёт счёт
* тиков (вызовов) внутри ctx и решает, когда двигать маршрут дальше
* остаётся чистой функцией (ctx, вызов) (новый ctx, результат),
* host-тестируется как обычный декодер (число тиков ожидаемый sul_result_t),
* без привязки к реальному времени (та живёт только в кадансе sul_rx_task).
*/
#ifndef DOMAIN_SUL_DEMO_H_
#define DOMAIN_SUL_DEMO_H_
#include "domain/elevator_model.h"
#include "domain/sul.h"
#ifdef __cplusplus
extern "C"
{
#endif
typedef struct
{
sul_result_t state;
uint8_t step; /**< индекс в маршруте, заворачивается по кругу */
uint8_t speed_idx; /**< 0..2 — медленно/норма/быстро (настройка) */
uint16_t ticks_since_step; /**< счётчик вызовов decode() до следующего шага */
} demo_ctx_t;
/** Сброс: маршрут на первом шаге, скорость — "Норма". */
void demo_init(demo_ctx_t *p_ctx);
/**
* @brief Задать скорость прохождения маршрута.
*
* Вызывать из app-слоя, значение из настроек (proto_slice[0], §8).
* @param speed_idx 0..2 (медленно/норма/быстро); вне диапазона клампится к 2.
*/
void demo_set_speed(demo_ctx_t *p_ctx, uint8_t speed_idx);
/** decode() для реестра sul — см. sul_decode_fn в domain/sul.h. Кадр игнорируется. */
sul_status_t demo_decode(void *p_ctx, const sul_frame_t *p_frame, sul_result_t *p_out);
#ifdef __cplusplus
}
#endif
#endif /* DOMAIN_SUL_DEMO_H_ */

View file

@ -0,0 +1,72 @@
#include "domain/sul/demo.h"
#include "demo_route.h"
#include <stdio.h>
#define DEMO_SPEED_MAX 2U
/* Тиков (вызовов decode()) на один шаг маршрута, по индексу скорости
* (0=медленно/1=норма/2=быстро). Тик = один опрос sul_rx_task (~100 мс,
* см. task_sul_rx.c) -> ~2с/~1с/~0.4с на шаг, полный круг ~50/25/10с. */
static const uint16_t K_TICKS_PER_STEP[DEMO_SPEED_MAX + 1U] = { 20U, 10U, 4U };
/* Спецрежим шага -> соответствующий булев сигнал sul_result_t (§6). Ровно
* один активен за раз (SUL_MODE_NORMAL -> все false) этого достаточно для
* скриптованной демонстрации; настоящая одновременность нескольких сигналов
* (как теоретически возможно у живого НКУ-CAN, PACKET2+PACKET4 независимо)
* демо не воспроизводит не тот масштаб задачи, при необходимости
* расширяется в demo_route.h (см. demo_step_t). */
static void apply_mode(sul_mode_t mode, sul_result_t *p_state)
{
p_state->overload = (mode == SUL_MODE_OVERLOAD);
p_state->fire_alarm = (mode == SUL_MODE_FIRE_ALARM);
p_state->lading = (mode == SUL_MODE_LADING);
p_state->maintenance = (mode == SUL_MODE_MAINTENANCE);
p_state->fireman = (mode == SUL_MODE_FIREMAN);
p_state->seismic = (mode == SUL_MODE_SEISMIC);
}
static void apply_step(demo_ctx_t *p_ctx)
{
const demo_step_t *p_step = &K_DEMO_ROUTE[p_ctx->step];
(void) snprintf(p_ctx->state.pos, SUL_POS_BUF_LEN, "%s", p_step->p_pos);
p_ctx->state.next[0] = '\0'; /* демо не показывает "следующий этаж" */
p_ctx->state.direction = p_step->direction;
p_ctx->state.movement = (p_step->direction != SUL_DIR_NONE);
p_ctx->state.arrival = p_step->arrival;
p_ctx->state.floor_num = p_step->floor_num;
apply_mode(p_step->mode, &p_ctx->state);
}
void demo_init(demo_ctx_t *p_ctx)
{
p_ctx->state = sul_default_state();
p_ctx->step = 0U;
p_ctx->speed_idx = 1U; /* "Норма" по умолчанию */
p_ctx->ticks_since_step = 0U;
apply_step(p_ctx);
}
void demo_set_speed(demo_ctx_t *p_ctx, uint8_t speed_idx)
{
p_ctx->speed_idx = (speed_idx <= DEMO_SPEED_MAX) ? speed_idx : DEMO_SPEED_MAX;
}
sul_status_t demo_decode(void *p_ctx, const sul_frame_t *p_frame, sul_result_t *p_out)
{
(void) p_frame; /* демо не читает содержимое кадра, только факт вызова (тик) */
demo_ctx_t *p_state = (demo_ctx_t *) p_ctx;
p_state->ticks_since_step++;
if (p_state->ticks_since_step >= K_TICKS_PER_STEP[p_state->speed_idx])
{
p_state->ticks_since_step = 0U;
p_state->step = (uint8_t) ((p_state->step + 1U) % DEMO_ROUTE_LEN);
apply_step(p_state);
}
*p_out = p_state->state;
return SUL_STATUS_OK;
}

View file

@ -0,0 +1,34 @@
#include "demo_route.h"
/* Скриптованный маршрут (согласовано с пользователем, Фаза 3.3): этаж 1 -> 11
* с промежуточной остановкой на 7 (едет вверх), затем 11 -> 1 с промежуточной
* остановкой на 3 (едет вниз), повтор. Остановки/концы короткий гонг
* (arrival). mode = SUL_MODE_NORMAL везде спецрежим (перегруз, пожар, ...)
* на конкретном шаге задаётся этим полем, см. demo_step_t в demo_route.h. */
const demo_step_t K_DEMO_ROUTE[DEMO_ROUTE_LEN] = {
{ "1", 1U, SUL_DIR_NONE, false, SUL_MODE_NORMAL },
{ "1", 1U, SUL_DIR_UP, false, SUL_MODE_NORMAL },
{ "2", 2U, SUL_DIR_UP, false, SUL_MODE_NORMAL },
{ "3", 3U, SUL_DIR_UP, false, SUL_MODE_NORMAL },
{ "4", 4U, SUL_DIR_UP, false, SUL_MODE_NORMAL },
{ "5", 5U, SUL_DIR_UP, false, SUL_MODE_NORMAL },
{ "6", 6U, SUL_DIR_UP, false, SUL_MODE_NORMAL },
{ "7", 7U, SUL_DIR_NONE, true, SUL_MODE_NORMAL }, /* промежуточная остановка вверх */
{ "7", 7U, SUL_DIR_UP, false, SUL_MODE_NORMAL },
{ "8", 8U, SUL_DIR_UP, false, SUL_MODE_NORMAL },
{ "9", 9U, SUL_DIR_UP, false, SUL_MODE_NORMAL },
{ "10", 10U, SUL_DIR_UP, false, SUL_MODE_NORMAL },
{ "11", 11U, SUL_DIR_NONE, true, SUL_MODE_NORMAL }, /* верхний этаж */
{ "11", 11U, SUL_DIR_DOWN, false, SUL_MODE_NORMAL },
{ "10", 10U, SUL_DIR_DOWN, false, SUL_MODE_NORMAL },
{ "9", 9U, SUL_DIR_DOWN, false, SUL_MODE_NORMAL },
{ "8", 8U, SUL_DIR_DOWN, false, SUL_MODE_NORMAL },
{ "7", 7U, SUL_DIR_DOWN, false, SUL_MODE_NORMAL },
{ "6", 6U, SUL_DIR_DOWN, false, SUL_MODE_NORMAL },
{ "5", 5U, SUL_DIR_DOWN, false, SUL_MODE_NORMAL },
{ "4", 4U, SUL_DIR_DOWN, false, SUL_MODE_NORMAL },
{ "3", 3U, SUL_DIR_NONE, true, SUL_MODE_NORMAL }, /* промежуточная остановка вниз */
{ "3", 3U, SUL_DIR_DOWN, false, SUL_MODE_NORMAL },
{ "2", 2U, SUL_DIR_DOWN, false, SUL_MODE_NORMAL },
{ "1", 1U, SUL_DIR_NONE, true, SUL_MODE_NORMAL }, /* нижний этаж, затем цикл сначала */
};

View file

@ -0,0 +1,48 @@
/**
* @file demo_route.h
* @brief Скриптованный маршрут демо-протокола ДАННЫЕ, не логика (ARCH §3,
* принцип 4: "данные, а не код"). Вынесены отдельно от decode()-логики
* (demo.c) для удобства правки число остановок, диапазон/номера
* этажей, спецрежимы правятся здесь, не трогая demo.c.
*
* Приватный заголовок (src/, НЕ include/) используется только demo.c
* внутри библиотеки tft_app_sul_demo, наружу (registry, app) не торчит.
*/
#ifndef DOMAIN_SUL_DEMO_ROUTE_H_
#define DOMAIN_SUL_DEMO_ROUTE_H_
#include "domain/elevator_model.h"
#include <stdbool.h>
#include <stdint.h>
/**
* @brief Один шаг маршрута.
*
* `p_pos` и `floor_num` РАЗНЫЕ вещи, легко перепутать:
* - `p_pos` что РИСУЕТСЯ на экране (UTF-8 строка, как `sul_result_t.
* pos`). Может быть что угодно, что умеет активный шрифт:
* "7", "10", "П" (подвал), "-1" не обязано быть числом.
* - `floor_num` производный ЧИСЛОВОЙ этаж (как `sul_result_t.floor_num`),
* для БУДУЩЕЙ озвучки (Фаза 6, audio_policy) рендера не
* касается вообще. У обычных этажей совпадает по смыслу с
* `p_pos` (напр. "7" и 7U), но не обязан: нечисловые позиции
* кодируются числом по-своему (см. `floor_number_parser()`
* в nku_can.c тот же паттерн у реального протокола,
* подвал/минус получают свой числовой диапазон).
*/
typedef struct
{
const char *p_pos;
uint8_t floor_num;
sul_direction_t direction;
bool arrival; /**< гонг на этом шаге */
sul_mode_t mode; /**< спецрежим (SUL_MODE_NORMAL — нет режима) */
} demo_step_t;
#define DEMO_ROUTE_LEN 25U
extern const demo_step_t K_DEMO_ROUTE[DEMO_ROUTE_LEN];
#endif /* DOMAIN_SUL_DEMO_ROUTE_H_ */

View file

@ -9,10 +9,10 @@
* decode(). Это позволяет декодеру оставаться описанным одной чистой функцией
* и не тянуть за собой выделение памяти/жизненный цикл.
*
* Фаза 1 одна запись реестра (nku_can), активный драйвер хардкожен.
* Фаза 3 добавит выбор активного протокола из настроек сигнатура
* `sul_registry_active()` не изменится, изменится только то, что она
* возвращает.
* Активный протокол выбирается из настроек (`settings_device_t.protocol_id`,
* ARCH §8) через `sul_registry_set_active()` вызывающий (app-слой) читает
* настройки и толкает id сюда; сам registry настройки не читает (домен не
* знает про settings_store, см. паттерн `nku_can_set_address()`).
*/
#ifndef DOMAIN_SUL_H_
@ -20,6 +20,7 @@
#include "domain/elevator_model.h"
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
@ -28,65 +29,197 @@ extern "C"
{
#endif
/** Результат decode() одного кадра/пакета. */
typedef enum
{
/** Результат decode() одного кадра/пакета. */
typedef enum
{
SUL_STATUS_OK = 0, /**< кадр распознан, ctx и *p_out обновлены */
SUL_STATUS_IGNORED, /**< кадр не для этого драйвера — *p_out не тронут */
SUL_STATUS_ERR, /**< кадр совпал по ID, но малформирован (DLC и т.п.) */
} sul_status_t;
} sul_status_t;
/**
/**
* @brief Кадр транспортного уровня, нейтральный к шине (CAN/UART/...).
*
* Транспорт-адаптер (HW) заполняет её из своего протокола (для CAN id и
* data/len из bsp_can_frame_t); декодер (чистый C) её только читает.
*/
typedef struct
{
typedef struct
{
uint32_t id; /**< CAN ID либо адрес/маркер кадра другого транспорта */
uint8_t bus; /**< на будущее — несколько шин одного типа (0 = единственная) */
const uint8_t *p_data;
uint16_t len;
} sul_frame_t;
} 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 sul_status_t (*sul_decode_fn_t)(void *p_ctx, const sul_frame_t *p_frame,
sul_result_t *p_out);
typedef struct
{
/**
* @brief `sul_driver_t.connection_timeout_ms` протокол не поддерживает
* детекцию обрыва связи по тишине (шлёт кадры ТОЛЬКО по изменению
* состояния на станции, не периодически) таймаут-логика в
* task_sul_rx.c для него отключена целиком, «--» по тишине не
* покажется никогда для этого протокола.
*/
#define SUL_CONNECTION_TIMEOUT_DISABLED 0U
/**
* @brief Запрос протокола на запись байта в СВОЙ proto_slice (ARCH §8)
* напр. удалённая установка адреса у НКУ-CAN.
*
* НЕ путать с decode()/sul_result_t это отдельный канал специально под
* settings, не под индикацию.
*/
typedef struct
{
uint8_t slice_offset; /**< куда в settings_t.user.proto_slice[] (§8) */
uint8_t value;
} sul_slice_write_t;
/**
* @brief Забрать запрос протокола на самостоятельную запись в proto_slice.
*
* Чистая функция читает НАКОПЛЕННОЕ состояние ctx после последнего
* decode(), settings_store не трогает вообще: домен не пишет настройки сам
* (см. докстрок файла), только сигнализирует через это; саму запись +
* идемпотентность (сравнение с текущим сохранённым значением ОБЩИЙ гейт,
* одинаковый для любого протокола) делает app-слой (task_sul_rx.c).
*
* @param p_ctx ctx драйвера (см. sul_driver_t.p_ctx)
* @param p_out заполняется только при возврате true
* @return true есть запрос, *p_out валиден; false писать нечего.
*/
typedef bool (*sul_take_pending_write_fn_t)(void *p_ctx, sul_slice_write_t *p_out);
/**
* @brief Тип редактируемого параметра протокола (ARCH §8).
*
* Подмножество `menu_item_type_t` без зависимости domainmenu (§4, домен не
* знает презентацию); трансляция в `menu_item_type_t` живёт на стороне menu/.
*/
typedef enum
{
SUL_SETTINGS_BYTE = 0U, /**< число min..max */
SUL_SETTINGS_SELECT, /**< выбор из p_options[0..max] */
SUL_SETTINGS_BOOL, /**< да/нет */
} sul_settings_type_t;
/**
* @brief Один параметр протокола строка данных (ARCH §8), не код.
*
* `slice_offset` смещение ВНУТРИ `settings_t.user.proto_slice[]`
* (0..SETTINGS_PROTO_SLICE_LEN-1 из settings_store.h), не внутри всего
* `settings_t` домен не включает settings_store.h, трансляцию в реальный
* offsetof() делает menu/ (единственный слой, знающий оба типа).
*/
typedef struct
{
const char *p_label;
sul_settings_type_t type;
uint8_t slice_offset;
uint8_t min; /**< для SELECT/BYTE/BOOL */
uint8_t max;
/** Метки для SELECT/BOOL (p_options[value]); NULL — рендер числом. */
const char *const *p_options;
} sul_settings_entry_t;
/** @brief Набор параметров протокола, регистрируется драйвером (ARCH §8). */
typedef struct
{
const sul_settings_entry_t *p_entries;
uint8_t count;
} sul_settings_desc_t;
typedef struct
{
uint8_t id; /**< стабильный идентификатор протокола */
const char *p_name; /**< для меню (Фаза 3) */
sul_decode_fn decode;
} sul_driver_t;
const char *p_name; /**< для меню (выбор протокола) */
sul_decode_fn_t decode;
/** Параметры протокола для меню (§8); NULL — протокол без настроек. */
const sul_settings_desc_t *p_settings;
/**
* @brief Контекст decode() статика, владеет драйвер (sul_registry.c),
* живёт постоянно (не пересоздаётся при смене активного
* протокола). Инициализируется `sul_registry_init()`.
*
* Транспорт (какую шину/источник опрашивать) сюда НЕ входит это
* HW-специфика, остаётся wiring'ом app-слоя (task_sul_rx.c), чтобы
* реестр/декодеры оставались host-тестируемыми без bsp.
*/
void *p_ctx;
/**
* @brief Протокол сам инициирует запись в свой proto_slice (§8) напр.
* удалённая адресация. NULL протокол никогда этого не делает
* (большинство); app-слой (task_sul_rx.c) проверяет указатель на
* NULL перед вызовом, никакой протокол-специфичной ветки там нет.
*/
sul_take_pending_write_fn_t take_pending_write;
/**
* @brief Таймаут «потери связи» (мс) сколько task_sul_rx.c ждёт без
* валидного кадра ЭТОГО протокола, прежде чем считать связь
* потерянной ( sul_default_state(), «--» на экране).
*
* ОБЯЗАТЕЛЬНОЕ поле, не пользовательская настройка: реальный период
* отправки у станции знание протокола (разные семейства/станции шлют
* раз в ~200 мс или раз в ~1 с оператор этого не знает и не должен
* настраивать), жёстко задаётся здесь при регистрации в sul_registry.c.
* `SUL_CONNECTION_TIMEOUT_DISABLED` (0) протокол event-driven на
* станции (шлёт только по изменению) для него понятие «обрыва по
* тишине» в принципе некорректно, таймаут выключен целиком.
*/
uint32_t connection_timeout_ms;
} sul_driver_t;
/**
/**
* @brief Идентификаторы протоколов стабильны, не переиспользовать.
*
* Список открыт (ARCH §2.2): УЭЛ, УКЛ, НКУ-SD7, УИМ добавляются в Фазе 8.
*/
enum
{
enum
{
SUL_PROTOCOL_NKU_CAN = 0U,
};
SUL_PROTOCOL_DEMO =
1U, /**< синтетический источник, ARCH §8 — витрина/тест дескрипторного механизма */
};
/**
* @brief Активный драйвер. Фаза 1 единственная запись, хардкод.
/**
* @brief Инициализировать ctx ВСЕХ зарегистрированных драйверов (вызывать
* один раз при bringup, до первого sul_registry_active()->decode()).
*/
void sul_registry_init(void);
/**
* @brief Активный драйвер см. `sul_registry_set_active()`.
* @return указатель на статический дескриптор, никогда NULL.
*/
const sul_driver_t *sul_registry_active(void);
const sul_driver_t *sul_registry_active(void);
/**
* @brief Найти драйвер по id (для будущего выбора по настройкам, Фаза 3).
/**
* @brief Найти драйвер по id.
* @return NULL, если протокол не зарегистрирован.
*/
const sul_driver_t *sul_registry_find(uint8_t id);
const sul_driver_t *sul_registry_find(uint8_t id);
/**
* @brief Выбрать активный протокол (ARCH §8 вызывается app-слоем из
* `settings_device_t.protocol_id`, дёшево при каждом вызове,
* см. паттерн `nku_can_set_address()`/CAN-фильтров).
*
* @param id id протокола. Неизвестный id игнорируется, активный не меняется
* (защита от мусора в настройках; `sul_registry_active()` остаётся
* валидным).
*/
void sul_registry_set_active(uint8_t id);
/** @brief Число зарегистрированных протоколов (для построения меню). */
uint8_t sul_registry_count(void);
#ifdef __cplusplus
}

View file

@ -5,8 +5,11 @@
* Фаза 2 полный разбор индикации: PACKET1 (направление, режимы, начало
* движения), PACKET2 (перегруз), PACKET3 (позиция, гонг, временная погрузка),
* PACKET4 (перегруз-вариант, сейсмо), PACKET5 (следующий этаж). Удалённая
* установка адреса (0x4X1/0x5XB) Фаза 3 (нужен settings_store для записи);
* здесь эти кадры игнорируются как чужие ID.
* установка адреса (0x4X1/0x5XB, Фаза 3.5, REMOTE_ADDRES_SETUP.pdf)
* decode() только распознаёт и выставляет запрос в ctx; саму запись в
* settings_store делает app-слой через generic-канал sul_take_pending_
* write_fn_t (domain/sul.h) nku_can_take_pending_write() ниже, decode()
* остаётся чистой функцией и не пишет настройки сам.
*
* Адрес станции (nku_address) захардкожен в 0 фильтры/ID без сдвига группы.
* Фаза 3 параметризует через настройки; сигнатура decode() не изменится.
@ -26,6 +29,10 @@ extern "C"
{
#endif
/** Сентинел «анонса/запроса ещё не было» для полей удалённой адресации ниже —
* вне диапазона валидных X (4-битный нибл ID, 0..15). */
#define NKU_REMOTE_ADDR_NONE 0xFFU
/**
* @brief Состояние декодера накопленный текущий sul_result_t + внутренние
* латчи для полей с несколькими источниками.
@ -52,9 +59,28 @@ typedef struct
uint8_t current_level; /**< PACKET1: data[3] & 0x3F — числовой уровень остановки,
для гейта «следующего этажа» в PACKET5 */
uint8_t nku_address; /**< адрес станции 0..15 — сдвиг ID пакетов (из настроек) */
/**
* @brief Удалённая установка адреса (REMOTE_ADDRES_SETUP.pdf, §3.5).
*
* 1. Кадр 0x4X1 X (биты [7:4] ID) объявляет адрес станции управления,
* копируется сюда безусловно (не во флеш это делает app-слой).
* 2. Кадр 0x5XB команда в старшем нибле data[3]; "2" запускает запись,
* но только если X ЭТОГО кадра совпадает с последним объявленным
* (согласовано строже буквы PDF, которая номинально допускает
* любой X у командного кадра).
* decode() НЕ пишет settings только выставляет pending_remote_write_
* addr; nku_can_take_pending_write() ниже транслирует его в generic
* sul_slice_write_t для app-слоя (идемпотентность сравнение с текущим
* сохранённым значением общий гейт для ЛЮБОГО протокола, живёт в
* task_sul_rx.c, не здесь).
*/
uint8_t remote_addr_candidate; /**< последний X из 0x4X1; NKU_REMOTE_ADDR_NONE — анонса не было */
uint8_t pending_remote_write_addr; /**< валиден ТОЛЬКО сразу после decode() этого вызова; NKU_REMOTE_ADDR_NONE — команды в этом кадре не было */
} nku_can_ctx_t;
/** Сброс к состоянию по умолчанию (sul_default_state()); адрес станции = 0. */
/** Сброс к состоянию по умолчанию (sul_default_state()); адрес станции = 0;
* состояние удалённой адресации «анонса/запроса не было». */
void nku_can_init(nku_can_ctx_t *p_ctx);
/**
@ -74,6 +100,20 @@ void nku_can_set_address(nku_can_ctx_t *p_ctx, uint8_t nku_address);
*/
sul_status_t nku_can_decode(void *p_ctx, const sul_frame_t *p_frame, sul_result_t *p_out);
/**
* @brief Реализация sul_take_pending_write_fn_t (domain/sul.h) для НКУ-CAN
* удалённая установка адреса (§3.5).
*
* @param p_ctx nku_can_ctx_t* после последнего decode()
* @param p_out slice_offset=0 (proto_slice[0] = адрес), value = запрошенный
* адрес; заполняется только при возврате true
* @return true, если pending_remote_write_addr валиден в этом ctx (декодер
* только что распознал команду записи см. nku_can_ctx_t выше).
* Идемпотентность НЕ его забота сравнение с текущим сохранённым
* значением делает вызывающий (общий гейт для любого протокола).
*/
bool nku_can_take_pending_write(void *p_ctx, sul_slice_write_t *p_out);
#ifdef __cplusplus
}
#endif

View file

@ -17,6 +17,18 @@
#define NKU_ADDRESS_MAX 15U /* адрес 0..15; group4 = addr<<4 */
/* ── Удалённая установка адреса (REMOTE_ADDRES_SETUP.pdf, §3.5) ──────────────
* Маска проверяет биты [10:8] и [3:0] ID, игнорирует адресный нибл X [7:4]
* так распознаём кадр НЕЗАВИСИМО от X (адрес станции управления, не наш). */
#define REMOTE_ADDR_ID_MASK 0x70FU
#define REMOTE_ANNOUNCE_ID 0x401U /* 0x4X1 — анонс адреса станции управления */
#define REMOTE_CMD_ID 0x50BU /* 0x5XB — несущая команды (тот же ID, что PACKET4_BASE:
* наш собственный 0x50B|group4 тоже сюда попадает
* не конфликт, команда/PACKET4 распознаются независимо */
#define REMOTE_CMD_WRITE_ADDR 0x2U /* команда "2" в старшем нибле data[3] — записать адрес */
#define REMOTE_CMD_DLC_MIN 4U /* нужен минимум data[3] — короче реального PROTO_DLC,
* но 0x5XB с чужим X не проходит общий DLC-гейт ниже */
#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] — код режима */
@ -62,6 +74,8 @@ void nku_can_init(nku_can_ctx_t *p_ctx)
p_ctx->lading_instr = false;
p_ctx->current_level = 0U;
p_ctx->nku_address = 0U; /* Фаза 3.1: caller задаёт из настроек через set_address() */
p_ctx->remote_addr_candidate = NKU_REMOTE_ADDR_NONE;
p_ctx->pending_remote_write_addr = NKU_REMOTE_ADDR_NONE;
}
void nku_can_set_address(nku_can_ctx_t *p_ctx, uint8_t nku_address)
@ -233,10 +247,48 @@ static bool decode_packet5(nku_can_ctx_t *p_ctx, const uint8_t *p_data)
return true;
}
/**
* @brief Удалённая установка адреса (REMOTE_ADDRES_SETUP.pdf) независимая
* от nku_address side-проверка на СЫРОМ ID/data кадра, не влияет на
* классификацию PACKET1..5 ниже (один и тот же 0x5XB может быть и
* нашим PACKET4, и несущей команды одновременно не конфликт).
*
* Транзитная (не латч): p_ctx->pending_remote_write_addr сбрасывается перед
* каждым вызовом в nku_can_decode() и выставляется заново только если ИМЕННО
* этот кадр валидная команда записи.
*/
static void check_remote_address(nku_can_ctx_t *p_ctx, const sul_frame_t *p_frame)
{
const uint32_t ID = p_frame->id;
if ((ID & REMOTE_ADDR_ID_MASK) == REMOTE_ANNOUNCE_ID)
{
/* 0x4X1: X (биты [7:4]) — адрес станции управления. В ОЗУ, не во
* флеш (PDF п.1) запись делает app-слой по команде ниже. */
p_ctx->remote_addr_candidate = (uint8_t) ((ID >> 4U) & 0x0FU);
}
else if (((ID & REMOTE_ADDR_ID_MASK) == REMOTE_CMD_ID) && (p_frame->len >= REMOTE_CMD_DLC_MIN))
{
/* 0x5XB: команда в старшем нибле data[3]. "2" запускает запись, но
* только если X ЭТОГО кадра совпадает с последним объявленным адресом
* (согласовано с пользователем строже буквы PDF п.2, которая
* номинально допускает любой X у командного кадра). */
const uint8_t CMD = (uint8_t) ((p_frame->p_data[3] >> 4U) & 0x0FU);
const uint8_t CMD_X = (uint8_t) ((ID >> 4U) & 0x0FU);
if ((CMD == REMOTE_CMD_WRITE_ADDR) && (CMD_X == p_ctx->remote_addr_candidate))
{
p_ctx->pending_remote_write_addr = p_ctx->remote_addr_candidate;
}
}
}
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;
p_state->pending_remote_write_addr = NKU_REMOTE_ADDR_NONE; /* транзитно, см. докстрок выше */
check_remote_address(p_state, p_frame);
/* Сдвиг ID по адресу станции (id пакетов адресно-зависим). */
const uint32_t G4 = (uint32_t) p_state->nku_address << 4U;
const uint32_t G6 = (uint32_t) p_state->nku_address << 6U;
@ -288,3 +340,17 @@ sul_status_t nku_can_decode(void *p_ctx, const sul_frame_t *p_frame, sul_result_
*p_out = p_state->state;
return SUL_STATUS_OK;
}
bool nku_can_take_pending_write(void *p_ctx, sul_slice_write_t *p_out)
{
const nku_can_ctx_t *p_state = (const nku_can_ctx_t *) p_ctx;
if (p_state->pending_remote_write_addr == NKU_REMOTE_ADDR_NONE)
{
return false;
}
p_out->slice_offset = 0U; /* proto_slice[0] = адрес, см. K_NKU_CAN_SETTINGS в sul_registry.c */
p_out->value = p_state->pending_remote_write_addr;
return true;
}

View file

@ -1,23 +1,90 @@
#include "domain/sul.h"
#include "domain/sul/demo.h"
#include "domain/sul/nku_can.h"
#include <stddef.h>
/* НКУ-CAN — один параметр: адрес станции 0..15 (proto_slice[0], §8). */
static const sul_settings_entry_t K_NKU_CAN_SETTINGS_ENTRIES[] = {
{
.p_label = "Адрес",
.type = SUL_SETTINGS_BYTE,
.slice_offset = 0U,
.min = 0U,
.max = 15U,
.p_options = NULL,
},
};
static const sul_settings_desc_t K_NKU_CAN_SETTINGS = {
.p_entries = K_NKU_CAN_SETTINGS_ENTRIES,
.count = sizeof(K_NKU_CAN_SETTINGS_ENTRIES) / sizeof(K_NKU_CAN_SETTINGS_ENTRIES[0]),
};
/* Демо — один параметр: скорость прохождения скриптованного маршрута (§8).
* Тест того, что дескрипторный механизм не завязан на "адрес"-подобную
* форму параметра (НКУ-CAN) здесь SELECT с метками, не BYTE. */
static const char *const K_DEMO_SPEED_LABELS[] = { "Медленно", "Норма", "Быстро" };
static const sul_settings_entry_t K_DEMO_SETTINGS_ENTRIES[] = {
{
.p_label = "Скорость",
.type = SUL_SETTINGS_SELECT,
.slice_offset = 0U,
.min = 0U,
.max = 2U,
.p_options = K_DEMO_SPEED_LABELS,
},
};
static const sul_settings_desc_t K_DEMO_SETTINGS = {
.p_entries = K_DEMO_SETTINGS_ENTRIES,
.count = sizeof(K_DEMO_SETTINGS_ENTRIES) / sizeof(K_DEMO_SETTINGS_ENTRIES[0]),
};
/* Ctx каждого драйвера — статика, живёт постоянно (см. domain/sul.h,
* .p_ctx). Инициализация sul_registry_init(). */
static nku_can_ctx_t s_nku_can_ctx;
static demo_ctx_t s_demo_ctx;
static const sul_driver_t s_registry[] = {
{
.id = SUL_PROTOCOL_NKU_CAN,
.p_name = "НКУ-CAN",
.decode = nku_can_decode,
.p_settings = &K_NKU_CAN_SETTINGS,
.p_ctx = &s_nku_can_ctx,
.take_pending_write = nku_can_take_pending_write, /* удалённая адресация, §3.5 */
.connection_timeout_ms = 3000U, /* как в OLD_PROJECT (~3с на отметку потери связи) */
},
{
.id = SUL_PROTOCOL_DEMO,
.p_name = "Демо",
.decode = demo_decode,
.p_settings = &K_DEMO_SETTINGS,
.p_ctx = &s_demo_ctx,
/* .take_pending_write не задан — демо никогда не пишет settings сам */
.connection_timeout_ms = SUL_CONNECTION_TIMEOUT_DISABLED, /* синтетический источник, обрыва не бывает */
},
};
#define REGISTRY_COUNT (sizeof(s_registry) / sizeof(s_registry[0]))
void sul_registry_init(void)
{
nku_can_init(&s_nku_can_ctx);
demo_init(&s_demo_ctx);
}
/* Активный id (§8) — толкает app-слой из settings_device_t.protocol_id через
* sul_registry_set_active(); дефолт первая запись реестра (никогда NULL,
* даже до первого вызова set_active(), напр. на самых ранних этапах bringup). */
static uint8_t g_s_active_id = SUL_PROTOCOL_NKU_CAN;
const sul_driver_t *sul_registry_active(void)
{
/* Фаза 1: единственная запись, хардкод. Фаза 3 выберет по настройкам. */
return &s_registry[0];
const sul_driver_t *p_driver = sul_registry_find(g_s_active_id);
return (p_driver != NULL) ? p_driver : &s_registry[0];
}
const sul_driver_t *sul_registry_find(uint8_t id)
@ -31,3 +98,17 @@ const sul_driver_t *sul_registry_find(uint8_t id)
}
return NULL;
}
void sul_registry_set_active(uint8_t id)
{
if (sul_registry_find(id) != NULL)
{
g_s_active_id = id;
}
/* неизвестный id — игнорируется, s_active_id не меняется (см. sul.h) */
}
uint8_t sul_registry_count(void)
{
return (uint8_t) REGISTRY_COUNT;
}

View file

@ -18,7 +18,23 @@
#define NKU_ADDRESS_MAX 15U /* 4-битный адрес, group4 = addr<<4 */
/* MB index 0..4 — PACKET1..5 соответственно (см. sul_transport_can_set_address). */
/* Удалённая установка адреса (§3.5, REMOTE_ADDRES_SETUP): кадры 0x4X1
* (анонс адреса станции) и 0x5XB (несущая команды) должны приниматься с
* ЛЮБЫМ X по определению фичи наш сохранённый адрес мог не совпадать с
* адресом станции. Точные фильтры PACKET1..5 (маска 0x7FF) такие кадры
* аппаратно отбрасывают: 0x4X1 не совпадает ни с одним из них вообще, а
* 0x5XB только при X == наш адрес (это PACKET4). Поэтому два
* ДОПОЛНИТЕЛЬНЫХ wildcard-фильтра с маской, игнорирующей адресный нибл
* [7:4]: проверяются биты [10:8] и [3:0] ID. Порт apply_remote_addr_filters()
* из OLD_PROJECT (main_programm.c) БЕЗ них удалённая адресация не работает
* вовсе (decode до кадров не доходит; найдено на стенде, host-тесты этого не
* ловят они кормят decode() напрямую, мимо HW-фильтров). */
#define REMOTE_ADDR_ID_MASK 0x70FU
#define REMOTE_ANNOUNCE_ID 0x401U /* 0x4X1 — анонс адреса станции */
#define REMOTE_CMD_ID 0x50BU /* 0x5XB — несущая команды (X — любой) */
/* MB index 0..4 — PACKET1..5 (точные, зависят от адреса);
* MB index 5..6 wildcard удалённой адресации (от адреса НЕ зависят). */
/* Хранилище последнего принятого кадра — см. предупреждение в can.h про
* время жизни p_out->p_data, возвращаемого sul_transport_can_receive(). */
@ -76,6 +92,23 @@ bsp_status_t sul_transport_can_set_address(uint8_t nku_address)
return st;
}
/* Wildcard-фильтры удалённой адресации (см. блок констант выше). От
* адреса не зависят но живут здесь же, а не в init(): применение
* идемпотентно и дёшево (адрес меняется редко), зато ВСЕ фильтры
* настраиваются одной функцией в одном месте нет второй точки входа,
* которую можно забыть позвать (init() фильтры не трогает намеренно,
* см. комментарий там). */
st = bsp_can_set_filter(5U, REMOTE_ANNOUNCE_ID, REMOTE_ADDR_ID_MASK, false);
if (st != BSP_OK)
{
return st;
}
st = bsp_can_set_filter(6U, REMOTE_CMD_ID, REMOTE_ADDR_ID_MASK, false);
if (st != BSP_OK)
{
return st;
}
s_last_applied_address = ADDR;
return BSP_OK;
}

View file

@ -0,0 +1,5 @@
add_library(tft_app_sul_transport_demo STATIC src/demo_transport.c)
target_include_directories(tft_app_sul_transport_demo PUBLIC include/)
target_link_libraries(tft_app_sul_transport_demo PUBLIC tft_app_sul_headers bsp_status)

View file

@ -0,0 +1,39 @@
/**
* @file demo.h
* @brief "Транспорт" демо-протокола (ARCH §8, Фаза 3.3) нет реальной шины.
*
* У демо-протокола нет транспорта в привычном смысле: sul_transport_demo_
* receive() ничего не ждёт и не читает с шины сразу возвращает пустой
* sul_frame_t. Кадр формальность ради существующего контракта sul_decode_fn
* (см. demo.h, domain/sul/demo/) demo_decode() его содержимое игнорирует,
* ведёт счёт тиков сам. Существует, чтобы sul_rx_task мог опрашивать демо
* тем же паттерном receive()->decode(), что и реальные протоколы.
*/
#ifndef DOMAIN_SUL_TRANSPORT_DEMO_H_
#define DOMAIN_SUL_TRANSPORT_DEMO_H_
#include "bsp/status.h"
#include "domain/sul.h"
#include <stdint.h>
#ifdef __cplusplus
extern "C"
{
#endif
/**
* @brief Всегда успешно и мгновенно (не блокируется) демо не ждёт шину.
*
* @param timeout_ms игнорируется (нечего ждать)
* @param p_out заполняется пустым кадром (id=0, len=0)
* @return BSP_OK всегда
*/
bsp_status_t sul_transport_demo_receive(uint32_t timeout_ms, sul_frame_t *p_out);
#ifdef __cplusplus
}
#endif
#endif /* DOMAIN_SUL_TRANSPORT_DEMO_H_ */

View file

@ -0,0 +1,15 @@
#include "domain/sul/transport/demo.h"
#include <stddef.h>
bsp_status_t sul_transport_demo_receive(uint32_t timeout_ms, sul_frame_t *p_out)
{
(void) timeout_ms;
p_out->id = 0U;
p_out->bus = 0U;
p_out->p_data = NULL;
p_out->len = 0U;
return BSP_OK;
}

View file

@ -5,4 +5,6 @@ add_library(tft_app_menu STATIC src/menu.c src/menu_tree.c)
target_include_directories(tft_app_menu PUBLIC include/)
# settings_store — только типы settings_t (заголовок чистый, без QSPI-кода).
target_link_libraries(tft_app_menu PUBLIC tft_app_settings_store)
# tft_app_sul — menu_tree_refresh_protocol_section() читает sul_registry_*()
# (§8: секция "Протокол" строится из sul_settings_desc_t активного драйвера).
target_link_libraries(tft_app_menu PUBLIC tft_app_settings_store tft_app_sul)

View file

@ -25,6 +25,24 @@ const menu_item_desc_t *menu_tree_items(void);
/** Число пунктов в дереве. */
uint8_t menu_tree_count(void);
/**
* @brief Перестроить секцию "Протокол" дерева из активного sul_settings_desc_t
* (ARCH §8) метки выбора протокола (из реестра) + единственный
* параметр активного протокола (label/тип/диапазон/offset/options);
* заодно клампит текущее значение параметра под новый диапазон
* (proto_slice[0] мог остаться от протокола с более широким
* диапазоном иначе рендер читал бы options[] за границей).
*
* @param p_settings_rw активные настройки (то же, что передано в menu_init());
* функция не сохраняет только клампит поле на месте.
*
* Вызывать: один раз при bringup (после settings_store_load(), до первого
* открытия меню) и после каждого menu_action() дёшево (копия нескольких
* полей + короткий цикл по реестру), тот же паттерн, что переприменение
* адреса/CAN-фильтров в sul_rx_task.
*/
void menu_tree_refresh_protocol_section(settings_t *p_settings_rw);
#ifdef __cplusplus
}
#endif

View file

@ -1,27 +1,41 @@
#include "menu/menu_tree.h"
#include "domain/sul.h"
#include "services/settings_store.h"
#include <stddef.h>
/* Метки значений (ярус B/устройство). */
static const char *const K_BOOL_LABELS[] = { "Выкл", "Вкл" };
static const char *const K_PROTO_LABELS[] = { "НКУ-CAN" }; /* пока один протокол (§8) */
/* Верхняя граница на число протоколов в реестре — только размер буфера меток
* (menu_tree_refresh_protocol_section), не ограничение самого реестра.
* Сейчас 2 (НКУ-CAN, демо), с запасом под Фазу 8 (+ УИМ/SD7/УЭЛ/УКЛ 6). */
#define MENU_TREE_MAX_PROTOCOLS 8U
static const char *s_proto_labels[MENU_TREE_MAX_PROTOCOLS] = { "НКУ-CAN" }; /* фолбэк до refresh() */
/* Индексы пунктов дерева. */
enum
{
T_ROOT = 0,
T_PROTO,
T_ADDR,
T_PROTO_PARAM, /* единственный параметр АКТИВНОГО протокола (§8) — популируется
* из sul_settings_desc_t, см. menu_tree_refresh_protocol_section() */
T_LOG,
T_EXIT,
T_COUNT,
};
/* Боевое дерево Фазы 3. Действуют сейчас: протокол+адрес (→ декодер), логи
* ( рантайм-гейт). Остальной каталог настроек по мере своих фаз. */
static const menu_item_desc_t K_TREE[T_COUNT] = {
/* Боевое дерево Фазы 3. Действуют сейчас: протокол+параметр (→ декодер),
* логи ( рантайм-гейт). Остальной каталог настроек по мере своих фаз.
*
* НЕ const: секцию протокола (T_PROTO.max/.options, T_PROTO_PARAM целиком)
* популирует menu_tree_refresh_protocol_section() из активного
* sul_settings_desc_t дескрипторный принцип (§8) применён и к выбору
* протокола, не только к его параметрам. Значения ниже safe-фолбэк на
* случай, если refresh() почему-то не вызван (совпадает с тем, что было
* до Фазы 3.3, когда протокол был всего один). */
static menu_item_desc_t s_tree[T_COUNT] = {
[T_ROOT] = { .label = "Настройки",
.type = MENU_SUBMENU,
.parent = MENU_ROOT_INDEX,
@ -31,10 +45,10 @@ static const menu_item_desc_t K_TREE[T_COUNT] = {
.type = MENU_SELECT,
.value_offset = offsetof(settings_t, device.protocol_id),
.min = 0U,
.max = 0U, /* один протокол; диапазон вырастет в Фазе 8 */
.max = 0U,
.parent = MENU_ROOT_INDEX,
.options = K_PROTO_LABELS },
[T_ADDR] = { .label = "Адрес",
.options = s_proto_labels },
[T_PROTO_PARAM] = { .label = "Адрес",
.type = MENU_BYTE,
.value_offset = offsetof(settings_t, user.proto_slice[0]),
.min = 0U,
@ -50,9 +64,77 @@ static const menu_item_desc_t K_TREE[T_COUNT] = {
[T_EXIT] = { .label = "Выход", .type = MENU_BACK, .parent = MENU_ROOT_INDEX },
};
static menu_item_type_t menu_type_from_sul(sul_settings_type_t type)
{
switch (type)
{
case SUL_SETTINGS_SELECT:
return MENU_SELECT;
case SUL_SETTINGS_BOOL:
return MENU_BOOL;
case SUL_SETTINGS_BYTE:
default:
return MENU_BYTE;
}
}
void menu_tree_refresh_protocol_section(settings_t *p_settings_rw)
{
/* Метки выбора протокола — имена из реестра, не хардкод (§8). */
const uint8_t COUNT = sul_registry_count();
const uint8_t VISIBLE = (COUNT < MENU_TREE_MAX_PROTOCOLS) ? COUNT : MENU_TREE_MAX_PROTOCOLS;
for (uint8_t i = 0U; i < VISIBLE; i++)
{
const sul_driver_t *p_drv = sul_registry_find(i);
s_proto_labels[i] = (p_drv != NULL) ? p_drv->p_name : "?";
}
s_tree[T_PROTO].max = (uint8_t) (VISIBLE - 1U);
/* Единственный параметр активного протокола (§8). Сейчас у каждого
* зарегистрированного протокола ровно один (НКУ-CAN: адрес; демо:
* скорость) N>1 на протокол и скрытие неиспользуемых слотов
* понадобится Фазе 8, не усложняем заранее (YAGNI). */
const sul_driver_t *p_active = sul_registry_active();
const sul_settings_desc_t *p_settings = p_active->p_settings;
if ((p_settings != NULL) && (p_settings->count > 0U))
{
const sul_settings_entry_t *p_entry = &p_settings->p_entries[0];
s_tree[T_PROTO_PARAM].label = p_entry->p_label;
s_tree[T_PROTO_PARAM].type = menu_type_from_sul(p_entry->type);
s_tree[T_PROTO_PARAM].value_offset =
(uint16_t) (offsetof(settings_t, user.proto_slice) + p_entry->slice_offset);
s_tree[T_PROTO_PARAM].min = p_entry->min;
s_tree[T_PROTO_PARAM].max = p_entry->max;
s_tree[T_PROTO_PARAM].options = p_entry->p_options;
/* Клампим ТЕКУЩЕЕ значение под новый диапазон — proto_slice[0] мог
* остаться от другого протокола с более широким диапазоном (напр.
* адрес НКУ-CAN 0..15 -> скорость демо 0..2); без этого рендер читал
* бы options[value] за пределами массива меток нового протокола. */
uint8_t *p_val = (uint8_t *) p_settings_rw + s_tree[T_PROTO_PARAM].value_offset;
if (*p_val > p_entry->max)
{
*p_val = p_entry->max;
}
}
else
{
/* Протокол без параметров — инертный дефолт (не встречается пока
* ни у одного зарегистрированного протокола). */
s_tree[T_PROTO_PARAM].label = "";
s_tree[T_PROTO_PARAM].type = MENU_BYTE;
s_tree[T_PROTO_PARAM].value_offset = offsetof(settings_t, user.proto_slice[0]);
s_tree[T_PROTO_PARAM].min = 0U;
s_tree[T_PROTO_PARAM].max = 0U;
s_tree[T_PROTO_PARAM].options = NULL;
}
}
const menu_item_desc_t *menu_tree_items(void)
{
return K_TREE;
return s_tree;
}
uint8_t menu_tree_count(void)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -14,4 +14,5 @@ target_include_directories(
target_link_libraries(
tft_app_settings_store
PUBLIC bsp_qspi_flash # bsp/qspi_flash.h (API + геометрия), bsp/status.h транзитивно
tft_app_partition) # смещения регионов (§10)
tft_app_partition # смещения регионов (§10)
freertos_kernel) # мьютекс save() — sul_rx_task (§3.5) и menu_task пишут конкурентно

View file

@ -1,19 +1,51 @@
#include "services/settings_store.h"
#include "FreeRTOS.h"
#include "bsp/qspi_flash.h"
#include "semphr.h"
#include "services/partition.h"
#include "settings_codec.h"
/* Активные настройки — единственный экземпляр модуля. */
static settings_t g_settings;
/**
* @brief Защита save() от параллельного вызова из двух задач сейчас
* menu_task (сохранение при выходе из меню) и sul_rx_task (запись
* удалённо заданного адреса НКУ-CAN, §3.5).
*
* menu_task переключает `g_menu_active=false` ДО своего save() (см.
* task_menu.c иначе sul_rx_task лишние мс держал бы мягкую паузу) то
* есть sul_rx_task может увидеть «меню уже закрыто» и попытаться сохранить
* СВОЙ адрес, пока save() из menu_task ещё физически пишет QSPI. Без
* мьютекса два параллельных erase+write в один сектор, порча настроек.
*
* Создаётся в init_defaults()/load() оба вызываются РОВНО один раз, из
* bringup_task, ДО xTaskCreate() menu_task/sul_rx_task (см. task_bringup.c)
* на момент создания гонки нет по конструкции; лениво в save() создавать
* было бы уже НЕ безопасно (save() зовут два разных таска на протяжении
* всей жизни системы, а не один раз при старте).
*/
static SemaphoreHandle_t g_save_mutex;
static void ensure_save_mutex(void)
{
if (g_save_mutex == NULL)
{
g_save_mutex = xSemaphoreCreateMutex();
}
}
void settings_store_init_defaults(void)
{
ensure_save_mutex();
g_settings = settings_defaults();
}
bsp_status_t settings_store_load(void)
{
ensure_save_mutex();
/* static: страница = сектор (4 КБ) — на стеке задачи (~3 КБ) переполнит его.
* Однократный вызов при старте, не реентерабельно (как s_page в save()). */
static settings_page_t s_page;
@ -35,15 +67,23 @@ bsp_status_t settings_store_load(void)
bsp_status_t settings_store_save(void)
{
/* g_save_mutex гарантированно создан к этому моменту (init_defaults()/
* load() единственные вызывающиеся раньше, см. докстрок выше). */
(void) xSemaphoreTake(g_save_mutex, portMAX_DELAY);
/* static: не 4 КБ на стеке + гарантия выравнивания под постраничную запись. */
static settings_page_t s_page;
settings_serialize(&g_settings, &s_page);
bsp_status_t rc = BSP_OK;
if (bsp_qspi_erase_sector(TFT_APP_QSPI_SETTINGS_OFFSET) != BSP_OK)
{
return BSP_ERR_HW;
rc = BSP_ERR_HW;
}
if (rc == BSP_OK)
{
const uint32_t PAGES = BSP_QSPI_SECTOR_SIZE / BSP_QSPI_PAGE_SIZE;
for (uint32_t i = 0U; i < PAGES; ++i)
{
@ -51,11 +91,14 @@ bsp_status_t settings_store_save(void)
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;
rc = BSP_ERR_HW;
break;
}
}
}
return BSP_OK;
(void) xSemaphoreGive(g_save_mutex);
return rc;
}
const settings_t *settings_store_get(void)

View file

@ -16,6 +16,20 @@ extern "C"
{
#endif
/**
* @brief Диспетчерский вход (opto IN1/IN2, §3.4) «локальный вход», не
* данные СУЛ (ARCH §8 п.3). Самый высокий приоритет из всех режимов
* безусловно перекрывает и обычную индикацию, и любой режим СУЛ
* (пожар/перегруз/), работает даже без связи со станцией. ОТВЕТ
* перебивает ВЫЗОВ, если оба почему-то активны одновременно.
*/
typedef enum
{
DISPATCHER_INDICATION_NONE = 0,
DISPATCHER_INDICATION_CALL, /**< «Вызов подан» — opto IN1 */
DISPATCHER_INDICATION_ANSWER, /**< «Вызов принят» — opto IN2, приоритет выше CALL */
} dispatcher_indication_t;
/**
* @brief Безусловная первая отрисовка при старте.
*
@ -23,17 +37,23 @@ extern "C"
* совпадёт с дефолтом, indication_task_t придёт «ничего не изменилось», и
* экран останется пустым, если полагаться только на ui_fallback_render().
* Вызвать один раз при старте до входа в цикл получения кадров.
*
* @param dispatcher текущее состояние диспетчерского входа (см. выше)
*/
void ui_fallback_render_initial(const sul_result_t *p_result);
void ui_fallback_render_initial(const sul_result_t *p_result, dispatcher_indication_t dispatcher);
/**
* @brief Инкрементальная перерисовка по diff.
*
* Фаза 1: перерисовывает весь кадр целиком при ЛЮБОМ pending-поле (нет
* partial-update gfx минимален, без dirty-rect). No-op, если ничего не
* помечено.
* помечено (dispatcher сюда не входит его смену обрабатывает отдельный
* путь в task_render.c через ui_fallback_render_initial(), см. TASKS.md).
*
* @param dispatcher текущее состояние диспетчерского входа (см. выше)
*/
void ui_fallback_render(const indication_task_t *p_task, const sul_result_t *p_result);
void ui_fallback_render(const indication_task_t *p_task, const sul_result_t *p_result,
dispatcher_indication_t dispatcher);
#ifdef __cplusplus
}

View file

@ -58,10 +58,37 @@ static void render_normal(const sul_result_t *p_result)
}
}
static void render(sul_mode_t mode, const sul_result_t *p_result)
/**
* @brief Метка диспетчерского входа (§3.4) короткая asset-free, тем же
* путём, что и метки режимов СУЛ (mode_label() выше). Слова временные
* (пользователь позже поправит формулировки для всех режимов разом).
*/
static const char *dispatcher_label(dispatcher_indication_t dispatcher)
{
switch (dispatcher)
{
case DISPATCHER_INDICATION_ANSWER: return "ОТВЕТ";
case DISPATCHER_INDICATION_CALL: return "ВЫЗОВ";
case DISPATCHER_INDICATION_NONE:
default: return NULL;
}
}
static void render(sul_mode_t mode, const sul_result_t *p_result, dispatcher_indication_t dispatcher)
{
gfx_clear();
/* Диспетчерский вход — локальный (ARCH §8 п.3, не данные СУЛ), приоритет
* ВЫШЕ любого mode: проверяем ПЕРВЫМ, безусловный return так же
* работает без связи со станцией (mode/p_result мог быть sul_default_
* state() после таймаута, сюда это не попадёт вообще). */
const char *dispatcher_text = dispatcher_label(dispatcher);
if (dispatcher_text != NULL)
{
draw_centered(&SystemFont, dispatcher_text, MODE_Y);
return;
}
const char *label = mode_label(mode);
if (label == NULL)
{
@ -82,20 +109,24 @@ static void render(sul_mode_t mode, const sul_result_t *p_result)
}
}
void ui_fallback_render_initial(const sul_result_t *p_result)
void ui_fallback_render_initial(const sul_result_t *p_result, dispatcher_indication_t dispatcher)
{
render(sul_resolve_mode(p_result), p_result);
render(sul_resolve_mode(p_result), p_result, dispatcher);
}
void ui_fallback_render(const indication_task_t *p_task, const sul_result_t *p_result)
void ui_fallback_render(const indication_task_t *p_task, const sul_result_t *p_result,
dispatcher_indication_t dispatcher)
{
/* Перерисовываем на изменение того, что fallback реально показывает:
* позиция, стрелка, режим (+ отсчёт погрузки идёт вместе с mode/pos).
* next-этаж в safe-mode не рисуется (богатый layout Фаза 5), поэтому
* на next_pending не будим. */
* на next_pending не будим. Смена dispatcher сюда не входит она не
* приходит с этим diff'ом вообще (свой путь пробуждения render_task,
* см. task_render.c) только пока mode/pos/direction ТОЖЕ изменились
* в этом же кадре, dispatcher едет попутно через параметр. */
if (!p_task->pos_pending && !p_task->direction_pending && !p_task->mode_pending)
{
return;
}
render(p_task->mode, p_result);
render(p_task->mode, p_result, dispatcher);
}

View file

@ -110,6 +110,19 @@ static void draw_footer(const menu_ctx_t *p_ctx, uint8_t level_first, uint8_t le
void menu_view_render(const menu_ctx_t *p_ctx)
{
/* Снимок на входе — p_ctx указывает на ЖИВОЙ g_menu (task_render.c зовёт
* menu_view_render(&g_menu)); menu_task ВЫШЕ по приоритету и может
* вытеснить ПОСРЕДИ этого рендера на любое нажатие кнопки. Без снимка
* .cur/.page читались бы заново на каждой итерации цикла ниже успей
* menu_task сменить .cur между итерациями, курсор оказался бы
* нарисован сразу на двух строках ОДНОГО кадра ("раздвоение" курсора,
* особенно заметно при быстрой навигации весь рендер занимает
* десятки мс против 5-мс каденции menu_task, окно гонки большое).
* Копия дешёвая (несколько полей структуры) схлопывает окно гонки с
* длительности всего рендера до одного присваивания. */
const menu_ctx_t SNAPSHOT = *p_ctx;
p_ctx = &SNAPSHOT;
/* Обнулить и нарисовать ТОЛЬКО окно (дёшево — оконная перерисовка на
* навигации). Полную очистку AS от индикации вне окна делает владелец
* дисплея (task_render) один раз на ОТКРЫТИИ меню вместе с двумя

View file

@ -379,3 +379,57 @@ add_host_test(
${PROJECT_SOURCE_DIR}/firmware/tft_app/src/menu/include
${TFT_APP_SETTINGS_DIR}/include
${PROJECT_SOURCE_DIR}/bsp/common/include)
# tft_app — sul_registry (Фаза 3.3: активный протокол из настроек, §8)
add_host_test(
NAME
test_sul_registry
SOURCES
tft_app_sul_registry/test_sul_registry.c
${TFT_APP_DOMAIN_DIR}/sul/src/sul_registry.c
${TFT_APP_DOMAIN_DIR}/sul/nku_can/src/nku_can.c
${TFT_APP_DOMAIN_DIR}/sul/demo/src/demo.c
${TFT_APP_DOMAIN_DIR}/sul/demo/src/demo_route.c
${TFT_APP_DOMAIN_DIR}/elevator_model/src/elevator_model.c
INCLUDES
${TFT_APP_DOMAIN_DIR}/sul/include
${TFT_APP_DOMAIN_DIR}/sul/nku_can/include
${TFT_APP_DOMAIN_DIR}/sul/demo/include
${TFT_APP_DOMAIN_DIR}/elevator_model/include)
# tft_app — menu_tree (Фаза 3.3: секция "Протокол" строится из
# sul_settings_desc_t активного драйвера, не хардкодом, §8)
add_host_test(
NAME
test_tft_app_menu_tree
SOURCES
tft_app_menu/test_menu_tree.c
${PROJECT_SOURCE_DIR}/firmware/tft_app/src/menu/src/menu_tree.c
${PROJECT_SOURCE_DIR}/firmware/tft_app/src/menu/src/menu.c
${TFT_APP_DOMAIN_DIR}/sul/src/sul_registry.c
${TFT_APP_DOMAIN_DIR}/sul/nku_can/src/nku_can.c
${TFT_APP_DOMAIN_DIR}/sul/demo/src/demo.c
${TFT_APP_DOMAIN_DIR}/sul/demo/src/demo_route.c
${TFT_APP_DOMAIN_DIR}/elevator_model/src/elevator_model.c
INCLUDES
${PROJECT_SOURCE_DIR}/firmware/tft_app/src/menu/include
${TFT_APP_SETTINGS_DIR}/include
${PROJECT_SOURCE_DIR}/bsp/common/include
${TFT_APP_DOMAIN_DIR}/sul/include
${TFT_APP_DOMAIN_DIR}/sul/nku_can/include
${TFT_APP_DOMAIN_DIR}/sul/demo/include
${TFT_APP_DOMAIN_DIR}/elevator_model/include)
# tft_app — sul_demo (Фаза 3.3: чистый декодер демо-протокола, §8)
add_host_test(
NAME
test_sul_demo
SOURCES
tft_app_sul_demo/test_sul_demo.c
${TFT_APP_DOMAIN_DIR}/sul/demo/src/demo.c
${TFT_APP_DOMAIN_DIR}/sul/demo/src/demo_route.c
${TFT_APP_DOMAIN_DIR}/elevator_model/src/elevator_model.c
INCLUDES
${TFT_APP_DOMAIN_DIR}/sul/demo/include
${TFT_APP_DOMAIN_DIR}/sul/include
${TFT_APP_DOMAIN_DIR}/elevator_model/include)

View file

@ -97,6 +97,7 @@ void setUp(void)
/* Каждый тест начинает с чистого состояния логгера */
log_init(capture_cb, NULL);
log_set_enabled(true); /* тумблер §3.6 — не сбрасывается log_init(), сбросить явно */
}
void tearDown(void)
@ -351,6 +352,52 @@ void test_ctx_pointer_passed_to_callback(void)
TEST_ASSERT_EQUAL_PTR(&dummy, g_s_capture.p_ctx);
}
/* ═══════════════════════════════════════════════════════════════════════════
* 7. Runtime toggle рантайм-тумблер поверх компайл-тайм LOG_LEVEL (§3.6)
* */
void test_enabled_by_default(void)
{
LOG_I("TAG", "message");
TEST_ASSERT_EQUAL_INT(1, g_s_capture.call_count);
}
void test_is_enabled_reflects_state(void)
{
TEST_ASSERT_TRUE(log_is_enabled());
log_set_enabled(false);
TEST_ASSERT_FALSE(log_is_enabled());
log_set_enabled(true);
TEST_ASSERT_TRUE(log_is_enabled());
}
void test_disabled_suppresses_output(void)
{
log_set_enabled(false);
LOG_I("TAG", "message");
TEST_ASSERT_EQUAL_INT(0, g_s_capture.call_count);
}
void test_reenabled_resumes_output(void)
{
log_set_enabled(false);
LOG_I("TAG", "swallowed");
log_set_enabled(true);
LOG_I("TAG", "visible");
TEST_ASSERT_EQUAL_INT(1, g_s_capture.call_count);
}
void test_disabled_does_not_take_mutex(void)
{
log_set_enabled(false);
LOG_I("TAG", "message");
/* Гейт — раньше форматирования/мьютекса (дёшево при выключенном тумблере). */
TEST_ASSERT_EQUAL_INT(0, log_mutex_lock_fake.call_count);
TEST_ASSERT_EQUAL_INT(0, log_mutex_unlock_fake.call_count);
}
/* ── Runner ──────────────────────────────────────────────────────────────── */
int main(void)
@ -392,5 +439,12 @@ int main(void)
RUN_TEST(test_ctx_null_passed_to_callback);
RUN_TEST(test_ctx_pointer_passed_to_callback);
/* Runtime toggle */
RUN_TEST(test_enabled_by_default);
RUN_TEST(test_is_enabled_reflects_state);
RUN_TEST(test_disabled_suppresses_output);
RUN_TEST(test_reenabled_resumes_output);
RUN_TEST(test_disabled_does_not_take_mutex);
return UNITY_END();
}

View file

@ -13,6 +13,7 @@ RAM), QSPI не трогает — моки не нужны. Деревья ме
## Что проверяется
**Плоское дерево** `[Протокол(SELECT), Адрес(BYTE), Логи(BOOL), Выход(BACK)]`:
- `open` ставит курсор на первый пункт верхнего уровня.
- `menu_next` идёт по уровню с **заворотом** (последний → первый).
- Инкремент `BYTE` (адрес 0..15) с заворотом `max→min`; `BOOL` — тумблер.
@ -20,6 +21,7 @@ RAM), QSPI не трогает — моки не нужны. Деревья ме
- `menu_next`/`menu_action` — no-op при закрытом меню.
**Дерево с подменю:**
- Вход в `SUBMENU` → первый ребёнок.
- `BACK` в подменю → возврат к пункту-подменю (не закрытие).
- Правка в подменю сохраняется в `settings_t`; выход из корня → `save_requested`.
@ -36,3 +38,37 @@ RAM), QSPI не трогает — моки не нужны. Деревья ме
```bash
ctest --preset host-debug-test -R test_tft_app_menu -V
```
---
# test_tft_app_menu_tree
## Модуль под тестом
`firmware/tft_app/src/menu/src/menu_tree.c` — боевое дерево меню Фазы 3:
секция "Протокол" строится из `sul_settings_desc_t` активного драйвера
(ARCH.md §8, Фаза 3.3), не хардкодится. Отдельно от теста выше (генерик-движок
на синтетических деревьях) — здесь проверяется КОНКРЕТНОЕ дерево и его связка
с реестром `sul`.
## Категория
A — платформонезависимый. Линкует реальный `sul_registry.c` (не мок) —
проверяется настоящая связка дескриптор → дерево, не только форма данных.
## Что проверяется
- После `menu_tree_refresh_protocol_section()`: `T_PROTO.max`/`.options`
соответствуют реестру (сейчас — один протокол, НКУ-CAN); пункт-параметр
(`T_PROTO_PARAM`) получает `label`/`type`/`min`/`max`/`value_offset` из
дескриптора НКУ-CAN, `value_offset` корректно указывает на
`settings_t.user.proto_slice[0]`.
- Конец-в-конец: правка через реальный `menu.c` (`menu_open`/`menu_next`/
`menu_action`) пункта, построенного из дескриптора, действительно меняет
тот самый байт `settings_t`, который назвал дескриптор.
## Запуск
```bash
ctest --preset host-debug-test -R test_tft_app_menu_tree -V
```

View file

@ -0,0 +1,124 @@
/**
* @file test_menu_tree.c
* @brief Host-тест боевого дерева меню (menu_tree.c) секция "Протокол"
* строится из sul_settings_desc_t активного протокола, не хардкодом
* (ARCH §8, Фаза 3.3).
*/
#include "domain/sul.h"
#include "menu/menu_tree.h"
#include "services/settings_store.h"
#include "unity.h"
#include <stddef.h>
#include <string.h>
void setUp(void) {}
void tearDown(void) {}
/* T_PROTO — первый ребёнок корня (гарантия menu.h: items[MENU_ROOT_INDEX] —
* корневой SUBMENU). T_PROTO_PARAM следующий по порядку в дереве
* (menu_tree.c, T_ROOT/T_PROTO/T_PROTO_PARAM/T_LOG/T_EXIT) единственное,
* что этот тест знает о внутреннем порядке конкретного дерева. */
static uint8_t proto_index(void)
{
return menu_tree_items()[MENU_ROOT_INDEX].first_child;
}
static void test_protocol_section_built_from_nku_can_descriptor(void)
{
settings_t s;
memset(&s, 0, sizeof(s));
sul_registry_set_active(SUL_PROTOCOL_NKU_CAN);
menu_tree_refresh_protocol_section(&s);
const menu_item_desc_t *p_items = menu_tree_items();
const uint8_t T_PROTO = proto_index();
const uint8_t T_PROTO_PARAM = (uint8_t) (T_PROTO + 1U);
TEST_ASSERT_EQUAL_UINT8(1U, p_items[T_PROTO].max); /* 2 протокола в реестре: НКУ-CAN + демо */
TEST_ASSERT_EQUAL_STRING("НКУ-CAN", p_items[T_PROTO].options[0]);
TEST_ASSERT_EQUAL_STRING("Демо", p_items[T_PROTO].options[1]);
TEST_ASSERT_EQUAL_STRING("Адрес", p_items[T_PROTO_PARAM].label);
TEST_ASSERT_EQUAL(MENU_BYTE, p_items[T_PROTO_PARAM].type);
TEST_ASSERT_EQUAL_UINT8(0U, p_items[T_PROTO_PARAM].min);
TEST_ASSERT_EQUAL_UINT8(15U, p_items[T_PROTO_PARAM].max);
TEST_ASSERT_EQUAL_UINT16((uint16_t) offsetof(settings_t, user.proto_slice[0]),
p_items[T_PROTO_PARAM].value_offset);
}
static void test_protocol_section_switches_to_demo_descriptor(void)
{
settings_t s;
memset(&s, 0, sizeof(s));
sul_registry_set_active(SUL_PROTOCOL_DEMO);
menu_tree_refresh_protocol_section(&s);
const menu_item_desc_t *p_items = menu_tree_items();
const uint8_t T_PROTO_PARAM = (uint8_t) (proto_index() + 1U);
TEST_ASSERT_EQUAL_STRING("Скорость", p_items[T_PROTO_PARAM].label);
TEST_ASSERT_EQUAL(MENU_SELECT, p_items[T_PROTO_PARAM].type);
TEST_ASSERT_EQUAL_UINT8(0U, p_items[T_PROTO_PARAM].min);
TEST_ASSERT_EQUAL_UINT8(2U, p_items[T_PROTO_PARAM].max);
TEST_ASSERT_NOT_NULL(p_items[T_PROTO_PARAM].options);
sul_registry_set_active(SUL_PROTOCOL_NKU_CAN); /* не оставлять активный реестр на демо */
}
/* Значение proto_slice[0], "протухшее" от протокола с более широким диапазоном
* (адрес НКУ-CAN 0..15), должно клампиться под диапазон демо (0..2) при
* переключении иначе рендер читал бы options[value] за пределами массива
* меток демо (см. комментарий в menu_tree_refresh_protocol_section()). */
static void test_stale_value_clamped_on_protocol_switch(void)
{
settings_t s;
memset(&s, 0, sizeof(s));
sul_registry_set_active(SUL_PROTOCOL_NKU_CAN);
menu_tree_refresh_protocol_section(&s);
s.user.proto_slice[0] = 15U; /* валидный адрес НКУ-CAN */
sul_registry_set_active(SUL_PROTOCOL_DEMO);
menu_tree_refresh_protocol_section(&s);
TEST_ASSERT_EQUAL_UINT8(2U, s.user.proto_slice[0]); /* клампится к max демо, не остаётся 15 */
sul_registry_set_active(SUL_PROTOCOL_NKU_CAN);
}
/* Конец-в-конец: правка пункта, построенного из дескриптора, действительно
* попадает в то самое поле settings_t, которое назвал дескриптор протокола
* не только структура данных совпадает, но и реальный edit через menu.c. */
static void test_protocol_param_edits_correct_settings_field(void)
{
settings_t s;
memset(&s, 0, sizeof(s));
sul_registry_set_active(SUL_PROTOCOL_NKU_CAN);
menu_tree_refresh_protocol_section(&s);
menu_ctx_t ctx;
menu_init(&ctx, menu_tree_items(), menu_tree_count(), &s);
menu_open(&ctx);
menu_next(&ctx); /* T_PROTO -> T_PROTO_PARAM */
menu_action(&ctx); /* инкремент адреса 0 -> 1 */
TEST_ASSERT_EQUAL_UINT8(1U, s.user.proto_slice[0]);
}
int main(void)
{
UNITY_BEGIN();
RUN_TEST(test_protocol_section_built_from_nku_can_descriptor);
RUN_TEST(test_protocol_section_switches_to_demo_descriptor);
RUN_TEST(test_stale_value_clamped_on_protocol_switch);
RUN_TEST(test_protocol_param_edits_correct_settings_field);
return UNITY_END();
}

View file

@ -0,0 +1,33 @@
# test_sul_demo
## Модуль под тестом
`firmware/tft_app/src/domain/sul/demo/src/demo.c` — чистый декодер
демо-протокола (ARCH.md §8, Фаза 3.3): скриптованный маршрут "поездка" по
кругу, синтетический источник без реальной шины/станции.
## Категория
A — платформонезависимый. `decode()` игнорирует содержимое кадра, ведёт счёт
тиков сам внутри `ctx` — детерминирован по числу вызовов, HAL/время не трогает.
## Что проверяется
- Дефолт после `demo_init()` — первый шаг маршрута (этаж "1", без движения).
- Маршрут НЕ двигается раньше порога тиков-на-шаг (без "перепрыгивания").
- Маршрут двигается ровно на пороге (норма — 10 тиков/шаг).
- Промежуточные остановки: этаж 7 (едет вверх), этаж 3 (едет вниз) — гонг
(`arrival`), направление `NONE` на остановке.
- Верхний этаж маршрута (11).
- Заворот маршрута по кругу — после последнего шага снова на первый, гонг
корректно снимается (не залипает `true`).
- `demo_set_speed()` — "Быстро" (4 тика/шаг) двигает маршрут за меньшее число
вызовов; вне диапазона (0..2) — клампится к максимуму.
- `decode()` не разыменовывает кадр (`NULL` — валидный вход, т.к. демо не
читает содержимое).
## Запуск
```bash
ctest --preset host-debug-test -R test_sul_demo -V
```

View file

@ -0,0 +1,175 @@
/**
* @file test_sul_demo.c
* @brief Host-тесты чистого декодера демо-протокола (domain/sul/demo/src/demo.c)
* скриптованный маршрут (ARCH §8, Фаза 3.3).
*
* Тики-на-шаг (20/10/4 для медленно/норма/быстро) деталь реализации
* demo.c (k_ticks_per_step), продублирована здесь как в golden-тестах
* nku_can (там так же захардкожены PACKET-ID) держать в синхроне при
* правке demo.c.
*/
#include "domain/sul/demo.h"
#include "unity.h"
#define TICKS_SLOW 20U
#define TICKS_NORMAL 10U
#define TICKS_FAST 4U
#define ROUTE_LEN 25U /* DEMO_ROUTE_LEN в demo.c — держать в синхроне */
void setUp(void) {}
void tearDown(void) {}
static sul_status_t advance(demo_ctx_t *p_ctx, sul_result_t *p_out, uint16_t ticks)
{
sul_status_t rc = SUL_STATUS_ERR;
for (uint16_t i = 0U; i < ticks; i++)
{
rc = demo_decode(p_ctx, NULL, p_out);
}
return rc;
}
static void test_init_shows_first_route_step(void)
{
demo_ctx_t ctx;
demo_init(&ctx);
TEST_ASSERT_EQUAL_STRING("1", ctx.state.pos);
TEST_ASSERT_EQUAL(SUL_DIR_NONE, ctx.state.direction);
TEST_ASSERT_FALSE(ctx.state.arrival);
TEST_ASSERT_EQUAL_UINT8(1U, ctx.state.floor_num);
}
static void test_decode_ignores_frame_content(void)
{
demo_ctx_t ctx;
demo_init(&ctx);
sul_result_t out;
/* p_frame = NULL — decode() не должен разыменовывать его вовсе. */
TEST_ASSERT_EQUAL(SUL_STATUS_OK, demo_decode(&ctx, NULL, &out));
}
static void test_route_does_not_advance_before_ticks_per_step(void)
{
demo_ctx_t ctx;
demo_init(&ctx);
sul_result_t out;
(void) advance(&ctx, &out, TICKS_NORMAL - 1U); /* на 1 тик меньше порога */
TEST_ASSERT_EQUAL_STRING("1", out.pos);
TEST_ASSERT_EQUAL(SUL_DIR_NONE, out.direction); /* всё ещё шаг 0, не 1 (UP) */
}
static void test_route_advances_to_second_step_at_normal_speed(void)
{
demo_ctx_t ctx;
demo_init(&ctx);
sul_result_t out;
(void) advance(&ctx, &out, TICKS_NORMAL); /* ровно порог -> шаг 1 */
TEST_ASSERT_EQUAL_STRING("1", out.pos); /* пол не меняется... */
TEST_ASSERT_EQUAL(SUL_DIR_UP, out.direction); /* ...но кабина уже поехала вверх */
}
static void test_route_reaches_intermediate_stop_at_floor_7_going_up(void)
{
demo_ctx_t ctx;
demo_init(&ctx);
sul_result_t out;
(void) advance(&ctx, &out, TICKS_NORMAL * 7U); /* шаг 7 — остановка на 7 вверх */
TEST_ASSERT_EQUAL_STRING("7", out.pos);
TEST_ASSERT_EQUAL(SUL_DIR_NONE, out.direction);
TEST_ASSERT_TRUE(out.arrival);
TEST_ASSERT_EQUAL_UINT8(7U, out.floor_num);
}
static void test_route_reaches_top_floor_11(void)
{
demo_ctx_t ctx;
demo_init(&ctx);
sul_result_t out;
(void) advance(&ctx, &out, TICKS_NORMAL * 12U); /* шаг 12 — верхний этаж */
TEST_ASSERT_EQUAL_STRING("11", out.pos);
TEST_ASSERT_EQUAL(SUL_DIR_NONE, out.direction);
TEST_ASSERT_TRUE(out.arrival);
TEST_ASSERT_EQUAL_UINT8(11U, out.floor_num);
}
static void test_route_reaches_intermediate_stop_at_floor_3_going_down(void)
{
demo_ctx_t ctx;
demo_init(&ctx);
sul_result_t out;
(void) advance(&ctx, &out, TICKS_NORMAL * 21U); /* шаг 21 — остановка на 3 вниз */
TEST_ASSERT_EQUAL_STRING("3", out.pos);
TEST_ASSERT_EQUAL(SUL_DIR_NONE, out.direction);
TEST_ASSERT_TRUE(out.arrival);
TEST_ASSERT_EQUAL_UINT8(3U, out.floor_num);
}
static void test_route_wraps_around_after_full_loop(void)
{
demo_ctx_t ctx;
demo_init(&ctx);
sul_result_t out;
/* Последний шаг (24) -> гонг на "1"; ровно ROUTE_LEN шагов дальше —
* назад на шаг 0 (тот же этаж "1", но arrival уже снят). */
(void) advance(&ctx, &out, TICKS_NORMAL * (ROUTE_LEN - 1U));
TEST_ASSERT_TRUE(out.arrival); /* последний шаг маршрута перед циклом */
(void) advance(&ctx, &out, TICKS_NORMAL);
TEST_ASSERT_EQUAL_STRING("1", out.pos);
TEST_ASSERT_EQUAL(SUL_DIR_NONE, out.direction);
TEST_ASSERT_FALSE(out.arrival); /* цикл замкнулся на шаг 0, не завис на 24 */
}
static void test_set_speed_fast_advances_sooner(void)
{
demo_ctx_t ctx;
demo_init(&ctx);
demo_set_speed(&ctx, 2U); /* "Быстро" */
sul_result_t out;
(void) advance(&ctx, &out, TICKS_FAST);
TEST_ASSERT_EQUAL(SUL_DIR_UP, out.direction); /* уже шаг 1 при вчетверо меньшем числе тиков */
}
static void test_set_speed_clamps_out_of_range(void)
{
demo_ctx_t ctx;
demo_init(&ctx);
demo_set_speed(&ctx, 99U);
TEST_ASSERT_EQUAL_UINT8(2U, ctx.speed_idx); /* клампится к максимуму (Быстро) */
}
int main(void)
{
UNITY_BEGIN();
RUN_TEST(test_init_shows_first_route_step);
RUN_TEST(test_decode_ignores_frame_content);
RUN_TEST(test_route_does_not_advance_before_ticks_per_step);
RUN_TEST(test_route_advances_to_second_step_at_normal_speed);
RUN_TEST(test_route_reaches_intermediate_stop_at_floor_7_going_up);
RUN_TEST(test_route_reaches_top_floor_11);
RUN_TEST(test_route_reaches_intermediate_stop_at_floor_3_going_down);
RUN_TEST(test_route_wraps_around_after_full_loop);
RUN_TEST(test_set_speed_fast_advances_sooner);
RUN_TEST(test_set_speed_clamps_out_of_range);
return UNITY_END();
}

View file

@ -86,6 +86,29 @@ static sul_frame_t make_packet5(uint8_t next_left, uint8_t next_right, uint8_t d
return (sul_frame_t){ .id = PACKET5_ID, .bus = 0, .p_data = s_data, .len = 8U };
}
/* 0x4X1 — анонс адреса станции управления (X = addr_x, биты [7:4] ID). */
static sul_frame_t make_remote_announce(uint8_t addr_x)
{
static uint8_t s_data[8];
memset(s_data, 0, sizeof(s_data));
return (sul_frame_t){ .id = 0x401U | ((uint32_t) addr_x << 4U),
.bus = 0,
.p_data = s_data,
.len = 8U };
}
/* 0x5XB — несущая команды; cmd в старшем нибле data[3], X = addr_x. */
static sul_frame_t make_remote_cmd(uint8_t addr_x, uint8_t cmd, uint8_t len)
{
static uint8_t s_data[8];
memset(s_data, 0, sizeof(s_data));
s_data[3] = (uint8_t) (cmd << 4U);
return (sul_frame_t){ .id = 0x50BU | ((uint32_t) addr_x << 4U),
.bus = 0,
.p_data = s_data,
.len = len };
}
/* ── PACKET1 — направление ───────────────────────────────────────────────── */
static void test_packet1_none(void)
@ -587,6 +610,157 @@ static void test_address_clamped_to_max(void)
TEST_ASSERT_EQUAL(SUL_DIR_DOWN, out.direction);
}
/* ── Удалённая установка адреса (REMOTE_ADDRES_SETUP.pdf, §3.5) ─────────────
* Кадры принадлежат чужому/произвольному X вне классификации PACKET1..5
* этой станции, поэтому decode() почти всегда возвращает SUL_STATUS_IGNORED
* (кроме теста на пересечение с PACKET4 ниже) проверяем ТОЛЬКО побочный
* эффект на ctx, а не *p_out. */
static void test_remote_announce_updates_candidate_and_is_ignored(void)
{
nku_can_ctx_t ctx;
nku_can_init(&ctx);
sul_result_t out;
const sul_frame_t frame = make_remote_announce(5U);
TEST_ASSERT_EQUAL(SUL_STATUS_IGNORED, nku_can_decode(&ctx, &frame, &out));
TEST_ASSERT_EQUAL_UINT8(5U, ctx.remote_addr_candidate);
TEST_ASSERT_EQUAL_UINT8(NKU_REMOTE_ADDR_NONE, ctx.pending_remote_write_addr);
}
static void test_remote_cmd_write_triggers_pending_when_x_matches(void)
{
nku_can_ctx_t ctx;
nku_can_init(&ctx);
sul_result_t out;
const sul_frame_t announce = make_remote_announce(5U);
(void) nku_can_decode(&ctx, &announce, &out);
const sul_frame_t cmd = make_remote_cmd(5U, 0x2U, 8U);
(void) nku_can_decode(&ctx, &cmd, &out);
TEST_ASSERT_EQUAL_UINT8(5U, ctx.pending_remote_write_addr);
}
static void test_remote_cmd_ignored_when_x_mismatches_announce(void)
{
nku_can_ctx_t ctx;
nku_can_init(&ctx);
sul_result_t out;
const sul_frame_t announce = make_remote_announce(5U);
(void) nku_can_decode(&ctx, &announce, &out);
/* Согласовано с пользователем: строже буквы PDF (которая номинально
* допускает любой X у командного кадра) X должен совпасть. */
const sul_frame_t cmd = make_remote_cmd(7U, 0x2U, 8U);
(void) nku_can_decode(&ctx, &cmd, &out);
TEST_ASSERT_EQUAL_UINT8(NKU_REMOTE_ADDR_NONE, ctx.pending_remote_write_addr);
}
static void test_remote_cmd_ignored_when_command_is_not_write(void)
{
nku_can_ctx_t ctx;
nku_can_init(&ctx);
sul_result_t out;
const sul_frame_t announce = make_remote_announce(5U);
(void) nku_can_decode(&ctx, &announce, &out);
const sul_frame_t cmd = make_remote_cmd(5U, 0x0U, 8U); /* "0" = нет команды (PDF п.5) */
(void) nku_can_decode(&ctx, &cmd, &out);
TEST_ASSERT_EQUAL_UINT8(NKU_REMOTE_ADDR_NONE, ctx.pending_remote_write_addr);
}
static void test_remote_pending_is_transient_not_latched(void)
{
nku_can_ctx_t ctx;
nku_can_init(&ctx);
sul_result_t out;
const sul_frame_t announce = make_remote_announce(5U);
(void) nku_can_decode(&ctx, &announce, &out);
const sul_frame_t cmd = make_remote_cmd(5U, 0x2U, 8U);
(void) nku_can_decode(&ctx, &cmd, &out);
TEST_ASSERT_EQUAL_UINT8(5U, ctx.pending_remote_write_addr); /* сработало */
/* Следующий кадр — обычный PACKET1, к удалённой адресации не относится:
* pending не должен остаться залипшим с прошлого вызова (app-слой читает
* его СРАЗУ после decode(), значение имеет смысл только для ЭТОГО кадра). */
const sul_frame_t normal = make_packet1(0U);
(void) nku_can_decode(&ctx, &normal, &out);
TEST_ASSERT_EQUAL_UINT8(NKU_REMOTE_ADDR_NONE, ctx.pending_remote_write_addr);
}
static void test_remote_short_cmd_frame_does_not_crash_or_trigger(void)
{
nku_can_ctx_t ctx;
nku_can_init(&ctx);
sul_result_t out;
const sul_frame_t announce = make_remote_announce(5U);
(void) nku_can_decode(&ctx, &announce, &out);
/* len < REMOTE_CMD_DLC_MIN (4) — data[3] недоступен, не должно ни упасть,
* ни ложно сработать (это НЕ проверка общего PROTO_DLC-гейта, тот на
* произвольный X 0x5XB не распространяется своя защита). */
const sul_frame_t short_cmd = make_remote_cmd(5U, 0x2U, 2U);
(void) nku_can_decode(&ctx, &short_cmd, &out);
TEST_ASSERT_EQUAL_UINT8(NKU_REMOTE_ADDR_NONE, ctx.pending_remote_write_addr);
}
/* Кадр 0x50B|G4 при адресе станции 0 — ОДНОВРЕМЕННО наш PACKET4 (X=0) И
* кандидат на "несущую команды" (X тоже 0). Обе классификации должны
* отработать независимо, без взаимной порчи. */
static void test_remote_check_does_not_interfere_with_own_packet4(void)
{
nku_can_ctx_t ctx;
nku_can_init(&ctx);
sul_result_t out;
const sul_frame_t announce = make_remote_announce(0U); /* X=0 — совпадёт с адресом станции ниже */
(void) nku_can_decode(&ctx, &announce, &out);
const sul_frame_t p4 = make_packet4(false, true); /* seismic=true, cmd-нибл data[3] остаётся 0 */
const sul_status_t RC = nku_can_decode(&ctx, &p4, &out);
TEST_ASSERT_EQUAL(SUL_STATUS_OK, RC);
TEST_ASSERT_TRUE(out.seismic); /* PACKET4-классификация не задета */
TEST_ASSERT_EQUAL_UINT8(NKU_REMOTE_ADDR_NONE, ctx.pending_remote_write_addr); /* cmd=0, не "2" */
}
/* ── nku_can_take_pending_write() — generic-канал для task_sul_rx.c ────────── */
static void test_take_pending_write_false_when_none(void)
{
nku_can_ctx_t ctx;
nku_can_init(&ctx);
sul_slice_write_t write;
TEST_ASSERT_FALSE(nku_can_take_pending_write(&ctx, &write));
}
static void test_take_pending_write_true_and_fills_slice_offset_zero(void)
{
nku_can_ctx_t ctx;
nku_can_init(&ctx);
sul_result_t out;
const sul_frame_t announce = make_remote_announce(9U);
(void) nku_can_decode(&ctx, &announce, &out);
const sul_frame_t cmd = make_remote_cmd(9U, 0x2U, 8U);
(void) nku_can_decode(&ctx, &cmd, &out);
sul_slice_write_t write;
TEST_ASSERT_TRUE(nku_can_take_pending_write(&ctx, &write));
TEST_ASSERT_EQUAL_UINT8(0U, write.slice_offset); /* proto_slice[0] = адрес */
TEST_ASSERT_EQUAL_UINT8(9U, write.value);
}
int main(void)
{
UNITY_BEGIN();
@ -636,5 +810,16 @@ int main(void)
RUN_TEST(test_address_shifts_packet_ids);
RUN_TEST(test_address_clamped_to_max);
RUN_TEST(test_remote_announce_updates_candidate_and_is_ignored);
RUN_TEST(test_remote_cmd_write_triggers_pending_when_x_matches);
RUN_TEST(test_remote_cmd_ignored_when_x_mismatches_announce);
RUN_TEST(test_remote_cmd_ignored_when_command_is_not_write);
RUN_TEST(test_remote_pending_is_transient_not_latched);
RUN_TEST(test_remote_short_cmd_frame_does_not_crash_or_trigger);
RUN_TEST(test_remote_check_does_not_interfere_with_own_packet4);
RUN_TEST(test_take_pending_write_false_when_none);
RUN_TEST(test_take_pending_write_true_and_fills_slice_offset_zero);
return UNITY_END();
}

View file

@ -0,0 +1,29 @@
# test_sul_registry
## Модуль под тестом
`firmware/tft_app/src/domain/sul/src/sul_registry.c` — реестр драйверов СУЛ:
выбор активного протокола (ARCH.md §8, Фаза 3.3).
## Категория
A — платформонезависимый. Реестр — статические данные + чистые функции
поиска/выбора, HAL не трогает.
## Что проверяется
- `sul_registry_active()` — дефолт (до первого `set_active()`) и после явного
выбора известного id.
- `sul_registry_find()``NULL` на неизвестный id.
- `sul_registry_set_active()` — известный id переключает активный драйвер;
неизвестный id **игнорируется** (активный не меняется) — защита от мусора
в `settings_device_t.protocol_id`.
- `sul_registry_count()` — количество зарегистрированных протоколов.
- Дескриптор настроек НКУ-CAN (`sul_settings_desc_t`) присутствует и содержит
ожидаемый диапазон адреса (0..15).
## Запуск
```bash
ctest --preset host-debug-test -R test_sul_registry -V
```

View file

@ -0,0 +1,130 @@
/**
* @file test_sul_registry.c
* @brief Host-тесты реестра драйверов СУЛ (sul_registry.c) выбор активного
* протокола из настроек (ARCH §8, Фаза 3.3), 2 зарегистрированных
* драйвера (НКУ-CAN, демо).
*/
#include "domain/sul.h"
#include "unity.h"
void setUp(void)
{
sul_registry_set_active(SUL_PROTOCOL_NKU_CAN); /* известное состояние перед каждым тестом */
}
void tearDown(void) {}
static void test_active_defaults_to_nku_can(void)
{
const sul_driver_t *p_active = sul_registry_active();
TEST_ASSERT_NOT_NULL(p_active);
TEST_ASSERT_EQUAL_UINT8(SUL_PROTOCOL_NKU_CAN, p_active->id);
}
static void test_find_returns_null_for_unknown_id(void)
{
TEST_ASSERT_NULL(sul_registry_find(0xFFU));
}
static void test_find_returns_demo(void)
{
const sul_driver_t *p_drv = sul_registry_find(SUL_PROTOCOL_DEMO);
TEST_ASSERT_NOT_NULL(p_drv);
TEST_ASSERT_EQUAL_STRING("Демо", p_drv->p_name);
}
static void test_set_active_switches_between_protocols(void)
{
sul_registry_set_active(SUL_PROTOCOL_DEMO);
TEST_ASSERT_EQUAL_UINT8(SUL_PROTOCOL_DEMO, sul_registry_active()->id);
sul_registry_set_active(SUL_PROTOCOL_NKU_CAN);
TEST_ASSERT_EQUAL_UINT8(SUL_PROTOCOL_NKU_CAN, sul_registry_active()->id);
}
static void test_set_active_ignores_unknown_id(void)
{
sul_registry_set_active(SUL_PROTOCOL_DEMO); /* известное состояние, не дефолт */
sul_registry_set_active(0xFFU); /* неизвестный id — игнорируется */
TEST_ASSERT_EQUAL_UINT8(SUL_PROTOCOL_DEMO, sul_registry_active()->id);
}
static void test_count_matches_registered_drivers(void)
{
TEST_ASSERT_EQUAL_UINT8(2U, sul_registry_count()); /* НКУ-CAN + демо */
}
static void test_nku_can_settings_descriptor_present(void)
{
const sul_driver_t *p_drv = sul_registry_find(SUL_PROTOCOL_NKU_CAN);
TEST_ASSERT_NOT_NULL(p_drv->p_settings);
TEST_ASSERT_EQUAL_UINT8(1U, p_drv->p_settings->count);
TEST_ASSERT_EQUAL_UINT8(0U, p_drv->p_settings->p_entries[0].min);
TEST_ASSERT_EQUAL_UINT8(15U, p_drv->p_settings->p_entries[0].max);
TEST_ASSERT_EQUAL(SUL_SETTINGS_BYTE, p_drv->p_settings->p_entries[0].type);
}
/* Демо даёт СВОЙ дескриптор непохожей формы (SELECT с метками, не BYTE) —
* тест того, что дескрипторный механизм не завязан на "адрес"-подобный
* параметр НКУ-CAN (см. HANDOFF_PHASE3_TAIL.md, п.1). */
static void test_demo_settings_descriptor_present(void)
{
const sul_driver_t *p_drv = sul_registry_find(SUL_PROTOCOL_DEMO);
TEST_ASSERT_NOT_NULL(p_drv->p_settings);
TEST_ASSERT_EQUAL_UINT8(1U, p_drv->p_settings->count);
TEST_ASSERT_EQUAL(SUL_SETTINGS_SELECT, p_drv->p_settings->p_entries[0].type);
TEST_ASSERT_EQUAL_UINT8(0U, p_drv->p_settings->p_entries[0].min);
TEST_ASSERT_EQUAL_UINT8(2U, p_drv->p_settings->p_entries[0].max);
TEST_ASSERT_NOT_NULL(p_drv->p_settings->p_entries[0].p_options);
}
/* Generic-канал протокол→settings (§3.5) — НКУ-CAN его использует (удалённая
* адресация), демо нет. task_sul_rx.c проверяет только NULL/не-NULL, без
* ветки по id вот что это гарантирует. */
static void test_take_pending_write_wired_only_for_nku_can(void)
{
TEST_ASSERT_NOT_NULL(sul_registry_find(SUL_PROTOCOL_NKU_CAN)->take_pending_write);
TEST_ASSERT_NULL(sul_registry_find(SUL_PROTOCOL_DEMO)->take_pending_write);
}
/* Таймаут "потери связи" — свойство протокола, не пользовательская настройка
* (жёстко задаётся при регистрации). НКУ-CAN шлёт периодически таймаут
* активен; демо синтетический источник, обрыва не бывает по определению. */
static void test_connection_timeout_set_per_protocol(void)
{
TEST_ASSERT_EQUAL_UINT32(3000U, sul_registry_find(SUL_PROTOCOL_NKU_CAN)->connection_timeout_ms);
TEST_ASSERT_EQUAL_UINT32(SUL_CONNECTION_TIMEOUT_DISABLED,
sul_registry_find(SUL_PROTOCOL_DEMO)->connection_timeout_ms);
}
static void test_each_driver_has_own_ctx(void)
{
sul_registry_init();
const sul_driver_t *p_nku = sul_registry_find(SUL_PROTOCOL_NKU_CAN);
const sul_driver_t *p_demo = sul_registry_find(SUL_PROTOCOL_DEMO);
TEST_ASSERT_NOT_NULL(p_nku->p_ctx);
TEST_ASSERT_NOT_NULL(p_demo->p_ctx);
TEST_ASSERT_NOT_EQUAL(p_nku->p_ctx, p_demo->p_ctx);
}
int main(void)
{
UNITY_BEGIN();
RUN_TEST(test_active_defaults_to_nku_can);
RUN_TEST(test_find_returns_null_for_unknown_id);
RUN_TEST(test_find_returns_demo);
RUN_TEST(test_set_active_switches_between_protocols);
RUN_TEST(test_set_active_ignores_unknown_id);
RUN_TEST(test_count_matches_registered_drivers);
RUN_TEST(test_nku_can_settings_descriptor_present);
RUN_TEST(test_demo_settings_descriptor_present);
RUN_TEST(test_take_pending_write_wired_only_for_nku_can);
RUN_TEST(test_connection_timeout_set_per_protocol);
RUN_TEST(test_each_driver_has_own_ctx);
return UNITY_END();
}

View file

@ -14,6 +14,7 @@
#include "log.h"
#include <stdarg.h>
#include <stdbool.h>
#include <stddef.h>
#include <stdio.h>
@ -38,6 +39,10 @@
static log_write_cb_t g_s_write_cb = NULL;
static void *g_s_p_ctx = NULL;
/* true по умолчанию — до первого log_set_enabled() (обычно из настроек,
* §3.6) ранние сообщения bringup не должны теряться молча. */
static bool g_s_log_enabled = true;
/* -------------------------------------------------------------------------- */
/* Weak-хуки мьютекса — NOP для bare-metal */
/* -------------------------------------------------------------------------- */
@ -89,10 +94,20 @@ void log_init(log_write_cb_t p_write_cb, void *p_ctx)
g_s_p_ctx = p_ctx;
}
void log_set_enabled(bool enabled)
{
g_s_log_enabled = enabled;
}
bool log_is_enabled(void)
{
return g_s_log_enabled;
}
void log_write(int level, const char *p_tag, const char *p_fmt, // NOLINT(readability-function-size)
...)
{
if (g_s_write_cb == NULL)
if (!g_s_log_enabled || (g_s_write_cb == NULL))
{
return;
}

View file

@ -35,6 +35,7 @@
#ifndef LOG_H
#define LOG_H
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
@ -95,6 +96,32 @@ extern "C"
*/
void log_init(log_write_cb_t p_write_cb, void *p_ctx);
/* -------------------------------------------------------------------------- */
/* Рантайм-тумблер (поверх компайл-тайм LOG_LEVEL) */
/* -------------------------------------------------------------------------- */
/**
* @brief Включить/выключить логи в рантайме гейт ПОВЕРХ компайл-тайм
* LOG_LEVEL (§3.6). LOG_LEVEL решается на этапе компиляции: макросы
* ниже уровня физически вырезаны (`((void)0)`) этому тумблеру там
* нечего гейтить. Тумблер гейтит то, что ОСТАЛОСЬ скомпилированным
* (напр. продакшн с `LOG_LEVEL>=INFO` включить/выключить INFO/WARN/
* ERROR в поле без пересборки).
*
* @param enabled false log_write() перестаёт звать транспорт (дёшево,
* проверка раньше форматирования строки).
*/
void log_set_enabled(bool enabled);
/**
* @brief Текущее состояние тумблера.
*
* По умолчанию (до первого вызова log_set_enabled()) true: ранние
* сообщения bringup (до загрузки настроек, откуда обычно приходит
* реальное значение) не теряются молча.
*/
bool log_is_enabled(void);
/* -------------------------------------------------------------------------- */
/* Мьютекс (weak-хуки) */
/* -------------------------------------------------------------------------- */