A lightweight, non-blocking Arduino library for the IMU406 serial (UART TTL) attitude sensor, with dual-mode serial acquisition — interrupt (on ESP32) or polling (on AVR & everywhere), same API.
The sensor is an on-board-solved 6/9-axis attitude reference system that streams
fixed binary frames over UART. This library parses the vendor 29-byte
0x84 protocol and exposes the data as simple getters:
- Attitude —
roll,pitch,yaw(float, degrees) with raw ±18000 values - Accelerometer — 3-axis acceleration
- Gyroscope — 3-axis angular rate
No dependency on I2C/SPI; you only need two wires (TX/RX).
Dual-mode serial acquisition — one library, same API, two backends:
- Interrupt mode (ESP32): frames parsed from the UART
onReceive()hook, soloop()does no polling — frees CPU for other tasks and for other serial ports' own interrupt handlers. - Polling mode (AVR Uno/Mega & everywhere):
update()inloop()— simple, portable, fully non-blocking.
The rest:
- Frame-sync receive state machine — recovers cleanly from arbitrary byte streams
- Fully non-blocking in both modes
- Float angles in degrees plus raw integer accessors
- 3-axis accelerometer & gyroscope getters
hasNewData()new-frame flag andisAlive()link/timeout detection- Multiple IMU406 instances can share different serial ports independently
- Portable across AVR, ESP32/ESP8266, SAMD, STM32, RP2040, nRF52
- Copy the whole
IMU406folder into your Arduino libraries directory:<Documents>\Arduino\libraries\IMU406\ - Restart the Arduino IDE.
- Open an example via File → Examples → IMU406 → get_angles.
- Tools → Board select your board, Tools → Port select the COM port, then click Upload and open the Serial Monitor at 115200.
The library provides two interchangeable ways to consume the IMU406 data stream. They share the exact same getters and parser — the only difference is where incoming bytes are consumed.
| Interrupt mode | Polling mode | |
|---|---|---|
| Supported platforms | ESP32 (hardware onReceive() hook) |
All (AVR, ESP32, SAMD, STM32, RP2040…) |
| Parser driven by | UART receive interrupt | update() in loop() |
Does loop() need update()? |
No | Yes |
CPU usage in loop() |
Minimal | Small per call |
| Best for | ESP32 projects that also use other serial interrupts, or want low latency | Uno/Mega, simple sketches, or avoiding any interrupt |
| Other serial ports | Untouched — each port keeps its own handler | Untouched |
begin(..., true) (or enableInterrupt()) attaches the library's handler to
the HardwareSerial onReceive() hook of the port this instance is bound to
only. Frames are parsed straight from that interrupt, so loop() is free. Any
other serial (e.g. Serial1, Serial) can keep its own onReceive()
handler — or stay in polling — without any conflict. See
examples/interrupt_mode/interrupt_mode.ino.
On recent ESP32 cores the hook is armed with
onlyIfTimeout=false, so it fires as soon as bytes arrive (low latency) rather than only after a timeout.
Call update() in loop(). Fully non-blocking; ideal on Uno/Mega or if you
prefer to avoid interrupts. Force it anywhere with begin(..., false) or
disableInterrupt().
// ESP32: interrupt — no loop polling needed
imu.begin(115200, rxPin, txPin, true); // or omit the 4th arg
// Anywhere (or forcing fallback): polling
imu.begin(115200, -1, -1, false);
imu.update(); // call each loop| What you have | Use |
|---|---|
| ESP32 + IMU406 on Serial2, maybe other interrupts | begin(..., true) — interrupt mode |
| Arduino Uno / Mega 2560 | begin(..., false) + update() in loop() — polling |
| Not sure / changing boards | Polling mode is the safe common denominator |
Both modes read the same registers, same parse accuracy, same API.
| IMU406 | MCU |
|---|---|
| TX | RX |
| RX | TX |
| 3.3V | 3.3V |
| GND | GND |
⚠️ TX ↔ RX cross-connect: IMU406TXgoes to the MCURXpin and IMU406RXto the MCUTXpin. Reversed wiring is the most common cause of no output.
⚠️ ESP32: the default Serial2 pins differ by family (Serial2 on the ESP32-S3 is not 16/17). Always pass your actual wiring pins tobegin().
#include <IMU406.h>
#if defined(ARDUINO_ARCH_ESP32)
IMU406 imu(&Serial2);
const int RX_PIN = 16, TX_PIN = 17; // match your wiring
#else
IMU406 imu(&Serial); // AVR etc.
#endif
void setup() {
Serial.begin(115200);
#if defined(ARDUINO_ARCH_ESP32)
imu.begin(115200, RX_PIN, TX_PIN, true); // interrupt mode
#else
imu.begin(115200, -1, -1, false); // polling mode (AVR)
#endif
}
void loop() {
#if !defined(ARDUINO_ARCH_ESP32)
imu.update(); // polling only — not needed on ESP32
#endif
if (imu.hasNewData()) {
Serial.print(imu.getRoll());
Serial.print("/");
Serial.print(imu.getPitch());
Serial.print("/");
Serial.println(imu.getYaw());
}
}See examples/get_angles/get_angles.ino for a full read-out of all 9 values, and
examples/interrupt_mode/interrupt_mode.ino for interrupt mode alongside an
independent Serial1 interrupt handler.
| Method | Description |
|---|---|
IMU406(HardwareSerial* serial) |
Construct and bind a serial port. |
void begin(long baud=115200, int8_t rxPin=-1, int8_t txPin=-1, bool useInterrupt=true) |
Open the port and reset state. ESP32 only: rxPin/txPin and interrupt switch. |
void enableInterrupt() |
(ESP32) Attach the onReceive() hook; loop() then needs no update(). No-op elsewhere. |
void disableInterrupt() |
Detach the hook (never touches other ports). Call update() to poll after this. |
bool interruptEnabled() |
true if interrupt mode is currently active. |
void update() |
Poll the bound port and parse. Skipped (no-op) in interrupt mode. |
void reset() |
Reset the receive state machine. |
| Method | Returns |
|---|---|
float getRoll() / getPitch() / getYaw() |
Float angle in degrees (already ÷100). |
int32_t getRollRaw() / getPitchRaw() / getYawRaw() |
Raw integer ±18000. |
| Method | Returns |
|---|---|
float getAccelX() / getAccelY() / getAccelZ() |
BCD-decoded value as float. |
int32_t getAccelXRaw() / getAccelYRaw() / getAccelZRaw() |
Raw integer. |
| Method | Returns |
|---|---|
float getGyroX() / getGyroY() / getGyroZ() |
BCD-decoded value as float. |
int32_t getGyroXRaw() / getGyroYRaw() / getGyroZRaw() |
Raw integer. |
| Method | Description |
|---|---|
bool hasNewData() |
true if a fresh frame was parsed since the last update(). |
unsigned long lastFrameTime() |
millis() timestamp of the last parsed frame. |
bool isAlive(unsigned long aging_ms=500) |
true if a frame arrived within aging_ms. |
29-byte frames with header 0x84. All numeric fields are BCD-encoded,
3 bytes per axis.
0 1..3 4..6 7..9 10..12 13..15 16..18 19..21 22..24 25..27 28
0x84 roll pitch yaw accX accY accZ gyroX gyroY gyroZ checksum
- Attitude is resolved to an integer ±18000 (e.g.
18000=180.00°). - Acceleration and angular rate share the same signed-BCD encoding; the
physical scale/units depend on the sensor model configuration — apply the
datasheet factor (e.g.
/100or/8192) for SI units (m/s²,°/s).
| Symptom | Cause / fix |
|---|---|
| Examples menu shows no IMU406 | Wrong library path, or IDE wasn't restarted. Path must be libraries\IMU406\. |
| All zeros / nothing printed | Wiring reversed, wrong baud (try 9600), or monitor baud is not 115200. |
| Nothing in interrupt mode | Check you passed the correct rxPin/txPin, the port is begin()-ed to the right baud, and loop() doesn't also need to call update(). |
| Other serial stopped working | This library only attaches onReceive() to its own port; if another port broke, review that port's own handler — they are independent. |
| Library reported as invalid | library.properties must be UTF-8 (no BOM), no stray characters. |
| Values drift / are noisy | Normal for raw serial IMUs; add filtering / calibration on top. |
| Wrong SPI/I2C expected | This is a serial module — it uses two UART pins, not I²C/SPI. |
Interrupt-driven reception. This is a major release that adds a second acquisition mode and a small breaking API change.
- New: Interrupt mode on ESP32 — frames parsed from the UART
onReceive()hook, soloop()does no polling. - New: Public
begin(..., bool useInterrupt),enableInterrupt(),disableInterrupt(),interruptEnabled(). - New: Example
interrupt_modeshowing IMU406 on Serial2 (interrupt) whileSerial1uses its own independent handler. - Cross-platform: Polling mode retained for AVR Uno/Mega & all other boards.
- API change:
feed()is now private (useupdate()orfeedAll()). - On recent ESP32 cores the hook is armed with
onlyIfTimeout=falsefor low latency.
Initial release.
- Non-blocking frame-sync parser for the 29-byte
0x84protocol. - Attitude
roll/pitch/yaw(float degrees) + raw ±18000 integers. - 3-axis accelerometer and gyroscope getters.
hasNewData(),lastFrameTime(),isAlive()helpers.- First example
get_angles.
MIT License. See LICENSE. Author: StrayerSQH.