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