Bootstrap DD3 Rust port workspace with host-first compatibility tests

This commit is contained in:
2026-02-21 00:59:03 +01:00
parent d3f9a2e62d
commit d0212f4e38
63 changed files with 3914 additions and 0 deletions

View File

@@ -0,0 +1,10 @@
[package]
name = "dd3_protocol"
version = "0.1.0"
edition = "2021"
[features]
default = ["std"]
std = []
[dependencies]

View 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]]),
})
}

View 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
}

View 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,
})
}

View 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;

View 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)
}

View 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
}

View 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());
}