# Загрузчик TFT — принцип работы
Документ описывает поведение загрузчика: как устроена память, как выбирается и обновляется образ
приложения, правила версий, даунгрейд и режим восстановления.
---
## 1. Карта памяти
Приложение хранится в QSPI NOR Flash в **двух слотах** — A и Б. Каждый слот содержит **полную,
самостоятельно валидную** копию приложения. Загрузчик занимает начало flash, за слотами идёт
отдельная область под ассеты (спрайты, звук), которая к процессу загрузки отношения не имеет.
| Область | Начало | Размер |
| ------------------------ | ------------ | ------------- |
| Загрузчик | `0x60000000` | 256 КБ |
| Slot A | `0x60040000` | 2 МБ |
| Slot Б | `0x60240000` | 2 МБ |
| Файловая система ассетов | `0x60440000` | до конца чипа |
```mermaid
flowchart TB
BL["Загрузчик — 256 КБ
0x60000000"]
SA["Slot A — 2 МБ
0x60040000"]
SB["Slot Б — 2 МБ
0x60240000"]
FS["Файловая система ассетов
0x60440000 … конец чипа"]
BL --- SA --- SB --- FS
```
**Приложение исполняется прямо из flash** (XIP) — из того слота, который выбрал
загрузчик, без копирования в ОЗУ. Из этого следует ключевое свойство: образ жёстко привязан к адресу
своего слота при сборке. Поэтому **релиз приложения — это два бинарника на одну версию**: один собран
под адрес Slot A, второй — под адрес Slot Б. Образ, физически положенный не в «свой» слот, пройдёт
проверку подписи, но не запустится.
Два слота нужны для безопасного обновления: пока приложение работает из одного слота, новый образ
пишется в другой. Рабочая копия никогда не затирается — на диске всегда есть чем загрузиться, даже
если обновление прервётся на середине.
---
## 2. Выбор образа при старте
На каждой подаче питания загрузчик решает, из какого слота запускать приложение.
**Слот считается кандидатом, только если он валиден целиком**: корректная сигнатура формата, целый
хэш содержимого и верная криптографическая подпись (ECDSA-P256). Битый или неподписанный слот
игнорируется.
Правила выбора:
- Оба слота валидны → активным становится слот с **большей версией**.
- Валиден только один → он и активен.
- Ни одного валидного → активного слота нет, загрузчик переходит в ожидание microSD.
```mermaid
flowchart TD
START([Подача питания]) --> CHECK["Проверить оба слота:
сигнатура + хэш + подпись"]
CHECK --> CMP{Сколько валидных?}
CMP -->|Оба| HIGHER["Активный = слот
с большей версией"]
CMP -->|Один| ONE["Активный = он"]
CMP -->|Ни одного| NONE["Ожидание microSD"]
HIGHER --> JUMP([Запуск приложения])
ONE --> JUMP
```
**Защитная сеть от битого обновления.** Свежеустановленный образ считается «непроверенным», пока сам
не подтвердит своё здоровье в рантайме. Если непроверенный образ запустился, но так и не подтвердился
(например, завис на старте) — при следующей загрузке он трактуется как неудавшееся обновление и
**автоматически стирается**, а загрузчик откатывается на прежний слот. Приложение обязано подтвердить
себя один раз, доказав работоспособность.
---
## 3. Обновление через microSD
**Единственный полевой канал обновления — карта microSD.** Загрузчик ищет в корне карты файл
`TFT_APP.BIN` — подписанный образ приложения. Карта проверяется при старте и периодически (примерно
раз в 1.5 с), пока загрузчик находится в ожидании, — карту можно вставить уже после включения.
Приёмка кандидата — **двухступенчатая**:
1. **Проверка заголовка** (сигнатура формата + версия) прямо с карты, до касания flash — этого
достаточно, чтобы решить «ставить или пропустить».
2. **Полная криптографическая проверка** — уже после записи в целевой слот. Если подпись битая,
только что записанный слот просто не будет выбран при загрузке, и загрузчик останется на прежнем
валидном образе.
Запись идёт **потоком по частям**, каждая записанная часть немедленно вычитывается обратно и
сверяется — битая страница ловится сразу.
**Целевой слот установки — всегда НЕ активный.** Работающий/загружаемый слот не перезаписывается
никогда. Если активного слота нет вообще (чистая плата) — по умолчанию Slot A.
```mermaid
flowchart TD
CARD([microSD + TFT_APP.BIN]) --> HDR["Пик заголовка:
сигнатура + версия"]
HDR -->|Сигнатура битая| REJ1["Отклонить
(candidate invalid)"]
HDR -->|OK| DEC{Решение по версии}
DEC -->|Ставить| WRITE["Записать в НЕактивный слот:
стереть → поток + verify"]
DEC -->|Пропустить| SKIP["Пропустить
(update skipped)"]
WRITE --> CRYPTO{"Крипто-проверка
записанного слота"}
CRYPTO -->|OK| DONE([Установлено → загрузка])
CRYPTO -->|Подпись битая| REJ2["Отклонить
(install rejected)"]
```
**Заводской сценарий** — частный случай этой же логики: чистая плата с одним загрузчиком, оба слота
пусты. Загрузчик ждёт SD, при появлении `TFT_APP.BIN` ставит его в Slot A и загружается. Никакого
отдельного механизма первичной заливки нет.
---
## 4. Правила версий
Версия образа — **major.minor.revision**. Номер сборки (build) в сравнении **не участвует**: два
образа, отличающиеся только номером сборки, считаются равными.
Версия используется дважды:
- при **выборе** активного слота — побеждает бо́льшая версия;
- при **решении об установке** кандидата с SD.
Решение по кандидату (без удержания кнопки):
| Кандидат относительно активного | Действие |
| ------------------------------- | ---------------------------- |
| Строго новее | Установить в неактивный слот |
| Активного слота нет вообще | Установить в Slot A |
| Старше или равен | Пропустить |
При штатном обновлении (кандидат новее) прежний активный слот **не стирается** — он естественным
образом проиграет сравнение версий при следующей загрузке, новый образ победит сам.
---
## 5. Даунгрейд
Установить образ **старее** уже стоящего можно только с помощью оператора: **удержать `BTN 1` в
момент подачи питания**. Кнопка считывается один раз на старте и действует всю сессию.
Ключевой момент: при обычном даунгрейде записать старый образ в свободный слот **недостаточно** —
прежний (более новый) активный слот остался бы валиден и снова победил бы по версии, и даунгрейд
физически лёг бы на flash, но не загрузился. Поэтому при форсированном даунгрейде прежний активный
слот **стирается** — но только **после** того, как новый образ уже записан и подтверждён валидным.
На диске никогда не бывает нуля рабочих слотов даже на середине операции.
| Условие | Действие |
| ---------------------------------- | ----------------------------------------------------- |
| Кандидат старше + `BTN 1` удержана | Установить в свободный слот, стереть прежний активный |
| Кандидат равен активному + `BTN 1` | Пропустить (переустановку той же версии не форсируем) |
---
## 6. Режим восстановления (Recovery Mode)
Назначение — не дать полевой плате превратиться в «кирпич», если уже установленный образ зависает в
рантайме, и дать оператору ручной аварийный вход.
### Аппаратный сторож
Плата защищена аппаратным watchdog с таймаутом **10 секунд**. Если управление зависает где-либо
(включая рантайм приложения), через 10 с происходит аппаратный сброс. Watchdog взводится один раз и
до перезагрузки по питанию не выключается — он «переживает» переход в приложение, поэтому приложение
обязано периодически его «кормить». Зависание → гарантированный сброс, а не вечный локап.
### Счётчик и порог
Число **подряд идущих** watchdog-сбросов хранится в регистре, который переживает тёплый/watchdog-сброс
и обнуляется только при настоящей подаче питания (POR). Счётчик обнуляется также при успешной
установке нового образа и при откате на фолбэк. Порог срабатывания — **3** сброса подряд.
### Классификация отказов и их обработка
| Класс | Ситуация | Что срабатывает |
| ----- | ----------------------------------------------- | ----------------------------------------------------- |
| **A** | Новый образ завис, ещё не подтвердив себя | Watchdog-сброс + автоматический откат на прежний слот |
| **B** | Уже подтверждённый образ завис в рантайме | Счётчик сбросов достиг порога → фолбэк на второй слот |
| **C** | Откатываться некуда (единственный/оба зависают) | Recovery Mode |
| **D** | Оператор хочет чистый старт вручную | Recovery Mode по `BTN 2` |
Класс A — самый частый — закрыт полностью автоматически: откат непроверенного образа не требует ни
счётчика, ни вмешательства. Класс B ловит то, что откат не покрывает (образ-то подтверждён): после
порога зависший слот стирается, и загружается второй, если он валиден.
### Решение при старте
```mermaid
flowchart TD
S([Начало попытки]) --> BTN{BTN 2 удержана?}
BTN -->|Да| REC[Recovery Mode]
BTN -->|Нет| CNT{"Счётчик сбросов
≥ порога (3)?"}
CNT -->|Нет| NORM[Обычная загрузка]
CNT -->|Да| FB{Второй слот валиден?}
FB -->|Да| ERASE["Стереть зависший слот →
обнулить счётчик →
загрузить второй"]
FB -->|Нет| REC
```
`BTN 2` проверяется **первым** — приоритет ручного входа выше и счётчика, и обычной загрузки, и
кнопки даунгрейда. Если `BTN 2` удержана, обычный путь загрузки не выполняется вообще, даже при
наличии валидного образа.
### Поведение в Recovery Mode
- **Прыжок в приложение подавлен** — это само по себе разрывает цикл зависаний.
- **Отдельная LED-индикация**: оба светодиода мигают синхронно, 100 мс включено / 100 мс выключено —
явно отличается от heartbeat и рабочих паттернов приложения.
- **Статус по USB**: `recovery_mode`.
- **Ослабленный контроль версий**: принимается **любой** подписанный образ с SD — без сравнения
версий и без кнопки. Проверка подписи при этом сохраняется всегда.
- При найденном валидном образе — **оба слота стираются**, образ ставится в Slot A, происходит
автоматический прыжок. «Чистый борт» достигается ровно тогда, когда есть чем заменить.
### Семантика «на одну сессию»
Счётчик не сохраняется во flash. На подаче питания он обнуляется, поэтому зависший слот **пробуется
заново** — если зависание было случайным (транзиентным), плата получает новый шанс. Если зависание
детерминированное, оператор жмёт `BTN 2` и входит в recovery немедленно, не дожидаясь порога.
### Честная граница
Если образ стабильно работает, обнуляет счётчик (доказав здоровье), и лишь **потом** ловит редкий баг
(конкретный файл на SD, конкретное входное сообщение) — счётчик каждый раз обнуляется до зависания,
автопорог не накапливается, и цикл автоматически не ловится. Это принципиально: по таймеру не отличить
«здоров» от «здоров, но потом словил редкое». В таком случае плата видимо циклится (watchdog +
recovery-индикация это показывают), лечится SD-фиксом или `BTN 2`. Watchdog как минимум не даёт плате
зависнуть намертво.
---
## 7. Индикация и обратная связь
### Светодиоды
Полный словарь (установка, «железо не в порядке», ошибка образа, приоритет между ними) —
[LED_PATTERNS.md](LED_PATTERNS.md). Кратко:
| Состояние | Паттерн |
| ---------------------- | ------------------------------------------------ |
| Загрузчик жив, ждёт SD | Один LED: короткий импульс ~50 мс, пауза ~450 мс |
| Приложение работает | Задаётся приложением |
| Recovery Mode | Оба LED синхронно: 100 мс вкл / 100 мс выкл |
### USB (виртуальный COM-порт)
Загрузчик поднимает USB-порт до обращения к SD, поэтому статусы видны, даже если оператор подключился
заранее. Обмен — текстовые JSON-строки.
Состояния (`status`):
| Значение | Когда |
| ---------------- | ------------------------------------------------- |
| `waiting_for_sd` | Нет валидного слота, ждём карту |
| `installing` | Принято решение установить кандидата, идёт запись |
| `update_skipped` | Кандидат отклонён по версии |
| `recovery_mode` | Плата в режиме восстановления |
Ошибки установки: `SD_CANDIDATE_INVALID` (битый заголовок), `SD_INSTALL_WRITE_FAILED` (сбой записи),
`SD_INSTALL_REJECTED` (записан, но подпись не прошла), `SD_DOWNGRADE_ERASE_FAILED`.
Статус сторожа (по запросу `wdog`): взведён ли watchdog, таймаут, был ли последний сброс по watchdog,
текущее значение счётчика сбросов и порог.
---
## 8. Полный жизненный цикл
```mermaid
stateDiagram-v2
state "Загрузка" as BOOT
state "Приложение" as APP
state "Ожидание SD" as WAIT
state "Установка" as INST
state "Recovery Mode" as REC
[*] --> BOOT: питание
BOOT --> APP: валидный образ выбран
BOOT --> WAIT: нет валидного слота
BOOT --> REC: порог сбросов / BTN 2
WAIT --> INST: TFT_APP.BIN найден
INST --> APP: установлено + прыжок
INST --> WAIT: отклонено по версии/подписи
APP --> BOOT: watchdog-сброс при зависании
REC --> INST: образ с SD (любая подписанная версия)
REC --> REC: ждём SD
```
---
## 9. Сводка сценариев
| Ситуация | Поведение загрузчика |
| ------------------------------------------------------ | --------------------------------------------------------------------- |
| Оба слота валидны | Загрузка слота с бо́льшей версией |
| Валиден один слот | Загрузка его |
| Чистая плата, оба слота пусты | Ожидание SD (бессрочно), heartbeat |
| SD с образом новее активного | Установка в свободный слот → загрузка |
| SD с образом старше/равным, кнопка не нажата | Пропуск, загрузка прежнего |
| SD с образом старше, `BTN 1` удержана | Даунгрейд: установка + стирание прежнего активного → загрузка старого |
| SD с битым заголовком / битой подписью | Отклонение, прежний валидный слот не тронут |
| Новый образ завис, не подтвердившись (Класс A) | Watchdog-сброс → автоматический откат на прежний слот |
| Подтверждённый образ завис, есть второй слот (Класс B) | 3 сброса → стирание зависшего слота → загрузка второго |
| Зависает, откатываться некуда (Класс C) | Recovery Mode |
| Оператор удержал `BTN 2` при старте (Класс D) | Recovery Mode немедленно, обычная загрузка подавлена |
| В recovery вставлена SD с подписанным образом | Оба слота стёрты, образ в Slot A, автоматический прыжок |
| POR после recovery без `BTN 2` | Счётчик обнулён, зависший слот пробуется заново |