use serde::{Deserialize, Serialize};
pub const MAG_FRAME_MAGIC: u32 = 0xC51A_6E70;
pub const MAG_FRAME_VERSION: u16 = 1;
pub const MAG_FRAME_BYTES: usize = 60;
pub mod flag {
pub const SATURATION_NEAR_FIELD: u16 = 1 << 0;
pub const ADC_SATURATED: u16 = 1 << 1;
pub const HEAVY_ATTENUATION: u16 = 1 << 2;
pub const SHOT_NOISE_DISABLED: u16 = 1 << 3;
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MagFrame {
pub flags: u16,
pub sensor_id: u16,
pub t_us: u64,
pub b_pt: [f32; 3],
pub sigma_pt: [f32; 3],
pub noise_floor_pt_sqrt_hz: f32,
pub temperature_k: f32,
}
impl MagFrame {
pub fn empty(sensor_id: u16) -> Self {
Self {
flags: 0,
sensor_id,
t_us: 0,
b_pt: [0.0; 3],
sigma_pt: [0.0; 3],
noise_floor_pt_sqrt_hz: 0.0,
temperature_k: 295.0,
}
}
#[inline]
pub fn has_flag(&self, flag_bit: u16) -> bool {
self.flags & flag_bit != 0
}
#[inline]
pub fn set_flag(&mut self, flag_bit: u16) {
self.flags |= flag_bit;
}
pub fn to_bytes(&self) -> [u8; MAG_FRAME_BYTES] {
let mut buf = [0u8; MAG_FRAME_BYTES];
buf[0..4].copy_from_slice(&MAG_FRAME_MAGIC.to_le_bytes());
buf[4..6].copy_from_slice(&MAG_FRAME_VERSION.to_le_bytes());
buf[6..8].copy_from_slice(&self.flags.to_le_bytes());
buf[8..10].copy_from_slice(&self.sensor_id.to_le_bytes());
buf[12..20].copy_from_slice(&self.t_us.to_le_bytes());
buf[20..24].copy_from_slice(&self.b_pt[0].to_le_bytes());
buf[24..28].copy_from_slice(&self.b_pt[1].to_le_bytes());
buf[28..32].copy_from_slice(&self.b_pt[2].to_le_bytes());
buf[32..36].copy_from_slice(&self.sigma_pt[0].to_le_bytes());
buf[36..40].copy_from_slice(&self.sigma_pt[1].to_le_bytes());
buf[40..44].copy_from_slice(&self.sigma_pt[2].to_le_bytes());
buf[44..48].copy_from_slice(&self.noise_floor_pt_sqrt_hz.to_le_bytes());
buf[48..52].copy_from_slice(&self.temperature_k.to_le_bytes());
buf
}
pub fn from_bytes(buf: &[u8]) -> Result<Self, crate::NvsimError> {
if buf.len() != MAG_FRAME_BYTES {
return Err(crate::NvsimError::FrameLengthMismatch {
got: buf.len(),
expected: MAG_FRAME_BYTES,
});
}
let magic = u32::from_le_bytes(buf[0..4].try_into().expect("4-byte slice"));
if magic != MAG_FRAME_MAGIC {
return Err(crate::NvsimError::MagicMismatch {
got: magic,
expected: MAG_FRAME_MAGIC,
});
}
let version = u16::from_le_bytes(buf[4..6].try_into().expect("2-byte slice"));
if version != MAG_FRAME_VERSION {
return Err(crate::NvsimError::UnsupportedVersion {
got: version,
supported: MAG_FRAME_VERSION,
});
}
let flags = u16::from_le_bytes(buf[6..8].try_into().expect("2-byte slice"));
let sensor_id = u16::from_le_bytes(buf[8..10].try_into().expect("2-byte slice"));
let t_us = u64::from_le_bytes(buf[12..20].try_into().expect("8-byte slice"));
let bx = f32::from_le_bytes(buf[20..24].try_into().expect("4-byte slice"));
let by = f32::from_le_bytes(buf[24..28].try_into().expect("4-byte slice"));
let bz = f32::from_le_bytes(buf[28..32].try_into().expect("4-byte slice"));
let sx = f32::from_le_bytes(buf[32..36].try_into().expect("4-byte slice"));
let sy = f32::from_le_bytes(buf[36..40].try_into().expect("4-byte slice"));
let sz = f32::from_le_bytes(buf[40..44].try_into().expect("4-byte slice"));
let noise_floor = f32::from_le_bytes(buf[44..48].try_into().expect("4-byte slice"));
let temperature = f32::from_le_bytes(buf[48..52].try_into().expect("4-byte slice"));
Ok(Self {
flags,
sensor_id,
t_us,
b_pt: [bx, by, bz],
sigma_pt: [sx, sy, sz],
noise_floor_pt_sqrt_hz: noise_floor,
temperature_k: temperature,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn magic_is_locked_to_documented_value() {
assert_eq!(MAG_FRAME_MAGIC, 0xC51A_6E70);
}
#[test]
fn frame_round_trip_byte_exact() {
let mut f = MagFrame::empty(7);
f.set_flag(flag::ADC_SATURATED);
f.set_flag(flag::SHOT_NOISE_DISABLED);
f.t_us = 123_456_789;
f.b_pt = [1.5, -2.5, 3.5];
f.sigma_pt = [0.1, 0.2, 0.3];
f.noise_floor_pt_sqrt_hz = 100.0;
f.temperature_k = 295.0;
let bytes = f.to_bytes();
assert_eq!(bytes.len(), MAG_FRAME_BYTES);
let f2 = MagFrame::from_bytes(&bytes).unwrap();
assert_eq!(f, f2);
assert!(f2.has_flag(flag::ADC_SATURATED));
assert!(f2.has_flag(flag::SHOT_NOISE_DISABLED));
assert!(!f2.has_flag(flag::SATURATION_NEAR_FIELD));
}
#[test]
fn frame_size_is_fixed_60_bytes() {
let f = MagFrame::empty(0);
assert_eq!(f.to_bytes().len(), 60);
}
#[test]
fn frame_rejects_short_buffer() {
let err = MagFrame::from_bytes(&[0u8; 10]).unwrap_err();
assert!(matches!(err, crate::NvsimError::FrameLengthMismatch { .. }));
}
#[test]
fn frame_rejects_bad_magic() {
let mut bytes = MagFrame::empty(0).to_bytes();
bytes[0..4].copy_from_slice(&0xDEAD_BEEF_u32.to_le_bytes());
let err = MagFrame::from_bytes(&bytes).unwrap_err();
assert!(matches!(err, crate::NvsimError::MagicMismatch { .. }));
}
#[test]
fn frame_rejects_unsupported_version() {
let mut bytes = MagFrame::empty(0).to_bytes();
bytes[4..6].copy_from_slice(&99_u16.to_le_bytes());
let err = MagFrame::from_bytes(&bytes).unwrap_err();
assert!(matches!(
err,
crate::NvsimError::UnsupportedVersion { got: 99, .. }
));
}
#[test]
fn frame_byte_order_is_deterministic() {
let f = MagFrame {
flags: 0,
sensor_id: 42,
t_us: 999,
b_pt: [1.0, 2.0, 3.0],
sigma_pt: [0.1, 0.2, 0.3],
noise_floor_pt_sqrt_hz: 50.0,
temperature_k: 295.0,
};
let bytes_a = f.to_bytes();
let bytes_b = f.to_bytes();
assert_eq!(bytes_a, bytes_b);
}
#[test]
fn flag_helpers_set_and_check() {
let mut f = MagFrame::empty(0);
assert!(!f.has_flag(flag::ADC_SATURATED));
f.set_flag(flag::ADC_SATURATED);
assert!(f.has_flag(flag::ADC_SATURATED));
assert!(!f.has_flag(flag::HEAVY_ATTENUATION));
}
}