Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 93382d2de6 | |||
| 92a11c3c72 | |||
| 586ba29821 | |||
| 394d1463c3 | |||
| aaae0508b9 | |||
| d8465c91e4 | |||
| 598070de96 | |||
| 4fe5103e88 | |||
| fd5c5c2dd5 | |||
| 2442f091d2 | |||
| 86ab11a54b | |||
| 62a6f332e2 | |||
| 798e5ba14a | |||
| e841c082b3 | |||
| 9a3b6a8753 | |||
| 9b87751ab8 | |||
| a9aad21636 |
@@ -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
|
|
||||||
@@ -42,9 +42,25 @@ vcan + Vehicle Simulator + моки (аудио/BT/камера/GPS/сеть/plu
|
|||||||
|
|
||||||
## Текущая цель — v0 (см. `docs/roadmap.md` § v0)
|
## Текущая цель — v0 (см. `docs/roadmap.md` § v0)
|
||||||
|
|
||||||
`v0.1` Образ-болванка → `v0.2` boot-конвейер → `v0.3` power-safe → `v0.5` shell-первый-кадр;
|
**Фундамент готов и в `main`** (Планы 1–5 ч.1; спека `docs/specs/v0.1-v0.6-foundation.md` + планы `docs/specs/plans/`):
|
||||||
`v0.6` dev-харнесс — параллельный enabling-трек (стартуем с него + v0.1). **Первый запускаемый артефакт:**
|
воркспейс + `shturman-common`/`ipc`/`sdk` + стаб-сервисы `firstboot`/`settings`/`power` (интеграция на D-Bus) +
|
||||||
boot в Lima-VM → стаб-сервисы (`Power`/`Settings` на D-Bus) → первый Slint-кадр.
|
первый 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
@@ -165,17 +165,6 @@ dependencies = [
|
|||||||
"slab",
|
"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]]
|
[[package]]
|
||||||
name = "async-io"
|
name = "async-io"
|
||||||
version = "2.6.0"
|
version = "2.6.0"
|
||||||
@@ -3589,6 +3578,16 @@ dependencies = [
|
|||||||
"zbus 4.4.0",
|
"zbus 4.4.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "shturman-render"
|
||||||
|
version = "0.0.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"png 0.17.16",
|
||||||
|
"slint",
|
||||||
|
"tempfile",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "shturman-sdk"
|
name = "shturman-sdk"
|
||||||
version = "0.0.0"
|
version = "0.0.0"
|
||||||
@@ -3622,13 +3621,29 @@ name = "shturman-shell"
|
|||||||
version = "0.0.0"
|
version = "0.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"png 0.17.16",
|
||||||
"shturman-common",
|
"shturman-common",
|
||||||
|
"shturman-render",
|
||||||
"shturman-sdk",
|
"shturman-sdk",
|
||||||
"slint",
|
"slint",
|
||||||
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "shturman-splash"
|
||||||
|
version = "0.0.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"png 0.17.16",
|
||||||
|
"shturman-common",
|
||||||
|
"shturman-render",
|
||||||
|
"slint",
|
||||||
|
"tempfile",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "signal-hook-registry"
|
name = "signal-hook-registry"
|
||||||
version = "1.4.8"
|
version = "1.4.8"
|
||||||
@@ -3859,6 +3874,16 @@ dependencies = [
|
|||||||
"serde_core",
|
"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]]
|
[[package]]
|
||||||
name = "softbuffer"
|
name = "softbuffer"
|
||||||
version = "0.4.8"
|
version = "0.4.8"
|
||||||
@@ -4183,11 +4208,14 @@ version = "1.52.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
|
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
"libc",
|
"libc",
|
||||||
"mio",
|
"mio",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"signal-hook-registry",
|
"signal-hook-registry",
|
||||||
|
"socket2",
|
||||||
"tokio-macros",
|
"tokio-macros",
|
||||||
|
"tracing",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -5415,15 +5443,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725"
|
checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-broadcast",
|
"async-broadcast",
|
||||||
"async-executor",
|
|
||||||
"async-fs",
|
|
||||||
"async-io",
|
|
||||||
"async-lock",
|
|
||||||
"async-process",
|
"async-process",
|
||||||
"async-recursion",
|
"async-recursion",
|
||||||
"async-task",
|
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"blocking",
|
|
||||||
"enumflags2",
|
"enumflags2",
|
||||||
"event-listener",
|
"event-listener",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
@@ -5437,6 +5459,7 @@ dependencies = [
|
|||||||
"serde_repr",
|
"serde_repr",
|
||||||
"sha1",
|
"sha1",
|
||||||
"static_assertions",
|
"static_assertions",
|
||||||
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"uds_windows",
|
"uds_windows",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.52.0",
|
||||||
|
|||||||
+4
-1
@@ -9,6 +9,8 @@ members = [
|
|||||||
"crates/core/shturman-firstboot",
|
"crates/core/shturman-firstboot",
|
||||||
"crates/core/shturman-settings",
|
"crates/core/shturman-settings",
|
||||||
"crates/core/shturman-power",
|
"crates/core/shturman-power",
|
||||||
|
"crates/apps/shturman-render",
|
||||||
|
"crates/apps/shturman-splash",
|
||||||
"crates/apps/shturman-shell",
|
"crates/apps/shturman-shell",
|
||||||
"crates/tools/shturman-manifest-validator",
|
"crates/tools/shturman-manifest-validator",
|
||||||
]
|
]
|
||||||
@@ -20,7 +22,8 @@ rust-version = "1.96"
|
|||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal", "sync", "time"] }
|
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 = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
serde_yaml = "0.9"
|
serde_yaml = "0.9"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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]), "кадр одноцветный");
|
||||||
|
}
|
||||||
@@ -7,7 +7,13 @@ license.workspace = true
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
shturman-sdk = { path = "../../shturman-sdk" }
|
shturman-sdk = { path = "../../shturman-sdk" }
|
||||||
shturman-common = { path = "../../shturman-common" }
|
shturman-common = { path = "../../shturman-common" }
|
||||||
|
shturman-render = { path = "../shturman-render" }
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
slint.workspace = true
|
slint.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile.workspace = true
|
||||||
|
# PNG-декодер для проверки кадра в tests/screenshot.rs (рендер — в shturman-render).
|
||||||
|
png = "0.17"
|
||||||
|
|||||||
@@ -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(())
|
||||||
|
}
|
||||||
@@ -1,152 +1,36 @@
|
|||||||
//! `shturman-shell` — первый Slint-кадр (срезы C03/C04/C05/C07/C02). На SDK (architecture §1).
|
//! `shturman-shell` (bin) — тонкая обёртка над `shturman_shell` (lib):
|
||||||
//! v0: одноразовое чтение `ui.theme`/Power при старте (best-effort; без шины — дефолты, #4); рендер.
|
//! - по умолчанию: интерактивный первый Slint-кадр (dev: weston в VM / нативно на хосте);
|
||||||
//! Live-обновления (Changed/AccChanged) и локальная tz часов — позже (v0.5 / a-base §7).
|
//! - `--screenshot <path>`: headless software-render кадра в PNG (E2E/CI, без композитора — §6).
|
||||||
|
|
||||||
mod theme;
|
use shturman_shell::{read_initial, render_screenshot, run_interactive, utc_hh_mm};
|
||||||
|
use std::path::PathBuf;
|
||||||
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}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() -> anyhow::Result<()> {
|
fn main() -> anyhow::Result<()> {
|
||||||
shturman_common::init_tracing("shturman-shell");
|
shturman_common::init_tracing("shturman-shell");
|
||||||
|
let screenshot = parse_screenshot_arg();
|
||||||
let initial = read_initial();
|
let initial = read_initial();
|
||||||
let (hour, clock) = utc_hh_mm();
|
let (hour, clock) = utc_hh_mm();
|
||||||
|
|
||||||
let ui = AppWindow::new()?;
|
match screenshot {
|
||||||
ui.set_is_night(theme::resolve_night(&initial.theme, hour));
|
Some(path) => {
|
||||||
ui.set_clock(clock.into());
|
render_screenshot(&initial, hour, &path)?;
|
||||||
ui.set_network(initial.network.into());
|
println!("{}", path.display()); // путь PNG — для E2E-скрипта
|
||||||
ui.set_ignition(initial.ignition.into());
|
}
|
||||||
tracing::info!("первый Slint-кадр");
|
None => run_interactive(&initial, hour, &clock)?,
|
||||||
ui.run()?;
|
}
|
||||||
Ok(())
|
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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
@@ -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 не отрисован"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
//! `ru.shturman.Power1` — стаб питания/жизненного цикла (домен B).
|
//! `ru.shturman.Power1` — питание/жизненный цикл (домен B). v0.3: реальный lifecycle-FSM
|
||||||
//! v0: статичное состояние `running`, мутируется только dev-mock (fake-ACC). Полная FSM/секвенсинг — v0.3.
|
//! (`fsm`), сервис оборачивает его (D-Bus state/signals из FSM; dev-mock кормит входы).
|
||||||
|
|
||||||
|
pub mod fsm;
|
||||||
pub mod service;
|
pub mod service;
|
||||||
|
|
||||||
pub use service::PowerService;
|
pub use service::PowerService;
|
||||||
|
|||||||
@@ -1,38 +1,25 @@
|
|||||||
//! Server-стаб `ru.shturman.Power1` + (feature `dev-mocks`) `ru.shturman.dev.PowerMock1` (fake-ACC).
|
//! Server `ru.shturman.Power1` + (feature `dev-mocks`) `ru.shturman.dev.PowerMock1` (fake-ACC).
|
||||||
//! zbus 4: несколько интерфейсов на одном объекте — это РАЗНЫЕ типы на одном пути, разделяющие
|
//! v0.3: оборачивает чистый `PowerFsm` (спека §5–§7). dev-mock кормит входы FSM (не флипает состояние).
|
||||||
//! состояние через `Arc<Mutex<State>>` (а не два `#[interface]` на одном типе).
|
|
||||||
|
|
||||||
|
use crate::fsm::{Action, Event, PowerFsm};
|
||||||
use shturman_common::monotonic_secs;
|
use shturman_common::monotonic_secs;
|
||||||
use shturman_ipc::types::{IgnitionState, PowerSource, PowerState};
|
use shturman_ipc::types::{IgnitionState, PowerSource, PowerState};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Duration;
|
||||||
use zbus::interface;
|
use zbus::interface;
|
||||||
use zbus::object_server::SignalContext;
|
use zbus::object_server::SignalContext;
|
||||||
|
|
||||||
struct State {
|
/// Grace-окно (сек): и поле сигнала `ShutdownImminent`, и длительность таймера. v0.3 — фикс. (конфиг — позже).
|
||||||
power: PowerState,
|
const GRACE_SECS: u32 = 2;
|
||||||
ignition: IgnitionState,
|
|
||||||
source: PowerSource,
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
pub struct PowerService {
|
||||||
state: Arc<Mutex<State>>,
|
fsm: Arc<Mutex<PowerFsm>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for PowerService {
|
impl Default for PowerService {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
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 {
|
pub fn new() -> Self {
|
||||||
Self::default()
|
Self::default()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inherent-аксессоры (тесты + источник для interface-методов).
|
|
||||||
pub fn power_state(&self) -> PowerState {
|
pub fn power_state(&self) -> PowerState {
|
||||||
self.state.lock().unwrap().power
|
self.fsm.lock().unwrap().power_state()
|
||||||
}
|
}
|
||||||
pub fn ignition(&self) -> IgnitionState {
|
pub fn ignition(&self) -> IgnitionState {
|
||||||
self.state.lock().unwrap().ignition
|
self.fsm.lock().unwrap().ignition()
|
||||||
}
|
}
|
||||||
pub fn source(&self) -> PowerSource {
|
pub fn source(&self) -> PowerSource {
|
||||||
self.state.lock().unwrap().source
|
self.fsm.lock().unwrap().source()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// dev-mock «fake-ACC», разделяющий состояние (только в dev-сборке).
|
|
||||||
#[cfg(feature = "dev-mocks")]
|
#[cfg(feature = "dev-mocks")]
|
||||||
pub fn mock(&self) -> PowerMock {
|
pub fn mock(&self) -> PowerMock {
|
||||||
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")]
|
#[interface(name = "ru.shturman.Power1")]
|
||||||
impl PowerService {
|
impl PowerService {
|
||||||
async fn get_power_state(&self) -> String {
|
async fn get_power_state(&self) -> String {
|
||||||
self.power_state().as_str().to_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) {}
|
async fn request_sleep(&self) {}
|
||||||
|
|
||||||
#[zbus(property)]
|
#[zbus(property)]
|
||||||
async fn ignition_state(&self) -> String {
|
async fn ignition_state(&self) -> String {
|
||||||
self.ignition().as_str().to_string()
|
self.ignition().as_str().to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[zbus(property)]
|
#[zbus(property)]
|
||||||
async fn uptime(&self) -> u64 {
|
async fn uptime(&self) -> u64 {
|
||||||
monotonic_secs()
|
monotonic_secs()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[zbus(property)]
|
#[zbus(property)]
|
||||||
async fn power_source(&self) -> String {
|
async fn power_source(&self) -> String {
|
||||||
self.source().as_str().to_string()
|
self.source().as_str().to_string()
|
||||||
@@ -102,51 +124,46 @@ impl PowerService {
|
|||||||
async fn wake(ctx: &SignalContext<'_>) -> zbus::Result<()>;
|
async fn wake(ctx: &SignalContext<'_>) -> zbus::Result<()>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// dev-mock «fake-ACC» — отдельный тип на том же пути. Прод (`--no-default-features`) его НЕ регистрирует.
|
/// dev-mock «fake-ACC/voltage/thermal» — кормит входы FSM. Прод (`--no-default-features`) не регистрирует.
|
||||||
/// Методы возвращают `()` (ошибку эмита сигнала игнорируем — мок не отвечает D-Bus-ошибкой).
|
|
||||||
#[cfg(feature = "dev-mocks")]
|
#[cfg(feature = "dev-mocks")]
|
||||||
pub struct PowerMock {
|
pub struct PowerMock {
|
||||||
state: Arc<Mutex<State>>,
|
fsm: Arc<Mutex<PowerFsm>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "dev-mocks")]
|
#[cfg(feature = "dev-mocks")]
|
||||||
#[interface(name = "ru.shturman.dev.PowerMock1")]
|
#[interface(name = "ru.shturman.dev.PowerMock1")]
|
||||||
impl PowerMock {
|
impl PowerMock {
|
||||||
async fn set_acc(&self, on: bool, #[zbus(signal_context)] ctx: SignalContext<'_>) {
|
async fn set_acc(&self, on: bool, #[zbus(signal_context)] ctx: SignalContext<'_>) {
|
||||||
{
|
let ev = if on { Event::AccOn } else { Event::AccOff };
|
||||||
let mut st = self.state.lock().unwrap();
|
let _ = apply_event(&self.fsm, ev, &ctx).await;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn set_ignition(&self, state: String) {
|
async fn set_ignition(&self, state: String, #[zbus(signal_context)] ctx: SignalContext<'_>) {
|
||||||
if let Ok(ig) = state.parse::<IgnitionState>() {
|
// accessory↔running — через EngineOn/Off; off — AccOff.
|
||||||
self.state.lock().unwrap().ignition = ig;
|
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(
|
async fn trigger_shutdown(
|
||||||
&self,
|
&self,
|
||||||
seconds: u32,
|
_seconds: u32,
|
||||||
reason: String,
|
reason: String,
|
||||||
#[zbus(signal_context)] ctx: SignalContext<'_>,
|
#[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<'_>) {
|
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();
|
let sig = acc.next().await.unwrap();
|
||||||
assert!(!sig.args().unwrap().on());
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -72,6 +72,10 @@
|
|||||||
- **Stage 1 (~3–5 c):** ядро-минимум (шина + Power + Settings + Perm-Broker + App-Host) → **Shell с первым кадром**;
|
- **Stage 1 (~3–5 c):** ядро-минимум (шина + Power + Settings + Perm-Broker + App-Host) → **Shell с первым кадром**;
|
||||||
- **Stage 2 (фоном):** Vehicle-Data, Assistant, Media, Nav прогреваются после интерактива.
|
- **Stage 2 (фоном):** Vehicle-Data, Assistant, Media, Nav прогреваются после интерактива.
|
||||||
- Быстрый boot: минимальный initramfs, параллельный systemd, ленивые сервисы.
|
- Быстрый 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,
|
- **Secure boot (verified boot, v4):** анкор доверия — **хэш публичного ключа в OTP/eFuse,
|
||||||
прожиг НЕОБРАТИМ** (burn-once, без ротации). Приватный ключ — offline/HSM с
|
прожиг НЕОБРАТИМ** (burn-once, без ротации). Приватный ключ — offline/HSM с
|
||||||
бэкапом (потеря = кирпич парка). **Dev-ключи ≠ prod**, на dev-платах eFuse НЕ жжём
|
бэкапом (потеря = кирпич парка). **Dev-ключи ≠ prod**, на dev-платах eFuse НЕ жжём
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -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` (реализованные срезы B01–B07 в 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).
|
||||||
@@ -390,9 +390,10 @@ Oneshot-бинарь под `shturman-firstboot.service` (`ConditionPathExists=!
|
|||||||
по мере событий).
|
по мере событий).
|
||||||
- **eMMC write-min (A11):** дисциплина (volatile-логи, tmpfs-транзиент, zram, без спама в `/data`).
|
- **eMMC write-min (A11):** дисциплина (volatile-логи, tmpfs-транзиент, zram, без спама в `/data`).
|
||||||
**Измеримая проверка (детерминированный VM-прокси):** дельта записанных секторов на loop-устройстве `/data`
|
**Измеримая проверка (детерминированный VM-прокси):** дельта записанных секторов на loop-устройстве `/data`
|
||||||
(`/proc/diskstats`) за фиксированное окно простоя (напр. 60 c после boot-settle) **ниже порога T** (🟡
|
(`/proc/diskstats`, поле 10) за фиксированное окно простоя (E2E: `E2E_IDLE_SECS`=20 c после boot-settle) **ниже
|
||||||
калибруется) + **нет периодических флашей вне allow-list писателей** (`fake-hwclock`, `Settings` on-Set);
|
порога T** + **нет периодических флашей вне allow-list писателей** (`fake-hwclock`, `Settings` on-Set); всё прочее
|
||||||
всё прочее → fail. Абсолютный байт-бюджет — вердикт на RK3588 (performance §2).
|
→ fail. **Калибровка (Lima, 2026-06-24): ~80–104 сектора/20 c простоя; порог T = 4096 секторов (~2 МБ/окно),
|
||||||
|
env `E2E_EMMC_MAX_SECTORS`.** Абсолютный байт-бюджет — вердикт на RK3588 (performance §2).
|
||||||
|
|
||||||
### 7.6 systemd-оркестрация (A15)
|
### 7.6 systemd-оркестрация (A15)
|
||||||
|
|
||||||
@@ -419,11 +420,18 @@ Oneshot-бинарь под `shturman-firstboot.service` (`ConditionPathExists=!
|
|||||||
«VM лёгкая»; правится локально); `mounts:` репозиторий **writable** (правим на хосте — собираем в VM).
|
«VM лёгкая»; правится локально); `mounts:` репозиторий **writable** (правим на хосте — собираем в VM).
|
||||||
- **provision (system):** установить пакеты (`systemd`, `dbus`, `pipewire` + WirePlumber [задел v1],
|
- **provision (system):** установить пакеты (`systemd`, `dbus`, `pipewire` + WirePlumber [задел v1],
|
||||||
`weston` + `weston-screenshooter`, `can-utils`, `rustup`/toolchain, `python3`+venv, **`systemd-zram-generator`**,
|
`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;
|
создать loopback-`/data` (ext4 + power-safe-опции) и завести **постоянный** `data.mount`/fstab + tmpfs-overlay;
|
||||||
override `fake-hwclock` пути на `/data`; разложить `systemd/`-юниты + journald/zram-generator/oomd drop-ins +
|
override `fake-hwclock` пути на `/data` + удалить стоковый `/etc/fake-hwclock.data` (A11; сервис в Lima **masked** —
|
||||||
dbus policy (прод `ru.shturman.conf` + dev-only `ru.shturman.dev.conf`); включить `shturman.target`.
|
Lima сам синхронит время, на HW юнит размаскирован и читает `FILE` через `EnvironmentFile`); разложить
|
||||||
*(screenshot кадра в CI — через Slint software-renderer, без пакета grim; см. §6.)*
|
`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 +
|
- **reference-«BSP» (A16):** в dev это **Lima-профиль** (дев-таргет). Реальный reference-BSP (DT overlay +
|
||||||
HAL + DBC) — на HW (a-base §13), вне VM.
|
HAL + DBC) — на HW (a-base §13), вне VM.
|
||||||
- **Подъём:** `just vm-up` → `limactl start --name=shturman lima/shturman.yaml` (создание+provision);
|
- **Подъём:** `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) |
|
| `sim` | **плейсхолдер** Vehicle Simulator (v2, домен E) |
|
||||||
| `ci` | локальный прогон гейта: `lint` + `test` + `deny` |
|
| `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**
|
раннер **уже Linux**, шину/сервисы/headless-рендер гоним напрямую, **без Lima**), **license**
|
||||||
(`cargo-deny`), **prod-build-gate** (`cargo build --workspace --no-default-features` + ассерт, что
|
(`cargo-deny`), **prod-build-gate** (`cargo build --workspace --no-default-features` + ассерт, что
|
||||||
`PowerMock1` не экспортируется без фичи `dev-mocks` — §5.2). Позже — **integration** (vcan+симулятор, v2)
|
`PowerMock1` не экспортируется без фичи `dev-mocks` — §5.2). Позже — **integration** (vcan+симулятор, v2)
|
||||||
@@ -659,6 +671,30 @@ SOFTWARE.
|
|||||||
- **`principles #12`**: уточнить LGPL — гранулярно (динамическая/системная линковка допустима), а не blanket;
|
- **`principles #12`**: уточнить LGPL — гранулярно (динамическая/системная линковка допустима), а не blanket;
|
||||||
согласовать с `deny.toml`/§3.
|
согласовать с `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):** калибровка ~80–104 сект/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. Дальше по ритму
|
## 14. Дальше по ритму
|
||||||
|
|||||||
@@ -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 ~3–5 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.
|
||||||
@@ -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` (~1–5 мин, 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) параллельно.
|
||||||
@@ -22,9 +22,10 @@ lint:
|
|||||||
deny:
|
deny:
|
||||||
cargo deny check
|
cargo deny check
|
||||||
|
|
||||||
# интеграционные тесты на session-шине (нужен dbus: brew install dbus / в Lima)
|
# интеграционные тесты на session-шине (нужен dbus: brew install dbus / в Lima).
|
||||||
|
# --test-threads=1: тесты владеют одними well-known именами на общей шине → серийно (иначе кросс-talk/вис).
|
||||||
test-integration:
|
test-integration:
|
||||||
dbus-run-session -- cargo test --workspace -- --ignored
|
dbus-run-session -- cargo test --workspace -- --ignored --test-threads=1
|
||||||
|
|
||||||
# полный локальный гейт
|
# полный локальный гейт
|
||||||
ci: lint test deny
|
ci: lint test deny
|
||||||
@@ -60,9 +61,9 @@ sim:
|
|||||||
|
|
||||||
# --- Lima-VM (часть 2 Плана 5: нужен limactl — brew install lima) ---
|
# --- Lima-VM (часть 2 Плана 5: нужен limactl — brew install lima) ---
|
||||||
|
|
||||||
# поднять dev-VM (создание + провижининг)
|
# поднять dev-VM (создание + провижининг). --tty=false: неинтерактивный старт (без редактора YAML).
|
||||||
vm-up:
|
vm-up:
|
||||||
limactl start --name=shturman lima/shturman.yaml
|
limactl start --tty=false --name=shturman lima/shturman.yaml
|
||||||
|
|
||||||
# остановить VM
|
# остановить VM
|
||||||
vm-down:
|
vm-down:
|
||||||
@@ -76,16 +77,39 @@ vm-shell:
|
|||||||
vm-reset:
|
vm-reset:
|
||||||
-limactl stop shturman
|
-limactl stop shturman
|
||||||
-limactl delete 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:
|
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:
|
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)
|
# ручная инспекция кадра: headless software-render первого кадра → PNG (без дисплея/композитора, §6)
|
||||||
shell-frame:
|
shell-frame path="target/shell-frame.png":
|
||||||
cargo run -p shturman-shell
|
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
@@ -26,9 +26,14 @@ provision:
|
|||||||
apt-get install -y \
|
apt-get install -y \
|
||||||
dbus pipewire wireplumber weston \
|
dbus pipewire wireplumber weston \
|
||||||
can-utils python3 python3-venv \
|
can-utils python3 python3-venv \
|
||||||
systemd-zram-generator fake-hwclock \
|
systemd-zram-generator systemd-oomd fake-hwclock \
|
||||||
fonts-dejavu-core fonts-noto-core \
|
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 — поднимаем заранее для воспроизводимости)
|
# vcan (для Vehicle Simulator, v2 — поднимаем заранее для воспроизводимости)
|
||||||
modprobe vcan || true
|
modprobe vcan || true
|
||||||
@@ -42,8 +47,9 @@ provision:
|
|||||||
mkfs.ext4 -q -L shturman-data /var/lib/shturman/data.img
|
mkfs.ext4 -q -L shturman-data /var/lib/shturman/data.img
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# systemd-юниты + конфиги из репозитория
|
# systemd-юниты + конфиги из репозитория (зонтик + 3 фазовых таргета; *.service ловит splash/warmup)
|
||||||
install -m644 /shturman/systemd/shturman.target /etc/systemd/system/
|
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/data.mount /etc/systemd/system/
|
||||||
install -m644 /shturman/systemd/shturman-*.service /etc/systemd/system/
|
install -m644 /shturman/systemd/shturman-*.service /etc/systemd/system/
|
||||||
install -d /etc/dbus-1/system.d
|
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 -m644 /shturman/systemd/zram-generator.conf /etc/systemd/zram-generator.conf
|
||||||
install -d /etc/systemd/oomd.conf.d
|
install -d /etc/systemd/oomd.conf.d
|
||||||
install -m644 /shturman/systemd/oomd-shturman.conf /etc/systemd/oomd.conf.d/shturman.conf
|
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
|
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 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).
|
# shturman.target включаем, но НЕ стартуем здесь — бинарей ещё нет (just run/e2e).
|
||||||
systemctl enable shturman.target || true
|
systemctl enable shturman.target || true
|
||||||
|
|
||||||
|
|||||||
@@ -11,4 +11,4 @@ RemainAfterExit=yes
|
|||||||
ExecStart=/usr/local/bin/shturman-firstboot
|
ExecStart=/usr/local/bin/shturman-firstboot
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=shturman.target
|
WantedBy=shturman-stage1.target
|
||||||
|
|||||||
@@ -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'
|
ExecStart=/bin/sh -c '[ -e /etc/machine-id ] || : > /etc/machine-id; mount --bind /data/state/machine-id /etc/machine-id'
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=shturman.target
|
WantedBy=shturman-stage1.target
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
Description=Штурман Power (ru.shturman.Power1)
|
Description=Штурман Power (ru.shturman.Power1)
|
||||||
Requires=data.mount shturman-firstboot.service
|
Requires=data.mount shturman-firstboot.service
|
||||||
After=data.mount shturman-firstboot.service shturman-machineid.service dbus.service
|
After=data.mount shturman-firstboot.service shturman-machineid.service dbus.service
|
||||||
PartOf=shturman.target
|
PartOf=shturman-stage1.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
ExecStart=/usr/local/bin/shturman-power
|
ExecStart=/usr/local/bin/shturman-power
|
||||||
@@ -11,4 +11,4 @@ RestartSec=2
|
|||||||
OOMScoreAdjust=-600
|
OOMScoreAdjust=-600
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=shturman.target
|
WantedBy=shturman-stage1.target
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Штурман periodic save-time (B07)
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnBootSec=2min
|
||||||
|
OnUnitActiveSec=5min
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=shturman-stage2.target
|
||||||
@@ -3,7 +3,7 @@ Description=Штурман Settings (ru.shturman.Settings1)
|
|||||||
# Requires+After firstboot: не стартуем против полу-провиженного /data (Wants недостаточно).
|
# Requires+After firstboot: не стартуем против полу-провиженного /data (Wants недостаточно).
|
||||||
Requires=data.mount shturman-firstboot.service
|
Requires=data.mount shturman-firstboot.service
|
||||||
After=data.mount shturman-firstboot.service shturman-machineid.service dbus.service
|
After=data.mount shturman-firstboot.service shturman-machineid.service dbus.service
|
||||||
PartOf=shturman.target
|
PartOf=shturman-stage1.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
ExecStart=/usr/local/bin/shturman-settings
|
ExecStart=/usr/local/bin/shturman-settings
|
||||||
@@ -13,4 +13,4 @@ RestartSec=2
|
|||||||
OOMScoreAdjust=-600
|
OOMScoreAdjust=-600
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=shturman.target
|
WantedBy=shturman-stage1.target
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=Штурман Shell (первый Slint-кадр)
|
Description=Штурман Shell — первый Slint-кадр (software-render → PNG, §6)
|
||||||
Requires=data.mount shturman-firstboot.service
|
Requires=data.mount shturman-firstboot.service
|
||||||
After=shturman-power.service shturman-settings.service shturman-machineid.service
|
After=shturman-power.service shturman-settings.service shturman-machineid.service
|
||||||
PartOf=shturman.target
|
PartOf=shturman-stage1.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
ExecStart=/usr/local/bin/shturman-shell
|
# v0.6: headless software-render кадра в PNG (спека §6 — основной автотест кадра, композитор не нужен).
|
||||||
Restart=on-failure
|
# oneshot+RemainAfterExit → is-active=active детерминированно, без хрупкого weston (живой weston-shell — v0.5).
|
||||||
RestartSec=2
|
# Кадр читает 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
|
OOMScoreAdjust=-600
|
||||||
# Wayland-дисплей: provisioning/E2E поднимает weston headless (финализируется в части 2).
|
|
||||||
Environment=WAYLAND_DISPLAY=wayland-1
|
|
||||||
Environment=XDG_RUNTIME_DIR=/run/user/0
|
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=shturman.target
|
WantedBy=shturman-stage1.target
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Штурман Stage 0 — splash (мгновенно)
|
||||||
|
Wants=shturman-splash.service
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Штурман Stage 2 — фон (после интерактива)
|
||||||
|
After=shturman-stage1.target
|
||||||
|
Wants=shturman-stage2-warmup.service shturman-savetime.timer
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=Штурман — v0 critical set (Stage 1: ядро + первый кадр)
|
Description=Штурман — v0 boot-конвейер (зонтик фаз Stage 0/1/2)
|
||||||
Requires=data.mount
|
Requires=data.mount
|
||||||
After=data.mount
|
After=data.mount
|
||||||
# Тянем членов critical set: `systemctl enable shturman.target` НЕ каскадит на WantedBy-юниты,
|
# Зонтик тянет три фазовых под-таргета. Порядок «splash → кадр → фон» — на уровне сервисов
|
||||||
# поэтому target должен явно Wants= их (ordering — в самих юнитах через After=).
|
# (splash Before=shell; warmup After=shell), не сериализацией таргетов (иначе critical set ждал бы splash).
|
||||||
Wants=shturman-firstboot.service shturman-machineid.service shturman-power.service shturman-settings.service shturman-shell.service
|
Wants=shturman-stage0.target shturman-stage1.target shturman-stage2.target
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
|
|||||||
@@ -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 -
|
||||||
@@ -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
@@ -1,54 +1,305 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Сквозной E2E Штурмана в Lima-VM (приёмка v0.1/v0.6 + шагающий скелет, спека §9.3/§9.4).
|
# Сквозной E2E Штурмана в Lima-VM (приёмка v0.1/v0.6 + шагающий скелет, спека §9.3/§9.4).
|
||||||
# Запуск: just e2e (внутри VM через limactl shell). Системная шина устройства.
|
# Запуск: just e2e (двухфазно через reboot) или just run (однофазно, без reboot).
|
||||||
# Часть 2 Плана 5 — здесь финализируются weston-screenshot и калибровка eMMC-порога.
|
#
|
||||||
set -euo pipefail
|
# Фазы (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
|
REPO=/shturman
|
||||||
cd "$REPO"
|
cd "$REPO" || { echo "нет $REPO"; exit 1; }
|
||||||
|
|
||||||
echo "== сборка =="
|
PHASE="${E2E_PHASE:-pre}"
|
||||||
cargo build --release --workspace
|
IDLE_SECS="${E2E_IDLE_SECS:-20}" # окно простоя для eMMC-прокси (§7.5)
|
||||||
sudo install -m755 target/release/shturman-firstboot /usr/local/bin/
|
EMMC_MAX_SECTORS="${E2E_EMMC_MAX_SECTORS:-4096}" # порог T (🟡 калибруется; 4096 сект ≈ 2 МБ/окно)
|
||||||
sudo install -m755 target/release/shturman-settings /usr/local/bin/
|
FRAME=/run/shturman/frame.png
|
||||||
sudo install -m755 target/release/shturman-power /usr/local/bin/
|
PROBE_KEY=ui.theme
|
||||||
sudo install -m755 target/release/shturman-shell /usr/local/bin/
|
PROBE_VAL=night
|
||||||
|
MID_BEFORE=/data/state/e2e-mid-before # снимок machine-id до reboot (персист в /data)
|
||||||
|
|
||||||
echo "== старт target =="
|
export PATH="$HOME/.cargo/bin:$PATH"
|
||||||
sudo systemctl daemon-reload
|
export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$HOME/.cache/shturman/target}"
|
||||||
sudo systemctl start shturman.target
|
|
||||||
sleep 3
|
|
||||||
|
|
||||||
|
pass() { echo " ✓ $*"; }
|
||||||
fail() { echo "E2E FAIL: $*" >&2; exit 1; }
|
fail() { echo "E2E FAIL: $*" >&2; exit 1; }
|
||||||
|
info() { echo; echo "== $* =="; }
|
||||||
|
|
||||||
echo "== 1. /data смонтирован до сервисов, реальные опции =="
|
# Имена на шине (зеркало crates/shturman-ipc/src/names.rs).
|
||||||
findmnt /data || fail "/data не смонтирован"
|
P_NAME=ru.shturman.Power; P_PATH=/ru/shturman/Power; P_IFACE=ru.shturman.Power1
|
||||||
findmnt -no OPTIONS /data | grep -q errors=remount-ro || fail "нет errors=remount-ro"
|
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 идемпотентен =="
|
settings_get() { busctl --system call "$S_NAME" "$S_PATH" "$S_IFACE" Get s "$1" 2>/dev/null; }
|
||||||
test -f /data/.shturman-provisioned || fail "нет маркера provisioned"
|
|
||||||
test -f /data/state/machine-id || fail "нет machine-id"
|
|
||||||
|
|
||||||
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
|
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
|
done
|
||||||
|
|
||||||
echo "== 4. имена на системной шине =="
|
# ---- 4. имена на системной шине (own) + сервис отвечает (§9.3.3) ----
|
||||||
busctl --system list | grep -q ru.shturman.Power || fail "нет ru.shturman.Power"
|
info "4. имена на шине + отклик"
|
||||||
busctl --system list | grep -q ru.shturman.Settings || fail "нет ru.shturman.Settings"
|
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 =="
|
# ---- 5. fake-ACC: SetAcc -> AccChanged (§9.3.5) ----
|
||||||
# (подписка+вызов dev.PowerMock1; реализация ассерта — busctl monitor/call, финал в части 2)
|
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 стабилен =="
|
# ---- 7. первый Slint-кадр: PNG не пустой (§9.3.6) ----
|
||||||
# (Settings.Set -> sudo reboot -> повторный прогон сверяет; оформляется в части 2)
|
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 не пустой) =="
|
# ---- Stage 0/1/2 разделены (v0.2 boot-конвейер) ----
|
||||||
# (weston headless + shturman-shell + screenshot; финал — часть 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"
|
||||||
|
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-прокси =="
|
# ---- power-safe (v0.3): FSM ShutdownImminent + N циклов зажигания + abort + power-cut ----
|
||||||
journalctl --header 2>/dev/null | grep -qi volatile || echo "WARN: journald не volatile?"
|
info "power-safe: ShutdownImminent + N=3 цикла зажигания + abort + power-cut"
|
||||||
zramctl | grep -q zram0 || echo "WARN: zram0 не активен?"
|
# Чистый 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)"
|
||||||
|
|||||||
Reference in New Issue
Block a user