Bootstrap DD3 Rust port workspace with host-first compatibility tests
This commit is contained in:
10
crates/dd3_protocol/Cargo.toml
Normal file
10
crates/dd3_protocol/Cargo.toml
Normal file
@@ -0,0 +1,10 @@
|
||||
[package]
|
||||
name = "dd3_protocol"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[features]
|
||||
default = ["std"]
|
||||
std = []
|
||||
|
||||
[dependencies]
|
||||
32
crates/dd3_protocol/src/ack.rs
Normal file
32
crates/dd3_protocol/src/ack.rs
Normal file
@@ -0,0 +1,32 @@
|
||||
use crate::ACK_DOWN_PAYLOAD_LEN;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct AckDownPayload {
|
||||
pub time_valid: bool,
|
||||
pub batch_id: u16,
|
||||
pub epoch_utc: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AckDecodeError {
|
||||
LengthMismatch,
|
||||
}
|
||||
|
||||
pub fn encode_ack_down_payload(payload: AckDownPayload) -> [u8; ACK_DOWN_PAYLOAD_LEN] {
|
||||
let mut out = [0u8; ACK_DOWN_PAYLOAD_LEN];
|
||||
out[0] = if payload.time_valid { 1 } else { 0 };
|
||||
out[1..3].copy_from_slice(&payload.batch_id.to_be_bytes());
|
||||
out[3..7].copy_from_slice(&payload.epoch_utc.to_be_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
pub fn decode_ack_down_payload(bytes: &[u8]) -> Result<AckDownPayload, AckDecodeError> {
|
||||
if bytes.len() != ACK_DOWN_PAYLOAD_LEN {
|
||||
return Err(AckDecodeError::LengthMismatch);
|
||||
}
|
||||
Ok(AckDownPayload {
|
||||
time_valid: (bytes[0] & 0x01) != 0,
|
||||
batch_id: u16::from_be_bytes([bytes[1], bytes[2]]),
|
||||
epoch_utc: u32::from_be_bytes([bytes[3], bytes[4], bytes[5], bytes[6]]),
|
||||
})
|
||||
}
|
||||
16
crates/dd3_protocol/src/crc.rs
Normal file
16
crates/dd3_protocol/src/crc.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
use core::num::Wrapping;
|
||||
|
||||
pub fn crc16_ccitt(data: &[u8]) -> u16 {
|
||||
let mut crc = Wrapping(0xFFFFu16);
|
||||
for byte in data {
|
||||
crc ^= Wrapping((*byte as u16) << 8);
|
||||
for _ in 0..8 {
|
||||
if (crc.0 & 0x8000) != 0 {
|
||||
crc = Wrapping((crc.0 << 1) ^ 0x1021);
|
||||
} else {
|
||||
crc = Wrapping(crc.0 << 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
crc.0
|
||||
}
|
||||
76
crates/dd3_protocol/src/frame.rs
Normal file
76
crates/dd3_protocol/src/frame.rs
Normal file
@@ -0,0 +1,76 @@
|
||||
use alloc::vec::Vec;
|
||||
|
||||
use crate::crc16_ccitt;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[repr(u8)]
|
||||
pub enum MsgKind {
|
||||
BatchUp = 0,
|
||||
AckDown = 1,
|
||||
}
|
||||
|
||||
impl MsgKind {
|
||||
pub fn from_u8(value: u8) -> Option<Self> {
|
||||
match value {
|
||||
0 => Some(Self::BatchUp),
|
||||
1 => Some(Self::AckDown),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_u8(self) -> u8 {
|
||||
self as u8
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Frame {
|
||||
pub msg_kind: MsgKind,
|
||||
pub short_id: u16,
|
||||
pub payload: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FrameDecodeError {
|
||||
LengthMismatch,
|
||||
CrcFail,
|
||||
InvalidMsgKind,
|
||||
}
|
||||
|
||||
pub fn encode_frame(msg_kind: MsgKind, short_id: u16, payload: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(payload.len() + 5);
|
||||
out.push(msg_kind.as_u8());
|
||||
out.extend_from_slice(&short_id.to_be_bytes());
|
||||
out.extend_from_slice(payload);
|
||||
let crc = crc16_ccitt(&out);
|
||||
out.extend_from_slice(&crc.to_be_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
pub fn decode_frame(frame: &[u8], max_msg_kind: u8) -> Result<Frame, FrameDecodeError> {
|
||||
if frame.len() < 5 {
|
||||
return Err(FrameDecodeError::LengthMismatch);
|
||||
}
|
||||
|
||||
let payload_len = frame.len() - 5;
|
||||
let crc_calc = crc16_ccitt(&frame[..frame.len() - 2]);
|
||||
let crc_rx = u16::from_be_bytes([frame[frame.len() - 2], frame[frame.len() - 1]]);
|
||||
if crc_calc != crc_rx {
|
||||
return Err(FrameDecodeError::CrcFail);
|
||||
}
|
||||
|
||||
let raw_kind = frame[0];
|
||||
if raw_kind > max_msg_kind {
|
||||
return Err(FrameDecodeError::InvalidMsgKind);
|
||||
}
|
||||
let msg_kind = MsgKind::from_u8(raw_kind).ok_or(FrameDecodeError::InvalidMsgKind)?;
|
||||
|
||||
let short_id = u16::from_be_bytes([frame[1], frame[2]]);
|
||||
let payload = frame[3..3 + payload_len].to_vec();
|
||||
|
||||
Ok(Frame {
|
||||
msg_kind,
|
||||
short_id,
|
||||
payload,
|
||||
})
|
||||
}
|
||||
29
crates/dd3_protocol/src/lib.rs
Normal file
29
crates/dd3_protocol/src/lib.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
#![cfg_attr(not(feature = "std"), no_std)]
|
||||
|
||||
extern crate alloc;
|
||||
|
||||
pub mod ack;
|
||||
pub mod crc;
|
||||
pub mod frame;
|
||||
pub mod payload_v3;
|
||||
pub mod reassembly;
|
||||
|
||||
pub use ack::{decode_ack_down_payload, encode_ack_down_payload, AckDecodeError, AckDownPayload};
|
||||
pub use crc::crc16_ccitt;
|
||||
pub use frame::{decode_frame, encode_frame, Frame, FrameDecodeError, MsgKind};
|
||||
pub use payload_v3::{
|
||||
decode_batch_v3, encode_batch_v3, BatchInputV3, PayloadDecodeError, PayloadEncodeError,
|
||||
};
|
||||
pub use reassembly::{push_chunk, reset_reassembly, ReassemblyState, ReassemblyStatus};
|
||||
|
||||
pub const LORA_MAX_PAYLOAD: usize = 230;
|
||||
pub const ACK_DOWN_PAYLOAD_LEN: usize = 7;
|
||||
|
||||
pub const MIN_ACCEPTED_EPOCH_UTC: u32 = 1_769_904_000;
|
||||
pub const SYNC_REQUEST_INTERVAL_MS: u32 = 15_000;
|
||||
pub const METER_SAMPLE_INTERVAL_MS: u32 = 1_000;
|
||||
pub const METER_SEND_INTERVAL_MS: u32 = 30_000;
|
||||
pub const BATCH_MAX_RETRIES: u8 = 2;
|
||||
pub const BATCH_QUEUE_DEPTH: usize = 10;
|
||||
pub const ACK_REPEAT_COUNT: u8 = 3;
|
||||
pub const ACK_REPEAT_DELAY_MS: u32 = 200;
|
||||
316
crates/dd3_protocol/src/payload_v3.rs
Normal file
316
crates/dd3_protocol/src/payload_v3.rs
Normal file
@@ -0,0 +1,316 @@
|
||||
use alloc::vec::Vec;
|
||||
|
||||
const MAGIC: u16 = 0xDDB3;
|
||||
const SCHEMA: u8 = 3;
|
||||
const FLAGS: u8 = 0x01;
|
||||
const MAX_SAMPLES: usize = 30;
|
||||
const PRESENT_MASK_VALID_BITS: u32 = 0x3FFF_FFFF;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BatchInputV3 {
|
||||
pub sender_id: u16,
|
||||
pub batch_id: u16,
|
||||
pub t_last: u32,
|
||||
pub present_mask: u32,
|
||||
pub n: u8,
|
||||
pub battery_mv: u16,
|
||||
pub err_m: u8,
|
||||
pub err_d: u8,
|
||||
pub err_tx: u8,
|
||||
pub err_last: u8,
|
||||
pub err_rx_reject: u8,
|
||||
pub energy_wh: [u32; MAX_SAMPLES],
|
||||
pub p1_w: [i16; MAX_SAMPLES],
|
||||
pub p2_w: [i16; MAX_SAMPLES],
|
||||
pub p3_w: [i16; MAX_SAMPLES],
|
||||
}
|
||||
|
||||
impl Default for BatchInputV3 {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
sender_id: 0,
|
||||
batch_id: 0,
|
||||
t_last: 0,
|
||||
present_mask: 0,
|
||||
n: 0,
|
||||
battery_mv: 0,
|
||||
err_m: 0,
|
||||
err_d: 0,
|
||||
err_tx: 0,
|
||||
err_last: 0,
|
||||
err_rx_reject: 0,
|
||||
energy_wh: [0; MAX_SAMPLES],
|
||||
p1_w: [0; MAX_SAMPLES],
|
||||
p2_w: [0; MAX_SAMPLES],
|
||||
p3_w: [0; MAX_SAMPLES],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PayloadEncodeError {
|
||||
InvalidN,
|
||||
InvalidPresentMask,
|
||||
BitCountMismatch,
|
||||
InvalidSyncMask,
|
||||
EnergyRegression,
|
||||
VarintOverflow,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PayloadDecodeError {
|
||||
LengthMismatch,
|
||||
InvalidMagic,
|
||||
InvalidSchema,
|
||||
InvalidFlags,
|
||||
InvalidN,
|
||||
InvalidPresentMask,
|
||||
BitCountMismatch,
|
||||
InvalidSyncMask,
|
||||
Truncated,
|
||||
Overflow,
|
||||
TrailingBytes,
|
||||
}
|
||||
|
||||
fn bit_count32(mut value: u32) -> u8 {
|
||||
let mut count = 0u8;
|
||||
while value != 0 {
|
||||
value &= value - 1;
|
||||
count = count.saturating_add(1);
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
fn uleb128_encode(mut v: u32, out: &mut Vec<u8>) -> Result<(), PayloadEncodeError> {
|
||||
for _ in 0..5 {
|
||||
let mut byte = (v & 0x7F) as u8;
|
||||
v >>= 7;
|
||||
if v != 0 {
|
||||
byte |= 0x80;
|
||||
}
|
||||
out.push(byte);
|
||||
if v == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err(PayloadEncodeError::VarintOverflow)
|
||||
}
|
||||
|
||||
fn uleb128_decode(buf: &[u8], pos: &mut usize) -> Result<u32, PayloadDecodeError> {
|
||||
let mut result = 0u32;
|
||||
let mut shift = 0u8;
|
||||
for i in 0..5 {
|
||||
if *pos >= buf.len() {
|
||||
return Err(PayloadDecodeError::Truncated);
|
||||
}
|
||||
let byte = buf[*pos];
|
||||
*pos += 1;
|
||||
|
||||
if i == 4 && (byte & 0xF0) != 0 {
|
||||
return Err(PayloadDecodeError::Overflow);
|
||||
}
|
||||
|
||||
result |= ((byte & 0x7F) as u32) << shift;
|
||||
if (byte & 0x80) == 0 {
|
||||
return Ok(result);
|
||||
}
|
||||
shift = shift.saturating_add(7);
|
||||
}
|
||||
|
||||
Err(PayloadDecodeError::Overflow)
|
||||
}
|
||||
|
||||
fn zigzag32(x: i32) -> u32 {
|
||||
((x as u32) << 1) ^ ((x >> 31) as u32)
|
||||
}
|
||||
|
||||
fn unzigzag32(u: u32) -> i32 {
|
||||
((u >> 1) as i32) ^ -((u & 1) as i32)
|
||||
}
|
||||
|
||||
fn svarint_encode(x: i32, out: &mut Vec<u8>) -> Result<(), PayloadEncodeError> {
|
||||
uleb128_encode(zigzag32(x), out)
|
||||
}
|
||||
|
||||
fn svarint_decode(buf: &[u8], pos: &mut usize) -> Result<i32, PayloadDecodeError> {
|
||||
let u = uleb128_decode(buf, pos)?;
|
||||
Ok(unzigzag32(u))
|
||||
}
|
||||
|
||||
pub fn encode_batch_v3(input: &BatchInputV3) -> Result<Vec<u8>, PayloadEncodeError> {
|
||||
if input.n as usize > MAX_SAMPLES {
|
||||
return Err(PayloadEncodeError::InvalidN);
|
||||
}
|
||||
if (input.present_mask & !PRESENT_MASK_VALID_BITS) != 0 {
|
||||
return Err(PayloadEncodeError::InvalidPresentMask);
|
||||
}
|
||||
if bit_count32(input.present_mask) != input.n {
|
||||
return Err(PayloadEncodeError::BitCountMismatch);
|
||||
}
|
||||
if input.n == 0 && input.present_mask != 0 {
|
||||
return Err(PayloadEncodeError::InvalidSyncMask);
|
||||
}
|
||||
|
||||
let mut out = Vec::with_capacity(128);
|
||||
out.extend_from_slice(&MAGIC.to_le_bytes());
|
||||
out.push(SCHEMA);
|
||||
out.push(FLAGS);
|
||||
out.extend_from_slice(&input.sender_id.to_le_bytes());
|
||||
out.extend_from_slice(&input.batch_id.to_le_bytes());
|
||||
out.extend_from_slice(&input.t_last.to_le_bytes());
|
||||
out.extend_from_slice(&input.present_mask.to_le_bytes());
|
||||
out.push(input.n);
|
||||
out.extend_from_slice(&input.battery_mv.to_le_bytes());
|
||||
out.push(input.err_m);
|
||||
out.push(input.err_d);
|
||||
out.push(input.err_tx);
|
||||
out.push(input.err_last);
|
||||
out.push(input.err_rx_reject);
|
||||
|
||||
if input.n == 0 {
|
||||
return Ok(out);
|
||||
}
|
||||
|
||||
let n = input.n as usize;
|
||||
|
||||
out.extend_from_slice(&input.energy_wh[0].to_le_bytes());
|
||||
for i in 1..n {
|
||||
if input.energy_wh[i] < input.energy_wh[i - 1] {
|
||||
return Err(PayloadEncodeError::EnergyRegression);
|
||||
}
|
||||
let delta = input.energy_wh[i] - input.energy_wh[i - 1];
|
||||
uleb128_encode(delta, &mut out)?;
|
||||
}
|
||||
|
||||
let encode_phase = |phase: &[i16; MAX_SAMPLES], out: &mut Vec<u8>| -> Result<(), PayloadEncodeError> {
|
||||
out.extend_from_slice(&phase[0].to_le_bytes());
|
||||
for i in 1..n {
|
||||
let delta = phase[i] as i32 - phase[i - 1] as i32;
|
||||
svarint_encode(delta, out)?;
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
|
||||
encode_phase(&input.p1_w, &mut out)?;
|
||||
encode_phase(&input.p2_w, &mut out)?;
|
||||
encode_phase(&input.p3_w, &mut out)?;
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn decode_batch_v3(buf: &[u8]) -> Result<BatchInputV3, PayloadDecodeError> {
|
||||
if buf.len() < 24 {
|
||||
return Err(PayloadDecodeError::LengthMismatch);
|
||||
}
|
||||
|
||||
let mut pos = 0usize;
|
||||
|
||||
let magic = u16::from_le_bytes([buf[pos], buf[pos + 1]]);
|
||||
pos += 2;
|
||||
if magic != MAGIC {
|
||||
return Err(PayloadDecodeError::InvalidMagic);
|
||||
}
|
||||
|
||||
let schema = buf[pos];
|
||||
pos += 1;
|
||||
if schema != SCHEMA {
|
||||
return Err(PayloadDecodeError::InvalidSchema);
|
||||
}
|
||||
|
||||
let flags = buf[pos];
|
||||
pos += 1;
|
||||
if (flags & 0x01) == 0 {
|
||||
return Err(PayloadDecodeError::InvalidFlags);
|
||||
}
|
||||
|
||||
let mut out = BatchInputV3::default();
|
||||
out.sender_id = u16::from_le_bytes([buf[pos], buf[pos + 1]]);
|
||||
pos += 2;
|
||||
out.batch_id = u16::from_le_bytes([buf[pos], buf[pos + 1]]);
|
||||
pos += 2;
|
||||
out.t_last = u32::from_le_bytes([buf[pos], buf[pos + 1], buf[pos + 2], buf[pos + 3]]);
|
||||
pos += 4;
|
||||
out.present_mask = u32::from_le_bytes([buf[pos], buf[pos + 1], buf[pos + 2], buf[pos + 3]]);
|
||||
pos += 4;
|
||||
out.n = buf[pos];
|
||||
pos += 1;
|
||||
out.battery_mv = u16::from_le_bytes([buf[pos], buf[pos + 1]]);
|
||||
pos += 2;
|
||||
out.err_m = buf[pos];
|
||||
pos += 1;
|
||||
out.err_d = buf[pos];
|
||||
pos += 1;
|
||||
out.err_tx = buf[pos];
|
||||
pos += 1;
|
||||
out.err_last = buf[pos];
|
||||
pos += 1;
|
||||
out.err_rx_reject = buf[pos];
|
||||
pos += 1;
|
||||
|
||||
if out.n as usize > MAX_SAMPLES {
|
||||
return Err(PayloadDecodeError::InvalidN);
|
||||
}
|
||||
if (out.present_mask & !PRESENT_MASK_VALID_BITS) != 0 {
|
||||
return Err(PayloadDecodeError::InvalidPresentMask);
|
||||
}
|
||||
if bit_count32(out.present_mask) != out.n {
|
||||
return Err(PayloadDecodeError::BitCountMismatch);
|
||||
}
|
||||
if out.n == 0 && out.present_mask != 0 {
|
||||
return Err(PayloadDecodeError::InvalidSyncMask);
|
||||
}
|
||||
|
||||
if out.n == 0 {
|
||||
return if pos == buf.len() {
|
||||
Ok(out)
|
||||
} else {
|
||||
Err(PayloadDecodeError::TrailingBytes)
|
||||
};
|
||||
}
|
||||
|
||||
let n = out.n as usize;
|
||||
|
||||
if pos + 4 > buf.len() {
|
||||
return Err(PayloadDecodeError::Truncated);
|
||||
}
|
||||
out.energy_wh[0] = u32::from_le_bytes([buf[pos], buf[pos + 1], buf[pos + 2], buf[pos + 3]]);
|
||||
pos += 4;
|
||||
|
||||
for i in 1..n {
|
||||
let delta = uleb128_decode(buf, &mut pos)?;
|
||||
let sum = out.energy_wh[i - 1] as u64 + delta as u64;
|
||||
if sum > u32::MAX as u64 {
|
||||
return Err(PayloadDecodeError::Overflow);
|
||||
}
|
||||
out.energy_wh[i] = sum as u32;
|
||||
}
|
||||
|
||||
let decode_phase = |dst: &mut [i16; MAX_SAMPLES], pos: &mut usize| -> Result<(), PayloadDecodeError> {
|
||||
if *pos + 2 > buf.len() {
|
||||
return Err(PayloadDecodeError::Truncated);
|
||||
}
|
||||
dst[0] = i16::from_le_bytes([buf[*pos], buf[*pos + 1]]);
|
||||
*pos += 2;
|
||||
let mut prev = dst[0] as i32;
|
||||
for i in 1..n {
|
||||
let delta = svarint_decode(buf, pos)?;
|
||||
let value = prev + delta;
|
||||
if value < i16::MIN as i32 || value > i16::MAX as i32 {
|
||||
return Err(PayloadDecodeError::Overflow);
|
||||
}
|
||||
dst[i] = value as i16;
|
||||
prev = value;
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
|
||||
decode_phase(&mut out.p1_w, &mut pos)?;
|
||||
decode_phase(&mut out.p2_w, &mut pos)?;
|
||||
decode_phase(&mut out.p3_w, &mut pos)?;
|
||||
|
||||
if pos != buf.len() {
|
||||
return Err(PayloadDecodeError::TrailingBytes);
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
109
crates/dd3_protocol/src/reassembly.rs
Normal file
109
crates/dd3_protocol/src/reassembly.rs
Normal file
@@ -0,0 +1,109 @@
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ReassemblyState {
|
||||
pub active: bool,
|
||||
pub batch_id: u16,
|
||||
pub next_index: u8,
|
||||
pub expected_chunks: u8,
|
||||
pub total_len: u16,
|
||||
pub received_len: u16,
|
||||
pub last_rx_ms: u32,
|
||||
pub timeout_ms: u32,
|
||||
}
|
||||
|
||||
impl Default for ReassemblyState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
active: false,
|
||||
batch_id: 0,
|
||||
next_index: 0,
|
||||
expected_chunks: 0,
|
||||
total_len: 0,
|
||||
received_len: 0,
|
||||
last_rx_ms: 0,
|
||||
timeout_ms: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ReassemblyStatus {
|
||||
InProgress,
|
||||
Complete { complete_len: u16 },
|
||||
ErrorReset,
|
||||
}
|
||||
|
||||
pub fn reset_reassembly(state: &mut ReassemblyState) {
|
||||
*state = ReassemblyState::default();
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn push_chunk(
|
||||
state: &mut ReassemblyState,
|
||||
batch_id: u16,
|
||||
chunk_index: u8,
|
||||
chunk_count: u8,
|
||||
total_len: u16,
|
||||
chunk_data: &[u8],
|
||||
now_ms: u32,
|
||||
timeout_ms_for_new_batch: u32,
|
||||
max_total_len: u16,
|
||||
buffer: &mut [u8],
|
||||
) -> ReassemblyStatus {
|
||||
if chunk_data.len() > 0 && total_len == 0 {
|
||||
reset_reassembly(state);
|
||||
return ReassemblyStatus::ErrorReset;
|
||||
}
|
||||
|
||||
let expired = state.timeout_ms > 0
|
||||
&& now_ms.wrapping_sub(state.last_rx_ms) > state.timeout_ms;
|
||||
|
||||
if !state.active || batch_id != state.batch_id || expired {
|
||||
if chunk_index != 0 {
|
||||
reset_reassembly(state);
|
||||
return ReassemblyStatus::ErrorReset;
|
||||
}
|
||||
if total_len == 0 || total_len > max_total_len || chunk_count == 0 {
|
||||
reset_reassembly(state);
|
||||
return ReassemblyStatus::ErrorReset;
|
||||
}
|
||||
|
||||
state.active = true;
|
||||
state.batch_id = batch_id;
|
||||
state.expected_chunks = chunk_count;
|
||||
state.total_len = total_len;
|
||||
state.received_len = 0;
|
||||
state.next_index = 0;
|
||||
state.last_rx_ms = now_ms;
|
||||
state.timeout_ms = timeout_ms_for_new_batch;
|
||||
}
|
||||
|
||||
if !state.active || chunk_index != state.next_index || chunk_count != state.expected_chunks {
|
||||
reset_reassembly(state);
|
||||
return ReassemblyStatus::ErrorReset;
|
||||
}
|
||||
|
||||
let next_received = state.received_len as usize + chunk_data.len();
|
||||
if next_received > state.total_len as usize
|
||||
|| next_received > max_total_len as usize
|
||||
|| next_received > buffer.len()
|
||||
{
|
||||
reset_reassembly(state);
|
||||
return ReassemblyStatus::ErrorReset;
|
||||
}
|
||||
|
||||
let start = state.received_len as usize;
|
||||
let end = start + chunk_data.len();
|
||||
buffer[start..end].copy_from_slice(chunk_data);
|
||||
|
||||
state.received_len = next_received as u16;
|
||||
state.next_index = state.next_index.wrapping_add(1);
|
||||
state.last_rx_ms = now_ms;
|
||||
|
||||
if state.next_index == state.expected_chunks && state.received_len == state.total_len {
|
||||
let complete_len = state.received_len;
|
||||
reset_reassembly(state);
|
||||
return ReassemblyStatus::Complete { complete_len };
|
||||
}
|
||||
|
||||
ReassemblyStatus::InProgress
|
||||
}
|
||||
347
crates/dd3_protocol/tests/protocol_tests.rs
Normal file
347
crates/dd3_protocol/tests/protocol_tests.rs
Normal file
@@ -0,0 +1,347 @@
|
||||
use dd3_protocol::{
|
||||
crc16_ccitt, decode_ack_down_payload, decode_batch_v3, decode_frame, encode_ack_down_payload,
|
||||
encode_batch_v3, encode_frame, push_chunk, AckDownPayload, BatchInputV3, FrameDecodeError, MsgKind,
|
||||
ReassemblyState, ReassemblyStatus,
|
||||
};
|
||||
|
||||
const FIX_FRAME_OK: &[u8] = include_bytes!("../../../fixtures/protocol/frames/batchup_f19c_payload_0102a5.bin");
|
||||
const FIX_FRAME_BAD_CRC: &[u8] =
|
||||
include_bytes!("../../../fixtures/protocol/frames/batchup_f19c_payload_0102a5_bad_crc.bin");
|
||||
|
||||
const FIX_CHUNK_OK: &[u8] = include_bytes!("../../../fixtures/protocol/chunks/in_order_ok.bin");
|
||||
const FIX_CHUNK_MISSING: &[u8] = include_bytes!("../../../fixtures/protocol/chunks/missing_chunk.bin");
|
||||
const FIX_CHUNK_OUT_OF_ORDER: &[u8] =
|
||||
include_bytes!("../../../fixtures/protocol/chunks/out_of_order_start.bin");
|
||||
const FIX_CHUNK_WRONG_LEN: &[u8] =
|
||||
include_bytes!("../../../fixtures/protocol/chunks/wrong_total_len.bin");
|
||||
|
||||
const FIX_SYNC_EMPTY: &[u8] = include_bytes!("../../../fixtures/protocol/payload_v3/sync_empty.bin");
|
||||
const FIX_SPARSE_5: &[u8] = include_bytes!("../../../fixtures/protocol/payload_v3/sparse_5.bin");
|
||||
const FIX_FULL_30: &[u8] = include_bytes!("../../../fixtures/protocol/payload_v3/full_30.bin");
|
||||
|
||||
fn parse_chunk_records(bytes: &[u8]) -> Vec<(u16, u8, u8, u16, Vec<u8>)> {
|
||||
let mut out = Vec::new();
|
||||
let mut pos = 0usize;
|
||||
while pos < bytes.len() {
|
||||
let batch_id = u16::from_le_bytes([bytes[pos], bytes[pos + 1]]);
|
||||
pos += 2;
|
||||
let idx = bytes[pos];
|
||||
pos += 1;
|
||||
let count = bytes[pos];
|
||||
pos += 1;
|
||||
let total_len = u16::from_le_bytes([bytes[pos], bytes[pos + 1]]);
|
||||
pos += 2;
|
||||
let chunk_len = bytes[pos] as usize;
|
||||
pos += 1;
|
||||
let chunk = bytes[pos..pos + chunk_len].to_vec();
|
||||
pos += chunk_len;
|
||||
out.push((batch_id, idx, count, total_len, chunk));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn fill_sparse_batch() -> BatchInputV3 {
|
||||
let mut input = BatchInputV3::default();
|
||||
input.sender_id = 1;
|
||||
input.batch_id = 42;
|
||||
input.t_last = 1_700_000_000;
|
||||
input.present_mask = (1u32 << 0) | (1u32 << 2) | (1u32 << 3) | (1u32 << 10) | (1u32 << 29);
|
||||
input.n = 5;
|
||||
input.battery_mv = 3750;
|
||||
input.err_m = 2;
|
||||
input.err_d = 1;
|
||||
input.err_tx = 3;
|
||||
input.err_last = 2;
|
||||
input.err_rx_reject = 1;
|
||||
|
||||
input.energy_wh[0] = 100_000;
|
||||
input.energy_wh[1] = 100_001;
|
||||
input.energy_wh[2] = 100_050;
|
||||
input.energy_wh[3] = 100_050;
|
||||
input.energy_wh[4] = 100_200;
|
||||
|
||||
input.p1_w[0] = -120;
|
||||
input.p1_w[1] = -90;
|
||||
input.p1_w[2] = 1910;
|
||||
input.p1_w[3] = -90;
|
||||
input.p1_w[4] = 500;
|
||||
|
||||
input.p2_w[0] = 50;
|
||||
input.p2_w[1] = -1950;
|
||||
input.p2_w[2] = 60;
|
||||
input.p2_w[3] = 2060;
|
||||
input.p2_w[4] = -10;
|
||||
|
||||
input.p3_w[0] = 0;
|
||||
input.p3_w[1] = 10;
|
||||
input.p3_w[2] = -1990;
|
||||
input.p3_w[3] = 10;
|
||||
input.p3_w[4] = 20;
|
||||
input
|
||||
}
|
||||
|
||||
fn fill_full_batch() -> BatchInputV3 {
|
||||
let mut input = BatchInputV3::default();
|
||||
input.sender_id = 1;
|
||||
input.batch_id = 0xBEEF;
|
||||
input.t_last = 1_769_904_999;
|
||||
input.present_mask = 0x3FFF_FFFF;
|
||||
input.n = 30;
|
||||
input.battery_mv = 4095;
|
||||
input.err_m = 10;
|
||||
input.err_d = 20;
|
||||
input.err_tx = 30;
|
||||
input.err_last = 3;
|
||||
input.err_rx_reject = 6;
|
||||
|
||||
for i in 0..30usize {
|
||||
input.energy_wh[i] = 500_000 + (i as u32 * i as u32 * 3);
|
||||
input.p1_w[i] = -1000 + (i as i16 * 25);
|
||||
input.p2_w[i] = 500 - (i as i16 * 30);
|
||||
input.p3_w[i] = if i % 2 == 0 {
|
||||
100 + (i as i16 * 5)
|
||||
} else {
|
||||
-100 + (i as i16 * 5)
|
||||
};
|
||||
}
|
||||
|
||||
input
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crc16_known_vectors() {
|
||||
assert_eq!(0x29B1, crc16_ccitt(b"123456789"));
|
||||
assert_eq!(0x1C0F, crc16_ccitt(&[0x00, 0x01, 0x02, 0x03, 0x04]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_encode_decode_and_crc_reject() {
|
||||
let encoded = encode_frame(MsgKind::BatchUp, 0xF19C, &[0x01, 0x02, 0xA5]);
|
||||
assert_eq!(FIX_FRAME_OK, encoded.as_slice());
|
||||
|
||||
let frame = decode_frame(FIX_FRAME_OK, MsgKind::AckDown as u8).unwrap();
|
||||
assert_eq!(MsgKind::BatchUp, frame.msg_kind);
|
||||
assert_eq!(0xF19C, frame.short_id);
|
||||
assert_eq!(&[0x01, 0x02, 0xA5], frame.payload.as_slice());
|
||||
|
||||
let err = decode_frame(FIX_FRAME_BAD_CRC, MsgKind::AckDown as u8).unwrap_err();
|
||||
assert_eq!(FrameDecodeError::CrcFail, err);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_rejects_invalid_msg_kind_and_short_length() {
|
||||
let mut frame = encode_frame(MsgKind::BatchUp, 0xF19C, &[0x42]);
|
||||
frame[0] = 2;
|
||||
let crc = crc16_ccitt(&frame[..frame.len() - 2]);
|
||||
let n = frame.len();
|
||||
frame[n - 2] = (crc >> 8) as u8;
|
||||
frame[n - 1] = (crc & 0xFF) as u8;
|
||||
|
||||
let bad_kind = decode_frame(&frame, MsgKind::AckDown as u8).unwrap_err();
|
||||
assert_eq!(FrameDecodeError::InvalidMsgKind, bad_kind);
|
||||
|
||||
let short_len = decode_frame(&frame[..4], MsgKind::AckDown as u8).unwrap_err();
|
||||
assert_eq!(FrameDecodeError::LengthMismatch, short_len);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ack_payload_fixed_7byte_contract() {
|
||||
let payload = AckDownPayload {
|
||||
time_valid: true,
|
||||
batch_id: 0x1234,
|
||||
epoch_utc: 1_769_904_000,
|
||||
};
|
||||
let encoded = encode_ack_down_payload(payload);
|
||||
assert_eq!(7, encoded.len());
|
||||
assert_eq!(1, encoded[0]);
|
||||
assert_eq!([0x12, 0x34], [encoded[1], encoded[2]]);
|
||||
|
||||
let decoded = decode_ack_down_payload(&encoded).unwrap();
|
||||
assert_eq!(payload, decoded);
|
||||
|
||||
assert!(decode_ack_down_payload(&encoded[..6]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chunk_reassembly_in_order_success() {
|
||||
let records = parse_chunk_records(FIX_CHUNK_OK);
|
||||
let mut state = ReassemblyState::default();
|
||||
let mut buffer = [0u8; 32];
|
||||
|
||||
let mut status = ReassemblyStatus::InProgress;
|
||||
for (i, rec) in records.iter().enumerate() {
|
||||
status = push_chunk(
|
||||
&mut state,
|
||||
rec.0,
|
||||
rec.1,
|
||||
rec.2,
|
||||
rec.3,
|
||||
&rec.4,
|
||||
1000 + (i as u32 * 100),
|
||||
5000,
|
||||
32,
|
||||
&mut buffer,
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(ReassemblyStatus::Complete { complete_len: 7 }, status);
|
||||
assert_eq!(&[1, 2, 3, 4, 5, 6, 7], &buffer[..7]);
|
||||
assert!(!state.active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chunk_reassembly_missing_or_out_of_order_fails_deterministically() {
|
||||
let records = parse_chunk_records(FIX_CHUNK_MISSING);
|
||||
let mut state = ReassemblyState::default();
|
||||
let mut buffer = [0u8; 32];
|
||||
|
||||
let s0 = push_chunk(
|
||||
&mut state,
|
||||
records[0].0,
|
||||
records[0].1,
|
||||
records[0].2,
|
||||
records[0].3,
|
||||
&records[0].4,
|
||||
1000,
|
||||
5000,
|
||||
32,
|
||||
&mut buffer,
|
||||
);
|
||||
assert_eq!(ReassemblyStatus::InProgress, s0);
|
||||
|
||||
let s1 = push_chunk(
|
||||
&mut state,
|
||||
records[1].0,
|
||||
records[1].1,
|
||||
records[1].2,
|
||||
records[1].3,
|
||||
&records[1].4,
|
||||
1100,
|
||||
5000,
|
||||
32,
|
||||
&mut buffer,
|
||||
);
|
||||
assert_eq!(ReassemblyStatus::ErrorReset, s1);
|
||||
assert!(!state.active);
|
||||
|
||||
let out_records = parse_chunk_records(FIX_CHUNK_OUT_OF_ORDER);
|
||||
let s2 = push_chunk(
|
||||
&mut state,
|
||||
out_records[0].0,
|
||||
out_records[0].1,
|
||||
out_records[0].2,
|
||||
out_records[0].3,
|
||||
&out_records[0].4,
|
||||
1200,
|
||||
5000,
|
||||
32,
|
||||
&mut buffer,
|
||||
);
|
||||
assert_eq!(ReassemblyStatus::ErrorReset, s2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chunk_reassembly_wrong_total_length_fails() {
|
||||
let records = parse_chunk_records(FIX_CHUNK_WRONG_LEN);
|
||||
let mut state = ReassemblyState::default();
|
||||
let mut buffer = [0u8; 8];
|
||||
|
||||
let s0 = push_chunk(
|
||||
&mut state,
|
||||
records[0].0,
|
||||
records[0].1,
|
||||
records[0].2,
|
||||
records[0].3,
|
||||
&records[0].4,
|
||||
1000,
|
||||
5000,
|
||||
8,
|
||||
&mut buffer,
|
||||
);
|
||||
assert_eq!(ReassemblyStatus::InProgress, s0);
|
||||
|
||||
let s1 = push_chunk(
|
||||
&mut state,
|
||||
records[1].0,
|
||||
records[1].1,
|
||||
records[1].2,
|
||||
records[1].3,
|
||||
&records[1].4,
|
||||
1100,
|
||||
5000,
|
||||
8,
|
||||
&mut buffer,
|
||||
);
|
||||
assert_eq!(ReassemblyStatus::ErrorReset, s1);
|
||||
assert!(!state.active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn payload_golden_vectors_roundtrip() {
|
||||
let decoded_sync = decode_batch_v3(FIX_SYNC_EMPTY).unwrap();
|
||||
assert_eq!(1, decoded_sync.sender_id);
|
||||
assert_eq!(0x1234, decoded_sync.batch_id);
|
||||
assert_eq!(1_769_904_100, decoded_sync.t_last);
|
||||
assert_eq!(0, decoded_sync.present_mask);
|
||||
assert_eq!(0, decoded_sync.n);
|
||||
assert_eq!(3750, decoded_sync.battery_mv);
|
||||
|
||||
let decoded_sparse = decode_batch_v3(FIX_SPARSE_5).unwrap();
|
||||
assert_eq!(fill_sparse_batch(), decoded_sparse);
|
||||
|
||||
let decoded_full = decode_batch_v3(FIX_FULL_30).unwrap();
|
||||
assert_eq!(fill_full_batch(), decoded_full);
|
||||
|
||||
assert_eq!(FIX_SYNC_EMPTY, encode_batch_v3(&decoded_sync).unwrap().as_slice());
|
||||
assert_eq!(FIX_SPARSE_5, encode_batch_v3(&decoded_sparse).unwrap().as_slice());
|
||||
assert_eq!(FIX_FULL_30, encode_batch_v3(&decoded_full).unwrap().as_slice());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn payload_decode_rejects_bad_magic_schema_flags() {
|
||||
let mut bad_magic = FIX_SPARSE_5.to_vec();
|
||||
bad_magic[0] = 0x00;
|
||||
assert!(decode_batch_v3(&bad_magic).is_err());
|
||||
|
||||
let mut bad_schema = FIX_SPARSE_5.to_vec();
|
||||
bad_schema[2] = 0x02;
|
||||
assert!(decode_batch_v3(&bad_schema).is_err());
|
||||
|
||||
let mut bad_flags = FIX_SPARSE_5.to_vec();
|
||||
bad_flags[3] = 0x00;
|
||||
assert!(decode_batch_v3(&bad_flags).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn payload_decode_rejects_truncated_and_trailing() {
|
||||
assert!(decode_batch_v3(&FIX_SPARSE_5[..FIX_SPARSE_5.len() - 1]).is_err());
|
||||
assert!(decode_batch_v3(&FIX_SPARSE_5[..12]).is_err());
|
||||
|
||||
let mut with_tail = FIX_SPARSE_5.to_vec();
|
||||
with_tail.push(0xAA);
|
||||
assert!(decode_batch_v3(&with_tail).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn payload_encode_decode_reject_invalid_mask_and_n() {
|
||||
let mut input = fill_sparse_batch();
|
||||
input.present_mask = 0x4000_0000;
|
||||
assert!(encode_batch_v3(&input).is_err());
|
||||
|
||||
let mut input2 = fill_sparse_batch();
|
||||
input2.n = 31;
|
||||
assert!(encode_batch_v3(&input2).is_err());
|
||||
|
||||
let mut input3 = fill_sparse_batch();
|
||||
input3.n = 0;
|
||||
input3.present_mask = 1;
|
||||
assert!(encode_batch_v3(&input3).is_err());
|
||||
|
||||
let mut invalid_bits = FIX_SPARSE_5.to_vec();
|
||||
invalid_bits[15] |= 0x40;
|
||||
assert!(decode_batch_v3(&invalid_bits).is_err());
|
||||
|
||||
let mut bitcount_mismatch = FIX_SPARSE_5.to_vec();
|
||||
bitcount_mismatch[16] = 0x01;
|
||||
assert!(decode_batch_v3(&bitcount_mismatch).is_err());
|
||||
}
|
||||
Reference in New Issue
Block a user