Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 737cb04f3a | |||
| 50fdaab25b | |||
| a050f57241 | |||
| 32ba1136c7 | |||
| cd2442f672 | |||
| 2e6144c54f | |||
| 860a591f16 | |||
| 147b20ddb6 | |||
| e54a34cd64 | |||
| b9ae2f23d5 | |||
| fb31a288c3 | |||
| c377a34c4f |
@@ -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.1–v0.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 — решение позже.
|
||||
|
||||
|
||||
@@ -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()); // 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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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 (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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<()>;
|
||||
|
||||
@@ -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 | ✅ |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)`.
|
||||
|
||||
@@ -10,8 +10,15 @@
|
||||
**Реализация (v0.3):** срезы **B01–B07** реализованы — чистый `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
@@ -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-платы.
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user