# bootloader: Phase 4 - preparing

This commit is contained in:
Dmitry Akimov 2026-07-13 16:47:47 +03:00
parent 1def117e44
commit 894d0d56c0
18 changed files with 789 additions and 41 deletions

View file

@ -162,7 +162,8 @@
"test_timeout_pattern",
"test_mcuboot_boot_select",
"test_slot_version",
"test_update_policy"
"test_update_policy",
"test_recovery"
]
},
{
@ -184,7 +185,8 @@
"test_timeout_pattern",
"test_mcuboot_boot_select",
"test_slot_version",
"test_update_policy"
"test_update_policy",
"test_recovery"
]
},
{

View file

@ -13,6 +13,7 @@ add_subdirectory(opto)
add_subdirectory(can)
add_subdirectory(button)
add_subdirectory(wdog)
add_subdirectory(boot_state)
add_subdirectory(display)
add_subdirectory(usb_cdc)
add_subdirectory(sdram)

View file

@ -0,0 +1,14 @@
if(BUILD_TESTS_HOST)
return()
endif()
add_library(bsp_boot_state STATIC src/boot_state.c)
target_include_directories(
bsp_boot_state
PUBLIC include/
PRIVATE src/)
target_link_libraries(
bsp_boot_state
PRIVATE sdk_src)

79
bsp/boot_state/README.md Normal file
View file

@ -0,0 +1,79 @@
# bsp_boot_state — счётчик попыток загрузки (SRC_GPR)
Счётчик попыток загрузки поверх `SRC` General Purpose Register — переживает тёплый/watchdog-сброс,
обнуляется только на POR. Даёт коду восстановления прожить несколько сбросов подряд без
персистентного хранилища во flash.
---
## Аппаратура
| Параметр | Значение |
| ------------------------ | -------------------------------------------------------------- |
| Периферия | SRC (System Reset Controller) |
| Регистр счётчика | `SRC_GPR[2]` (GPR3, 0-based индекс `fsl_src` API) |
| Переживает | тёплый сброс, watchdog-сброс |
| Обнуляется | только POR (детект — `SRC->SRSR`, бит `IPP_RESET_B`) |
| Занято ROM (не трогать) | GPR1/2 (warm-boot entry/arg), GPR6/7/8/9 (ROM, explicit note в RM), GPR10 (альт. SBMR1) |
| Не занято ROM, но занято конвенцией | GPR5 — RM рекомендует под различение SYSRESETREQ/CPU lockup, не наша задача |
---
## Контракт: разделение с `bsp_wdog`
`SRC->SRSR` и `WDOG1->WRSR` — разные регистры с разной семантикой очистки. `WRSR` самоочищается на
каждый сброс (не требует явной очистки — см. `bsp_wdog_caused_last_reset()`). `SRSR`
write-1-to-clear и **копит биты между тёплыми сбросами**, если их не чистить софтом:
`bsp_boot_state_init()` чистит `SRSR` при каждом вызове, поэтому вопрос «был ли сброс по watchdog»
остаётся за `bsp_wdog`, а не за этим модулем — этот модуль отвечает только за «был ли сброс POR».
---
## API
```c
void bsp_boot_state_init(void); /* взвести — читает/чистит SRC->SRSR, детектит POR */
bool bsp_boot_state_was_por(void); /* true, если последний сброс — POR */
uint32_t bsp_boot_attempt_count(void); /* текущее значение счётчика, 0 сразу после POR */
void bsp_boot_attempt_inc(void); /* +1 — звать перед попыткой прыжка в образ */
void bsp_boot_attempt_reset(void); /* обнулить — новый образ/фолбэк-стирание */
void bsp_boot_health_mark(void); /* = bsp_boot_attempt_reset(), для вызова из приложения */
```
`bsp_boot_state_init()` **не идемпотентна** — повторный вызов в той же сессии увидит уже очищенный
`SRSR` как «не POR». Звать ровно один раз, как можно раньше в `main()`.
---
## Быстрый старт
```c
#include "bsp/boot_state.h"
/* main.c — после board_hw_init()/bsp_wdog_init(): */
bsp_boot_state_init();
if (bsp_boot_attempt_count() >= THRESHOLD)
{
/* серия сбросов подряд без здорового образа — решение о фолбэке/recovery
* принимает вызывающий код, не этот модуль */
}
bsp_boot_attempt_inc(); /* перед каждой попыткой прыжка */
/* ... */
bsp_boot_health_mark(); /* приложение подтвердило собственное здоровье */
```
---
## CMake
```cmake
target_link_libraries(firmware_bootloader PRIVATE bsp_boot_state)
```
**Зависимости модуля:**
| Зависимость | Тип | Описание |
| ----------- | ------- | -------------------------------------------------------------- |
| `sdk_src` | PRIVATE | `fsl_src.h``SRC_Get/SetGeneralPurposeRegister`, `SRC_Get/ClearResetStatusFlags` |

View file

@ -0,0 +1,59 @@
/*
* bsp_boot_state счётчик попыток загрузки поверх SRC General Purpose
* Register (MIMXRT1052).
*
* Назначение: пережить несколько сбросов подряд без реального
* персистентного хранилища во flash. SRC_GPR retained-регистр: сохраняет
* значение через тёплый/watchdog-сброс, теряет его только на POR.
*
* Контракт: bsp_boot_state_init() сама детектирует POR по SRC->SRSR и, если
* это POR, обнуляет счётчик вызывающему коду не нужно проверять причину
* сброса самостоятельно для этой цели. Причина «сброс по watchdog?» не
* этот модуль, см. bsp_wdog_caused_last_reset() (bsp/wdog.h): SRC->SRSR и
* WDOG1->WRSR разные регистры с разной семантикой очистки (WRSR
* самоочищается на каждый сброс, SRSR write-1-to-clear, накапливает биты
* между сбросами без явной очистки).
*/
#ifndef BSP_BOOT_STATE_H
#define BSP_BOOT_STATE_H
#include <stdbool.h>
#include <stdint.h>
/*
* Инициализация. Вызывать один раз, как можно раньше в main() (после
* board_hw_init()/bsp_wdog_init()). Читает SRC->SRSR, определяет POR (бит
* IPP_RESET_B на MIMXRT1052 отдельного бита POR нет, эту роль играет он),
* чистит SRSR (write-1-to-clear, иначе биты копятся между тёплыми сбросами)
* и, если это POR, обнуляет счётчик попыток.
*
* Повторный вызов в той же сессии не идемпотентен (снова прочитает и
* очистит уже очищенный SRSR, увидит "не POR"): звать ровно один раз.
*/
void bsp_boot_state_init(void);
/* true, если последний сброс МК (на момент bsp_boot_state_init()) был POR. */
bool bsp_boot_state_was_por(void);
/* Текущее значение счётчика попыток. 0 сразу после POR. */
uint32_t bsp_boot_attempt_count(void);
/* Инкремент счётчика попыток. Звать перед каждой попыткой прыжка в образ. */
void bsp_boot_attempt_inc(void);
/*
* Обнулить счётчик попыток. Звать при: успешной установке нового образа
* (свежему образу полный бюджет попыток), фолбэк-стирании зависшего слота
* (ситуация изменилась).
*/
void bsp_boot_attempt_reset(void);
/*
* Отметить образ здоровым семантический алиас bsp_boot_attempt_reset() для
* вызова из приложения (tft_app), а не из загрузчика: тот же эффект (счётчик
* обнуляется), но имя называет намерение вызывающей стороны.
*/
void bsp_boot_health_mark(void);
#endif /* BSP_BOOT_STATE_H */

View file

@ -0,0 +1,67 @@
/*
* bsp_boot_state реализация поверх fsl_src (SRC_GPR5 + SRC->SRSR).
*/
#include "bsp/boot_state.h"
#include "fsl_src.h"
#define BSP_BOOT_STATE_SRC_BASE SRC
/*
* GPR-индекс счётчика попыток (0-based, index=0 -> GPR1). Сверено с i.MX RT1050
* RM (SRC, гл. 21.8.5-21.8.13): GPR1/2 ROM (entry/arg пробуждения из
* low-power), GPR6/7/8/9 ТОЖЕ explicit "used by the ROM code, should not be
* used by application software" (несмотря на первоначальное предположение,
* что свободны только GPR1/2/10), GPR10 ROM (альтернативный SBMR1 через бит
* [28]). GPR5 формально не ROM, но RM рекомендует именно его под отдельную
* задачу (различение SYSRESETREQ/CPU lockup) не занимаем во избежание
* конфликта с этой конвенцией. GPR3 (index 2) единственный не подписан
* НИКАКИМ примечанием в RM, описан просто как "arbitrary value".
*/
#define BSP_BOOT_STATE_GPR_INDEX 2U
static bool g_s_was_por = false;
void bsp_boot_state_init(void)
{
uint32_t flags = SRC_GetResetStatusFlags(BSP_BOOT_STATE_SRC_BASE);
g_s_was_por = (flags & (uint32_t) kSRC_IppResetPinFlag) != 0U;
/* SRSR — write-1-to-clear, копит биты между тёплыми сбросами без явной
* очистки (в отличие от WDOG1->WRSR, который самоочищается). Чистим всё,
* что доступно, чтобы следующая загрузка увидела только свою причину. */
SRC_ClearResetStatusFlags(BSP_BOOT_STATE_SRC_BASE, ~0U);
if (g_s_was_por)
{
bsp_boot_attempt_reset();
}
}
bool bsp_boot_state_was_por(void)
{
return g_s_was_por;
}
uint32_t bsp_boot_attempt_count(void)
{
return SRC_GetGeneralPurposeRegister(BSP_BOOT_STATE_SRC_BASE, BSP_BOOT_STATE_GPR_INDEX);
}
void bsp_boot_attempt_inc(void)
{
uint32_t count = bsp_boot_attempt_count();
SRC_SetGeneralPurposeRegister(BSP_BOOT_STATE_SRC_BASE, BSP_BOOT_STATE_GPR_INDEX, count + 1U);
}
void bsp_boot_attempt_reset(void)
{
SRC_SetGeneralPurposeRegister(BSP_BOOT_STATE_SRC_BASE, BSP_BOOT_STATE_GPR_INDEX, 0U);
}
void bsp_boot_health_mark(void)
{
bsp_boot_attempt_reset();
}

View file

@ -29,6 +29,7 @@ add_executable(
src/update_policy.c
src/slot_version.c
src/sd_update.c
src/recovery.c
mcuboot_port/flash_map_backend.c
mcuboot_port/keys.c
${MCUBOOT_BOOTUTIL_SOURCES}
@ -52,7 +53,8 @@ target_compile_definitions(
target_link_libraries(${TARGET_NAME} PRIVATE bsp_board bsp_led bsp_tick
bsp_usb_cdc bsp_qspi_flash
bsp_boot_xip_no_dcd bsp_button
bsp_wdog bootloader_fatfs)
bsp_wdog bsp_boot_state
bootloader_fatfs)
# -----------------------------------------------------------------------------
# Linker script — вариант flexspi_nor с m_text, ограниченным бюджетом

View file

@ -7,7 +7,7 @@
| 0 — Карта Flash | ✅ завершена | [docs/mimxrt1052/BOOTLOADER_FLASH_MAP.md](../../docs/mimxrt1052/BOOTLOADER_FLASH_MAP.md) |
| 1 — Скелет (CDC + LED) | ✅ завершена | сборка/HAB/SWD-прошивка/ping-pong/debug — все пункты верификации пройдены на реальной плате, детали ниже |
| 2 — bootutil (Direct-XIP) | ✅ завершена | host-тесты 5/5, аппаратная верификация — все 5 сценариев пройдены на реальной плате (детали и 3 найденных/исправленных бага — [DEBUG_LOG_PHASE2.md](DEBUG_LOG_PHASE2.md)) |
| 3 — SD-путь установки | ✅ верифицирована (2026-07-10); раунд 4 + watchdog ждут ре-проверки | все 5 сценариев пройдены на плате; баг card-detect закрыт чтением USDHC PRES_STATE.CINST (раунд 3). Раунд 4 (консолидация детекта на единый PRSSTAT + ранний сэмпл кнопки) и **аппаратный watchdog** (`bsp/wdog`, см. ниже) реализованы, сборка зелёная, **аппаратно ещё не проверены**. См. [DEBUG_LOG_PHASE3_SD.md](DEBUG_LOG_PHASE3_SD.md), чек-лист — [test_stub/HARDWARE_VERIFICATION_PHASE3.md](test_stub/HARDWARE_VERIFICATION_PHASE3.md) |
| 3 — SD-путь установки | ✅ полностью верифицирована (2026-07-13) | все 5 сценариев + раунд 4 (консолидация детекта на единый PRSSTAT + ранний сэмпл кнопки) + **аппаратный watchdog** (`bsp/wdog`, см. ниже) пройдены на реальной плате. По пути найден и исправлен баг чек-листа сценария 1 (стабы не PIC, см. ниже) — не регресс кода. См. [DEBUG_LOG_PHASE3_SD.md](DEBUG_LOG_PHASE3_SD.md), чек-лист — [test_stub/HARDWARE_VERIFICATION_PHASE3.md](test_stub/HARDWARE_VERIFICATION_PHASE3.md) |
| 4 — SDRAM/W25Q smoke-test + LED-паттерны | не начата | рекомендуется ПОСЛЕ Фазы 6 (см. ниже) |
| 5 — HAB Release + service-tui | не начата | |
| 6 — Устойчивость и восстановление (recovery) | спроектирована, код не начат | **рекомендованный следующий шаг** (раньше 4/5): закрывает зависание уже установленного образа и даёт ручной аварийный вход. Дизайн зафиксирован в обсуждении — см. раздел «Фаза 6» ниже |
@ -491,7 +491,7 @@ host-тестом Фазы 2 (`test_boot_go_reverts_unconfirmed_image` в `tests
плате). Единственное отличие от `firmware_test_fatfs`: **`FF_FS_READONLY=1`** — bootloader только
читает `TFT_APP.BIN`, никогда не пишет на SD; убирает весь write-путь FatFS из сборки.
### Итог (выполнено, аппаратно ещё не проверено)
### Итог (выполнено, аппаратно верифицировано)
- `firmware/bootloader/src/update_policy.{c,h}` — чистая логика install/skip + выбор целевого слота
(см. решения выше). `image_version_compare()` — свой аналог `boot_version_cmp()` (`static` в
@ -588,12 +588,18 @@ CDC. Сценарии 2/3 эту грань не задевали случайн
`HARDWARE_VERIFICATION_PHASE3.md` §5 дополнен явным предупреждением о требовании
линковка-под-слот-установки для любого файла на SD.
### ✅ Реализовано (2026-07-10): аппаратный watchdog
### ✅ Реализовано и аппаратно верифицировано (2026-07-13): аппаратный watchdog
**Статус:** реализован, собран (ARM + host зелёные), **аппаратно ещё не проверен** — провокацию
реального зависания и подтверждение восстановления см. в
[test_stub/HARDWARE_VERIFICATION_PHASE3.md](test_stub/HARDWARE_VERIFICATION_PHASE3.md), раздел
«Watchdog». Реализация против плана ниже:
**Статус:** реализован, собран (ARM + host зелёные), **аппаратно верифицирован** (2026-07-13) —
все пункты 9.19.5 чек-листа
[test_stub/HARDWARE_VERIFICATION_PHASE3.md](test_stub/HARDWARE_VERIFICATION_PHASE3.md#9-watchdog--провокация-зависания-и-подтверждение-восстановления)
пройдены: синтетическое зависание (механизм WDOG1 напрямую и в реалистичном SD-контексте) →
сброс ≈10 c → авто-восстановление в валидный слот без второго вмешательства; регрессия (чек-лист §8
без патчей, 2+ мин в цикле ожидания) — ни одного ложного сброса; SWD-halt дольше 10 c не сбрасывает
плату. Дополнительно подтверждено прямым чтением регистров причины сброса через pyOCD (п. 9.3,
опционально) — `WDOG1->WRSR=0x0010` (бит `POR`, не `TOUT`) и `SRC->SRSR=0x00000001` (бит
`IPP_RESET_B`, не `WDOG_RST_B`) на момент чтения (после регрессии §9.4 — ожидаемо обычный POR, не
watchdog-сброс; согласуется). Реализация против плана ниже:
- **Модуль `bsp/wdog`** (WDOG1): `bsp_wdog_init(timeout_s)` / `bsp_wdog_refresh()` /
`bsp_wdog_caused_last_reset()` / `bsp_wdog_is_armed()` / `bsp_wdog_timeout_s()`. Таймаут **10 c**
@ -651,7 +657,8 @@ while (true)` без выхода по времени, если callback `cardDe
</details>
**Верификация (первая часть Фазы 3, требующая реального железа — ещё не проведена)**:
**Верификация (первая часть Фазы 3, требующая реального железа — пройдена, см. чек-лист выше и
[test_stub/HARDWARE_VERIFICATION_PHASE3.md](test_stub/HARDWARE_VERIFICATION_PHASE3.md))**:
1. Чистая плата (только bootloader, оба слота пустые) + SD с валидным подписанным образом →
автоустановка, переход к загрузке (проверить по CDC-статусам и LED).
2. То же SD с образом версии ниже уже установленной, кнопка не нажата → отклонён
@ -808,6 +815,34 @@ recovery-LED/CDC это показывают), лечится SD-фиксом и
- Расширить CDC `wdog`-статус: `{"type":"wdog",...,"reset_count":N,"threshold":M}` — диагностика в
поле («сбрасывалась N из M»).
### ✅ Реализовано (2026-07-13): 6a — счётчик + фолбэк, host-тесты зелёные, ARM собирается
- **`bsp/boot_state`** (не расширение `bsp_wdog` — разные периферии, см. решения выше): `SRC_GPR[2]`
(GPR3, исправлено после сверки с официальной RM — см. ниже) — счётчик, `SRC->SRSR` бит
`IPP_RESET_B` — POR-детект с обязательной
`SRC_ClearResetStatusFlags()` после чтения (иначе биты копятся между тёплыми сбросами — см. находку
про разделение `WRSR`/`SRSR` выше). API: `bsp_boot_state_init()`, `bsp_boot_state_was_por()`,
`bsp_boot_attempt_count/inc/reset()`, `bsp_boot_health_mark()`. Переиспользует вендоренный
`fsl_src.h` (новый `sdk_src` таргет в `sdk/CMakeLists.txt`, тот же `add_sdk_driver()` макрос, что и
у `sdk_wdog`) — регистры руками не трогаем. Документация — по строгому стандарту остальных `bsp/*`.
- **`firmware/bootloader/src/recovery.{c,h}`** — `recovery_decide(attempt_count, threshold, slot_a,
slot_b, btn2_held)`. `is_por` из исходной сигнатуры плана убран — POR-обнуление счётчика происходит
как побочный эффект `bsp_boot_state_init()`, чистой функции этот параметр не нужен. 9 host-тестов
(`tests/host/recovery/`) — все ветки таксономии (BTN_2 главнее счётчика, ниже/на/выше порога, есть
фолбэк/нет, оба слота невалидны).
- **`main.c`** — обе точки прыжка (до цикла ожидания и внутри пере-скана) объединены в один
`attempt_boot()`: `sd_update_check()``peek_slot()` обоих слотов → `recovery_decide()` → ветка.
`bsp_boot_state_init()` — сразу после `bsp_wdog_init()`. `BSP_BUTTON_2` сэмплируется тем же ранним
приёмом, что и `BSP_BUTTON_1`. Ветка `RECOVERY_ENTER_RECOVERY_MODE` в 6a — заглушка (не прыгаем,
падаем в существующий цикл ожидания); полноценное recovery-состояние — 6b.
- **CDC**: `protocol_send_wdog_status()` расширен полями `reset_count`/`threshold`.
- **Host-тесты**: `just build::test-host`**16/16 зелёных** (было 15, +9 новых `test_recovery`,
один тест из старых 15 групп — не путать с количеством отдельных assert'ов).
- **ARM-сборка**: `just build::build-bootloader-debug` и `hab-bootloader-debug`оба зелёные.
`m_text` — 90280 Б из 247 КБ бюджета (35.69%, было 87928 Б / 34.76% после Фазы 3 watchdog).
- **Аппаратно не проверено** — как и остальная Фаза 6, ждёт стенда `test_stub` (6c) и чек-листа §
«Верификация» выше.
**6b — Recovery-режим + ручной BTN_2** (тонкий аппаратный оркестратор)
- Единое состояние recovery: подавить `boot_select_and_jump()`, отдельный LED-паттерн (узнаваемый
«шиммер»/SOS обоими LED, явно отличный от heartbeat 50/450 и app 500/250 — сведётся в словарь
@ -849,12 +884,45 @@ recovery-LED/CDC это показывают), лечится SD-фиксом и
→ счётчик обнулён (POR), слот пробуется снова (подтверждает per-session-семантику).
6. **Не мешает норме** — прогнать сценарии Фазы 3 как есть: без ложных recovery/сбросов.
### Открытые вопросы к реализации
### Решения, принятые в обсуждении реализации Фазы 6 (2026-07-13, до кода — не пересматриваются)
- `SRC_GPR[n]`: индекс (свободный от ROM) + подтвердить переживание watchdog-сброса и обнуление на
POR (RM + железо).
- `SRC->SRSR` — корректно читать/чистить (w1c) причину сброса, различать POR vs WDOG (биты
`SRC_SRSR_WDOG_RST_B` / POR).
- **Новый модуль `bsp/boot_state`**, не расширение `bsp_wdog` — разные периферии (WDOG1 vs SRC),
разная ответственность. Документация — по тому же строгому стандарту, что и остальные `bsp/*`
(см. недавний рефактор `bsp/wdog/README.md`): `Аппаратура``Контракт`/`Архитектура` → `API`
`Быстрый старт``CMake`, без ссылок на конкретных потребителей.
- **Переиспользуем вендоренный `sdk/devices/MIMXRT1052/drivers/fsl_src.h`**, не хардкодим регистры
`SRC` руками: `SRC_GetGeneralPurposeRegister`/`SRC_SetGeneralPurposeRegister` (индекс 0-based,
`index=0` → GPR1) для счётчика; `SRC_GetResetStatusFlags`/`SRC_ClearResetStatusFlags` для `SRSR`
(уже корректно реализует w1c через `SRC_SRSR_W1C_BITS_MASK`, ручной код не нужен).
- **`SRC_GPR[2]` (GPR3)** — счётчик попыток. **Исправлено после сверки с официальной i.MX RT1050 RM**
(гл. 21.8.521.8.13, PDF от пользователя) — первоначальная догадка по одним заголовкам
(`GPR5..8` "спокойные", заняты только `GPR1/2/10`) оказалась **неверной**: RM явно помечает
`SRC_GPR6`, `GPR7`, `GPR8`, `GPR9` тем же примечанием **"used by the ROM code and should not be
used by application software"**, что и `GPR1`/`GPR2` (entry/arg тёплой загрузки) — заголовки
`PERI_SRC.h` этого не показывают (там только имена битовых полей, без ROM-пометок из текста RM).
`GPR10` — тоже ROM (бит `[28]` включает трактовку `GPR9` как альтернативного `SBMR1`).
**`GPR5`** формально не ROM-зарезервирован, но RM прямо рекомендует именно его под свою задачу
(различение `SYSRESETREQ` vs CPU lockup перед программным сбросом) — заняли бы чужую конвенцию.
Единственные регистры **без единой пометки** в RM — `GPR3` и `GPR4` ("Read/write general purpose
bits used to store an arbitrary value"). Выбран `GPR3`.
**Всё ещё стоит подтвердить на железе** (RM говорит про GPR1/2 явно "SRC ensures that the register
value will persist across system resets" — вся физическая шина GPR та же, но не для каждого
регистра дословно повторено): реально ли значение переживает watchdog-сброс — первый шаг 6a.
- **Разделение ответственности WRSR vs SRSR** (важный нюанс, не был очевиден из плана): `SRSR`
накапливает биты между resets, если не чистить софтом (w1c) — эмпирически подтверждено (§9.3):
чистый POR обнулил `SRSR` целиком аппаратно, но между двумя warm-резетами подряд (класс B — серия
watchdog-сбросов без POR между ними) биты будут копиться без явной чистки. `WDOG1->WRSR` (уже
использует `bsp_wdog_caused_last_reset()`) — self-clean, накопления не даёт. Поэтому:
- **«Это был сброс по watchdog?»** → как и раньше, `bsp_wdog_caused_last_reset()` (WRSR) — код уже
есть, не трогаем.
- **«Это был POR (обнулить счётчик) или тёплый резет (не трогать)?»** → новый код в
`bsp/boot_state`, бит `IPP_RESET_B` в `SRC->SRSR` (для MIMXRT1052 `FSL_FEATURE_SRC_HAS_SRSR_POR=0`
— отдельного бита POR нет, роль POR играет `IPP_RESET_B`) — и **обязательная
`SRC_ClearResetStatusFlags()` сразу после чтения**, каждую загрузку.
- **LED-паттерн recovery** (не дожидаясь словаря Фазы 4): **оба LED вместе, синхронно, 100 мс
вкл/100 мс выкл** — чётко отличается от heartbeat (50/450 мс, один LED) и app (500/250 мс, один
LED).
- **Порог класса B — подтверждён: 3** (дефолт из исходного плана остаётся).
- Word choice recovery-LED — согласовать со словарём Фазы 4, чтобы не переделывать.
---

View file

@ -10,6 +10,14 @@
* ожидания, где SD периодически пере-сканируется (см. sd_update.h о том,
* почему boot_go() нельзя звать без новой попытки установки между вызовами).
*
* Фаза 6 (recovery, см. recovery.h): каждая попытка обёрнута в attempt_boot()
* после SD-скана, но перед прыжком, recovery_decide() решает, обычная ли
* это загрузка, нужно ли стереть подозреваемый в зависании слот (счётчик
* bsp_boot_attempt_count() дошёл до порога, но есть валидный фолбэк), или
* входить в recovery (порог без фолбэка, или удержан BSP_BUTTON_2). Класс A
* таксономии (незавершённая установка) закрывается штатным revert MCUboot
* без участия этой логики.
*
* USB CDC поднимается ДО SD-логики (не дожидаясь подключения хоста
* bsp_usb_cdc_write() не блокируется без хоста, см. bsp/usb_cdc/src/usb_cdc.c)
* чтобы статусы ("installing" и т.п.) были видны, если технолог уже
@ -18,15 +26,16 @@
* Последовательность старта:
* 1. board_hw_init() тактирование, MPU, кэш, пины
* 2. bsp_wdog_init() аппаратный watchdog как можно раньше (см. ниже)
* 3. bsp_led_init() оба LED выключены
* 4. bsp_tick_init() SysTick 1 мс
* 5. bsp_button_init() для проверки удержания BSP_BUTTON_1
* 6. bsp_qspi_init() доступ к Slot A/Б
* 7. bsp_usb_cdc_init() не блокирует, см. выше
* 8. sd_update_check() no-op быстро, если SD не вставлена
* 9. boot_select_and_jump() при успехе не возвращается
* 3. bsp_boot_state_init() POR-детект + счётчик попыток (Фаза 6)
* 4. bsp_led_init() оба LED выключены
* 5. bsp_tick_init() SysTick 1 мс
* 6. bsp_button_init() для проверки удержания BSP_BUTTON_1/2
* 7. bsp_qspi_init() доступ к Slot A/Б
* 8. bsp_usb_cdc_init() не блокирует, см. выше
* 9. attempt_boot() SD-скан + recovery-гейт + прыжок; при
* успехе не возвращается
* 10. Цикл ожидания CDC ping/pong + LED + периодический
* пере-скан SD (шаги 8-9 повторно)
* пере-скан SD (шаг 9 повторно)
*
* Watchdog (bsp_wdog): единственная защита от бесконечных зависаний в
* блокирующих вызовах SDMMC-стека, не возвращающих управление в наш код
@ -42,6 +51,7 @@
*/
#include "board.h"
#include "boot_select.h"
#include "bsp/boot_state.h"
#include "bsp/button.h"
#include "bsp/led.h"
#include "bsp/qspi_flash.h"
@ -49,12 +59,73 @@
#include "bsp/usb_cdc.h"
#include "bsp/wdog.h"
#include "cli.h"
#include "flash_map.h"
#include "protocol.h"
#include "recovery.h"
#include "sd_update.h"
#include "slot_version.h"
#include <stdbool.h>
#include <stdint.h>
/* ── Одна попытка загрузки: SD-скан + recovery-гейт (Фаза 6) + прыжок ─────
*
* sd_update_check() до чтения счётчика/состояния слотов: новый образ с SD
* заслуживает полный бюджет попыток независимо от текущего счётчика (см.
* "Правила обнуления счётчика" в PLAN.md), а не только после гейта.
*
* peek_slot() та же логика, что private peek_slot() в sd_update.c: не
* шарим напрямую между модулями (см. update_policy.h), копия минимальна. */
static update_policy_slot_state_t peek_slot(uint8_t fa_id)
{
update_policy_slot_state_t state;
state.valid = slot_version_get(fa_id, &state.version);
return state;
}
static void attempt_boot(bool downgrade_held, bool recovery_held)
{
sd_update_check(downgrade_held); /* no-op быстро, если SD не вставлена */
update_policy_slot_state_t slot_a = peek_slot(0U);
update_policy_slot_state_t slot_b = peek_slot(1U);
recovery_decision_t decision = recovery_decide(
bsp_boot_attempt_count(), RECOVERY_DEFAULT_THRESHOLD, &slot_a, &slot_b, recovery_held);
switch (decision.action)
{
case RECOVERY_ERASE_ACTIVE_THEN_BOOT_OTHER:
{
/* Подозреваемый в зависании слот — стереть, есть подтверждённый
* фолбэк (recovery_decide() это уже проверила). boot_go() внутри
* boot_select_and_jump() ниже сам выберет оставшийся. */
const struct flash_area *p_fap;
if (flash_area_open((uint8_t) decision.active_slot, &p_fap) == 0)
{
(void) flash_area_erase(p_fap, 0U, p_fap->fa_size);
flash_area_close(p_fap);
}
bsp_boot_attempt_reset(); /* ситуация изменилась — новый полный бюджет */
bsp_wdog_refresh(); /* образ унаследует полное окно таймаута */
boot_select_and_jump(); /* при успехе не возвращается */
break;
}
case RECOVERY_ENTER_RECOVERY_MODE:
/* TODO(Фаза 6b): отдельное recovery-состояние — LED-паттерн, CDC
* status:recovery_mode, ослабленный version-gate на SD-установке.
* Пока просто не прыгаем падаем в обычный цикл ожидания main(),
* как и при отсутствии валидного образа. */
break;
case RECOVERY_NORMAL_BOOT:
default:
bsp_boot_attempt_inc(); /* перед попыткой — см. bsp/boot_state.h */
bsp_wdog_refresh(); /* образ унаследует полное окно таймаута */
boot_select_and_jump(); /* при успехе не возвращается */
break;
}
}
int main(void)
{
const uint32_t ERROR_BLINK_MS = 250U;
@ -72,6 +143,10 @@ int main(void)
/* Как можно раньше — до первой же SD-логики, которая может зависнуть. */
(void) bsp_wdog_init(WDOG_TIMEOUT_S);
/* Сразу после watchdog — сама операция дешёвая (пара регистров SRC), а
* решение recovery_decide() ниже нужно уже на первой попытке. */
bsp_boot_state_init();
bsp_led_init();
bsp_tick_init();
bsp_button_init();
@ -84,14 +159,16 @@ int main(void)
* Значение переиспользуется и первой попыткой, и пере-сканами в цикле. */
const bool DOWNGRADE_HELD = bsp_button_read(BSP_BUTTON_1);
/* Жест recovery (Фаза 6) — тот же приём, удержание BSP_BUTTON_2. Приоритет
* над BTN_1 разрешается внутри recovery_decide() (проверяется первым). */
const bool RECOVERY_HELD = bsp_button_read(BSP_BUTTON_2);
bool qspi_ok = (bsp_qspi_init() == BSP_OK);
bool cdc_ok = (bsp_usb_cdc_init() == BSP_OK);
if (qspi_ok)
{
sd_update_check(DOWNGRADE_HELD); /* no-op быстро, если SD не вставлена */
bsp_wdog_refresh(); /* образ унаследует полное окно таймаута */
boot_select_and_jump(); /* при успехе не возвращается */
attempt_boot(DOWNGRADE_HELD, RECOVERY_HELD);
}
/* Нет валидного образа ни в одном слоте (или сбой QSPI) —
@ -139,9 +216,7 @@ int main(void)
protocol_send_status("waiting_for_sd");
sd_update_check(DOWNGRADE_HELD);
bsp_wdog_refresh(); /* образ унаследует полное окно таймаута */
boot_select_and_jump(); /* при успехе не возвращается */
attempt_boot(DOWNGRADE_HELD, RECOVERY_HELD);
}
}
}

View file

@ -5,8 +5,10 @@
#include "protocol.h"
#include "bsp/boot_state.h"
#include "bsp/wdog.h"
#include "cli.h"
#include "recovery.h"
#include <stdio.h>
@ -49,9 +51,12 @@ void protocol_send_wdog_status(void)
{
char buf[PROTO_BUF_SIZE];
(void) snprintf(buf, sizeof(buf),
"{\"type\":\"wdog\",\"armed\":%s,\"timeout_s\":%u,\"recovered\":%s}\n",
"{\"type\":\"wdog\",\"armed\":%s,\"timeout_s\":%u,\"recovered\":%s,"
"\"reset_count\":%u,\"threshold\":%u}\n",
bsp_wdog_is_armed() ? "true" : "false",
(unsigned) bsp_wdog_timeout_s(),
bsp_wdog_caused_last_reset() ? "true" : "false");
bsp_wdog_caused_last_reset() ? "true" : "false",
(unsigned) bsp_boot_attempt_count(),
(unsigned) RECOVERY_DEFAULT_THRESHOLD);
cli_send(buf);
}

View file

@ -63,13 +63,19 @@ void protocol_send_error(const char *p_code);
void protocol_send_status(const char *p_state);
/**
* @brief Отправить статус аппаратного watchdog.
* @brief Отправить статус аппаратного watchdog и счётчика попыток загрузки
* (Фаза 6).
*
* Формат: {"type":"wdog","armed":true,"timeout_s":10,"recovered":false}
* - armed watchdog взведён (bsp_wdog_init выполнен);
* - timeout_s сконфигурированный таймаут в секундах;
* - recovered ПОСЛЕДНИЙ сброс МК был по таймауту watchdog (плата
* восстановилась после зависания).
* Формат: {"type":"wdog","armed":true,"timeout_s":10,"recovered":false,
* "reset_count":0,"threshold":3}
* - armed watchdog взведён (bsp_wdog_init выполнен);
* - timeout_s сконфигурированный таймаут в секундах;
* - recovered ПОСЛЕДНИЙ сброс МК был по таймауту watchdog (плата
* восстановилась после зависания);
* - reset_count bsp_boot_attempt_count(): сколько попыток подряд без
* подтверждения здоровья (health-mark/новая установка), 0
* сразу после POR;
* - threshold RECOVERY_DEFAULT_THRESHOLD: порог фолбэка/recovery.
*
* Эмитится один раз на старте, если recovered, и по команде "wdog".
*/

View file

@ -0,0 +1,75 @@
/**
* @file recovery.c
* @brief Реализация см. recovery.h.
*/
#include "recovery.h"
/* ── Определение активного слота ──────────────────────────────────────────
* Тот же приём, что find_active_slot() в update_policy.c (private там)
* не шарим напрямую, копия минимальна и независимо тестируется здесь же,
* как уже принято в проекте для мелкой логики между модулями. */
typedef struct
{
bool have_active;
update_policy_slot_t active_slot;
struct image_version active_ver;
} active_slot_info_t;
static active_slot_info_t find_active_slot(const update_policy_slot_state_t *p_slot_a,
const update_policy_slot_state_t *p_slot_b)
{
active_slot_info_t info = { .have_active = false };
if (p_slot_a->valid)
{
info.have_active = true;
info.active_slot = UPDATE_POLICY_SLOT_A;
info.active_ver = p_slot_a->version;
}
if (p_slot_b->valid &&
(!info.have_active || image_version_compare(&p_slot_b->version, &info.active_ver) > 0))
{
info.have_active = true;
info.active_slot = UPDATE_POLICY_SLOT_B;
info.active_ver = p_slot_b->version;
}
return info;
}
static bool other_slot_valid(update_policy_slot_t active_slot,
const update_policy_slot_state_t *p_slot_a,
const update_policy_slot_state_t *p_slot_b)
{
return (active_slot == UPDATE_POLICY_SLOT_A) ? p_slot_b->valid : p_slot_a->valid;
}
/* ── Публичный API ─────────────────────────────────────────────────────── */
recovery_decision_t recovery_decide(uint32_t attempt_count, uint32_t threshold,
const update_policy_slot_state_t *p_slot_a,
const update_policy_slot_state_t *p_slot_b, bool btn2_held)
{
if (btn2_held)
{
return (recovery_decision_t) { .action = RECOVERY_ENTER_RECOVERY_MODE };
}
if (attempt_count < threshold)
{
return (recovery_decision_t) { .action = RECOVERY_NORMAL_BOOT };
}
active_slot_info_t active = find_active_slot(p_slot_a, p_slot_b);
if (active.have_active && other_slot_valid(active.active_slot, p_slot_a, p_slot_b))
{
return (recovery_decision_t) { .action = RECOVERY_ERASE_ACTIVE_THEN_BOOT_OTHER,
.active_slot = active.active_slot };
}
return (recovery_decision_t) { .action = RECOVERY_ENTER_RECOVERY_MODE };
}

View file

@ -0,0 +1,72 @@
/**
* @file recovery.h
* @brief Чистая логика решения "что делать с этой попыткой загрузки"
* без аппаратных зависимостей (flash/SRC_GPR), полностью
* host-тестируема.
*
* См. firmware/bootloader/PLAN.md, Фаза 6, разбивка 6a.
*/
#ifndef RECOVERY_H_
#define RECOVERY_H_
#include "update_policy.h"
#include <stdbool.h>
#include <stdint.h>
typedef enum
{
RECOVERY_NORMAL_BOOT = 0,
RECOVERY_ERASE_ACTIVE_THEN_BOOT_OTHER,
RECOVERY_ENTER_RECOVERY_MODE,
} recovery_action_t;
typedef struct
{
recovery_action_t action;
/**
* Слот, который нужно стереть перед прыжком. Значим только при
* action == RECOVERY_ERASE_ACTIVE_THEN_BOOT_OTHER.
*/
update_policy_slot_t active_slot;
} recovery_decision_t;
/**
* @brief Решить, что делать с текущей попыткой загрузки таксономия
* отказов Фазы 6 (классы B/C/D; класс A закрывается штатным revert
* MCUboot без участия этой функции).
*
* Правила (приоритет сверху вниз):
* - btn2_held RECOVERY_ENTER_RECOVERY_MODE. Ручной триггер главнее
* счётчика оператор может войти в recovery в любой момент, независимо
* от истории попыток.
* - attempt_count < threshold RECOVERY_NORMAL_BOOT. Обычная загрузка,
* ничего не предпринимаем (счётчик инкрементирует вызывающий код перед
* прыжком).
* - attempt_count >= threshold и есть "активный" слот (валидный, более
* высокой версии тот же приём, что update_policy_decide()) он и
* есть подозреваемый в зависании:
* - другой слот валиден (есть фолбэк) RECOVERY_ERASE_ACTIVE_THEN_
* BOOT_OTHER(active_slot). Вызывающий код стирает active_slot и
* обнуляет счётчик boot_go() сам выберет оставшийся слот.
* - другого валидного слота нет (стирать нечего иначе ноль рабочих
* слотов) RECOVERY_ENTER_RECOVERY_MODE.
* - attempt_count >= threshold и активного слота нет вообще
* RECOVERY_ENTER_RECOVERY_MODE (нечего анализировать, нечего стирать).
*
* @param[in] attempt_count Текущее значение счётчика попыток (bsp_boot_attempt_count()).
* @param[in] threshold Порог срабатывания фолбэка/recovery (см. RECOVERY_DEFAULT_THRESHOLD).
* @param[in] p_slot_a Состояние Slot A.
* @param[in] p_slot_b Состояние Slot Б.
* @param[in] btn2_held BSP_BUTTON_2 удержана на старте ручной вход в recovery.
*/
recovery_decision_t recovery_decide(uint32_t attempt_count, uint32_t threshold,
const update_policy_slot_state_t *p_slot_a,
const update_policy_slot_state_t *p_slot_b, bool btn2_held);
/** @brief Дефолтный порог — 3 сброса подряд до фолбэка/recovery. */
#define RECOVERY_DEFAULT_THRESHOLD 3U
#endif /* RECOVERY_H_ */

View file

@ -7,9 +7,11 @@ bootloader и заглушки слотов (`test_stub`) уже знакомы
Все команды — из корня репозитория (`tft_manufacture_test/`). Сборка и host-тесты — в devcontainer;
прошивка/отладка/проверки на железе — на стороне пользователя.
**Статус: ✅ все 5 сценариев пройдены на реальной плате (2026-07-10).** Историю бага
card-detect и его разрешение (чтение USDHC `PRES_STATE.CINST`) см. в
[../DEBUG_LOG_PHASE3_SD.md](../DEBUG_LOG_PHASE3_SD.md).
**Статус: ✅ все 5 сценариев §8 пройдены на реальной плате (2026-07-10, повторно — с раундом 4 и
watchdog — 2026-07-13); watchdog §9 (9.19.5) пройден 2026-07-13.** Историю бага card-detect и его
разрешение (чтение USDHC `PRES_STATE.CINST`) см. в [../DEBUG_LOG_PHASE3_SD.md](../DEBUG_LOG_PHASE3_SD.md).
По пути найден и исправлен баг самого чек-листа (не кода) — сценарий 1 использовал не тот линкованный
стаб, см. [../PLAN.md](../PLAN.md), раздел «Найден и исправлен баг чек-листа».
---

View file

@ -63,6 +63,7 @@ add_sdk_driver(edma fsl_edma.c)
add_sdk_driver(dmamux fsl_dmamux.c)
add_sdk_driver(xbara fsl_xbara.c)
add_sdk_driver(wdog fsl_wdog.c)
add_sdk_driver(src fsl_src.c)
# Драйверы которые зависят от clock
target_link_libraries(sdk_lpuart PUBLIC sdk_clock)

View file

@ -263,6 +263,22 @@ add_host_test(
${PROJECT_SOURCE_DIR}/firmware/bootloader/src
${MCUBOOT_BOOTUTIL_INCLUDES})
# -----------------------------------------------------------------------------
# recovery — чистая логика решения "что делать с этой попыткой загрузки"
# (Фаза 6, разбивка 6a). Переиспользует update_policy_slot_state_t/
# image_version_compare() из update_policy.c — тот же набор INCLUDES.
# -----------------------------------------------------------------------------
add_host_test(
NAME
test_recovery
SOURCES
recovery/test_recovery.c
${PROJECT_SOURCE_DIR}/firmware/bootloader/src/recovery.c
${PROJECT_SOURCE_DIR}/firmware/bootloader/src/update_policy.c
INCLUDES
${PROJECT_SOURCE_DIR}/firmware/bootloader/src
${MCUBOOT_BOOTUTIL_INCLUDES})
# -----------------------------------------------------------------------------
# slot_version — read-only пик версии слота через bootutil_img_validate()
# (Фаза 3). Реальный bootutil + TinyCrypt поверх fake_flash_map_backend.c —

View file

@ -0,0 +1,48 @@
# test_recovery
## Модуль под тестом
`firmware/bootloader/src/recovery.c` (`recovery.h`) — чистая логика решения
«что делать с этой попыткой загрузки» (таксономия отказов Фазы 6, классы
B/C/D), без аппаратных зависимостей (`SRC_GPR`/flash). См.
`firmware/bootloader/PLAN.md`.
## Категория
A — платформонезависимый модуль. Переиспользует `update_policy_slot_state_t`
и `image_version_compare()` из `update_policy.c` (линкуется вместе с тестом),
сам `bootutil` не линкуется.
## Моки
Нет — тестируется напрямую, без фейков/стабов.
## Что проверяется
- **BSP_BUTTON_2 удержана**`RECOVERY_ENTER_RECOVERY_MODE` независимо от
счётчика попыток и состояния слотов (ручной триггер главнее счётчика).
- **Счётчик ниже порога**`RECOVERY_NORMAL_BOOT`, независимо от слотов.
- **Счётчик на пороге, есть фолбэк** (второй слот валиден) —
`RECOVERY_ERASE_ACTIVE_THEN_BOOT_OTHER` с правильным `active_slot`
(активный — слот с более высокой версией, тот же приём, что в
`update_policy_decide()`), проверено для обеих сторон (А активен/Б
активен).
- **Счётчик выше порога** — фолбэк продолжает срабатывать (не только ровно
на пороге).
- **Счётчик на пороге, фолбэка нет** (единственный валидный слот, или ни
одного валидного) — `RECOVERY_ENTER_RECOVERY_MODE` (стирать единственный
рабочий слот нельзя).
## Гарантии
- Класс A (незавершённая установка, `copy_done!=image_ok`) этой функцией не
закрывается — целиком авто-откат штатным revert MCUboot, без участия
загрузчика.
- Фолбэк никогда не оставляет ноль валидных слотов — стирается только
активный, и только когда другой уже подтверждён валидным.
## Запуск
```bash
ctest --preset host-debug-test -R test_recovery -V
```

View file

@ -0,0 +1,156 @@
/**
* @file test_recovery.c
* @brief Host-тесты чистой логики recovery_decide().
*
* Без аппаратных зависимостей (SRC_GPR/flash) сценарии зеркалят таксономию
* отказов Фазы 6 (firmware/bootloader/PLAN.md).
*/
#include "recovery.h"
#include "unity.h"
/* ── Вспомогательные конструкторы ─────────────────────────────────────── */
static struct image_version make_version(uint8_t major, uint8_t minor, uint16_t revision)
{
struct image_version ver = {
.iv_major = major,
.iv_minor = minor,
.iv_revision = revision,
.iv_build_num = 0,
};
return ver;
}
static update_policy_slot_state_t make_valid_slot(struct image_version ver)
{
update_policy_slot_state_t slot = { .valid = true, .version = ver };
return slot;
}
static const update_policy_slot_state_t K_INVALID_SLOT = { .valid = false };
static const uint32_t K_THRESHOLD = 3U;
void setUp(void)
{
}
void tearDown(void)
{
}
/* ── BTN_2 — главнее счётчика ─────────────────────────────────────────── */
void test_btn2_held_enters_recovery_even_with_zero_attempts(void)
{
recovery_decision_t d =
recovery_decide(0U, K_THRESHOLD, &K_INVALID_SLOT, &K_INVALID_SLOT, true);
TEST_ASSERT_EQUAL(RECOVERY_ENTER_RECOVERY_MODE, d.action);
}
void test_btn2_held_enters_recovery_even_with_healthy_slots(void)
{
update_policy_slot_state_t slot_a = make_valid_slot(make_version(1, 0, 0));
update_policy_slot_state_t slot_b = make_valid_slot(make_version(2, 0, 0));
recovery_decision_t d = recovery_decide(0U, K_THRESHOLD, &slot_a, &slot_b, true);
TEST_ASSERT_EQUAL(RECOVERY_ENTER_RECOVERY_MODE, d.action);
}
/* ── Ниже порога — обычная загрузка ──────────────────────────────────── */
void test_below_threshold_is_normal_boot(void)
{
recovery_decision_t d = recovery_decide(K_THRESHOLD - 1U, K_THRESHOLD, &K_INVALID_SLOT,
&K_INVALID_SLOT, false);
TEST_ASSERT_EQUAL(RECOVERY_NORMAL_BOOT, d.action);
}
void test_zero_attempts_is_normal_boot(void)
{
update_policy_slot_state_t slot_a = make_valid_slot(make_version(1, 0, 0));
recovery_decision_t d = recovery_decide(0U, K_THRESHOLD, &slot_a, &K_INVALID_SLOT, false);
TEST_ASSERT_EQUAL(RECOVERY_NORMAL_BOOT, d.action);
}
/* ── На пороге, класс B, есть фолбэк ─────────────────────────────────── */
void test_at_threshold_with_fallback_erases_active_slot_a(void)
{
update_policy_slot_state_t slot_a = make_valid_slot(make_version(2, 0, 0)); /* активный */
update_policy_slot_state_t slot_b = make_valid_slot(make_version(1, 0, 0)); /* фолбэк */
recovery_decision_t d = recovery_decide(K_THRESHOLD, K_THRESHOLD, &slot_a, &slot_b, false);
TEST_ASSERT_EQUAL(RECOVERY_ERASE_ACTIVE_THEN_BOOT_OTHER, d.action);
TEST_ASSERT_EQUAL(UPDATE_POLICY_SLOT_A, d.active_slot);
}
void test_at_threshold_with_fallback_erases_active_slot_b(void)
{
update_policy_slot_state_t slot_a = make_valid_slot(make_version(1, 0, 0)); /* фолбэк */
update_policy_slot_state_t slot_b = make_valid_slot(make_version(2, 0, 0)); /* активный */
recovery_decision_t d = recovery_decide(K_THRESHOLD, K_THRESHOLD, &slot_a, &slot_b, false);
TEST_ASSERT_EQUAL(RECOVERY_ERASE_ACTIVE_THEN_BOOT_OTHER, d.action);
TEST_ASSERT_EQUAL(UPDATE_POLICY_SLOT_B, d.active_slot);
}
void test_above_threshold_still_triggers_fallback(void)
{
update_policy_slot_state_t slot_a = make_valid_slot(make_version(2, 0, 0));
update_policy_slot_state_t slot_b = make_valid_slot(make_version(1, 0, 0));
recovery_decision_t d = recovery_decide(K_THRESHOLD + 5U, K_THRESHOLD, &slot_a, &slot_b, false);
TEST_ASSERT_EQUAL(RECOVERY_ERASE_ACTIVE_THEN_BOOT_OTHER, d.action);
TEST_ASSERT_EQUAL(UPDATE_POLICY_SLOT_A, d.active_slot);
}
/* ── На пороге, класс C — откатываться некуда ────────────────────────── */
void test_at_threshold_without_fallback_enters_recovery(void)
{
update_policy_slot_state_t slot_a = make_valid_slot(make_version(1, 0, 0)); /* активный, одинокий */
recovery_decision_t d =
recovery_decide(K_THRESHOLD, K_THRESHOLD, &slot_a, &K_INVALID_SLOT, false);
TEST_ASSERT_EQUAL(RECOVERY_ENTER_RECOVERY_MODE, d.action);
}
void test_at_threshold_with_no_valid_slots_enters_recovery(void)
{
recovery_decision_t d =
recovery_decide(K_THRESHOLD, K_THRESHOLD, &K_INVALID_SLOT, &K_INVALID_SLOT, false);
TEST_ASSERT_EQUAL(RECOVERY_ENTER_RECOVERY_MODE, d.action);
}
/* ── Точка входа ───────────────────────────────────────────────────────── */
int main(void)
{
UNITY_BEGIN();
RUN_TEST(test_btn2_held_enters_recovery_even_with_zero_attempts);
RUN_TEST(test_btn2_held_enters_recovery_even_with_healthy_slots);
RUN_TEST(test_below_threshold_is_normal_boot);
RUN_TEST(test_zero_attempts_is_normal_boot);
RUN_TEST(test_at_threshold_with_fallback_erases_active_slot_a);
RUN_TEST(test_at_threshold_with_fallback_erases_active_slot_b);
RUN_TEST(test_above_threshold_still_triggers_fallback);
RUN_TEST(test_at_threshold_without_fallback_enters_recovery);
RUN_TEST(test_at_threshold_with_no_valid_slots_enters_recovery);
return UNITY_END();
}