fb31a288c3
TDD-разбивка вехи v0.4 (A12/B08/B09/B10) на P8.1–P8.9: ThermalPolicy (гистерезис) → FSM-события (ThermalCleared/FailsafeCut) → протокол+кодек (CRC/replay/desync) → Coprocessor (мок + B09-модель + клиент) → TempSource/Throttler/ThermalMonitor → проводка сервиса (D-Bus ThermalState/ThermalChanged + spawn_loops + dev-mock SetTemp/ HangSoc) → integration → E2E-блок → verify+prod-gate+швы+finish. Полный код в шагах, self-review пройден (тайминги тестов поправлены). Спека: docs/specs/v0.4-mcu-thermal.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Alexander <akotenev2003@gmail.com>
1355 lines
52 KiB
Markdown
1355 lines
52 KiB
Markdown
# План 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<u8> {
|
||
match self {
|
||
SocToMcu::ShutdownImminent { budget } => vec![*budget],
|
||
_ => vec![],
|
||
}
|
||
}
|
||
pub fn from_wire(t: u8, p: &[u8]) -> Option<Self> {
|
||
match t {
|
||
wire::HEARTBEAT => Some(SocToMcu::Heartbeat),
|
||
wire::SHUTDOWN_IMMINENT => Some(SocToMcu::ShutdownImminent { budget: *p.first()? }),
|
||
wire::SAFE_TO_CUT => Some(SocToMcu::SafeToCut),
|
||
_ => None,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl McuToSoc {
|
||
pub fn wire_type(&self) -> u8 {
|
||
match self {
|
||
McuToSoc::Ack => wire::ACK,
|
||
McuToSoc::Acc { .. } => wire::ACC,
|
||
McuToSoc::Voltage { .. } => wire::VOLTAGE,
|
||
McuToSoc::CutWarning => wire::CUT_WARNING,
|
||
}
|
||
}
|
||
pub fn payload(&self) -> Vec<u8> {
|
||
match self {
|
||
McuToSoc::Acc { on } => vec![*on as u8],
|
||
McuToSoc::Voltage { mv } => mv.to_be_bytes().to_vec(),
|
||
_ => vec![],
|
||
}
|
||
}
|
||
pub fn from_wire(t: u8, p: &[u8]) -> Option<Self> {
|
||
match t {
|
||
wire::ACK => Some(McuToSoc::Ack),
|
||
wire::ACC => Some(McuToSoc::Acc { on: *p.first()? != 0 }),
|
||
wire::VOLTAGE => Some(McuToSoc::Voltage {
|
||
mv: u16::from_be_bytes([*p.first()?, *p.get(1)?]),
|
||
}),
|
||
wire::CUT_WARNING => Some(McuToSoc::CutWarning),
|
||
_ => None,
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Шаг 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<u8> {
|
||
let mut body = Vec::with_capacity(3 + payload.len());
|
||
body.push(payload.len() as u8); // LEN
|
||
body.push(seq); // SEQ
|
||
body.push(msg_type); // TYPE
|
||
body.extend_from_slice(payload);
|
||
let crc = crc16_ccitt(&body);
|
||
let mut frame = Vec::with_capacity(1 + body.len() + 2);
|
||
frame.push(SYNC);
|
||
frame.extend_from_slice(&body);
|
||
frame.push((crc >> 8) as u8);
|
||
frame.push((crc & 0xff) as u8);
|
||
frame
|
||
}
|
||
|
||
#[derive(Debug, PartialEq, Eq)]
|
||
pub struct DecodedFrame {
|
||
pub seq: u8,
|
||
pub msg_type: u8,
|
||
pub payload: Vec<u8>,
|
||
}
|
||
|
||
/// Потоковый декодер: накапливает байты, выдаёт валидные кадры. Resync/replay-guard внутри.
|
||
#[derive(Default)]
|
||
pub struct FrameDecoder {
|
||
buf: Vec<u8>,
|
||
last_seq: Option<u8>,
|
||
}
|
||
|
||
impl FrameDecoder {
|
||
pub fn push(&mut self, bytes: &[u8]) -> Vec<DecodedFrame> {
|
||
self.buf.extend_from_slice(bytes);
|
||
let mut out = Vec::new();
|
||
loop {
|
||
// resync: отбросить мусор до SYNC
|
||
while !self.buf.is_empty() && self.buf[0] != SYNC {
|
||
self.buf.remove(0);
|
||
}
|
||
if self.buf.len() < 4 {
|
||
break; // нужно минимум SYNC+LEN+SEQ+TYPE
|
||
}
|
||
let len = self.buf[1] as usize;
|
||
let frame_len = 1 + 3 + len + 2;
|
||
if self.buf.len() < frame_len {
|
||
break; // кадр ещё не дочитан
|
||
}
|
||
let body = &self.buf[1..1 + 3 + len];
|
||
let crc_rx = ((self.buf[1 + 3 + len] as u16) << 8) | self.buf[1 + 3 + len + 1] as u16;
|
||
if crc_rx != crc16_ccitt(body) {
|
||
self.buf.remove(0); // битый CRC → сдвиг, resync
|
||
continue;
|
||
}
|
||
let seq = self.buf[2];
|
||
let msg_type = self.buf[3];
|
||
let payload = self.buf[4..4 + len].to_vec();
|
||
self.buf.drain(0..frame_len);
|
||
let replay = matches!(self.last_seq, Some(l) if l == seq);
|
||
self.last_seq = Some(seq);
|
||
if !replay {
|
||
out.push(DecodedFrame { seq, msg_type, payload });
|
||
}
|
||
}
|
||
out
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn roundtrip() {
|
||
let f = encode_frame(7, 0x02, &[42]);
|
||
let mut d = FrameDecoder::default();
|
||
let out = d.push(&f);
|
||
assert_eq!(out, vec![DecodedFrame { seq: 7, msg_type: 0x02, payload: vec![42] }]);
|
||
}
|
||
|
||
#[test]
|
||
fn corruption_dropped_then_resyncs() {
|
||
let mut d = FrameDecoder::default();
|
||
let mut f = encode_frame(1, 0x01, &[]);
|
||
f[4] ^= 0xFF; // флип в CRC/payload-зоне → битый CRC
|
||
assert_eq!(d.push(&f), vec![]); // отброшен
|
||
let g = encode_frame(2, 0x01, &[]);
|
||
assert_eq!(d.push(&g), vec![DecodedFrame { seq: 2, msg_type: 0x01, payload: vec![] }]);
|
||
}
|
||
|
||
#[test]
|
||
fn replay_dropped() {
|
||
let mut d = FrameDecoder::default();
|
||
let f = encode_frame(5, 0x01, &[]);
|
||
assert_eq!(d.push(&f).len(), 1);
|
||
assert_eq!(d.push(&f), vec![]); // тот же seq → replay drop
|
||
}
|
||
|
||
#[test]
|
||
fn desync_garbage_before_sync() {
|
||
let mut d = FrameDecoder::default();
|
||
let mut stream = vec![0x00, 0xFF, 0x13]; // мусор
|
||
stream.extend_from_slice(&encode_frame(9, 0x83, &[0x2B, 0x67]));
|
||
let out = d.push(&stream);
|
||
assert_eq!(out, vec![DecodedFrame { seq: 9, msg_type: 0x83, payload: vec![0x2B, 0x67] }]);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Шаг 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<u8>; // MCU → SoC (drain)
|
||
fn failsafe_due(&self) -> bool {
|
||
false
|
||
}
|
||
fn set_now(&self, _secs: u64) {}
|
||
}
|
||
|
||
/// SoC-сторона: heartbeat / shutdown-imminent / safe-to-cut + декод входящих MCU→SoC.
|
||
pub struct CoprocessorClient {
|
||
link: Arc<dyn Coprocessor>,
|
||
seq: u8,
|
||
decoder: FrameDecoder,
|
||
}
|
||
|
||
impl CoprocessorClient {
|
||
pub fn new(link: Arc<dyn Coprocessor>) -> Self {
|
||
Self { link, seq: 0, decoder: FrameDecoder::default() }
|
||
}
|
||
fn send(&mut self, msg: SocToMcu) {
|
||
self.seq = self.seq.wrapping_add(1);
|
||
let f = encode_frame(self.seq, msg.wire_type(), &msg.payload());
|
||
self.link.tx(&f);
|
||
}
|
||
pub fn heartbeat(&mut self) {
|
||
self.send(SocToMcu::Heartbeat);
|
||
}
|
||
pub fn shutdown_imminent(&mut self, budget: u8) {
|
||
self.send(SocToMcu::ShutdownImminent { budget });
|
||
}
|
||
pub fn safe_to_cut(&mut self) {
|
||
self.send(SocToMcu::SafeToCut);
|
||
}
|
||
pub fn poll(&mut self) -> Vec<McuToSoc> {
|
||
let bytes = self.link.rx();
|
||
self.decoder
|
||
.push(&bytes)
|
||
.into_iter()
|
||
.filter_map(|f| McuToSoc::from_wire(f.msg_type, &f.payload))
|
||
.collect()
|
||
}
|
||
}
|
||
|
||
#[derive(Default)]
|
||
struct MockState {
|
||
soc_decoder: FrameDecoder,
|
||
mcu_seq: u8,
|
||
out: Vec<u8>,
|
||
last_heartbeat: u64,
|
||
shutdown_at: Option<u64>,
|
||
safe_to_cut: bool,
|
||
hung: bool,
|
||
now: u64,
|
||
}
|
||
|
||
/// Мок MCU: декодит SoC-кадры (через реальный codec), моделирует B09-таймер, эмитит MCU→SoC.
|
||
#[derive(Clone, Default)]
|
||
pub struct MockCoprocessor {
|
||
st: Arc<Mutex<MockState>>,
|
||
}
|
||
|
||
impl MockCoprocessor {
|
||
pub fn new() -> Self {
|
||
Self::default()
|
||
}
|
||
/// MCU → SoC (dev-mock кормит ACC/voltage отсюда).
|
||
pub fn emit(&self, msg: McuToSoc) {
|
||
let mut s = self.st.lock().unwrap();
|
||
s.mcu_seq = s.mcu_seq.wrapping_add(1);
|
||
let 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<u8> {
|
||
std::mem::take(&mut self.st.lock().unwrap().out)
|
||
}
|
||
fn failsafe_due(&self) -> bool {
|
||
let s = self.st.lock().unwrap();
|
||
match s.shutdown_at {
|
||
// running: тишина heartbeat дольше FAILSAFE_MISS окон → SoC завис → cut
|
||
None => 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<u8> {
|
||
Vec::new()
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn client_heartbeat_decoded_by_mock() {
|
||
let mock = Arc::new(MockCoprocessor::new());
|
||
mock.set_now(1);
|
||
let mut client = CoprocessorClient::new(mock.clone());
|
||
client.heartbeat();
|
||
// heartbeat освежил таймер → нет failsafe
|
||
assert!(!mock.failsafe_due());
|
||
}
|
||
|
||
#[test]
|
||
fn mcu_to_soc_roundtrip() {
|
||
let mock = Arc::new(MockCoprocessor::new());
|
||
let mut client = CoprocessorClient::new(mock.clone());
|
||
mock.emit(McuToSoc::Acc { on: true });
|
||
mock.emit(McuToSoc::Voltage { mv: 13_800 });
|
||
assert_eq!(client.poll(), vec![McuToSoc::Acc { on: true }, McuToSoc::Voltage { mv: 13_800 }]);
|
||
}
|
||
|
||
#[test]
|
||
fn failsafe_on_soc_hang() {
|
||
let mock = Arc::new(MockCoprocessor::new());
|
||
let mut client = CoprocessorClient::new(mock.clone());
|
||
mock.set_now(1);
|
||
client.heartbeat(); // last_heartbeat = 1
|
||
mock.hang(); // SoC завис
|
||
mock.set_now(2);
|
||
client.heartbeat(); // hung → таймер НЕ освежается
|
||
assert!(!mock.failsafe_due()); // 2−1=1, ≤ 3
|
||
mock.set_now(5); // 5−1=4 > FAILSAFE_MISS*HEARTBEAT (3)
|
||
assert!(mock.failsafe_due());
|
||
}
|
||
|
||
#[test]
|
||
fn failsafe_on_holdup_budget() {
|
||
let mock = Arc::new(MockCoprocessor::new());
|
||
let mut client = CoprocessorClient::new(mock.clone());
|
||
mock.set_now(10);
|
||
client.shutdown_imminent(2); // shutdown_at = 10
|
||
mock.set_now(14);
|
||
assert!(!mock.failsafe_due()); // 14−10=4 ≤ 5
|
||
mock.set_now(16); // > HOLDUP_BUDGET (5)
|
||
assert!(mock.failsafe_due());
|
||
// safe-to-cut снимает failsafe
|
||
mock.set_now(11);
|
||
client.safe_to_cut();
|
||
mock.set_now(20);
|
||
assert!(!mock.failsafe_due());
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Шаг 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<AtomicI32>,
|
||
}
|
||
impl MockTempSource {
|
||
pub fn new(init_c: i32) -> Self {
|
||
Self { temp: Arc::new(AtomicI32::new(init_c)) }
|
||
}
|
||
pub fn set(&self, c: i32) {
|
||
self.temp.store(c, Ordering::Relaxed);
|
||
}
|
||
}
|
||
impl TempSource for MockTempSource {
|
||
fn read_celsius(&self) -> i32 {
|
||
self.temp.load(Ordering::Relaxed)
|
||
}
|
||
}
|
||
|
||
/// Прод: max по `/sys/class/thermal/thermal_zone*/temp` (миллиградусы). В Lima зоны статичны → числа на RK3588.
|
||
#[derive(Default)]
|
||
pub struct SysfsTempSource;
|
||
impl SysfsTempSource {
|
||
pub fn new() -> Self {
|
||
Self
|
||
}
|
||
}
|
||
impl TempSource for SysfsTempSource {
|
||
fn read_celsius(&self) -> i32 {
|
||
let mut max = i32::MIN;
|
||
if let Ok(rd) = std::fs::read_dir("/sys/class/thermal") {
|
||
for e in rd.flatten() {
|
||
let p = e.path().join("temp");
|
||
if let Ok(s) = std::fs::read_to_string(&p) {
|
||
if let Ok(milli) = s.trim().parse::<i32>() {
|
||
max = max.max(milli / 1000);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if max == i32::MIN {
|
||
0
|
||
} else {
|
||
max
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Применение throttle. real = cpufreq (HW); VM = запись уровня (no-op-эффект).
|
||
pub trait Throttler: Send + Sync {
|
||
fn apply(&self, level: ThermalLevel);
|
||
}
|
||
|
||
/// VM/прод-каркас: логирует + запоминает последний уровень (реальный cpufreq — HW).
|
||
#[derive(Default, Clone)]
|
||
pub struct NoopThrottler {
|
||
last: Arc<std::sync::Mutex<Option<ThermalLevel>>>,
|
||
}
|
||
impl NoopThrottler {
|
||
pub fn last(&self) -> Option<ThermalLevel> {
|
||
*self.last.lock().unwrap()
|
||
}
|
||
}
|
||
impl Throttler for NoopThrottler {
|
||
fn apply(&self, level: ThermalLevel) {
|
||
*self.last.lock().unwrap() = Some(level);
|
||
tracing::info!("thermal: throttle уровень {} (эффект cpufreq — HW)", level.as_str());
|
||
}
|
||
}
|
||
|
||
/// Результат шага монитора: уровень + рёбра входа/выхода Critical (для FSM ThermalTrip/ThermalCleared).
|
||
#[derive(Debug, PartialEq, Eq)]
|
||
pub struct ThermalObservation {
|
||
pub level: ThermalLevel,
|
||
pub changed: bool,
|
||
pub entered_critical: bool,
|
||
pub left_critical: bool,
|
||
}
|
||
|
||
/// Монитор: хранит предыдущий уровень, применяет политику, размечает рёбра Critical.
|
||
pub struct ThermalMonitor {
|
||
prev: ThermalLevel,
|
||
}
|
||
impl Default for ThermalMonitor {
|
||
fn default() -> Self {
|
||
Self { prev: ThermalLevel::Normal }
|
||
}
|
||
}
|
||
impl ThermalMonitor {
|
||
pub fn new() -> Self {
|
||
Self::default()
|
||
}
|
||
pub fn observe(&mut self, temp_c: i32) -> ThermalObservation {
|
||
let level = ThermalPolicy::next(self.prev, temp_c);
|
||
let changed = level != self.prev;
|
||
let entered_critical = changed && level == ThermalLevel::Critical;
|
||
let left_critical = changed && self.prev == ThermalLevel::Critical;
|
||
self.prev = level;
|
||
ThermalObservation { level, changed, entered_critical, left_critical }
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Шаг 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<Mutex<PowerFsm>>,
|
||
thermal_state: Arc<Mutex<ThermalLevel>>,
|
||
}
|
||
|
||
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<Mutex<PowerFsm>> {
|
||
Arc::clone(&self.fsm)
|
||
}
|
||
pub fn thermal_state_handle(&self) -> Arc<Mutex<ThermalLevel>> {
|
||
Arc::clone(&self.thermal_state)
|
||
}
|
||
|
||
#[cfg(feature = "dev-mocks")]
|
||
pub fn mock(
|
||
&self,
|
||
temp: Arc<crate::thermal::MockTempSource>,
|
||
copro: Arc<crate::coprocessor::MockCoprocessor>,
|
||
) -> 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<Mutex<PowerFsm>>,
|
||
thermal_state: Arc<Mutex<crate::thermal::ThermalLevel>>,
|
||
temp: Arc<dyn TempSource>,
|
||
throttler: Arc<dyn Throttler>,
|
||
copro: Arc<dyn Coprocessor>,
|
||
ctx: SignalContext<'static>,
|
||
) {
|
||
// thermal-цикл
|
||
{
|
||
let (fsm, ctx) = (Arc::clone(&fsm), ctx.clone());
|
||
tokio::spawn(async move {
|
||
let mut mon = ThermalMonitor::new();
|
||
loop {
|
||
tokio::time::sleep(Duration::from_secs(crate::thermal::POLL_SECS)).await;
|
||
let 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<Mutex<PowerFsm>>,
|
||
temp: Arc<crate::thermal::MockTempSource>,
|
||
copro: Arc<crate::coprocessor::MockCoprocessor>,
|
||
}
|
||
```
|
||
|
||
В `#[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<dyn shturman_power::thermal::TempSource>,
|
||
throttler as std::sync::Arc<dyn shturman_power::thermal::Throttler>,
|
||
copro as std::sync::Arc<dyn shturman_power::coprocessor::Coprocessor>,
|
||
ctx,
|
||
);
|
||
|
||
tracing::info!("ru.shturman.Power1 на шине (FSM + thermal + coprocessor)");
|
||
std::future::pending::<()>().await;
|
||
Ok(())
|
||
}
|
||
```
|
||
|
||
- [ ] **Шаг 8 — `proxy.rs`: добавить в `Power1Proxy`.** Рядом с существующими property/signal:
|
||
|
||
```rust
|
||
#[zbus(property)]
|
||
fn thermal_state(&self) -> zbus::Result<String>;
|
||
|
||
#[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<dyn shturman_power::thermal::TempSource>,
|
||
Arc::new(NoopThrottler::default()) as Arc<dyn shturman_power::thermal::Throttler>,
|
||
copro as Arc<dyn shturman_power::coprocessor::Coprocessor>,
|
||
ctx,
|
||
);
|
||
|
||
let client = zbus::Connection::session().await.unwrap();
|
||
let power = PowerClient::new(&client).await.unwrap();
|
||
let mut imminent = power.proxy().receive_shutdown_imminent().await.unwrap();
|
||
let mut aborted = power.proxy().receive_shutdown_aborted().await.unwrap();
|
||
|
||
// перегрев → ShutdownImminent(thermal)
|
||
temp.set(99);
|
||
let sig = imminent.next().await.unwrap();
|
||
assert_eq!(sig.args().unwrap().reason(), "thermal");
|
||
assert_eq!(power.power_state().await.unwrap(), PowerState::ShuttingDown);
|
||
|
||
// остыло до PONR → ShutdownAborted + running
|
||
temp.set(20);
|
||
aborted.next().await.unwrap();
|
||
assert_eq!(power.power_state().await.unwrap(), PowerState::Running);
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[ignore = "нужна session-шина: just test-integration"]
|
||
async fn mcu_failsafe_cuts_on_hang() {
|
||
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<dyn shturman_power::thermal::TempSource>,
|
||
Arc::new(NoopThrottler::default()) as Arc<dyn shturman_power::thermal::Throttler>,
|
||
copro.clone() as Arc<dyn shturman_power::coprocessor::Coprocessor>,
|
||
ctx,
|
||
);
|
||
|
||
let client = zbus::Connection::session().await.unwrap();
|
||
let power = PowerClient::new(&client).await.unwrap();
|
||
assert_eq!(power.power_state().await.unwrap(), PowerState::Running);
|
||
|
||
// дать coproc-циклу послать ≥1 heartbeat (иначе last_heartbeat=0 и guard не даст cut)
|
||
tokio::time::sleep(std::time::Duration::from_millis(1300)).await;
|
||
copro.hang(); // SoC завис → heartbeat не освежает таймер
|
||
// ждём, пока coproc-цикл (HEARTBEAT=1с) накопит > FAILSAFE_MISS окон и сделает FailsafeCut
|
||
for _ in 0..10 {
|
||
tokio::time::sleep(std::time::Duration::from_millis(700)).await;
|
||
if power.power_state().await.unwrap() == PowerState::Off {
|
||
break;
|
||
}
|
||
}
|
||
assert_eq!(power.power_state().await.unwrap(), PowerState::Off);
|
||
}
|
||
```
|
||
|
||
- [ ] **Шаг 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).
|
||
```
|