from __future__ import annotations import binascii import io import struct from dataclasses import dataclass, asdict from pathlib import Path from typing import BinaryIO, Iterator FILE_MAGIC = "RAW_SERIAL_CAPTURE_FILE_V2" RECORD_MAGIC = "RAW_SERIAL_RECORD_V2" FOOTER_MAGIC = "RAW_SERIAL_CAPTURE_FOOTER_V2" def read_7bit_int(stream: BinaryIO) -> int: value = 0 shift = 0 while True: raw = stream.read(1) if not raw: raise EOFError("truncated .NET string length") value |= (raw[0] & 0x7F) << shift if not raw[0] & 0x80: return value shift += 7 if shift > 35: raise ValueError("invalid .NET string length") def read_dotnet_string(stream: BinaryIO) -> str: length = read_7bit_int(stream) raw = stream.read(length) if len(raw) != length: raise EOFError("truncated .NET string") return raw.decode("utf-8") def read_i32(stream: BinaryIO) -> int: raw = stream.read(4) if len(raw) != 4: raise EOFError("truncated int32") return struct.unpack(" int: raw = stream.read(8) if len(raw) != 8: raise EOFError("truncated int64") return struct.unpack(" int: raw = stream.read(4) if len(raw) != 4: raise EOFError("truncated uint32") return struct.unpack(" CaptureHeader: if read_dotnet_string(stream) != FILE_MAGIC: raise ValueError("not a V2 raw capture file") version = read_i32(stream) if version != 2: raise ValueError(f"unsupported capture version: {version}") return CaptureHeader( sensor_kind=read_dotnet_string(stream), session_id=read_dotnet_string(stream), session_start_utc_ticks=read_i64(stream), session_start_monotonic_ticks=read_i64(stream), monotonic_frequency=read_i64(stream), port=read_dotnet_string(stream), baud=read_i32(stream), file_start_utc_ticks=read_i64(stream), ) def parse_record_body(body: bytes, record_file_offset: int, record_crc: int) -> RawChunk: stream = io.BytesIO(body) if read_dotnet_string(stream) != RECORD_MAGIC: raise ValueError("invalid record magic") sequence = read_i64(stream) receive_utc_ticks = read_i64(stream) receive_monotonic_ticks = read_i64(stream) raw_length = read_i32(stream) if raw_length < 0 or raw_length > 64 * 1024 * 1024: raise ValueError(f"invalid raw length: {raw_length}") raw_offset = record_file_offset + 4 + stream.tell() raw = stream.read(raw_length) if len(raw) != raw_length: raise EOFError("truncated raw bytes") crc_valid = (binascii.crc32(body) & 0xFFFFFFFF) == record_crc return RawChunk( sequence=sequence, receive_utc_ticks=receive_utc_ticks, receive_monotonic_ticks=receive_monotonic_ticks, raw=raw, record_file_offset=record_file_offset, raw_file_offset=raw_offset, record_crc32=record_crc, crc_valid=crc_valid, ) def parse_footer(body: bytes, expected_crc: int) -> CaptureFooter: stream = io.BytesIO(body) if read_dotnet_string(stream) != FOOTER_MAGIC: raise ValueError("invalid footer magic") clean_close = stream.read(1) == b"\x01" records = read_i64(stream) raw_bytes = read_i64(stream) first_sequence = read_i64(stream) last_sequence = read_i64(stream) dropped_chunks = read_i64(stream) dropped_bytes = read_i64(stream) return CaptureFooter( clean_close=clean_close, records=records, bytes=raw_bytes, first_sequence=first_sequence, last_sequence=last_sequence, dropped_chunks=dropped_chunks, dropped_bytes=dropped_bytes, crc_valid=(binascii.crc32(body) & 0xFFFFFFFF) == expected_crc, ) def read_capture(path: Path) -> CaptureFile: chunks: list[RawChunk] = [] footer = None truncated = False with path.open("rb") as stream: header = read_header(stream) while True: record_offset = stream.tell() length_raw = stream.read(4) if not length_raw: break if len(length_raw) != 4: truncated = True break length = struct.unpack(" 1024 * 1024: raise ValueError("invalid footer length") footer_body = stream.read(footer_length) if len(footer_body) != footer_length: raise EOFError("truncated footer") footer = parse_footer(footer_body, read_u32(stream)) break if length <= 0 or length > 64 * 1024 * 1024: raise ValueError("invalid record length") body = stream.read(length) if len(body) != length: raise EOFError("truncated record body") record_crc = read_u32(stream) chunks.append(parse_record_body(body, record_offset, record_crc)) except (EOFError, ValueError): truncated = True break return CaptureFile(str(path), header, chunks, footer, truncated) def sequence_gaps(chunks: list[RawChunk]) -> list[tuple[int, int, int]]: result = [] for previous, current in zip(chunks, chunks[1:]): if current.sequence > previous.sequence + 1: result.append((previous.sequence, current.sequence, current.sequence - previous.sequence - 1)) return result def file_summary(capture: CaptureFile) -> dict: gaps = sequence_gaps(capture.chunks) sequences = [chunk.sequence for chunk in capture.chunks] return { "path": capture.path, "sensor": capture.header.sensor_kind, "session_id": capture.header.session_id, "port": capture.header.port, "baud": capture.header.baud, "chunks_read": len(capture.chunks), "bytes_read": sum(len(chunk.raw) for chunk in capture.chunks), "first_sequence": sequences[0] if sequences else None, "last_sequence": sequences[-1] if sequences else None, "missing_chunks": sum(gap[2] for gap in gaps), "gap_count": len(gaps), "bad_record_crc": sum(not chunk.crc_valid for chunk in capture.chunks), "truncated_tail": capture.truncated_tail, "footer": None if capture.footer is None else asdict(capture.footer), "gaps": gaps[:100], } def iter_contiguous_segments(chunks: list[RawChunk]) -> Iterator[tuple[int, list[RawChunk]]]: if not chunks: return segment_id = 0 current = [chunks[0]] for previous, chunk in zip(chunks, chunks[1:]): if chunk.sequence != previous.sequence + 1: yield segment_id, current segment_id += 1 current = [chunk] else: current.append(chunk) yield segment_id, current