17 Commits

Author SHA1 Message Date
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
43 changed files with 3038 additions and 331 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
+19 -3
View File
@@ -42,9 +42,25 @@ 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 и `v0.5` полный shell (живой weston-shell) — параллельно поверх v0.2 · затем `v0.4` MCU/thermal.
> 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 не отрисован"
);
}
+267
View File
@@ -0,0 +1,267 @@
//! Чистый 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,
Commit,
}
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]
}
// 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
);
}
}
+3 -2
View File
@@ -1,6 +1,7 @@
//! `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 fsm;
pub mod service;
pub use service::PowerService;
+73 -56
View File
@@ -1,38 +1,25 @@
//! 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::fsm::{Action, Event, PowerFsm};
use shturman_common::monotonic_secs;
use shturman_ipc::types::{IgnitionState, PowerSource, PowerState};
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>>,
}
impl Default for PowerService {
fn default() -> Self {
Self {
state: Arc::new(Mutex::new(State::default())),
fsm: Arc::new(Mutex::new(PowerFsm::new())),
}
}
}
@@ -41,46 +28,81 @@ 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()
}
/// dev-mock «fake-ACC», разделяющий состояние (только в dev-сборке).
#[cfg(feature = "dev-mocks")]
pub fn mock(&self) -> PowerMock {
PowerMock {
state: Arc::clone(&self.state),
fsm: Arc::clone(&self.fsm),
}
}
}
/// 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(),
}
}
Ok(())
}
#[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()
@@ -102,51 +124,46 @@ 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>>,
}
#[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;
}
}
@@ -48,3 +48,56 @@ 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();
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);
}
+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 НЕ жжём
+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).
+45 -9
View File
@@ -390,9 +390,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 +420,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 +456,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 +671,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) параллельно.
+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
+286 -35
View File
@@ -1,54 +1,305 @@
#!/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).
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 start shturman.target # re-mount data.mount + сервисы (machineid re-bind)
for _ in $(seq 1 15); do systemctl is-active --quiet shturman-settings && 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 start shturman.target # re-mount + restart
for _ in $(seq 1 15); do systemctl is-active --quiet shturman-settings && 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-конфиг на месте"
# ---- 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)"