diff --git a/docs/specs/plans/08-v0.4-mcu-thermal.md b/docs/specs/plans/08-v0.4-mcu-thermal.md new file mode 100644 index 0000000..b34e10e --- /dev/null +++ b/docs/specs/plans/08-v0.4-mcu-thermal.md @@ -0,0 +1,1354 @@ +# План 8 — v0.4 MCU/thermal fail-safe (тепловой триггер + MCU-протокол + fail-safe-таймер) + +> REQUIRED SUB-SKILL: `executing-plans` (или `subagent-driven-development`) + **TDD**. Спека: `docs/specs/v0.4-mcu-thermal.md`. +> Шаги — чекбоксы `- [ ]`. P8.7/P8.8/P8.9 — тяжёлые (Lima). Поверх живого FSM v0.3 (`shturman-power`). + +**Goal:** замкнуть тепловой и MCU-швы домена B: thermal-trip → graceful (реюз v0.3); SoC↔MCU shutdown-протокол с +защищённым кодеком; модель независимого fail-safe-таймера (MCU режет питание при зависшем SoC). + +**Architecture:** новые модули в `shturman-power` (без нового крейта). Чистые юнит-тестируемые ядра +(`ThermalPolicy`, `codec`, `CoprocessorClient`, `ThermalMonitor`) → абстракции (`TempSource`/`Throttler`/ +`Coprocessor` traits) → dev-mock. FSM кормится через `apply_event` (как v0.3). Циклы (thermal/coprocessor) — +фоновые tokio-таски на монотонике, спавнятся в `main` после регистрации на шине. + +**Tech Stack:** Rust, zbus 4, tokio, CRC16-CCITT, Lima E2E (bash). Все таймеры — `CLOCK_MONOTONIC`. + +--- + +## File Structure + +- **Create** `crates/core/shturman-power/src/thermal.rs` — `ThermalLevel`, `ThermalPolicy` (чистая, гистерезис), + `TempSource`/`Throttler` traits + `MockTempSource`/`SysfsTempSource`/`NoopThrottler`/`CpufreqThrottler`, + `ThermalMonitor`. +- **Create** `crates/core/shturman-power/src/protocol.rs` — `SocToMcu`/`McuToSoc` + wire-типы. +- **Create** `crates/core/shturman-power/src/codec.rs` — CRC16, `encode_frame`, `FrameDecoder` (resync + replay). +- **Create** `crates/core/shturman-power/src/coprocessor.rs` — `Coprocessor` trait, `MockCoprocessor` (+B09-модель), + `SerialCoprocessor` (стаб), `CoprocessorClient`. +- **Modify** `crates/core/shturman-power/src/fsm.rs` — `Event::{ThermalCleared, FailsafeCut}`, `Action::Cut`. +- **Modify** `crates/core/shturman-power/src/service.rs` — `ThermalState` property, `ThermalChanged` signal, + `Action::Cut`-хендлер, `spawn_loops`, dev-mock `SetTemp`/`HangSoc`/`McuLinkLoss`, хендлы fsm/thermal_state. +- **Modify** `crates/core/shturman-power/src/main.rs` — собрать источники (mock/prod) + `spawn_loops`. +- **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 `thermal_state` + сигнал `thermal_changed`. +- **Modify** `crates/core/shturman-power/tests/integration.rs` — thermal-trip / abort / failsafe-cut. +- **Modify** `tests/e2e/run.sh` — блок v0.4 (после блока power-safe v0.3). +- **Modify (P8.9, швы §10)** `docs/domains/b-power-lifecycle.md`, `docs/contracts/hardware.md`, + `docs/contracts/ipc.md`, `docs/capability-catalog.md`, `CLAUDE.md`. + +--- + +## P8.1: `ThermalPolicy` — чистый тепловой автомат (A12/B10) + +**Files:** Create `crates/core/shturman-power/src/thermal.rs` (часть 1 — политика); Modify `lib.rs`. + +- [ ] **Шаг 1 — `thermal.rs` (политика + уровни + тесты):** + +```rust +//! Тепловая подсистема (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; + +/// Чистая политика: `(предыдущий уровень, температура) → уровень` с гистерезисом (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) + } + } +} + +#[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); + } +} +``` + +- [ ] **Шаг 2 — `lib.rs`:** добавить `pub mod thermal;` (рядом с `pub mod fsm;`). + +- [ ] **Шаг 3 — прогон.** Run: `cargo test -p shturman-power thermal::policy_tests`. Expected: PASS (3 теста). + +- [ ] **Шаг 4 — commit.** + +```bash +git add crates/core/shturman-power/src/thermal.rs crates/core/shturman-power/src/lib.rs +git commit -s -m "feat(v0.4): чистый ThermalPolicy (банды + гистерезис, A12/B10)" +``` + +--- + +## P8.2: FSM-события `ThermalCleared` + `FailsafeCut` (B09/B10) + +**Files:** Modify `crates/core/shturman-power/src/fsm.rs`. + +- [ ] **Шаг 1 — расширить `Event` и `Action`.** В `fsm.rs` добавить в enum `Event` (после `GraceExpired`): + +```rust + ThermalCleared, // тепло вернулось в норму до PONR → abort thermal-shutdown (гейт reason==Thermal) + FailsafeCut, // MCU-авторитетный cut (зависший SoC / истёк hold-up) → off, необратимо +``` + +В enum `Action` (после `Commit`): + +```rust + Cut, // MCU снял питание (fail-safe) — сервис логирует + переходит в off +``` + +- [ ] **Шаг 2 — добавить переходы в `step`.** В `match (self.state, ev)` **перед** финальным `_ => vec![]` вставить: + +```rust + ( + ShuttingDown { phase: Abortable, reason: ShutdownReason::Thermal }, + E::ThermalCleared, + ) => { + self.state = Running; + vec![Action::ShutdownAborted] + } + (Off, E::FailsafeCut) => vec![], + (_, E::FailsafeCut) => { + self.state = Off; + vec![Action::Cut] + } +``` + +- [ ] **Шаг 3 — тесты.** В `#[cfg(test)] mod tests` (`fsm.rs`) добавить: + +```rust + #[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); + } +``` + +(Использует `PowerState`/`State`/`Action`/`Event`, уже импортированные в тест-модуле/файле.) + +- [ ] **Шаг 4 — прогон.** Run: `cargo test -p shturman-power fsm`. Expected: PASS (v0.3 7 тестов + 2 новых = 9). + +- [ ] **Шаг 5 — commit.** + +```bash +git add crates/core/shturman-power/src/fsm.rs +git commit -s -m "feat(v0.4): FSM ThermalCleared (abort thermal) + FailsafeCut (MCU cut)" +``` + +--- + +## P8.3: протокол + кодек (B08 — защита линка) + +**Files:** Create `crates/core/shturman-power/src/protocol.rs`, `crates/core/shturman-power/src/codec.rs`; Modify `lib.rs`. + +- [ ] **Шаг 1 — `protocol.rs`:** + +```rust +//! Типы сообщений 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 { + match self { + SocToMcu::ShutdownImminent { budget } => vec![*budget], + _ => vec![], + } + } + pub fn from_wire(t: u8, p: &[u8]) -> Option { + 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 { + 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 { + 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, + } + } +} +``` + +- [ ] **Шаг 2 — `codec.rs`:** + +```rust +//! Кадр 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 { + 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, +} + +/// Потоковый декодер: накапливает байты, выдаёт валидные кадры. Resync/replay-guard внутри. +#[derive(Default)] +pub struct FrameDecoder { + buf: Vec, + last_seq: Option, +} + +impl FrameDecoder { + pub fn push(&mut self, bytes: &[u8]) -> Vec { + 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] }]); + } +} +``` + +- [ ] **Шаг 3 — `lib.rs`:** добавить `pub mod protocol;` и `pub mod codec;`. + +- [ ] **Шаг 4 — прогон.** Run: `cargo test -p shturman-power codec`. Expected: PASS (4 теста). + +- [ ] **Шаг 5 — commit.** + +```bash +git add crates/core/shturman-power/src/protocol.rs crates/core/shturman-power/src/codec.rs crates/core/shturman-power/src/lib.rs +git commit -s -m "feat(v0.4): SoC↔MCU протокол + кодек (CRC/replay/desync-guard, B08)" +``` + +--- + +## P8.4: copprocessor — `Coprocessor` trait + мок + клиент + B09-модель + +**Files:** Create `crates/core/shturman-power/src/coprocessor.rs`; Modify `lib.rs`. + +- [ ] **Шаг 1 — `coprocessor.rs`:** + +```rust +//! 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::collections::VecDeque; +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; // 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, + seq: u8, + decoder: FrameDecoder, +} + +impl CoprocessorClient { + pub fn new(link: Arc) -> 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 { + 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, + last_heartbeat: u64, + shutdown_at: Option, + safe_to_cut: bool, + hung: bool, + now: u64, +} + +/// Мок MCU: декодит SoC-кадры (через реальный codec), моделирует B09-таймер, эмитит MCU→SoC. +#[derive(Clone, Default)] +pub struct MockCoprocessor { + st: Arc>, +} + +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 f = encode_frame(s.mcu_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 = now; + } + } + SocToMcu::ShutdownImminent { .. } => s.shutdown_at = Some(now), + SocToMcu::SafeToCut => s.safe_to_cut = true, + } + } + } + } + fn rx(&self) -> Vec { + 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 => s.last_heartbeat != 0 && s.now.saturating_sub(s.last_heartbeat) > 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 { + 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()); + } +} +``` + +- [ ] **Шаг 2 — `lib.rs`:** добавить `pub mod coprocessor;`. + +- [ ] **Шаг 3 — прогон.** Run: `cargo test -p shturman-power coprocessor`. Expected: PASS (4 теста). + +- [ ] **Шаг 4 — commit.** + +```bash +git add crates/core/shturman-power/src/coprocessor.rs crates/core/shturman-power/src/lib.rs +git commit -s -m "feat(v0.4): Coprocessor trait + MockCoprocessor (B09-модель) + клиент (B08)" +``` + +--- + +## P8.5: thermal — источники/throttler + `ThermalMonitor` + +**Files:** Modify `crates/core/shturman-power/src/thermal.rs` (часть 2). + +- [ ] **Шаг 1 — дописать `thermal.rs`** (после политики; перед `#[cfg(test)] mod policy_tests`): + +```rust +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, +} +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::() { + 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>>, +} +impl NoopThrottler { + pub fn last(&self) -> Option { + *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 } + } +} +``` + +- [ ] **Шаг 2 — тесты монитора.** В `thermal.rs` добавить отдельный тест-модуль: + +```rust +#[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))); + } +} +``` + +- [ ] **Шаг 3 — прогон.** Run: `cargo test -p shturman-power thermal`. Expected: PASS (policy 3 + monitor 2). + +- [ ] **Шаг 4 — commit.** + +```bash +git add crates/core/shturman-power/src/thermal.rs +git commit -s -m "feat(v0.4): TempSource/Throttler-абстракции + ThermalMonitor (A12/B10)" +``` + +--- + +## P8.6: проводка сервиса — D-Bus + циклы + dev-mock + +**Files:** Modify `service.rs`, `main.rs`, `crates/shturman-ipc/src/proxy.rs`. + +- [ ] **Шаг 1 — `service.rs`: поля сервиса + хендлы.** В `PowerService` добавить поле `thermal_state` и хендлы: + +```rust +use crate::thermal::ThermalLevel; +// ... в use-блоке к существующим +``` + +Заменить структуру/`Default`/`new`/`mock` блок на: + +```rust +pub struct PowerService { + fsm: Arc>, + thermal_state: Arc>, +} + +impl Default for PowerService { + fn default() -> Self { + Self { + fsm: Arc::new(Mutex::new(PowerFsm::new())), + thermal_state: Arc::new(Mutex::new(ThermalLevel::Normal)), + } + } +} + +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() + } + pub fn fsm_handle(&self) -> Arc> { + Arc::clone(&self.fsm) + } + pub fn thermal_state_handle(&self) -> Arc> { + Arc::clone(&self.thermal_state) + } + + #[cfg(feature = "dev-mocks")] + pub fn mock( + &self, + temp: Arc, + copro: Arc, + ) -> PowerMock { + PowerMock { fsm: Arc::clone(&self.fsm), temp, copro } + } +} +``` + +- [ ] **Шаг 2 — `service.rs`: `Action::Cut` в `apply_event`.** В `match a { ... }` добавить рукав: + +```rust + Action::Cut => { + tracing::warn!("power: MCU fail-safe cut (SoC hang / hold-up budget) — forced off"); + } +``` + +- [ ] **Шаг 3 — `service.rs`: D-Bus property + сигнал.** В `#[interface(name = "ru.shturman.Power1")]`-блоке добавить: + +```rust + #[zbus(property)] + async fn thermal_state(&self) -> String { + self.thermal_state.lock().unwrap().as_str().to_string() + } + + #[zbus(signal)] + async fn thermal_changed(ctx: &SignalContext<'_>, state: &str, celsius: i32) -> zbus::Result<()>; +``` + +- [ ] **Шаг 4 — `service.rs`: `spawn_loops`** (свободная функция, после `apply_event`): + +```rust +use crate::coprocessor::{Coprocessor, CoprocessorClient, BROWNOUT_MV}; +use crate::fsm::{Phase, State}; +use crate::protocol::McuToSoc; +use crate::thermal::{TempSource, ThermalMonitor, Throttler}; + +/// Фоновые циклы v0.4 — thermal-монитор + coprocessor (heartbeat/wait/safe-to-cut/B09). Монотоника. +#[allow(clippy::too_many_arguments)] +pub fn spawn_loops( + fsm: Arc>, + thermal_state: Arc>, + temp: Arc, + throttler: Arc, + copro: Arc, + 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 obs = mon.observe(temp.read_celsius()); + if !obs.changed { + continue; + } + throttler.apply(obs.level); + *thermal_state.lock().unwrap() = obs.level; + let _ = PowerService::thermal_changed( + &ctx, + obs.level.as_str(), + temp.read_celsius(), + ) + .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: shturman_ipc::types::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; + } + } + }); + } +} +``` + +(Добавить `use std::time::Duration;` если ещё нет — в v0.3 уже есть. `monotonic_secs` уже импортирован.) + +- [ ] **Шаг 5 — `service.rs`: расширить `PowerMock`.** Заменить структуру `PowerMock` и добавить методы: + +```rust +#[cfg(feature = "dev-mocks")] +pub struct PowerMock { + fsm: Arc>, + temp: Arc, + copro: Arc, +} +``` + +В `#[interface(name = "ru.shturman.dev.PowerMock1")] impl PowerMock` добавить (к существующим SetAcc/…): + +```rust + /// Задать температуру (°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)"); + } +``` + +- [ ] **Шаг 6 — `thermal.rs`: добавить `POLL_SECS`.** Рядом с порогами: + +```rust +pub const POLL_SECS: u64 = 1; // период опроса температуры (монотоника) +``` + +- [ ] **Шаг 7 — `main.rs`: собрать источники + spawn.** Заменить тело `main` на: + +```rust +#[tokio::main] +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(); + + #[cfg(feature = "dev-mocks")] + 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?; + + let iface = conn + .object_server() + .interface::<_, PowerService>(names::power::PATH) + .await?; + let ctx = iface.signal_context().to_owned(); + shturman_power::spawn_loops( + fsm, + thermal_state, + temp as std::sync::Arc, + throttler as std::sync::Arc, + copro as std::sync::Arc, + ctx, + ); + + tracing::info!("ru.shturman.Power1 на шине (FSM + thermal + coprocessor)"); + std::future::pending::<()>().await; + Ok(()) +} +``` + +- [ ] **Шаг 8 — `proxy.rs`: добавить в `Power1Proxy`.** Рядом с существующими property/signal: + +```rust + #[zbus(property)] + fn thermal_state(&self) -> zbus::Result; + + #[zbus(signal)] + fn thermal_changed(&self, state: String, celsius: i32) -> zbus::Result<()>; +``` + +- [ ] **Шаг 9 — прогон unit + lint.** Run: `cargo test -p shturman-power && cargo clippy -p shturman-power --all-targets -- -D warnings`. Expected: PASS (все юниты v0.3+v0.4; clippy чисто). + +- [ ] **Шаг 10 — commit.** + +```bash +git add crates/core/shturman-power/src/service.rs crates/core/shturman-power/src/main.rs crates/shturman-ipc/src/proxy.rs crates/core/shturman-power/src/thermal.rs +git commit -s -m "feat(v0.4): проводка thermal+coprocessor циклов + D-Bus ThermalState/ThermalChanged + dev-mock" +``` + +--- + +## P8.7: integration-тесты (session-шина) + +**Files:** Modify `crates/core/shturman-power/tests/integration.rs`. + +- [ ] **Шаг 1 — добавить тесты** (после существующих v0.3; используют тот же паттерн server/client + `spawn_loops`): + +```rust +#[tokio::test] +#[ignore = "нужна session-шина: just test-integration"] +async fn thermal_trip_then_clear() { + use futures_util::StreamExt; + use shturman_power::{coprocessor::MockCoprocessor, thermal::{MockTempSource, NoopThrottler}}; + use std::sync::Arc; + + 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::spawn_loops( + fsm, + thermal_state, + temp.clone() as Arc, + Arc::new(NoopThrottler::default()) as Arc, + copro as Arc, + 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() { + use shturman_power::{coprocessor::MockCoprocessor, thermal::{MockTempSource, NoopThrottler}}; + use std::sync::Arc; + + 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::spawn_loops( + fsm, + thermal_state, + temp as Arc, + Arc::new(NoopThrottler::default()) as Arc, + copro.clone() as Arc, + 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); +} +``` + +- [ ] **Шаг 2 — прогон.** Run: `just test-integration`. Expected: PASS (v0.3 2 + v0.4 2 = 4; `--test-threads=1`). + +- [ ] **Шаг 3 — commit.** + +```bash +git add crates/core/shturman-power/tests/integration.rs +git commit -s -m "test(v0.4): integration — thermal-trip/abort + MCU fail-safe-cut (session-шина)" +``` + +--- + +## P8.8: E2E-блок v0.4 (Lima, гибрид) + +**Files:** Modify `tests/e2e/run.sh`. + +- [ ] **Шаг 1 — блок v0.4 в `run.sh`.** Вставить **после** блока power-safe v0.3 (после строки + `pass "watchdog-конфиг на месте"`), до `# ---- 8. base-бюджеты`: + +```bash +# ---- 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 для последующих блоков +``` + +(`P_CALL` уже определён в блоке v0.3: `busctl --system call "$P_NAME" "$P_PATH" "$P_MOCK" "$@"`. `P_IFACE`/`P_NAME`/ +`P_PATH` — из преамбулы run.sh.) + +- [ ] **Шаг 2 — shellcheck.** Run: `shellcheck -S warning tests/e2e/run.sh`. Expected: чисто. + +- [ ] **Шаг 3 — commit.** + +```bash +git add tests/e2e/run.sh +git commit -s -m "test(v0.4): E2E-блок thermal-trip + throttling + MCU fail-safe (мок-sensor/MCU)" +``` + +--- + +## P8.9: verify в Lima + acceptance + prod-gate + швы + +- [ ] **Шаг 1 — host-гейт.** Run: `just ci` + `just test-integration`. Expected: exit 0; все Power-юниты + 4 + integration-теста зелёные. + +- [ ] **Шаг 2 — чистый E2E.** Run: `just vm-reset && just e2e`. Expected: exit 0; **блок v0.4 зелёный** + (thermal-trip→ShutdownImminent(thermal), recovery→abort, throttle-банд, HangSoc→off); **регресс v0.1–v0.3 + зелёный** (power-safe N=3, abort, power-cut; machine-id стабилен); `E2E OK ✅`. + +- [ ] **Шаг 3 — итерации** по реальным ошибкам (тайминги монитора/heartbeat, start-limit — `reset-failed` уже + в паттерне, busctl-типы `i`/`SetTemp`) — систематически, один симптом → одна правка, повтор шаг 2. + +- [ ] **Шаг 4 — prod-build-gate.** Run: + `cargo build -p shturman-power --no-default-features && ! strings target/debug/shturman-power | grep -qE 'PowerMock1|SetTemp|HangSoc'`. + Expected: сборка ок; `PowerMock1`/`SetTemp`/`HangSoc` отсутствуют. + +- [ ] **Шаг 5 — синхронизация швов (спека §10):** `docs/domains/b-power-lifecycle.md` (§4 thermal-путь реализован, + §5 MCU-протокол+кодек+клиент — софт/модель, физический MCU/fail-safe → HW; §6 wait-for-completion; §1a пороги + placeholder; пометить A12/B08/B09/B10), `docs/contracts/hardware.md` §3/§1a (B08/B09 🟡 → HW-bring-up), + `docs/contracts/ipc.md` §3 (Power += ThermalState/ThermalChanged), `docs/capability-catalog.md` (A12/B10 ✅, + B08/B09 софт+модель/🟡 HW), `CLAUDE.md` (статус v0.4 готово → v0.5). + +- [ ] **Шаг 6 — commit доков.** + +```bash +git add docs/ CLAUDE.md +git commit -s -m "docs(v0.4): синхронизация швов MCU/thermal + статус" +``` + +- [ ] **Шаг 7 — finishing-a-development-branch** (merge/PR — спросить пользователя; в `main` без явного «ок» не мержить). + +--- + +## Acceptance (спека v0.4 §9.4) + +- [ ] thermal-trip → graceful (`ShutdownImminent(thermal)`→commit→`/data` консистентен); гистерезис — нет осцилляции. +- [ ] MCU fail-safe-таймер: SoC-hang/бюджет → детерминированный cut (FSM off); `/data` консистентен. +- [ ] Throttling-политика по бандам (запись/`ThermalState`; числа — RK3588). +- [ ] Кодек: replay/desync/corruption отбиты (unit). +- [ ] `ThermalState`/`ThermalChanged` на шине; таймеры монотонны. +- [ ] Регресс v0.1–v0.3 зелёный; `just ci` зелёный; prod-build-gate (нет `PowerMock1`/`SetTemp`/`HangSoc`); + красные линии целы (MCU/Power — только питание, нет CAN/actuator). +```