diff --git a/docs/specs/plans/07-v0.3-power-safe.md b/docs/specs/plans/07-v0.3-power-safe.md new file mode 100644 index 0000000..3b05438 --- /dev/null +++ b/docs/specs/plans/07-v0.3-power-safe.md @@ -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 { + 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 { + 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>, +} + +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>, + 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>, +} + +#[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).