# tft_app: Phase 3.4 NKU Remote, Opto Inputs
This commit is contained in:
parent
958fd9d5e5
commit
8c72be8708
53 changed files with 8739 additions and 251 deletions
|
|
@ -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"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
|
|||
341
docs/tft_app/ADDING_PROTOCOL.md
Normal file
341
docs/tft_app/ADDING_PROTOCOL.md
Normal 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) |
|
||||
196
docs/tft_app/ADDING_SETTING.md
Normal file
196
docs/tft_app/ADDING_SETTING.md
Normal 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) |
|
||||
|
|
@ -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).
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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**
|
||||
индикация: богатая полноэкранная графика режимов (фон+спрайты) появится в Фазах 4–5. Для
|
||||
временной погрузки дополнительно рисуется обратный отсчёт (`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` + нового диспетчерского значения.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
||||
|
|
|
|||
|
|
@ -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-движок со спрайтами
|
||||
(Фазы 4–5), аудио (Фаза 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-движок со спрайтами (Фазы 4–5, включая ярус C настроек — клиентский
|
||||
TLV, см. SETTINGS.md §3/§9), аудио (Фаза 6), остальные протоколы — УИМ/SD7/УЭЛ/УКЛ (Фаза 8).
|
||||
|
|
|
|||
275
docs/tft_app/SETTINGS.md
Normal file
275
docs/tft_app/SETTINGS.md
Normal 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 ещё нет (Фазы 4–5).
|
||||
|
||||
**Почему 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, Фазы 4–5 |
|
||||
|
||||
Ядро сознательно маленькое и жёсткое (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-движка, Фазы 4–5).
|
||||
- **`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.
|
||||
|
|
@ -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`, гонка) |
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.5–2 с); 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) — по
|
||||
конвенции «доки по реализованному», ничего не выдаётся за готовое раньше времени.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
124
firmware/tft_app/src/app/dispatcher.c
Normal file
124
firmware/tft_app/src/app/dispatcher.c
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 ниже. */
|
||||
|
|
|
|||
|
|
@ -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 и держал бы мягкую паузу
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 + timeout→default. Порядок величины — как
|
||||
* в 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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
5
firmware/tft_app/src/domain/sul/demo/CMakeLists.txt
Normal file
5
firmware/tft_app/src/domain/sul/demo/CMakeLists.txt
Normal 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)
|
||||
|
|
@ -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_ */
|
||||
72
firmware/tft_app/src/domain/sul/demo/src/demo.c
Normal file
72
firmware/tft_app/src/domain/sul/demo/src/demo.c
Normal 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;
|
||||
}
|
||||
34
firmware/tft_app/src/domain/sul/demo/src/demo_route.c
Normal file
34
firmware/tft_app/src/domain/sul/demo/src/demo_route.c
Normal 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 }, /* нижний этаж, затем цикл сначала */
|
||||
};
|
||||
48
firmware/tft_app/src/domain/sul/demo/src/demo_route.h
Normal file
48
firmware/tft_app/src/domain/sul/demo/src/demo_route.h
Normal 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_ */
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
@ -57,13 +58,123 @@ typedef struct
|
|||
* @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);
|
||||
|
||||
/**
|
||||
* @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` — без зависимости domain→menu (§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;
|
||||
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;
|
||||
|
||||
/**
|
||||
|
|
@ -74,20 +185,42 @@ typedef struct
|
|||
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);
|
||||
|
||||
/**
|
||||
* @brief Найти драйвер по id (для будущего выбора по настройкам, Фаза 3).
|
||||
* @brief Найти драйвер по id.
|
||||
* @return NULL, если протокол не зарегистрирован.
|
||||
*/
|
||||
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
|
||||
}
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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_ */
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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 пишут конкурентно
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) один раз на ОТКРЫТИИ меню — вместе с двумя
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
124
tests/host/tft_app_menu/test_menu_tree.c
Normal file
124
tests/host/tft_app_menu/test_menu_tree.c
Normal 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();
|
||||
}
|
||||
33
tests/host/tft_app_sul_demo/README.md
Normal file
33
tests/host/tft_app_sul_demo/README.md
Normal 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
|
||||
```
|
||||
175
tests/host/tft_app_sul_demo/test_sul_demo.c
Normal file
175
tests/host/tft_app_sul_demo/test_sul_demo.c
Normal 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();
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
29
tests/host/tft_app_sul_registry/README.md
Normal file
29
tests/host/tft_app_sul_registry/README.md
Normal 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
|
||||
```
|
||||
130
tests/host/tft_app_sul_registry/test_sul_registry.c
Normal file
130
tests/host/tft_app_sul_registry/test_sul_registry.c
Normal 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();
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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-хуки) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
|
|
|||
Loading…
Reference in a new issue