diff --git a/crates/core/shturman-power/src/codec.rs b/crates/core/shturman-power/src/codec.rs new file mode 100644 index 0000000..a9cf092 --- /dev/null +++ b/crates/core/shturman-power/src/codec.rs @@ -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 { + 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, +} + +/// Потоковый декодер: накапливает байты, выдаёт валидные кадры. Resync/replay-guard внутри. +#[derive(Default)] +pub struct FrameDecoder { + buf: Vec, + last_seq: Option, +} + +impl FrameDecoder { + pub fn push(&mut self, bytes: &[u8]) -> Vec { + 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] + }] + ); + } +} diff --git a/crates/core/shturman-power/src/lib.rs b/crates/core/shturman-power/src/lib.rs index d21f3af..95f560b 100644 --- a/crates/core/shturman-power/src/lib.rs +++ b/crates/core/shturman-power/src/lib.rs @@ -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; diff --git a/crates/core/shturman-power/src/protocol.rs b/crates/core/shturman-power/src/protocol.rs new file mode 100644 index 0000000..be56134 --- /dev/null +++ b/crates/core/shturman-power/src/protocol.rs @@ -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 { + match self { + SocToMcu::ShutdownImminent { budget } => vec![*budget], + _ => vec![], + } + } + pub fn from_wire(t: u8, p: &[u8]) -> Option { + 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 { + 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 { + 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, + } + } +}