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>
This commit is contained in:
2026-06-25 15:31:55 +03:00
parent 147b20ddb6
commit 860a591f16
2 changed files with 209 additions and 0 deletions
@@ -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: u64,
shutdown_at: Option<u64>,
safe_to_cut: bool,
hung: bool,
now: u64,
}
/// Мок MCU: декодит SoC-кадры (через реальный codec), моделирует B09-таймер, эмитит MCU→SoC.
#[derive(Clone, Default)]
pub struct MockCoprocessor {
st: Arc<Mutex<MockState>>,
}
impl MockCoprocessor {
pub fn new() -> Self {
Self::default()
}
/// MCU → SoC (dev-mock кормит ACC/voltage отсюда).
pub fn emit(&self, msg: McuToSoc) {
let mut s = self.st.lock().unwrap();
s.mcu_seq = s.mcu_seq.wrapping_add(1);
let 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 = now;
}
}
SocToMcu::ShutdownImminent { .. } => s.shutdown_at = Some(now),
SocToMcu::SafeToCut => s.safe_to_cut = true,
}
}
}
}
fn rx(&self) -> Vec<u8> {
std::mem::take(&mut self.st.lock().unwrap().out)
}
fn failsafe_due(&self) -> bool {
let s = self.st.lock().unwrap();
match s.shutdown_at {
// running: тишина heartbeat дольше FAILSAFE_MISS окон → SoC завис → cut
None => {
s.last_heartbeat != 0
&& s.now.saturating_sub(s.last_heartbeat) > FAILSAFE_MISS * HEARTBEAT_SECS
}
// shutdown: бюджет истёк без safe-to-cut → cut
Some(t0) => !s.safe_to_cut && s.now.saturating_sub(t0) > HOLDUP_BUDGET_SECS,
}
}
fn set_now(&self, secs: u64) {
self.st.lock().unwrap().now = secs;
}
}
/// Прод-стаб: реальный UART/I2C — HW-bring-up-подфаза. В v0.4 не активен (прод-источника событий нет).
#[derive(Default)]
pub struct SerialCoprocessor;
impl SerialCoprocessor {
pub fn new() -> Self {
Self
}
}
impl Coprocessor for SerialCoprocessor {
fn tx(&self, _bytes: &[u8]) {}
fn rx(&self) -> Vec<u8> {
Vec::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn client_heartbeat_decoded_by_mock() {
let mock = Arc::new(MockCoprocessor::new());
mock.set_now(1);
let mut client = CoprocessorClient::new(mock.clone());
client.heartbeat();
// heartbeat освежил таймер → нет failsafe
assert!(!mock.failsafe_due());
}
#[test]
fn mcu_to_soc_roundtrip() {
let mock = Arc::new(MockCoprocessor::new());
let mut client = CoprocessorClient::new(mock.clone());
mock.emit(McuToSoc::Acc { on: true });
mock.emit(McuToSoc::Voltage { mv: 13_800 });
assert_eq!(
client.poll(),
vec![McuToSoc::Acc { on: true }, McuToSoc::Voltage { mv: 13_800 }]
);
}
#[test]
fn failsafe_on_soc_hang() {
let mock = Arc::new(MockCoprocessor::new());
let mut client = CoprocessorClient::new(mock.clone());
mock.set_now(1);
client.heartbeat(); // last_heartbeat = 1
mock.hang(); // SoC завис
mock.set_now(2);
client.heartbeat(); // hung → таймер НЕ освежается
assert!(!mock.failsafe_due()); // 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());
}
}
+1
View File
@@ -2,6 +2,7 @@
//! (`fsm`), сервис оборачивает его (D-Bus state/signals из FSM; dev-mock кормит входы).
pub mod codec;
pub mod coprocessor;
pub mod fsm;
pub mod protocol;
pub mod service;