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>
This commit is contained in:
2026-06-25 15:30:55 +03:00
parent e54a34cd64
commit 147b20ddb6
3 changed files with 231 additions and 0 deletions
+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]
}]
);
}
}
+2
View File
@@ -1,7 +1,9 @@
//! `ru.shturman.Power1` — питание/жизненный цикл (домен B). v0.3: реальный lifecycle-FSM
//! (`fsm`), сервис оборачивает его (D-Bus state/signals из FSM; dev-mock кормит входы).
pub mod codec;
pub mod fsm;
pub mod protocol;
pub mod service;
pub mod thermal;
@@ -0,0 +1,79 @@
//! Типы сообщений 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,
}
}
}