imud-arduino

ImudClient

Arduino client library for imud — an IMU daemon for marine and robotics navigation that fuses gyro/accel/ magnetometer data with a Kalman filter and publishes attitude (heading, roll, pitch, quaternion, rate of turn, heave, sea state…) over TCP or UDP.

ImudClient receives, validates, and decodes imud’s binary packets on ESP32-class boards — cockpit displays, NMEA gauges, autopilot remotes — so a sketch can do:

if (imud.poll())
    display.show(imud.packet().heading_deg);

Primary target is ESP32 (Arduino IDE and PlatformIO); the library also compiles for ESP8266, RP2040 (Pico W), and Ethernet-shield boards.

New to this library? Start with docs/GETTING-STARTED.md. It walks you from an empty sketch to live heading, pitch and roll on your Serial Monitor — without an IMU, without wiring anything, using the test server bundled in tools/. The rest of this README is reference material.

Unfamiliar terms? docs/GLOSSARY.md. Something not working? docs/TROUBLESHOOTING.md.


Table of contents

Installation

Arduino IDE

  1. Tools → Manage Libraries… (or Sketch → Include Library → Manage Libraries…).
  2. Search for ImudClient and click Install.
  3. #include <ImudClient.h> in your sketch.

The examples then appear under File → Examples → ImudClient; start with HelloAttitude.

Installing manually instead (unreleased versions, or a fork) 1. Download this repository as a ZIP (or `git clone` it into your Arduino `libraries/` folder). 2. Sketch → Include Library → Add .ZIP Library… (or just restart the IDE if you cloned directly into `libraries/`).

PlatformIO

Add to platformio.ini:

lib_deps =
    https://github.com/richcreations/imud-arduino.git

Quick start

TCP

Lossless, framed, up to 8 concurrent clients — the right choice for a display that needs every attitude update in order.

#include <WiFi.h>
#include <ImudClient.h>

ImudClient imud;
WiFiClient net;

void setup() {
    Serial.begin(115200);          // set the Serial Monitor to 115200 too
    WiFi.begin("ssid", "password");
    while (WiFi.status() != WL_CONNECTED) delay(100);

    imud.beginTCP(net, "192.168.1.50", 10112);  // stores host/port, connects
}

void loop() {
    if (imud.poll()) {                    // true = at least one NEW valid packet
        const imud_packet_t &p = imud.packet();

        // heading_deg is already degrees (and magnetic);
        // pitch/roll/yaw are radians, hence imud_rad_to_deg().
        Serial.printf("heading %.1f  pitch %.1f  roll %.1f\n",
                      p.heading_deg,
                      imud_rad_to_deg(p.pitch),
                      imud_rad_to_deg(p.roll));
    }
}

beginTCP()’s initial connect can fail (daemon not up yet, wrong IP) — that’s fine, auto-reconnect takes over. See examples/TcpBasic for a complete sketch with staleness detection, reconnect handling, and daemon-shutdown reporting.

UDP

Higher rate (up to 500 Hz), unicast or broadcast — bind is all that’s needed:

#include <WiFi.h>
#include <ImudClient.h>

ImudClient imud;
WiFiUDP udp;

void setup() {
    WiFi.begin("ssid", "password");
    while (WiFi.status() != WL_CONNECTED) delay(100);

    imud.beginUDP(udp, 10111);   // calls udp.begin(10111) internally
}

void loop() {
    if (imud.poll()) {
        const imud_packet_t &p = imud.packet();
        Serial.println(p.heading_deg);
    }
}

UDP multicast

imud’s default high-rate destination is multicast 239.255.0.1. The abstract Arduino UDP class has no portable multicast-join method, so the sketch joins first with the concrete transport’s API, then hands the already-bound socket to ImudClient:

WiFiUDP udp;
udp.beginMulticast(IPAddress(239, 255, 0, 1), 10111);   // ESP32 API
imud.beginUDP(udp, 10111, /*alreadyBound=*/true);

See examples/UdpListen for a complete sketch that also reports the achieved packet rate.

What’s in a packet

Every packet carries the same full set of fields. Read them straight off the struct — imud.packet().heading_deg — there are no per-field accessors.

Watch the units: heading_deg is degrees, but pitch, roll and yaw are radians. Convert with imud_rad_to_deg().

Fields marked “gated by” are only meaningful once that flag is set; before then they read 0.0. Terms are defined in docs/GLOSSARY.md.

Attitude — what most sketches want

Field Meaning Units Gated by
heading_deg magnetic heading degrees, 0–360
trueHeading() true (geographic) heading; -1.0f if unknown degrees, 0–360 DECLINATION_VALID
pitch bow up (+) / down radians
roll starboard up (+) / down radians
yaw rotation about vertical, magnetic radians
quat_w/x/y/z same orientation as a quaternion, body→NED unit quaternion
rate_of_turn turn rate, + = turning right degrees/minute
declination_deg local magnetic variation, + = east degrees DECLINATION_VALID

Motion and sea state

Field Meaning Units Gated by
heave_m vertical displacement, + up metres HEAVE_VALID
heave_rate vertical velocity, + up m/s HEAVE_VALID
wave_height_m significant wave height (Hs) metres WAVE_VALID
wave_period_s mean zero-crossing wave period seconds WAVE_VALID
roll_period_s / pitch_period_s vessel roll / pitch period; 0.0 = not rolling seconds WAVE_VALID
roll_amplitude / pitch_amplitude significant single amplitude (2σ) radians WAVE_VALID

Raw and calibrated sensors

Field Meaning Units
accel_x/y/z acceleration, calibrated, NED m/s²
gyro_x/y/z angular rate, bias-corrected rad/s
mag_x/y/z magnetic field, calibrated µT
accel_raw_* / gyro_raw_* / mag_raw_* the same before calibration as above
gyro_bias_x/y/z estimated gyro bias rad/s
temp_c IMU die temperature °C

Health and diagnostics

Field Meaning Good value
flags IMUD_FLAG_* bitmask — see below
flags_ext IMUD_FLAG_EXT_* bitmask, new in v18 — a second, separate flag word
imu_seq monotonic sample counter; gaps = dropped samples increments by 1
cov[9] 3×3 attitude error covariance, row-major smaller = more confident
mag_anomaly nearby-metal indicator near 0.0
mag_residual compass disagreement with the filter near 0.0
innov_weight how much the outlier cap is engaging 1.0
innov_reject fraction of updates discarded outright 0.0
nis_accel / nis_mag is the filter’s confidence honest? 1.0

Frequently used flags — the full list is in src/ImudClient.h:

Flag Meaning
IMUD_FLAG_FUSION_CONVERGED filter has settled — don’t trust attitude before this
IMUD_FLAG_DECLINATION_VALID declination known; gates trueHeading()
IMUD_FLAG_MAG_VALID magnetometer healthy and calibrated
IMUD_FLAG_MAG_UNCAL heading from an uncalibrated mag: offset by hard iron, but bounded and repeatable. Mutually exclusive with MAG_VALID
IMUD_FLAG_HEAVE_VALID / IMUD_FLAG_WAVE_VALID heave / sea-state estimators settled
IMUD_FLAG_STATE_RESET the filter hit a non-finite state and reset; latched until it reconverges, so attitude is valid but re-aligning
IMUD_FLAG_SHUTDOWN daemon’s final packet before a clean exit
if (p.flags & IMUD_FLAG_FUSION_CONVERGED)
    display.show(p.heading_deg);

flags_ext — the second flag word (v18)

flags used up all sixteen of its bits, so v18 added a separate 32-bit word. The two are unrelated: bit 0 of flags_ext is not bit 0 of flags. One bit is defined so far:

Flag Meaning
IMUD_FLAG_EXT_MAG_ABSENT no magnetometer is fitted at all — heading is gravity-referenced only, starting at zero in the orientation imud booted in and dead-reckoning from the gyro. Not a bearing, and it will drift

MAG_ABSENT is not the same as an unhealthy compass: MAG_VALID clear (or MAG_UNCAL set) describes hardware that exists and may recover, and this does not.

Test the bits you know and ignore the rest — never compare flags_ext for equality. That is what lets imud define a new bit without another wire bump, so a sketch written today keeps working against a newer daemon:

if (p.flags_ext & IMUD_FLAG_EXT_MAG_ABSENT)   // correct
    display.showRelativeHeading(p.heading_deg);

if (p.flags_ext == IMUD_FLAG_EXT_MAG_ABSENT)  // WRONG — breaks on any new bit
    ;

For byte offsets and the full 288-byte layout, see docs/PROTOCOL.md.

API reference

ImudClient

The Arduino-facing wrapper. Owns no sockets — only pointers to caller-owned transports — and never blocks except during an explicit or throttled-automatic TCP reconnect.

Method Description
bool beginTCP(Client &c, const char *host, uint16_t port = 10112) Also an IPAddress overload. Stores host/port for reconnects. Returns the initial connect result; a failed initial connect is fine — auto-reconnect takes over.
bool beginUDP(UDP &u, uint16_t port = 10111, bool alreadyBound = false) If alreadyBound is false, calls u.begin(port). For multicast, join first (see above) and pass alreadyBound=true.
bool poll() Non-blocking. TCP: drains available() bytes (through a small stack chunk buffer) into the parser, and may trigger a throttled auto-reconnect while disconnected. UDP: drains every pending datagram. Returns true if ≥1 new valid packet arrived; packet() then holds the newest.
const imud_packet_t &packet() const The newest valid packet decoded so far (zero-initialized before the first one).
float trueHeading() const imud_true_heading() applied to the current packet. -1.0f until declination is valid.
bool connected() const TCP: Client::connected(). UDP: true once beginUDP() has been called.
bool reconnect() TCP only: stop() + connect() to the stored host/port. Blocks like Client::connect() does. No-op (returns false) on UDP.
void setAutoReconnect(bool on) Default on, minimum 2 s between attempts (millis()-based). Client::connect() blocks — sometimes for several seconds on ESP32 — so a control loop that can’t tolerate that should setAutoReconnect(false) and call reconnect() when convenient.
uint32_t millisSinceLastPacket() const Milliseconds since the last new valid packet, or UINT32_MAX before the first one — for watchdogs/UI staleness indicators.
bool daemonShutdown() const true if the newest packet carried IMUD_FLAG_SHUTDOWN — the daemon’s final packet before a clean exit. Display “daemon stopping” and suppress the reconnect alarm for this, rather than treating it as a dropped link.
uint32_t packetsReceived() const Cumulative valid packets decoded.
uint32_t crcErrors() const Cumulative validation failures (magic/version/size/CRC — despite the name, not CRC-only).
uint32_t resyncs() const Cumulative magic re-scans after a validation failure on the TCP stream path.
void end() Stops the transport and resets the parser (partial buffer + counters).

ImudParser (power users / testing)

Pure C++, zero Arduino includes, zero I/O — this is what ImudClient uses internally, and what the native unit tests exercise directly. Useful if you want to decode packets from a source ImudClient doesn’t cover (e.g. a custom transport, or a file of captured frames).

Method Description
size_t feed(const uint8_t *data, size_t len) Streaming path (TCP): any chunking, one byte at a time is fine. Reassembles 288-byte frames and validates each as it completes; on failure, resynchronizes by dropping one byte and rescanning for the next magic sequence rather than discarding the whole buffer. Returns the count of new valid packets decoded this call.
bool feedDatagram(const uint8_t *data, size_t len) Datagram path (UDP): no reassembly, no resync — a datagram is either a valid 288-byte packet or it’s discarded.
const imud_packet_t &packet() const Newest valid packet.
uint32_t packetsReceived() / crcErrors() / resyncs() const Same semantics as on ImudClient.
void reset() Drops the partial accumulation buffer and resets all counters. Does not clear packet().

imud_packet_t and flags

The full 288-byte wire struct (accel/gyro/mag, quaternion, pitch/roll/yaw, heading, rate of turn, covariance, heave, sea state, compass health, filter gate health, …) and the IMUD_FLAG_* bitmask are defined in src/ImudClient.h.

See What’s in a packet above for a field-by-field table with units, or docs/PROTOCOL.md for byte offsets and the resync algorithm walkthrough.

imud_true_heading()

float imud_true_heading(const imud_packet_t *pkt);

Returns true (geographic) heading in [0, 360) when IMUD_FLAG_DECLINATION_VALID is set, or -1.0f if declination isn’t known yet — or if the packet carries out-of-range values. Wire data is untrusted: a crafted packet with Inf/NaN/1e38 here must not hang the caller (this was found by fuzzing upstream), so the range check is written to fail closed on NaN too. Ported verbatim from imud’s reference implementation.

imud_rad_to_deg()

float imud_rad_to_deg(float rad);   // also: IMUD_RAD_TO_DEG

pitch, roll and yaw arrive in radians; heading_deg is the one attitude field already in degrees. This converts:

Serial.println(imud_rad_to_deg(p.roll));   // e.g. -7.2

Protocol semantics

Server contract (what the client tolerates)

TCP ([stream] listener, default :10112, default 100 Hz):

UDP (default :10111, up to 500 Hz):

Enabling the real daemon’s outputs

Both transports are off by default on the daemon side. In /etc/imud/imud.conf:

[stream]
tcp_enabled = true      # TCP listener, default port 10112

[highrate]
enabled = true           # UDP output, default port 10111

Restart the imud service after editing. See imud’s own documentation for the full set of [stream]/[highrate] options (bind address, port, destination, rate).

Testing

Native unit tests (no hardware)

pio test -e native

Runs test/test_parser/test_parser.cpp against ImudParser directly, on the host, using the golden vectors in extras/golden/: a bit-exact valid packet (every field checked against extras/golden/valid_packet.md), a corrupted-CRC packet, a version-mismatch packet, and a resync stream (decoy magic + garbage + two valid frames, fed both whole and one byte at a time) that must yield exactly two packets and never lose the following frame.

Live smoke test (a real client, no imud install needed)

python3 tools/fake_daemon.py                          # TCP listener on :10112
python3 tools/fake_daemon.py --udp 192.168.1.42:10111  # ... plus UDP
python3 tools/fake_daemon.py --rate 100                # packet rate (default 10 Hz)

fake_daemon.py faithfully mimics the real daemon’s contract — the 8-client cap with immediate-close on the 9th, a SHUTDOWN-flag final packet on Ctrl-C, and frame-skipping (seq gaps) for a slow client — and has been verified against imud’s reference Python client (resources/imud_client.py upstream; useful as prose documentation of TCP reassembly and as a second live consumer while testing). Point examples/TcpBasic or examples/UdpListen at the machine running it and you should see a smoothly sweeping heading. Try restarting the fake daemon mid-run to watch the sketch reconnect, and Ctrl-C it to see the daemon-shutdown message.

CI

GitHub Actions (.github/workflows/ci.yml) runs the native test suite and compiles both examples against esp32dev, esp32-s3-devkitc-1, and esp32-c3-devkitm-1 on every push/PR, plus a best-effort build for d1_mini (ESP8266). RP2040 (Pico W) isn’t covered in CI — see CONTRIBUTING.md’s known CI gaps for why — but the library’s Client/UDP-only design still targets it; it just isn’t automatically verified.

A separate workflow (.github/workflows/codeql.yml) runs CodeQL static analysis on push/PR and weekly: C/C++ over ImudParser (built via pio test -e native --without-testing, the same native target the unit tests use — this is what lets CodeQL trace real compiler invocations for a header-only library with no top-level build system) and Python over tools/fake_daemon.py. This is an “advanced setup” workflow, not GitHub’s default code-scanning autobuild — the default fails outright here since there’s nothing for it to autodetect.

Wire-sync warning

This library pins wire v18 and rejects any other version outright (see Protocol semantics and docs/PROTOCOL.md). When imud revises its packet layout, it bumps the wire version — and this library needs a synced struct plus a version bump before it can talk to the new daemon. Wire bumps are coordinated from the imud repo (its AGENTS.md wire-sync checklist is the source of truth); this repo is listed there as a downstream consumer.

Repository layout

imud-arduino/
  library.properties          # Arduino IDE/Library Manager manifest
  library.json                # PlatformIO manifest
  keywords.txt                # IDE syntax highlighting
  LICENSE                     # MIT
  src/ImudClient.h            # everything: ImudParser + ImudClient, header-only
  examples/
    HelloAttitude/            # START HERE: connect + print heading/pitch/roll
      HelloAttitude.ino
    TcpBasic/TcpBasic.ino     # WiFi + TCP: heading/roll/pitch/yaw, true heading,
                              #   staleness, reconnect
    UdpListen/UdpListen.ino   # multicast join + high-rate receive, rate/counters
  test/
    test_parser/test_parser.cpp  # PlatformIO native env + Unity
  extras/golden/               # golden wire-format test vectors
  tools/
    fake_daemon.py             # hardware-free test server (TCP+UDP, stdlib only)
    gen_vectors.py             # regenerates extras/golden/ for a new wire version
  docs/
    GETTING-STARTED.md         # beginner walkthrough, no hardware needed
    TROUBLESHOOTING.md         # symptom-first problem solving
    GLOSSARY.md                # NED, declination, heave, NIS, …
    PROTOCOL.md                # full field reference + resync algorithm
  platformio.ini
  .github/workflows/ci.yml

Contributing

See CONTRIBUTING.md.

License

MIT — see LICENSE. Copyright (c) 2026 Richard Simpson.