12 Commits

Author SHA1 Message Date
kk0t9 737cb04f3a docs(v0.4): синхронизация швов MCU/thermal + статус
domain B (A12/B08/B09/B10 софт/модель; физический выбор → HW-bring-up), ipc §3 (Power +=
ThermalState/ThermalChanged), hardware §3 (B08/B09 статус v0.4 + 🟡 HW), capability-catalog
(A12/B10 , B08/B09 софт+модель), CLAUDE.md (статус v0.4 ГОТОВО → v0.5).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Alexander <akotenev2003@gmail.com>
2026-06-25 15:52:13 +03:00
kk0t9 50fdaab25b style(v0.4): rustfmt thermal/protocol/coprocessor/service/integration
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Alexander <akotenev2003@gmail.com>
2026-06-25 15:44:10 +03:00
kk0t9 a050f57241 test(v0.4): E2E-блок thermal-trip + throttling + MCU fail-safe (мок-sensor/MCU)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Alexander <akotenev2003@gmail.com>
2026-06-25 15:43:10 +03:00
kk0t9 32ba1136c7 test(v0.4): integration — thermal-trip/abort + MCU fail-safe-cut (session-шина)
+ фикс B09-таймера: last_heartbeat Option<u64> вместо сентинела 0 (monotonic_secs() стартует
с 0 → первый heartbeat попадал на 0, guard !=0 ложно трактовал как «не было» → cut не срабатывал).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Alexander <akotenev2003@gmail.com>
2026-06-25 15:42:20 +03:00
kk0t9 cd2442f672 feat(v0.4): проводка thermal+coprocessor циклов + D-Bus ThermalState/ThermalChanged + dev-mock
spawn_loops (thermal-монитор + coprocessor heartbeat/wait/safe-to-cut/B09) в main после
регистрации интерфейса; PowerService += thermal_state + хендлы fsm/thermal_state + mock(temp,copro);
dev-mock += SetTemp/HangSoc/McuLinkLoss; proxy.rs += thermal_state/thermal_changed. Existing
integration-тесты подогнаны под mock(temp,copro).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Alexander <akotenev2003@gmail.com>
2026-06-25 15:36:19 +03:00
kk0t9 2e6144c54f feat(v0.4): TempSource/Throttler-абстракции + ThermalMonitor (A12/B10)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Alexander <akotenev2003@gmail.com>
2026-06-25 15:32:53 +03:00
kk0t9 860a591f16 feat(v0.4): Coprocessor trait + MockCoprocessor (B09-модель) + клиент (B08)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Alexander <akotenev2003@gmail.com>
2026-06-25 15:31:55 +03:00
kk0t9 147b20ddb6 feat(v0.4): SoC↔MCU протокол + кодек (CRC/replay/desync-guard, B08)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Alexander <akotenev2003@gmail.com>
2026-06-25 15:30:55 +03:00
kk0t9 e54a34cd64 feat(v0.4): FSM ThermalCleared (abort thermal) + FailsafeCut (MCU cut)
+ Action::Cut и его хендлер в apply_event (нужен для компиляции крейта — P8.6 шаг 2 сделан здесь).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Alexander <akotenev2003@gmail.com>
2026-06-25 15:29:36 +03:00
kk0t9 b9ae2f23d5 feat(v0.4): чистый ThermalPolicy (банды + гистерезис, A12/B10)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Alexander <akotenev2003@gmail.com>
2026-06-25 15:27:29 +03:00
kk0t9 fb31a288c3 docs(v0.4): план реализации MCU/thermal (План 8)
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>
2026-06-25 15:18:11 +03:00
kk0t9 c377a34c4f docs(v0.4): спека MCU/thermal fail-safe (тепловой триггер + MCU-протокол + fail-safe)
Веха v0.4 (A12/B08/B09/B10) поверх живого FSM v0.3. Решение брейнсторма: разработка без
платы (принцип #13) — MCU-копилот как reference-арх, софт+симуляция, физический выбор
B08/B09 + железо отложены в HW-bring-up-подфазу. Симметрия с v0.3: чистое ядро (ThermalPolicy/
codec) → абстракция (TempSource/Throttler/Coprocessor trait) → dev-mock.

Скоуп: тепловая политика+гистерезис → Event::ThermalTrip (реюз FSM); SoC↔MCU протокол + кодек
(CRC/replay/desync-guard) + CoprocessorClient (heartbeat/wait-for-completion/safe-to-cut);
B09 fail-safe-таймер — модель (hang/budget → Event::FailsafeCut → off); D-Bus ThermalState/
ThermalChanged (контракт сейчас, рендер v0.5). Приёмка §9.4; швы §10.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Alexander <akotenev2003@gmail.com>
2026-06-25 15:00:10 +03:00
18 changed files with 2800 additions and 18 deletions
+13 -1
View File
@@ -67,7 +67,19 @@ save-time timer (B07). `just vm-reset && just e2e` зелёный с нуля: *
регресс v0.1/v0.2 + machine-id-стабильность цел. prod-build-gate: `--no-default-features` без `PowerMock1`. Приёмка спека
v0.3 §9.4 выполнена. **VM-модель** (abort/PONR = stop+umount+remount); аппаратное (MCU/hold-up/fail-safe-таймер, **B08/B09**) → v0.4.
**Следующее:** `v0.5` полный shell (живой weston-shell) и `v0.4` MCU/thermal (замыкает B08/B09) — параллельно поверх v0.2/v0.3.
**v0.4 MCU/thermal fail-safe — ГОТОВО (ветка `feat/v0.4-mcu-thermal`):** спека `docs/specs/v0.4-mcu-thermal.md` + план
`docs/specs/plans/08-v0.4-mcu-thermal.md`. Поверх FSM v0.3, в стиле «чистое ядро → абстракция → dev-mock»: **A12/B10**
тепло — чистая `ThermalPolicy` (банды + гистерезис) → `Event::ThermalTrip` (реюз FSM) + abort `ThermalCleared`;
`TempSource`/`Throttler` абстракции (VM mock/noop; sysfs/cpufreq + пороги → RK3588). **B08** MCU-протокол
(`SocToMcu`/`McuToSoc`) + кодек (CRC16/replay/desync-guard) + `CoprocessorClient` (heartbeat/wait-for-completion/
`safe-to-cut`). **B09** fail-safe-таймер — **модель** (`MockCoprocessor`: hang/budget → `Event::FailsafeCut` → off).
`ru.shturman.Power` += `ThermalState`/`ThermalChanged` (рендер «перегрев» → v0.5). `just vm-reset && just e2e` зелёный
с нуля: thermal-trip→`ShutdownImminent(thermal)`, throttle-банд, **MCU fail-safe (HangSoc → cut)**; регресс v0.1v0.3
цел. prod-build-gate без `PowerMock1`/`SetTemp`/`HangSoc`. Приёмка спека v0.4 §9.4 выполнена. **Физический выбор B08/B09
(MCU vs supercap-only) + реальное железо (UART/MCU-чип/таймер/cpufreq) → HW-bring-up-подфаза** (нужна плата RK3588).
**Следующее:** `v0.5` полный shell (живой weston-shell; замкнёт thermal-UX-рендер) — поверх v0.2. **HW-bring-up**
(MCU/supercap, реальный UART/cpufreq/B09-чип, тепловой/перф-вердикт) — отдельной подфазой при появлении платы.
> CI: GitHub-Actions-конфиг **удалён** (его ловит Gitea). Гейт — локальный `just ci`. CI на Gitea — решение позже.
+150
View File
@@ -0,0 +1,150 @@
//! Кадр SoC↔MCU + защита линка (B08, спека v0.4 §6.2). Кадр: [SYNC][LEN][SEQ][TYPE][PAYLOAD..][CRC16].
//! CRC16-CCITT по LEN..=PAYLOAD. Декодер: resync по SYNC, drop при битом CRC, drop-replay (seq==last).
pub const SYNC: u8 = 0xA5;
pub fn crc16_ccitt(data: &[u8]) -> u16 {
let mut crc: u16 = 0xFFFF;
for &b in data {
crc ^= (b as u16) << 8;
for _ in 0..8 {
crc = if crc & 0x8000 != 0 {
(crc << 1) ^ 0x1021
} else {
crc << 1
};
}
}
crc
}
pub fn encode_frame(seq: u8, msg_type: u8, payload: &[u8]) -> Vec<u8> {
let mut body = Vec::with_capacity(3 + payload.len());
body.push(payload.len() as u8); // LEN
body.push(seq); // SEQ
body.push(msg_type); // TYPE
body.extend_from_slice(payload);
let crc = crc16_ccitt(&body);
let mut frame = Vec::with_capacity(1 + body.len() + 2);
frame.push(SYNC);
frame.extend_from_slice(&body);
frame.push((crc >> 8) as u8);
frame.push((crc & 0xff) as u8);
frame
}
#[derive(Debug, PartialEq, Eq)]
pub struct DecodedFrame {
pub seq: u8,
pub msg_type: u8,
pub payload: Vec<u8>,
}
/// Потоковый декодер: накапливает байты, выдаёт валидные кадры. Resync/replay-guard внутри.
#[derive(Default)]
pub struct FrameDecoder {
buf: Vec<u8>,
last_seq: Option<u8>,
}
impl FrameDecoder {
pub fn push(&mut self, bytes: &[u8]) -> Vec<DecodedFrame> {
self.buf.extend_from_slice(bytes);
let mut out = Vec::new();
loop {
// resync: отбросить мусор до SYNC
while !self.buf.is_empty() && self.buf[0] != SYNC {
self.buf.remove(0);
}
if self.buf.len() < 4 {
break; // нужно минимум SYNC+LEN+SEQ+TYPE
}
let len = self.buf[1] as usize;
let frame_len = 1 + 3 + len + 2;
if self.buf.len() < frame_len {
break; // кадр ещё не дочитан
}
let body = &self.buf[1..1 + 3 + len];
let crc_rx = ((self.buf[1 + 3 + len] as u16) << 8) | self.buf[1 + 3 + len + 1] as u16;
if crc_rx != crc16_ccitt(body) {
self.buf.remove(0); // битый CRC → сдвиг, resync
continue;
}
let seq = self.buf[2];
let msg_type = self.buf[3];
let payload = self.buf[4..4 + len].to_vec();
self.buf.drain(0..frame_len);
let replay = matches!(self.last_seq, Some(l) if l == seq);
self.last_seq = Some(seq);
if !replay {
out.push(DecodedFrame {
seq,
msg_type,
payload,
});
}
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roundtrip() {
let f = encode_frame(7, 0x02, &[42]);
let mut d = FrameDecoder::default();
let out = d.push(&f);
assert_eq!(
out,
vec![DecodedFrame {
seq: 7,
msg_type: 0x02,
payload: vec![42]
}]
);
}
#[test]
fn corruption_dropped_then_resyncs() {
let mut d = FrameDecoder::default();
let mut f = encode_frame(1, 0x01, &[]);
f[4] ^= 0xFF; // флип в CRC/payload-зоне → битый CRC
assert_eq!(d.push(&f), vec![]); // отброшен
let g = encode_frame(2, 0x01, &[]);
assert_eq!(
d.push(&g),
vec![DecodedFrame {
seq: 2,
msg_type: 0x01,
payload: vec![]
}]
);
}
#[test]
fn replay_dropped() {
let mut d = FrameDecoder::default();
let f = encode_frame(5, 0x01, &[]);
assert_eq!(d.push(&f).len(), 1);
assert_eq!(d.push(&f), vec![]); // тот же seq → replay drop
}
#[test]
fn desync_garbage_before_sync() {
let mut d = FrameDecoder::default();
let mut stream = vec![0x00, 0xFF, 0x13]; // мусор
stream.extend_from_slice(&encode_frame(9, 0x83, &[0x2B, 0x67]));
let out = d.push(&stream);
assert_eq!(
out,
vec![DecodedFrame {
seq: 9,
msg_type: 0x83,
payload: vec![0x2B, 0x67]
}]
);
}
}
@@ -0,0 +1,208 @@
//! SoC↔MCU копроцессор (B08/B09, спека v0.4 §6.3–§6.4). Транспорт байт-уровневый (codec исполняется реально).
//! `MockCoprocessor` моделирует MCU + независимый fail-safe-таймер (B09). Прод `SerialCoprocessor` — стаб (UART → HW).
use crate::codec::{encode_frame, FrameDecoder};
use crate::protocol::{McuToSoc, SocToMcu};
use std::sync::{Arc, Mutex};
// Тайминги — placeholder (сек). Тюнинг hold-up — hardware §3 (RK3588).
pub const HEARTBEAT_SECS: u64 = 1;
pub const FAILSAFE_MISS: u64 = 3; // пропущено heartbeat-окон в running → SoC завис
pub const HOLDUP_BUDGET_SECS: u64 = 5; // shutdown без safe-to-cut дольше → cut
pub const BROWNOUT_MV: u16 = 11_000; // under-voltage backstop (placeholder, hardware §3)
/// Байт-уровневый линк к MCU. `failsafe_due`/`set_now` — модель B09 (прод: реальный MCU в железе → default).
pub trait Coprocessor: Send + Sync {
fn tx(&self, bytes: &[u8]); // SoC → MCU
fn rx(&self) -> Vec<u8>; // MCU → SoC (drain)
fn failsafe_due(&self) -> bool {
false
}
fn set_now(&self, _secs: u64) {}
}
/// SoC-сторона: heartbeat / shutdown-imminent / safe-to-cut + декод входящих MCU→SoC.
pub struct CoprocessorClient {
link: Arc<dyn Coprocessor>,
seq: u8,
decoder: FrameDecoder,
}
impl CoprocessorClient {
pub fn new(link: Arc<dyn Coprocessor>) -> Self {
Self {
link,
seq: 0,
decoder: FrameDecoder::default(),
}
}
fn send(&mut self, msg: SocToMcu) {
self.seq = self.seq.wrapping_add(1);
let f = encode_frame(self.seq, msg.wire_type(), &msg.payload());
self.link.tx(&f);
}
pub fn heartbeat(&mut self) {
self.send(SocToMcu::Heartbeat);
}
pub fn shutdown_imminent(&mut self, budget: u8) {
self.send(SocToMcu::ShutdownImminent { budget });
}
pub fn safe_to_cut(&mut self) {
self.send(SocToMcu::SafeToCut);
}
pub fn poll(&mut self) -> Vec<McuToSoc> {
let bytes = self.link.rx();
self.decoder
.push(&bytes)
.into_iter()
.filter_map(|f| McuToSoc::from_wire(f.msg_type, &f.payload))
.collect()
}
}
#[derive(Default)]
struct MockState {
soc_decoder: FrameDecoder,
mcu_seq: u8,
out: Vec<u8>,
last_heartbeat: Option<u64>, // None = heartbeat ещё не было (sentinel 0 коллизировал с monotonic-стартом)
shutdown_at: Option<u64>,
safe_to_cut: bool,
hung: bool,
now: u64,
}
/// Мок MCU: декодит SoC-кадры (через реальный codec), моделирует B09-таймер, эмитит MCU→SoC.
#[derive(Clone, Default)]
pub struct MockCoprocessor {
st: Arc<Mutex<MockState>>,
}
impl MockCoprocessor {
pub fn new() -> Self {
Self::default()
}
/// MCU → SoC (dev-mock кормит ACC/voltage отсюда).
pub fn emit(&self, msg: McuToSoc) {
let mut s = self.st.lock().unwrap();
s.mcu_seq = s.mcu_seq.wrapping_add(1);
let seq = s.mcu_seq;
let f = encode_frame(seq, msg.wire_type(), &msg.payload());
s.out.extend_from_slice(&f);
}
/// `HangSoc()` — SoC «завис»: heartbeat больше не освежает таймер → сработает B09.
pub fn hang(&self) {
self.st.lock().unwrap().hung = true;
}
}
impl Coprocessor for MockCoprocessor {
fn tx(&self, bytes: &[u8]) {
let mut s = self.st.lock().unwrap();
let now = s.now;
for f in s.soc_decoder.push(bytes) {
if let Some(msg) = SocToMcu::from_wire(f.msg_type, &f.payload) {
match msg {
SocToMcu::Heartbeat => {
if !s.hung {
s.last_heartbeat = Some(now);
}
}
SocToMcu::ShutdownImminent { .. } => s.shutdown_at = Some(now),
SocToMcu::SafeToCut => s.safe_to_cut = true,
}
}
}
}
fn rx(&self) -> Vec<u8> {
std::mem::take(&mut self.st.lock().unwrap().out)
}
fn failsafe_due(&self) -> bool {
let s = self.st.lock().unwrap();
match s.shutdown_at {
// running: тишина heartbeat дольше FAILSAFE_MISS окон → SoC завис → cut
// None last_heartbeat = heartbeat ещё не было → не режем (startup, не зависание)
None => s
.last_heartbeat
.is_some_and(|h| s.now.saturating_sub(h) > FAILSAFE_MISS * HEARTBEAT_SECS),
// shutdown: бюджет истёк без safe-to-cut → cut
Some(t0) => !s.safe_to_cut && s.now.saturating_sub(t0) > HOLDUP_BUDGET_SECS,
}
}
fn set_now(&self, secs: u64) {
self.st.lock().unwrap().now = secs;
}
}
/// Прод-стаб: реальный UART/I2C — HW-bring-up-подфаза. В v0.4 не активен (прод-источника событий нет).
#[derive(Default)]
pub struct SerialCoprocessor;
impl SerialCoprocessor {
pub fn new() -> Self {
Self
}
}
impl Coprocessor for SerialCoprocessor {
fn tx(&self, _bytes: &[u8]) {}
fn rx(&self) -> Vec<u8> {
Vec::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn client_heartbeat_decoded_by_mock() {
let mock = Arc::new(MockCoprocessor::new());
mock.set_now(1);
let mut client = CoprocessorClient::new(mock.clone());
client.heartbeat();
// heartbeat освежил таймер → нет failsafe
assert!(!mock.failsafe_due());
}
#[test]
fn mcu_to_soc_roundtrip() {
let mock = Arc::new(MockCoprocessor::new());
let mut client = CoprocessorClient::new(mock.clone());
mock.emit(McuToSoc::Acc { on: true });
mock.emit(McuToSoc::Voltage { mv: 13_800 });
assert_eq!(
client.poll(),
vec![McuToSoc::Acc { on: true }, McuToSoc::Voltage { mv: 13_800 }]
);
}
#[test]
fn failsafe_on_soc_hang() {
let mock = Arc::new(MockCoprocessor::new());
let mut client = CoprocessorClient::new(mock.clone());
mock.set_now(1);
client.heartbeat(); // last_heartbeat = 1
mock.hang(); // SoC завис
mock.set_now(2);
client.heartbeat(); // hung → таймер НЕ освежается
assert!(!mock.failsafe_due()); // 21=1, ≤ 3
mock.set_now(5); // 51=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()); // 1410=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());
}
}
+49
View File
@@ -30,6 +30,8 @@ pub enum Event {
UnderVoltage,
ThermalTrip,
GraceExpired,
ThermalCleared, // тепло вернулось в норму до PONR → abort thermal-shutdown (гейт reason==Thermal)
FailsafeCut, // MCU-авторитетный cut (зависший SoC / истёк hold-up) → off, необратимо
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -39,6 +41,7 @@ pub enum Action {
AccChanged(bool),
StartGrace,
Commit,
Cut, // MCU снял питание (fail-safe) — сервис логирует + переходит в off
}
pub struct PowerFsm {
@@ -149,6 +152,23 @@ impl PowerFsm {
};
vec![Action::Commit]
}
// тепло вернулось до PONR → abort (только thermal-shutdown; ACC-off/under-voltage — no-op)
(
ShuttingDown {
phase: Abortable,
reason: ShutdownReason::Thermal,
},
E::ThermalCleared,
) => {
self.state = Running;
vec![Action::ShutdownAborted]
}
// MCU fail-safe cut → off из любого не-off (необратимо, MCU-авторитет)
(Off, E::FailsafeCut) => vec![],
(_, E::FailsafeCut) => {
self.state = Off;
vec![Action::Cut]
}
// committed/off/sleep/battery_cutoff + всё прочее — no-op (committed не abort-ится)
_ => vec![],
}
@@ -264,4 +284,33 @@ mod tests {
IgnitionState::Off
);
}
#[test]
fn thermal_cleared_aborts_only_thermal_abortable() {
let mut f = PowerFsm::new(); // Running
f.step(Event::ThermalTrip); // → ShuttingDown{Abortable, Thermal}
assert_eq!(f.step(Event::ThermalCleared), vec![Action::ShutdownAborted]);
assert_eq!(f.state(), State::Running);
// из ACC-off-shutdown ThermalCleared — no-op
let mut g = PowerFsm::new();
g.step(Event::AccOff);
assert_eq!(g.step(Event::ThermalCleared), vec![]);
assert_eq!(g.power_state(), PowerState::ShuttingDown);
}
#[test]
fn failsafe_cut_forces_off_from_any_nonoff() {
let mut f = PowerFsm::new(); // Running
assert_eq!(f.step(Event::FailsafeCut), vec![Action::Cut]);
assert_eq!(f.state(), State::Off);
// из off — no-op
assert_eq!(f.step(Event::FailsafeCut), vec![]);
// даже из committed (необратимый shutdown) cut уводит в off
let mut g = PowerFsm::new();
g.step(Event::AccOff);
g.step(Event::GraceExpired); // committed
assert_eq!(g.step(Event::FailsafeCut), vec![Action::Cut]);
assert_eq!(g.state(), State::Off);
}
}
+4
View File
@@ -1,7 +1,11 @@
//! `ru.shturman.Power1` — питание/жизненный цикл (домен B). v0.3: реальный lifecycle-FSM
//! (`fsm`), сервис оборачивает его (D-Bus state/signals из FSM; dev-mock кормит входы).
pub mod codec;
pub mod coprocessor;
pub mod fsm;
pub mod protocol;
pub mod service;
pub mod thermal;
pub use service::PowerService;
+33 -2
View File
@@ -10,13 +10,44 @@ async fn main() -> anyhow::Result<()> {
init_tracing("shturman-power");
let conn = connect().await?;
let svc = PowerService::new();
let fsm = svc.fsm_handle();
let thermal_state = svc.thermal_state_handle();
// источники: dev = mock (управляется dev-D-Bus), prod = реальные (sysfs/UART)
#[cfg(feature = "dev-mocks")]
let mock = svc.mock();
let temp = std::sync::Arc::new(shturman_power::thermal::MockTempSource::new(20));
#[cfg(not(feature = "dev-mocks"))]
let temp = std::sync::Arc::new(shturman_power::thermal::SysfsTempSource::new());
let throttler = std::sync::Arc::new(shturman_power::thermal::NoopThrottler::default());
#[cfg(feature = "dev-mocks")]
let copro = std::sync::Arc::new(shturman_power::coprocessor::MockCoprocessor::new());
#[cfg(not(feature = "dev-mocks"))]
let copro = std::sync::Arc::new(shturman_power::coprocessor::SerialCoprocessor::new());
#[cfg(feature = "dev-mocks")]
let mock = svc.mock(temp.clone(), copro.clone());
conn.object_server().at(names::power::PATH, svc).await?;
#[cfg(feature = "dev-mocks")]
conn.object_server().at(names::power::PATH, mock).await?;
conn.request_name(names::power::NAME).await?;
tracing::info!("ru.shturman.Power1 на шине");
// контекст сигналов для фоновых циклов (после регистрации интерфейса)
let iface = conn
.object_server()
.interface::<_, PowerService>(names::power::PATH)
.await?;
let ctx = iface.signal_context().to_owned();
shturman_power::service::spawn_loops(
fsm,
thermal_state,
temp as std::sync::Arc<dyn shturman_power::thermal::TempSource>,
throttler as std::sync::Arc<dyn shturman_power::thermal::Throttler>,
copro as std::sync::Arc<dyn shturman_power::coprocessor::Coprocessor>,
ctx,
);
tracing::info!("ru.shturman.Power1 на шине (FSM + thermal + coprocessor)");
std::future::pending::<()>().await;
Ok(())
}
@@ -0,0 +1,83 @@
//! Типы сообщений SoC↔MCU (B08, спека v0.4 §6.1). seq — поле кадра (codec), не сообщения.
pub mod wire {
pub const HEARTBEAT: u8 = 0x01;
pub const SHUTDOWN_IMMINENT: u8 = 0x02;
pub const SAFE_TO_CUT: u8 = 0x03;
pub const ACK: u8 = 0x81;
pub const ACC: u8 = 0x82;
pub const VOLTAGE: u8 = 0x83;
pub const CUT_WARNING: u8 = 0x84;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SocToMcu {
Heartbeat,
ShutdownImminent { budget: u8 },
SafeToCut,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum McuToSoc {
Ack,
Acc { on: bool },
Voltage { mv: u16 },
CutWarning,
}
impl SocToMcu {
pub fn wire_type(&self) -> u8 {
match self {
SocToMcu::Heartbeat => wire::HEARTBEAT,
SocToMcu::ShutdownImminent { .. } => wire::SHUTDOWN_IMMINENT,
SocToMcu::SafeToCut => wire::SAFE_TO_CUT,
}
}
pub fn payload(&self) -> Vec<u8> {
match self {
SocToMcu::ShutdownImminent { budget } => vec![*budget],
_ => vec![],
}
}
pub fn from_wire(t: u8, p: &[u8]) -> Option<Self> {
match t {
wire::HEARTBEAT => Some(SocToMcu::Heartbeat),
wire::SHUTDOWN_IMMINENT => Some(SocToMcu::ShutdownImminent {
budget: *p.first()?,
}),
wire::SAFE_TO_CUT => Some(SocToMcu::SafeToCut),
_ => None,
}
}
}
impl McuToSoc {
pub fn wire_type(&self) -> u8 {
match self {
McuToSoc::Ack => wire::ACK,
McuToSoc::Acc { .. } => wire::ACC,
McuToSoc::Voltage { .. } => wire::VOLTAGE,
McuToSoc::CutWarning => wire::CUT_WARNING,
}
}
pub fn payload(&self) -> Vec<u8> {
match self {
McuToSoc::Acc { on } => vec![*on as u8],
McuToSoc::Voltage { mv } => mv.to_be_bytes().to_vec(),
_ => vec![],
}
}
pub fn from_wire(t: u8, p: &[u8]) -> Option<Self> {
match t {
wire::ACK => Some(McuToSoc::Ack),
wire::ACC => Some(McuToSoc::Acc {
on: *p.first()? != 0,
}),
wire::VOLTAGE => Some(McuToSoc::Voltage {
mv: u16::from_be_bytes([*p.first()?, *p.get(1)?]),
}),
wire::CUT_WARNING => Some(McuToSoc::CutWarning),
_ => None,
}
}
}
+145 -3
View File
@@ -1,9 +1,12 @@
//! Server `ru.shturman.Power1` + (feature `dev-mocks`) `ru.shturman.dev.PowerMock1` (fake-ACC).
//! v0.3: оборачивает чистый `PowerFsm` (спека §5–§7). dev-mock кормит входы FSM (не флипает состояние).
use crate::fsm::{Action, Event, PowerFsm};
use crate::coprocessor::{Coprocessor, CoprocessorClient, BROWNOUT_MV};
use crate::fsm::{Action, Event, Phase, PowerFsm, State};
use crate::protocol::McuToSoc;
use crate::thermal::{TempSource, ThermalLevel, ThermalMonitor, Throttler};
use shturman_common::monotonic_secs;
use shturman_ipc::types::{IgnitionState, PowerSource, PowerState};
use shturman_ipc::types::{IgnitionState, PowerSource, PowerState, ShutdownReason};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use zbus::interface;
@@ -14,12 +17,14 @@ const GRACE_SECS: u32 = 2;
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)),
}
}
}
@@ -37,11 +42,23 @@ impl PowerService {
pub fn source(&self) -> 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) -> PowerMock {
pub fn mock(
&self,
temp: Arc<crate::thermal::MockTempSource>,
copro: Arc<crate::coprocessor::MockCoprocessor>,
) -> PowerMock {
PowerMock {
fsm: Arc::clone(&self.fsm),
temp,
copro,
}
}
}
@@ -81,11 +98,107 @@ async fn apply_event(
});
}
Action::Commit => durable_barrier(),
Action::Cut => {
tracing::warn!("power: MCU fail-safe cut (SoC hang / hold-up budget) — forced off");
}
}
}
Ok(())
}
/// Фоновые циклы v0.4 — thermal-монитор + coprocessor (heartbeat/wait/safe-to-cut/B09). Монотоника.
#[allow(clippy::too_many_arguments)]
pub fn spawn_loops(
fsm: Arc<Mutex<PowerFsm>>,
thermal_state: Arc<Mutex<ThermalLevel>>,
temp: Arc<dyn TempSource>,
throttler: Arc<dyn Throttler>,
copro: Arc<dyn Coprocessor>,
ctx: SignalContext<'static>,
) {
// thermal-цикл
{
let (fsm, ctx) = (Arc::clone(&fsm), ctx.clone());
tokio::spawn(async move {
let mut mon = ThermalMonitor::new();
loop {
tokio::time::sleep(Duration::from_secs(crate::thermal::POLL_SECS)).await;
let t = temp.read_celsius();
let obs = mon.observe(t);
if !obs.changed {
continue;
}
throttler.apply(obs.level);
*thermal_state.lock().unwrap() = obs.level;
let _ = PowerService::thermal_changed(&ctx, obs.level.as_str(), t).await;
if obs.entered_critical {
let _ = apply_event(&fsm, Event::ThermalTrip, &ctx).await;
}
if obs.left_critical {
let in_thermal_abortable = matches!(
fsm.lock().unwrap().state(),
State::ShuttingDown {
phase: Phase::Abortable,
reason: ShutdownReason::Thermal
}
);
if in_thermal_abortable {
let _ = apply_event(&fsm, Event::ThermalCleared, &ctx).await;
}
}
}
});
}
// coprocessor-цикл (heartbeat / wait-for-completion / safe-to-cut / B09 failsafe)
{
tokio::spawn(async move {
let mut client = CoprocessorClient::new(Arc::clone(&copro));
let mut last_committed = false;
let mut last_shutting = false;
loop {
tokio::time::sleep(Duration::from_secs(crate::coprocessor::HEARTBEAT_SECS)).await;
copro.set_now(monotonic_secs());
// входящие MCU→SoC → FSM
for msg in client.poll() {
match msg {
McuToSoc::Acc { on } => {
let ev = if on { Event::AccOn } else { Event::AccOff };
let _ = apply_event(&fsm, ev, &ctx).await;
}
McuToSoc::Voltage { mv } if mv < BROWNOUT_MV => {
let _ = apply_event(&fsm, Event::UnderVoltage, &ctx).await;
}
_ => {}
}
}
// рёбра состояния FSM → протокол
let st = fsm.lock().unwrap().state();
let shutting = matches!(st, State::ShuttingDown { .. });
let committed = matches!(
st,
State::ShuttingDown {
phase: Phase::Committed,
..
}
);
if shutting && !last_shutting {
client.shutdown_imminent(crate::coprocessor::HOLDUP_BUDGET_SECS as u8);
} else if committed && !last_committed {
client.safe_to_cut(); // PONR → MCU режет немедленно
} else if !shutting {
client.heartbeat(); // running/accessory — keepalive
}
last_shutting = shutting;
last_committed = committed;
// B09: независимый fail-safe-таймер (зависший SoC / истёк бюджет)
if copro.failsafe_due() {
let _ = apply_event(&fsm, Event::FailsafeCut, &ctx).await;
}
}
});
}
}
#[interface(name = "ru.shturman.Power1")]
impl PowerService {
async fn get_power_state(&self) -> String {
@@ -107,10 +220,20 @@ impl PowerService {
async fn power_source(&self) -> String {
self.source().as_str().to_string()
}
#[zbus(property)]
async fn thermal_state(&self) -> String {
self.thermal_state.lock().unwrap().as_str().to_string()
}
#[zbus(signal)]
async fn acc_changed(ctx: &SignalContext<'_>, on: bool) -> zbus::Result<()>;
#[zbus(signal)]
async fn thermal_changed(
ctx: &SignalContext<'_>,
state: &str,
celsius: i32,
) -> zbus::Result<()>;
#[zbus(signal)]
async fn shutdown_imminent(
ctx: &SignalContext<'_>,
seconds: u32,
@@ -128,6 +251,8 @@ impl PowerService {
#[cfg(feature = "dev-mocks")]
pub struct PowerMock {
fsm: Arc<Mutex<PowerFsm>>,
temp: Arc<crate::thermal::MockTempSource>,
copro: Arc<crate::coprocessor::MockCoprocessor>,
}
#[cfg(feature = "dev-mocks")]
@@ -165,6 +290,23 @@ impl PowerMock {
async fn abort_shutdown(&self, #[zbus(signal_context)] ctx: SignalContext<'_>) {
let _ = apply_event(&self.fsm, Event::AccOn, &ctx).await;
}
/// Задать температуру (°C) → thermal-монитор подхватит на следующем poll.
async fn set_temp(&self, celsius: i32) {
self.temp.set(celsius);
}
/// «Завис SoC»: heartbeat перестаёт освежать B09-таймер → MCU срежет питание.
async fn hang_soc(&self) {
self.copro.hang();
}
/// Тишина линка: SoC-сторона деградирует (лог, не self-cut — red-line). MCU-политика cut-vs-hold — B §12/HW.
async fn mcu_link_loss(&self) {
tracing::warn!(
"coprocessor: MCU link loss — SoC деградирует (cut-vs-hold политика — HW/§12)"
);
}
}
#[cfg(test)]
+290
View File
@@ -0,0 +1,290 @@
//! Тепловая подсистема (A12/B10, спека v0.4 §5). `ThermalPolicy` — чистая (без I/O), с гистерезисом.
//! Источники/throttler/монитор — P8.5 (этот же файл).
/// Уровень теплового состояния. `Throttle(u8)` — банд (v0.4 использует уровень 1; мульти-банд — RK3588).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ThermalLevel {
Normal,
Warn,
Throttle(u8),
Critical,
}
impl ThermalLevel {
pub fn as_str(&self) -> &'static str {
match self {
ThermalLevel::Normal => "normal",
ThermalLevel::Warn => "warn",
ThermalLevel::Throttle(_) => "throttle",
ThermalLevel::Critical => "critical",
}
}
fn rank(&self) -> u8 {
match self {
ThermalLevel::Normal => 0,
ThermalLevel::Warn => 1,
ThermalLevel::Throttle(_) => 2,
ThermalLevel::Critical => 3,
}
}
}
// Пороги — placeholder-константы (°C). Тюнинг на RK3588 (hardware §1a; Tjmax ~100 °C).
pub const WARN_C: i32 = 75;
pub const THROTTLE_C: i32 = 85;
pub const CRITICAL_C: i32 = 95;
pub const HYST_C: i32 = 5;
pub const POLL_SECS: u64 = 1; // период опроса температуры (монотоника)
/// Чистая политика: `(предыдущий уровень, температура) → уровень` с гистерезисом (Schmitt по бандам).
pub struct ThermalPolicy;
impl ThermalPolicy {
fn band_by_entry(t: i32) -> ThermalLevel {
if t >= CRITICAL_C {
ThermalLevel::Critical
} else if t >= THROTTLE_C {
ThermalLevel::Throttle(1)
} else if t >= WARN_C {
ThermalLevel::Warn
} else {
ThermalLevel::Normal
}
}
fn band_by_exit(t: i32) -> ThermalLevel {
// нижние (гистерезисные) пороги = entry HYST
if t >= CRITICAL_C - HYST_C {
ThermalLevel::Critical
} else if t >= THROTTLE_C - HYST_C {
ThermalLevel::Throttle(1)
} else if t >= WARN_C - HYST_C {
ThermalLevel::Warn
} else {
ThermalLevel::Normal
}
}
/// Подъём — по entry-порогам; спуск — по exit-порогам (entry − HYST) → нет осцилляции на границе.
pub fn next(prev: ThermalLevel, temp_c: i32) -> ThermalLevel {
let up = Self::band_by_entry(temp_c);
if up.rank() >= prev.rank() {
up
} else {
Self::band_by_exit(temp_c)
}
}
}
use std::sync::atomic::{AtomicI32, Ordering};
use std::sync::Arc;
/// Источник температуры (°C). real = sysfs; VM = mock.
pub trait TempSource: Send + Sync {
fn read_celsius(&self) -> i32;
}
/// Mock-источник (dev): температуру задаёт `SetTemp` через dev-D-Bus.
#[derive(Clone)]
pub struct MockTempSource {
temp: Arc<AtomicI32>,
}
impl MockTempSource {
pub fn new(init_c: i32) -> Self {
Self {
temp: Arc::new(AtomicI32::new(init_c)),
}
}
pub fn set(&self, c: i32) {
self.temp.store(c, Ordering::Relaxed);
}
}
impl TempSource for MockTempSource {
fn read_celsius(&self) -> i32 {
self.temp.load(Ordering::Relaxed)
}
}
/// Прод: max по `/sys/class/thermal/thermal_zone*/temp` (миллиградусы). В Lima зоны статичны → числа на RK3588.
#[derive(Default)]
pub struct SysfsTempSource;
impl SysfsTempSource {
pub fn new() -> Self {
Self
}
}
impl TempSource for SysfsTempSource {
fn read_celsius(&self) -> i32 {
let mut max = i32::MIN;
if let Ok(rd) = std::fs::read_dir("/sys/class/thermal") {
for e in rd.flatten() {
let p = e.path().join("temp");
if let Ok(s) = std::fs::read_to_string(&p) {
if let Ok(milli) = s.trim().parse::<i32>() {
max = max.max(milli / 1000);
}
}
}
}
if max == i32::MIN {
0
} else {
max
}
}
}
/// Применение throttle. real = cpufreq (HW); VM = запись уровня (no-op-эффект).
pub trait Throttler: Send + Sync {
fn apply(&self, level: ThermalLevel);
}
/// VM/прод-каркас: логирует + запоминает последний уровень (реальный cpufreq — HW).
#[derive(Default, Clone)]
pub struct NoopThrottler {
last: Arc<std::sync::Mutex<Option<ThermalLevel>>>,
}
impl NoopThrottler {
pub fn last(&self) -> Option<ThermalLevel> {
*self.last.lock().unwrap()
}
}
impl Throttler for NoopThrottler {
fn apply(&self, level: ThermalLevel) {
*self.last.lock().unwrap() = Some(level);
tracing::info!(
"thermal: throttle уровень {} (эффект cpufreq — HW)",
level.as_str()
);
}
}
/// Результат шага монитора: уровень + рёбра входа/выхода Critical (для FSM ThermalTrip/ThermalCleared).
#[derive(Debug, PartialEq, Eq)]
pub struct ThermalObservation {
pub level: ThermalLevel,
pub changed: bool,
pub entered_critical: bool,
pub left_critical: bool,
}
/// Монитор: хранит предыдущий уровень, применяет политику, размечает рёбра Critical.
pub struct ThermalMonitor {
prev: ThermalLevel,
}
impl Default for ThermalMonitor {
fn default() -> Self {
Self {
prev: ThermalLevel::Normal,
}
}
}
impl ThermalMonitor {
pub fn new() -> Self {
Self::default()
}
pub fn observe(&mut self, temp_c: i32) -> ThermalObservation {
let level = ThermalPolicy::next(self.prev, temp_c);
let changed = level != self.prev;
let entered_critical = changed && level == ThermalLevel::Critical;
let left_critical = changed && self.prev == ThermalLevel::Critical;
self.prev = level;
ThermalObservation {
level,
changed,
entered_critical,
left_critical,
}
}
}
#[cfg(test)]
mod monitor_tests {
use super::*;
#[test]
fn marks_critical_edges() {
let mut m = ThermalMonitor::new();
let o = m.observe(96);
assert!(o.entered_critical && o.changed && o.level == ThermalLevel::Critical);
let o = m.observe(96); // держится — рёбер нет
assert!(!o.changed && !o.entered_critical);
let o = m.observe(80); // < 90 → выход из critical
assert!(o.left_critical && o.level == ThermalLevel::Throttle(1));
}
#[test]
fn mock_source_and_noop_throttler() {
let src = MockTempSource::new(20);
assert_eq!(src.read_celsius(), 20);
src.set(88);
assert_eq!(src.read_celsius(), 88);
let th = NoopThrottler::default();
th.apply(ThermalLevel::Throttle(1));
assert_eq!(th.last(), Some(ThermalLevel::Throttle(1)));
}
}
#[cfg(test)]
mod policy_tests {
use super::*;
#[test]
fn rises_by_entry_thresholds() {
assert_eq!(
ThermalPolicy::next(ThermalLevel::Normal, 70),
ThermalLevel::Normal
);
assert_eq!(
ThermalPolicy::next(ThermalLevel::Normal, 75),
ThermalLevel::Warn
);
assert_eq!(
ThermalPolicy::next(ThermalLevel::Warn, 85),
ThermalLevel::Throttle(1)
);
assert_eq!(
ThermalPolicy::next(ThermalLevel::Throttle(1), 95),
ThermalLevel::Critical
);
// прыжок вверх через банды
assert_eq!(
ThermalPolicy::next(ThermalLevel::Normal, 99),
ThermalLevel::Critical
);
}
#[test]
fn hysteresis_holds_until_below_exit() {
// critical держится до < 90 (955)
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
);
}
}
+109 -2
View File
@@ -3,14 +3,20 @@
use futures_util::StreamExt;
use shturman_ipc::{names, types::PowerState};
use shturman_power::coprocessor::MockCoprocessor;
use shturman_power::thermal::MockTempSource;
use shturman_power::PowerService;
use shturman_sdk::PowerClient;
use std::sync::Arc;
#[tokio::test]
#[ignore = "нужна session-шина: just test-integration"]
async fn power_state_and_fake_acc() {
let svc = PowerService::new();
let mock = svc.mock();
let mock = svc.mock(
Arc::new(MockTempSource::new(20)),
Arc::new(MockCoprocessor::new()),
);
// сервер: Power1 + dev.PowerMock1 на одном пути (владеет ru.shturman.Power)
let server = zbus::Connection::session().await.unwrap();
@@ -53,7 +59,10 @@ async fn power_state_and_fake_acc() {
#[ignore = "нужна session-шина: just test-integration"]
async fn shutdown_imminent_then_abort() {
let svc = PowerService::new();
let mock = svc.mock();
let mock = svc.mock(
Arc::new(MockTempSource::new(20)),
Arc::new(MockCoprocessor::new()),
);
let server = zbus::Connection::session().await.unwrap();
server
.object_server()
@@ -101,3 +110,101 @@ async fn shutdown_imminent_then_abort() {
aborted.next().await.unwrap();
assert_eq!(power.power_state().await.unwrap(), PowerState::Running);
}
#[tokio::test]
#[ignore = "нужна session-шина: just test-integration"]
async fn thermal_trip_then_clear() {
let svc = PowerService::new();
let fsm = svc.fsm_handle();
let thermal_state = svc.thermal_state_handle();
let temp = Arc::new(MockTempSource::new(20));
let copro = Arc::new(MockCoprocessor::new());
let server = zbus::Connection::session().await.unwrap();
server
.object_server()
.at(names::power::PATH, svc)
.await
.unwrap();
server.request_name(names::power::NAME).await.unwrap();
let iface = server
.object_server()
.interface::<_, PowerService>(names::power::PATH)
.await
.unwrap();
let ctx = iface.signal_context().to_owned();
shturman_power::service::spawn_loops(
fsm,
thermal_state,
temp.clone() as Arc<dyn shturman_power::thermal::TempSource>,
Arc::new(shturman_power::thermal::NoopThrottler::default())
as Arc<dyn shturman_power::thermal::Throttler>,
copro as Arc<dyn shturman_power::coprocessor::Coprocessor>,
ctx,
);
let client = zbus::Connection::session().await.unwrap();
let power = PowerClient::new(&client).await.unwrap();
let mut imminent = power.proxy().receive_shutdown_imminent().await.unwrap();
let mut aborted = power.proxy().receive_shutdown_aborted().await.unwrap();
// перегрев → ShutdownImminent(thermal)
temp.set(99);
let sig = imminent.next().await.unwrap();
assert_eq!(sig.args().unwrap().reason(), "thermal");
assert_eq!(power.power_state().await.unwrap(), PowerState::ShuttingDown);
// остыло до PONR → ShutdownAborted + running
temp.set(20);
aborted.next().await.unwrap();
assert_eq!(power.power_state().await.unwrap(), PowerState::Running);
}
#[tokio::test]
#[ignore = "нужна session-шина: just test-integration"]
async fn mcu_failsafe_cuts_on_hang() {
let svc = PowerService::new();
let fsm = svc.fsm_handle();
let thermal_state = svc.thermal_state_handle();
let temp = Arc::new(MockTempSource::new(20));
let copro = Arc::new(MockCoprocessor::new());
let server = zbus::Connection::session().await.unwrap();
server
.object_server()
.at(names::power::PATH, svc)
.await
.unwrap();
server.request_name(names::power::NAME).await.unwrap();
let iface = server
.object_server()
.interface::<_, PowerService>(names::power::PATH)
.await
.unwrap();
let ctx = iface.signal_context().to_owned();
shturman_power::service::spawn_loops(
fsm,
thermal_state,
temp as Arc<dyn shturman_power::thermal::TempSource>,
Arc::new(shturman_power::thermal::NoopThrottler::default())
as Arc<dyn shturman_power::thermal::Throttler>,
copro.clone() as Arc<dyn shturman_power::coprocessor::Coprocessor>,
ctx,
);
let client = zbus::Connection::session().await.unwrap();
let power = PowerClient::new(&client).await.unwrap();
assert_eq!(power.power_state().await.unwrap(), PowerState::Running);
// дать coproc-циклу послать ≥1 heartbeat (иначе last_heartbeat=0 и guard не даст cut)
tokio::time::sleep(std::time::Duration::from_millis(1300)).await;
copro.hang(); // SoC завис → heartbeat не освежает таймер
// ждём, пока coproc-цикл (HEARTBEAT=1с) накопит > FAILSAFE_MISS окон и сделает FailsafeCut
for _ in 0..10 {
tokio::time::sleep(std::time::Duration::from_millis(700)).await;
if power.power_state().await.unwrap() == PowerState::Off {
break;
}
}
assert_eq!(power.power_state().await.unwrap(), PowerState::Off);
}
+4
View File
@@ -22,10 +22,14 @@ pub trait Power1 {
fn uptime(&self) -> zbus::Result<u64>;
#[zbus(property)]
fn power_source(&self) -> zbus::Result<String>;
#[zbus(property)]
fn thermal_state(&self) -> zbus::Result<String>;
#[zbus(signal)]
fn acc_changed(&self, on: bool) -> zbus::Result<()>;
#[zbus(signal)]
fn thermal_changed(&self, state: String, celsius: i32) -> zbus::Result<()>;
#[zbus(signal)]
fn shutdown_imminent(&self, seconds: u32, reason: String) -> zbus::Result<()>;
#[zbus(signal)]
fn shutdown_aborted(&self) -> zbus::Result<()>;
+4 -4
View File
@@ -36,7 +36,7 @@
| A09 | Память (zram + OOM + cgroup-лимиты) | A | MVP (день 1) | v0 | — | ✅ |
| A10 | Логирование (journald volatile + критичное в `/data` + pstore) | A | MVP (день 1) | v0 | — | ✅ |
| A11 | eMMC write-minimization | A | MVP (день 1) | v0 | — | ✅ |
| A12 | Тепловой мониторинг + базовый throttling (SoC) | A | MVP | v0 | hardware | ✅ |
| A12 | Тепловой мониторинг + базовый throttling (SoC) | A | MVP | v0 | hardware | ✅ v0.4 (политика+абстракция; cpufreq/пороги → RK3588) |
| A13 | Тепловой тюнинг (политики под горячий салон) | A | later | v1 | — | ✅ |
| A14 | Hardware watchdog (вооружён в boot-окне) + recovery | A | MVP | v0 | hardware, B | ✅ |
| A15 | systemd-таргеты / оркестрация | A | MVP | v0 | — | ✅ |
@@ -56,9 +56,9 @@
| B05 | Watchdog (runtime + shutdown-фаза + boot-окно) | B | MVP | v0 | hardware, a-base | ✅ |
| B06 | Load-shedding при power-loss | B | MVP | v0 | селективные рейлы (hardware §6) | ✅ |
| B07 | Save last-known-time + периодика | B | MVP | v0 | a-base §7 | ✅ |
| B08 | MCU-копилот shutdown-протокол (если MCU) | B | MVP | v0 | hardware §3 | 🟡 MCU vs supercap-only |
| B09 | MCU аппаратный fail-safe-таймер | B | MVP | v0 | hardware §3 | 🟡 MCU vs supercap-only |
| B10 | Thermal shutdown (триггер + hysteresis + UX) | B | MVP | v0/v1 | a-base §10, hardware §1a | ✅ |
| B08 | MCU-копилот shutdown-протокол (если MCU) | B | MVP | v0 | hardware §3 | v0.4 софт (протокол+кодек+клиент); 🟡 физический MCU vs supercap → HW |
| B09 | MCU аппаратный fail-safe-таймер | B | MVP | v0 | hardware §3 | v0.4 модель (hang/budget→cut); 🟡 реальный таймер-чип → HW |
| B10 | Thermal shutdown (триггер + hysteresis + UX) | B | MVP | v0/v1 | a-base §10, hardware §1a | ✅ v0.4 (триггер+гистерезис; UX-рендер → v0.5) |
| B11 | MCU прошивка: update path | B | later | v1 | hardware | 🟡 MCU vs supercap-only |
| B12 | Sleep/wake + battery-cutoff | B | later | v1/v2 | — | ✅ |
| B13 | Гейт wake-word по состояниям (с D) | B | MVP | v1 | D | ✅ |
+5
View File
@@ -54,6 +54,11 @@ Panfrost GPU, мощности хватает на плавный UI и **лок
Рекомендую MCU-копилот: он же закрывает watchdog и пробуждение. (Прошивка МК — домен B.)
> **Статус (v0.4):** SoC-сторона MCU-протокола (B08), кодек линка и **модель** fail-safe-таймера (B09) реализованы
> в софте (`shturman-power`, симметрия с FSM v0.3) — спека `docs/specs/v0.4-mcu-thermal.md`. **Физический выбор
> B08/B09** (MCU-копилот vs supercap-only), реальный МК-чип/прошивка/независимый таймер и hold-up sizing — остаются
> 🟡 и закрываются в **HW-bring-up-подфазе** (нужна плата RK3588; перф/тепловой вердикт — там же).
**Бюджет hold-up (числовой контракт; sizing — здесь, sequencing — домен B):** энергия =
worst-case ток (SoC + контроллер хранилища при флаше/unmount) × hold-time (верхняя оценка
flush+durable-write+unmount с запасом) × **дератинг** (низкая T −40 °C: ёмкость/ESR supercap
+4 -3
View File
@@ -58,9 +58,10 @@
### `ru.shturman.Power` — питание и жизненный цикл (домен B)
- **Методы:** `GetPowerState() → state` (enum `off`/`accessory`/`running`/`shutting_down`/`sleep`/`battery_cutoff`), `RequestSleep()` (внутр.).
- **Сигналы:** `AccChanged(on)`, `ShutdownImminent(seconds, reason)` (`reason ∈ acc_off|under_voltage|thermal|battery_cutoff`), **`ShutdownAborted()`** (re-power до PONR), `Sleep()`, `Wake()`.
- **Properties:** `IgnitionState` (off/accessory/running — **канон**; E зеркалит, не дублирует), `Uptime` (монотонные часы), `PowerSource` (`vehicle_12v`/`holdup_cap`/`sleep_rail`/`low_battery`).
- **Реализация (v0.3):** состояние/сигналы **оживлены из FSM** (`PowerFsm` в `shturman-power`, не mock); `ShutdownImminent`/`ShutdownAborted` — из реальных переходов (abort до PONR + grace-таймер). `Sleep`/`Wake`/`RequestSleep` объявлены, но **зарезервированы** (v1/v2, B §7). Источник событий (ACC/voltage/thermal через MCU) → v0.4; в v0.3 кормит dev-mock.
- **Сигналы:** `AccChanged(on)`, `ShutdownImminent(seconds, reason)` (`reason ∈ acc_off|under_voltage|thermal|battery_cutoff`), **`ShutdownAborted()`** (re-power/остывание до PONR), `ThermalChanged(state, celsius)`, `Sleep()`, `Wake()`.
- **Properties:** `IgnitionState` (off/accessory/running — **канон**; E зеркалит, не дублирует), `Uptime` (монотонные часы), `PowerSource` (`vehicle_12v`/`holdup_cap`/`sleep_rail`/`low_battery`), `ThermalState` (`normal`/`warn`/`throttle`/`critical`).
- **Реализация (v0.3):** состояние/сигналы **оживлены из FSM** (`PowerFsm` в `shturman-power`, не mock); `ShutdownImminent`/`ShutdownAborted` — из реальных переходов (abort до PONR + grace-таймер). `Sleep`/`Wake`/`RequestSleep` объявлены, но **зарезервированы** (v1/v2, B §7).
- **Реализация (v0.4):** `ThermalState`/`ThermalChanged` — из `ThermalPolicy` (банды + гистерезис), `ShutdownImminent(thermal)` реально эмитится; SoC↔MCU протокол + кодек + клиент (B08) + fail-safe-**модель** (B09). Источник событий — dev-mock (`SetTemp`/`HangSoc`); реальный MCU/sysfs/cpufreq + рендер «перегрев» в Shell → HW/v0.5.
### `ru.shturman.Settings` — конфигурация и состояние
- **Методы:** `Get(key) → value`, `Set(key, value)`, `List(prefix) → [key]`, `Reset(key)`.
+11 -3
View File
@@ -10,8 +10,15 @@
**Реализация (v0.3):** срезы **B01B07** реализованы — чистый `PowerFsm` (§2: `off↔accessory↔running→shutting-down
{abortable→committed}→off`, abort до PONR) + сервис `ru.shturman.Power` оживлён из FSM (grace-таймер + durable-barrier
`sync` на commit), watchdog/save-time-конфиг. **VM-модель:** abort/PONR в Lima = stop+umount+remount, power-cut =
SIGKILL+`fsck`. Аппаратное (MCU/hold-up/heartbeat/`safe-to-cut`/fail-safe-таймер) и выбор **B08/B09****v0.4**;
sleep/wake/battery-cutoff — каркас (no-op), тело → v1/v2 (§7). Спека: `docs/specs/v0.3-power-safe.md`.
SIGKILL+`fsck`. sleep/wake/battery-cutoff — каркас (no-op), тело → v1/v2 (§7). Спека: `docs/specs/v0.3-power-safe.md`.
**Реализация (v0.4):** срезы **A12/B08/B09/B10** — софт/модель. Тепло (§4/§1a): чистая `ThermalPolicy` (банды +
гистерезис) → `Event::ThermalTrip` (реюз FSM) + abort `ThermalCleared`; `TempSource`/`Throttler` абстракции (VM mock/noop,
реальный sysfs/cpufreq + числовые пороги → RK3588). MCU (§5/§6): протокол `SocToMcu`/`McuToSoc` + кодек (CRC16/replay/
desync-guard) + `CoprocessorClient` (heartbeat/wait-for-completion/`safe-to-cut`); **B09 fail-safe-таймер — модель**
(`MockCoprocessor`: hang/budget → `Event::FailsafeCut` → off). `ru.shturman.Power` += `ThermalState`/`ThermalChanged`
(рендер «перегрев» → v0.5). **Физический выбор B08/B09** (MCU vs supercap-only), реальный UART/MCU-чип/fail-safe-таймер,
supercap-only-путь → **HW-bring-up-подфаза**. Спека: `docs/specs/v0.4-mcu-thermal.md`.
---
@@ -200,7 +207,8 @@ power-эффектом; ни одно SoC-сообщение не должно (
## 12. Открытые вопросы
- 🟡 **MCU-копилот vs supercap-only** (hardware §3, **B08/B09**) — определяет владельца ACC/watchdog, **наличие
независимого бэкстопа и fail-safe-снятия при зависшем SoC**, и доступность scheduled-wake. **→ v0.4** (вероятно нужна аппаратная проверка).
независимого бэкстопа и fail-safe-снятия при зависшем SoC**, и доступность scheduled-wake. Протокол/кодек/клиент +
fail-safe-**модель** реализованы в **v0.4** (софт); **физический выбор + железо → HW-bring-up-подфаза** (нужна плата).
- ◻️ **Протокол SoC↔MCU** (UART/I2C/GPIO, формат, keepalive, политика тишины-линка, replay-защита) —
shutdown-подмножество уже специфицировано в §4/§5, остальное здесь.
- ◻️ **Бюджет разряда АКБ** (sleep, ACC-off listening, battery-cutoff порог) — числа с hardware.
File diff suppressed because it is too large Load Diff
+291
View File
@@ -0,0 +1,291 @@
# Спека реализации: v0.4 — MCU/thermal fail-safe (тепловой триггер + MCU-протокол + fail-safe-таймер)
> Веха `v0.4` роадмапы: «аппаратный фундамент питания/тепла». Capabilities **A12** (тепловой мониторинг +
> базовый throttling), **B08** (MCU-копилот shutdown-протокол), **B09** (MCU аппаратный fail-safe-таймер),
> **B10** (thermal shutdown: триггер + hysteresis + UX). Поверх **v0.3** (живой `PowerFsm` + graceful shutdown).
> Источники: `docs/domains/b-power-lifecycle.md` §4/§5/§6/§1a, `docs/contracts/hardware.md` §3/§1a,
> `docs/contracts/ipc.md` §3, `docs/contracts/safety.md`, `docs/roadmap.md` §v0.4. Приёмка роадмапы:
> **«thermal-trip → graceful; MCU-таймер режет питание, если SoC завис»**.
>
> **Решение скоупа (брейнсторм):** разрабатываем без платы (принцип #13). MCU-копилот принят как
> **reference-архитектура** (доки рек.); делаем **софт + симуляцию**, физический выбор **B08/B09**
> (MCU vs supercap-only) и реальное железо — отложены в **HW-bring-up-подфазу**. Симметрия с v0.3:
> чистое ядро (политика/кодек) → абстракция (trait) → dev-mock.
---
## 1. Цель и первый артефакт
Замкнуть **тепловой** и **MCU**-швы домена B поверх живого FSM v0.3: (а) тепловой монитор с гистерезисом
кормит существующий `Event::ThermalTrip` → graceful shutdown; (б) SoC↔MCU shutdown-протокол (heartbeat /
`safe-to-cut` / wait-for-completion) с защищённым кодеком; (в) модель **независимого fail-safe-таймера**
MCU детерминированно «режет питание», если SoC завис.
**Первый артефакт (в Lima, мок-MCU/sensor):**
1. **thermal-trip:** `SetTemp ≥ critical``ThermalTrip``ShutdownImminent(thermal)` → graceful (реюз v0.3-пути,
`/data` консистентен); восстановление по гистерезису.
2. **MCU fail-safe:** `HangSoc` (heartbeat пропал в `running`) → мок-MCU детерминированно снимает питание
(в VM = форс-`off` сервиса) — «MCU режет питание, если SoC завис»; `/data` консистентен.
3. **throttling:** `SetTemp` в warn/throttle-банде → throttle-действие записано (VM `Noop`), без shutdown;
гистерезис на спаде (нет осцилляции).
**Не цель v0.4:** реальный UART/I2C-драйвер, реальный cpufreq-эффект, прошивка MCU, физический B09-чип/supercap,
полный supercap-only-путь (остаётся абстракцией-fallback), thermal-рендер в Shell (**v0.5**), sleep/wake/
battery-cutoff (**v1/v2**), числовой тюнинг порогов/hold-up — **RK3588**. Перф/тепловой вердикт — на таргете.
---
## 2. Скоуп и границы
### 2.1 В скоупе (делаем сейчас)
- **Тепловая политика (A12/B10):** чистый `ThermalPolicy` — `temp + предыдущий уровень → ThermalLevel
∈ {Normal, Warn, Throttle(n), Critical}` с **гистерезисом** (раздельные пороги вверх/вниз — нет осцилляции).
Юнит-тестируемый без I/O. Пороги — placeholder-константы (`// тюнинг на RK3588`, hardware §1a).
- **Источник температуры (`TempSource` trait):** real = sysfs `/sys/class/thermal/thermal_zone*/temp` (max по
зонам); VM = `MockTempSource` (значение из dev-D-Bus `SetTemp`). В v0.4 активен mock.
- **Throttler (`Throttler` trait):** real = cpufreq-cap (best-effort, HW); VM = `NoopThrottler` (запись уровня
для E2E/лог). Эффект — абстракция; реальное снижение частоты — HW.
- **Thermal-монитор:** периодический poll на **монотонике** → политика → throttle + на `Critical` кормит
`Event::ThermalTrip` в FSM (тот же `apply_event` из v0.3). Восстановление до PONR → `Event::ThermalCleared`
(abort thermal-shutdown, симметрия с re-power; гейт по `reason == Thermal`).
- **SoC↔MCU протокол (B08):** типы сообщений `SocToMcu`/`McuToSoc`; **чистый кодек** (framing + seq + CRC16 +
**replay/desync-guard**) — закрывает требование «защита линка от replay/мусора/десинка» (B §5, hardware §4).
Байты текут через in-memory-канал (codec исполняется по-настоящему и в integration).
- **Coprocessor (`Coprocessor` trait):** real = `SerialCoprocessor` (UART — **стаб**, HW-подфаза); VM =
`MockCoprocessor` (in-process, кормится через dev-D-Bus).
- **SoC-side `CoprocessorClient`:** heartbeat в `running`; на `ShutdownImminent` → **wait-for-completion**
(расширенный таймаут ≥ shutdown-бюджет, B §6) — не короткий keepalive посреди unmount; `safe-to-cut` после
PONR → немедленный cut. MCU — **fail-safe-авторитет** (SoC не командует cut-на-ходу — B §5).
- **Fail-safe-таймер (B09) — модель:** `MockCoprocessor` моделирует независимый таймер: heartbeat пропал
(`running`) ИЛИ бюджет истёк без `safe-to-cut` → детерминированный cut (в VM = форс-`off` power-сервиса).
- **D-Bus-контракт (ipc §3):** property `ThermalState ∈ {normal, warn, throttle, critical}` + сигнал
`ThermalChanged(state, celsius)`; `ShutdownImminent(thermal)` уже есть. **Контракт сейчас — Shell рисует в v0.5.**
- **Харнесс:** юниты (политика/кодек/клиент/таймер) + integration (session-шина) + E2E-блок v0.4 (§9).
### 2.2 Явно НЕ в скоупе (отложено, с указателем «куда»)
- **Физический выбор B08/B09** (MCU-копилот vs supercap-only), реальный MCU-чип, прошивка, реальный
hold-up cap/supercap, реальный UART/I2C-драйвер + GPIO ACC-детект — **HW-bring-up-подфаза** (hardware §3).
- **Реальный cpufreq/DDR/GPU-throttling-эффект + числовые тепловые пороги/дератинг** — **RK3588** (hardware §1a).
- **Thermal-UX рендер в Shell** («перегрев»-overlay) — **v0.5** (живой shell; v0.4 даёт только контракт).
- **Supercap-only полный путь** (SoC-таймер + разряд cap, ACC-детект в софте) — остаётся **абстракцией-fallback**
(`Coprocessor` trait), тело — HW-подфаза при выборе supercap.
- **Sleep/wake/scheduled-wake/battery-cutoff** — **v1/v2** (B §7); состояния зарезервированы (как в v0.3).
- **Реальный `/dev/watchdog` арминг + MCU-watchdog-бэкстоп железом** — **HW** (в v0.4 — дисциплина/модель, как v0.3).
- **Перф-вердикт** (время до cut, hold-time, тепловая инерция) — **RK3588** (performance §2). В VM — функционально.
### 2.3 Частично в скоупе (каркас сейчас, тело — позже)
- **`Throttler`** — trait + `Noop` (лог уровня); реальный cpufreq — HW.
- **`Coprocessor`** — trait + `MockCoprocessor`; `SerialCoprocessor` (UART) — стаб, HW-подфаза.
- **`TempSource`** — trait + `MockTempSource`; `SysfsTempSource` — каркас (читает зоны, в Lima зоны статичны).
- **MCU-watchdog/линк-fail-safe** — логика клиента + модель таймера; реальный независимый чип — HW.
### 2.4 Трассируемость ID → статус
| ID | Веха | Статус в v0.4 |
|----|------|----------------|
| A12 | Тепловой мониторинг + базовый throttling | `ThermalPolicy` + `TempSource`/`Throttler` (VM mock/noop); пороги placeholder, эффект cpufreq — HW |
| B08 | MCU-копилот shutdown-протокол | типы + кодек (CRC/replay/desync) + `CoprocessorClient` (heartbeat/wait/safe-to-cut); транспорт = in-memory (UART — HW) |
| B09 | MCU аппаратный fail-safe-таймер | **модель** в `MockCoprocessor` (hang/budget → детерминированный cut); реальный независимый чип — HW |
| B10 | Thermal shutdown (триггер + hysteresis + UX) | триггер `ThermalTrip` (реюз FSM v0.3) + гистерезис + abort `ThermalCleared`; **UX-рендер — v0.5** (контракт-property/сигнал сейчас) |
---
## 3. Красные линии, безопасность
- **MCU/Power — только питание устройства, не CAN/actuator (#1/#2):** копроцессор мониторит зажигание/напряжение
и коммутирует **рейл питания SoC** — никаких узлов авто, никаких write/actuator-путей. Протокол SoC↔MCU не
несёт автомобильных команд. (Engine-state/OBD — домен E, read-only; Power **не трогает CAN**.)
- **MCU — fail-safe-авторитет (B §5):** ни одно SoC-сообщение не может (а) снять питание на ходу, (б) держать
вечно и разрядить АКБ. В модели `MockCoprocessor` cut инициируется **только** таймером MCU (hang/budget) или
`safe-to-cut` после PONR — не произвольной SoC-командой.
- **Защита линка:** кодек отбрасывает replay (seq ≤ last)/мусор (битый CRC)/десинк (resync по SYNC) —
юнит-доказано (§9.1). Аналог защиты CAN-линка (hardware §4).
- **Durability-инвариант v0.3 сохраняется:** thermal-trip и MCU-cut идут через тот же graceful-путь до PONR
(durable-barrier `sync` до unmount); после усечённого shutdown `/data` консистентен (atomic-write A §3).
- **prod-build-gate:** `--no-default-features` (без `dev-mocks`) → `PowerMock1`/`SetTemp`/`HangSoc` не
регистрируются (как в v0.3). dev-D-Bus-policy — dev-only drop-in.
---
## 4. Раскладка (новые/изменённые артефакты)
- **Create** `crates/core/shturman-power/src/thermal.rs` — `ThermalLevel`, `ThermalPolicy` (чистая, гистерезис),
`TempSource` trait (`SysfsTempSource`/`MockTempSource`), `Throttler` trait (`Cpufreq`-стаб/`NoopThrottler`),
`ThermalMonitor` (poll → политика → throttle + `ThermalTrip`/`ThermalCleared`).
- **Create** `crates/core/shturman-power/src/protocol.rs` — `SocToMcu`/`McuToSoc` (типы сообщений).
- **Create** `crates/core/shturman-power/src/codec.rs` — кадр (SYNC/LEN/SEQ/TYPE/PAYLOAD/CRC16) + encode/decode +
replay/desync-guard. Юнит-тесты в файле.
- **Create** `crates/core/shturman-power/src/coprocessor.rs` — `Coprocessor` trait, `MockCoprocessor` (in-process +
B09-таймер-модель), `SerialCoprocessor` (UART-стаб), `CoprocessorClient` (SoC-side: heartbeat/wait/safe-to-cut).
- **Modify** `crates/core/shturman-power/src/fsm.rs` — `Event::ThermalCleared` (+ переход abort из
`ShuttingDown{Abortable, reason: Thermal}` → `Running`); `Event::FailsafeCut` (→ `off` из любого не-`off`,
необратимо — MCU-авторитет); подтвердить армы `ThermalTrip`.
- **Modify** `crates/core/shturman-power/src/service.rs` — владеть `ThermalMonitor` + `CoprocessorClient` (кормят
FSM); property `ThermalState` + сигнал `ThermalChanged`; dev-mock расширить: `SetTemp(d)`, `HangSoc()`,
`McuLinkLoss()`.
- **Modify** `crates/core/shturman-power/src/lib.rs` — `pub mod thermal; pub mod protocol; pub mod codec; pub mod coprocessor;`.
- **Modify** `crates/shturman-ipc/src/proxy.rs` — `Power1Proxy`: property `ThermalState` + сигнал `ThermalChanged`.
- **Modify** `crates/core/shturman-power/tests/integration.rs` — thermal-trip / abort / fail-safe-cut по session-шине.
- **Modify** `tests/e2e/run.sh` — блок v0.4 (thermal-trip → graceful; MCU fail-safe; throttling/гистерезис).
- **Modify (швы §10)** `docs/domains/b-power-lifecycle.md`, `docs/contracts/hardware.md`, `docs/contracts/ipc.md` §3,
`docs/capability-catalog.md` (A12/B08/B09/B10), `CLAUDE.md`.
---
## 5. Тепловая подсистема (A12/B10) — контракт
```
ThermalLevel = Normal | Warn | Throttle(level: u8) | Critical
```
**Пороги (placeholder-константы, тюнинг на RK3588 — hardware §1a; Tjmax RK3588 ~100 °C):**
| Переход | Порог вверх | Порог вниз (гистерезис) |
|---------|-------------|--------------------------|
| Normal → Warn | `WARN_C = 75` | `WARN_C HYST` |
| Warn → Throttle | `THROTTLE_C = 85` | `THROTTLE_C HYST` |
| Throttle → Critical | `CRITICAL_C = 95` | `CRITICAL_C HYST` |
| `HYST = 5 °C` | | |
- **`ThermalPolicy::next(prev: ThermalLevel, temp_c) -> ThermalLevel`** — чистая; гистерезис = переход вниз
только ниже `(порог HYST)`, иначе уровень держится (нет осцилляции на границе).
- **`ThermalMonitor`** (tokio-интервал на монотонике, ~`POLL_SECS = 1`): `temp = TempSource::read()` →
`lvl = policy.next(prev, temp)`; при смене уровня: `Throttler::apply(lvl)` + `ThermalChanged(state, temp)`;
**на входе в `Critical`** → `apply_event(Event::ThermalTrip)`; **на выходе из `Critical`** (по гистерезису),
если FSM ещё в `ShuttingDown{Abortable, reason: Thermal}` → `apply_event(Event::ThermalCleared)`.
- **`ThermalState` (D-Bus property)** = проекция текущего `ThermalLevel` (`Throttle(_)` → `"throttle"`).
---
## 6. SoC↔MCU протокол (B08) + fail-safe-таймер (B09) — контракт
### 6.1 Сообщения
```
SocToMcu = Heartbeat { seq } // периодический keepalive в running/accessory
| ShutdownImminent { budget } // вход в shutdown → MCU в wait-for-completion (таймаут ≥ budget)
| SafeToCut // после PONR → MCU снимает питание немедленно
McuToSoc = Ack { seq }
| Acc { on } // дебаунснутый ACC (источник зажигания; в VM кормит FSM AccOn/AccOff)
| Voltage { mv } // напряжение бортсети (под under-voltage backstop)
| CutWarning // бюджет почти истёк (диагностика)
```
### 6.2 Кодек (`codec.rs`)
- **Кадр:** `[SYNC=0xA5][LEN u8][SEQ u8][TYPE u8][PAYLOAD…][CRC16-CCITT]`, CRC по `LEN..=PAYLOAD`.
- **Replay/dup guard:** приёмник держит `last_seq` на направление; кадр с `seq ≤ last_seq` (в окне) — **drop**.
- **Desync/мусор:** битый CRC или нет SYNC → **resync** (скан до следующего `SYNC`), кадр отброшен.
- Юнит-тесты: round-trip всех типов; corruption (флип бита → drop); replay (повтор seq → drop); desync
(мусор перед SYNC → восстановление на следующем валидном кадре).
### 6.3 SoC-side `CoprocessorClient`
- В `running`/`accessory`: `Heartbeat{seq++}` каждые `HEARTBEAT_SECS` (монотоника); ждёт `Acc`/`Voltage` от MCU
→ кормит FSM (`AccOn`/`AccOff`/`UnderVoltage`).
- На `ShutdownImminent` (FSM вошёл в shutdown): шлёт `SocToMcu::ShutdownImminent{budget}` → переходит в
**wait-for-completion** (heartbeat останавливается, ждёт завершения секвенсинга; таймаут ≥ shutdown-бюджет, B §6).
- После PONR (commit): `SafeToCut` → MCU режет немедленно.
- **Не** шлёт power-команд с эффектом cut-на-ходу (red-line §3).
### 6.4 Fail-safe-таймер (B09) — модель в `MockCoprocessor`
- **Hang-детект:** нет `Heartbeat` дольше `FAILSAFE_MISS × HEARTBEAT_SECS` в `running` → SoC завис → cut.
- **Budget-таймер:** после `ShutdownImminent` без `SafeToCut` за `HOLDUP_BUDGET_SECS` → cut (детерминированно).
- **Cut (в VM):** мок-MCU зовёт `apply_event(Event::FailsafeCut)` → FSM → `off` (необратимо, MCU-авторитет) +
лог «MCU cut». В E2E дополнительно реюзаем v0.3 power-cut (SIGKILL до fsync) для durability-доказательства
(`fsck` clean, durable-value цел). Это **VM-модель**: реальный зависший SoC теряет питание извне, в модели
cut = событие не-реально-зависшего процесса (симметрично v0.3 «abort/PONR = stop+umount+remount»).
- Значения (`HEARTBEAT_SECS=1`, `FAILSAFE_MISS=3`, `HOLDUP_BUDGET_SECS` ~ grace+запас) — **placeholder**
(реальный hold-up sizing — hardware §3, RK3588).
---
## 7. D-Bus `ru.shturman.Power` — v0.4 расширяет
- **+ Property `ThermalState: s`** ∈ `{normal, warn, throttle, critical}` (+ `PropertiesChanged`).
- **+ Сигнал `ThermalChanged(s state, i celsius)`** — для Shell (рендер «перегрев» — **v0.5**).
- `ShutdownImminent(u seconds, s reason)` — `reason=thermal` уже объявлен (ipc §3); v0.4 его **реально эмитит**
на thermal-trip.
- **dev-mock `ru.shturman.dev.PowerMock1` (feature `dev-mocks`)** дополняется:
- `SetTemp(i celsius)` → кормит `MockTempSource` → монитор → политика.
- `HangSoc()` → останавливает heartbeat → провоцирует B09-таймер.
- `McuLinkLoss()` → тишина линка: **SoC-сторона деградирует** (лог/маркер degraded, **не** self-cut — red-line §3).
MCU-сторонняя политика cut-vs-hold при тишине — **B §12-open → HW**.
- Прод-гейт `--no-default-features` — не регистрируются (как v0.3 §3).
---
## 8. Watchdog / монотоника (реюз v0.3)
- Все новые таймеры (poll, heartbeat, wait-for-completion, fail-safe, hold-up) — на **`CLOCK_MONOTONIC`**
(`shturman_common::monotonic_secs`), НЕ wall-clock (B §8).
- MCU-watchdog/линк-fail-safe — **логика клиента + модель** (реальный независимый чип/арминг — HW). systemd
`RuntimeWatchdogSec`/`RebootWatchdogSec` — уже из v0.3 (новых юнитов не требуется; thermal/coprocessor живут
внутри `shturman-power.service`).
---
## 9. Dev-харнесс и план тестирования
### 9.1 Unit (чистые модули)
- **`thermal.rs`:** банды Normal/Warn/Throttle/Critical; **гистерезис** (нет осцилляции на границе ±HYST);
`ThermalTrip` на входе в Critical, `ThermalCleared` на выходе.
- **`codec.rs`:** round-trip всех типов; corruption→drop; replay(seq)→drop; desync→resync.
- **`coprocessor.rs`:** клиент heartbeat→wait-for-completion→safe-to-cut; B09-таймер (hang→cut, budget→cut);
MCU игнорит небезопасные SoC-команды (red-line).
- **`fsm.rs`:** `ThermalCleared` abort только из `ShuttingDown{Abortable, reason: Thermal}` (из `AccOff`-shutdown —
no-op); committed — no-op. `FailsafeCut` → `off` из любого не-`off` (необратимо).
### 9.2 Integration (session-шина, `#[ignore]`, `just test-integration`)
- `SetTemp ≥ critical` → наблюдаем `ShutdownImminent(thermal)` + `ThermalChanged(critical)`; state `shutting_down`.
- `SetTemp` recovery до grace → `ShutdownAborted` (через `ThermalCleared`) + `running`.
- `HangSoc` → наблюдаем fail-safe-cut (state → off / forced-off).
### 9.3 E2E (Lima, гибрид — расширение `run.sh`, после блока power-safe v0.3)
- **thermal-trip:** `SetTemp ≥ critical` → `ShutdownImminent(thermal)` → graceful (реюз v0.3: stop→umount(PONR)→
remount) → `/data` консистентен; затем `SetTemp` норма → `running`.
- **MCU fail-safe:** `HangSoc` (heartbeat пропал) → мок-MCU режет питание (наблюдаем forced-off /
SIGKILL-эквивалент) → `fsck` clean, durable-value цел (как v0.3 power-cut).
- **throttling/гистерезис:** `SetTemp` в warn/throttle → `ThermalState` меняется, throttle записан, **без**
shutdown; спад чуть выше нижнего порога — уровень держится (нет осцилляции).
- **Регресс v0.1/v0.2/v0.3 зелёный**; machine-id стабилен; `E2E OK ✅`.
### 9.4 Критерии приёмки (роадмапа + спека)
- [ ] thermal-trip → graceful (`ShutdownImminent(thermal)`→commit→`/data` консистентен); гистерезис — нет осцилляции.
- [ ] MCU fail-safe-таймер: SoC-hang/бюджет → **детерминированный cut** (модель); `/data` консистентен.
- [ ] Throttling-политика по бандам применена (запись в VM; числа — RK3588).
- [ ] Кодек: replay/desync/corruption отбиты (unit).
- [ ] `ThermalState`/`ThermalChanged` на шине; `Uptime`/таймеры монотонны.
- [ ] Регресс v0.1–v0.3 зелёный; `just ci` зелёный; prod-build-gate (нет `PowerMock1`/`SetTemp`); красные линии целы
(MCU/Power — только питание, нет CAN/actuator).
---
## 10. Двунаправленные швы (синхронизировать при реализации)
- **`domain B`:** §4 (thermal-trip-путь реализован), §5 (MCU shutdown-протокол + кодек + клиент — софт/модель;
физический MCU/fail-safe-чип/supercap → HW-подфаза), §6 (wait-for-completion реализован), §1a (тепловые пороги —
placeholder, тюнинг RK3588). Пометить **A12/B08/B09/B10** реализованными (VM-модель).
- **`hardware §3`/`§1a`:** **B08/B09** физический выбор (MCU vs supercap-only) остаётся **🟡 → HW-bring-up-подфаза**;
тепловой конверт/класс/охлаждение — 🟡 (числа на таргете).
- **`ipc.md §3`:** Power + `ThermalState`/`ThermalChanged`; `ShutdownImminent(thermal)` реально эмитится.
- **`capability-catalog`:** A12 ✅ (политика+абстракция), B10 ✅ (триггер+гистерезис; UX→v0.5), B08/B09 — софт/модель
реализованы, физический выбор 🟡 → HW.
- **`CLAUDE.md`:** статус v0.4 (после реализации) → следующее v0.5 shell.
---
## 11. Дальше по ритму
`v0.4` (после утверждения спеки) → **План 8** (`docs/specs/plans/08-v0.4-mcu-thermal.md`, writing-plans) → TDD →
verify в Lima → коммиты `feat/v0.4-mcu-thermal`. Затем **v0.5 — полный shell** (живой weston-shell; замкнёт
thermal-UX-рендер). Физический **HW-bring-up** (MCU/supercap выбор, реальный UART/cpufreq/B09-чип, тепловой
вердикт) — отдельной подфазой при появлении RK3588-платы.
+43
View File
@@ -274,6 +274,49 @@ test -f /etc/systemd/system.conf.d/shturman-watchdog.conf || fail "нет watchd
systemctl is-active --quiet shturman-savetime.timer && pass "savetime.timer активен" || echo " WARN: savetime.timer не активен"
pass "watchdog-конфиг на месте"
# ---- v0.4: thermal-trip + throttling + MCU fail-safe (мок-sensor/MCU) ----
# Каждый под-тест стартует с чистого running-power (рестарт сбрасывает MockTempSource→20, FSM→running,
# и счётчик start-limit). thermal-abort покрыт integration-тестом (P8.7) — в E2E не гоняем с grace-окном.
info "v0.4: thermal-trip → ShutdownImminent(thermal); throttling-банд; MCU fail-safe (hang → cut)"
P_RESTART() { # чистый рестарт power → running
sudo systemctl reset-failed shturman-power.service 2>/dev/null || true
sudo systemctl restart shturman-power.service
for _ in $(seq 1 10); do systemctl is-active --quiet shturman-power && break; sleep 1; done
sleep 1.5 # дать циклам (poll/heartbeat ~1с) стартовать
}
# thermal-trip: SetTemp ≥ critical → ShutdownImminent(thermal) (монитор poll ~1с; ловим до grace-commit)
P_RESTART
mon=$(mktemp)
# shellcheck disable=SC2024
sudo busctl --system monitor "$P_NAME" >"$mon" 2>&1 & M=$!
sleep 0.4; P_CALL SetTemp i 99; sleep 1.6
sudo kill "$M" 2>/dev/null; wait "$M" 2>/dev/null
grep -q ShutdownImminent "$mon" || { cat "$mon"; rm -f "$mon"; fail "thermal: ShutdownImminent не наблюдаем"; }
grep -q thermal "$mon" || { cat "$mon"; rm -f "$mon"; fail "thermal: reason != thermal"; }
rm -f "$mon"
pass "thermal-trip: SetTemp≥critical → ShutdownImminent(thermal)"
# throttling-банд (85..95) → ThermalState=throttle, БЕЗ shutdown
P_RESTART
P_CALL SetTemp i 88; sleep 2
ts=$(busctl --system get-property "$P_NAME" "$P_PATH" "$P_IFACE" ThermalState 2>/dev/null)
echo "$ts" | grep -q throttle || { echo "ThermalState=$ts"; fail "thermal: ThermalState != throttle на 88°C"; }
busctl --system call "$P_NAME" "$P_PATH" "$P_IFACE" GetPowerState | grep -q running || fail "thermal: throttle не должен ронять"
pass "throttling-банд: ThermalState=throttle на 88°C, без shutdown"
# MCU fail-safe: HangSoc → heartbeat пропал → MCU режет (FSM → off) детерминированно (B09)
P_RESTART
P_CALL HangSoc
ok=0
for _ in $(seq 1 10); do
sleep 1
busctl --system call "$P_NAME" "$P_PATH" "$P_IFACE" GetPowerState 2>/dev/null | grep -q off && { ok=1; break; }
done
[ "$ok" = 1 ] || fail "MCU fail-safe: power не off после HangSoc (B09 не сработал)"
pass "MCU fail-safe: HangSoc → MCU cut (FSM off), детерминированно"
P_RESTART # чистый running для последующих блоков
# ---- 8. base-бюджеты: journald / zram / fake-hwclock / eMMC-прокси (§9.3.7) ----
info "8. base-бюджеты (функц.)"
# journald volatile: активный журнал в /run/log/journal, persistent /var/log/journal отсутствует (A10)