use std::io::{Read, Seek, SeekFrom};
use binrw::{Endian, binrw};
use crate::{
domain::{AcquisitionDateTime, ByteOrder, FileRevision, GraphMetadata},
error::{BiopacError, HeaderSection, ParseError, UnsupportedVersionError},
};
pub(super) const REVISION_POST4: i32 = 68;
const REVISION_MIN: i32 = 30;
const REVISION_MAX: i32 = 200;
const MAX_CHANNELS: i16 = 256;
const COMPRESSED_FLAG_MIN_LEN: i32 = 1937;
const COMPRESSED_FLAG_OFFSET: u64 = 1936;
const GRAPH_TITLE_OFFSET: u64 = 236;
const GRAPH_HDR_TITLE_MIN_LEN: i32 = 276;
const GRAPH_DATETIME_OFFSET: u64 = 276;
const GRAPH_HDR_DATETIME_MIN_LEN: i32 = 300;
const GRAPH_MAX_RATE_OFFSET: u64 = 1940;
const GRAPH_HDR_MAX_RATE_MIN_LEN: i32 = 1944;
pub(super) fn detect_byte_order<R: Read + Seek>(
reader: &mut R,
) -> Result<(Endian, i32), BiopacError> {
let mut buf = [0u8; 6];
reader.read_exact(&mut buf).map_err(BiopacError::Io)?;
reader.seek(SeekFrom::Start(0)).map_err(BiopacError::Io)?;
let ver_bytes = [buf[2], buf[3], buf[4], buf[5]];
let le = i32::from_le_bytes(ver_bytes);
if (REVISION_MIN..=REVISION_MAX).contains(&le) {
return Ok((Endian::Little, le));
}
let be = i32::from_be_bytes(ver_bytes);
if (REVISION_MIN..=REVISION_MAX).contains(&be) {
return Ok((Endian::Big, be));
}
Err(BiopacError::UnsupportedVersion(UnsupportedVersionError {
revision: le,
min_supported: REVISION_MIN,
max_supported: REVISION_MAX,
}))
}
#[binrw]
#[derive(Debug, Copy, Clone)]
pub(super) struct GraphHeaderPre4Raw {
#[br(pad_before = 2)]
pub version: i32,
#[br(pad_before = 4)]
pub channels: i16,
#[br(pad_before = 4)]
pub sample_time_ms: f64,
#[br(pad_before = 228, pad_after = 2)]
pub chan_header_len: i16,
}
pub(super) struct Pre4Parsed {
pub metadata: GraphMetadata,
pub graph_header_len: u64,
pub chan_header_len: i32,
}
pub(super) fn parse_graph_header_pre4(
raw: GraphHeaderPre4Raw,
endian: Endian,
) -> Result<Pre4Parsed, BiopacError> {
validate_channels(raw.channels, 10)?;
validate_sample_time(raw.sample_time_ms, 16)?;
let metadata = GraphMetadata {
file_revision: FileRevision::new(raw.version),
samples_per_second: 1000.0 / raw.sample_time_ms,
channel_count: u16::try_from(raw.channels).unwrap_or(0),
byte_order: endian_to_byte_order(endian),
compressed: false, title: None,
acquisition_datetime: None,
max_samples_per_second: None,
};
Ok(Pre4Parsed {
metadata,
graph_header_len: 256,
chan_header_len: i32::from(raw.chan_header_len),
})
}
#[binrw]
#[derive(Debug, Copy, Clone)]
pub(super) struct GraphHeaderPost4Raw {
#[br(pad_before = 2)]
pub version: i32,
pub graph_header_len: i32,
pub channels: i16,
#[br(pad_before = 4)]
pub sample_time_ms: f64,
#[br(
if(graph_header_len >= GRAPH_HDR_TITLE_MIN_LEN),
seek_before = SeekFrom::Start(GRAPH_TITLE_OFFSET)
)]
pub title: Option<[u8; 40]>,
#[br(
if(graph_header_len >= GRAPH_HDR_DATETIME_MIN_LEN),
seek_before = SeekFrom::Start(GRAPH_DATETIME_OFFSET)
)]
pub acq_sec: Option<i32>,
#[br(if(graph_header_len >= GRAPH_HDR_DATETIME_MIN_LEN))]
pub acq_min: Option<i32>,
#[br(if(graph_header_len >= GRAPH_HDR_DATETIME_MIN_LEN))]
pub acq_hour: Option<i32>,
#[br(if(graph_header_len >= GRAPH_HDR_DATETIME_MIN_LEN))]
pub acq_day: Option<i32>,
#[br(if(graph_header_len >= GRAPH_HDR_DATETIME_MIN_LEN))]
pub acq_month: Option<i32>,
#[br(if(graph_header_len >= GRAPH_HDR_DATETIME_MIN_LEN))]
pub acq_year: Option<i32>,
#[br(
if(graph_header_len >= COMPRESSED_FLAG_MIN_LEN),
seek_before = SeekFrom::Start(COMPRESSED_FLAG_OFFSET)
)]
pub compressed: Option<u8>,
#[br(
if(graph_header_len >= GRAPH_HDR_MAX_RATE_MIN_LEN),
seek_before = SeekFrom::Start(GRAPH_MAX_RATE_OFFSET)
)]
pub max_acq_samples_per_sec: Option<i32>,
}
pub(super) struct Post4Parsed {
pub metadata: GraphMetadata,
pub graph_header_len: u64,
}
pub(super) fn parse_graph_header_post4(
raw: GraphHeaderPost4Raw,
endian: Endian,
) -> Result<Post4Parsed, BiopacError> {
validate_channels(raw.channels, 10)?;
validate_sample_time(raw.sample_time_ms, 16)?;
if raw.graph_header_len < 20 {
return Err(BiopacError::Parse(ParseError {
byte_offset: 6,
expected: alloc::string::String::from("lExtItemHeaderLen >= 20"),
actual: alloc::format!("{}", raw.graph_header_len),
section: HeaderSection::Graph,
}));
}
let compressed = raw.compressed.is_some_and(|b| b != 0);
#[expect(
clippy::cast_sign_loss,
reason = "validated graph_header_len >= 20 above"
)]
let graph_header_len = raw.graph_header_len as u64;
let title = raw.title.map(|t| null_term_bytes_to_string(&t));
let acquisition_datetime = match (
raw.acq_year,
raw.acq_month,
raw.acq_day,
raw.acq_hour,
raw.acq_min,
raw.acq_sec,
) {
(Some(year), Some(month), Some(day), Some(hour), Some(minute), Some(second)) => {
Some(AcquisitionDateTime {
year,
month,
day,
hour,
minute,
second,
})
}
_ => None,
};
let max_samples_per_second = raw
.max_acq_samples_per_sec
.and_then(|n| u32::try_from(n).ok());
let metadata = GraphMetadata {
file_revision: FileRevision::new(raw.version),
samples_per_second: 1000.0 / raw.sample_time_ms,
channel_count: u16::try_from(raw.channels).unwrap_or(0),
byte_order: endian_to_byte_order(endian),
compressed,
title,
acquisition_datetime,
max_samples_per_second,
};
Ok(Post4Parsed {
metadata,
graph_header_len,
})
}
fn null_term_bytes_to_string(bytes: &[u8]) -> alloc::string::String {
let end = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len());
alloc::string::String::from_utf8_lossy(bytes.get(..end).unwrap_or(bytes)).into_owned()
}
const fn endian_to_byte_order(endian: Endian) -> ByteOrder {
match endian {
Endian::Little => ByteOrder::LittleEndian,
Endian::Big => ByteOrder::BigEndian,
}
}
fn validate_channels(channels: i16, byte_offset: u64) -> Result<(), BiopacError> {
if !(1..=MAX_CHANNELS).contains(&channels) {
return Err(BiopacError::Parse(ParseError {
byte_offset,
expected: alloc::format!("1..={MAX_CHANNELS}"),
actual: alloc::format!("{channels}"),
section: HeaderSection::Graph,
}));
}
Ok(())
}
fn validate_sample_time(sample_time_ms: f64, byte_offset: u64) -> Result<(), BiopacError> {
if sample_time_ms <= 0.0 {
return Err(BiopacError::Parse(ParseError {
byte_offset,
expected: alloc::string::String::from("dSampleTime > 0.0"),
actual: alloc::format!("{sample_time_ms}"),
section: HeaderSection::Graph,
}));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use alloc::{boxed::Box, vec, vec::Vec};
use binrw::BinRead;
use std::io::Cursor;
#[test]
fn detect_little_endian_revision_38() -> Result<(), Box<dyn std::error::Error>> {
let mut bytes = [0u8; 256];
bytes[2..6].copy_from_slice(&38i32.to_le_bytes());
let mut cursor = Cursor::new(&bytes[..]);
let (endian, version) = detect_byte_order(&mut cursor)?;
assert_eq!(endian, Endian::Little);
assert_eq!(version, 38);
assert_eq!(cursor.position(), 0);
Ok(())
}
#[test]
fn detect_big_endian_revision_68() -> Result<(), Box<dyn std::error::Error>> {
let mut bytes = [0u8; 256];
bytes[2..6].copy_from_slice(&68i32.to_be_bytes());
let mut cursor = Cursor::new(&bytes[..]);
let (endian, version) = detect_byte_order(&mut cursor)?;
assert_eq!(endian, Endian::Big);
assert_eq!(version, 68);
Ok(())
}
#[test]
fn detect_invalid_version_returns_error() {
let bytes = [0u8; 6];
let mut cursor = Cursor::new(&bytes[..]);
let result = detect_byte_order(&mut cursor);
assert!(result.is_err());
}
fn pre4_bytes(
version: i32,
channels: i16,
sample_time_ms: f64,
chan_header_len: i16,
) -> [u8; 256] {
let mut b = [0u8; 256];
b[2..6].copy_from_slice(&version.to_le_bytes()); b[6..10].copy_from_slice(&256i32.to_le_bytes()); b[10..12].copy_from_slice(&channels.to_le_bytes()); b[16..24].copy_from_slice(&sample_time_ms.to_le_bytes()); b[252..254].copy_from_slice(&chan_header_len.to_le_bytes()); b
}
#[test]
fn pre4_parses_revision_38() -> Result<(), Box<dyn std::error::Error>> {
let bytes = pre4_bytes(38, 3, 1.0, 252);
let mut cursor = Cursor::new(&bytes[..]);
let raw = GraphHeaderPre4Raw::read_le(&mut cursor)?;
let parsed = parse_graph_header_pre4(raw, Endian::Little)?;
assert_eq!(parsed.metadata.file_revision, FileRevision::new(38));
assert_eq!(parsed.metadata.channel_count, 3);
assert!(
(parsed.metadata.samples_per_second - 1000.0).abs() < 1e-9,
"expected 1000 Hz, got {}",
parsed.metadata.samples_per_second
);
assert_eq!(parsed.metadata.byte_order, ByteOrder::LittleEndian);
assert!(!parsed.metadata.compressed);
assert_eq!(parsed.graph_header_len, 256);
assert_eq!(parsed.chan_header_len, 252);
Ok(())
}
#[test]
fn pre4_zero_channels_returns_error() -> Result<(), Box<dyn std::error::Error>> {
let bytes = pre4_bytes(38, 0, 1.0, 252);
let mut cursor = Cursor::new(&bytes[..]);
let raw = GraphHeaderPre4Raw::read_le(&mut cursor)?;
let result = parse_graph_header_pre4(raw, Endian::Little);
assert!(result.is_err());
Ok(())
}
#[test]
fn pre4_too_many_channels_returns_error() -> Result<(), Box<dyn std::error::Error>> {
let bytes = pre4_bytes(38, 300, 1.0, 252); let mut cursor = Cursor::new(&bytes[..]);
let raw = GraphHeaderPre4Raw::read_le(&mut cursor)?;
let result = parse_graph_header_pre4(raw, Endian::Little);
assert!(result.is_err());
if let Err(e) = result {
let msg = alloc::format!("{e}");
assert!(msg.contains("Graph"), "should name Graph section: {msg}");
}
Ok(())
}
#[expect(
clippy::indexing_slicing,
clippy::cast_sign_loss,
reason = "test helper: slices at fixed offsets within buffer of size header_len.max(40) >= 20"
)]
fn post4_bytes_short(
version: i32,
channels: i16,
header_len: i32,
sample_time_ms: f64,
) -> Vec<u8> {
let mut b = vec![0u8; header_len.max(40) as usize];
b[2..6].copy_from_slice(&version.to_le_bytes()); b[6..10].copy_from_slice(&header_len.to_le_bytes()); b[10..12].copy_from_slice(&channels.to_le_bytes()); b[16..24].copy_from_slice(&sample_time_ms.to_le_bytes()); b
}
#[test]
fn post4_parses_revision_68() -> Result<(), Box<dyn std::error::Error>> {
let bytes = post4_bytes_short(68, 2, 40, 2.0);
let mut cursor = Cursor::new(&bytes);
let raw = GraphHeaderPost4Raw::read_le(&mut cursor)?;
let parsed = parse_graph_header_post4(raw, Endian::Little)?;
assert_eq!(parsed.metadata.file_revision, FileRevision::new(68));
assert_eq!(parsed.metadata.channel_count, 2);
assert!(
(parsed.metadata.samples_per_second - 500.0).abs() < 1e-9,
"expected 500 Hz, got {}",
parsed.metadata.samples_per_second
);
assert!(!parsed.metadata.compressed); assert_eq!(parsed.graph_header_len, 40);
Ok(())
}
#[test]
#[expect(
clippy::indexing_slicing,
clippy::cast_sign_loss,
reason = "test: slices at known offsets within 1940-byte buffer"
)]
fn post4_reads_compressed_flag() -> Result<(), Box<dyn std::error::Error>> {
let header_len: i32 = 1940;
let mut bytes = vec![0u8; header_len as usize];
bytes[2..6].copy_from_slice(&77i32.to_le_bytes()); bytes[6..10].copy_from_slice(&header_len.to_le_bytes()); bytes[10..12].copy_from_slice(&1i16.to_le_bytes()); bytes[16..24].copy_from_slice(&1.0f64.to_le_bytes()); bytes[1936] = 1;
let mut cursor = Cursor::new(&bytes);
let raw = GraphHeaderPost4Raw::read_le(&mut cursor)?;
assert_eq!(raw.compressed, Some(1));
let parsed = parse_graph_header_post4(raw, Endian::Little)?;
assert!(parsed.metadata.compressed);
assert_eq!(parsed.metadata.file_revision, FileRevision::new(77));
assert_eq!(parsed.graph_header_len, 1940);
Ok(())
}
#[test]
fn post4_no_compressed_flag_for_short_header() -> Result<(), Box<dyn std::error::Error>> {
let bytes = post4_bytes_short(68, 1, 100, 1.0); let mut cursor = Cursor::new(&bytes);
let raw = GraphHeaderPost4Raw::read_le(&mut cursor)?;
assert_eq!(raw.compressed, None);
let parsed = parse_graph_header_post4(raw, Endian::Little)?;
assert!(!parsed.metadata.compressed);
Ok(())
}
#[test]
#[expect(
clippy::indexing_slicing,
clippy::cast_sign_loss,
reason = "test: slices at known offsets within a 300-byte buffer"
)]
fn post4_extracts_title_when_header_long_enough() -> Result<(), Box<dyn std::error::Error>> {
let header_len: i32 = 300;
let mut bytes = vec![0u8; header_len as usize];
bytes[2..6].copy_from_slice(&68i32.to_le_bytes()); bytes[6..10].copy_from_slice(&header_len.to_le_bytes()); bytes[10..12].copy_from_slice(&1i16.to_le_bytes()); bytes[16..24].copy_from_slice(&1.0f64.to_le_bytes()); let title = b"ECG Test\0";
bytes[236..236 + title.len()].copy_from_slice(title);
let mut cursor = Cursor::new(&bytes);
let raw = GraphHeaderPost4Raw::read_le(&mut cursor)?;
let parsed = parse_graph_header_post4(raw, Endian::Little)?;
assert_eq!(parsed.metadata.title.as_deref(), Some("ECG Test"));
Ok(())
}
#[test]
fn post4_title_is_none_for_short_header() -> Result<(), Box<dyn std::error::Error>> {
let bytes = post4_bytes_short(68, 1, 40, 1.0); let mut cursor = Cursor::new(&bytes);
let raw = GraphHeaderPost4Raw::read_le(&mut cursor)?;
let parsed = parse_graph_header_post4(raw, Endian::Little)?;
assert!(parsed.metadata.title.is_none());
Ok(())
}
#[test]
#[expect(
clippy::indexing_slicing,
clippy::cast_sign_loss,
reason = "test: slices at known offsets within a 300-byte buffer"
)]
fn post4_extracts_acquisition_datetime() -> Result<(), Box<dyn std::error::Error>> {
let header_len: i32 = 300;
let mut bytes = vec![0u8; header_len as usize];
bytes[2..6].copy_from_slice(&74i32.to_le_bytes());
bytes[6..10].copy_from_slice(&header_len.to_le_bytes());
bytes[10..12].copy_from_slice(&2i16.to_le_bytes());
bytes[16..24].copy_from_slice(&1.0f64.to_le_bytes());
let dt_fields: [i32; 6] = [30, 45, 9, 14, 3, 2008]; for (i, &v) in dt_fields.iter().enumerate() {
let offset = 276 + i * 4;
bytes[offset..offset + 4].copy_from_slice(&v.to_le_bytes());
}
let mut cursor = Cursor::new(&bytes);
let raw = GraphHeaderPost4Raw::read_le(&mut cursor)?;
let parsed = parse_graph_header_post4(raw, Endian::Little)?;
let dt = parsed.metadata.acquisition_datetime;
assert!(dt.is_some(), "expected datetime to be parsed");
assert_eq!(dt.map(|d| d.year), Some(2008));
assert_eq!(dt.map(|d| d.month), Some(3));
assert_eq!(dt.map(|d| d.day), Some(14));
assert_eq!(dt.map(|d| d.hour), Some(9));
assert_eq!(dt.map(|d| d.minute), Some(45));
assert_eq!(dt.map(|d| d.second), Some(30));
Ok(())
}
#[test]
fn post4_datetime_is_none_for_short_header() -> Result<(), Box<dyn std::error::Error>> {
let bytes = post4_bytes_short(68, 1, 280, 1.0); let mut cursor = Cursor::new(&bytes);
let raw = GraphHeaderPost4Raw::read_le(&mut cursor)?;
let parsed = parse_graph_header_post4(raw, Endian::Little)?;
assert!(parsed.metadata.acquisition_datetime.is_none());
Ok(())
}
#[test]
#[expect(
clippy::indexing_slicing,
clippy::cast_sign_loss,
reason = "test: slices at known offsets within a 1944-byte buffer"
)]
fn post4_extracts_max_samples_per_second() -> Result<(), Box<dyn std::error::Error>> {
let header_len: i32 = 1944;
let mut bytes = vec![0u8; header_len as usize];
bytes[2..6].copy_from_slice(&74i32.to_le_bytes());
bytes[6..10].copy_from_slice(&header_len.to_le_bytes());
bytes[10..12].copy_from_slice(&1i16.to_le_bytes());
bytes[16..24].copy_from_slice(&1.0f64.to_le_bytes());
let max_rate: i32 = 400_000;
bytes[1940..1944].copy_from_slice(&max_rate.to_le_bytes());
let mut cursor = Cursor::new(&bytes);
let raw = GraphHeaderPost4Raw::read_le(&mut cursor)?;
let parsed = parse_graph_header_post4(raw, Endian::Little)?;
assert_eq!(parsed.metadata.max_samples_per_second, Some(400_000));
Ok(())
}
#[test]
fn post4_max_rate_is_none_for_short_header() -> Result<(), Box<dyn std::error::Error>> {
let bytes = post4_bytes_short(74, 1, 1940, 1.0);
let mut cursor = Cursor::new(&bytes);
let raw = GraphHeaderPost4Raw::read_le(&mut cursor)?;
let parsed = parse_graph_header_post4(raw, Endian::Little)?;
assert!(parsed.metadata.max_samples_per_second.is_none());
Ok(())
}
#[test]
fn null_term_bytes_to_string_strips_at_first_null() {
let input = *b"Hello\0garbage-bytes";
assert_eq!(null_term_bytes_to_string(&input), "Hello");
}
#[test]
fn null_term_bytes_to_string_all_null() {
let input = [0u8; 40];
assert_eq!(null_term_bytes_to_string(&input), "");
}
#[test]
fn null_term_bytes_to_string_no_null() {
let input = *b"ABCDE";
assert_eq!(null_term_bytes_to_string(&input), "ABCDE");
}
}