31 Commits

Author SHA1 Message Date
kk0t9 737cb04f3a docs(v0.4): синхронизация швов MCU/thermal + статус
domain B (A12/B08/B09/B10 софт/модель; физический выбор → HW-bring-up), ipc §3 (Power +=
ThermalState/ThermalChanged), hardware §3 (B08/B09 статус v0.4 + 🟡 HW), capability-catalog
(A12/B10 , B08/B09 софт+модель), CLAUDE.md (статус v0.4 ГОТОВО → v0.5).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Alexander <akotenev2003@gmail.com>
2026-06-25 15:52:13 +03:00
kk0t9 50fdaab25b style(v0.4): rustfmt thermal/protocol/coprocessor/service/integration
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Alexander <akotenev2003@gmail.com>
2026-06-25 15:44:10 +03:00
kk0t9 a050f57241 test(v0.4): E2E-блок thermal-trip + throttling + MCU fail-safe (мок-sensor/MCU)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Alexander <akotenev2003@gmail.com>
2026-06-25 15:43:10 +03:00
kk0t9 32ba1136c7 test(v0.4): integration — thermal-trip/abort + MCU fail-safe-cut (session-шина)
+ фикс B09-таймера: last_heartbeat Option<u64> вместо сентинела 0 (monotonic_secs() стартует
с 0 → первый heartbeat попадал на 0, guard !=0 ложно трактовал как «не было» → cut не срабатывал).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Alexander <akotenev2003@gmail.com>
2026-06-25 15:42:20 +03:00
kk0t9 cd2442f672 feat(v0.4): проводка thermal+coprocessor циклов + D-Bus ThermalState/ThermalChanged + dev-mock
spawn_loops (thermal-монитор + coprocessor heartbeat/wait/safe-to-cut/B09) в main после
регистрации интерфейса; PowerService += thermal_state + хендлы fsm/thermal_state + mock(temp,copro);
dev-mock += SetTemp/HangSoc/McuLinkLoss; proxy.rs += thermal_state/thermal_changed. Existing
integration-тесты подогнаны под mock(temp,copro).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Alexander <akotenev2003@gmail.com>
2026-06-25 15:36:19 +03:00
kk0t9 2e6144c54f feat(v0.4): TempSource/Throttler-абстракции + ThermalMonitor (A12/B10)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Alexander <akotenev2003@gmail.com>
2026-06-25 15:32:53 +03:00
kk0t9 860a591f16 feat(v0.4): Coprocessor trait + MockCoprocessor (B09-модель) + клиент (B08)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Alexander <akotenev2003@gmail.com>
2026-06-25 15:31:55 +03:00
kk0t9 147b20ddb6 feat(v0.4): SoC↔MCU протокол + кодек (CRC/replay/desync-guard, B08)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Alexander <akotenev2003@gmail.com>
2026-06-25 15:30:55 +03:00
kk0t9 e54a34cd64 feat(v0.4): FSM ThermalCleared (abort thermal) + FailsafeCut (MCU cut)
+ Action::Cut и его хендлер в apply_event (нужен для компиляции крейта — P8.6 шаг 2 сделан здесь).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Alexander <akotenev2003@gmail.com>
2026-06-25 15:29:36 +03:00
kk0t9 b9ae2f23d5 feat(v0.4): чистый ThermalPolicy (банды + гистерезис, A12/B10)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Alexander <akotenev2003@gmail.com>
2026-06-25 15:27:29 +03:00
kk0t9 fb31a288c3 docs(v0.4): план реализации MCU/thermal (План 8)
TDD-разбивка вехи v0.4 (A12/B08/B09/B10) на P8.1–P8.9: ThermalPolicy (гистерезис) →
FSM-события (ThermalCleared/FailsafeCut) → протокол+кодек (CRC/replay/desync) →
Coprocessor (мок + B09-модель + клиент) → TempSource/Throttler/ThermalMonitor →
проводка сервиса (D-Bus ThermalState/ThermalChanged + spawn_loops + dev-mock SetTemp/
HangSoc) → integration → E2E-блок → verify+prod-gate+швы+finish. Полный код в шагах,
self-review пройден (тайминги тестов поправлены). Спека: docs/specs/v0.4-mcu-thermal.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Alexander <akotenev2003@gmail.com>
2026-06-25 15:18:11 +03:00
kk0t9 c377a34c4f docs(v0.4): спека MCU/thermal fail-safe (тепловой триггер + MCU-протокол + fail-safe)
Веха v0.4 (A12/B08/B09/B10) поверх живого FSM v0.3. Решение брейнсторма: разработка без
платы (принцип #13) — MCU-копилот как reference-арх, софт+симуляция, физический выбор
B08/B09 + железо отложены в HW-bring-up-подфазу. Симметрия с v0.3: чистое ядро (ThermalPolicy/
codec) → абстракция (TempSource/Throttler/Coprocessor trait) → dev-mock.

Скоуп: тепловая политика+гистерезис → Event::ThermalTrip (реюз FSM); SoC↔MCU протокол + кодек
(CRC/replay/desync-guard) + CoprocessorClient (heartbeat/wait-for-completion/safe-to-cut);
B09 fail-safe-таймер — модель (hang/budget → Event::FailsafeCut → off); D-Bus ThermalState/
ThermalChanged (контракт сейчас, рендер v0.5). Приёмка §9.4; швы §10.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Alexander <akotenev2003@gmail.com>
2026-06-25 15:00:10 +03:00
kk0t9 b9500356b0 docs(v0.3): синхронизация швов power-safe + статус
Двунаправленные швы (спека v0.3 §10) после реализации Плана 7:
- domain B: banner «Реализация (v0.3)» (B01–B07, VM-модель abort/PONR=stop+umount+remount);
  §12 — MCU/supercap (B08/B09) → v0.4.
- ipc.md §3: Power оживлён из FSM (не mock); Sleep/Wake/RequestSleep зарезервированы.
- foundation §5.2: «Power-стаб» → реальный PowerFsm (проекции state/ignition/source из FSM);
  dev-mock кормит входы FSM.
- CLAUDE.md: статус v0.3 ГОТОВО; «Следующее» → v0.5 shell / v0.4 MCU-thermal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Alexander <akotenev2003@gmail.com>
2026-06-25 00:21:10 +03:00
kk0t9 35cd6b7230 fix(v0.3): E2E power-safe — пере-bind machine-id + reset-failed (start-limit)
Полный чистый `just vm-reset && just e2e` (PRE→reboot→POST) впервые прогнан после
добавления power-safe-блока — вскрыл два латентных шва харнесса (FSM/сервис-код цел):

1. start-limit-hit: блок намеренно рестартит shturman-power N+ раз за <10s →
   systemd StartLimitBurst (5/10s) → power в failed, имя ru.shturman.Power теряется,
   блок abort не доезжает. Фикс: reset-failed перед намеренными рестартами (как при
   реальном reboot); ждать active power, не только settings.

2. machine-id залипал на нижнем rootfs-значении: цикл/power-cut делает
   `umount /etc/machine-id`, но `systemctl start shturman.target` НЕ пере-bind-ит —
   shturman-machineid это oneshot RemainAfterExit (уже active) → ExecStart не
   перезапускается. POST-чек стабильности machine-id (§9.3.4) падал после reboot.
   Фикс: `systemctl restart shturman-machineid` после remount (форсит пере-bind).

E2E OK  с нуля (vm-reset → PRE → reboot → POST); регресс v0.1/v0.2 цел.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Alexander <akotenev2003@gmail.com>
2026-06-25 00:17:23 +03:00
kk0t9 93382d2de6 fix(v0.3): E2E power-safe-блок по реальным ошибкам Lima
- machineid bind (/data/state/machine-id→/etc/machine-id) держит /data busy →
  снимаем bind перед umount; remount через systemctl start (не mount /data — нет fstab).
- restart power/settings после install (повторный just run: иначе крутится старый
  бинарь, start=no-op) + restart power в начале power-safe-блока (чистый FSM Running).
- §3 oneshot-чек: firstboot/machineid валидны в active|inactive (отработал ИЛИ корректно
  пропущен Condition'ом на повторном boot); реальный сбой = failed.
- stage2.target тянет savetime.timer (WantedBy без enable не срабатывал).
Блок power-safe зелёный: N=3 цикла /data цел, abort→ShutdownAborted, power-cut fsck-clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Alexander <akotenev2003@gmail.com>
2026-06-24 23:33:36 +03:00
kk0t9 92a11c3c72 style(v0.3): rustfmt power FSM/service/integration
Прогон cargo fmt (гейт lint): многострочное форматирование match-веток FSM,
PowerMock/PowerService-литералов, integration-вызовов. Без изменений логики.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Alexander <akotenev2003@gmail.com>
2026-06-24 23:22:12 +03:00
kk0t9 586ba29821 feat(v0.3): lima/E2E блок power-safe (N циклов + abort + power-cut)
P7.4: lima/run.sh раскладывают watchdog (system.conf.d) + savetime.service/.timer.
run.sh блок power-safe: N=3 цикла зажигания (ACC-off→ShutdownImminent→stop stage1→
umount/remount /data→restart; маркер+счётчик целы), abort до PONR (re-power→
ShutdownAborted, /data RW), power-cut-сим (SIGKILL до fsync→fsck clean, night present),
watchdog/savetime конфиг. shellcheck чист.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Alexander <akotenev2003@gmail.com>
2026-06-24 23:20:33 +03:00
kk0t9 394d1463c3 feat(v0.3): watchdog-конфиг (B05/A14) + save-time timer (B07)
P7.3: watchdog-shturman.conf (system.conf.d: RuntimeWatchdogSec/RebootWatchdogSec —
дисциплина; реальный /dev/watchdog + MCU-backstop — HW/v0.4). shturman-savetime.service/
.timer — periodic fake-hwclock save в /data (~5 мин).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Alexander <akotenev2003@gmail.com>
2026-06-24 23:18:00 +03:00
kk0t9 aaae0508b9 feat(v0.3): Power-сервис на FSM — dev-mock кормит события, grace+durable-barrier
P7.2: service.rs оборачивает PowerFsm — D-Bus state/signals из FSM; apply_event
исполняет действия (эмит сигналов, фоновый grace-таймер, durable-barrier sync).
dev-mock SetAcc/SetIgnition/TriggerShutdown/AbortShutdown кормят входы FSM.
FSM: AccOff → AccChanged(false)+ShutdownImminent (сохранён walking-skeleton-регресс).
Integration: ShutdownImminent + abort. zbus → tokio-executor (default-features=false,
features=["tokio"]) — иначе tokio::spawn в хендлере паникует (async-io). test-integration
--test-threads=1 (тесты владеют одним именем на шине).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Alexander <akotenev2003@gmail.com>
2026-06-24 23:17:13 +03:00
kk0t9 d8465c91e4 feat(v0.3): чистый PowerFsm (состояния/переходы B03)
P7.1: State/Event/Action + step (чистый, без I/O) + проекции в PowerState/
IgnitionState/PowerSource. Переходы off↔accessory↔running→shutting_down{abortable→
committed}→off; abort до PONR; sleep/battery_cutoff — каркас. 8 unit-тестов (каждый переход).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Alexander <akotenev2003@gmail.com>
2026-06-24 21:12:48 +03:00
kk0t9 598070de96 docs(v0.3): план реализации power-safe (План 7)
P7.1 чистый PowerFsm (TDD, все переходы) → P7.2 Power-сервис на FSM
(dev-mock кормит события, grace-таймер + durable-barrier sync, integration abort) →
P7.3 watchdog-конфиг + save-time timer → P7.4 lima/E2E блок (N=3 цикла + abort +
power-cut-сим) → P7.5 verify Lima + acceptance + швы. Полный код, без плейсхолдеров.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Alexander <akotenev2003@gmail.com>
2026-06-24 20:58:38 +03:00
kk0t9 4fe5103e88 docs(v0.3): спека power-safe ядра (FSM + graceful shutdown)
Веха v0.3: стаб Power → реальный lifecycle-FSM. Состояния off/accessory/running/
shutting_down{abortable,committed} (sleep/battery_cutoff — каркас); graceful
shutdown (ShutdownImminent→grace→durable-barrier sync→commit=PONR=unmount); abort
до PONR (re-power→ShutdownAborted). dev-mock кормит входы FSM. Watchdog/save-time/
монотоника. Подход A: FSM+сигналы, teardown через systemd/харнесс. Гибрид-E2E:
N=3 in-VM цикла + 1 reboot + abort + power-cut-сим. HW (hold-up/MCU/B08-B09) — v0.4.
Красные линии: Power не трогает CAN, без actuator (#2).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Alexander <akotenev2003@gmail.com>
2026-06-24 20:51:58 +03:00
kk0t9 fd5c5c2dd5 docs(v0.2): синхронизация швов boot-конвейера + статус
P6.5: CLAUDE.md — v0.2 ГОТОВО, следующее v0.3/v0.5 параллельно поверх v0.2.
a-base §4 — dev-VM Stage 0/1/2 = фазовые таргеты + shturman-splash (software-render),
U-Boot framebuffer/A-B/secure-boot — HW (VM↔HW-граница). v0.1-v0.6 спека §13 —
шов «shturman.target → зонтик; critical set → stage1.target».

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Alexander <akotenev2003@gmail.com>
2026-06-24 20:20:32 +03:00
kk0t9 2442f091d2 feat(v0.2): splash-frame + lima/E2E раскладка Stage 0/1/2
P6.4: just splash-frame; lima yaml ставит stage0/1/2.target + tmpfiles
(splash/warmup ловит glob *.service). run.sh: install splash-бинаря, раскладка
новых юнитов+tmpfiles, блок «Stage 0/1/2 разделены» (3 таргета reached, splash.png
до frame.png, stage2.ready после, systemd-analyze лог). Убран restart shell
(ломал бы порядок warmup-после-кадра — фазовый старт рендерит всё по порядку).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Alexander <akotenev2003@gmail.com>
2026-06-24 20:12:02 +03:00
kk0t9 86ab11a54b feat(v0.2): фазовые systemd-таргеты Stage 0/1/2 + splash/warmup + зонтик
P6.3: shturman.target → зонтик (Wants stage0/1/2). Новые: stage0/1/2.target,
shturman-splash.service (Before=shell, минимум зависимостей → «мгновенно»),
shturman-stage2-warmup.service (After=shell, маркер /run/shturman/stage2.ready),
tmpfiles-shturman.conf (/run/shturman). Члены critical set → stage1.target
(WantedBy+PartOf); у shell убран RuntimeDirectory (каталог теперь от tmpfiles).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Alexander <akotenev2003@gmail.com>
2026-06-24 20:08:10 +03:00
kk0t9 62a6f332e2 feat(v0.2): shturman-splash — Stage-0 splash (software-render → PNG)
P6.2: статичный брендовый splash-кадр (wordmark «Штурман» на тёмном),
render_splash через shturman-render; bin с --screenshot. Без чтения шины
(стартует до Power/Settings → «мгновенно»). Визуальные токены — каркас (язык v0.5).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Alexander <akotenev2003@gmail.com>
2026-06-24 20:05:22 +03:00
kk0t9 798e5ba14a refactor(v0.2): вынести headless render в shturman-render (shell использует)
P6.1: общий хелпер render_to_png<C: ComponentHandle>(build, w, h, path) поверх
Slint software-renderer (thread_local окно + set_platform once + draw + png).
shturman-shell.render_screenshot теперь зовёт его; плумбинг-дубль удалён.
png в shell → dev-deps (рендер в render-крейте, тест декодирует). Тесты зелёные.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Alexander <akotenev2003@gmail.com>
2026-06-24 20:03:16 +03:00
kk0t9 e841c082b3 docs(v0.2): план реализации boot-конвейера (План 6)
P6.1 общий рендер-хелпер shturman-render (рефактор из shell) →
P6.2 shturman-splash (Stage 0) → P6.3 фазовые systemd-таргеты + splash/warmup +
зонтик → P6.4 justfile/lima/E2E-блок Stage 0/1/2 → P6.5 verify в Lima + acceptance.
TDD-шаги с полным кодом, без плейсхолдеров. Self-review: покрытие спеки полное.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Alexander <akotenev2003@gmail.com>
2026-06-24 19:57:36 +03:00
kk0t9 9a3b6a8753 docs(v0.2): спека boot-конвейера (Stage 0/1/2 + splash)
Веха v0.2: рефактор плоского shturman.target в фазовый конвейер.
- Stage 0: shturman-splash (Slint software-render → splash.png, до первого кадра);
- Stage 1: критический набор v0.1 → shturman-stage1.target (без изменений тела);
- Stage 2: shturman-stage2-warmup (oneshot-плейсхолдер, после кадра);
- shturman.target → зонтик; общий headless-render хелпер из shell переиспользует splash.
D-Bus-поверхности нет (фазы — systemd). Splash в VM = software-render PNG
(U-Boot framebuffer — HW, шов §10). Тайминг <10c — функц. в VM, вердикт на RK3588.
Приёмка: фазы разделены + splash до кадра + warmup после + регресс v0.1/v0.6 зелёный.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Alexander <akotenev2003@gmail.com>
2026-06-24 19:51:49 +03:00
kk0t9 9b87751ab8 feat(v0.6): Lima E2E зелёный с нуля + shell software-render screenshot
План 5 ч.2: поднял Lima-VM и довёл сквозной E2E до зелёного из чистого yaml
(just vm-reset && just e2e — exit 0). Приёмка §9.4 (v0.1 + v0.6 + шагающий скелет).

Shell (lib+bin split):
- режим --screenshot <path>: headless software-render первого кадра в PNG
  (Slint software-renderer, без дисплея/композитора, §6); TDD-тест «кадр не
  пустой + тема отражена», зелёный и на dev-Mac, и в VM (Linux).
- shturman-shell.service → oneshot software-render → /run/shturman/frame.png
  (RemainAfterExit → is-active детерминированно, без хрупкого weston;
  живой weston-shell — v0.5). just shell-frame — инспекция кадра.

E2E (tests/e2e/run.sh, двухфазно pre→reboot→post):
- /data+power-safe опции, volatile-tmpfs, first-boot идемпотентность, per-unit
  active, имена на шине + GetPowerState, fake-ACC SetAcc→AccChanged, первый кадр
  PNG, base-бюджеты (journald volatile / zram / oomd / fake-hwclock→/data /
  eMMC-прокси), персист Settings + machine-id every-boot bind после reboot.

Провижининг (lima/shturman.yaml) — правки по реальным ошибкам Lima:
- build-deps Slint/winit на Linux (libfontconfig1-dev/libxkbcommon-dev/libwayland-dev);
- linux-modules-extra (zram/vcan не в vz-ядре); systemd-oomd; rm стокового
  /etc/fake-hwclock.data (A11); VM-локальный CARGO_TARGET_DIR.

Док-синхронизация (спека §13/§8.1/§7.5 + CLAUDE.md): швы реализации, eMMC-порог
T=4096 сект, fake-hwclock masked-в-Lima, dev-mock policy не нужен.

Перф-вердикт — на RK3588 (в VM — функционально, performance §2). just ci зелёный.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Alexander <akotenev2003@gmail.com>
2026-06-24 17:14:31 +03:00
kk0t9 a9aad21636 chore: убрать GitHub-Actions CI (триггерит Gitea) + CLAUDE.md цель → Lima E2E
- удалён .github/workflows/ci.yml: self-hosted Gitea ловит GitHub-Actions-формат, не нужно.
  Активный гейт — локальный just ci; авто-CI на Gitea — решение позже.
- CLAUDE.md § «Текущая цель»: фундамент (Планы 1–5 ч.1) в main; следующее — A (Lima E2E, План 5 ч.2).
- spec §8.3 — пометка об удалении CI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Alexander <akotenev2003@gmail.com>
2026-06-24 14:50:59 +03:00
55 changed files with 5866 additions and 349 deletions
-29
View File
@@ -1,29 +0,0 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
lint:
runs-on: ubuntu-24.04-arm
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- run: cargo fmt --all --check
- run: cargo clippy --workspace --all-targets -- -D warnings
test:
runs-on: ubuntu-24.04-arm
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- run: cargo test --workspace
license:
runs-on: ubuntu-24.04-arm
steps:
- uses: actions/checkout@v4
- uses: EmbarkStudios/cargo-deny-action@v2
+40 -3
View File
@@ -42,9 +42,46 @@ vcan + Vehicle Simulator + моки (аудио/BT/камера/GPS/сеть/plu
## Текущая цель — v0 (см. `docs/roadmap.md` § v0)
`v0.1` Образ-болванка → `v0.2` boot-конвейер → `v0.3` power-safe → `v0.5` shell-первый-кадр;
`v0.6` dev-харнесс — параллельный enabling-трек (стартуем с него + v0.1). **Первый запускаемый артефакт:**
boot в Lima-VM → стаб-сервисы (`Power`/`Settings` на D-Bus) → первый Slint-кадр.
**Фундамент готов и в `main`** (Планы 1–5 ч.1; спека `docs/specs/v0.1-v0.6-foundation.md` + планы `docs/specs/plans/`):
воркспейс + `shturman-common`/`ipc`/`sdk` + стаб-сервисы `firstboot`/`settings`/`power` (интеграция на D-Bus) +
первый Slint-кадр (`shell`) + dev-tools (валидатор/scaffolding) + systemd/Lima/E2E-файлы. `just ci` зелёный.
**План 5 ч.2 — ГОТОВО (ветка `feat/v0.6-lima-e2e`):** Lima-VM поднимается (`just vm-up`), сквозной `just e2e`
зелёный с нуля (`just vm-reset && just e2e`): boot → `data.mount` → firstboot → machine-id bind → `Power`/`Settings`
на системной шине → fake-ACC `AccChanged`**первый Slint-кадр** (software-render → PNG, oneshot-сервис) →
base-бюджеты (journald volatile / zram / oomd / fake-hwclock→/data / eMMC-прокси) → **reboot**: персист Settings +
machine-id стабилен. Приёмка §9.4 (v0.1 + v0.6 + шагающий скелет) выполнена. Швы реализации — спека §13.
**v0.2 Boot-конвейер — ГОТОВО (ветка `feat/v0.2-boot-pipeline`):** спека `docs/specs/v0.2-boot-pipeline.md` + план
`docs/specs/plans/06-v0.2-boot-pipeline.md`. `shturman.target`**зонтик** фаз Stage 0/1/2; `shturman-splash` (Stage 0,
software-render → `/run/shturman/splash.png`, `Before=shell` → до первого кадра) + `shturman-stage2-warmup` (деферред
`After=shell`); общий рендер-хелпер `shturman-render` (shell+splash). `just vm-reset && just e2e` зелёный с нуля:
фазы разделены (splash ≤ frame ≤ stage2), регресс v0.1/v0.6 цел. Приёмка спека v0.2 §9.3 выполнена.
**v0.3 Power-safe ядро — ГОТОВО (ветка `feat/v0.3-power-safe`):** спека `docs/specs/v0.3-power-safe.md` + план
`docs/specs/plans/07-v0.3-power-safe.md`. Стаб `Power`**реальный lifecycle-FSM**: чистый `PowerFsm`
(`off↔accessory↔running→shutting_down{abortable→committed}→off`, abort до PONR) + сервис `ru.shturman.Power`
оживлён из FSM (dev-mock кормит события, grace-таймер + durable-barrier `sync` на commit); watchdog-конфиг (B05/A14) +
save-time timer (B07). `just vm-reset && just e2e` зелёный с нуля: **N=3 цикла зажигания** (`/data` + счётчик целы),
**abort до PONR** (`ShutdownAborted`, `/data` RW, running), **power-cut-сим** (SIGKILL → `fsck` clean, durable-value цел);
регресс v0.1/v0.2 + machine-id-стабильность цел. prod-build-gate: `--no-default-features` без `PowerMock1`. Приёмка спека
v0.3 §9.4 выполнена. **VM-модель** (abort/PONR = stop+umount+remount); аппаратное (MCU/hold-up/fail-safe-таймер, **B08/B09**) → v0.4.
**v0.4 MCU/thermal fail-safe — ГОТОВО (ветка `feat/v0.4-mcu-thermal`):** спека `docs/specs/v0.4-mcu-thermal.md` + план
`docs/specs/plans/08-v0.4-mcu-thermal.md`. Поверх FSM v0.3, в стиле «чистое ядро → абстракция → dev-mock»: **A12/B10**
тепло — чистая `ThermalPolicy` (банды + гистерезис) → `Event::ThermalTrip` (реюз FSM) + abort `ThermalCleared`;
`TempSource`/`Throttler` абстракции (VM mock/noop; sysfs/cpufreq + пороги → RK3588). **B08** MCU-протокол
(`SocToMcu`/`McuToSoc`) + кодек (CRC16/replay/desync-guard) + `CoprocessorClient` (heartbeat/wait-for-completion/
`safe-to-cut`). **B09** fail-safe-таймер — **модель** (`MockCoprocessor`: hang/budget → `Event::FailsafeCut` → off).
`ru.shturman.Power` += `ThermalState`/`ThermalChanged` (рендер «перегрев» → v0.5). `just vm-reset && just e2e` зелёный
с нуля: thermal-trip→`ShutdownImminent(thermal)`, throttle-банд, **MCU fail-safe (HangSoc → cut)**; регресс v0.1v0.3
цел. prod-build-gate без `PowerMock1`/`SetTemp`/`HangSoc`. Приёмка спека v0.4 §9.4 выполнена. **Физический выбор B08/B09
(MCU vs supercap-only) + реальное железо (UART/MCU-чип/таймер/cpufreq) → HW-bring-up-подфаза** (нужна плата RK3588).
**Следующее:** `v0.5` полный shell (живой weston-shell; замкнёт thermal-UX-рендер) — поверх v0.2. **HW-bring-up**
(MCU/supercap, реальный UART/cpufreq/B09-чип, тепловой/перф-вердикт) — отдельной подфазой при появлении платы.
> CI: GitHub-Actions-конфиг **удалён** (его ловит Gitea). Гейт — локальный `just ci`. CI на Gitea — решение позже.
## Карта документации
Generated
+40 -17
View File
@@ -165,17 +165,6 @@ dependencies = [
"slab",
]
[[package]]
name = "async-fs"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5"
dependencies = [
"async-lock",
"blocking",
"futures-lite",
]
[[package]]
name = "async-io"
version = "2.6.0"
@@ -3589,6 +3578,16 @@ dependencies = [
"zbus 4.4.0",
]
[[package]]
name = "shturman-render"
version = "0.0.0"
dependencies = [
"anyhow",
"png 0.17.16",
"slint",
"tempfile",
]
[[package]]
name = "shturman-sdk"
version = "0.0.0"
@@ -3622,13 +3621,29 @@ name = "shturman-shell"
version = "0.0.0"
dependencies = [
"anyhow",
"png 0.17.16",
"shturman-common",
"shturman-render",
"shturman-sdk",
"slint",
"tempfile",
"tokio",
"tracing",
]
[[package]]
name = "shturman-splash"
version = "0.0.0"
dependencies = [
"anyhow",
"png 0.17.16",
"shturman-common",
"shturman-render",
"slint",
"tempfile",
"tracing",
]
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
@@ -3859,6 +3874,16 @@ dependencies = [
"serde_core",
]
[[package]]
name = "socket2"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
dependencies = [
"libc",
"windows-sys 0.60.2",
]
[[package]]
name = "softbuffer"
version = "0.4.8"
@@ -4183,11 +4208,14 @@ version = "1.52.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
dependencies = [
"bytes",
"libc",
"mio",
"pin-project-lite",
"signal-hook-registry",
"socket2",
"tokio-macros",
"tracing",
"windows-sys 0.61.2",
]
@@ -5415,15 +5443,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725"
dependencies = [
"async-broadcast",
"async-executor",
"async-fs",
"async-io",
"async-lock",
"async-process",
"async-recursion",
"async-task",
"async-trait",
"blocking",
"enumflags2",
"event-listener",
"futures-core",
@@ -5437,6 +5459,7 @@ dependencies = [
"serde_repr",
"sha1",
"static_assertions",
"tokio",
"tracing",
"uds_windows",
"windows-sys 0.52.0",
+4 -1
View File
@@ -9,6 +9,8 @@ members = [
"crates/core/shturman-firstboot",
"crates/core/shturman-settings",
"crates/core/shturman-power",
"crates/apps/shturman-render",
"crates/apps/shturman-splash",
"crates/apps/shturman-shell",
"crates/tools/shturman-manifest-validator",
]
@@ -20,7 +22,8 @@ rust-version = "1.96"
[workspace.dependencies]
tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal", "sync", "time"] }
zbus = "4"
# tokio-executor у zbus (а не async-io) — сервисы на #[tokio::main]; нужно для tokio::spawn в хендлерах (grace-таймер).
zbus = { version = "4", default-features = false, features = ["tokio"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_yaml = "0.9"
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "shturman-render"
version = "0.0.0"
edition.workspace = true
license.workspace = true
[dependencies]
anyhow.workspace = true
slint.workspace = true
png = "0.17"
[dev-dependencies]
tempfile.workspace = true
+68
View File
@@ -0,0 +1,68 @@
//! Headless software-render Slint-компонента в PNG (без дисплея/композитора).
//! Общий для shturman-shell (первый кадр) и shturman-splash (Stage 0). Спека v0.2 §4.1.
use slint::platform::software_renderer::{MinimalSoftwareWindow, RepaintBufferType};
use slint::platform::{Platform, PlatformError, WindowAdapter};
use slint::ComponentHandle;
use std::path::Path;
use std::rc::Rc;
use std::sync::Once;
thread_local! {
static WINDOW: Rc<MinimalSoftwareWindow> =
MinimalSoftwareWindow::new(RepaintBufferType::ReusedBuffer);
}
struct SwPlatform;
impl Platform for SwPlatform {
fn create_window_adapter(&self) -> Result<Rc<dyn WindowAdapter>, PlatformError> {
Ok(WINDOW.with(|w| w.clone()))
}
}
/// Software-platform ставится один раз на процесс (Slint — глобально). В мультитестовом процессе
/// повтор терпим (`Once` + терпимый результат); рендер в проде зовётся раз на процесс (oneshot-сервис).
fn ensure_platform() {
static ONCE: Once = Once::new();
ONCE.call_once(|| {
let _ = slint::platform::set_platform(Box::new(SwPlatform));
});
}
/// Построить компонент (ВНУТРИ — после `set_platform`) и отрендерить его кадр в PNG.
/// `build` зовётся после установки software-platform (порядок обязателен для Slint).
pub fn render_to_png<C: ComponentHandle>(
build: impl FnOnce() -> anyhow::Result<C>,
w: u32,
h: u32,
path: &Path,
) -> anyhow::Result<()> {
ensure_platform();
let ui = build()?;
let window = WINDOW.with(|x| x.clone());
window.set_size(slint::PhysicalSize::new(w, h));
ui.show()?;
ui.window().request_redraw(); // форсим перерисовку (повторный рендер в том же потоке)
let mut buf = vec![slint::Rgb8Pixel { r: 0, g: 0, b: 0 }; (w * h) as usize];
let drawn = window.draw_if_needed(|r| {
r.render(buf.as_mut_slice(), w as usize);
});
ui.hide()?; // освободить окно для следующего рендера в том же потоке
if !drawn {
anyhow::bail!("software-renderer не отрисовал кадр");
}
write_png(path, w, h, &buf)
}
fn write_png(path: &Path, w: u32, h: u32, buf: &[slint::Rgb8Pixel]) -> anyhow::Result<()> {
let mut enc = png::Encoder::new(std::io::BufWriter::new(std::fs::File::create(path)?), w, h);
enc.set_color(png::ColorType::Rgb);
enc.set_depth(png::BitDepth::Eight);
let mut writer = enc.write_header()?;
let mut data = Vec::with_capacity((w * h * 3) as usize);
for px in buf {
data.extend_from_slice(&[px.r, px.g, px.b]);
}
writer.write_image_data(&data)?;
Ok(())
}
@@ -0,0 +1,25 @@
//! Общий headless-рендер: произвольный Slint-компонент → непустой PNG (План 6 P6.1).
use shturman_render::render_to_png;
slint::slint! {
export component Probe inherits Window {
width: 64px; height: 48px; background: #101418;
Rectangle { background: #ffffff; width: 20px; height: 20px; }
}
}
#[test]
fn renders_component_to_nonempty_png() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("probe.png");
render_to_png(|| Ok(Probe::new()?), 64, 48, &path).expect("render");
let dec = png::Decoder::new(std::fs::File::open(&path).unwrap());
let mut r = dec.read_info().unwrap();
assert_eq!((r.info().width, r.info().height), (64, 48));
let mut buf = vec![0u8; r.output_buffer_size()];
let info = r.next_frame(&mut buf).unwrap();
let px = &buf[..info.buffer_size()];
assert!(px.iter().any(|&b| b != px[0]), "кадр одноцветный");
}
+6
View File
@@ -7,7 +7,13 @@ license.workspace = true
[dependencies]
shturman-sdk = { path = "../../shturman-sdk" }
shturman-common = { path = "../../shturman-common" }
shturman-render = { path = "../shturman-render" }
tokio.workspace = true
anyhow.workspace = true
tracing.workspace = true
slint.workspace = true
[dev-dependencies]
tempfile.workspace = true
# PNG-декодер для проверки кадра в tests/screenshot.rs (рендер — в shturman-render).
png = "0.17"
+175
View File
@@ -0,0 +1,175 @@
//! `shturman-shell` (lib) — первый Slint-кадр (срезы C03/C04/C05/C07/C02) + headless
//! software-render кадра в PNG (спека §6). Bin (`main.rs`) — тонкая обёртка над этим API:
//! интерактивный `run` (dev: weston/нативно) либо `--screenshot <path>` (E2E/CI, без композитора).
//! v0: одноразовое чтение `ui.theme`/Power при старте (best-effort; без шины — дефолты, #4).
mod theme;
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
use slint::ComponentHandle;
slint::slint! {
import { VerticalBox, HorizontalBox } from "std-widgets.slint";
export component AppWindow inherits Window {
in property <bool> is-night: false;
in property <string> clock: "--:--";
in property <string> network: "unknown";
in property <string> ignition: "unknown";
in property <[string]> tiles: ["Навигация", "Музыка", "Телефон", "Ассистент", "Машина", "Настройки"];
title: "Штурман";
width: 1024px;
height: 600px;
background: root.is-night ? #0e1014 : #f4f5f7;
VerticalBox {
padding: 16px;
spacing: 16px;
HorizontalBox {
height: 44px;
Text {
text: root.clock;
font-size: 22px;
color: root.is-night ? #f0f0f0 : #1a1a1a;
vertical-alignment: center;
}
Rectangle { }
Text {
text: "сеть: " + root.network;
color: root.is-night ? #9aa0a6 : #5f6368;
vertical-alignment: center;
}
Text {
text: "зажигание: " + root.ignition;
color: root.is-night ? #9aa0a6 : #5f6368;
vertical-alignment: center;
}
}
HorizontalBox {
spacing: 16px;
for tile in root.tiles : Rectangle {
background: root.is-night ? #1b1e24 : #ffffff;
border-radius: 16px;
Text {
text: tile;
font-size: 18px;
color: root.is-night ? #e8eaed : #202124;
horizontal-alignment: center;
vertical-alignment: center;
}
}
}
}
}
}
/// Размер первого кадра (логические пиксели = физические при scale 1.0).
const FRAME_W: u32 = 1024;
const FRAME_H: u32 = 600;
/// Начальное состояние кадра, прочитанное с шины (best-effort). Без шины — дефолты (#4).
#[derive(Debug, Clone)]
pub struct Initial {
pub theme: String,
pub ignition: String,
pub network: String,
}
impl Default for Initial {
fn default() -> Self {
Self {
theme: "auto".into(),
ignition: "unknown".into(),
network: "unknown".into(),
}
}
}
/// Одноразовое чтение состояния с шины (best-effort). Без шины/сервисов — дефолты (#4).
pub fn read_initial() -> Initial {
// current-thread рантайм: одно best-effort чтение на холодном старте, без пула потоков (#11).
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(_) => return Initial::default(),
};
rt.block_on(async {
match connect_and_read().await {
Ok(i) => i,
Err(e) => {
tracing::warn!(error = %e, "нет шины/сервисов — дефолты кадра");
Initial::default()
}
}
})
}
async fn connect_and_read() -> anyhow::Result<Initial> {
let def = Initial::default(); // единый источник дефолтов (без дублей литералов)
let conn = shturman_sdk::connect().await?;
let settings = shturman_sdk::SettingsClient::new(&conn).await?;
let theme = settings
.get("ui.theme")
.await
.ok()
.and_then(|v| String::try_from(v).ok())
.unwrap_or(def.theme);
let power = shturman_sdk::PowerClient::new(&conn).await?;
let ignition = power
.ignition_state()
.await
.map(|i| i.as_str().to_string())
.unwrap_or(def.ignition);
Ok(Initial {
theme,
ignition,
network: def.network,
})
}
/// Часы UTC `HH:MM` без tz-зависимостей (локальная tz — позже, a-base §7). Возвращает (час, строка).
pub fn utc_hh_mm() -> (u8, String) {
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let h = ((secs / 3600) % 24) as u8;
let m = ((secs / 60) % 60) as u8;
(h, format!("{h:02}:{m:02}"))
}
/// Создать и наполнить окно (общий код интерактива и скриншота).
fn build_ui(initial: &Initial, hour: u8, clock: &str) -> anyhow::Result<AppWindow> {
let ui = AppWindow::new()?;
ui.set_is_night(theme::resolve_night(&initial.theme, hour));
ui.set_clock(clock.into());
ui.set_network(initial.network.as_str().into());
ui.set_ignition(initial.ignition.as_str().into());
Ok(ui)
}
/// Интерактивный запуск (dev: weston в VM / нативно на хосте). Блокирующий event-loop.
pub fn run_interactive(initial: &Initial, hour: u8, clock: &str) -> anyhow::Result<()> {
let ui = build_ui(initial, hour, clock)?;
tracing::info!("первый Slint-кадр (интерактивно)");
ui.run()?;
Ok(())
}
// --- headless software-render первого кадра (спека §6) — через общий хелпер shturman-render ---
/// Headless software-render первого кадра в PNG (спека §6). Без дисплей-сервера/композитора.
/// `hour` задаёт тему для `auto` (тест — детерминированно); `clock` берётся из текущего времени.
pub fn render_screenshot(initial: &Initial, hour: u8, path: &Path) -> anyhow::Result<()> {
let (_, clock) = utc_hh_mm();
shturman_render::render_to_png(|| build_ui(initial, hour, &clock), FRAME_W, FRAME_H, path)?;
tracing::info!(path = %path.display(), "кадр записан (software-render)");
Ok(())
}
+27 -143
View File
@@ -1,152 +1,36 @@
//! `shturman-shell` — первый Slint-кадр (срезы C03/C04/C05/C07/C02). На SDK (architecture §1).
//! v0: одноразовое чтение `ui.theme`/Power при старте (best-effort; без шины — дефолты, #4); рендер.
//! Live-обновления (Changed/AccChanged) и локальная tz часов — позже (v0.5 / a-base §7).
//! `shturman-shell` (bin) — тонкая обёртка над `shturman_shell` (lib):
//! - по умолчанию: интерактивный первый Slint-кадр (dev: weston в VM / нативно на хосте);
//! - `--screenshot <path>`: headless software-render кадра в PNG (E2E/CI, без композитора — §6).
mod theme;
use std::time::{SystemTime, UNIX_EPOCH};
slint::slint! {
import { VerticalBox, HorizontalBox } from "std-widgets.slint";
export component AppWindow inherits Window {
in property <bool> is-night: false;
in property <string> clock: "--:--";
in property <string> network: "unknown";
in property <string> ignition: "unknown";
in property <[string]> tiles: ["Навигация", "Музыка", "Телефон", "Ассистент", "Машина", "Настройки"];
title: "Штурман";
width: 1024px;
height: 600px;
background: root.is-night ? #0e1014 : #f4f5f7;
VerticalBox {
padding: 16px;
spacing: 16px;
HorizontalBox {
height: 44px;
Text {
text: root.clock;
font-size: 22px;
color: root.is-night ? #f0f0f0 : #1a1a1a;
vertical-alignment: center;
}
Rectangle { }
Text {
text: "сеть: " + root.network;
color: root.is-night ? #9aa0a6 : #5f6368;
vertical-alignment: center;
}
Text {
text: "зажигание: " + root.ignition;
color: root.is-night ? #9aa0a6 : #5f6368;
vertical-alignment: center;
}
}
HorizontalBox {
spacing: 16px;
for tile in root.tiles : Rectangle {
background: root.is-night ? #1b1e24 : #ffffff;
border-radius: 16px;
Text {
text: tile;
font-size: 18px;
color: root.is-night ? #e8eaed : #202124;
horizontal-alignment: center;
vertical-alignment: center;
}
}
}
}
}
}
#[derive(Debug)]
struct Initial {
theme: String,
ignition: String,
network: String,
}
impl Default for Initial {
fn default() -> Self {
Self {
theme: "auto".into(),
ignition: "unknown".into(),
network: "unknown".into(),
}
}
}
/// Одноразовое чтение состояния с шины (best-effort). Без шины/сервисов — дефолты (#4).
fn read_initial() -> Initial {
// current-thread рантайм: одно best-effort чтение на холодном старте, без пула потоков (#11).
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(_) => return Initial::default(),
};
rt.block_on(async {
match connect_and_read().await {
Ok(i) => i,
Err(e) => {
tracing::warn!(error = %e, "нет шины/сервисов — дефолты кадра");
Initial::default()
}
}
})
}
async fn connect_and_read() -> anyhow::Result<Initial> {
let def = Initial::default(); // единый источник дефолтов (без дублей литералов)
let conn = shturman_sdk::connect().await?;
let settings = shturman_sdk::SettingsClient::new(&conn).await?;
let theme = settings
.get("ui.theme")
.await
.ok()
.and_then(|v| String::try_from(v).ok())
.unwrap_or(def.theme);
let power = shturman_sdk::PowerClient::new(&conn).await?;
let ignition = power
.ignition_state()
.await
.map(|i| i.as_str().to_string())
.unwrap_or(def.ignition);
Ok(Initial {
theme,
ignition,
network: def.network,
})
}
/// Часы UTC `HH:MM` без tz-зависимостей (локальная tz — позже, a-base §7). Возвращает (час, строка).
fn utc_hh_mm() -> (u8, String) {
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let h = ((secs / 3600) % 24) as u8;
let m = ((secs / 60) % 60) as u8;
(h, format!("{h:02}:{m:02}"))
}
use shturman_shell::{read_initial, render_screenshot, run_interactive, utc_hh_mm};
use std::path::PathBuf;
fn main() -> anyhow::Result<()> {
shturman_common::init_tracing("shturman-shell");
let screenshot = parse_screenshot_arg();
let initial = read_initial();
let (hour, clock) = utc_hh_mm();
let ui = AppWindow::new()?;
ui.set_is_night(theme::resolve_night(&initial.theme, hour));
ui.set_clock(clock.into());
ui.set_network(initial.network.into());
ui.set_ignition(initial.ignition.into());
tracing::info!("первый Slint-кадр");
ui.run()?;
match screenshot {
Some(path) => {
render_screenshot(&initial, hour, &path)?;
println!("{}", path.display()); // путь PNG — для E2E-скрипта
}
None => run_interactive(&initial, hour, &clock)?,
}
Ok(())
}
/// Разобрать `--screenshot <path>` / `--screenshot=<path>` (без внешних зависимостей).
fn parse_screenshot_arg() -> Option<PathBuf> {
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
if a == "--screenshot" {
return args.next().map(PathBuf::from);
}
if let Some(p) = a.strip_prefix("--screenshot=") {
return Some(PathBuf::from(p));
}
}
None
}
@@ -0,0 +1,71 @@
//! Headless software-render первого кадра в PNG (спека §6 / §9.3 п.6 / §9.4 «шагающий скелет»).
//! Работает без дисплея (и на dev-Mac, и в Lima): Slint software-renderer → PNG.
//!
//! Один тест намеренно: Slint-платформа процесс-глобальна и ставится один раз, а `render_screenshot`
//! в проде зовётся ровно раз на процесс (oneshot-сервис / `just shell-frame`) — параллельный прогон
//! нескольких рендер-тестов в одном бинаре ушёл бы в дефолтный winit-бэкенд (на macOS — только main-thread).
use shturman_shell::{render_screenshot, Initial};
use std::path::Path;
/// Декодировать PNG → (ширина, высота, RGB-байты).
fn decode(path: &Path) -> (u32, u32, Vec<u8>) {
let dec = png::Decoder::new(std::fs::File::open(path).unwrap());
let mut reader = dec.read_info().unwrap();
let (w, h) = (reader.info().width, reader.info().height);
let mut buf = vec![0u8; reader.output_buffer_size()];
let info = reader.next_frame(&mut buf).unwrap();
buf.truncate(info.buffer_size());
(w, h, buf)
}
#[test]
fn renders_first_frame_reflecting_theme() {
let dir = tempfile::tempdir().unwrap();
// --- ночь: кадр не пустой, верный размер, тёмный фон ---
let night = dir.path().join("night.png");
render_screenshot(
&Initial {
theme: "night".into(),
ignition: "running".into(),
network: "unknown".into(),
},
12,
&night,
)
.expect("render ночь");
let (w, h, npx) = decode(&night);
assert_eq!((w, h), (1024, 600), "размер кадра");
// «не пустой» содержательно: кадр не одноцветный (нарисованы тайлы/текст, не только фон).
let first = npx[0];
assert!(
npx.iter().any(|&b| b != first),
"кадр одноцветный — рендер пустой"
);
// угол (0,0) = фон окна — ночью тёмный (#0e1014).
let (nr, ng, nb) = (npx[0], npx[1], npx[2]);
assert!(
nr < 64 && ng < 64 && nb < 64,
"ночной фон не тёмный: {nr},{ng},{nb}"
);
// --- день: тот же угол — светлый (#f4f5f7) — тема отражена ---
let day = dir.path().join("day.png");
render_screenshot(
&Initial {
theme: "day".into(),
..Default::default()
},
12,
&day,
)
.expect("render день");
let (_, _, dpx) = decode(&day);
let (dr, dg, db) = (dpx[0], dpx[1], dpx[2]);
assert!(
dr > 192 && dg > 192 && db > 192,
"дневной фон не светлый: {dr},{dg},{db}"
);
}
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "shturman-splash"
version = "0.0.0"
edition.workspace = true
license.workspace = true
[dependencies]
shturman-render = { path = "../shturman-render" }
shturman-common = { path = "../../shturman-common" }
anyhow.workspace = true
tracing.workspace = true
slint.workspace = true
[dev-dependencies]
tempfile.workspace = true
png = "0.17"
+30
View File
@@ -0,0 +1,30 @@
//! `shturman-splash` (lib) — Stage-0 splash-кадр (A05). Статичный брендовый кадр (без шины → «мгновенно»).
//! Визуальные токены — каркас (язык design-system — гейт v0.5). Спека v0.2 §6.
use std::path::Path;
slint::slint! {
export component SplashWindow inherits Window {
in property <string> brand: "Штурман";
width: 1024px;
height: 600px;
background: #0e1014;
Text {
text: root.brand;
font-size: 64px;
color: #e8eaed;
horizontal-alignment: center;
vertical-alignment: center;
}
}
}
const W: u32 = 1024;
const H: u32 = 600;
/// Headless software-render splash-кадра в PNG (без дисплея/композитора).
pub fn render_splash(path: &Path) -> anyhow::Result<()> {
shturman_render::render_to_png(|| Ok(SplashWindow::new()?), W, H, path)?;
tracing::info!(path = %path.display(), "splash записан (software-render)");
Ok(())
}
+34
View File
@@ -0,0 +1,34 @@
//! `shturman-splash` (bin) — Stage-0 splash. `--screenshot <path>` → headless PNG (VM/E2E);
//! без аргументов — интерактив (dev/HW; в v0 VM используется только screenshot-режим).
use shturman_splash::render_splash;
use std::path::PathBuf;
fn main() -> anyhow::Result<()> {
shturman_common::init_tracing("shturman-splash");
match parse_screenshot_arg() {
Some(path) => {
render_splash(&path)?;
println!("{}", path.display());
}
None => {
// интерактив придёт с живым дисплеем (v0.5); в v0 VM splash — только screenshot.
anyhow::bail!("ожидался --screenshot <path> (интерактивный splash — v0.5)");
}
}
Ok(())
}
/// Разобрать `--screenshot <path>` / `--screenshot=<path>` (без внешних зависимостей).
fn parse_screenshot_arg() -> Option<PathBuf> {
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
if a == "--screenshot" {
return args.next().map(PathBuf::from);
}
if let Some(p) = a.strip_prefix("--screenshot=") {
return Some(PathBuf::from(p));
}
}
None
}
@@ -0,0 +1,29 @@
//! Splash-кадр Stage 0: непустой брендовый PNG, тёмный фон (План 6 P6.2 / спека v0.2 §6).
use shturman_splash::render_splash;
#[test]
fn renders_dark_branded_splash() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("splash.png");
render_splash(&path).expect("render_splash");
let dec = png::Decoder::new(std::fs::File::open(&path).unwrap());
let mut r = dec.read_info().unwrap();
assert_eq!((r.info().width, r.info().height), (1024, 600));
let mut buf = vec![0u8; r.output_buffer_size()];
let info = r.next_frame(&mut buf).unwrap();
let px = &buf[..info.buffer_size()];
// фон тёмный (угол) + не одноцветный (wordmark отрисован)
assert!(
px[0] < 64 && px[1] < 64 && px[2] < 64,
"splash фон не тёмный: {},{},{}",
px[0],
px[1],
px[2]
);
assert!(
px.iter().any(|&b| b != px[0]),
"splash одноцветный — wordmark не отрисован"
);
}
+150
View File
@@ -0,0 +1,150 @@
//! Кадр SoC↔MCU + защита линка (B08, спека v0.4 §6.2). Кадр: [SYNC][LEN][SEQ][TYPE][PAYLOAD..][CRC16].
//! CRC16-CCITT по LEN..=PAYLOAD. Декодер: resync по SYNC, drop при битом CRC, drop-replay (seq==last).
pub const SYNC: u8 = 0xA5;
pub fn crc16_ccitt(data: &[u8]) -> u16 {
let mut crc: u16 = 0xFFFF;
for &b in data {
crc ^= (b as u16) << 8;
for _ in 0..8 {
crc = if crc & 0x8000 != 0 {
(crc << 1) ^ 0x1021
} else {
crc << 1
};
}
}
crc
}
pub fn encode_frame(seq: u8, msg_type: u8, payload: &[u8]) -> Vec<u8> {
let mut body = Vec::with_capacity(3 + payload.len());
body.push(payload.len() as u8); // LEN
body.push(seq); // SEQ
body.push(msg_type); // TYPE
body.extend_from_slice(payload);
let crc = crc16_ccitt(&body);
let mut frame = Vec::with_capacity(1 + body.len() + 2);
frame.push(SYNC);
frame.extend_from_slice(&body);
frame.push((crc >> 8) as u8);
frame.push((crc & 0xff) as u8);
frame
}
#[derive(Debug, PartialEq, Eq)]
pub struct DecodedFrame {
pub seq: u8,
pub msg_type: u8,
pub payload: Vec<u8>,
}
/// Потоковый декодер: накапливает байты, выдаёт валидные кадры. Resync/replay-guard внутри.
#[derive(Default)]
pub struct FrameDecoder {
buf: Vec<u8>,
last_seq: Option<u8>,
}
impl FrameDecoder {
pub fn push(&mut self, bytes: &[u8]) -> Vec<DecodedFrame> {
self.buf.extend_from_slice(bytes);
let mut out = Vec::new();
loop {
// resync: отбросить мусор до SYNC
while !self.buf.is_empty() && self.buf[0] != SYNC {
self.buf.remove(0);
}
if self.buf.len() < 4 {
break; // нужно минимум SYNC+LEN+SEQ+TYPE
}
let len = self.buf[1] as usize;
let frame_len = 1 + 3 + len + 2;
if self.buf.len() < frame_len {
break; // кадр ещё не дочитан
}
let body = &self.buf[1..1 + 3 + len];
let crc_rx = ((self.buf[1 + 3 + len] as u16) << 8) | self.buf[1 + 3 + len + 1] as u16;
if crc_rx != crc16_ccitt(body) {
self.buf.remove(0); // битый CRC → сдвиг, resync
continue;
}
let seq = self.buf[2];
let msg_type = self.buf[3];
let payload = self.buf[4..4 + len].to_vec();
self.buf.drain(0..frame_len);
let replay = matches!(self.last_seq, Some(l) if l == seq);
self.last_seq = Some(seq);
if !replay {
out.push(DecodedFrame {
seq,
msg_type,
payload,
});
}
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roundtrip() {
let f = encode_frame(7, 0x02, &[42]);
let mut d = FrameDecoder::default();
let out = d.push(&f);
assert_eq!(
out,
vec![DecodedFrame {
seq: 7,
msg_type: 0x02,
payload: vec![42]
}]
);
}
#[test]
fn corruption_dropped_then_resyncs() {
let mut d = FrameDecoder::default();
let mut f = encode_frame(1, 0x01, &[]);
f[4] ^= 0xFF; // флип в CRC/payload-зоне → битый CRC
assert_eq!(d.push(&f), vec![]); // отброшен
let g = encode_frame(2, 0x01, &[]);
assert_eq!(
d.push(&g),
vec![DecodedFrame {
seq: 2,
msg_type: 0x01,
payload: vec![]
}]
);
}
#[test]
fn replay_dropped() {
let mut d = FrameDecoder::default();
let f = encode_frame(5, 0x01, &[]);
assert_eq!(d.push(&f).len(), 1);
assert_eq!(d.push(&f), vec![]); // тот же seq → replay drop
}
#[test]
fn desync_garbage_before_sync() {
let mut d = FrameDecoder::default();
let mut stream = vec![0x00, 0xFF, 0x13]; // мусор
stream.extend_from_slice(&encode_frame(9, 0x83, &[0x2B, 0x67]));
let out = d.push(&stream);
assert_eq!(
out,
vec![DecodedFrame {
seq: 9,
msg_type: 0x83,
payload: vec![0x2B, 0x67]
}]
);
}
}
@@ -0,0 +1,208 @@
//! SoC↔MCU копроцессор (B08/B09, спека v0.4 §6.3–§6.4). Транспорт байт-уровневый (codec исполняется реально).
//! `MockCoprocessor` моделирует MCU + независимый fail-safe-таймер (B09). Прод `SerialCoprocessor` — стаб (UART → HW).
use crate::codec::{encode_frame, FrameDecoder};
use crate::protocol::{McuToSoc, SocToMcu};
use std::sync::{Arc, Mutex};
// Тайминги — placeholder (сек). Тюнинг hold-up — hardware §3 (RK3588).
pub const HEARTBEAT_SECS: u64 = 1;
pub const FAILSAFE_MISS: u64 = 3; // пропущено heartbeat-окон в running → SoC завис
pub const HOLDUP_BUDGET_SECS: u64 = 5; // shutdown без safe-to-cut дольше → cut
pub const BROWNOUT_MV: u16 = 11_000; // under-voltage backstop (placeholder, hardware §3)
/// Байт-уровневый линк к MCU. `failsafe_due`/`set_now` — модель B09 (прод: реальный MCU в железе → default).
pub trait Coprocessor: Send + Sync {
fn tx(&self, bytes: &[u8]); // SoC → MCU
fn rx(&self) -> Vec<u8>; // MCU → SoC (drain)
fn failsafe_due(&self) -> bool {
false
}
fn set_now(&self, _secs: u64) {}
}
/// SoC-сторона: heartbeat / shutdown-imminent / safe-to-cut + декод входящих MCU→SoC.
pub struct CoprocessorClient {
link: Arc<dyn Coprocessor>,
seq: u8,
decoder: FrameDecoder,
}
impl CoprocessorClient {
pub fn new(link: Arc<dyn Coprocessor>) -> Self {
Self {
link,
seq: 0,
decoder: FrameDecoder::default(),
}
}
fn send(&mut self, msg: SocToMcu) {
self.seq = self.seq.wrapping_add(1);
let f = encode_frame(self.seq, msg.wire_type(), &msg.payload());
self.link.tx(&f);
}
pub fn heartbeat(&mut self) {
self.send(SocToMcu::Heartbeat);
}
pub fn shutdown_imminent(&mut self, budget: u8) {
self.send(SocToMcu::ShutdownImminent { budget });
}
pub fn safe_to_cut(&mut self) {
self.send(SocToMcu::SafeToCut);
}
pub fn poll(&mut self) -> Vec<McuToSoc> {
let bytes = self.link.rx();
self.decoder
.push(&bytes)
.into_iter()
.filter_map(|f| McuToSoc::from_wire(f.msg_type, &f.payload))
.collect()
}
}
#[derive(Default)]
struct MockState {
soc_decoder: FrameDecoder,
mcu_seq: u8,
out: Vec<u8>,
last_heartbeat: Option<u64>, // None = heartbeat ещё не было (sentinel 0 коллизировал с monotonic-стартом)
shutdown_at: Option<u64>,
safe_to_cut: bool,
hung: bool,
now: u64,
}
/// Мок MCU: декодит SoC-кадры (через реальный codec), моделирует B09-таймер, эмитит MCU→SoC.
#[derive(Clone, Default)]
pub struct MockCoprocessor {
st: Arc<Mutex<MockState>>,
}
impl MockCoprocessor {
pub fn new() -> Self {
Self::default()
}
/// MCU → SoC (dev-mock кормит ACC/voltage отсюда).
pub fn emit(&self, msg: McuToSoc) {
let mut s = self.st.lock().unwrap();
s.mcu_seq = s.mcu_seq.wrapping_add(1);
let seq = s.mcu_seq;
let f = encode_frame(seq, msg.wire_type(), &msg.payload());
s.out.extend_from_slice(&f);
}
/// `HangSoc()` — SoC «завис»: heartbeat больше не освежает таймер → сработает B09.
pub fn hang(&self) {
self.st.lock().unwrap().hung = true;
}
}
impl Coprocessor for MockCoprocessor {
fn tx(&self, bytes: &[u8]) {
let mut s = self.st.lock().unwrap();
let now = s.now;
for f in s.soc_decoder.push(bytes) {
if let Some(msg) = SocToMcu::from_wire(f.msg_type, &f.payload) {
match msg {
SocToMcu::Heartbeat => {
if !s.hung {
s.last_heartbeat = Some(now);
}
}
SocToMcu::ShutdownImminent { .. } => s.shutdown_at = Some(now),
SocToMcu::SafeToCut => s.safe_to_cut = true,
}
}
}
}
fn rx(&self) -> Vec<u8> {
std::mem::take(&mut self.st.lock().unwrap().out)
}
fn failsafe_due(&self) -> bool {
let s = self.st.lock().unwrap();
match s.shutdown_at {
// running: тишина heartbeat дольше FAILSAFE_MISS окон → SoC завис → cut
// None last_heartbeat = heartbeat ещё не было → не режем (startup, не зависание)
None => s
.last_heartbeat
.is_some_and(|h| s.now.saturating_sub(h) > FAILSAFE_MISS * HEARTBEAT_SECS),
// shutdown: бюджет истёк без safe-to-cut → cut
Some(t0) => !s.safe_to_cut && s.now.saturating_sub(t0) > HOLDUP_BUDGET_SECS,
}
}
fn set_now(&self, secs: u64) {
self.st.lock().unwrap().now = secs;
}
}
/// Прод-стаб: реальный UART/I2C — HW-bring-up-подфаза. В v0.4 не активен (прод-источника событий нет).
#[derive(Default)]
pub struct SerialCoprocessor;
impl SerialCoprocessor {
pub fn new() -> Self {
Self
}
}
impl Coprocessor for SerialCoprocessor {
fn tx(&self, _bytes: &[u8]) {}
fn rx(&self) -> Vec<u8> {
Vec::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn client_heartbeat_decoded_by_mock() {
let mock = Arc::new(MockCoprocessor::new());
mock.set_now(1);
let mut client = CoprocessorClient::new(mock.clone());
client.heartbeat();
// heartbeat освежил таймер → нет failsafe
assert!(!mock.failsafe_due());
}
#[test]
fn mcu_to_soc_roundtrip() {
let mock = Arc::new(MockCoprocessor::new());
let mut client = CoprocessorClient::new(mock.clone());
mock.emit(McuToSoc::Acc { on: true });
mock.emit(McuToSoc::Voltage { mv: 13_800 });
assert_eq!(
client.poll(),
vec![McuToSoc::Acc { on: true }, McuToSoc::Voltage { mv: 13_800 }]
);
}
#[test]
fn failsafe_on_soc_hang() {
let mock = Arc::new(MockCoprocessor::new());
let mut client = CoprocessorClient::new(mock.clone());
mock.set_now(1);
client.heartbeat(); // last_heartbeat = 1
mock.hang(); // SoC завис
mock.set_now(2);
client.heartbeat(); // hung → таймер НЕ освежается
assert!(!mock.failsafe_due()); // 21=1, ≤ 3
mock.set_now(5); // 51=4 > FAILSAFE_MISS*HEARTBEAT (3)
assert!(mock.failsafe_due());
}
#[test]
fn failsafe_on_holdup_budget() {
let mock = Arc::new(MockCoprocessor::new());
let mut client = CoprocessorClient::new(mock.clone());
mock.set_now(10);
client.shutdown_imminent(2); // shutdown_at = 10
mock.set_now(14);
assert!(!mock.failsafe_due()); // 1410=4 ≤ 5
mock.set_now(16); // > HOLDUP_BUDGET (5)
assert!(mock.failsafe_due());
// safe-to-cut снимает failsafe
mock.set_now(11);
client.safe_to_cut();
mock.set_now(20);
assert!(!mock.failsafe_due());
}
}
+316
View File
@@ -0,0 +1,316 @@
//! Чистый FSM питания (B03, спека v0.3 §5). Без D-Bus/async/I/O — сервис исполняет `Action`.
use shturman_ipc::types::{IgnitionState, PowerSource, PowerState, ShutdownReason};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Phase {
Abortable,
Committed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum State {
Off,
Accessory,
Running,
ShuttingDown {
phase: Phase,
reason: ShutdownReason,
},
Sleep, // зарезервировано (полные sleep/wake — v1/v2)
BatteryCutoff, // зарезервировано (long-park — v1/v2)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Event {
AccOn,
AccOff,
EngineOn,
EngineOff,
UnderVoltage,
ThermalTrip,
GraceExpired,
ThermalCleared, // тепло вернулось в норму до PONR → abort thermal-shutdown (гейт reason==Thermal)
FailsafeCut, // MCU-авторитетный cut (зависший SoC / истёк hold-up) → off, необратимо
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Action {
ShutdownImminent(ShutdownReason),
ShutdownAborted,
AccChanged(bool),
StartGrace,
Commit,
Cut, // MCU снял питание (fail-safe) — сервис логирует + переходит в off
}
pub struct PowerFsm {
state: State,
}
impl Default for PowerFsm {
fn default() -> Self {
Self {
state: State::Running,
}
}
}
impl PowerFsm {
pub fn new() -> Self {
Self::default()
}
pub fn state(&self) -> State {
self.state
}
/// D-Bus-проекция состояния (`PowerState`).
pub fn power_state(&self) -> PowerState {
match self.state {
State::Off => PowerState::Off,
State::Accessory => PowerState::Accessory,
State::Running => PowerState::Running,
State::ShuttingDown { .. } => PowerState::ShuttingDown,
State::Sleep => PowerState::Sleep,
State::BatteryCutoff => PowerState::BatteryCutoff,
}
}
/// Ось зажигания (канон, B §1).
pub fn ignition(&self) -> IgnitionState {
match self.state {
State::Running => IgnitionState::Running,
State::Accessory => IgnitionState::Accessory,
_ => IgnitionState::Off,
}
}
/// Источник питания — сигнал потребителям «времени мало» при shutdown.
pub fn source(&self) -> PowerSource {
match self.state {
State::ShuttingDown {
reason: ShutdownReason::UnderVoltage,
..
} => PowerSource::LowBattery,
State::ShuttingDown { .. } => PowerSource::HoldupCap,
_ => PowerSource::Vehicle12v,
}
}
/// Шаг FSM. Возвращает действия для исполнения сервисом (спека §5).
pub fn step(&mut self, ev: Event) -> Vec<Action> {
use Event as E;
use Phase::*;
use State::*;
match (self.state, ev) {
(Off, E::AccOn) => {
self.state = Accessory;
vec![Action::AccChanged(true)]
}
(Accessory, E::EngineOn) => {
self.state = Running;
vec![]
}
(Running, E::EngineOff) => {
self.state = Accessory;
vec![]
}
// ACC-off: линия ACC сменилась (AccChanged) + старт shutdown.
(Accessory | Running, E::AccOff) => {
self.state = ShuttingDown {
phase: Abortable,
reason: ShutdownReason::AccOff,
};
vec![
Action::AccChanged(false),
Action::ShutdownImminent(ShutdownReason::AccOff),
Action::StartGrace,
]
}
// under-voltage/thermal: ACC не менялся → без AccChanged.
(Accessory | Running, E::UnderVoltage) => {
self.begin_shutdown(ShutdownReason::UnderVoltage)
}
(Accessory | Running, E::ThermalTrip) => self.begin_shutdown(ShutdownReason::Thermal),
(
ShuttingDown {
phase: Abortable, ..
},
E::AccOn,
) => {
self.state = Running;
vec![Action::ShutdownAborted, Action::AccChanged(true)]
}
(
ShuttingDown {
phase: Abortable,
reason,
},
E::GraceExpired,
) => {
self.state = ShuttingDown {
phase: Committed,
reason,
};
vec![Action::Commit]
}
// тепло вернулось до PONR → abort (только thermal-shutdown; ACC-off/under-voltage — no-op)
(
ShuttingDown {
phase: Abortable,
reason: ShutdownReason::Thermal,
},
E::ThermalCleared,
) => {
self.state = Running;
vec![Action::ShutdownAborted]
}
// MCU fail-safe cut → off из любого не-off (необратимо, MCU-авторитет)
(Off, E::FailsafeCut) => vec![],
(_, E::FailsafeCut) => {
self.state = Off;
vec![Action::Cut]
}
// committed/off/sleep/battery_cutoff + всё прочее — no-op (committed не abort-ится)
_ => vec![],
}
}
fn begin_shutdown(&mut self, reason: ShutdownReason) -> Vec<Action> {
self.state = State::ShuttingDown {
phase: Phase::Abortable,
reason,
};
vec![Action::ShutdownImminent(reason), Action::StartGrace]
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn off_acc_on_to_accessory() {
let mut f = PowerFsm { state: State::Off };
assert_eq!(f.step(Event::AccOn), vec![Action::AccChanged(true)]);
assert_eq!(f.state(), State::Accessory);
}
#[test]
fn accessory_engine_on_to_running_and_back() {
let mut f = PowerFsm {
state: State::Accessory,
};
assert_eq!(f.step(Event::EngineOn), vec![]);
assert_eq!(f.state(), State::Running);
assert_eq!(f.step(Event::EngineOff), vec![]);
assert_eq!(f.state(), State::Accessory);
}
#[test]
fn acc_off_begins_abortable_shutdown() {
let mut f = PowerFsm::new(); // Running
assert_eq!(
f.step(Event::AccOff),
vec![
Action::AccChanged(false),
Action::ShutdownImminent(ShutdownReason::AccOff),
Action::StartGrace
]
);
assert_eq!(f.power_state(), PowerState::ShuttingDown);
assert_eq!(f.source(), PowerSource::HoldupCap);
}
#[test]
fn under_voltage_reason_and_source() {
let mut f = PowerFsm::new();
let a = f.step(Event::UnderVoltage);
assert_eq!(a[0], Action::ShutdownImminent(ShutdownReason::UnderVoltage));
assert_eq!(f.source(), PowerSource::LowBattery);
}
#[test]
fn abort_before_ponr() {
let mut f = PowerFsm::new();
f.step(Event::AccOff);
assert_eq!(
f.step(Event::AccOn),
vec![Action::ShutdownAborted, Action::AccChanged(true)]
);
assert_eq!(f.state(), State::Running);
}
#[test]
fn grace_expired_commits_and_is_irreversible() {
let mut f = PowerFsm::new();
f.step(Event::AccOff);
assert_eq!(f.step(Event::GraceExpired), vec![Action::Commit]);
assert_eq!(f.step(Event::AccOn), vec![]); // committed: abort игнорируется
assert!(matches!(
f.state(),
State::ShuttingDown {
phase: Phase::Committed,
..
}
));
}
#[test]
fn reserved_states_noop() {
let mut f = PowerFsm {
state: State::Sleep,
};
assert_eq!(f.step(Event::AccOn), vec![]);
assert_eq!(f.state(), State::Sleep);
}
#[test]
fn ignition_projection() {
assert_eq!(
PowerFsm {
state: State::Running
}
.ignition(),
IgnitionState::Running
);
assert_eq!(
PowerFsm {
state: State::Accessory
}
.ignition(),
IgnitionState::Accessory
);
assert_eq!(
PowerFsm { state: State::Off }.ignition(),
IgnitionState::Off
);
}
#[test]
fn thermal_cleared_aborts_only_thermal_abortable() {
let mut f = PowerFsm::new(); // Running
f.step(Event::ThermalTrip); // → ShuttingDown{Abortable, Thermal}
assert_eq!(f.step(Event::ThermalCleared), vec![Action::ShutdownAborted]);
assert_eq!(f.state(), State::Running);
// из ACC-off-shutdown ThermalCleared — no-op
let mut g = PowerFsm::new();
g.step(Event::AccOff);
assert_eq!(g.step(Event::ThermalCleared), vec![]);
assert_eq!(g.power_state(), PowerState::ShuttingDown);
}
#[test]
fn failsafe_cut_forces_off_from_any_nonoff() {
let mut f = PowerFsm::new(); // Running
assert_eq!(f.step(Event::FailsafeCut), vec![Action::Cut]);
assert_eq!(f.state(), State::Off);
// из off — no-op
assert_eq!(f.step(Event::FailsafeCut), vec![]);
// даже из committed (необратимый shutdown) cut уводит в off
let mut g = PowerFsm::new();
g.step(Event::AccOff);
g.step(Event::GraceExpired); // committed
assert_eq!(g.step(Event::FailsafeCut), vec![Action::Cut]);
assert_eq!(g.state(), State::Off);
}
}
+7 -2
View File
@@ -1,6 +1,11 @@
//! `ru.shturman.Power1` — стаб питания/жизненного цикла (домен B).
//! v0: статичное состояние `running`, мутируется только dev-mock (fake-ACC). Полная FSM/секвенсинг — v0.3.
//! `ru.shturman.Power1` — питание/жизненный цикл (домен B). v0.3: реальный lifecycle-FSM
//! (`fsm`), сервис оборачивает его (D-Bus state/signals из FSM; dev-mock кормит входы).
pub mod codec;
pub mod coprocessor;
pub mod fsm;
pub mod protocol;
pub mod service;
pub mod thermal;
pub use service::PowerService;
+33 -2
View File
@@ -10,13 +10,44 @@ async fn main() -> anyhow::Result<()> {
init_tracing("shturman-power");
let conn = connect().await?;
let svc = PowerService::new();
let fsm = svc.fsm_handle();
let thermal_state = svc.thermal_state_handle();
// источники: dev = mock (управляется dev-D-Bus), prod = реальные (sysfs/UART)
#[cfg(feature = "dev-mocks")]
let mock = svc.mock();
let temp = std::sync::Arc::new(shturman_power::thermal::MockTempSource::new(20));
#[cfg(not(feature = "dev-mocks"))]
let temp = std::sync::Arc::new(shturman_power::thermal::SysfsTempSource::new());
let throttler = std::sync::Arc::new(shturman_power::thermal::NoopThrottler::default());
#[cfg(feature = "dev-mocks")]
let copro = std::sync::Arc::new(shturman_power::coprocessor::MockCoprocessor::new());
#[cfg(not(feature = "dev-mocks"))]
let copro = std::sync::Arc::new(shturman_power::coprocessor::SerialCoprocessor::new());
#[cfg(feature = "dev-mocks")]
let mock = svc.mock(temp.clone(), copro.clone());
conn.object_server().at(names::power::PATH, svc).await?;
#[cfg(feature = "dev-mocks")]
conn.object_server().at(names::power::PATH, mock).await?;
conn.request_name(names::power::NAME).await?;
tracing::info!("ru.shturman.Power1 на шине");
// контекст сигналов для фоновых циклов (после регистрации интерфейса)
let iface = conn
.object_server()
.interface::<_, PowerService>(names::power::PATH)
.await?;
let ctx = iface.signal_context().to_owned();
shturman_power::service::spawn_loops(
fsm,
thermal_state,
temp as std::sync::Arc<dyn shturman_power::thermal::TempSource>,
throttler as std::sync::Arc<dyn shturman_power::thermal::Throttler>,
copro as std::sync::Arc<dyn shturman_power::coprocessor::Coprocessor>,
ctx,
);
tracing::info!("ru.shturman.Power1 на шине (FSM + thermal + coprocessor)");
std::future::pending::<()>().await;
Ok(())
}
@@ -0,0 +1,83 @@
//! Типы сообщений SoC↔MCU (B08, спека v0.4 §6.1). seq — поле кадра (codec), не сообщения.
pub mod wire {
pub const HEARTBEAT: u8 = 0x01;
pub const SHUTDOWN_IMMINENT: u8 = 0x02;
pub const SAFE_TO_CUT: u8 = 0x03;
pub const ACK: u8 = 0x81;
pub const ACC: u8 = 0x82;
pub const VOLTAGE: u8 = 0x83;
pub const CUT_WARNING: u8 = 0x84;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SocToMcu {
Heartbeat,
ShutdownImminent { budget: u8 },
SafeToCut,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum McuToSoc {
Ack,
Acc { on: bool },
Voltage { mv: u16 },
CutWarning,
}
impl SocToMcu {
pub fn wire_type(&self) -> u8 {
match self {
SocToMcu::Heartbeat => wire::HEARTBEAT,
SocToMcu::ShutdownImminent { .. } => wire::SHUTDOWN_IMMINENT,
SocToMcu::SafeToCut => wire::SAFE_TO_CUT,
}
}
pub fn payload(&self) -> Vec<u8> {
match self {
SocToMcu::ShutdownImminent { budget } => vec![*budget],
_ => vec![],
}
}
pub fn from_wire(t: u8, p: &[u8]) -> Option<Self> {
match t {
wire::HEARTBEAT => Some(SocToMcu::Heartbeat),
wire::SHUTDOWN_IMMINENT => Some(SocToMcu::ShutdownImminent {
budget: *p.first()?,
}),
wire::SAFE_TO_CUT => Some(SocToMcu::SafeToCut),
_ => None,
}
}
}
impl McuToSoc {
pub fn wire_type(&self) -> u8 {
match self {
McuToSoc::Ack => wire::ACK,
McuToSoc::Acc { .. } => wire::ACC,
McuToSoc::Voltage { .. } => wire::VOLTAGE,
McuToSoc::CutWarning => wire::CUT_WARNING,
}
}
pub fn payload(&self) -> Vec<u8> {
match self {
McuToSoc::Acc { on } => vec![*on as u8],
McuToSoc::Voltage { mv } => mv.to_be_bytes().to_vec(),
_ => vec![],
}
}
pub fn from_wire(t: u8, p: &[u8]) -> Option<Self> {
match t {
wire::ACK => Some(McuToSoc::Ack),
wire::ACC => Some(McuToSoc::Acc {
on: *p.first()? != 0,
}),
wire::VOLTAGE => Some(McuToSoc::Voltage {
mv: u16::from_be_bytes([*p.first()?, *p.get(1)?]),
}),
wire::CUT_WARNING => Some(McuToSoc::CutWarning),
_ => None,
}
}
}
+217 -58
View File
@@ -1,38 +1,30 @@
//! Server-стаб `ru.shturman.Power1` + (feature `dev-mocks`) `ru.shturman.dev.PowerMock1` (fake-ACC).
//! zbus 4: несколько интерфейсов на одном объекте — это РАЗНЫЕ типы на одном пути, разделяющие
//! состояние через `Arc<Mutex<State>>` (а не два `#[interface]` на одном типе).
//! Server `ru.shturman.Power1` + (feature `dev-mocks`) `ru.shturman.dev.PowerMock1` (fake-ACC).
//! v0.3: оборачивает чистый `PowerFsm` (спека §5–§7). dev-mock кормит входы FSM (не флипает состояние).
use crate::coprocessor::{Coprocessor, CoprocessorClient, BROWNOUT_MV};
use crate::fsm::{Action, Event, Phase, PowerFsm, State};
use crate::protocol::McuToSoc;
use crate::thermal::{TempSource, ThermalLevel, ThermalMonitor, Throttler};
use shturman_common::monotonic_secs;
use shturman_ipc::types::{IgnitionState, PowerSource, PowerState};
use shturman_ipc::types::{IgnitionState, PowerSource, PowerState, ShutdownReason};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use zbus::interface;
use zbus::object_server::SignalContext;
struct State {
power: PowerState,
ignition: IgnitionState,
source: PowerSource,
}
/// Grace-окно (сек): и поле сигнала `ShutdownImminent`, и длительность таймера. v0.3 — фикс. (конфиг — позже).
const GRACE_SECS: u32 = 2;
impl Default for State {
fn default() -> Self {
Self {
power: PowerState::Running,
ignition: IgnitionState::Running,
source: PowerSource::Vehicle12v,
}
}
}
/// Стаб питания (`Power1`). В v0 стартует в `running`; запись/actuator отсутствуют (#2).
pub struct PowerService {
state: Arc<Mutex<State>>,
fsm: Arc<Mutex<PowerFsm>>,
thermal_state: Arc<Mutex<ThermalLevel>>,
}
impl Default for PowerService {
fn default() -> Self {
Self {
state: Arc::new(Mutex::new(State::default())),
fsm: Arc::new(Mutex::new(PowerFsm::new())),
thermal_state: Arc::new(Mutex::new(ThermalLevel::Normal)),
}
}
}
@@ -41,54 +33,207 @@ impl PowerService {
pub fn new() -> Self {
Self::default()
}
// Inherent-аксессоры (тесты + источник для interface-методов).
pub fn power_state(&self) -> PowerState {
self.state.lock().unwrap().power
self.fsm.lock().unwrap().power_state()
}
pub fn ignition(&self) -> IgnitionState {
self.state.lock().unwrap().ignition
self.fsm.lock().unwrap().ignition()
}
pub fn source(&self) -> PowerSource {
self.state.lock().unwrap().source
self.fsm.lock().unwrap().source()
}
pub fn fsm_handle(&self) -> Arc<Mutex<PowerFsm>> {
Arc::clone(&self.fsm)
}
pub fn thermal_state_handle(&self) -> Arc<Mutex<ThermalLevel>> {
Arc::clone(&self.thermal_state)
}
/// dev-mock «fake-ACC», разделяющий состояние (только в dev-сборке).
#[cfg(feature = "dev-mocks")]
pub fn mock(&self) -> PowerMock {
pub fn mock(
&self,
temp: Arc<crate::thermal::MockTempSource>,
copro: Arc<crate::coprocessor::MockCoprocessor>,
) -> PowerMock {
PowerMock {
state: Arc::clone(&self.state),
fsm: Arc::clone(&self.fsm),
temp,
copro,
}
}
}
/// Durable-write barrier (#5): сбросить грязные страницы `/data` ДО PONR (Settings уже синхронен).
fn durable_barrier() {
let _ = std::process::Command::new("sync").status();
tracing::info!(
"power: commit (PONR) — durable barrier sync; load-shed: amp/backlight/modem (нет реальных нагрузок в v0)"
);
}
/// Шагнуть FSM и исполнить действия (эмит сигналов, grace-таймер, durable-barrier).
async fn apply_event(
fsm: &Arc<Mutex<PowerFsm>>,
ev: Event,
ctx: &SignalContext<'_>,
) -> zbus::Result<()> {
let actions = fsm.lock().unwrap().step(ev);
for a in actions {
match a {
Action::ShutdownImminent(r) => {
PowerService::shutdown_imminent(ctx, GRACE_SECS, r.as_str()).await?
}
Action::ShutdownAborted => PowerService::shutdown_aborted(ctx).await?,
Action::AccChanged(on) => PowerService::acc_changed(ctx, on).await?,
Action::StartGrace => {
// Фоновый grace-таймер (монотоника tokio). По истечении — GraceExpired:
// commit (durable-barrier), если FSM ещё в abortable; если был re-power (abort) — no-op.
let fsm = Arc::clone(fsm);
tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(GRACE_SECS as u64)).await;
let acts = fsm.lock().unwrap().step(Event::GraceExpired);
if acts.contains(&Action::Commit) {
durable_barrier();
}
});
}
Action::Commit => durable_barrier(),
Action::Cut => {
tracing::warn!("power: MCU fail-safe cut (SoC hang / hold-up budget) — forced off");
}
}
}
Ok(())
}
/// Фоновые циклы v0.4 — thermal-монитор + coprocessor (heartbeat/wait/safe-to-cut/B09). Монотоника.
#[allow(clippy::too_many_arguments)]
pub fn spawn_loops(
fsm: Arc<Mutex<PowerFsm>>,
thermal_state: Arc<Mutex<ThermalLevel>>,
temp: Arc<dyn TempSource>,
throttler: Arc<dyn Throttler>,
copro: Arc<dyn Coprocessor>,
ctx: SignalContext<'static>,
) {
// thermal-цикл
{
let (fsm, ctx) = (Arc::clone(&fsm), ctx.clone());
tokio::spawn(async move {
let mut mon = ThermalMonitor::new();
loop {
tokio::time::sleep(Duration::from_secs(crate::thermal::POLL_SECS)).await;
let t = temp.read_celsius();
let obs = mon.observe(t);
if !obs.changed {
continue;
}
throttler.apply(obs.level);
*thermal_state.lock().unwrap() = obs.level;
let _ = PowerService::thermal_changed(&ctx, obs.level.as_str(), t).await;
if obs.entered_critical {
let _ = apply_event(&fsm, Event::ThermalTrip, &ctx).await;
}
if obs.left_critical {
let in_thermal_abortable = matches!(
fsm.lock().unwrap().state(),
State::ShuttingDown {
phase: Phase::Abortable,
reason: ShutdownReason::Thermal
}
);
if in_thermal_abortable {
let _ = apply_event(&fsm, Event::ThermalCleared, &ctx).await;
}
}
}
});
}
// coprocessor-цикл (heartbeat / wait-for-completion / safe-to-cut / B09 failsafe)
{
tokio::spawn(async move {
let mut client = CoprocessorClient::new(Arc::clone(&copro));
let mut last_committed = false;
let mut last_shutting = false;
loop {
tokio::time::sleep(Duration::from_secs(crate::coprocessor::HEARTBEAT_SECS)).await;
copro.set_now(monotonic_secs());
// входящие MCU→SoC → FSM
for msg in client.poll() {
match msg {
McuToSoc::Acc { on } => {
let ev = if on { Event::AccOn } else { Event::AccOff };
let _ = apply_event(&fsm, ev, &ctx).await;
}
McuToSoc::Voltage { mv } if mv < BROWNOUT_MV => {
let _ = apply_event(&fsm, Event::UnderVoltage, &ctx).await;
}
_ => {}
}
}
// рёбра состояния FSM → протокол
let st = fsm.lock().unwrap().state();
let shutting = matches!(st, State::ShuttingDown { .. });
let committed = matches!(
st,
State::ShuttingDown {
phase: Phase::Committed,
..
}
);
if shutting && !last_shutting {
client.shutdown_imminent(crate::coprocessor::HOLDUP_BUDGET_SECS as u8);
} else if committed && !last_committed {
client.safe_to_cut(); // PONR → MCU режет немедленно
} else if !shutting {
client.heartbeat(); // running/accessory — keepalive
}
last_shutting = shutting;
last_committed = committed;
// B09: независимый fail-safe-таймер (зависший SoC / истёк бюджет)
if copro.failsafe_due() {
let _ = apply_event(&fsm, Event::FailsafeCut, &ctx).await;
}
}
});
}
}
#[interface(name = "ru.shturman.Power1")]
impl PowerService {
async fn get_power_state(&self) -> String {
self.power_state().as_str().to_string()
}
/// Внутренний; в v0-стабе — no-op (полная sleep/wake — v1/v2, B §7).
/// Внутренний; sleep/wake — v1/v2 (B §7). В v0.3 — no-op.
async fn request_sleep(&self) {}
#[zbus(property)]
async fn ignition_state(&self) -> String {
self.ignition().as_str().to_string()
}
#[zbus(property)]
async fn uptime(&self) -> u64 {
monotonic_secs()
}
#[zbus(property)]
async fn power_source(&self) -> String {
self.source().as_str().to_string()
}
#[zbus(property)]
async fn thermal_state(&self) -> String {
self.thermal_state.lock().unwrap().as_str().to_string()
}
#[zbus(signal)]
async fn acc_changed(ctx: &SignalContext<'_>, on: bool) -> zbus::Result<()>;
#[zbus(signal)]
async fn thermal_changed(
ctx: &SignalContext<'_>,
state: &str,
celsius: i32,
) -> zbus::Result<()>;
#[zbus(signal)]
async fn shutdown_imminent(
ctx: &SignalContext<'_>,
seconds: u32,
@@ -102,51 +247,65 @@ impl PowerService {
async fn wake(ctx: &SignalContext<'_>) -> zbus::Result<()>;
}
/// dev-mock «fake-ACC» — отдельный тип на том же пути. Прод (`--no-default-features`) его НЕ регистрирует.
/// Методы возвращают `()` (ошибку эмита сигнала игнорируем — мок не отвечает D-Bus-ошибкой).
/// dev-mock «fake-ACC/voltage/thermal» — кормит входы FSM. Прод (`--no-default-features`) не регистрирует.
#[cfg(feature = "dev-mocks")]
pub struct PowerMock {
state: Arc<Mutex<State>>,
fsm: Arc<Mutex<PowerFsm>>,
temp: Arc<crate::thermal::MockTempSource>,
copro: Arc<crate::coprocessor::MockCoprocessor>,
}
#[cfg(feature = "dev-mocks")]
#[interface(name = "ru.shturman.dev.PowerMock1")]
impl PowerMock {
async fn set_acc(&self, on: bool, #[zbus(signal_context)] ctx: SignalContext<'_>) {
{
let mut st = self.state.lock().unwrap();
st.ignition = if on {
IgnitionState::Running
} else {
IgnitionState::Off
};
st.power = if on {
PowerState::Running
} else {
PowerState::Off
};
}
// Эмитим Power1-сигнал (тот же путь; имя интерфейса добавляет сама acc_changed).
let _ = PowerService::acc_changed(&ctx, on).await;
let ev = if on { Event::AccOn } else { Event::AccOff };
let _ = apply_event(&self.fsm, ev, &ctx).await;
}
async fn set_ignition(&self, state: String) {
if let Ok(ig) = state.parse::<IgnitionState>() {
self.state.lock().unwrap().ignition = ig;
}
async fn set_ignition(&self, state: String, #[zbus(signal_context)] ctx: SignalContext<'_>) {
// accessory↔running — через EngineOn/Off; off — AccOff.
let ev = match state.as_str() {
"running" => Event::EngineOn,
"accessory" => Event::EngineOff,
_ => Event::AccOff,
};
let _ = apply_event(&self.fsm, ev, &ctx).await;
}
async fn trigger_shutdown(
&self,
seconds: u32,
_seconds: u32,
reason: String,
#[zbus(signal_context)] ctx: SignalContext<'_>,
) {
let _ = PowerService::shutdown_imminent(&ctx, seconds, &reason).await;
let ev = match reason.as_str() {
"thermal" => Event::ThermalTrip,
"under_voltage" => Event::UnderVoltage,
_ => Event::AccOff,
};
let _ = apply_event(&self.fsm, ev, &ctx).await;
}
async fn abort_shutdown(&self, #[zbus(signal_context)] ctx: SignalContext<'_>) {
let _ = PowerService::shutdown_aborted(&ctx).await;
let _ = apply_event(&self.fsm, Event::AccOn, &ctx).await;
}
/// Задать температуру (°C) → thermal-монитор подхватит на следующем poll.
async fn set_temp(&self, celsius: i32) {
self.temp.set(celsius);
}
/// «Завис SoC»: heartbeat перестаёт освежать B09-таймер → MCU срежет питание.
async fn hang_soc(&self) {
self.copro.hang();
}
/// Тишина линка: SoC-сторона деградирует (лог, не self-cut — red-line). MCU-политика cut-vs-hold — B §12/HW.
async fn mcu_link_loss(&self) {
tracing::warn!(
"coprocessor: MCU link loss — SoC деградирует (cut-vs-hold политика — HW/§12)"
);
}
}
+290
View File
@@ -0,0 +1,290 @@
//! Тепловая подсистема (A12/B10, спека v0.4 §5). `ThermalPolicy` — чистая (без I/O), с гистерезисом.
//! Источники/throttler/монитор — P8.5 (этот же файл).
/// Уровень теплового состояния. `Throttle(u8)` — банд (v0.4 использует уровень 1; мульти-банд — RK3588).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ThermalLevel {
Normal,
Warn,
Throttle(u8),
Critical,
}
impl ThermalLevel {
pub fn as_str(&self) -> &'static str {
match self {
ThermalLevel::Normal => "normal",
ThermalLevel::Warn => "warn",
ThermalLevel::Throttle(_) => "throttle",
ThermalLevel::Critical => "critical",
}
}
fn rank(&self) -> u8 {
match self {
ThermalLevel::Normal => 0,
ThermalLevel::Warn => 1,
ThermalLevel::Throttle(_) => 2,
ThermalLevel::Critical => 3,
}
}
}
// Пороги — placeholder-константы (°C). Тюнинг на RK3588 (hardware §1a; Tjmax ~100 °C).
pub const WARN_C: i32 = 75;
pub const THROTTLE_C: i32 = 85;
pub const CRITICAL_C: i32 = 95;
pub const HYST_C: i32 = 5;
pub const POLL_SECS: u64 = 1; // период опроса температуры (монотоника)
/// Чистая политика: `(предыдущий уровень, температура) → уровень` с гистерезисом (Schmitt по бандам).
pub struct ThermalPolicy;
impl ThermalPolicy {
fn band_by_entry(t: i32) -> ThermalLevel {
if t >= CRITICAL_C {
ThermalLevel::Critical
} else if t >= THROTTLE_C {
ThermalLevel::Throttle(1)
} else if t >= WARN_C {
ThermalLevel::Warn
} else {
ThermalLevel::Normal
}
}
fn band_by_exit(t: i32) -> ThermalLevel {
// нижние (гистерезисные) пороги = entry HYST
if t >= CRITICAL_C - HYST_C {
ThermalLevel::Critical
} else if t >= THROTTLE_C - HYST_C {
ThermalLevel::Throttle(1)
} else if t >= WARN_C - HYST_C {
ThermalLevel::Warn
} else {
ThermalLevel::Normal
}
}
/// Подъём — по entry-порогам; спуск — по exit-порогам (entry − HYST) → нет осцилляции на границе.
pub fn next(prev: ThermalLevel, temp_c: i32) -> ThermalLevel {
let up = Self::band_by_entry(temp_c);
if up.rank() >= prev.rank() {
up
} else {
Self::band_by_exit(temp_c)
}
}
}
use std::sync::atomic::{AtomicI32, Ordering};
use std::sync::Arc;
/// Источник температуры (°C). real = sysfs; VM = mock.
pub trait TempSource: Send + Sync {
fn read_celsius(&self) -> i32;
}
/// Mock-источник (dev): температуру задаёт `SetTemp` через dev-D-Bus.
#[derive(Clone)]
pub struct MockTempSource {
temp: Arc<AtomicI32>,
}
impl MockTempSource {
pub fn new(init_c: i32) -> Self {
Self {
temp: Arc::new(AtomicI32::new(init_c)),
}
}
pub fn set(&self, c: i32) {
self.temp.store(c, Ordering::Relaxed);
}
}
impl TempSource for MockTempSource {
fn read_celsius(&self) -> i32 {
self.temp.load(Ordering::Relaxed)
}
}
/// Прод: max по `/sys/class/thermal/thermal_zone*/temp` (миллиградусы). В Lima зоны статичны → числа на RK3588.
#[derive(Default)]
pub struct SysfsTempSource;
impl SysfsTempSource {
pub fn new() -> Self {
Self
}
}
impl TempSource for SysfsTempSource {
fn read_celsius(&self) -> i32 {
let mut max = i32::MIN;
if let Ok(rd) = std::fs::read_dir("/sys/class/thermal") {
for e in rd.flatten() {
let p = e.path().join("temp");
if let Ok(s) = std::fs::read_to_string(&p) {
if let Ok(milli) = s.trim().parse::<i32>() {
max = max.max(milli / 1000);
}
}
}
}
if max == i32::MIN {
0
} else {
max
}
}
}
/// Применение throttle. real = cpufreq (HW); VM = запись уровня (no-op-эффект).
pub trait Throttler: Send + Sync {
fn apply(&self, level: ThermalLevel);
}
/// VM/прод-каркас: логирует + запоминает последний уровень (реальный cpufreq — HW).
#[derive(Default, Clone)]
pub struct NoopThrottler {
last: Arc<std::sync::Mutex<Option<ThermalLevel>>>,
}
impl NoopThrottler {
pub fn last(&self) -> Option<ThermalLevel> {
*self.last.lock().unwrap()
}
}
impl Throttler for NoopThrottler {
fn apply(&self, level: ThermalLevel) {
*self.last.lock().unwrap() = Some(level);
tracing::info!(
"thermal: throttle уровень {} (эффект cpufreq — HW)",
level.as_str()
);
}
}
/// Результат шага монитора: уровень + рёбра входа/выхода Critical (для FSM ThermalTrip/ThermalCleared).
#[derive(Debug, PartialEq, Eq)]
pub struct ThermalObservation {
pub level: ThermalLevel,
pub changed: bool,
pub entered_critical: bool,
pub left_critical: bool,
}
/// Монитор: хранит предыдущий уровень, применяет политику, размечает рёбра Critical.
pub struct ThermalMonitor {
prev: ThermalLevel,
}
impl Default for ThermalMonitor {
fn default() -> Self {
Self {
prev: ThermalLevel::Normal,
}
}
}
impl ThermalMonitor {
pub fn new() -> Self {
Self::default()
}
pub fn observe(&mut self, temp_c: i32) -> ThermalObservation {
let level = ThermalPolicy::next(self.prev, temp_c);
let changed = level != self.prev;
let entered_critical = changed && level == ThermalLevel::Critical;
let left_critical = changed && self.prev == ThermalLevel::Critical;
self.prev = level;
ThermalObservation {
level,
changed,
entered_critical,
left_critical,
}
}
}
#[cfg(test)]
mod monitor_tests {
use super::*;
#[test]
fn marks_critical_edges() {
let mut m = ThermalMonitor::new();
let o = m.observe(96);
assert!(o.entered_critical && o.changed && o.level == ThermalLevel::Critical);
let o = m.observe(96); // держится — рёбер нет
assert!(!o.changed && !o.entered_critical);
let o = m.observe(80); // < 90 → выход из critical
assert!(o.left_critical && o.level == ThermalLevel::Throttle(1));
}
#[test]
fn mock_source_and_noop_throttler() {
let src = MockTempSource::new(20);
assert_eq!(src.read_celsius(), 20);
src.set(88);
assert_eq!(src.read_celsius(), 88);
let th = NoopThrottler::default();
th.apply(ThermalLevel::Throttle(1));
assert_eq!(th.last(), Some(ThermalLevel::Throttle(1)));
}
}
#[cfg(test)]
mod policy_tests {
use super::*;
#[test]
fn rises_by_entry_thresholds() {
assert_eq!(
ThermalPolicy::next(ThermalLevel::Normal, 70),
ThermalLevel::Normal
);
assert_eq!(
ThermalPolicy::next(ThermalLevel::Normal, 75),
ThermalLevel::Warn
);
assert_eq!(
ThermalPolicy::next(ThermalLevel::Warn, 85),
ThermalLevel::Throttle(1)
);
assert_eq!(
ThermalPolicy::next(ThermalLevel::Throttle(1), 95),
ThermalLevel::Critical
);
// прыжок вверх через банды
assert_eq!(
ThermalPolicy::next(ThermalLevel::Normal, 99),
ThermalLevel::Critical
);
}
#[test]
fn hysteresis_holds_until_below_exit() {
// critical держится до < 90 (955)
assert_eq!(
ThermalPolicy::next(ThermalLevel::Critical, 92),
ThermalLevel::Critical
);
assert_eq!(
ThermalPolicy::next(ThermalLevel::Critical, 89),
ThermalLevel::Throttle(1)
);
// warn держится до < 70
assert_eq!(
ThermalPolicy::next(ThermalLevel::Warn, 73),
ThermalLevel::Warn
);
assert_eq!(
ThermalPolicy::next(ThermalLevel::Warn, 69),
ThermalLevel::Normal
);
}
#[test]
fn no_oscillation_at_boundary() {
// на 84 (чуть ниже entry throttle=85): зависит от prev (Schmitt), не дёргается
assert_eq!(
ThermalPolicy::next(ThermalLevel::Throttle(1), 84),
ThermalLevel::Throttle(1)
);
assert_eq!(
ThermalPolicy::next(ThermalLevel::Warn, 84),
ThermalLevel::Warn
);
}
}
+161 -1
View File
@@ -3,14 +3,20 @@
use futures_util::StreamExt;
use shturman_ipc::{names, types::PowerState};
use shturman_power::coprocessor::MockCoprocessor;
use shturman_power::thermal::MockTempSource;
use shturman_power::PowerService;
use shturman_sdk::PowerClient;
use std::sync::Arc;
#[tokio::test]
#[ignore = "нужна session-шина: just test-integration"]
async fn power_state_and_fake_acc() {
let svc = PowerService::new();
let mock = svc.mock();
let mock = svc.mock(
Arc::new(MockTempSource::new(20)),
Arc::new(MockCoprocessor::new()),
);
// сервер: Power1 + dev.PowerMock1 на одном пути (владеет ru.shturman.Power)
let server = zbus::Connection::session().await.unwrap();
@@ -48,3 +54,157 @@ async fn power_state_and_fake_acc() {
let sig = acc.next().await.unwrap();
assert!(!sig.args().unwrap().on());
}
#[tokio::test]
#[ignore = "нужна session-шина: just test-integration"]
async fn shutdown_imminent_then_abort() {
let svc = PowerService::new();
let mock = svc.mock(
Arc::new(MockTempSource::new(20)),
Arc::new(MockCoprocessor::new()),
);
let server = zbus::Connection::session().await.unwrap();
server
.object_server()
.at(names::power::PATH, svc)
.await
.unwrap();
server
.object_server()
.at(names::power::PATH, mock)
.await
.unwrap();
server.request_name(names::power::NAME).await.unwrap();
let client = zbus::Connection::session().await.unwrap();
let power = PowerClient::new(&client).await.unwrap();
let mut imminent = power.proxy().receive_shutdown_imminent().await.unwrap();
let mut aborted = power.proxy().receive_shutdown_aborted().await.unwrap();
// ACC-off → ShutdownImminent(acc_off), состояние shutting_down
client
.call_method(
Some(names::power::NAME),
names::power::PATH,
Some(names::power::MOCK_IFACE),
"SetAcc",
&(false,),
)
.await
.unwrap();
let sig = imminent.next().await.unwrap();
assert_eq!(sig.args().unwrap().reason(), "acc_off");
assert_eq!(power.power_state().await.unwrap(), PowerState::ShuttingDown);
// re-power до grace → ShutdownAborted + running
client
.call_method(
Some(names::power::NAME),
names::power::PATH,
Some(names::power::MOCK_IFACE),
"SetAcc",
&(true,),
)
.await
.unwrap();
aborted.next().await.unwrap();
assert_eq!(power.power_state().await.unwrap(), PowerState::Running);
}
#[tokio::test]
#[ignore = "нужна session-шина: just test-integration"]
async fn thermal_trip_then_clear() {
let svc = PowerService::new();
let fsm = svc.fsm_handle();
let thermal_state = svc.thermal_state_handle();
let temp = Arc::new(MockTempSource::new(20));
let copro = Arc::new(MockCoprocessor::new());
let server = zbus::Connection::session().await.unwrap();
server
.object_server()
.at(names::power::PATH, svc)
.await
.unwrap();
server.request_name(names::power::NAME).await.unwrap();
let iface = server
.object_server()
.interface::<_, PowerService>(names::power::PATH)
.await
.unwrap();
let ctx = iface.signal_context().to_owned();
shturman_power::service::spawn_loops(
fsm,
thermal_state,
temp.clone() as Arc<dyn shturman_power::thermal::TempSource>,
Arc::new(shturman_power::thermal::NoopThrottler::default())
as Arc<dyn shturman_power::thermal::Throttler>,
copro as Arc<dyn shturman_power::coprocessor::Coprocessor>,
ctx,
);
let client = zbus::Connection::session().await.unwrap();
let power = PowerClient::new(&client).await.unwrap();
let mut imminent = power.proxy().receive_shutdown_imminent().await.unwrap();
let mut aborted = power.proxy().receive_shutdown_aborted().await.unwrap();
// перегрев → ShutdownImminent(thermal)
temp.set(99);
let sig = imminent.next().await.unwrap();
assert_eq!(sig.args().unwrap().reason(), "thermal");
assert_eq!(power.power_state().await.unwrap(), PowerState::ShuttingDown);
// остыло до PONR → ShutdownAborted + running
temp.set(20);
aborted.next().await.unwrap();
assert_eq!(power.power_state().await.unwrap(), PowerState::Running);
}
#[tokio::test]
#[ignore = "нужна session-шина: just test-integration"]
async fn mcu_failsafe_cuts_on_hang() {
let svc = PowerService::new();
let fsm = svc.fsm_handle();
let thermal_state = svc.thermal_state_handle();
let temp = Arc::new(MockTempSource::new(20));
let copro = Arc::new(MockCoprocessor::new());
let server = zbus::Connection::session().await.unwrap();
server
.object_server()
.at(names::power::PATH, svc)
.await
.unwrap();
server.request_name(names::power::NAME).await.unwrap();
let iface = server
.object_server()
.interface::<_, PowerService>(names::power::PATH)
.await
.unwrap();
let ctx = iface.signal_context().to_owned();
shturman_power::service::spawn_loops(
fsm,
thermal_state,
temp as Arc<dyn shturman_power::thermal::TempSource>,
Arc::new(shturman_power::thermal::NoopThrottler::default())
as Arc<dyn shturman_power::thermal::Throttler>,
copro.clone() as Arc<dyn shturman_power::coprocessor::Coprocessor>,
ctx,
);
let client = zbus::Connection::session().await.unwrap();
let power = PowerClient::new(&client).await.unwrap();
assert_eq!(power.power_state().await.unwrap(), PowerState::Running);
// дать coproc-циклу послать ≥1 heartbeat (иначе last_heartbeat=0 и guard не даст cut)
tokio::time::sleep(std::time::Duration::from_millis(1300)).await;
copro.hang(); // SoC завис → heartbeat не освежает таймер
// ждём, пока coproc-цикл (HEARTBEAT=1с) накопит > FAILSAFE_MISS окон и сделает FailsafeCut
for _ in 0..10 {
tokio::time::sleep(std::time::Duration::from_millis(700)).await;
if power.power_state().await.unwrap() == PowerState::Off {
break;
}
}
assert_eq!(power.power_state().await.unwrap(), PowerState::Off);
}
+4
View File
@@ -22,10 +22,14 @@ pub trait Power1 {
fn uptime(&self) -> zbus::Result<u64>;
#[zbus(property)]
fn power_source(&self) -> zbus::Result<String>;
#[zbus(property)]
fn thermal_state(&self) -> zbus::Result<String>;
#[zbus(signal)]
fn acc_changed(&self, on: bool) -> zbus::Result<()>;
#[zbus(signal)]
fn thermal_changed(&self, state: String, celsius: i32) -> zbus::Result<()>;
#[zbus(signal)]
fn shutdown_imminent(&self, seconds: u32, reason: String) -> zbus::Result<()>;
#[zbus(signal)]
fn shutdown_aborted(&self) -> zbus::Result<()>;
+4 -4
View File
@@ -36,7 +36,7 @@
| A09 | Память (zram + OOM + cgroup-лимиты) | A | MVP (день 1) | v0 | — | ✅ |
| A10 | Логирование (journald volatile + критичное в `/data` + pstore) | A | MVP (день 1) | v0 | — | ✅ |
| A11 | eMMC write-minimization | A | MVP (день 1) | v0 | — | ✅ |
| A12 | Тепловой мониторинг + базовый throttling (SoC) | A | MVP | v0 | hardware | ✅ |
| A12 | Тепловой мониторинг + базовый throttling (SoC) | A | MVP | v0 | hardware | ✅ v0.4 (политика+абстракция; cpufreq/пороги → RK3588) |
| A13 | Тепловой тюнинг (политики под горячий салон) | A | later | v1 | — | ✅ |
| A14 | Hardware watchdog (вооружён в boot-окне) + recovery | A | MVP | v0 | hardware, B | ✅ |
| A15 | systemd-таргеты / оркестрация | A | MVP | v0 | — | ✅ |
@@ -56,9 +56,9 @@
| B05 | Watchdog (runtime + shutdown-фаза + boot-окно) | B | MVP | v0 | hardware, a-base | ✅ |
| B06 | Load-shedding при power-loss | B | MVP | v0 | селективные рейлы (hardware §6) | ✅ |
| B07 | Save last-known-time + периодика | B | MVP | v0 | a-base §7 | ✅ |
| B08 | MCU-копилот shutdown-протокол (если MCU) | B | MVP | v0 | hardware §3 | 🟡 MCU vs supercap-only |
| B09 | MCU аппаратный fail-safe-таймер | B | MVP | v0 | hardware §3 | 🟡 MCU vs supercap-only |
| B10 | Thermal shutdown (триггер + hysteresis + UX) | B | MVP | v0/v1 | a-base §10, hardware §1a | ✅ |
| B08 | MCU-копилот shutdown-протокол (если MCU) | B | MVP | v0 | hardware §3 | v0.4 софт (протокол+кодек+клиент); 🟡 физический MCU vs supercap → HW |
| B09 | MCU аппаратный fail-safe-таймер | B | MVP | v0 | hardware §3 | v0.4 модель (hang/budget→cut); 🟡 реальный таймер-чип → HW |
| B10 | Thermal shutdown (триггер + hysteresis + UX) | B | MVP | v0/v1 | a-base §10, hardware §1a | ✅ v0.4 (триггер+гистерезис; UX-рендер → v0.5) |
| B11 | MCU прошивка: update path | B | later | v1 | hardware | 🟡 MCU vs supercap-only |
| B12 | Sleep/wake + battery-cutoff | B | later | v1/v2 | — | ✅ |
| B13 | Гейт wake-word по состояниям (с D) | B | MVP | v1 | D | ✅ |
+5
View File
@@ -54,6 +54,11 @@ Panfrost GPU, мощности хватает на плавный UI и **лок
Рекомендую MCU-копилот: он же закрывает watchdog и пробуждение. (Прошивка МК — домен B.)
> **Статус (v0.4):** SoC-сторона MCU-протокола (B08), кодек линка и **модель** fail-safe-таймера (B09) реализованы
> в софте (`shturman-power`, симметрия с FSM v0.3) — спека `docs/specs/v0.4-mcu-thermal.md`. **Физический выбор
> B08/B09** (MCU-копилот vs supercap-only), реальный МК-чип/прошивка/независимый таймер и hold-up sizing — остаются
> 🟡 и закрываются в **HW-bring-up-подфазе** (нужна плата RK3588; перф/тепловой вердикт — там же).
**Бюджет hold-up (числовой контракт; sizing — здесь, sequencing — домен B):** энергия =
worst-case ток (SoC + контроллер хранилища при флаше/unmount) × hold-time (верхняя оценка
flush+durable-write+unmount с запасом) × **дератинг** (низкая T −40 °C: ёмкость/ESR supercap
+4 -2
View File
@@ -58,8 +58,10 @@
### `ru.shturman.Power` — питание и жизненный цикл (домен B)
- **Методы:** `GetPowerState() → state` (enum `off`/`accessory`/`running`/`shutting_down`/`sleep`/`battery_cutoff`), `RequestSleep()` (внутр.).
- **Сигналы:** `AccChanged(on)`, `ShutdownImminent(seconds, reason)` (`reason ∈ acc_off|under_voltage|thermal|battery_cutoff`), **`ShutdownAborted()`** (re-power до PONR), `Sleep()`, `Wake()`.
- **Properties:** `IgnitionState` (off/accessory/running — **канон**; E зеркалит, не дублирует), `Uptime` (монотонные часы), `PowerSource` (`vehicle_12v`/`holdup_cap`/`sleep_rail`/`low_battery`).
- **Сигналы:** `AccChanged(on)`, `ShutdownImminent(seconds, reason)` (`reason ∈ acc_off|under_voltage|thermal|battery_cutoff`), **`ShutdownAborted()`** (re-power/остывание до PONR), `ThermalChanged(state, celsius)`, `Sleep()`, `Wake()`.
- **Properties:** `IgnitionState` (off/accessory/running — **канон**; E зеркалит, не дублирует), `Uptime` (монотонные часы), `PowerSource` (`vehicle_12v`/`holdup_cap`/`sleep_rail`/`low_battery`), `ThermalState` (`normal`/`warn`/`throttle`/`critical`).
- **Реализация (v0.3):** состояние/сигналы **оживлены из FSM** (`PowerFsm` в `shturman-power`, не mock); `ShutdownImminent`/`ShutdownAborted` — из реальных переходов (abort до PONR + grace-таймер). `Sleep`/`Wake`/`RequestSleep` объявлены, но **зарезервированы** (v1/v2, B §7).
- **Реализация (v0.4):** `ThermalState`/`ThermalChanged` — из `ThermalPolicy` (банды + гистерезис), `ShutdownImminent(thermal)` реально эмитится; SoC↔MCU протокол + кодек + клиент (B08) + fail-safe-**модель** (B09). Источник событий — dev-mock (`SetTemp`/`HangSoc`); реальный MCU/sysfs/cpufreq + рендер «перегрев» в Shell → HW/v0.5.
### `ru.shturman.Settings` — конфигурация и состояние
- **Методы:** `Get(key) → value`, `Set(key, value)`, `List(prefix) → [key]`, `Reset(key)`.
+4
View File
@@ -72,6 +72,10 @@
- **Stage 1 (~35 c):** ядро-минимум (шина + Power + Settings + Perm-Broker + App-Host) → **Shell с первым кадром**;
- **Stage 2 (фоном):** Vehicle-Data, Assistant, Media, Nav прогреваются после интерактива.
- Быстрый boot: минимальный initramfs, параллельный systemd, ленивые сервисы.
- **Dev-VM (v0.2 реализовано):** Stage 0/1/2 = фазовые systemd-таргеты под зонтиком `shturman.target`; splash —
`shturman-splash` (Slint software-render → `/run/shturman/splash.png`, `Before=shell` → до первого кадра); Stage 2 —
warmup-плейсхолдер (`After=shell`, деферред). U-Boot framebuffer-splash + A/B + secure-boot + ранний путь камеры —
HW (VM↔HW-граница, как overlay/A-B в v0.6). Тайминг <10 c — функц. в VM, вердикт на RK3588.
- **Secure boot (verified boot, v4):** анкор доверия — **хэш публичного ключа в OTP/eFuse,
прожиг НЕОБРАТИМ** (burn-once, без ротации). Приватный ключ — offline/HSM с
бэкапом (потеря = кирпич парка). **Dev-ключи ≠ prod**, на dev-платах eFuse НЕ жжём
+16 -2
View File
@@ -7,6 +7,19 @@
Статус: **v2 (на ревью).** v2 — после adversarial-ревью (22 находки).
Связано с: [architecture.md](../architecture.md) (§6) · [hardware.md](../contracts/hardware.md) (§3 питание/MCU) · [a-base-system.md](a-base-system.md) (§5–§11) · [ipc.md](../contracts/ipc.md) (`Power`) · [principles.md](../principles.md) (#5) · домены D (гейт wake-word), E (engine-state)
**Реализация (v0.3):** срезы **B01B07** реализованы — чистый `PowerFsm` (§2: `off↔accessory↔running→shutting-down
{abortable→committed}→off`, abort до PONR) + сервис `ru.shturman.Power` оживлён из FSM (grace-таймер + durable-barrier
`sync` на commit), watchdog/save-time-конфиг. **VM-модель:** abort/PONR в Lima = stop+umount+remount, power-cut =
SIGKILL+`fsck`. sleep/wake/battery-cutoff — каркас (no-op), тело → v1/v2 (§7). Спека: `docs/specs/v0.3-power-safe.md`.
**Реализация (v0.4):** срезы **A12/B08/B09/B10** — софт/модель. Тепло (§4/§1a): чистая `ThermalPolicy` (банды +
гистерезис) → `Event::ThermalTrip` (реюз FSM) + abort `ThermalCleared`; `TempSource`/`Throttler` абстракции (VM mock/noop,
реальный sysfs/cpufreq + числовые пороги → RK3588). MCU (§5/§6): протокол `SocToMcu`/`McuToSoc` + кодек (CRC16/replay/
desync-guard) + `CoprocessorClient` (heartbeat/wait-for-completion/`safe-to-cut`); **B09 fail-safe-таймер — модель**
(`MockCoprocessor`: hang/budget → `Event::FailsafeCut` → off). `ru.shturman.Power` += `ThermalState`/`ThermalChanged`
(рендер «перегрев» → v0.5). **Физический выбор B08/B09** (MCU vs supercap-only), реальный UART/MCU-чип/fail-safe-таймер,
supercap-only-путь → **HW-bring-up-подфаза**. Спека: `docs/specs/v0.4-mcu-thermal.md`.
---
## 1. Назначение и границы
@@ -193,8 +206,9 @@ power-эффектом; ни одно SoC-сообщение не должно (
## 12. Открытые вопросы
- 🟡 **MCU-копилот vs supercap-only** (hardware §3) — определяет владельца ACC/watchdog, **наличие
независимого бэкстопа и fail-safe-снятия при зависшем SoC**, и доступность scheduled-wake.
- 🟡 **MCU-копилот vs supercap-only** (hardware §3, **B08/B09**) — определяет владельца ACC/watchdog, **наличие
независимого бэкстопа и fail-safe-снятия при зависшем SoC**, и доступность scheduled-wake. Протокол/кодек/клиент +
fail-safe-**модель** реализованы в **v0.4** (софт); **физический выбор + железо → HW-bring-up-подфаза** (нужна плата).
- ◻️ **Протокол SoC↔MCU** (UART/I2C/GPIO, формат, keepalive, политика тишины-линка, replay-защита) —
shutdown-подмножество уже специфицировано в §4/§5, остальное здесь.
- ◻️ **Бюджет разряда АКБ** (sleep, ACC-off listening, battery-cutoff порог) — числа с hardware.
+524
View File
@@ -0,0 +1,524 @@
# План 6 — v0.2 Boot-конвейер (Stage 0/1/2 + splash)
> REQUIRED SUB-SKILL: `executing-plans` (или `subagent-driven-development`) + **TDD**. Спека: `docs/specs/v0.2-boot-pipeline.md`.
> Шаги — чекбоксы `- [ ]`. Часть «verify в Lima» (P6.5) — тяжёлая (vm-reset + e2e), но VM уже поднята.
**Goal:** превратить плоский `shturman.target` (v0.1) в фазовый конвейер: Stage 0 (splash) → Stage 1 (ядро+кадр) →
Stage 2 (warmup), с мгновенным splash до первого кадра и деферредом фона после.
**Architecture:** общий headless-render хелпер (Slint software-renderer → PNG) выделяется из `shturman-shell` в
крейт `shturman-render`; новый `shturman-splash` его использует. systemd: зонтик `shturman.target` тянет три
под-таргета; splash `Before=shell`, warmup `After=shell`. Новой D-Bus-поверхности нет.
**Tech Stack:** Rust, Slint 1.16 (software-renderer + `png`), systemd (targets/oneshot), Lima VM, bash E2E.
---
## File Structure
- **Create** `crates/apps/shturman-render/``Cargo.toml`, `src/lib.rs` (хелпер `render_to_png`), `tests/render.rs`.
- **Create** `crates/apps/shturman-splash/``Cargo.toml`, `src/lib.rs` (Slint splash + `render_splash`), `src/main.rs` (CLI), `tests/screenshot.rs`.
- **Modify** `crates/apps/shturman-shell/src/lib.rs` — использовать `shturman_render::render_to_png` (убрать своё плумбинг-дублирование), `Cargo.toml` (+ dep `shturman-render`).
- **Modify** `Cargo.toml` (workspace) — добавить два крейта в `members`.
- **Create** `systemd/`: `shturman-stage0.target`, `shturman-stage1.target`, `shturman-stage2.target`, `shturman-splash.service`, `shturman-stage2-warmup.service`, `tmpfiles-shturman.conf`.
- **Modify** `systemd/shturman.target` (→ зонтик), `shturman-{firstboot,machineid,power,settings,shell}.service` (`WantedBy=``shturman-stage1.target`), `shturman-shell.service` (убрать `RuntimeDirectory`, `/run/shturman` даёт tmpfiles).
- **Modify** `justfile` (+ `splash-frame`), `lima/shturman.yaml` (разложить новые юниты + tmpfiles), `tests/e2e/run.sh` (+ блок Stage 0/1/2 + install splash-бинаря).
---
## P6.1: крейт `shturman-render` — общий headless-рендер (рефактор из shell)
**Files:** Create `crates/apps/shturman-render/{Cargo.toml,src/lib.rs,tests/render.rs}`; Modify workspace `Cargo.toml`, `crates/apps/shturman-shell/{Cargo.toml,src/lib.rs}`.
- [ ] **Шаг 1 — падающий тест** `crates/apps/shturman-render/tests/render.rs`:
```rust
use shturman_render::render_to_png;
slint::slint! {
export component Probe inherits Window {
width: 64px; height: 48px; background: #101418;
Rectangle { background: #ffffff; width: 20px; height: 20px; }
}
}
#[test]
fn renders_component_to_nonempty_png() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("probe.png");
render_to_png(|| Ok(Probe::new()?), 64, 48, &path).expect("render");
let dec = png::Decoder::new(std::fs::File::open(&path).unwrap());
let mut r = dec.read_info().unwrap();
assert_eq!((r.info().width, r.info().height), (64, 48));
let mut buf = vec![0u8; r.output_buffer_size()];
let info = r.next_frame(&mut buf).unwrap();
let px = &buf[..info.buffer_size()];
assert!(px.iter().any(|&b| b != px[0]), "кадр одноцветный");
}
```
- [ ] **Шаг 2 — `Cargo.toml` крейта** (`crates/apps/shturman-render/Cargo.toml`):
```toml
[package]
name = "shturman-render"
version = "0.0.0"
edition.workspace = true
license.workspace = true
[dependencies]
anyhow.workspace = true
slint.workspace = true
png = "0.17"
[dev-dependencies]
tempfile.workspace = true
```
Добавить `"crates/apps/shturman-render"` в `members` корневого `Cargo.toml`.
- [ ] **Шаг 3 — прогнать тест, убедиться что НЕ компилируется/падает.** Run: `cargo test -p shturman-render`. Expected: FAIL (нет `render_to_png`).
- [ ] **Шаг 4 — реализация** `crates/apps/shturman-render/src/lib.rs` (вынести из текущего `shturman-shell/src/lib.rs`):
```rust
//! Headless software-render Slint-компонента в PNG (без дисплея/композитора).
//! Общий для shturman-shell (первый кадр) и shturman-splash (Stage 0). Спека v0.2 §4.1.
use slint::platform::software_renderer::{MinimalSoftwareWindow, RepaintBufferType};
use slint::platform::{Platform, PlatformError, WindowAdapter};
use slint::ComponentHandle;
use std::path::Path;
use std::rc::Rc;
use std::sync::Once;
thread_local! {
static WINDOW: Rc<MinimalSoftwareWindow> =
MinimalSoftwareWindow::new(RepaintBufferType::ReusedBuffer);
}
struct SwPlatform;
impl Platform for SwPlatform {
fn create_window_adapter(&self) -> Result<Rc<dyn WindowAdapter>, PlatformError> {
Ok(WINDOW.with(|w| w.clone()))
}
}
fn ensure_platform() {
static ONCE: Once = Once::new();
ONCE.call_once(|| {
let _ = slint::platform::set_platform(Box::new(SwPlatform));
});
}
/// Построить компонент (ВНУТРИ — после `set_platform`) и отрендерить его кадр в PNG.
/// `build` зовётся после установки software-platform (порядок обязателен для Slint).
pub fn render_to_png<C: ComponentHandle>(
build: impl FnOnce() -> anyhow::Result<C>,
w: u32,
h: u32,
path: &Path,
) -> anyhow::Result<()> {
ensure_platform();
let ui = build()?;
let window = WINDOW.with(|x| x.clone());
window.set_size(slint::PhysicalSize::new(w, h));
ui.show()?;
ui.window().request_redraw();
let mut buf = vec![slint::Rgb8Pixel { r: 0, g: 0, b: 0 }; (w * h) as usize];
let drawn = window.draw_if_needed(|r| {
r.render(buf.as_mut_slice(), w as usize);
});
ui.hide()?;
if !drawn {
anyhow::bail!("software-renderer не отрисовал кадр");
}
write_png(path, w, h, &buf)
}
fn write_png(path: &Path, w: u32, h: u32, buf: &[slint::Rgb8Pixel]) -> anyhow::Result<()> {
let mut enc = png::Encoder::new(std::io::BufWriter::new(std::fs::File::create(path)?), w, h);
enc.set_color(png::ColorType::Rgb);
enc.set_depth(png::BitDepth::Eight);
let mut writer = enc.write_header()?;
let mut data = Vec::with_capacity((w * h * 3) as usize);
for px in buf {
data.extend_from_slice(&[px.r, px.g, px.b]);
}
writer.write_image_data(&data)?;
Ok(())
}
```
- [ ] **Шаг 5 — прогнать тест: PASS.** Run: `cargo test -p shturman-render`. Expected: PASS.
- [ ] **Шаг 6 — рефактор shell на хелпер.** В `crates/apps/shturman-shell/Cargo.toml` добавить `shturman-render = { path = "../shturman-render" }`. В `crates/apps/shturman-shell/src/lib.rs`: удалить локальные `SCREENSHOT_WINDOW`/`ScreenshotPlatform`/`ensure_screenshot_platform`/`write_png`; `render_screenshot` стал:
```rust
pub fn render_screenshot(initial: &Initial, hour: u8, path: &Path) -> anyhow::Result<()> {
let (_, clock) = utc_hh_mm();
shturman_render::render_to_png(|| build_ui(initial, hour, &clock), FRAME_W, FRAME_H, path)?;
tracing::info!(path = %path.display(), "кадр записан (software-render)");
Ok(())
}
```
(Импорты `slint::platform::*`, `std::rc::Rc`, `std::sync::Once` в shell больше не нужны — убрать.)
- [ ] **Шаг 7 — прогнать тесты shell + воркспейс.** Run: `cargo test -p shturman-shell && cargo test --workspace`. Expected: PASS (screenshot-тест shell зелёный на новом хелпере).
- [ ] **Шаг 8 — commit.**
```bash
git add crates/apps/shturman-render crates/apps/shturman-shell Cargo.toml Cargo.lock
git commit -s -m "refactor(v0.2): вынести headless render в shturman-render (shell использует)"
```
---
## P6.2: `shturman-splash` — Stage-0 splash-бинарь
**Files:** Create `crates/apps/shturman-splash/{Cargo.toml,src/lib.rs,src/main.rs,tests/screenshot.rs}`; Modify workspace `Cargo.toml`.
- [ ] **Шаг 1 — падающий тест** `crates/apps/shturman-splash/tests/screenshot.rs`:
```rust
use shturman_splash::render_splash;
#[test]
fn renders_dark_branded_splash() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("splash.png");
render_splash(&path).expect("render_splash");
let dec = png::Decoder::new(std::fs::File::open(&path).unwrap());
let mut r = dec.read_info().unwrap();
assert_eq!((r.info().width, r.info().height), (1024, 600));
let mut buf = vec![0u8; r.output_buffer_size()];
let info = r.next_frame(&mut buf).unwrap();
let px = &buf[..info.buffer_size()];
// фон тёмный (угол) + не одноцветный (wordmark отрисован)
assert!(px[0] < 64 && px[1] < 64 && px[2] < 64, "splash фон не тёмный: {},{},{}", px[0], px[1], px[2]);
assert!(px.iter().any(|&b| b != px[0]), "splash одноцветный — wordmark не отрисован");
}
```
- [ ] **Шаг 2 — `Cargo.toml`** (`crates/apps/shturman-splash/Cargo.toml`) + добавить в workspace `members`:
```toml
[package]
name = "shturman-splash"
version = "0.0.0"
edition.workspace = true
license.workspace = true
[dependencies]
shturman-render = { path = "../shturman-render" }
shturman-common = { path = "../../shturman-common" }
anyhow.workspace = true
tracing.workspace = true
slint.workspace = true
[dev-dependencies]
tempfile.workspace = true
png = "0.17"
```
- [ ] **Шаг 3 — тест падает.** Run: `cargo test -p shturman-splash`. Expected: FAIL (нет `render_splash`).
- [ ] **Шаг 4 — реализация** `crates/apps/shturman-splash/src/lib.rs`:
```rust
//! `shturman-splash` (lib) — Stage-0 splash-кадр (A05). Статичный брендовый кадр (без шины → «мгновенно»).
//! Визуальные токены — каркас (язык design-system — гейт v0.5). Спека v0.2 §6.
use std::path::Path;
slint::slint! {
export component SplashWindow inherits Window {
in property <string> brand: "Штурман";
width: 1024px;
height: 600px;
background: #0e1014;
Text {
text: root.brand;
font-size: 64px;
color: #e8eaed;
horizontal-alignment: center;
vertical-alignment: center;
}
}
}
const W: u32 = 1024;
const H: u32 = 600;
/// Headless software-render splash-кадра в PNG (без дисплея/композитора).
pub fn render_splash(path: &Path) -> anyhow::Result<()> {
shturman_render::render_to_png(|| Ok(SplashWindow::new()?), W, H, path)?;
tracing::info!(path = %path.display(), "splash записан (software-render)");
Ok(())
}
```
- [ ] **Шаг 5 — `main.rs`** `crates/apps/shturman-splash/src/main.rs`:
```rust
//! `shturman-splash` (bin) — Stage-0 splash. `--screenshot <path>` → headless PNG (VM/E2E);
//! без аргументов — интерактив (dev/HW; в v0.6 VM используется только screenshot-режим).
use shturman_splash::render_splash;
use std::path::PathBuf;
fn main() -> anyhow::Result<()> {
shturman_common::init_tracing("shturman-splash");
match parse_screenshot_arg() {
Some(path) => {
render_splash(&path)?;
println!("{}", path.display());
}
None => {
// интерактив придёт с живым дисплеем (v0.5); в v0 VM splash — только screenshot.
anyhow::bail!("ожидался --screenshot <path> (интерактивный splash — v0.5)");
}
}
Ok(())
}
fn parse_screenshot_arg() -> Option<PathBuf> {
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
if a == "--screenshot" {
return args.next().map(PathBuf::from);
}
if let Some(p) = a.strip_prefix("--screenshot=") {
return Some(PathBuf::from(p));
}
}
None
}
```
- [ ] **Шаг 6 — тест PASS + lint.** Run: `cargo test -p shturman-splash && cargo clippy -p shturman-splash --all-targets -- -D warnings`. Expected: PASS, без warnings.
- [ ] **Шаг 7 — глазами (опц.).** Run: `cargo run -p shturman-splash -- --screenshot target/splash.png` → открыть PNG (тёмный фон + «Штурман»).
- [ ] **Шаг 8 — commit.**
```bash
git add crates/apps/shturman-splash Cargo.toml Cargo.lock
git commit -s -m "feat(v0.2): shturman-splash — Stage-0 splash (software-render → PNG)"
```
---
## P6.3: systemd — фазовые таргеты + splash/warmup + рефактор (зонтик)
**Files:** Create 6 файлов в `systemd/`; Modify `shturman.target` + 5 сервисов.
- [ ] **Шаг 1 — `systemd/tmpfiles-shturman.conf`** (создаёт `/run/shturman` на boot — общий для splash/shell/warmup; tmpfs/volatile, A11):
```
# /run/shturman — volatile-каталог кадров/маркеров (splash.png, frame.png, stage2.ready). A11.
d /run/shturman 0755 root root -
```
- [ ] **Шаг 2 — `systemd/shturman-stage0.target`:**
```ini
[Unit]
Description=Штурман Stage 0 — splash (мгновенно)
Wants=shturman-splash.service
```
- [ ] **Шаг 3 — `systemd/shturman-stage1.target`** (нынешний critical set v0.1):
```ini
[Unit]
Description=Штурман Stage 1 — ядро + первый кадр
Requires=data.mount
After=data.mount
Wants=shturman-firstboot.service shturman-machineid.service shturman-power.service shturman-settings.service shturman-shell.service
```
- [ ] **Шаг 4 — `systemd/shturman-stage2.target`:**
```ini
[Unit]
Description=Штурман Stage 2 — фон (после интерактива)
After=shturman-stage1.target
Wants=shturman-stage2-warmup.service
```
- [ ] **Шаг 5 — `systemd/shturman-splash.service`** (Stage 0; минимум зависимостей; до первого кадра):
```ini
[Unit]
Description=Штурман splash (Stage 0, software-render → PNG)
# «Мгновенно»: без Requires=data.mount/dbus — стартует рано, параллельно critical set.
# Before=shell гарантирует splash.png раньше frame.png. /run/shturman даёт tmpfiles.
After=systemd-tmpfiles-setup.service
Before=shturman-shell.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/bin/shturman-splash --screenshot /run/shturman/splash.png
TimeoutStartSec=15
[Install]
WantedBy=shturman-stage0.target
```
- [ ] **Шаг 6 — `systemd/shturman-stage2-warmup.service`** (плейсхолдер фона; после кадра):
```ini
[Unit]
Description=Штурман Stage 2 warmup (плейсхолдер фона)
After=shturman-shell.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/bin/sh -c 'echo "stage2 warmup" | systemd-cat -t shturman-stage2; : > /run/shturman/stage2.ready'
[Install]
WantedBy=shturman-stage2.target
```
- [ ] **Шаг 7 — рефактор `systemd/shturman.target` → зонтик:**
```ini
[Unit]
Description=Штурман — v0 boot-конвейер (зонтик фаз Stage 0/1/2)
Requires=data.mount
After=data.mount
Wants=shturman-stage0.target shturman-stage1.target shturman-stage2.target
[Install]
WantedBy=multi-user.target
```
- [ ] **Шаг 8 — переключить членство сервисов на Stage 1.** В `systemd/shturman-firstboot.service`, `shturman-machineid.service`, `shturman-power.service`, `shturman-settings.service`, `shturman-shell.service` заменить `WantedBy=shturman.target``WantedBy=shturman-stage1.target`. (Ordering внутри Stage 1 — без изменений: `After=`/`Requires=` в самих юнитах.)
- [ ] **Шаг 9 — у `shturman-shell.service` убрать `RuntimeDirectory=shturman`** (каталог теперь от tmpfiles; иначе рестарт shell снёс бы splash.png). Строку `RuntimeDirectory=shturman` удалить.
- [ ] **Шаг 10 — commit** (конфиги; проверка — в P6.5/Lima).
```bash
git add systemd/
git commit -s -m "feat(v0.2): фазовые systemd-таргеты Stage 0/1/2 + splash/warmup + зонтик"
```
---
## P6.4: justfile + lima provisioning + E2E-блок Stage 0/1/2
**Files:** Modify `justfile`, `lima/shturman.yaml`, `tests/e2e/run.sh`.
- [ ] **Шаг 1 — `justfile`: цель `splash-frame`** (после `shell-frame`):
```just
# инспекция splash-кадра: headless software-render → PNG
splash-frame path="target/splash-frame.png":
cargo run -q -p shturman-splash -- --screenshot {{path}}
@echo "splash записан: {{path}}"
```
- [ ] **Шаг 2 — `lima/shturman.yaml`: разложить новые юниты.** В provision-скрипте, рядом с установкой `systemd/`-юнитов, добавить tmpfiles (новые `.target`/`.service` ставятся тем же `install -m644 /shturman/systemd/shturman-*.service` + явно таргеты):
```bash
install -m644 /shturman/systemd/shturman-stage0.target /etc/systemd/system/
install -m644 /shturman/systemd/shturman-stage1.target /etc/systemd/system/
install -m644 /shturman/systemd/shturman-stage2.target /etc/systemd/system/
install -d /etc/tmpfiles.d
install -m644 /shturman/systemd/tmpfiles-shturman.conf /etc/tmpfiles.d/shturman.conf
systemd-tmpfiles --create /etc/tmpfiles.d/shturman.conf || true
```
(Существующий `install -m644 /shturman/systemd/shturman-*.service` уже подхватит `shturman-splash.service`/`shturman-stage2-warmup.service`.)
- [ ] **Шаг 3 — `tests/e2e/run.sh`: установить splash-бинарь.** В цикле install (`for b in firstboot settings power shell`) добавить `splash`:
```bash
for b in firstboot settings power shell splash; do
sudo install -m755 "$CARGO_TARGET_DIR/release/shturman-$b" /usr/local/bin/ || fail "install shturman-$b"
done
```
- [ ] **Шаг 4 — `tests/e2e/run.sh`: разложить новые юниты + tmpfiles.** ⚠️ `run.sh` ставит сервисы **явным
списком** (НЕ glob — в отличие от lima yaml), поэтому splash/warmup добавляем явно. В блоке раскладки юнитов:
(а) к строке `install … shturman.target … data.mount …` добавить три таргета; (б) к строке install сервисов
добавить splash+warmup; (в) добавить tmpfiles. Итог блока:
```bash
sudo install -m644 systemd/shturman.target systemd/data.mount \
systemd/shturman-stage0.target systemd/shturman-stage1.target systemd/shturman-stage2.target \
/etc/systemd/system/
sudo install -m644 systemd/shturman-firstboot.service systemd/shturman-machineid.service \
systemd/shturman-power.service systemd/shturman-settings.service \
systemd/shturman-shell.service systemd/shturman-splash.service \
systemd/shturman-stage2-warmup.service /etc/systemd/system/
sudo install -d /etc/tmpfiles.d
sudo install -m644 systemd/tmpfiles-shturman.conf /etc/tmpfiles.d/shturman.conf
sudo systemd-tmpfiles --create /etc/tmpfiles.d/shturman.conf || true
```
- [ ] **Шаг 5 — `tests/e2e/run.sh`: блок «Stage 0/1/2»** (вставить после §7 «первый кадр», до §8):
```bash
# ---- Stage 0/1/2 разделены (v0.2) ----
info "Stage 0/1/2: фазы разделены + splash до кадра + warmup после"
for t in shturman-stage0 shturman-stage1 shturman-stage2; do
systemctl is-active --quiet "$t.target" || fail "$t.target не достигнут ($(systemctl is-active "$t.target" 2>&1))"
pass "$t.target reached"
done
sudo test -f /run/shturman/splash.png || fail "нет splash.png (Stage 0)"
sudo head -c8 /run/shturman/splash.png | od -An -tx1 | tr -d ' \n' | grep -qi 89504e47 || fail "splash.png не PNG"
sp=$(sudo stat -c %Y /run/shturman/splash.png); fr=$(sudo stat -c %Y /run/shturman/frame.png)
[ "$sp" -le "$fr" ] || fail "splash.png позже frame.png ($sp > $fr)"
sudo test -f /run/shturman/stage2.ready || fail "нет stage2.ready (warmup не отработал)"
w2=$(sudo stat -c %Y /run/shturman/stage2.ready)
[ "$w2" -ge "$fr" ] || fail "stage2.ready ($w2) раньше кадра ($fr)"
pass "порядок фаз: splash($sp) ≤ frame($fr) ≤ stage2($w2)"
# boot-тайминг (функц., НЕ гейт; вердикт — RK3588)
echo " $(systemd-analyze time 2>/dev/null | head -1 || echo 'systemd-analyze н/д')"
```
- [ ] **Шаг 6 — commit.**
```bash
git add justfile lima/shturman.yaml tests/e2e/run.sh
git commit -s -m "feat(v0.2): splash-frame + lima/E2E раскладка Stage 0/1/2"
```
---
## P6.5: verify в Lima + acceptance + синхронизация доков
- [ ] **Шаг 1 — host-гейт.** Run: `just ci`. Expected: exit 0 (все unit-тесты, включая `shturman-render`/`shturman-splash`; clippy; deny).
- [ ] **Шаг 2 — чистый E2E с нуля.** Run: `just vm-reset && just e2e`. Expected: exit 0; в выводе — `shturman-stage0/1/2.target reached`, `splash($sp) ≤ frame($fr) ≤ stage2($w2)`, **вся приёмка v0.1/v0.6 зелёная** (нет регресса), `E2E OK ✅`.
- [ ] **Шаг 3 — если падёт — итерировать** по реальным ошибкам (ordering таргетов, glob юнитов, tmpfiles, splash before shell) и повторить P6.5 шаг 2. (Систематически: один симптом → одна правка.)
- [ ] **Шаг 4 — синхронизация доков (швы спеки §10):** в `docs/domains/a-base-system.md` §4 / `docs/architecture.md` §6 — пометка «dev-VM Stage-0-splash = systemd software-render PNG; U-Boot framebuffer — HW»; `docs/roadmap.md` §v0.2 → ✅; `docs/specs/v0.1-v0.6-foundation.md` §13 — шов «shturman.target → зонтик, critical set → stage1.target»; `CLAUDE.md` — статус v0.2 готово → следующее v0.3/v0.5.
- [ ] **Шаг 5 — commit доков.**
```bash
git add docs/ CLAUDE.md
git commit -s -m "docs(v0.2): синхронизация швов boot-конвейера + статус"
```
- [ ] **Шаг 6 — finishing-a-development-branch** (merge/PR — спросить пользователя; в `main` без явного «ок» не мержить).
---
## Acceptance (спека v0.2 §9.3)
- [ ] `shturman.target` = зонтик; `shturman-stage0/1/2.target` достижимы и разделены (per-target active).
- [ ] Splash-кадр (`splash.png`) непустой и рендерится **раньше** первого кадра Shell (`frame.png`).
- [ ] Stage 2 (warmup) стартует **после** первого кадра (`stage2.ready` mtime ≥ frame).
- [ ] Boot-тайминг логируется (`systemd-analyze`); <10 c — пометка «вердикт на RK3588».
- [ ] Вся приёмка v0.1/v0.6 (foundation §9.4) — зелёная на фазовой раскладке (нет регресса).
- [ ] `just ci` зелёный; красные линии целы (нет CAN/actuator).
+643
View File
@@ -0,0 +1,643 @@
# План 7 — v0.3 Power-safe ядро (FSM + graceful shutdown)
> REQUIRED SUB-SKILL: `executing-plans` (или `subagent-driven-development`) + **TDD**. Спека: `docs/specs/v0.3-power-safe.md`.
> Шаги — чекбоксы `- [ ]`. P7.4/P7.5 — тяжёлые (Lima); VM уже поднята.
**Goal:** стаб Power → реальный lifecycle-FSM: ACC → graceful shutdown с durable-write до PONR → переживание срыва питания.
**Architecture:** чистый `PowerFsm` (состояния/события/действия, юнит-тестируемый) в `shturman-power`; сервис оборачивает его
(D-Bus state/signals из FSM, dev-mock кормит события, grace-таймер на монотонике, durable-barrier `sync` на commit).
Teardown/unmount — через systemd (реальный poweroff) / харнесс (in-VM-цикл). Подход A спеки §6.
**Tech Stack:** Rust, zbus 4 (signals/properties), tokio (grace-таймер), systemd (watchdog/savetime), Lima E2E (bash).
---
## File Structure
- **Create** `crates/core/shturman-power/src/fsm.rs``PowerFsm` (State/Event/Action/step) + проекции в `PowerState`/`IgnitionState`/`PowerSource`.
- **Modify** `crates/core/shturman-power/src/lib.rs``pub mod fsm;`.
- **Modify** `crates/core/shturman-power/src/service.rs` — обернуть FSM: D-Bus из FSM; dev-mock кормит события; `apply_event` (grace-таймер + durable-barrier).
- **Create** `systemd/watchdog-shturman.conf``RuntimeWatchdogSec`/`RebootWatchdogSec` (system.conf.d).
- **Create** `systemd/shturman-savetime.service` + `systemd/shturman-savetime.timer` (B07 periodic save).
- **Modify** `lima/shturman.yaml` (разложить watchdog/savetime), `tests/e2e/run.sh` (блок power-safe).
- **Modify (P7.5)** `docs/domains/b-power-lifecycle.md`, `docs/contracts/ipc.md`, `docs/specs/v0.1-v0.6-foundation.md` §5.2, `CLAUDE.md`.
---
## P7.1: `PowerFsm` — чистый FSM питания (B03)
**Files:** Create `crates/core/shturman-power/src/fsm.rs`; Modify `lib.rs`.
- [ ] **Шаг 1 — реализация** `crates/core/shturman-power/src/fsm.rs` (тесты — в этом же файле, шаг 2):
```rust
//! Чистый FSM питания (B03, спека v0.3 §5). Без D-Bus/async/I/O — сервис исполняет `Action`.
use shturman_ipc::types::{IgnitionState, PowerSource, PowerState, ShutdownReason};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Phase {
Abortable,
Committed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum State {
Off,
Accessory,
Running,
ShuttingDown { phase: Phase, reason: ShutdownReason },
Sleep, // зарезервировано (полные sleep/wake — v1/v2)
BatteryCutoff, // зарезервировано (long-park — v1/v2)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Event {
AccOn,
AccOff,
EngineOn,
EngineOff,
UnderVoltage,
ThermalTrip,
GraceExpired,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Action {
ShutdownImminent(ShutdownReason),
ShutdownAborted,
AccChanged(bool),
StartGrace, // сервис запускает grace-таймер (длительность — конфиг сервиса)
Commit, // durable-barrier (sync) → PONR
}
/// FSM питания. v0: старт в `Running` (как стаб v0.1). Чистый: `step` без I/O.
pub struct PowerFsm {
state: State,
}
impl Default for PowerFsm {
fn default() -> Self {
Self { state: State::Running }
}
}
impl PowerFsm {
pub fn new() -> Self {
Self::default()
}
pub fn state(&self) -> State {
self.state
}
pub fn power_state(&self) -> PowerState {
match self.state {
State::Off => PowerState::Off,
State::Accessory => PowerState::Accessory,
State::Running => PowerState::Running,
State::ShuttingDown { .. } => PowerState::ShuttingDown,
State::Sleep => PowerState::Sleep,
State::BatteryCutoff => PowerState::BatteryCutoff,
}
}
pub fn ignition(&self) -> IgnitionState {
match self.state {
State::Running => IgnitionState::Running,
State::Accessory => IgnitionState::Accessory,
_ => IgnitionState::Off,
}
}
pub fn source(&self) -> PowerSource {
match self.state {
State::ShuttingDown { reason: ShutdownReason::UnderVoltage, .. } => PowerSource::LowBattery,
State::ShuttingDown { .. } => PowerSource::HoldupCap,
_ => PowerSource::Vehicle12v,
}
}
/// Шаг FSM. Возвращает действия для исполнения сервисом (спека §5).
pub fn step(&mut self, ev: Event) -> Vec<Action> {
use Event as E;
use Phase::*;
use State::*;
match (self.state, ev) {
(Off, E::AccOn) => {
self.state = Accessory;
vec![Action::AccChanged(true)]
}
(Accessory, E::EngineOn) => {
self.state = Running;
vec![]
}
(Running, E::EngineOff) => {
self.state = Accessory;
vec![]
}
(Accessory | Running, E::AccOff) => self.begin_shutdown(ShutdownReason::AccOff),
(Accessory | Running, E::UnderVoltage) => self.begin_shutdown(ShutdownReason::UnderVoltage),
(Accessory | Running, E::ThermalTrip) => self.begin_shutdown(ShutdownReason::Thermal),
(ShuttingDown { phase: Abortable, .. }, E::AccOn) => {
self.state = Running;
vec![Action::ShutdownAborted, Action::AccChanged(true)]
}
(ShuttingDown { phase: Abortable, reason }, E::GraceExpired) => {
self.state = ShuttingDown { phase: Committed, reason };
vec![Action::Commit]
}
// committed/off/sleep/battery_cutoff + всё прочее — no-op (инвариант: committed не abort-ится)
_ => vec![],
}
}
fn begin_shutdown(&mut self, reason: ShutdownReason) -> Vec<Action> {
self.state = State::ShuttingDown { phase: Phase::Abortable, reason };
vec![Action::ShutdownImminent(reason), Action::StartGrace]
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn off_acc_on_to_accessory() {
let mut f = PowerFsm { state: State::Off };
assert_eq!(f.step(Event::AccOn), vec![Action::AccChanged(true)]);
assert_eq!(f.state(), State::Accessory);
}
#[test]
fn accessory_engine_on_to_running_and_back() {
let mut f = PowerFsm { state: State::Accessory };
assert_eq!(f.step(Event::EngineOn), vec![]);
assert_eq!(f.state(), State::Running);
assert_eq!(f.step(Event::EngineOff), vec![]);
assert_eq!(f.state(), State::Accessory);
}
#[test]
fn acc_off_begins_abortable_shutdown() {
let mut f = PowerFsm::new(); // Running
assert_eq!(
f.step(Event::AccOff),
vec![Action::ShutdownImminent(ShutdownReason::AccOff), Action::StartGrace]
);
assert_eq!(f.power_state(), PowerState::ShuttingDown);
assert_eq!(f.source(), PowerSource::HoldupCap);
}
#[test]
fn under_voltage_reason_and_source() {
let mut f = PowerFsm::new();
let a = f.step(Event::UnderVoltage);
assert_eq!(a[0], Action::ShutdownImminent(ShutdownReason::UnderVoltage));
assert_eq!(f.source(), PowerSource::LowBattery);
}
#[test]
fn abort_before_ponr() {
let mut f = PowerFsm::new();
f.step(Event::AccOff);
assert_eq!(
f.step(Event::AccOn),
vec![Action::ShutdownAborted, Action::AccChanged(true)]
);
assert_eq!(f.state(), State::Running);
}
#[test]
fn grace_expired_commits_and_is_irreversible() {
let mut f = PowerFsm::new();
f.step(Event::AccOff);
assert_eq!(f.step(Event::GraceExpired), vec![Action::Commit]);
// committed: abort игнорируется
assert_eq!(f.step(Event::AccOn), vec![]);
assert!(matches!(f.state(), State::ShuttingDown { phase: Phase::Committed, .. }));
}
#[test]
fn reserved_states_noop() {
let mut f = PowerFsm { state: State::Sleep };
assert_eq!(f.step(Event::AccOn), vec![]);
assert_eq!(f.state(), State::Sleep);
}
}
```
- [ ] **Шаг 2 — `lib.rs`:** добавить `pub mod fsm;`. (Прочитать `crates/core/shturman-power/src/lib.rs`, добавить строку рядом с другими `mod`.)
- [ ] **Шаг 3 — прогон.** Run: `cargo test -p shturman-power fsm`. Expected: PASS (7 тестов).
- [ ] **Шаг 4 — commit.**
```bash
git add crates/core/shturman-power/src/fsm.rs crates/core/shturman-power/src/lib.rs
git commit -s -m "feat(v0.3): чистый PowerFsm (состояния/переходы B03)"
```
---
## P7.2: обернуть FSM в сервис (D-Bus из FSM + dev-mock кормит события + grace + barrier)
**Files:** Modify `crates/core/shturman-power/src/service.rs`. Test: `crates/core/shturman-power/tests/integration.rs` (расширить).
- [ ] **Шаг 1 — переписать `service.rs`** (полностью — заменяет плоский `State` на FSM):
```rust
//! Server `ru.shturman.Power1` + (feature `dev-mocks`) `ru.shturman.dev.PowerMock1` (fake-ACC).
//! v0.3: оборачивает чистый `PowerFsm` (спека §5–§7). dev-mock кормит входы FSM (не флипает состояние).
use crate::fsm::{Action, Event, PowerFsm};
use shturman_common::monotonic_secs;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use zbus::interface;
use zbus::object_server::SignalContext;
/// Grace-окно (сек): и поле сигнала `ShutdownImminent`, и длительность таймера. v0.3 — фикс. (конфиг — позже).
const GRACE_SECS: u32 = 2;
pub struct PowerService {
fsm: Arc<Mutex<PowerFsm>>,
}
impl Default for PowerService {
fn default() -> Self {
Self { fsm: Arc::new(Mutex::new(PowerFsm::new())) }
}
}
impl PowerService {
pub fn new() -> Self {
Self::default()
}
pub fn power_state(&self) -> shturman_ipc::types::PowerState {
self.fsm.lock().unwrap().power_state()
}
pub fn ignition(&self) -> shturman_ipc::types::IgnitionState {
self.fsm.lock().unwrap().ignition()
}
pub fn source(&self) -> shturman_ipc::types::PowerSource {
self.fsm.lock().unwrap().source()
}
#[cfg(feature = "dev-mocks")]
pub fn mock(&self) -> PowerMock {
PowerMock { fsm: Arc::clone(&self.fsm) }
}
}
/// Шагнуть FSM и исполнить действия (эмит сигналов, grace-таймер, durable-barrier). Свободная функция —
/// чтобы её мог звать и dev-mock, и фоновый grace-таймер (с owned-контекстом).
async fn apply_event(
fsm: &Arc<Mutex<PowerFsm>>,
ev: Event,
ctx: &SignalContext<'_>,
) -> zbus::Result<()> {
let actions = fsm.lock().unwrap().step(ev);
for a in actions {
match a {
Action::ShutdownImminent(r) => {
PowerService::shutdown_imminent(ctx, GRACE_SECS, r.as_str()).await?
}
Action::ShutdownAborted => PowerService::shutdown_aborted(ctx).await?,
Action::AccChanged(on) => PowerService::acc_changed(ctx, on).await?,
Action::StartGrace => {
let fsm = Arc::clone(fsm);
let octx = ctx.to_owned();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(GRACE_SECS as u64)).await;
let _ = apply_event(&fsm, Event::GraceExpired, &octx).await;
});
}
Action::Commit => {
// Durable-write barrier (#5): сбросить грязные страницы /data ДО PONR. Settings уже синхронен.
let _ = std::process::Command::new("sync").status();
tracing::info!("power: commit (PONR) — durable barrier sync; load-shed: amp/backlight/modem (нет реальных нагрузок в v0)");
}
}
}
Ok(())
}
#[interface(name = "ru.shturman.Power1")]
impl PowerService {
async fn get_power_state(&self) -> String {
self.power_state().as_str().to_string()
}
/// Внутренний; sleep/wake — v1/v2 (B §7). В v0.3 — no-op.
async fn request_sleep(&self) {}
#[zbus(property)]
async fn ignition_state(&self) -> String {
self.ignition().as_str().to_string()
}
#[zbus(property)]
async fn uptime(&self) -> u64 {
monotonic_secs()
}
#[zbus(property)]
async fn power_source(&self) -> String {
self.source().as_str().to_string()
}
#[zbus(signal)]
async fn acc_changed(ctx: &SignalContext<'_>, on: bool) -> zbus::Result<()>;
#[zbus(signal)]
async fn shutdown_imminent(ctx: &SignalContext<'_>, seconds: u32, reason: &str) -> zbus::Result<()>;
#[zbus(signal)]
async fn shutdown_aborted(ctx: &SignalContext<'_>) -> zbus::Result<()>;
#[zbus(signal)]
async fn sleep(ctx: &SignalContext<'_>) -> zbus::Result<()>;
#[zbus(signal)]
async fn wake(ctx: &SignalContext<'_>) -> zbus::Result<()>;
}
/// dev-mock «fake-ACC/voltage/thermal» — кормит входы FSM. Прод (`--no-default-features`) не регистрирует.
#[cfg(feature = "dev-mocks")]
pub struct PowerMock {
fsm: Arc<Mutex<PowerFsm>>,
}
#[cfg(feature = "dev-mocks")]
#[interface(name = "ru.shturman.dev.PowerMock1")]
impl PowerMock {
async fn set_acc(&self, on: bool, #[zbus(signal_context)] ctx: SignalContext<'_>) {
let ev = if on { Event::AccOn } else { Event::AccOff };
let _ = apply_event(&self.fsm, ev, &ctx).await;
}
async fn set_ignition(&self, state: String, #[zbus(signal_context)] ctx: SignalContext<'_>) {
// accessory↔running — через EngineOn/Off; off — AccOff.
let ev = match state.as_str() {
"running" => Event::EngineOn,
"accessory" => Event::EngineOff,
_ => Event::AccOff,
};
let _ = apply_event(&self.fsm, ev, &ctx).await;
}
async fn trigger_shutdown(
&self,
_seconds: u32,
reason: String,
#[zbus(signal_context)] ctx: SignalContext<'_>,
) {
let ev = match reason.as_str() {
"thermal" => Event::ThermalTrip,
"under_voltage" => Event::UnderVoltage,
_ => Event::AccOff,
};
let _ = apply_event(&self.fsm, ev, &ctx).await;
}
async fn abort_shutdown(&self, #[zbus(signal_context)] ctx: SignalContext<'_>) {
let _ = apply_event(&self.fsm, Event::AccOn, &ctx).await;
}
}
#[cfg(test)]
mod tests {
use super::*;
use shturman_ipc::types::{IgnitionState, PowerState, PowerSource};
#[test]
fn defaults_running() {
let svc = PowerService::new();
assert_eq!(svc.power_state(), PowerState::Running);
assert_eq!(svc.ignition(), IgnitionState::Running);
assert_eq!(svc.source(), PowerSource::Vehicle12v);
}
}
```
- [ ] **Шаг 2 — прогон unit.** Run: `cargo test -p shturman-power`. Expected: PASS (fsm 7 + service 1).
- [ ] **Шаг 3 — расширить integration-тест** `crates/core/shturman-power/tests/integration.rs` — добавить тест abort (после существующего `power_state_and_fake_acc`):
```rust
#[tokio::test]
#[ignore = "нужна session-шина: just test-integration"]
async fn shutdown_imminent_then_abort() {
use futures_util::StreamExt;
let svc = PowerService::new();
let mock = svc.mock();
let server = zbus::Connection::session().await.unwrap();
server.object_server().at(names::power::PATH, svc).await.unwrap();
server.object_server().at(names::power::PATH, mock).await.unwrap();
server.request_name(names::power::NAME).await.unwrap();
let client = zbus::Connection::session().await.unwrap();
let power = PowerClient::new(&client).await.unwrap();
let mut imminent = power.proxy().receive_shutdown_imminent().await.unwrap();
let mut aborted = power.proxy().receive_shutdown_aborted().await.unwrap();
// ACC-off → ShutdownImminent(acc_off)
client.call_method(Some(names::power::NAME), names::power::PATH, Some(names::power::MOCK_IFACE), "SetAcc", &(false,)).await.unwrap();
let sig = imminent.next().await.unwrap();
assert_eq!(sig.args().unwrap().reason(), "acc_off");
assert_eq!(power.power_state().await.unwrap(), PowerState::ShuttingDown);
// re-power до grace → ShutdownAborted + running
client.call_method(Some(names::power::NAME), names::power::PATH, Some(names::power::MOCK_IFACE), "SetAcc", &(true,)).await.unwrap();
aborted.next().await.unwrap();
assert_eq!(power.power_state().await.unwrap(), PowerState::Running);
}
```
(`Power1Proxy` уже объявляет сигналы `shutdown_imminent`/`shutdown_aborted``crates/shturman-ipc/src/proxy.rs:29,31`; zbus генерит `receive_shutdown_imminent()`/`receive_shutdown_aborted()`. `sig.args().unwrap().reason()``&str`.)
- [ ] **Шаг 4 — прогон integration.** Run: `just test-integration` (или `dbus-run-session -- cargo test -p shturman-power -- --ignored`). Expected: PASS (оба теста).
- [ ] **Шаг 5 — lint + commit.**
```bash
cargo clippy -p shturman-power --all-targets -- -D warnings
git add crates/core/shturman-power/
git commit -s -m "feat(v0.3): Power-сервис на FSM — dev-mock кормит события, grace+durable-barrier"
```
---
## P7.3: systemd watchdog drop-in + save-time timer (B05/A14/B07)
**Files:** Create `systemd/watchdog-shturman.conf`, `systemd/shturman-savetime.service`, `systemd/shturman-savetime.timer`.
- [ ] **Шаг 1 — `systemd/watchdog-shturman.conf`** (system.conf.d; реальный `/dev/watchdog` — HW, в VM no-op):
```ini
# Watchdog (B05/A14): systemd пингует HW-watchdog в runtime + дедлайн на shutdown-фазу.
# Установка: /etc/systemd/system.conf.d/shturman-watchdog.conf. В VM /dev/watchdog нет → дисциплина (HW-арминг — v0.4).
[Manager]
RuntimeWatchdogSec=30s
RebootWatchdogSec=60s
```
- [ ] **Шаг 2 — `systemd/shturman-savetime.service`** (B07 periodic save last-known-time):
```ini
[Unit]
Description=Штурман save last-known-time (fake-hwclock → /data, B07)
After=data.mount
Requires=data.mount
[Service]
Type=oneshot
# FILE из /etc/default/fake-hwclock (→ /data; v0.6). Сервис в Lima masked → зовём напрямую с env.
ExecStart=/bin/sh -c '. /etc/default/fake-hwclock 2>/dev/null; FILE="${FILE:-/data/state/fake-hwclock.data}" fake-hwclock save'
```
- [ ] **Шаг 3 — `systemd/shturman-savetime.timer`** (периодика ~5 мин):
```ini
[Unit]
Description=Штурман periodic save-time (B07)
[Timer]
OnBootSec=2min
OnUnitActiveSec=5min
[Install]
WantedBy=shturman-stage2.target
```
- [ ] **Шаг 4 — commit** (проверка — P7.4/Lima).
```bash
git add systemd/watchdog-shturman.conf systemd/shturman-savetime.service systemd/shturman-savetime.timer
git commit -s -m "feat(v0.3): watchdog-конфиг (B05/A14) + save-time timer (B07)"
```
---
## P7.4: lima + E2E-блок power-safe (гибрид §9.3)
**Files:** Modify `lima/shturman.yaml`, `tests/e2e/run.sh`.
- [ ] **Шаг 1 — `lima/shturman.yaml`:** в блоке раскладки юнитов добавить watchdog + savetime:
```bash
install -d /etc/systemd/system.conf.d
install -m644 /shturman/systemd/watchdog-shturman.conf /etc/systemd/system.conf.d/shturman-watchdog.conf
# savetime.service/.timer ловит glob shturman-*.service/.timer? Нет — .timer не под *.service. Ставим явно:
install -m644 /shturman/systemd/shturman-savetime.service /shturman/systemd/shturman-savetime.timer /etc/systemd/system/
```
(Существующий `install /shturman/systemd/shturman-*.service` НЕ ловит `.timer` — ставим явно выше.)
- [ ] **Шаг 2 — `tests/e2e/run.sh`: установка savetime + watchdog в раскладке.** К блоку install (рядом с tmpfiles) добавить:
```bash
sudo install -d /etc/systemd/system.conf.d
sudo install -m644 systemd/watchdog-shturman.conf /etc/systemd/system.conf.d/shturman-watchdog.conf
sudo install -m644 systemd/shturman-savetime.service systemd/shturman-savetime.timer /etc/systemd/system/
```
- [ ] **Шаг 3 — `tests/e2e/run.sh`: блок power-safe.** Вставить после блока «Stage 0/1/2», до §8:
```bash
# ---- power-safe (v0.3): FSM + N циклов зажигания + abort + power-cut ----
info "power-safe: ShutdownImminent + N=3 цикла зажигания + abort + power-cut"
P_CALL() { busctl --system call "$P_NAME" "$P_PATH" "$P_MOCK" "$@"; }
busctl --system call "$S_NAME" "$S_PATH" "$S_IFACE" Set sv ui.theme s night >/dev/null
echo 0 | sudo tee /data/state/power-cycles >/dev/null
observe_imminent() { # SetAcc(false) → ждём ShutdownImminent
local mon; mon=$(mktemp)
# shellcheck disable=SC2024
sudo busctl --system monitor "$P_NAME" >"$mon" 2>&1 & local M=$!
sleep 0.7; P_CALL SetAcc b false; sleep 0.7
sudo kill "$M" 2>/dev/null; wait "$M" 2>/dev/null
grep -q ShutdownImminent "$mon" || { echo "--- mon ---"; cat "$mon"; rm -f "$mon"; return 1; }
rm -f "$mon"
}
for i in 1 2 3; do
observe_imminent || fail "цикл $i: ShutdownImminent не наблюдаем"
n=$(($(sudo cat /data/state/power-cycles) + 1))
sudo systemctl stop shturman-stage1.target # освобождает /data
sync; sudo umount /data || fail "цикл $i: umount /data"
sudo mount /data || fail "цикл $i: mount /data"
echo "$n" | sudo tee /data/state/power-cycles >/dev/null
sudo systemctl start shturman.target
for _ in $(seq 1 15); do systemctl is-active --quiet shturman-settings && break; sleep 1; done
pass "цикл зажигания $i: stop→umount→remount→restart"
done
got=$(busctl --system call "$S_NAME" "$S_PATH" "$S_IFACE" Get s ui.theme 2>/dev/null)
echo "$got" | grep -q '"night"' || fail "ui.theme потерян после циклов"
[ "$(sudo cat /data/state/power-cycles)" = 3 ] || fail "счётчик циклов != 3"
pass "N=3 цикла: /data + счётчик целы (нет потери)"
# abort до PONR
mon=$(mktemp)
# shellcheck disable=SC2024
sudo busctl --system monitor "$P_NAME" >"$mon" 2>&1 & M=$!
sleep 0.7; P_CALL SetAcc b false; sleep 0.3; P_CALL SetAcc b true; sleep 0.7
sudo kill "$M" 2>/dev/null; wait "$M" 2>/dev/null
grep -q ShutdownAborted "$mon" || { cat "$mon"; rm -f "$mon"; fail "ShutdownAborted не наблюдаем"; }
rm -f "$mon"
findmnt /data >/dev/null || fail "/data не смонтирован после abort"
busctl --system call "$P_NAME" "$P_PATH" "$P_IFACE" GetPowerState | grep -q running || fail "не running после abort"
pass "abort до PONR: ShutdownAborted + /data RW + running"
# power-cut-сим: SIGKILL во время shutdown → /data консистентен
P_CALL SetAcc b false; sleep 0.3
sudo systemctl kill -s KILL shturman-power.service shturman-settings.service 2>/dev/null || true
sudo systemctl stop shturman-stage1.target 2>/dev/null || true
sudo umount /data 2>/dev/null || true
sudo fsck.ext4 -n /var/lib/shturman/data.img >/dev/null 2>&1 || fail "fsck /data не clean после power-cut"
sudo mount /data
sudo grep -q night /data/settings/settings.json || fail "last durable value потерян после power-cut"
pass "power-cut-сим: /data консистентен (fsck clean, night present)"
sudo systemctl start shturman.target
for _ in $(seq 1 15); do systemctl is-active --quiet shturman-settings && break; sleep 1; done
# watchdog/save-time конфиг присутствует
test -f /etc/systemd/system.conf.d/shturman-watchdog.conf || fail "нет watchdog-конфига"
systemctl is-active --quiet shturman-savetime.timer && pass "savetime.timer активен" || echo " WARN: savetime.timer не активен"
pass "watchdog-конфиг (RuntimeWatchdogSec) на месте"
```
- [ ] **Шаг 4 — shellcheck + commit.**
```bash
shellcheck -S warning tests/e2e/run.sh
git add lima/shturman.yaml tests/e2e/run.sh
git commit -s -m "feat(v0.3): lima/E2E блок power-safe (N циклов + abort + power-cut)"
```
---
## P7.5: verify в Lima + acceptance + синхронизация доков
- [ ] **Шаг 1 — host-гейт.** Run: `just ci`. Expected: exit 0 (fsm-юниты + service + integration `#[ignore]`; clippy; deny). Плюс `just test-integration` (session-шина) — оба Power-теста зелёные.
- [ ] **Шаг 2 — чистый E2E.** Run: `just vm-reset && just e2e`. Expected: exit 0; power-safe-блок зелёный (N=3 цикла /data цел, abort→ShutdownAborted, power-cut fsck clean); **регресс v0.1/v0.2 зелёный**; `E2E OK ✅`.
- [ ] **Шаг 3 — итерации** по реальным ошибкам (grace-таймауты, umount-holders, fsck, proxy-сигналы) — систематически, один симптом → одна правка, повтор P7.5 шаг 2.
- [ ] **Шаг 4 — prod-build-gate.** Run: `cargo build -p shturman-power --no-default-features && ! strings target/debug/shturman-power | grep -q PowerMock1`. Expected: сборка ок, `PowerMock1` отсутствует.
- [ ] **Шаг 5 — синхронизация доков (швы §10):** `docs/domains/b-power-lifecycle.md` (реализованные срезы B01B07 в v0.3; abort/PONR-модель VM; HW/MCU/B08-B09 → v0.4); `docs/contracts/ipc.md` §3 (Power оживлён из FSM); `docs/specs/v0.1-v0.6-foundation.md` §5.2 («стаб» → реальный FSM); `CLAUDE.md` (статус v0.3 готово → v0.4/v0.5).
- [ ] **Шаг 6 — commit доков.**
```bash
git add docs/ CLAUDE.md
git commit -s -m "docs(v0.3): синхронизация швов power-safe + статус"
```
- [ ] **Шаг 7 — finishing-a-development-branch** (merge/PR — спросить пользователя; в `main` без явного «ок» не мержить).
---
## Acceptance (спека v0.3 §9.4)
- [ ] FSM: все переходы §5 — unit-тесты; sleep/battery_cutoff — no-op.
- [ ] `ShutdownImminent` на ACC-off; **abort до PONR → `ShutdownAborted`**; commit после grace + durable-barrier.
- [ ] **N=3 цикла зажигания — `/data` + счётчик целы**.
- [ ] power-cut-сим — `/data` консистентен (`fsck -n` clean, last value present).
- [ ] `Uptime` монотонен; watchdog/save-time конфиг на месте.
- [ ] Регресс v0.1/v0.2 зелёный; `just ci` зелёный; prod-build-gate (нет `PowerMock1`); красные линии целы (нет CAN/actuator).
File diff suppressed because it is too large Load Diff
+52 -14
View File
@@ -242,7 +242,7 @@ shturman/
`dbus-daemon`) — герметично, параллелизуемо, без root (§9).
- Изоляция песочных апов — через прокси (появится с App-Host, v3).
### 5.2 `ru.shturman.Power` — стаб (B04, домен B §9)
### 5.2 `ru.shturman.Power` — стаб v0.1 → **реальный FSM v0.3** (B03/B04, домен B §9)
- **Имя/путь/интерфейс:** `ru.shturman.Power` · `/ru/shturman/Power` · `ru.shturman.Power1`.
- **Методы:**
@@ -258,11 +258,13 @@ shturman/
- `IgnitionState: s``{off, accessory, running}`**канон** (B §1; E зеркалит, не дублирует)
- `Uptime: t` — секунды **монотонных** часов (`CLOCK_MONOTONIC`, B §8)
- `PowerSource: s``{vehicle_12v, holdup_cap, sleep_rail, low_battery}`
- **Стаб-поведение (v0):** старт в `running`/`IgnitionState=running`/`PowerSource=vehicle_12v`;
`Uptime` растёт монотонно. **Никаких** методов записи/actuator (#2). Реальная FSM/секвенсинг — v0.3.
- **Поведение:** старт в `running`/`IgnitionState=running`/`PowerSource=vehicle_12v`; `Uptime` растёт монотонно.
**Никаких** методов записи/actuator (#2). **v0.1 — плоский стаб; v0.3 — реальный `PowerFsm`** (состояния/переходы B03,
graceful shutdown подход A, grace-таймер + durable-barrier `sync` на commit/PONR); `power_state`/`ignition`/`source`
проекции FSM. Реальный источник событий (ACC/voltage/thermal через MCU) → v0.4; в v0.3 события кормит dev-mock.
- **Dev-mock (feature `dev-mocks`):** доп.интерфейс `ru.shturman.dev.PowerMock1` на том же объекте —
«**fake-ACC**» для тестов и будущего v0.3:
- `SetAcc(b on)`меняет state/IgnitionState + эмитит `AccChanged`
«**fake-ACC/voltage/thermal**», **кормит входы FSM** (v0.3) для тестов/E2E:
- `SetAcc(b on)``Event::AccOn`/`AccOff` (FSM) + эмитит `AccChanged`
- `SetIgnition(s state)`
- `TriggerShutdown(u seconds, s reason)` → эмитит `ShutdownImminent`
- `AbortShutdown()` → эмитит `ShutdownAborted`
@@ -390,9 +392,10 @@ Oneshot-бинарь под `shturman-firstboot.service` (`ConditionPathExists=!
по мере событий).
- **eMMC write-min (A11):** дисциплина (volatile-логи, tmpfs-транзиент, zram, без спама в `/data`).
**Измеримая проверка (детерминированный VM-прокси):** дельта записанных секторов на loop-устройстве `/data`
(`/proc/diskstats`) за фиксированное окно простоя (напр. 60 c после boot-settle) **ниже порога T** (🟡
калибруется) + **нет периодических флашей вне allow-list писателей** (`fake-hwclock`, `Settings` on-Set);
всё прочее → fail. Абсолютный байт-бюджет — вердикт на RK3588 (performance §2).
(`/proc/diskstats`, поле 10) за фиксированное окно простоя (E2E: `E2E_IDLE_SECS`=20 c после boot-settle) **ниже
порога T** + **нет периодических флашей вне allow-list писателей** (`fake-hwclock`, `Settings` on-Set); всё прочее
→ fail. **Калибровка (Lima, 2026-06-24): ~80104 сектора/20 c простоя; порог T = 4096 секторов (~2 МБ/окно),
env `E2E_EMMC_MAX_SECTORS`.** Абсолютный байт-бюджет — вердикт на RK3588 (performance §2).
### 7.6 systemd-оркестрация (A15)
@@ -419,11 +422,18 @@ Oneshot-бинарь под `shturman-firstboot.service` (`ConditionPathExists=!
«VM лёгкая»; правится локально); `mounts:` репозиторий **writable** (правим на хосте — собираем в VM).
- **provision (system):** установить пакеты (`systemd`, `dbus`, `pipewire` + WirePlumber [задел v1],
`weston` + `weston-screenshooter`, `can-utils`, `rustup`/toolchain, `python3`+venv, **`systemd-zram-generator`**,
`fake-hwclock`, кириллические шрифты); `modprobe vcan` + `ip link add vcan0`;
`fake-hwclock`, кириллические шрифты + **build-deps Slint/winit на Linux**: `libfontconfig1-dev`/`libxkbcommon-dev`/
`libwayland-dev` — иначе `cargo build` shell падает на `yeslogic-fontconfig-sys`); **`linux-modules-extra-$(uname -r)`**
(модули `zram`/`vcan` НЕ входят в базовый vz-образ Lima); `modprobe vcan` + `ip link add vcan0` (vcan может
отсутствовать в vz-ядре — VM↔HW-граница, как раньше);
создать loopback-`/data` (ext4 + power-safe-опции) и завести **постоянный** `data.mount`/fstab + tmpfs-overlay;
override `fake-hwclock` пути на `/data`; разложить `systemd/`-юниты + journald/zram-generator/oomd drop-ins +
dbus policy (прод `ru.shturman.conf` + dev-only `ru.shturman.dev.conf`); включить `shturman.target`.
*(screenshot кадра в CI — через Slint software-renderer, без пакета grim; см. §6.)*
override `fake-hwclock` пути на `/data` + удалить стоковый `/etc/fake-hwclock.data` (A11; сервис в Lima **masked**
Lima сам синхронит время, на HW юнит размаскирован и читает `FILE` через `EnvironmentFile`); разложить
`systemd/`-юниты + journald/zram-generator/oomd drop-ins + dbus policy (прод `ru.shturman.conf`; dev-mock
`ru.shturman.dev.PowerMock1` — интерфейс на объекте `ru.shturman.Power`, **отдельное dev-имя/policy НЕ нужны**,
покрыт `send_destination`); включить `shturman.target`.
*(screenshot кадра в E2E — через Slint software-renderer → PNG, без weston/grim; `shell.service` в v0.6 =
oneshot-screenshot, живой weston-shell — v0.5; см. §6.)*
- **reference-«BSP» (A16):** в dev это **Lima-профиль** (дев-таргет). Реальный reference-BSP (DT overlay +
HAL + DBC) — на HW (a-base §13), вне VM.
- **Подъём:** `just vm-up``limactl start --name=shturman lima/shturman.yaml` (создание+provision);
@@ -448,9 +458,13 @@ Oneshot-бинарь под `shturman-firstboot.service` (`ConditionPathExists=!
| `sim` | **плейсхолдер** Vehicle Simulator (v2, домен E) |
| `ci` | локальный прогон гейта: `lint` + `test` + `deny` |
### 8.3 CI (GitHub Actions, ARM64-Linux)
### 8.3 CI (локальный гейт; авто-CI — позже)
- `.github/workflows/ci.yml`: jobs **lint** (fmt+clippy), **build**, **test** (unit+integration —
> ⚠️ **`.github/workflows/ci.yml` удалён** (2026-06-24): self-hosted **Gitea** триггерится на GitHub-Actions-формат,
> что не нужно. Активный гейт — **локальный `just ci`**. Авто-CI на Gitea (Gitea Actions/runner) — решение позже.
> Описание ниже — задел на тот момент.
- (задел) jobs **lint** (fmt+clippy), **build**, **test** (unit+integration —
раннер **уже Linux**, шину/сервисы/headless-рендер гоним напрямую, **без Lima**), **license**
(`cargo-deny`), **prod-build-gate** (`cargo build --workspace --no-default-features` + ассерт, что
`PowerMock1` не экспортируется без фичи `dev-mocks` — §5.2). Позже — **integration** (vcan+симулятор, v2)
@@ -659,6 +673,30 @@ SOFTWARE.
- **`principles #12`**: уточнить LGPL — гранулярно (динамическая/системная линковка допустима), а не blanket;
согласовать с `deny.toml`/§3.
**Реализация (План 5 ч.2 — v0.6 Lima E2E, 2026-06-24, проверено в Lima):**
- **`shell.service` (§6/§7.6):** v0.6 = **oneshot software-render → PNG** (`shturman-shell --screenshot
/run/shturman/frame.png`, tmpfs/volatile; `RemainAfterExit=yes` → `is-active=active` детерминированно, без
хрупкого weston). Живой weston-shell (`ui.run()`) — **v0.5**. `shturman-shell` стал lib+bin (рендер тестируем
headless и на dev-Mac). Доки §6/§7.6 уже называют software-renderer основным — синхронизировано.
- **Провижининг Lima (§8.1):** добавлены build-deps `libfontconfig1-dev`/`libxkbcommon-dev`/`libwayland-dev`
(Slint/winit на Linux тянут fontconfig — на macOS CoreText, поэтому всплыло только в VM) +
`linux-modules-extra-$(uname -r)` (модули `zram`/`vcan` не в базовом vz-ядре). vcan-модуль всё равно может
отсутствовать — `zram` ставится, `vcan` — best-effort (Vehicle Sim v2).
- **fake-hwclock (§7.3):** сервис в Lima **masked** (Lima синхронит время с хоста) → override через
`EnvironmentFile` не срабатывает в VM; скрипт читает `FILE` из env, E2E демонстрирует запись в `/data` напрямую.
Стоковый `/etc/fake-hwclock.data` удаляется в провижининге (A11). На HW юнит размаскирован — механизм тот же.
- **eMMC-порог T (§7.5):** калибровка ~80104 сект/20 c → **T=4096 сект**; окно простоя 20 c (не 60).
- **CARGO_TARGET_DIR (§8.2):** E2E/сборка в VM пишут в **VM-локальный** target (`~/.cache/shturman/target`), не в
смонтированный `target/` — иначе конфликт Darwin↔Linux-артефактов + медленный virtiofs.
- **dev-mock policy (§5.1):** отдельный `ru.shturman.dev.conf` для `PowerMock1` **не нужен** — это интерфейс на
объекте `ru.shturman.Power` (имя то же), покрыт `send_destination=ru.shturman.Power`. Файл зарезервирован на
случай отдельного dev-**имени** на шине.
- **E2E reboot (§9.3.4):** двухфазно `just e2e` (pre → guest-reboot Lima → post); персист Settings + machine-id
every-boot bind проверяются после реального ребута. `just run` = только pre (без ребута).
- **v0.2 (boot-конвейер):** `shturman.target` стал **зонтиком** фаз; критический набор v0.1 переехал в
`shturman-stage1.target` (тело юнитов без изменений, у shell `RuntimeDirectory` → tmpfiles). Splash (Stage 0) +
warmup (Stage 2) — новые; headless-render плумбинг вынесен в `shturman-render`. Детали — `docs/specs/v0.2-boot-pipeline.md`.
---
## 14. Дальше по ритму
+197
View File
@@ -0,0 +1,197 @@
# Спека реализации: v0.2 — Boot-конвейер (Stage 0/1/2 + splash)
> Веха `v0.2` роадмапа: «splash → таргет фазами»; capabilities **A04** (быстрый boot Stage 0/1/2, <10 c),
> **A05** (splash, Stage 0), **A15** (systemd-таргеты/оркестрация). Поверх **v0.1** (образ + `/data` + `shturman.target`).
> Источники: `docs/architecture.md` §6 (boot), `docs/domains/a-base-system.md` §4, `docs/roadmap.md` §v0.
> Приёмка роадмапа: **«Stage 0/1/2 разделены; splash мгновенно»**.
---
## 1. Цель и первый артефакт
Превратить плоский `shturman.target` (v0.1: один critical set) в **фазовый boot-конвейер** из трёх явных
стадий, с **мгновенным splash** до первого кадра Shell и **деферредом фоновой нагрузки** после интерактива.
**Первый артефакт:** на boot VM рендерится `/run/shturman/splash.png` (Stage 0) **раньше**, чем
`/run/shturman/frame.png` (Stage 1, Shell), а Stage 2 (warmup) стартует **после** кадра. Все три стадии —
отдельные systemd-таргеты, достижимые и упорядоченные; boot-тайминг логируется.
**Не цель v0.2:** перфоманс-вердикт (<10 c — на RK3588, performance §2; в VM — функционально); красивый
визуальный язык splash/Shell (язык — гейт v0.5); реальные Stage-2-сервисы (Vehicle-Data/Assistant/… — v1+).
---
## 2. Скоуп и границы
### 2.1 В скоупе (делаем сейчас)
- **Splash (A05):** новый `shturman-splash` — Slint software-render брендового splash-кадра → PNG
(headless, без дисплея/композитора; зеркалит механику shell-кадра v0.1). Стартует максимально рано
(Stage 0, минимум зависимостей), **до** первого кадра Shell.
- **Фазовые таргеты (A15):** `shturman-stage0/1/2.target` + рефактор `shturman.target` в **зонтик**.
Члены нынешнего critical set переезжают в `shturman-stage1.target`.
- **Деферред Stage 2:** `shturman-stage2-warmup.service` — oneshot-плейсхолдер (лог+маркер), `After` первого
кадра. Каркас для реальных фоновых сервисов v1+.
- **Boot-тайминг (A04):** E2E логирует `systemd-analyze` + Δ(splash→frame); **функционально, не гейт**.
Жёстко ассертим **порядок фаз** (splash → frame → warmup) и **достижимость** трёх таргетов.
- **Общий рендер-хелпер:** headless Slint-software-render → PNG выделяется из `shturman-shell` в
переиспользуемый модуль (используют shell + splash). Точное место — план реализации.
### 2.2 Явно НЕ в скоупе (отложено, с указателем «куда»)
- **U-Boot splash (Stage 0 на железе):** framebuffer-картинка загрузчика до ядра — **HW** (a-base §4, §1).
В VM U-Boot'а нет → splash моделируем systemd-сервисом (шов §10).
- **Splash→Shell handoff на реальном дисплее** (без чёрного мелькания, передача поверхности композитору) —
**v0.5** (Shell/композитор smithay). В VM обе стадии — отдельные PNG.
- **Ранний низколатентный путь задней камеры/парктроника в Stage 0** (a-base §1 шов с J/B) — **домен J / v1+**.
- **A/B boot-select, bootlimit, mark-good, secure/verified boot, security-version rollback** (a-base §2, §4) —
**HW/v4** (нет U-Boot/eFuse в VM).
- **Перф-вердикт <10 c****RK3588** (performance §2). В VM — функциональный замер с пометкой «не вердикт».
- **Реальные Stage-2-сервисы** (Vehicle-Data, Assistant, Media, Nav, Connectivity) — **v1+** (домены E/D/H/I/G).
### 2.3 Частично в скоупе (каркас сейчас, тело — позже)
- **Stage 2** — только структура (таргет + один warmup-плейсхолдер); приоритеты/oomd-жертвы фона
проверяются на реальной нагрузке позже (performance §5).
- **Параллельный быстрый boot** (минимальный initramfs, ленивые сервисы, a-base §4) — в VM моделируем
systemd-фазами; тюнинг initramfs/ядра — HW.
### 2.4 Трассируемость ID → статус
| ID | Веха | Статус в v0.2 |
|----|------|----------------|
| A04 | Быстрый boot Stage 0/1/2 <10 c | фазы разделены + тайминг логируется (вердикт — HW) |
| A05 | Splash (Stage 0) | splash-сервис рендерит PNG до первого кадра (U-Boot framebuffer — HW) |
| A15 | systemd-таргеты/оркестрация | зонтик + 3 фазовых таргета + splash/warmup юниты |
---
## 3. Красные линии, безопасность, лицензии
- **#1/#2 (нерушимы):** boot-оркестрация + read-only splash — **нет** CAN/actuator/safety-путей. Splash не
читает шину (статичный бренд-кадр), Shell-кадр — read-only OBD/состояние (как в v0.1).
- **Лицензии:** новых тяжёлых зависимостей нет; splash переиспользует Slint (GPL-3.0 exception в `deny.toml`,
уже заведено) + `png` (уже в lock). `just deny` — зелёный.
- **eMMC write-min (A11):** splash.png/frame.png/маркеры — в **tmpfs `/run`** (volatile), не на flash.
---
## 4. Раскладка (новые/изменённые артефакты)
### 4.1 Бинари/крейты
- **`shturman-splash`** (новый bin, `crates/apps/`): Slint-компонент splash + `--screenshot <path>` режим
(как shell). Дефолт — интерактив (HW/dev-Mac); `--screenshot` — headless PNG (VM/E2E). Splash **не** читает
шину (нет зависимости от Power/Settings — стартует до них).
- **Общий рендер-хелпер** (выделить из `shturman-shell`): `render_component_to_png(ui, size, path)` поверх
Slint software-renderer (thread_local `MinimalSoftwareWindow` + `set_platform` once + `draw_if_needed` + `png`).
Используют `shturman-shell` и `shturman-splash`. Место (отдельный lib-крейт vs модуль) — план.
### 4.2 systemd-юниты
| Юнит | Роль | Ключевое |
|------|------|----------|
| `shturman.target` | **зонтик** v0 | `Wants=`stage0+stage1+stage2; `After=data.mount`; `WantedBy=multi-user.target` |
| `shturman-stage0.target` | Stage 0 (splash) | `Wants=shturman-splash.service` |
| `shturman-stage1.target` | Stage 1 (ядро+кадр) | `Wants=`firstboot+machineid+power+settings+shell; `Requires/After=data.mount` |
| `shturman-stage2.target` | Stage 2 (фон) | `Wants=shturman-stage2-warmup.service`; `After=shturman-stage1.target` |
| `shturman-splash.service` | рендер splash | минимум зависимостей; `Before=shturman-shell.service`; oneshot+RemainAfterExit |
| `shturman-stage2-warmup.service` | плейсхолдер фона | oneshot; `After=shturman-shell.service`; лог+маркер `/run/shturman/stage2.ready` |
**Рефактор:** нынешние `WantedBy=shturman.target` у firstboot/machineid/power/settings/shell → **`WantedBy=shturman-stage1.target`**.
`shturman.target` перестаёт прямо тянуть сервисы — тянет три под-таргета.
---
## 5. Контракты D-Bus
**Новой поверхности нет.** Фазы boot — это systemd-оркестрация, не шина. (`BootStage`-property на `Power`
рассматривалась — **YAGNI**, отвергнута: фазы наблюдаемы через `systemctl`/journald; реальный lifecycle-FSM —
v0.3, домен B.) Power/Settings-контракты v0.1 — без изменений.
---
## 6. Splash — Stage-0 кадр (срез A05)
- **UI (Slint):** минимальный брендовый кадр — wordmark «Штурман» по центру на тёмном фоне (нейтральный
плейсхолдер; **визуальные токены design-system — каркас**, полный язык — гейт v0.5). Без статус-бара/тайлов
(это Shell, Stage 1). Без чтения шины — **статичный** (поэтому стартует до Power/Settings → «мгновенно»).
- **Рендер-бэкенды** (как Shell §6 v0.1):
- *dev интерактивно:* Slint под weston/нативно.
- *VM/E2E:* **software-renderer → `/run/shturman/splash.png`** (без дисплея); ассерт «splash не пустой».
- **«Мгновенность»:** splash-сервис без `Requires=data.mount`/dbus — стартует на `basic.target`/`/run` готов,
параллельно критическому набору; `Before=shturman-shell.service` гарантирует splash.png **раньше** frame.png.
- **Граница:** на железе Stage-0-splash — U-Boot framebuffer **до** systemd (a-base §4); systemd-splash здесь —
dev-модель + ранний пост-ядерный splash. Handoff на дисплее (splash→Shell без мелькания) — v0.5.
---
## 7. Boot-конвейер фазами (A04/A15)
Модель (architecture §6, a-base §4): **Stage 0 мгновенно · Stage 1 ~35 c · Stage 2 фоном**.
- **Stage 0:** `shturman-splash.service` → splash.png. Минимум зависимостей, до первого кадра.
- **Stage 1 (нынешний critical set v0.1):** `data.mount` → firstboot → machineid → dbus → power+settings →
**shell (первый кадр frame.png)**. Ordering — в самих юнитах (как в v0.1), членство — `shturman-stage1.target`.
- **Stage 2:** `shturman-stage2-warmup.service` `After=shturman-shell.service` — стартует **после** кадра
(деферред); пишет маркер `/run/shturman/stage2.ready` + лог. Каркас для фоновых сервисов v1+.
- **Наблюдаемый порядок фаз:** `splash.png` (mtime) < `frame.png` (mtime) < `stage2.ready` (mtime); три
таргета достижимы (`is-active`); warmup `After` кадра (journald-время старта > времени рендера кадра).
- **Тайминг (A04, функц.):** E2E логирует `systemd-analyze time` + Δ(boot→splash) + Δ(boot→frame); порог <10 c
**не гейтит** (вердикт — RK3588). Дисциплина: монотонные часы для замеров (a-base §4 / common-helper).
---
## 8. Dev-харнесс (расширение v0.6)
- **`justfile`:** `splash-frame [path]` — инспекция splash-кадра (как `shell-frame`). `run`/`e2e` — без новых
целей (фазы поднимаются тем же `shturman.target`).
- **`tests/e2e/run.sh`:** добавить блок **«Stage 0/1/2»**:
- три таргета `is-active`/reached;
- `splash.png` существует, валидный PNG, mtime **< `frame.png`**;
- `stage2.ready` существует, mtime **> `frame.png`** (warmup после кадра);
- лог `systemd-analyze` + Δ-тайминги (не гейт).
- **`lima/shturman.yaml`:** разложить новые юниты (stage0/1/2.target, splash, warmup) + бинарь splash в `run.sh`.
Splash-зависимостей (пакетов) нет — Slint build-deps уже есть (v0.6).
---
## 9. План тестирования и приёмка
### 9.1 Unit
- `shturman-splash`: `render → PNG` непустой, верный размер, бренд-фон тёмный (зеркало shell `screenshot.rs`).
- Общий рендер-хелпер: один тест на оба компонента (или по тесту на крейт).
### 9.2 E2E (Lima, расширение `run.sh`)
- **Фазы разделены:** `shturman-stage0/1/2.target` достижимы; splash.png **до** frame.png; stage2.ready **после**.
- **Splash:** PNG валиден/непустой.
- **Регресс v0.1/v0.6 не сломан:** все прежние проверки (mount/firstboot/per-unit/шина/fake-ACC/кадр/бюджеты/
персист+reboot) — зелёные на рефакторенных таргетах.
- **Тайминг:** залогирован (функц.).
### 9.3 Критерии приёмки (acceptance)
- [ ] `shturman.target` = зонтик; `shturman-stage0/1/2.target` достижимы и **разделены** (per-target active).
- [ ] Splash-кадр рендерится (`splash.png` непустой) **раньше** первого кадра Shell (`frame.png`).
- [ ] Stage 2 (warmup) стартует **после** первого кадра (деферред наблюдаем).
- [ ] Boot-тайминг логируется (Δ splash/frame, `systemd-analyze`); <10 c — пометка «вердикт на RK3588».
- [ ] Вся приёмка v0.1/v0.6 (§9.4 foundation) — **зелёная** на новой фазовой раскладке (нет регресса).
- [ ] `just ci` зелёный; красные линии целы (нет CAN/actuator).
---
## 10. Двунаправленные швы (синхронизировать при реализации)
- **a-base §4 / architecture §6:** уточнить, что в **dev-VM** Stage-0-splash — systemd-сервис (software-render
PNG), а U-Boot framebuffer-splash — HW; пометить как VM↔HW-границу (как уже сделано для overlay/A-B в v0.6).
- **roadmap §v0.2:** по прохождении — отметить веху ✅; «splash мгновенно» в VM = splash.png до frame.png.
- **CLAUDE.md:** обновить статус (v0.2 готово → следующее v0.3/v0.5).
- **v0.1-v0.6 spec §13:** добавить шов «shturman.target → зонтик; критический набор → stage1.target».
- Если всплывёт: handoff splash→Shell и ранний путь камеры — указатели на v0.5 / домен J.
---
## 11. Дальше по ритму
`v0.2` (эта спека) → **writing-plans** (план реализации: рендер-хелпер → splash → таргеты-рефактор →
warmup → E2E-блок) → **TDD** → реализация → **verify в Lima** → коммит. Не писать код до утверждённой спеки.
Далее по роадмапу: `v0.3` power-safe и `v0.5` shell — параллельно поверх v0.2.
+241
View File
@@ -0,0 +1,241 @@
# Спека реализации: v0.3 — Power-safe ядро (FSM + graceful shutdown)
> Веха `v0.3` роадмапа: «переживает срыв питания». Capabilities **B01** (детект ACC), **B02** (graceful
> shutdown sequencing), **B03** (FSM + abort/committed), **B04** (`ru.shturman.Power`), **B05** (watchdog),
> **B06** (load-shedding), **B07** (save last-known-time), **A14** (HW-watchdog + recovery). Поверх **v0.2**.
> Источники: `docs/domains/b-power-lifecycle.md`, `docs/contracts/safety.md`, `docs/contracts/hardware.md`,
> `docs/contracts/ipc.md` §3, `docs/roadmap.md` §v0.3. Приёмка роадмапа: **«N циклов зажигания без потери `/data`;
> abort до PONR»**.
---
## 1. Цель и первый артефакт
Оживить **стаб** Power (v0.1: плоский `State`, mock флипает ignition/power) в **реальный lifecycle-FSM**:
ACC → graceful shutdown с **durable-write до PONR** → переживание срыва питания.
**Первый артефакт:** fake-ACC-off → FSM `running``shutting_down``ShutdownImminent` → grace → commit
(durable-write barrier `sync` → unmount `/data` = PONR); **N=3 цикла зажигания**`/data` цел; **abort до PONR**
(re-power → `ShutdownAborted``running`); **power-cut-сим** (SIGKILL до fsync → `/data` консистентен).
**Не цель v0.3:** реальный hold-up cap / MCU-протокол / fail-safe-таймер и выбор **B08/B09** (MCU vs supercap) —
**v0.4** (вероятно нужна аппаратная проверка); реальный `/dev/watchdog` арминг — HW; полные sleep/wake/long-park —
v1/v2; перф-вердикт — RK3588.
---
## 2. Скоуп и границы
### 2.1 В скоупе (делаем сейчас)
- **FSM питания (B03):** чистый модуль `PowerFsm` — состояния, события, переходы, действия. Юнит-тестируемый
без D-Bus. Внутренние субсостояния `ShuttingDown{Abortable, Committed}` маппятся в D-Bus `shutting_down`.
- **Детект ACC-логика (B01):** debounce/гистерезис + crank-приоритет — **формализованы в FSM** (вход `AccOff`
принимается только как стабильный; в VM источник — fake-ACC dev-mock). Реальный GPIO/MCU-детект — HW.
- **Graceful shutdown sequencing core (B02):** `ShutdownImminent(sec, reason)` → grace-окно → **durable-write
barrier** (`sync(2)`; Settings уже синхронен) → commit → unmount (PONR). Ordered teardown апов/CAN — позже (§2.2).
- **Abort до PONR (B03):** re-power в abortable → `ShutdownAborted` → назад в `running`/`accessory`.
- **Load-shedding (B06):** хук на commit/power-loss — в v0 **лог** (реальных нагрузок нет; рейлы amp/подсветка/
модем — HW).
- **Watchdog (B05/A14):** systemd `RuntimeWatchdogSec` (runtime) + `RebootWatchdogSec` (shutdown-фаза) — **конфиг
+ дисциплина**. Реальный `/dev/watchdog`/MCU-арминг — HW.
- **Save last-known-time (B07):** `shturman-savetime.timer`+`.service` — periodic fake-hwclock save (`/data`) +
on-shutdown save (в graceful sequence до PONR). `fake-hwclock``/data` — уже из v0.6.
- **Монотоника (§8):** `Uptime` + grace-timer + все lifecycle-таймеры на `CLOCK_MONOTONIC`
(`shturman_common::monotonic_secs` уже есть). НЕ wall-clock.
- **Харнесс:** FSM-юниты (каждый переход) + integration (сигналы по session-шине) + E2E **гибрид** (§9).
### 2.2 Явно НЕ в скоупе (отложено, с указателем «куда»)
- **Hold-up cap / supercap, MCU-копилот, MCU fail-safe-таймер, SoC↔MCU heartbeat/`safe-to-cut`, реальный
power-cut energy budget + дератинг по T** — **HW** (hardware §3); **выбор B08/B09 (MCU vs supercap-only) — v0.4**.
- **Реальный `/dev/watchdog` арминг + bootcount-handshake recovery****HW/v4** (в VM watchdog-device нет).
- **Полные sleep/wake + long-park battery-cutoff** (низкопотребление, wake-on-ACC/таймер/реверс) — **v1/v2** (B §7).
В v0.3 состояния `sleep`/`battery_cutoff`**зарезервированы** (переходы заглушены).
- **Consumer-ack save-протокол** (`ShutdownImminent`→consumers save→ack/timeout, сумма ≤ hold-up-бюджет) —
**App-Host v3** (в v0 Settings durable-write синхронен → ack не нужен).
- **E гасит OBD-TX / закрывает ISO-TP при shutdown****домен E / v1** (Power **не трогает CAN**, §3).
- **Перф-вердикт** (time-to-shutdown, hold-time) — **RK3588** (performance §2). В VM — функционально.
### 2.3 Частично в скоупе (каркас сейчас, тело — позже)
- **`sleep`/`battery_cutoff`** — состояния в enum/FSM есть, переходы **no-op/заглушка** (тело — v1/v2).
- **Load-shedding** — лог-хук (реальные рейлы — HW).
- **Watchdog** — конфиг systemd (реальный арминг — HW).
- **ACC-детект** — debounce-логика в FSM (источник в VM — fake-ACC; реальный GPIO/MCU — HW).
### 2.4 Трассируемость ID → статус
| ID | Веха | Статус в v0.3 |
|----|------|----------------|
| B01 | Детект ACC (debounce + crank) | логика в FSM; источник VM = fake-ACC (реальный GPIO/MCU — HW) |
| B02 | Graceful shutdown sequencing | core: ShutdownImminent→grace→durable-barrier→commit (teardown апов/CAN — позже) |
| B03 | FSM + abort/committed | полностью (sleep/battery_cutoff — каркас) |
| B04 | `ru.shturman.Power` | оживлён из FSM (сигналы/состояние — реальные переходы) |
| B05 | Watchdog | конфиг RuntimeWatchdogSec/RebootWatchdogSec + дисциплина (реальный WDT — HW) |
| B06 | Load-shedding | лог-хук (реальные нагрузки — HW) |
| B07 | Save last-known-time | periodic timer + on-shutdown save в `/data` |
| A14 | HW-watchdog + recovery | конфиг + дисциплина (реальный арминг/recovery — HW/v4) |
---
## 3. Красные линии, безопасность
- **#2 (нерушимо):** Power **не трогает CAN** и **не имеет actuator-путей** — только software-оркестрация
lifecycle + **read-only** состояние. CAN-TX-гашение при shutdown — **домен E (v1)**, не Power. Граница — safety.md.
- **#5 (power-safe):** durable-write до PONR — главная гарантия; FSM коммитит только после grace + `sync`. Реальный
power-cut на HW; в VM — функциональная модель + атомарность файла (foundation §9.1) уже доказана.
- **Прод-гейт:** dev-mock `PowerMock1` (fake-ACC/voltage/thermal) — за фичей `dev-mocks`; прод `--no-default-features`
→ не регистрируется (foundation §5.2, §8.3).
---
## 4. Раскладка (новые/изменённые артефакты)
- **`crates/core/shturman-power/src/fsm.rs`** (новый) — `PowerFsm`: `State`, `Event`, `Action`, `fn step(&mut self,
Event) -> Vec<Action>`. Чистый, без D-Bus/async. Grace-таймер — снаружи (сервис), FSM лишь даёт `StartGrace(sec)`/
принимает `GraceExpired`.
- **`crates/core/shturman-power/src/service.rs`** — обернуть FSM: D-Bus state/properties/signals **из FSM**;
dev-mock методы **кормят FSM-события** (не флипают `State` напрямую).
- **`crates/core/shturman-power/src/main.rs`** — grace-таймер (монотоника, tokio), durable-write barrier (`sync`),
трансляция FSM-actions → D-Bus-сигналы.
- **systemd:** `shturman-power.service` drop-in `RuntimeWatchdogSec=` (дисциплина); `shturman-savetime.service`+
`.timer` (B07 periodic save); system `RebootWatchdogSec=` (shutdown-дедлайн). Раскладка — lima/E2E.
- **harness:** `tests/e2e/run.sh` — блок power-safe (гибрид §9); integration-тесты в `crates/core/shturman-power/tests/`.
---
## 5. FSM питания (B03) — контракт
**Состояния (внутренние):** `Off`, `Accessory`, `Running`, `ShuttingDown { phase: Abortable | Committed, reason }`,
`Sleep`*, `BatteryCutoff`* (`*` — зарезервированы). **D-Bus-маппинг** (`PowerState`): `ShuttingDown{*}``shutting_down`;
остальные 1:1.
**События (`Event`):** `AccOn`, `AccOff`, `EngineOn`, `EngineOff` (accessory↔running по напряжению; VM — mock),
`UnderVoltage`, `ThermalTrip`, `GraceExpired`. (Re-power = `AccOn` во время shutdown.)
**Переходы:**
| Из | Событие | В | Действия |
|----|---------|---|----------|
| `Off` | `AccOn` | `Accessory` | `EmitAccChanged(true)` |
| `Accessory` | `EngineOn` | `Running` | — |
| `Running` | `EngineOff` | `Accessory` | — |
| `Accessory`/`Running` | `AccOff` | `ShuttingDown{Abortable, acc_off}` | `EmitShutdownImminent(sec, acc_off)`, `StartGrace(sec)` |
| `Accessory`/`Running` | `UnderVoltage` | `ShuttingDown{Abortable, under_voltage}` | `EmitShutdownImminent(sec, under_voltage)`, `StartGrace(sec)` |
| `Accessory`/`Running` | `ThermalTrip` | `ShuttingDown{Abortable, thermal}` | `EmitShutdownImminent(sec, thermal)`, `StartGrace(sec)` |
| `ShuttingDown{Abortable}` | `AccOn` (re-power) | `Running` | `EmitShutdownAborted`, `EmitAccChanged(true)` |
| `ShuttingDown{Abortable}` | `GraceExpired` | `ShuttingDown{Committed}` | `Commit` (durable-barrier → PONR) |
| `ShuttingDown{Committed}` | — | `Off` | (cut: unmount + снятие питания — systemd/харнесс/HW) |
| `Sleep`/`BatteryCutoff` | * | (no-op) | зарезервировано (v1/v2) |
**Действия (`Action`):** `EmitShutdownImminent(reason)`, `EmitShutdownAborted`, `EmitAccChanged(bool)`,
`StartGrace(secs)`, `Commit`. `reason ∈ {acc_off, under_voltage, thermal, battery_cutoff}`.
**Инвариант:** после `Committed` abort невозможен (только → `Off`).
**Чистота:** `step` детерминирован, без I/O; сервис исполняет действия (сигналы/таймер/`sync`). Юнит-тест — каждый переход.
---
## 6. Graceful shutdown (B02/B06) — последовательность (подход A)
Power-сервис = **FSM + сигналы + grace/abort-окно**; реальный teardown (unmount/cut) — через systemd (реальный
poweroff) либо харнесс (in-VM-цикл). На железе — MCU/supercap-sequencing (v0.4).
1. `AccOff` (стабильный; VM — fake-ACC) → `ShuttingDown{Abortable}`**`ShutdownImminent(sec, acc_off)`** + grace-таймер
(монотоника). Потребители (приборка/будущие апы) получают сигнал и сохраняются.
2. **Abort-окно (abortable):** `AccOn` до `GraceExpired``EmitShutdownAborted``Running`; откат load-shed (v0: лог).
3. **`GraceExpired` → Commit:** save last-known-time (B07) → **durable-write barrier** (`sync(2)`; Settings уже
синхронен по каждому Set) → `Committed` (**= PONR**).
4. **PONR = unmount `/data`** (RW→RO): на реальном poweroff — systemd; в in-VM-цикле — харнесс; на HW — MCU/supercap
дают энергию завершить unmount/sync до cut.
5. **Load-shedding (B06):** на commit/power-loss — лог `«load-shed: amp/backlight/modem (реальных нагрузок нет в v0)»`;
hold-up кормит SoC+хранилище — HW.
**Гарантия #5:** commit (и потому unmount/PONR) наступает **только после** grace + `sync` → усечённый shutdown
оставляет `/data` консистентным (атомарность файлов — foundation §9.1).
---
## 7. D-Bus `ru.shturman.Power` — v0.3 оживляет (расширение foundation §5.2)
- **Состояние/properties из FSM** (не из плоского `State`): `GetPowerState`, `IgnitionState`, `PowerSource`, `Uptime`.
- **Сигналы из FSM-actions** (не из mock-флипа): `AccChanged`, `ShutdownImminent(sec, reason)`, `ShutdownAborted`.
`Sleep`/`Wake`**объявлены, не эмитятся** (sleep — v1/v2).
- **`PowerSource`:** `vehicle_12v` (норма) → на under-voltage/commit сигналим `holdup_cap`/`low_battery` (потребителям
«времени мало»). `sleep_rail` — v1/v2.
- **dev-mock `ru.shturman.dev.PowerMock1` (fake-ACC, фича `dev-mocks`) — кормит входы FSM:**
- `SetAcc(on)``AccOn`/`AccOff`;
- `SetIgnition(state)``EngineOn`/`EngineOff` (accessory↔running) либо `AccOn`/`AccOff`;
- `TriggerShutdown(sec, reason)``UnderVoltage`/`ThermalTrip` с заданным grace;
- `AbortShutdown()` → re-power (`AccOn`) в abortable.
Прод-сборка mock не регистрирует (#3-гейт §3). Policy `send_destination=ru.shturman.Power` покрывает (foundation §13).
---
## 8. Watchdog / монотоника / save-time
- **Watchdog (B05/A14):** drop-in `shturman-power.service.d/watchdog.conf``RuntimeWatchdogSec=` (дисциплина: один
userspace-владелец WDT). System `systemd/system.conf.d` `RebootWatchdogSec=` — дедлайн shutdown-фазы (зависание в
unmount/sync не оставит устройство под питанием). **В VM `/dev/watchdog` нет → конфиг присутствует, реальный арминг —
HW** (VM↔HW-граница, как zram/vcan в v0.6). MCU-backstop — v0.4.
- **Монотоника (§8):** `Uptime` + grace-таймер + sleep/wake-таймеры на `CLOCK_MONOTONIC` (`monotonic_secs`). Wall-clock
легитимно прыгает на NTP/GPS-синке — lifecycle на него не завязан.
- **Save last-known-time (B07):** `shturman-savetime.service` (`fake-hwclock save` с `FILE=/data/...`, как в v0.6) +
`.timer` (~15 мин, monotonic). On-shutdown save — шаг 3 §6. После срыва часы откатываются максимум на интервал.
---
## 9. Dev-харнесс и план тестирования
### 9.1 Unit (FSM — `fsm.rs`)
Каждый переход §5: `Off→Accessory→Running`; `Running→ShuttingDown{abortable,reason}` для каждого reason; abort
(`Abortable+AccOn→Running`+`ShutdownAborted`); `GraceExpired→Committed`+`Commit`; `Committed` — abort игнорируется;
`Sleep`/`BatteryCutoff` — no-op. Действия проверяются по возвращаемому `Vec<Action>`.
### 9.2 Integration (session-шина, `#[ignore]`, `just test-integration`)
`SetAcc(false)` → наблюдаем `ShutdownImminent`; `AbortShutdown` (в abortable) → `ShutdownAborted`; `SetIgnition`
`IgnitionState` property; `GetPowerState` отражает FSM.
### 9.3 E2E (Lima, гибрид — расширение `run.sh`)
- **N=3 in-VM цикла зажигания:** записать `/data`-маркер (Settings `ui.theme=night` + счётчик `/data/state/power-cycles`);
цикл: fake-ACC-off → наблюдать `ShutdownImminent` → харнесс: stop `shturman-stage1.target` + `sync` + `umount /data`
`mount /data` → restart → **маркер цел, счётчик++**. После 3 циклов: night + счётчик=3.
- **1 реальный reboot-цикл:** fake-ACC-off → commit → `systemctl poweroff``limactl start` → boot → `/data` цел.
- **Abort до PONR:** fake-ACC-off → `ShutdownImminent` → fake-ACC-on **до unmount**`ShutdownAborted` наблюдаем →
`/data` RW (смонтирован) → `GetPowerState=running`.
- **Power-cut-сим:** во время shutdown (до fsync) `SIGKILL` power+settings → `/data`: remount ok, `fsck -n` clean,
последнее durable-значение присутствует (атомарность §9.1 на уровне файла).
- **Монотоника:** `Uptime` растёт; не прыгает при wall-clock-синке.
- **Watchdog/save-time:** drop-in `RuntimeWatchdogSec` у power.service присутствует; `shturman-savetime.timer` активен;
`/data/state/fake-hwclock.data` обновляется.
### 9.4 Критерии приёмки
- [ ] FSM: все переходы §5 покрыты unit-тестами; `sleep`/`battery_cutoff` — no-op/документированы.
- [ ] `ShutdownImminent` на ACC-off; **abort до PONR → `ShutdownAborted`**; commit только после grace + durable-barrier.
- [ ] **N=3 цикла зажигания — `/data` + счётчик целы** (нет потери).
- [ ] 1 реальный reboot-цикл — `/data` цел.
- [ ] power-cut-сим — `/data` консистентен (`fsck -n` clean, last value present).
- [ ] `Uptime` монотонен; lifecycle-таймеры на `CLOCK_MONOTONIC`.
- [ ] watchdog-конфиг (`RuntimeWatchdogSec`/`RebootWatchdogSec`) на месте; `savetime.timer` активен.
- [ ] **Регресс v0.1/v0.2** (foundation §9.4 + v0.2 §9.3) зелёный (фазы/кадр/персист не сломаны).
- [ ] `just ci` зелёный; красные линии целы (нет CAN/actuator); **prod-build-gate** (`--no-default-features`
нет `PowerMock1`) зелёный.
---
## 10. Двунаправленные швы (синхронизировать при реализации)
- **`domain B`:** пометить реализованные срезы B01–B07 (v0.3, VM-модель); abort/PONR в VM = stop+umount+remount;
HW (MCU/hold-up/heartbeat/`safe-to-cut`/fail-safe-таймер) + выбор **B08/B09****v0.4**.
- **`ipc.md` §3:** Power-сигналы/состояние оживлены из FSM (не mock); `Sleep`/`Wake` зарезервированы.
- **`foundation §5.2`:** «Power-стаб» → **реальный FSM** (обновить формулировку «стартует в running, без логики»).
- **`hardware §3` / `B §5`:** B08/B09 (MCU vs supercap-only) остаётся открытым 🟡 → v0.4.
- **`CLAUDE.md`:** статус v0.3 готово → следующее v0.4/v0.5.
---
## 11. Дальше по ритму
`v0.3` (эта спека) → **writing-plans****TDD** (FSM-юниты → сервис-обёртка → durable-barrier/grace → systemd/save-time
→ E2E-блок) → реализация → **verify в Lima** → коммит. Далее: `v0.4` (MCU/thermal — замыкает B08/B09) после v0.3;
`v0.5` (полный shell) параллельно.
+291
View File
@@ -0,0 +1,291 @@
# Спека реализации: v0.4 — MCU/thermal fail-safe (тепловой триггер + MCU-протокол + fail-safe-таймер)
> Веха `v0.4` роадмапы: «аппаратный фундамент питания/тепла». Capabilities **A12** (тепловой мониторинг +
> базовый throttling), **B08** (MCU-копилот shutdown-протокол), **B09** (MCU аппаратный fail-safe-таймер),
> **B10** (thermal shutdown: триггер + hysteresis + UX). Поверх **v0.3** (живой `PowerFsm` + graceful shutdown).
> Источники: `docs/domains/b-power-lifecycle.md` §4/§5/§6/§1a, `docs/contracts/hardware.md` §3/§1a,
> `docs/contracts/ipc.md` §3, `docs/contracts/safety.md`, `docs/roadmap.md` §v0.4. Приёмка роадмапы:
> **«thermal-trip → graceful; MCU-таймер режет питание, если SoC завис»**.
>
> **Решение скоупа (брейнсторм):** разрабатываем без платы (принцип #13). MCU-копилот принят как
> **reference-архитектура** (доки рек.); делаем **софт + симуляцию**, физический выбор **B08/B09**
> (MCU vs supercap-only) и реальное железо — отложены в **HW-bring-up-подфазу**. Симметрия с v0.3:
> чистое ядро (политика/кодек) → абстракция (trait) → dev-mock.
---
## 1. Цель и первый артефакт
Замкнуть **тепловой** и **MCU**-швы домена B поверх живого FSM v0.3: (а) тепловой монитор с гистерезисом
кормит существующий `Event::ThermalTrip` → graceful shutdown; (б) SoC↔MCU shutdown-протокол (heartbeat /
`safe-to-cut` / wait-for-completion) с защищённым кодеком; (в) модель **независимого fail-safe-таймера**
MCU детерминированно «режет питание», если SoC завис.
**Первый артефакт (в Lima, мок-MCU/sensor):**
1. **thermal-trip:** `SetTemp ≥ critical``ThermalTrip``ShutdownImminent(thermal)` → graceful (реюз v0.3-пути,
`/data` консистентен); восстановление по гистерезису.
2. **MCU fail-safe:** `HangSoc` (heartbeat пропал в `running`) → мок-MCU детерминированно снимает питание
(в VM = форс-`off` сервиса) — «MCU режет питание, если SoC завис»; `/data` консистентен.
3. **throttling:** `SetTemp` в warn/throttle-банде → throttle-действие записано (VM `Noop`), без shutdown;
гистерезис на спаде (нет осцилляции).
**Не цель v0.4:** реальный UART/I2C-драйвер, реальный cpufreq-эффект, прошивка MCU, физический B09-чип/supercap,
полный supercap-only-путь (остаётся абстракцией-fallback), thermal-рендер в Shell (**v0.5**), sleep/wake/
battery-cutoff (**v1/v2**), числовой тюнинг порогов/hold-up — **RK3588**. Перф/тепловой вердикт — на таргете.
---
## 2. Скоуп и границы
### 2.1 В скоупе (делаем сейчас)
- **Тепловая политика (A12/B10):** чистый `ThermalPolicy` — `temp + предыдущий уровень → ThermalLevel
∈ {Normal, Warn, Throttle(n), Critical}` с **гистерезисом** (раздельные пороги вверх/вниз — нет осцилляции).
Юнит-тестируемый без I/O. Пороги — placeholder-константы (`// тюнинг на RK3588`, hardware §1a).
- **Источник температуры (`TempSource` trait):** real = sysfs `/sys/class/thermal/thermal_zone*/temp` (max по
зонам); VM = `MockTempSource` (значение из dev-D-Bus `SetTemp`). В v0.4 активен mock.
- **Throttler (`Throttler` trait):** real = cpufreq-cap (best-effort, HW); VM = `NoopThrottler` (запись уровня
для E2E/лог). Эффект — абстракция; реальное снижение частоты — HW.
- **Thermal-монитор:** периодический poll на **монотонике** → политика → throttle + на `Critical` кормит
`Event::ThermalTrip` в FSM (тот же `apply_event` из v0.3). Восстановление до PONR → `Event::ThermalCleared`
(abort thermal-shutdown, симметрия с re-power; гейт по `reason == Thermal`).
- **SoC↔MCU протокол (B08):** типы сообщений `SocToMcu`/`McuToSoc`; **чистый кодек** (framing + seq + CRC16 +
**replay/desync-guard**) — закрывает требование «защита линка от replay/мусора/десинка» (B §5, hardware §4).
Байты текут через in-memory-канал (codec исполняется по-настоящему и в integration).
- **Coprocessor (`Coprocessor` trait):** real = `SerialCoprocessor` (UART — **стаб**, HW-подфаза); VM =
`MockCoprocessor` (in-process, кормится через dev-D-Bus).
- **SoC-side `CoprocessorClient`:** heartbeat в `running`; на `ShutdownImminent` → **wait-for-completion**
(расширенный таймаут ≥ shutdown-бюджет, B §6) — не короткий keepalive посреди unmount; `safe-to-cut` после
PONR → немедленный cut. MCU — **fail-safe-авторитет** (SoC не командует cut-на-ходу — B §5).
- **Fail-safe-таймер (B09) — модель:** `MockCoprocessor` моделирует независимый таймер: heartbeat пропал
(`running`) ИЛИ бюджет истёк без `safe-to-cut` → детерминированный cut (в VM = форс-`off` power-сервиса).
- **D-Bus-контракт (ipc §3):** property `ThermalState ∈ {normal, warn, throttle, critical}` + сигнал
`ThermalChanged(state, celsius)`; `ShutdownImminent(thermal)` уже есть. **Контракт сейчас — Shell рисует в v0.5.**
- **Харнесс:** юниты (политика/кодек/клиент/таймер) + integration (session-шина) + E2E-блок v0.4 (§9).
### 2.2 Явно НЕ в скоупе (отложено, с указателем «куда»)
- **Физический выбор B08/B09** (MCU-копилот vs supercap-only), реальный MCU-чип, прошивка, реальный
hold-up cap/supercap, реальный UART/I2C-драйвер + GPIO ACC-детект — **HW-bring-up-подфаза** (hardware §3).
- **Реальный cpufreq/DDR/GPU-throttling-эффект + числовые тепловые пороги/дератинг****RK3588** (hardware §1a).
- **Thermal-UX рендер в Shell** («перегрев»-overlay) — **v0.5** (живой shell; v0.4 даёт только контракт).
- **Supercap-only полный путь** (SoC-таймер + разряд cap, ACC-детект в софте) — остаётся **абстракцией-fallback**
(`Coprocessor` trait), тело — HW-подфаза при выборе supercap.
- **Sleep/wake/scheduled-wake/battery-cutoff****v1/v2** (B §7); состояния зарезервированы (как в v0.3).
- **Реальный `/dev/watchdog` арминг + MCU-watchdog-бэкстоп железом****HW** (в v0.4 — дисциплина/модель, как v0.3).
- **Перф-вердикт** (время до cut, hold-time, тепловая инерция) — **RK3588** (performance §2). В VM — функционально.
### 2.3 Частично в скоупе (каркас сейчас, тело — позже)
- **`Throttler`** — trait + `Noop` (лог уровня); реальный cpufreq — HW.
- **`Coprocessor`** — trait + `MockCoprocessor`; `SerialCoprocessor` (UART) — стаб, HW-подфаза.
- **`TempSource`** — trait + `MockTempSource`; `SysfsTempSource` — каркас (читает зоны, в Lima зоны статичны).
- **MCU-watchdog/линк-fail-safe** — логика клиента + модель таймера; реальный независимый чип — HW.
### 2.4 Трассируемость ID → статус
| ID | Веха | Статус в v0.4 |
|----|------|----------------|
| A12 | Тепловой мониторинг + базовый throttling | `ThermalPolicy` + `TempSource`/`Throttler` (VM mock/noop); пороги placeholder, эффект cpufreq — HW |
| B08 | MCU-копилот shutdown-протокол | типы + кодек (CRC/replay/desync) + `CoprocessorClient` (heartbeat/wait/safe-to-cut); транспорт = in-memory (UART — HW) |
| B09 | MCU аппаратный fail-safe-таймер | **модель** в `MockCoprocessor` (hang/budget → детерминированный cut); реальный независимый чип — HW |
| B10 | Thermal shutdown (триггер + hysteresis + UX) | триггер `ThermalTrip` (реюз FSM v0.3) + гистерезис + abort `ThermalCleared`; **UX-рендер — v0.5** (контракт-property/сигнал сейчас) |
---
## 3. Красные линии, безопасность
- **MCU/Power — только питание устройства, не CAN/actuator (#1/#2):** копроцессор мониторит зажигание/напряжение
и коммутирует **рейл питания SoC** — никаких узлов авто, никаких write/actuator-путей. Протокол SoC↔MCU не
несёт автомобильных команд. (Engine-state/OBD — домен E, read-only; Power **не трогает CAN**.)
- **MCU — fail-safe-авторитет (B §5):** ни одно SoC-сообщение не может (а) снять питание на ходу, (б) держать
вечно и разрядить АКБ. В модели `MockCoprocessor` cut инициируется **только** таймером MCU (hang/budget) или
`safe-to-cut` после PONR — не произвольной SoC-командой.
- **Защита линка:** кодек отбрасывает replay (seq ≤ last)/мусор (битый CRC)/десинк (resync по SYNC) —
юнит-доказано (§9.1). Аналог защиты CAN-линка (hardware §4).
- **Durability-инвариант v0.3 сохраняется:** thermal-trip и MCU-cut идут через тот же graceful-путь до PONR
(durable-barrier `sync` до unmount); после усечённого shutdown `/data` консистентен (atomic-write A §3).
- **prod-build-gate:** `--no-default-features` (без `dev-mocks`) → `PowerMock1`/`SetTemp`/`HangSoc` не
регистрируются (как в v0.3). dev-D-Bus-policy — dev-only drop-in.
---
## 4. Раскладка (новые/изменённые артефакты)
- **Create** `crates/core/shturman-power/src/thermal.rs``ThermalLevel`, `ThermalPolicy` (чистая, гистерезис),
`TempSource` trait (`SysfsTempSource`/`MockTempSource`), `Throttler` trait (`Cpufreq`-стаб/`NoopThrottler`),
`ThermalMonitor` (poll → политика → throttle + `ThermalTrip`/`ThermalCleared`).
- **Create** `crates/core/shturman-power/src/protocol.rs``SocToMcu`/`McuToSoc` (типы сообщений).
- **Create** `crates/core/shturman-power/src/codec.rs` — кадр (SYNC/LEN/SEQ/TYPE/PAYLOAD/CRC16) + encode/decode +
replay/desync-guard. Юнит-тесты в файле.
- **Create** `crates/core/shturman-power/src/coprocessor.rs``Coprocessor` trait, `MockCoprocessor` (in-process +
B09-таймер-модель), `SerialCoprocessor` (UART-стаб), `CoprocessorClient` (SoC-side: heartbeat/wait/safe-to-cut).
- **Modify** `crates/core/shturman-power/src/fsm.rs``Event::ThermalCleared` (+ переход abort из
`ShuttingDown{Abortable, reason: Thermal}``Running`); `Event::FailsafeCut` (→ `off` из любого не-`off`,
необратимо — MCU-авторитет); подтвердить армы `ThermalTrip`.
- **Modify** `crates/core/shturman-power/src/service.rs` — владеть `ThermalMonitor` + `CoprocessorClient` (кормят
FSM); property `ThermalState` + сигнал `ThermalChanged`; dev-mock расширить: `SetTemp(d)`, `HangSoc()`,
`McuLinkLoss()`.
- **Modify** `crates/core/shturman-power/src/lib.rs``pub mod thermal; pub mod protocol; pub mod codec; pub mod coprocessor;`.
- **Modify** `crates/shturman-ipc/src/proxy.rs``Power1Proxy`: property `ThermalState` + сигнал `ThermalChanged`.
- **Modify** `crates/core/shturman-power/tests/integration.rs` — thermal-trip / abort / fail-safe-cut по session-шине.
- **Modify** `tests/e2e/run.sh` — блок v0.4 (thermal-trip → graceful; MCU fail-safe; throttling/гистерезис).
- **Modify (швы §10)** `docs/domains/b-power-lifecycle.md`, `docs/contracts/hardware.md`, `docs/contracts/ipc.md` §3,
`docs/capability-catalog.md` (A12/B08/B09/B10), `CLAUDE.md`.
---
## 5. Тепловая подсистема (A12/B10) — контракт
```
ThermalLevel = Normal | Warn | Throttle(level: u8) | Critical
```
**Пороги (placeholder-константы, тюнинг на RK3588 — hardware §1a; Tjmax RK3588 ~100 °C):**
| Переход | Порог вверх | Порог вниз (гистерезис) |
|---------|-------------|--------------------------|
| Normal → Warn | `WARN_C = 75` | `WARN_C HYST` |
| Warn → Throttle | `THROTTLE_C = 85` | `THROTTLE_C HYST` |
| Throttle → Critical | `CRITICAL_C = 95` | `CRITICAL_C HYST` |
| `HYST = 5 °C` | | |
- **`ThermalPolicy::next(prev: ThermalLevel, temp_c) -> ThermalLevel`** — чистая; гистерезис = переход вниз
только ниже `(порог HYST)`, иначе уровень держится (нет осцилляции на границе).
- **`ThermalMonitor`** (tokio-интервал на монотонике, ~`POLL_SECS = 1`): `temp = TempSource::read()`
`lvl = policy.next(prev, temp)`; при смене уровня: `Throttler::apply(lvl)` + `ThermalChanged(state, temp)`;
**на входе в `Critical`**`apply_event(Event::ThermalTrip)`; **на выходе из `Critical`** (по гистерезису),
если FSM ещё в `ShuttingDown{Abortable, reason: Thermal}``apply_event(Event::ThermalCleared)`.
- **`ThermalState` (D-Bus property)** = проекция текущего `ThermalLevel` (`Throttle(_)``"throttle"`).
---
## 6. SoC↔MCU протокол (B08) + fail-safe-таймер (B09) — контракт
### 6.1 Сообщения
```
SocToMcu = Heartbeat { seq } // периодический keepalive в running/accessory
| ShutdownImminent { budget } // вход в shutdown → MCU в wait-for-completion (таймаут ≥ budget)
| SafeToCut // после PONR → MCU снимает питание немедленно
McuToSoc = Ack { seq }
| Acc { on } // дебаунснутый ACC (источник зажигания; в VM кормит FSM AccOn/AccOff)
| Voltage { mv } // напряжение бортсети (под under-voltage backstop)
| CutWarning // бюджет почти истёк (диагностика)
```
### 6.2 Кодек (`codec.rs`)
- **Кадр:** `[SYNC=0xA5][LEN u8][SEQ u8][TYPE u8][PAYLOAD…][CRC16-CCITT]`, CRC по `LEN..=PAYLOAD`.
- **Replay/dup guard:** приёмник держит `last_seq` на направление; кадр с `seq ≤ last_seq` (в окне) — **drop**.
- **Desync/мусор:** битый CRC или нет SYNC → **resync** (скан до следующего `SYNC`), кадр отброшен.
- Юнит-тесты: round-trip всех типов; corruption (флип бита → drop); replay (повтор seq → drop); desync
(мусор перед SYNC → восстановление на следующем валидном кадре).
### 6.3 SoC-side `CoprocessorClient`
- В `running`/`accessory`: `Heartbeat{seq++}` каждые `HEARTBEAT_SECS` (монотоника); ждёт `Acc`/`Voltage` от MCU
→ кормит FSM (`AccOn`/`AccOff`/`UnderVoltage`).
- На `ShutdownImminent` (FSM вошёл в shutdown): шлёт `SocToMcu::ShutdownImminent{budget}` → переходит в
**wait-for-completion** (heartbeat останавливается, ждёт завершения секвенсинга; таймаут ≥ shutdown-бюджет, B §6).
- После PONR (commit): `SafeToCut` → MCU режет немедленно.
- **Не** шлёт power-команд с эффектом cut-на-ходу (red-line §3).
### 6.4 Fail-safe-таймер (B09) — модель в `MockCoprocessor`
- **Hang-детект:** нет `Heartbeat` дольше `FAILSAFE_MISS × HEARTBEAT_SECS` в `running` → SoC завис → cut.
- **Budget-таймер:** после `ShutdownImminent` без `SafeToCut` за `HOLDUP_BUDGET_SECS` → cut (детерминированно).
- **Cut (в VM):** мок-MCU зовёт `apply_event(Event::FailsafeCut)` → FSM → `off` (необратимо, MCU-авторитет) +
лог «MCU cut». В E2E дополнительно реюзаем v0.3 power-cut (SIGKILL до fsync) для durability-доказательства
(`fsck` clean, durable-value цел). Это **VM-модель**: реальный зависший SoC теряет питание извне, в модели
cut = событие не-реально-зависшего процесса (симметрично v0.3 «abort/PONR = stop+umount+remount»).
- Значения (`HEARTBEAT_SECS=1`, `FAILSAFE_MISS=3`, `HOLDUP_BUDGET_SECS` ~ grace+запас) — **placeholder**
(реальный hold-up sizing — hardware §3, RK3588).
---
## 7. D-Bus `ru.shturman.Power` — v0.4 расширяет
- **+ Property `ThermalState: s`** ∈ `{normal, warn, throttle, critical}` (+ `PropertiesChanged`).
- **+ Сигнал `ThermalChanged(s state, i celsius)`** — для Shell (рендер «перегрев» — **v0.5**).
- `ShutdownImminent(u seconds, s reason)``reason=thermal` уже объявлен (ipc §3); v0.4 его **реально эмитит**
на thermal-trip.
- **dev-mock `ru.shturman.dev.PowerMock1` (feature `dev-mocks`)** дополняется:
- `SetTemp(i celsius)` → кормит `MockTempSource` → монитор → политика.
- `HangSoc()` → останавливает heartbeat → провоцирует B09-таймер.
- `McuLinkLoss()` → тишина линка: **SoC-сторона деградирует** (лог/маркер degraded, **не** self-cut — red-line §3).
MCU-сторонняя политика cut-vs-hold при тишине — **B §12-open → HW**.
- Прод-гейт `--no-default-features` — не регистрируются (как v0.3 §3).
---
## 8. Watchdog / монотоника (реюз v0.3)
- Все новые таймеры (poll, heartbeat, wait-for-completion, fail-safe, hold-up) — на **`CLOCK_MONOTONIC`**
(`shturman_common::monotonic_secs`), НЕ wall-clock (B §8).
- MCU-watchdog/линк-fail-safe — **логика клиента + модель** (реальный независимый чип/арминг — HW). systemd
`RuntimeWatchdogSec`/`RebootWatchdogSec` — уже из v0.3 (новых юнитов не требуется; thermal/coprocessor живут
внутри `shturman-power.service`).
---
## 9. Dev-харнесс и план тестирования
### 9.1 Unit (чистые модули)
- **`thermal.rs`:** банды Normal/Warn/Throttle/Critical; **гистерезис** (нет осцилляции на границе ±HYST);
`ThermalTrip` на входе в Critical, `ThermalCleared` на выходе.
- **`codec.rs`:** round-trip всех типов; corruption→drop; replay(seq)→drop; desync→resync.
- **`coprocessor.rs`:** клиент heartbeat→wait-for-completion→safe-to-cut; B09-таймер (hang→cut, budget→cut);
MCU игнорит небезопасные SoC-команды (red-line).
- **`fsm.rs`:** `ThermalCleared` abort только из `ShuttingDown{Abortable, reason: Thermal}` (из `AccOff`-shutdown —
no-op); committed — no-op. `FailsafeCut``off` из любого не-`off` (необратимо).
### 9.2 Integration (session-шина, `#[ignore]`, `just test-integration`)
- `SetTemp ≥ critical` → наблюдаем `ShutdownImminent(thermal)` + `ThermalChanged(critical)`; state `shutting_down`.
- `SetTemp` recovery до grace → `ShutdownAborted` (через `ThermalCleared`) + `running`.
- `HangSoc` → наблюдаем fail-safe-cut (state → off / forced-off).
### 9.3 E2E (Lima, гибрид — расширение `run.sh`, после блока power-safe v0.3)
- **thermal-trip:** `SetTemp ≥ critical``ShutdownImminent(thermal)` → graceful (реюз v0.3: stop→umount(PONR)→
remount) → `/data` консистентен; затем `SetTemp` норма → `running`.
- **MCU fail-safe:** `HangSoc` (heartbeat пропал) → мок-MCU режет питание (наблюдаем forced-off /
SIGKILL-эквивалент) → `fsck` clean, durable-value цел (как v0.3 power-cut).
- **throttling/гистерезис:** `SetTemp` в warn/throttle → `ThermalState` меняется, throttle записан, **без**
shutdown; спад чуть выше нижнего порога — уровень держится (нет осцилляции).
- **Регресс v0.1/v0.2/v0.3 зелёный**; machine-id стабилен; `E2E OK ✅`.
### 9.4 Критерии приёмки (роадмапа + спека)
- [ ] thermal-trip → graceful (`ShutdownImminent(thermal)`→commit→`/data` консистентен); гистерезис — нет осцилляции.
- [ ] MCU fail-safe-таймер: SoC-hang/бюджет → **детерминированный cut** (модель); `/data` консистентен.
- [ ] Throttling-политика по бандам применена (запись в VM; числа — RK3588).
- [ ] Кодек: replay/desync/corruption отбиты (unit).
- [ ] `ThermalState`/`ThermalChanged` на шине; `Uptime`/таймеры монотонны.
- [ ] Регресс v0.1–v0.3 зелёный; `just ci` зелёный; prod-build-gate (нет `PowerMock1`/`SetTemp`); красные линии целы
(MCU/Power — только питание, нет CAN/actuator).
---
## 10. Двунаправленные швы (синхронизировать при реализации)
- **`domain B`:** §4 (thermal-trip-путь реализован), §5 (MCU shutdown-протокол + кодек + клиент — софт/модель;
физический MCU/fail-safe-чип/supercap → HW-подфаза), §6 (wait-for-completion реализован), §1a (тепловые пороги —
placeholder, тюнинг RK3588). Пометить **A12/B08/B09/B10** реализованными (VM-модель).
- **`hardware §3`/`§1a`:** **B08/B09** физический выбор (MCU vs supercap-only) остаётся **🟡 → HW-bring-up-подфаза**;
тепловой конверт/класс/охлаждение — 🟡 (числа на таргете).
- **`ipc.md §3`:** Power + `ThermalState`/`ThermalChanged`; `ShutdownImminent(thermal)` реально эмитится.
- **`capability-catalog`:** A12 ✅ (политика+абстракция), B10 ✅ (триггер+гистерезис; UX→v0.5), B08/B09 — софт/модель
реализованы, физический выбор 🟡 → HW.
- **`CLAUDE.md`:** статус v0.4 (после реализации) → следующее v0.5 shell.
---
## 11. Дальше по ритму
`v0.4` (после утверждения спеки) → **План 8** (`docs/specs/plans/08-v0.4-mcu-thermal.md`, writing-plans) → TDD →
verify в Lima → коммиты `feat/v0.4-mcu-thermal`. Затем **v0.5 — полный shell** (живой weston-shell; замкнёт
thermal-UX-рендер). Физический **HW-bring-up** (MCU/supercap выбор, реальный UART/cpufreq/B09-чип, тепловой
вердикт) — отдельной подфазой при появлении RK3588-платы.
+36 -12
View File
@@ -22,9 +22,10 @@ lint:
deny:
cargo deny check
# интеграционные тесты на session-шине (нужен dbus: brew install dbus / в Lima)
# интеграционные тесты на session-шине (нужен dbus: brew install dbus / в Lima).
# --test-threads=1: тесты владеют одними well-known именами на общей шине → серийно (иначе кросс-talk/вис).
test-integration:
dbus-run-session -- cargo test --workspace -- --ignored
dbus-run-session -- cargo test --workspace -- --ignored --test-threads=1
# полный локальный гейт
ci: lint test deny
@@ -60,9 +61,9 @@ sim:
# --- Lima-VM (часть 2 Плана 5: нужен limactl — brew install lima) ---
# поднять dev-VM (создание + провижининг)
# поднять dev-VM (создание + провижининг). --tty=false: неинтерактивный старт (без редактора YAML).
vm-up:
limactl start --name=shturman lima/shturman.yaml
limactl start --tty=false --name=shturman lima/shturman.yaml
# остановить VM
vm-down:
@@ -76,16 +77,39 @@ vm-shell:
vm-reset:
-limactl stop shturman
-limactl delete shturman
limactl start --name=shturman lima/shturman.yaml
limactl start --tty=false --name=shturman lima/shturman.yaml
# собрать + развернуть + поднять target в VM (boot → сервисы → кадр)
# собрать + развернуть + поднять target в VM (boot → сервисы → кадр); без reboot (фаза pre)
run:
limactl shell shturman -- bash -lc 'cd /shturman && bash tests/e2e/run.sh'
limactl shell --workdir /shturman shturman -- bash -lc 'E2E_PHASE=pre bash tests/e2e/run.sh'
# сквозной E2E в VM (приёмка v0.1/v0.6 + шагающий скелет)
# сквозной E2E в VM (приёмка v0.1/v0.6 + шагающий скелет): pre → reboot → post
e2e:
limactl shell shturman -- bash -lc 'cd /shturman && bash tests/e2e/run.sh'
#!/usr/bin/env bash
set -euo pipefail
echo "== E2E фаза PRE (сборка → подъём → проверки → персист-проба) =="
limactl shell --workdir /shturman shturman -- bash -lc 'E2E_PHASE=pre bash tests/e2e/run.sh'
echo
echo "== reboot VM (проверка персиста + machine-id every-boot bind) =="
limactl shell --workdir / shturman -- sudo systemctl reboot 2>/dev/null || true
echo "== ждём возврат VM =="
for i in $(seq 1 60); do
sleep 2
if limactl shell --workdir / shturman -- true 2>/dev/null; then echo "VM вернулась (попытка $i)"; break; fi
done
limactl shell --workdir / shturman -- bash -lc 'for i in $(seq 1 30); do systemctl is-active --quiet shturman.target && break; sleep 1; done' 2>/dev/null || true
echo
echo "== E2E фаза POST (персист после reboot) =="
limactl shell --workdir /shturman shturman -- bash -lc 'E2E_PHASE=post bash tests/e2e/run.sh'
echo
echo "== E2E OK ✅ =="
# ручная проверка кадра: на хосте — окно Slint (headless PNG-screenshot — часть 2/Lima)
shell-frame:
cargo run -p shturman-shell
# ручная инспекция кадра: headless software-render первого кадра → PNG (без дисплея/композитора, §6)
shell-frame path="target/shell-frame.png":
cargo run -q -p shturman-shell -- --screenshot {{path}}
@echo "кадр записан: {{path}}"
# инспекция splash-кадра (Stage 0): headless software-render → PNG
splash-frame path="target/splash-frame.png":
cargo run -q -p shturman-splash -- --screenshot {{path}}
@echo "splash записан: {{path}}"
+21 -5
View File
@@ -26,9 +26,14 @@ provision:
apt-get install -y \
dbus pipewire wireplumber weston \
can-utils python3 python3-venv \
systemd-zram-generator fake-hwclock \
systemd-zram-generator systemd-oomd fake-hwclock \
fonts-dejavu-core fonts-noto-core \
build-essential pkg-config curl
build-essential pkg-config curl \
libfontconfig1-dev libxkbcommon-dev libwayland-dev
# zram/vcan-модули не входят в базовый vz-образ Lima → доустановить linux-modules-extra
# (иначе zram-generator падает). vcan всё равно может отсутствовать — честная VM↔HW-граница.
apt-get install -y "linux-modules-extra-$(uname -r)" || true
# vcan (для Vehicle Simulator, v2 — поднимаем заранее для воспроизводимости)
modprobe vcan || true
@@ -42,8 +47,9 @@ provision:
mkfs.ext4 -q -L shturman-data /var/lib/shturman/data.img
fi
# systemd-юниты + конфиги из репозитория
# systemd-юниты + конфиги из репозитория (зонтик + 3 фазовых таргета; *.service ловит splash/warmup)
install -m644 /shturman/systemd/shturman.target /etc/systemd/system/
install -m644 /shturman/systemd/shturman-stage0.target /shturman/systemd/shturman-stage1.target /shturman/systemd/shturman-stage2.target /etc/systemd/system/
install -m644 /shturman/systemd/data.mount /etc/systemd/system/
install -m644 /shturman/systemd/shturman-*.service /etc/systemd/system/
install -d /etc/dbus-1/system.d
@@ -53,12 +59,22 @@ provision:
install -m644 /shturman/systemd/zram-generator.conf /etc/systemd/zram-generator.conf
install -d /etc/systemd/oomd.conf.d
install -m644 /shturman/systemd/oomd-shturman.conf /etc/systemd/oomd.conf.d/shturman.conf
# /run/shturman (кадры/маркеры, volatile) — tmpfiles на boot
install -d /etc/tmpfiles.d
install -m644 /shturman/systemd/tmpfiles-shturman.conf /etc/tmpfiles.d/shturman.conf
systemd-tmpfiles --create /etc/tmpfiles.d/shturman.conf || true
# watchdog (B05/A14, system.conf.d) + save-time .timer (B07; .service ловит *.service glob выше)
install -d /etc/systemd/system.conf.d
install -m644 /shturman/systemd/watchdog-shturman.conf /etc/systemd/system.conf.d/shturman-watchdog.conf
install -m644 /shturman/systemd/shturman-savetime.timer /etc/systemd/system/
# fake-hwclock → /data (не на rootfs; A07/A11)
# fake-hwclock → /data (не на rootfs; A07/A11). Сервис в Lima masked (Lima сам синхронит время) —
# на HW он размаскирован и читает FILE из /etc/default/fake-hwclock через EnvironmentFile.
echo 'FILE=/data/state/fake-hwclock.data' > /etc/default/fake-hwclock || true
rm -f /etc/fake-hwclock.data || true # стоковый файл на rootfs — A11: персист только в /data
systemctl daemon-reload
systemctl enable systemd-oomd.service || true
systemctl enable --now systemd-oomd.service || true # защита critical set от OOM (A09); политика — oomd.conf.d
# shturman.target включаем, но НЕ стартуем здесь — бинарей ещё нет (just run/e2e).
systemctl enable shturman.target || true
+1 -1
View File
@@ -11,4 +11,4 @@ RemainAfterExit=yes
ExecStart=/usr/local/bin/shturman-firstboot
[Install]
WantedBy=shturman.target
WantedBy=shturman-stage1.target
+1 -1
View File
@@ -13,4 +13,4 @@ RemainAfterExit=yes
ExecStart=/bin/sh -c '[ -e /etc/machine-id ] || : > /etc/machine-id; mount --bind /data/state/machine-id /etc/machine-id'
[Install]
WantedBy=shturman.target
WantedBy=shturman-stage1.target
+2 -2
View File
@@ -2,7 +2,7 @@
Description=Штурман Power (ru.shturman.Power1)
Requires=data.mount shturman-firstboot.service
After=data.mount shturman-firstboot.service shturman-machineid.service dbus.service
PartOf=shturman.target
PartOf=shturman-stage1.target
[Service]
ExecStart=/usr/local/bin/shturman-power
@@ -11,4 +11,4 @@ RestartSec=2
OOMScoreAdjust=-600
[Install]
WantedBy=shturman.target
WantedBy=shturman-stage1.target
+9
View File
@@ -0,0 +1,9 @@
[Unit]
Description=Штурман save last-known-time (fake-hwclock → /data, B07)
After=data.mount
Requires=data.mount
[Service]
Type=oneshot
# FILE из /etc/default/fake-hwclock (→ /data; v0.6). Сервис fake-hwclock в Lima masked → зовём напрямую с env.
ExecStart=/bin/sh -c '. /etc/default/fake-hwclock 2>/dev/null; FILE="${FILE:-/data/state/fake-hwclock.data}" fake-hwclock save'
+9
View File
@@ -0,0 +1,9 @@
[Unit]
Description=Штурман periodic save-time (B07)
[Timer]
OnBootSec=2min
OnUnitActiveSec=5min
[Install]
WantedBy=shturman-stage2.target
+2 -2
View File
@@ -3,7 +3,7 @@ Description=Штурман Settings (ru.shturman.Settings1)
# Requires+After firstboot: не стартуем против полу-провиженного /data (Wants недостаточно).
Requires=data.mount shturman-firstboot.service
After=data.mount shturman-firstboot.service shturman-machineid.service dbus.service
PartOf=shturman.target
PartOf=shturman-stage1.target
[Service]
ExecStart=/usr/local/bin/shturman-settings
@@ -13,4 +13,4 @@ RestartSec=2
OOMScoreAdjust=-600
[Install]
WantedBy=shturman.target
WantedBy=shturman-stage1.target
+10 -9
View File
@@ -1,17 +1,18 @@
[Unit]
Description=Штурман Shell (первый Slint-кадр)
Description=Штурман Shell первый Slint-кадр (software-render → PNG, §6)
Requires=data.mount shturman-firstboot.service
After=shturman-power.service shturman-settings.service shturman-machineid.service
PartOf=shturman.target
PartOf=shturman-stage1.target
[Service]
ExecStart=/usr/local/bin/shturman-shell
Restart=on-failure
RestartSec=2
# v0.6: headless software-render кадра в PNG (спека §6 — основной автотест кадра, композитор не нужен).
# oneshot+RemainAfterExit → is-active=active детерминированно, без хрупкого weston (живой weston-shell — v0.5).
# Кадр читает ui.theme/Power с системной шины (After=power/settings) и пишет в tmpfs /run (volatile, A11).
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/bin/shturman-shell --screenshot /run/shturman/frame.png
TimeoutStartSec=30
OOMScoreAdjust=-600
# Wayland-дисплей: provisioning/E2E поднимает weston headless (финализируется в части 2).
Environment=WAYLAND_DISPLAY=wayland-1
Environment=XDG_RUNTIME_DIR=/run/user/0
[Install]
WantedBy=shturman.target
WantedBy=shturman-stage1.target
+15
View File
@@ -0,0 +1,15 @@
[Unit]
Description=Штурман splash (Stage 0, software-render → PNG)
# «Мгновенно»: без Requires=data.mount/dbus — стартует рано, параллельно critical set.
# Before=shell гарантирует splash.png раньше frame.png. /run/shturman даёт tmpfiles.
After=systemd-tmpfiles-setup.service
Before=shturman-shell.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/bin/shturman-splash --screenshot /run/shturman/splash.png
TimeoutStartSec=15
[Install]
WantedBy=shturman-stage0.target
+3
View File
@@ -0,0 +1,3 @@
[Unit]
Description=Штурман Stage 0 — splash (мгновенно)
Wants=shturman-splash.service
+6
View File
@@ -0,0 +1,6 @@
[Unit]
Description=Штурман Stage 1 — ядро + первый кадр
Requires=data.mount
After=data.mount
# Члены critical set (v0.1). Ordering — в самих юнитах (After=/Requires=).
Wants=shturman-firstboot.service shturman-machineid.service shturman-power.service shturman-settings.service shturman-shell.service
+13
View File
@@ -0,0 +1,13 @@
[Unit]
Description=Штурман Stage 2 warmup (плейсхолдер фона)
# Деферред: после первого кадра. Каркас для реальных фоновых сервисов v1+
# (Vehicle-Data/Assistant/Media/Nav). Пишет маркер — E2E проверяет «фаза разделена».
After=shturman-shell.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/bin/sh -c 'echo "stage2 warmup" | systemd-cat -t shturman-stage2; : > /run/shturman/stage2.ready'
[Install]
WantedBy=shturman-stage2.target
+4
View File
@@ -0,0 +1,4 @@
[Unit]
Description=Штурман Stage 2 — фон (после интерактива)
After=shturman-stage1.target
Wants=shturman-stage2-warmup.service shturman-savetime.timer
+4 -4
View File
@@ -1,10 +1,10 @@
[Unit]
Description=Штурман — v0 critical set (Stage 1: ядро + первый кадр)
Description=Штурман — v0 boot-конвейер (зонтик фаз Stage 0/1/2)
Requires=data.mount
After=data.mount
# Тянем членов critical set: `systemctl enable shturman.target` НЕ каскадит на WantedBy-юниты,
# поэтому target должен явно Wants= их (ordering — в самих юнитах через After=).
Wants=shturman-firstboot.service shturman-machineid.service shturman-power.service shturman-settings.service shturman-shell.service
# Зонтик тянет три фазовых под-таргета. Порядок «splash → кадр → фон» — на уровне сервисов
# (splash Before=shell; warmup After=shell), не сериализацией таргетов (иначе critical set ждал бы splash).
Wants=shturman-stage0.target shturman-stage1.target shturman-stage2.target
[Install]
WantedBy=multi-user.target
+3
View File
@@ -0,0 +1,3 @@
# /run/shturman — volatile-каталог кадров/маркеров (splash.png, frame.png, stage2.ready). A11.
# Создаётся на boot до сервисов; splash/shell/warmup пишут сюда (tmpfs, не на flash).
d /run/shturman 0755 root root -
+6
View File
@@ -0,0 +1,6 @@
# Watchdog (B05/A14): systemd пингует HW-watchdog в runtime + дедлайн на shutdown-фазу.
# Установка: /etc/systemd/system.conf.d/shturman-watchdog.conf. В VM /dev/watchdog нет → дисциплина
# (реальный HW-арминг + MCU-backstop — v0.4, VM↔HW-граница как zram/vcan в v0.6).
[Manager]
RuntimeWatchdogSec=30s
RebootWatchdogSec=60s
+339 -35
View File
@@ -1,54 +1,358 @@
#!/usr/bin/env bash
# Сквозной E2E Штурмана в Lima-VM (приёмка v0.1/v0.6 + шагающий скелет, спека §9.3/§9.4).
# Запуск: just e2e (внутри VM через limactl shell). Системная шина устройства.
# Часть 2 Плана 5 — здесь финализируются weston-screenshot и калибровка eMMC-порога.
set -euo pipefail
# Запуск: just e2e (двухфазно через reboot) или just run (однофазно, без reboot).
#
# Фазы (env E2E_PHASE):
# pre — сборка → install бинарей/юнитов → старт shturman.target → проверки §9.3 (1–8)
# → выставить персист-пробу (Settings.Set + запомнить machine-id); [по умолчанию]
# post — после reboot: персист настройки сохранился + machine-id стабилен (every-boot bind, §9.3.4).
#
# Системная шина устройства (§5.1). dev-mocks включён дефолт-фичей сборки (fake-ACC).
set -uo pipefail
REPO=/shturman
cd "$REPO"
cd "$REPO" || { echo "нет $REPO"; exit 1; }
echo "== сборка =="
cargo build --release --workspace
sudo install -m755 target/release/shturman-firstboot /usr/local/bin/
sudo install -m755 target/release/shturman-settings /usr/local/bin/
sudo install -m755 target/release/shturman-power /usr/local/bin/
sudo install -m755 target/release/shturman-shell /usr/local/bin/
PHASE="${E2E_PHASE:-pre}"
IDLE_SECS="${E2E_IDLE_SECS:-20}" # окно простоя для eMMC-прокси (§7.5)
EMMC_MAX_SECTORS="${E2E_EMMC_MAX_SECTORS:-4096}" # порог T (🟡 калибруется; 4096 сект ≈ 2 МБ/окно)
FRAME=/run/shturman/frame.png
PROBE_KEY=ui.theme
PROBE_VAL=night
MID_BEFORE=/data/state/e2e-mid-before # снимок machine-id до reboot (персист в /data)
echo "== старт target =="
sudo systemctl daemon-reload
sudo systemctl start shturman.target
sleep 3
export PATH="$HOME/.cargo/bin:$PATH"
export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$HOME/.cache/shturman/target}"
pass() { echo "$*"; }
fail() { echo "E2E FAIL: $*" >&2; exit 1; }
info() { echo; echo "== $* =="; }
echo "== 1. /data смонтирован до сервисов, реальные опции =="
findmnt /data || fail "/data не смонтирован"
findmnt -no OPTIONS /data | grep -q errors=remount-ro || fail "нет errors=remount-ro"
# Имена на шине (зеркало crates/shturman-ipc/src/names.rs).
P_NAME=ru.shturman.Power; P_PATH=/ru/shturman/Power; P_IFACE=ru.shturman.Power1
P_MOCK=ru.shturman.dev.PowerMock1
S_NAME=ru.shturman.Settings; S_PATH=/ru/shturman/Settings; S_IFACE=ru.shturman.Settings1
echo "== 2. first-boot идемпотентен =="
test -f /data/.shturman-provisioned || fail "нет маркера provisioned"
test -f /data/state/machine-id || fail "нет machine-id"
settings_get() { busctl --system call "$S_NAME" "$S_PATH" "$S_IFACE" Get s "$1" 2>/dev/null; }
echo "== 3. per-unit critical set active (не довольствуемся degraded) =="
# =========================== POST-reboot фаза ===========================
if [ "$PHASE" = post ]; then
info "POST-reboot: персист настроек + machine-id стабилен (§9.3.4)"
# дождаться, пока сервисы поднимутся после автозагрузки target
for _ in $(seq 1 30); do systemctl is-active --quiet shturman-settings && break; sleep 1; done
findmnt /data >/dev/null || fail "/data не смонтирован после reboot"
pass "/data смонтирован после reboot"
# настройка пережила reboot
got=$(settings_get "$PROBE_KEY")
echo "$got" | grep -q "\"$PROBE_VAL\"" || fail "Settings.$PROBE_KEY != $PROBE_VAL после reboot (got: $got)"
pass "Settings.$PROBE_KEY = $PROBE_VAL пережил reboot"
# machine-id стабилен (every-boot bind из /data/state/machine-id)
sudo test -f "$MID_BEFORE" || fail "нет снимка $MID_BEFORE (фаза pre не отработала?)"
before=$(sudo cat "$MID_BEFORE"); now=$(cat /etc/machine-id)
[ -n "$now" ] || fail "/etc/machine-id пуст"
[ "$before" = "$now" ] || fail "machine-id изменился: было $before, стало $now"
src=$(sudo cat /data/state/machine-id)
[ "$now" = "$src" ] || fail "/etc/machine-id($now) != /data/state/machine-id($src) — bind не сработал"
pass "machine-id стабилен после reboot ($now), привязан из /data"
# journald volatile теперь естественно (drop-in присутствовал на boot)
test -d /run/log/journal && ! test -d /var/log/journal \
&& pass "journald volatile (/run/log/journal)" || echo " WARN: journald не строго volatile"
echo; echo "E2E POST OK ✅"
exit 0
fi
# ============================ PRE фаза ============================
info "сборка (release, VM-локальный target=$CARGO_TARGET_DIR)"
cargo build --release --workspace || fail "сборка"
for b in firstboot settings power shell splash; do
sudo install -m755 "$CARGO_TARGET_DIR/release/shturman-$b" /usr/local/bin/ || fail "install shturman-$b"
done
pass "бинари установлены в /usr/local/bin"
info "раскладка systemd-юнитов + dbus policy (из репо — подхватить правки)"
sudo install -m644 systemd/shturman.target systemd/data.mount \
systemd/shturman-stage0.target systemd/shturman-stage1.target systemd/shturman-stage2.target \
/etc/systemd/system/
sudo install -m644 systemd/shturman-firstboot.service systemd/shturman-machineid.service \
systemd/shturman-power.service systemd/shturman-settings.service \
systemd/shturman-shell.service systemd/shturman-splash.service \
systemd/shturman-stage2-warmup.service systemd/shturman-savetime.service \
/etc/systemd/system/
sudo install -d /etc/dbus-1/system.d
sudo install -m644 systemd/dbus/ru.shturman.conf /etc/dbus-1/system.d/
sudo install -d /etc/systemd/journald.conf.d /etc/systemd/oomd.conf.d
sudo install -m644 systemd/journald-shturman.conf /etc/systemd/journald.conf.d/shturman.conf
sudo install -m644 systemd/oomd-shturman.conf /etc/systemd/oomd.conf.d/shturman.conf
sudo install -m644 systemd/zram-generator.conf /etc/systemd/zram-generator.conf
sudo install -d /etc/tmpfiles.d
sudo install -m644 systemd/tmpfiles-shturman.conf /etc/tmpfiles.d/shturman.conf
sudo systemd-tmpfiles --create /etc/tmpfiles.d/shturman.conf || true
# watchdog (B05/A14) + save-time .timer (B07)
sudo install -d /etc/systemd/system.conf.d
sudo install -m644 systemd/watchdog-shturman.conf /etc/systemd/system.conf.d/shturman-watchdog.conf
sudo install -m644 systemd/shturman-savetime.timer /etc/systemd/system/
sudo systemctl daemon-reload
# применить конфиги детерминированно (на свежем boot drop-in’ы появились после старта демонов)
sudo systemctl reload dbus 2>/dev/null || true
sudo systemctl restart systemd-journald 2>/dev/null || true
sudo rm -rf /var/log/journal 2>/dev/null || true # устаревший persistent-журнал (до drop-in); volatile его не пересоздаст
sudo modprobe zram 2>/dev/null || true # zram-модуль (linux-modules-extra); может отсутствовать в vz-ядре
sudo systemctl start "systemd-zram-setup@zram0.service" 2>/dev/null || true
sudo systemctl restart systemd-oomd 2>/dev/null || sudo systemctl start systemd-oomd 2>/dev/null || true # подхватить oomd.conf.d
pass "юниты/политики разложены"
info "старт shturman.target (зонтик → Stage 0/1/2)"
sudo systemctl start shturman.target || fail "shturman.target не стартовал"
# Перезапустить демоны, чтобы подхватить свежесобранные бинари (на повторном just run — иначе крутится
# старый бинарь, start=no-op; на чистом vm-reset сервисы и так стартуют с новым). shell НЕ трогаем:
# иначе frame.png стал бы новее stage2.ready и сломал бы ассерт «warmup после кадра» (порядок фаз).
sudo systemctl restart shturman-power.service shturman-settings.service
for _ in $(seq 1 15); do systemctl is-active --quiet shturman-shell && break; sleep 1; done
# ---- 1. /data до сервисов + реальные power-safe опции (§9.3.1) ----
info "1. /data смонтирован, реальные non-default опции + volatile-слой"
findmnt /data >/dev/null || fail "/data не смонтирован"
opts=$(findmnt -no OPTIONS /data)
echo "$opts" | grep -q errors=remount-ro || fail "нет errors=remount-ro (opts: $opts)"
pass "/data: $opts"
# volatile-слой (tmpfs) присутствует — кадр/журнал/транзиент пишутся сюда, не на flash (A11).
# Полный RO-rootfs + overlay(upper на tmpfs) — на HW/v4 (A/B boot-select нет в VM, §7.1); тут — дисциплина + tmpfs.
findmnt -t tmpfs /run >/dev/null || fail "/run не tmpfs (нет volatile-слоя)"
pass "volatile-слой: /run = tmpfs"
# ---- 2. first-boot маркер + идемпотентность (§9.3.2) ----
info "2. first-boot маркер + machine-id"
sudo test -f /data/.shturman-provisioned || fail "нет маркера .shturman-provisioned"
sudo test -f /data/state/machine-id || fail "нет /data/state/machine-id"
sudo systemctl start shturman-firstboot.service # повторно — Condition гейтит → no-op
pass "first-boot маркер на месте, повторный запуск no-op"
# ---- 3. per-unit critical set active (degraded не маскирует, §9.3.1) ----
info "3. per-unit critical set"
for u in shturman-power shturman-settings shturman-shell; do
systemctl is-active --quiet "$u" || fail "$u не active"
systemctl is-active --quiet "$u" || fail "$u не active ($(systemctl is-active "$u" 2>&1))"
pass "$u: active"
done
for u in shturman-firstboot shturman-machineid; do
state=$(systemctl is-active "$u" 2>&1)
# oneshot валиден в active (отработал, RemainAfterExit) ИЛИ inactive (корректно пропущен Condition'ом
# на повторном boot: firstboot — marker есть; reflects clean+re-run). Реальный сбой = failed/activating.
case "$state" in
active | inactive) pass "$u: $state (oneshot)" ;;
*) fail "$u не active/inactive (сбой): $state" ;;
esac
done
echo "== 4. имена на системной шине =="
busctl --system list | grep -q ru.shturman.Power || fail "нет ru.shturman.Power"
busctl --system list | grep -q ru.shturman.Settings || fail "нет ru.shturman.Settings"
# ---- 4. имена на системной шине (own) + сервис отвечает (§9.3.3) ----
info "4. имена на шине + отклик"
busctl --system list | grep -q "$P_NAME" || fail "нет $P_NAME на шине"
busctl --system list | grep -q "$S_NAME" || fail "нет $S_NAME на шине"
busctl --system call "$P_NAME" "$P_PATH" "$P_IFACE" GetPowerState | grep -q running \
|| fail "Power.GetPowerState != running"
pass "$P_NAME / $S_NAME владеют именами; GetPowerState=running"
echo "== 5. fake-ACC: SetAcc -> AccChanged =="
# (подписка+вызов dev.PowerMock1; реализация ассерта — busctl monitor/call, финал в части 2)
# ---- 5. fake-ACC: SetAcc -> AccChanged (§9.3.5) ----
info "5. fake-ACC SetAcc -> AccChanged"
mon=$(mktemp)
# sudo нужен busctl для eavesdrop системной шины; редирект в $mon — намеренно user-owned mktemp (SC2024 ок).
# shellcheck disable=SC2024
sudo busctl --system monitor "$P_NAME" >"$mon" 2>&1 &
MON=$!
sleep 1
busctl --system call "$P_NAME" "$P_PATH" "$P_MOCK" SetAcc b false || { sudo kill "$MON" 2>/dev/null; fail "вызов SetAcc"; }
sleep 1
sudo kill "$MON" 2>/dev/null; wait "$MON" 2>/dev/null
grep -q AccChanged "$mon" || { echo "--- monitor ---"; cat "$mon"; fail "AccChanged не наблюдаем"; }
rm -f "$mon"
busctl --system call "$P_NAME" "$P_PATH" "$P_MOCK" SetAcc b true >/dev/null 2>&1 || true # вернуть acc=on
pass "AccChanged наблюдаем после SetAcc"
echo "== 6. персист настроек через reboot + machine-id стабилен =="
# (Settings.Set -> sudo reboot -> повторный прогон сверяет; оформляется в части 2)
# ---- 7. первый Slint-кадр: PNG не пустой (§9.3.6) ----
info "7. первый Slint-кадр (software-render PNG)"
sudo test -f "$FRAME" || fail "нет кадра $FRAME (shell.service не отрендерил?)"
sz=$(sudo stat -c%s "$FRAME"); [ "$sz" -gt 10000 ] || fail "кадр подозрительно мал ($sz Б)"
sudo head -c8 "$FRAME" | od -An -tx1 | tr -d ' \n' | grep -qi "89504e47" || fail "$FRAME не PNG"
pass "кадр $FRAME: $sz Б, валидный PNG"
echo "== 7. первый кадр (software-render PNG не пустой) =="
# (weston headless + shturman-shell + screenshot; финал — часть 2)
# ---- Stage 0/1/2 разделены (v0.2 boot-конвейер) ----
info "Stage 0/1/2: фазы разделены + splash до кадра + warmup после"
for t in shturman-stage0 shturman-stage1 shturman-stage2; do
systemctl is-active --quiet "$t.target" || fail "$t.target не достигнут ($(systemctl is-active "$t.target" 2>&1))"
pass "$t.target reached"
done
sudo test -f /run/shturman/splash.png || fail "нет splash.png (Stage 0)"
sudo head -c8 /run/shturman/splash.png | od -An -tx1 | tr -d ' \n' | grep -qi 89504e47 || fail "splash.png не PNG"
fr=$(sudo stat -c %Y "$FRAME"); sp=$(sudo stat -c %Y /run/shturman/splash.png)
[ "$sp" -le "$fr" ] || fail "splash.png ($sp) позже frame.png ($fr) — Stage 0 не раньше Stage 1"
sudo test -f /run/shturman/stage2.ready || fail "нет stage2.ready (Stage 2 warmup не отработал)"
w2=$(sudo stat -c %Y /run/shturman/stage2.ready)
[ "$w2" -ge "$fr" ] || fail "stage2.ready ($w2) раньше кадра ($fr) — Stage 2 не деферред"
pass "порядок фаз: splash($sp) ≤ frame($fr) ≤ stage2($w2)"
# boot-тайминг (функц., НЕ гейт; вердикт — RK3588, performance §2)
echo " $(systemd-analyze time 2>/dev/null | head -1 || echo 'systemd-analyze н/д')"
echo "== 8. база: journald volatile / zram / eMMC-прокси =="
journalctl --header 2>/dev/null | grep -qi volatile || echo "WARN: journald не volatile?"
zramctl | grep -q zram0 || echo "WARN: zram0 не активен?"
# ---- power-safe (v0.3): FSM ShutdownImminent + N циклов зажигания + abort + power-cut ----
info "power-safe: ShutdownImminent + N=3 цикла зажигания + abort + power-cut"
# Чистый FSM Running для циклов (свежий бинарь + сброс любого «залипшего» состояния от §5 fake-ACC).
# reset-failed: блок ниже намеренно рестартит power N+ раз — сбрасываем счётчик StartLimitBurst (дефолт 5/10s),
# иначе systemd ловит start-limit-hit и power падает в failed (имя ru.shturman.Power на шине теряется).
sudo systemctl reset-failed shturman-power.service shturman-settings.service 2>/dev/null || true
sudo systemctl restart shturman-power.service
for _ in $(seq 1 10); do systemctl is-active --quiet shturman-power && break; sleep 1; done
sleep 1 # дать power re-acquire ru.shturman.Power на шине
P_CALL() { busctl --system call "$P_NAME" "$P_PATH" "$P_MOCK" "$@"; }
busctl --system call "$S_NAME" "$S_PATH" "$S_IFACE" Set sv ui.theme s night >/dev/null
echo 0 | sudo tee /data/state/power-cycles >/dev/null
echo "E2E OK (каркас; пункты 5–7 финализируются в части 2)"
observe_imminent() { # SetAcc(false) → ждём ShutdownImminent на шине
local mon; mon=$(mktemp)
# shellcheck disable=SC2024
sudo busctl --system monitor "$P_NAME" >"$mon" 2>&1 & local M=$!
sleep 0.7; P_CALL SetAcc b false >/dev/null; sleep 0.7
sudo kill "$M" 2>/dev/null; wait "$M" 2>/dev/null
grep -q ShutdownImminent "$mon" || { echo "--- mon ---"; cat "$mon"; rm -f "$mon"; return 1; }
rm -f "$mon"
}
for i in 1 2 3; do
observe_imminent || fail "цикл $i: ShutdownImminent не наблюдаем"
n=$(($(sudo cat /data/state/power-cycles) + 1))
sudo systemctl stop shturman-stage1.target # стоп сервисов (освобождает /data)
sudo umount /etc/machine-id 2>/dev/null || true # снять machineid-bind, иначе /data busy
sync; sudo umount /data || fail "цикл $i: umount /data (PONR)"
findmnt /data >/dev/null && fail "цикл $i: /data не размонтирован (PONR не достигнут)"
sudo systemctl reset-failed shturman-power.service shturman-settings.service 2>/dev/null || true
sudo systemctl start shturman.target # re-mount data.mount + сервисы
# machineid — oneshot RemainAfterExit (уже active): plain start его НЕ перезапускает, bind не вернётся.
# restart форсит ExecStart → пере-bind /data/state/machine-id поверх снятого выше. Без этого /etc/machine-id
# залипает на нижнем rootfs-значении, и POST-чек стабильности machine-id (§9.3.4) падает после reboot.
sudo systemctl restart shturman-machineid.service
for _ in $(seq 1 15); do systemctl is-active --quiet shturman-settings && systemctl is-active --quiet shturman-power && break; sleep 1; done
findmnt /data >/dev/null || fail "цикл $i: /data не вернулся после remount"
echo "$n" | sudo tee /data/state/power-cycles >/dev/null
pass "цикл зажигания $i: stop→umount(PONR)→remount→restart, /data вернулся"
done
got=$(busctl --system call "$S_NAME" "$S_PATH" "$S_IFACE" Get s ui.theme 2>/dev/null)
echo "$got" | grep -q '"night"' || fail "ui.theme потерян после циклов"
[ "$(sudo cat /data/state/power-cycles)" = 3 ] || fail "счётчик циклов != 3"
pass "N=3 цикла: /data + счётчик целы (нет потери)"
# abort до PONR
mon=$(mktemp)
# shellcheck disable=SC2024
sudo busctl --system monitor "$P_NAME" >"$mon" 2>&1 & M=$!
sleep 0.7; P_CALL SetAcc b false >/dev/null; sleep 0.3; P_CALL SetAcc b true >/dev/null; sleep 0.7
sudo kill "$M" 2>/dev/null; wait "$M" 2>/dev/null
grep -q ShutdownAborted "$mon" || { cat "$mon"; rm -f "$mon"; fail "ShutdownAborted не наблюдаем"; }
rm -f "$mon"
findmnt /data >/dev/null || fail "/data не смонтирован после abort"
busctl --system call "$P_NAME" "$P_PATH" "$P_IFACE" GetPowerState | grep -q running || fail "не running после abort"
pass "abort до PONR: ShutdownAborted + /data RW + running"
# power-cut-сим: SIGKILL во время shutdown → /data консистентен
P_CALL SetAcc b false >/dev/null; sleep 0.3
sudo systemctl kill -s KILL shturman-power.service shturman-settings.service 2>/dev/null || true
sudo systemctl stop shturman-stage1.target 2>/dev/null || true
sudo umount /etc/machine-id 2>/dev/null || true
sudo umount /data 2>/dev/null || sudo umount -l /data 2>/dev/null || true
findmnt /data >/dev/null && fail "power-cut: /data не размонтирован (fsck был бы на смонтированном)"
sudo fsck.ext4 -n /var/lib/shturman/data.img >/dev/null 2>&1 || fail "fsck /data не clean после power-cut"
sudo systemctl reset-failed shturman-power.service shturman-settings.service 2>/dev/null || true
sudo systemctl start shturman.target # re-mount + restart
sudo systemctl restart shturman-machineid.service # пере-bind machine-id (plain start не перезапускает oneshot — см. цикл)
for _ in $(seq 1 15); do systemctl is-active --quiet shturman-settings && systemctl is-active --quiet shturman-power && break; sleep 1; done
sudo grep -q night /data/settings/settings.json || fail "last durable value потерян после power-cut"
pass "power-cut-сим: /data консистентен (fsck clean, night present)"
# watchdog/save-time конфиг
test -f /etc/systemd/system.conf.d/shturman-watchdog.conf || fail "нет watchdog-конфига"
systemctl is-active --quiet shturman-savetime.timer && pass "savetime.timer активен" || echo " WARN: savetime.timer не активен"
pass "watchdog-конфиг на месте"
# ---- v0.4: thermal-trip + throttling + MCU fail-safe (мок-sensor/MCU) ----
# Каждый под-тест стартует с чистого running-power (рестарт сбрасывает MockTempSource→20, FSM→running,
# и счётчик start-limit). thermal-abort покрыт integration-тестом (P8.7) — в E2E не гоняем с grace-окном.
info "v0.4: thermal-trip → ShutdownImminent(thermal); throttling-банд; MCU fail-safe (hang → cut)"
P_RESTART() { # чистый рестарт power → running
sudo systemctl reset-failed shturman-power.service 2>/dev/null || true
sudo systemctl restart shturman-power.service
for _ in $(seq 1 10); do systemctl is-active --quiet shturman-power && break; sleep 1; done
sleep 1.5 # дать циклам (poll/heartbeat ~1с) стартовать
}
# thermal-trip: SetTemp ≥ critical → ShutdownImminent(thermal) (монитор poll ~1с; ловим до grace-commit)
P_RESTART
mon=$(mktemp)
# shellcheck disable=SC2024
sudo busctl --system monitor "$P_NAME" >"$mon" 2>&1 & M=$!
sleep 0.4; P_CALL SetTemp i 99; sleep 1.6
sudo kill "$M" 2>/dev/null; wait "$M" 2>/dev/null
grep -q ShutdownImminent "$mon" || { cat "$mon"; rm -f "$mon"; fail "thermal: ShutdownImminent не наблюдаем"; }
grep -q thermal "$mon" || { cat "$mon"; rm -f "$mon"; fail "thermal: reason != thermal"; }
rm -f "$mon"
pass "thermal-trip: SetTemp≥critical → ShutdownImminent(thermal)"
# throttling-банд (85..95) → ThermalState=throttle, БЕЗ shutdown
P_RESTART
P_CALL SetTemp i 88; sleep 2
ts=$(busctl --system get-property "$P_NAME" "$P_PATH" "$P_IFACE" ThermalState 2>/dev/null)
echo "$ts" | grep -q throttle || { echo "ThermalState=$ts"; fail "thermal: ThermalState != throttle на 88°C"; }
busctl --system call "$P_NAME" "$P_PATH" "$P_IFACE" GetPowerState | grep -q running || fail "thermal: throttle не должен ронять"
pass "throttling-банд: ThermalState=throttle на 88°C, без shutdown"
# MCU fail-safe: HangSoc → heartbeat пропал → MCU режет (FSM → off) детерминированно (B09)
P_RESTART
P_CALL HangSoc
ok=0
for _ in $(seq 1 10); do
sleep 1
busctl --system call "$P_NAME" "$P_PATH" "$P_IFACE" GetPowerState 2>/dev/null | grep -q off && { ok=1; break; }
done
[ "$ok" = 1 ] || fail "MCU fail-safe: power не off после HangSoc (B09 не сработал)"
pass "MCU fail-safe: HangSoc → MCU cut (FSM off), детерминированно"
P_RESTART # чистый running для последующих блоков
# ---- 8. base-бюджеты: journald / zram / fake-hwclock / eMMC-прокси (§9.3.7) ----
info "8. base-бюджеты (функц.)"
# journald volatile: активный журнал в /run/log/journal, persistent /var/log/journal отсутствует (A10)
test -d /run/log/journal || fail "journald не volatile (нет /run/log/journal)"
test -d /var/log/journal && fail "journald пишет в persistent /var/log/journal (нарушение A10)"
pass "journald volatile (/run/log/journal, без /var/log/journal)"
# ровно одно zram-устройство (A09); модуль zram может отсутствовать в vz-ядре — честная VM↔HW-граница
zn=$(zramctl --noheadings 2>/dev/null | wc -l | tr -d ' ')
if [ "$zn" = 1 ]; then pass "zram: ровно одно устройство ($(zramctl --noheadings --output NAME 2>/dev/null))"
elif [ "$zn" = 0 ]; then echo " WARN: zram-устройств нет (модуль zram отсутствует в vz-ядре — HW-only, §13)"
else fail "zram: ожидалось одно устройство, найдено $zn"; fi
# fake-hwclock пишет в /data, не в /etc (A11). Override — FILE из /etc/default/fake-hwclock
# (сервис в Lima masked: Lima сам синхронит время — на HW юнит размаскирован, EnvironmentFile тот же).
sudo sh -c '. /etc/default/fake-hwclock 2>/dev/null; FILE="${FILE:-/data/state/fake-hwclock.data}" fake-hwclock save' || true
sudo test -f /data/state/fake-hwclock.data || fail "fake-hwclock не записал в /data/state/fake-hwclock.data"
sudo test -f /etc/fake-hwclock.data && fail "fake-hwclock пишет в /etc (нарушение A11)"
pass "fake-hwclock → /data/state/fake-hwclock.data (не в /etc)"
# systemd-oomd: запущен + наша политика загружена (A09). PSI/cgroup2 нужны — в vz есть; иначе honest WARN.
if [ "$(systemctl is-active systemd-oomd 2>/dev/null)" = active ] && test -f /etc/systemd/oomd.conf.d/shturman.conf; then
pass "systemd-oomd active, политика oomd.conf.d/shturman.conf загружена"
else echo " WARN: systemd-oomd не active (нет PSI/пакета — проверь провижининг)"; fi
# eMMC-прокси: дельта записанных секторов loop-/data за окно простоя
src=$(findmnt -no SOURCE /data); dev=$(basename "$src")
if [ ! -e "/sys/block/$dev" ]; then dev=$(losetup -j /var/lib/shturman/data.img -O NAME --noheadings 2>/dev/null | tr -d ' ' | xargs -r basename); fi
read_w() { awk -v d="$dev" '$3==d {print $10}' /proc/diskstats; }
s0=$(read_w); echo " окно простоя ${IDLE_SECS}s (loop-dev=$dev)…"; sleep "$IDLE_SECS"; s1=$(read_w)
delta=$(( ${s1:-0} - ${s0:-0} ))
echo " eMMC-прокси: записано $delta секторов за ${IDLE_SECS}s (порог $EMMC_MAX_SECTORS, ~калибровка)"
[ "$delta" -le "$EMMC_MAX_SECTORS" ] || fail "eMMC: дельта $delta > порога $EMMC_MAX_SECTORS секторов"
pass "eMMC-прокси в пороге"
# ---- персист-проба для POST-фазы ----
info "персист-проба (для проверки после reboot)"
busctl --system call "$S_NAME" "$S_PATH" "$S_IFACE" Set sv "$PROBE_KEY" s "$PROBE_VAL" || fail "Settings.Set"
got=$(settings_get "$PROBE_KEY"); echo "$got" | grep -q "\"$PROBE_VAL\"" || fail "Set не применился (got: $got)"
sudo cp /etc/machine-id "$MID_BEFORE" # снимок до reboot
pass "Settings.$PROBE_KEY=$PROBE_VAL выставлен; machine-id снят в $MID_BEFORE"
echo; echo "E2E PRE OK ✅ (для полной приёмки — reboot + фаза post: just e2e)"