"""Decode Hipnuc / HI13 (HI91/HI92) IMU frames from a V2 .rscap capture. Matches ``EcarSensorMinimal/RawSerialImu/Hi13Protocol.cs``: sync ``5A A5``, CRC16 over header[0:4]+payload, tag ``0x91`` / ``0x92``. HI91 (preferred for calibration): - accel: float32 in g → m/s² (* 9.80665) - gyro: float32 in deg/s → rad/s - device time: uint32 ms at frame offset 14 → ``t_s = ms * 1e-3`` """ from __future__ import annotations import struct from dataclasses import dataclass import numpy as np from .capture_format_v2 import CaptureFile from .n300_imu import ImuSample, samples_to_arrays G0 = 9.80665 DEG2RAD = np.pi / 180.0 @dataclass(frozen=True) class Hi13Sample: """One CRC-valid native HI91 record. t_s/system_time_ms are sensor-owned. Host receive ticks are retained only for clock diagnostics and cross-clock fitting. """ t_s: float system_time_ms: int device_timestamp_us: int host_receive_utc_ticks: int gyro_rad_s: tuple[float, float, float] accel_m_s2: tuple[float, float, float] rpy_deg: tuple[float, float, float] quaternion_wxyz: tuple[float, float, float, float] mag_ut: tuple[float, float, float] pps_sync_stamp_ms: int temperature_c: int air_pressure_pa: float frame_tag: int = 0x91 def crc16_hi13(frame: bytes, payload_length: int) -> int: crc = 0 for value in frame[:4]: crc = _update_crc16(crc, value) for value in frame[6 : 6 + payload_length]: crc = _update_crc16(crc, value) return crc & 0xFFFF def _update_crc16(crc: int, value: int) -> int: crc ^= (value & 0xFF) << 8 for _ in range(8): if crc & 0x8000: crc = ((crc << 1) ^ 0x1021) & 0xFFFF else: crc = (crc << 1) & 0xFFFF return crc def parse_hi91_sample(raw: bytes, host_receive_utc_ticks: int = 0) -> Hi13Sample | None: """Decode every calibration-relevant field from a CRC-valid HI91 frame.""" if len(raw) < 6 + 76: return None payload_length = raw[2] | (raw[3] << 8) if payload_length < 76 or len(raw) < 6 + payload_length: return None if raw[6] != 0x91: return None expected = raw[4] | (raw[5] << 8) if crc16_hi13(raw, payload_length) != expected: return None device_ms = int(struct.unpack_from(" tuple[tuple[float, float, float], tuple[float, float, float], int] | None: """Backward-compatible compact HI91 decoder.""" sample = parse_hi91_sample(raw) if sample is None: return None return sample.gyro_rad_s, sample.accel_m_s2, sample.system_time_ms def iter_hi13_imu_samples( capture: CaptureFile, *, host_utc_ticks_min: int | None = None, host_utc_ticks_max: int | None = None, ) -> list[Hi13Sample]: """Return CRC-valid HI91 samples sorted by device timestamp. Streams chunk-by-chunk (no giant join) and can skip whole chunks outside the host UTC receive window before parsing. """ samples: list[Hi13Sample] = [] carry = b"" for chunk in capture.chunks: if host_utc_ticks_min is not None and chunk.receive_utc_ticks < host_utc_ticks_min: carry = b"" continue if host_utc_ticks_max is not None and chunk.receive_utc_ticks > host_utc_ticks_max: # chunks are time-ordered; remaining ones are later if chunk.receive_utc_ticks > host_utc_ticks_max: break stream = carry + chunk.raw cursor = 0 while cursor + 6 < len(stream): sync = stream.find(b"\x5A\xA5", cursor) if sync < 0: carry = b"" break if sync + 6 > len(stream): carry = stream[sync:] break payload_length = stream[sync + 2] | (stream[sync + 3] << 8) if payload_length < 1 or payload_length > 512: cursor = sync + 1 continue end = sync + 6 + payload_length if end > len(stream): carry = stream[sync:] break host_ticks = chunk.receive_utc_ticks parsed = parse_hi91_sample(stream[sync:end], host_ticks) cursor = end if parsed is None: continue if host_utc_ticks_min is not None and host_ticks < host_utc_ticks_min: continue if host_utc_ticks_max is not None and host_ticks > host_utc_ticks_max: continue samples.append(parsed) else: carry = b"" samples.sort(key=lambda sample: (sample.t_s, sample.device_timestamp_us)) return samples __all__ = [ "ImuSample", "Hi13Sample", "crc16_hi13", "iter_hi13_imu_samples", "parse_hi91_frame", "parse_hi91_sample", "samples_to_arrays", ]