This document is the deep-dive companion to the README’s
API reference: the full byte-level layout of the 288-byte packet, the flag
bitmask, the CRC32 definition, and a walkthrough of ImudParser’s
stream-resync algorithm. It’s aimed at anyone extending ImudClient,
debugging a decode issue, or writing a second implementation against the
same wire format.
The struct itself lives in src/ImudClient.h and is copied verbatim from
imud’s own reference implementation — see the
wire-sync warning before touching it.
__attribute__((packed)) in the struct definition is a guard
against a future field breaking that, not a behavior change today)."IMUD", 0x494D5544, little-endian
bytes 44 55 4D 49 on the wire) plus a trailing CRC32 are enough to find
and validate frame boundaries in an arbitrary byte stream — no
length-prefix or delimiter needed.| Offset | Size | Field | Type | Units / notes |
|---|---|---|---|---|
| 0 | 4 | magic |
uint32_t |
IMUD_MAGIC = 0x494D5544 |
| 4 | 2 | version |
uint16_t |
IMUD_VERSION = 18; reject anything else |
| 6 | 2 | flags |
uint16_t |
IMUD_FLAG_* bitmask, see below |
| 8 | 8 | ts_wall_ns |
uint64_t |
CLOCK_REALTIME, nanoseconds |
| 16 | 8 | ts_tai_ns |
uint64_t |
CLOCK_TAI, nanoseconds |
| 24 | 4 | ts_chip_ticks |
uint32_t |
IMU hardware counter |
| 28 | 4 | anchor_gen |
uint32_t |
increments on wall-clock re-anchor |
| 32 | 4×3 | accel_x/y/z |
float |
m/s², calibrated |
| 44 | 4×3 | accel_raw_x/y/z |
float |
m/s², pre-calibration |
| 56 | 4×3 | gyro_x/y/z |
float |
rad/s, bias-corrected |
| 68 | 4×3 | gyro_raw_x/y/z |
float |
rad/s, before bias correction |
| 80 | 4×3 | mag_x/y/z |
float |
µT, calibrated |
| 92 | 4×3 | mag_raw_x/y/z |
float |
µT, pre-calibration |
| 104 | 4×4 | quat_w/x/y/z |
float |
unit quaternion, body→NED |
| 120 | 4 | pitch |
float |
rad, NED (+bow up) |
| 124 | 4 | roll |
float |
rad, NED (+starboard up) |
| 128 | 4 | yaw |
float |
rad, NED magnetic |
| 132 | 4 | heading_deg |
float |
0–360°, magnetic |
| 136 | 4 | rate_of_turn |
float |
deg/min, + = turning right |
| 140 | 4 | temp_c |
float |
IMU die temperature, °C |
| 144 | 4×9 | cov[9] |
float |
3×3 attitude error covariance, row-major (rad²) |
| 180 | 4 | imu_seq |
uint32_t |
monotonic sample counter (daemon-side, not per-packet-received) |
| 184 | 4 | declination_deg |
float |
°E+; 0.0 unless DECLINATION_VALID |
| 188 | 4 | heave_m |
float |
m, +up; 0.0 unless HEAVE_VALID |
| 192 | 4×3 | gyro_bias_x/y/z |
float |
rad/s, estimated gyro bias |
| 204 | 4×3 | gyro_bias_var_x/y/z |
float |
(rad/s)², gyro-bias variance |
| 216 | 4 | heave_rate |
float |
m/s, +up; 0.0 unless HEAVE_VALID |
| 220 | 4 | accel_quiescence |
float |
EMA of (|a|/g − 1)² |
| 224 | 4 | wave_height_m |
float |
significant wave height Hs, m; 0.0 unless WAVE_VALID |
| 228 | 4 | wave_period_s |
float |
mean zero-crossing period Tz, s |
| 232 | 4 | roll_period_s |
float |
vessel roll period, s; 0.0 = not rolling |
| 236 | 4 | roll_amplitude |
float |
significant single amplitude 2σ(roll), rad |
| 240 | 4 | pitch_period_s |
float |
vessel pitch period, s |
| 244 | 4 | pitch_amplitude |
float |
significant single amplitude 2σ(pitch), rad |
| 248 | 4 | mag_anomaly |
float |
EMA of ||B|−|B_ref||/|B_ref| (unitless) |
| 252 | 4 | mag_residual |
float |
EMA of |heading innovation|, rad — compass health |
| 256 | 4 | innov_weight |
float |
EMA of the Huber weight √(γ/d²) applied to accepted updates; 1.0 = never capped, → 0.33 = sustained capping at the reject boundary |
| 260 | 4 | innov_reject |
float |
EMA of the reject indicator: fraction of updates discarded by the gross-outlier gate; 0.0 = nothing rejected |
| 264 | 4 | nis_accel |
float |
rolling normalised innovation squared for the accelerometer update, d²/2 |
| 268 | 4 | nis_mag |
float |
same for the magnetometer update, d²/2 (3-D) or d²/1 (yaw-only) |
| 272 | 4 | flags_ext |
uint32_t |
IMUD_FLAG_EXT_* bitmask — new in v18, a second flag word independent of flags |
| 276 | 8 | reserved |
uint8_t[8] |
new in v18; zero on the wire, do not interpret |
| 284 | 4 | crc32 |
uint32_t |
IEEE 802.3 CRC32 of bytes 0–283 |
Total: 288 bytes.
Every field below offset 272 keeps the offset it had in v17: the bump is purely an append plus the CRC move, so a decoder’s existing offsets stay correct.
The layout above tells you where these four live, but not how to interpret them, and the distinction matters:
nis_accel / nis_mag are normalised by effective degrees of freedom,
so 1.0 means the filter’s covariance correctly predicts its own
innovation spread. Above 1 = over-confident; well below 1 = carrying
more uncertainty than it needs. Where innov_weight/innov_reject
report how hard the robustness machinery is working, these report
whether the noise model itself is right.innov_weight — which saturates once
the cap engages — they keep climbing as the model gets worse.nis_mag reads lower in 3-D magnetometer mode by design when the
daemon’s mekf_mag_dip_sigma_deg is non-zero: the dip channel is
deliberately trusted less. Do not compare nis_mag across magnetometer
modes, and do not treat a low value as a fault.flags, offset 6)| Bit | Name | Meaning |
|---|---|---|
| 0 | IMUD_FLAG_MAG_VALID |
mag healthy and calibrated |
| 1 | IMUD_FLAG_MAG_SET_RESET |
SET pulse within last read |
| 2 | IMUD_FLAG_FUSION_CONVERGED |
filter covariance settled — don’t trust attitude before this |
| 3 | IMUD_FLAG_ACCEL_CAL |
accel calibration applied |
| 4 | IMUD_FLAG_GYRO_CAL |
gyro bias applied |
| 5 | IMUD_FLAG_MAG_CAL |
mag hard/soft-iron applied |
| 6 | IMUD_FLAG_MOTION |
retired — defined but never set. v18 deliberately did not reuse this bit; imud 1.8 published that it would not |
| 7 | IMUD_FLAG_FIFO_OVERFLOW |
sample gap (FIFO overflow) |
| 8 | IMUD_FLAG_STARTUP |
gyro bias estimation in progress |
| 9 | IMUD_FLAG_SHUTDOWN |
final packet before clean daemon exit |
| 10 | IMUD_FLAG_DECLINATION_VALID |
declination known — gates declination_deg and trueHeading() |
| 11 | IMUD_FLAG_HEAVE_VALID |
heave estimator settled — gates heave_m/heave_rate |
| 12 | IMUD_FLAG_WAVE_VALID |
sea-state stats settled — gates wave_*/roll_*/pitch_* (period/amplitude) |
| 13 | IMUD_FLAG_ENGINE_ON |
engine-vibration detected |
| 14 | IMUD_FLAG_STATE_RESET |
the MEKF found a non-finite value in its own state and reset itself. Latched from the reset until the filter next converges, not a per-packet pulse — at up to 500 Hz a momentary bit is invisible to a 1 Hz consumer. While it is set the attitude is valid but re-aligning, and FUSION_CONVERGED is clear for the same span |
| 15 | IMUD_FLAG_MAG_UNCAL |
heading fused from an uncalibrated magnetometer — offset by the uncorrected hard iron, but bounded and repeatable. Mutually exclusive with MAG_VALID. Added in imud 1.9.1 |
Assigning bit 15 used up the last of the 16-bit flags word, which is what
forced flags_ext into existence in v18.
flags_ext, offset 272) — new in v18A second, separate flag word. Bit n of flags_ext has nothing to do
with bit n of flags.
| Bit | Name | Meaning |
|---|---|---|
| 0 | IMUD_FLAG_EXT_MAG_ABSENT |
no magnetometer is configured, so heading is gravity-referenced only: it starts at zero in whatever orientation imud booted in and dead-reckons from the gyro for the life of the run |
MAG_ABSENT is not the same as MAG_VALID and MAG_UNCAL both being
clear. That combination describes a fitted magnetometer that is currently
stale or failed, and it can recover. MAG_ABSENT means there is no compass
at all, and it cannot.
Test only the bits you know, and never compare flags_ext for equality.
That contract is what lets imud define a new bit here without another wire
version bump — a receiver that ignores unrecognised bits keeps working
against a newer daemon:
if (p.flags_ext & IMUD_FLAG_EXT_MAG_ABSENT) { /* correct */ }
if (p.flags_ext == IMUD_FLAG_EXT_MAG_ABSENT) { /* WRONG — breaks on any new bit */ }
Applied in this exact order — get it right and a malformed or truncated packet is rejected as cheaply as possible, without ever touching uninitialized or attacker-controlled memory beyond the buffer bounds:
magic == IMUD_MAGIC (wire bytes 44 55 4D 49).version == IMUD_VERSION (18); reject anything else.crc32 at
offset 284. Derive that range from offsetof(imud_packet_t, crc32)
rather than hardcoding it — it moved in v18 and will move again.Anything that fails any step is discarded silently and counted (never logged per-packet — a malicious or malfunctioning peer flooding invalid frames shouldn’t also flood your log).
IEEE 802.3 / zlib polynomial (0xEDB88320), computed bitwise with no
lookup table:
uint32_t imud_crc32(const uint8_t *data, size_t len) {
uint32_t crc = 0xFFFFFFFFu;
for (size_t i = 0; i < len; i++) {
crc ^= data[i];
for (int b = 0; b < 8; b++)
crc = (crc >> 1) ^ (0xEDB88320u & -(crc & 1u));
}
return crc ^ 0xFFFFFFFFu;
}
288 bytes at 240 MHz is on the order of microseconds, so a table isn’t worth the ~1 KB of flash — this is deliberate, not an oversight; don’t “optimize” it into a CRC library dependency.
ImudParser::feed())The TCP transport contract guarantees frames arrive whole and aligned from
the first byte of a fresh connection (§ Server contract in the README), so
in practice resync only fires after real corruption or an unlucky
mid-stream reconnect. feed() treats that as the exception, not the rule,
but the algorithm has to be correct for any byte stream, not just a
well-behaved TCP socket — that’s what makes it safe to also use as the
receive path for, say, a serial bridge or a captured-frame file.
The core loop:
Resync never discards the whole buffer as its default action — a valid frame may start partway through what’s currently buffered, and throwing all of it away would risk cutting that frame in half:
memmove the rest down by one).memmove) and stop — the buffer
now starts at a plausible frame boundary.44 55 4D, one byte short of a complete magic). If so, keep just that
tail — the next feed() call’s bytes can complete the match. This
matters because feed() accepts arbitrary chunking, including one byte
at a time; without it, a magic sequence that happens to straddle a
resync point could be missed.Each call to this routine increments resyncs(), independent of
crcErrors() (which counts the validation failure that triggered it).
extras/golden/resync_stream.hexThis 573-byte vector is built specifically to exercise the false-lock
case: 21 bytes of garbage containing a decoy magic sequence at offset
7 (followed by a plausible version, 11 00 = 17, but garbage after that),
then two genuinely valid frames back to back (imu_seq 1000 and 1001).
Fed through feed() (whole, one byte at a time, or in any other
chunking), the trace looks like:
[0..275] — starts with garbage, so
magic check fails at byte 0. Resync drops 1 byte, then finds the decoy
magic (now at buffer offset 6) and discards everything before it.imu_seq
1000), validates, and is emitted. Frame B (imu_seq 1001) follows
immediately and validates on the first try — no resync needed.Net result: exactly 2 valid packets, in order, resyncs() >= 1 — verified
in test/test_parser/test_parser.cpp both fed whole and fed one byte at a
time (chunking must not change the outcome, since a real TCP stack can
deliver bytes in any grouping).
ts_wall_ns/ts_tai_ns are the daemon’s clocks at sample time, not
network receipt time — don’t use them to measure link latency without
also accounting for clock skew between the daemon host and your board.imu_seq is the right counter for detecting sample gaps (skipped
daemon ticks or dropped UDP datagrams); it says nothing about wall-clock
timing on its own.