docs(v0.3): план реализации power-safe (План 7)
P7.1 чистый PowerFsm (TDD, все переходы) → P7.2 Power-сервис на FSM (dev-mock кормит события, grace-таймер + durable-barrier sync, integration abort) → P7.3 watchdog-конфиг + save-time timer → P7.4 lima/E2E блок (N=3 цикла + abort + power-cut-сим) → P7.5 verify Lima + acceptance + швы. Полный код, без плейсхолдеров. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Alexander <akotenev2003@gmail.com>
This commit is contained in:
@@ -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).
|
||||
Reference in New Issue
Block a user