Files
wadamesh/variants/thinknode_m9/M9Imu.cpp
T
Christopher Van HooseandClaude Fable 5 fcc146f57a M9 IMU (QMI8658) + tilt-compensated heading
A two-axis magnetic heading assumes the device is level. At this latitude the
field dips ~60 degrees, so the vertical component is 1.6x the horizontal one
and tipping the device leaks it into the pair the heading is made from: about
1.5 degrees of heading per degree of tilt. That is what "it drifts" was once
the calibration was sound -- it was the hand holding it.

Driver: variants/thinknode_m9/M9Imu.{h,cpp}, QMI8658 at 0x6B on the peripheral
bus, accelerometer only (the gyro is most of the power budget and nothing here
needs it): +/-2 g at 62.5 Hz with the low-pass on, soft reset with a 160 ms
wait that covers both die variants, and the same idle-suspend as the compass so
it costs nothing when unused. CTRL1's ADDR_AI bit is set and read back -- with
it clear the burst read silently returns six copies of one byte, which looks
like a working sensor reporting nonsense. HAS_M9_IMU -> CAP_IMU ->
wada.sys.accel(), caps().accel.

Axes MEASURED, not guessed, by holding three attitudes and logging:
  flat, screen up        z = -1.02  -> +Z into the screen (down)
  on bottom edge, top up x = +0.97  -> +X at the top edge (forward)
  on left edge, right up y = +1.08  -> +Y at the right edge
So the IMU is already in the aerospace body frame, and it agrees with the
magnetometer's independently measured +Z-into-screen. (Meshtastic's M9 driver
passes both sensors through untransformed and mirrors the heading on the sign
of accel Z, so its compass flips when the device is turned over. Not copied.)

Heading now rotates the field back into the horizontal plane using gravity
(NXP AN4248 / ST AN3192) before taking the angle, and reports the tilt angle;
past 55 degrees it says "too steep to read" rather than lying. Calibration
returns to a 3D fit because tilt compensation needs the vertical offset too --
but the instruction is now "turn it every way ON ONE SPOT", and the
accelerometer VERIFIES it: coverage is measured by how far gravity swung, so a
flat spin is refused by name ("turn it nose over tail") instead of silently
fitting a degenerate sphere. Simulated in the harness against a modelled M9 in
a known attitude: offsets recovered exactly, and the heading holds within 3
degrees through 20 degrees of roll and pitch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 10:00:44 -04:00

228 lines
7.6 KiB
C++

// ThinkNode M9 QMI8658 accelerometer driver — see M9Imu.h.
#if defined(HAS_M9_IMU) && defined(ESP32)
#include "M9Imu.h"
#include <Arduino.h>
#include <Wire.h>
// Bring-up aid, same role as M9_COMPASS_DEBUG: log the raw vector once a
// second so the sensor's axis orientation on this board can be derived from
// readings in known attitudes. Set to 0 once the mapping is baked in.
#ifndef M9_IMU_DEBUG
#define M9_IMU_DEBUG 1
#endif
namespace {
constexpr uint8_t kAddr = 0x6B; // SA0 grounded on this board
constexpr uint8_t kRegWhoAmI = 0x00;
constexpr uint8_t kRegRevision = 0x01;
constexpr uint8_t kRegCtrl1 = 0x02;
constexpr uint8_t kRegCtrl2 = 0x03;
constexpr uint8_t kRegCtrl3 = 0x04;
constexpr uint8_t kRegCtrl5 = 0x06;
constexpr uint8_t kRegCtrl7 = 0x08;
constexpr uint8_t kRegStatus0 = 0x2E;
constexpr uint8_t kRegAxL = 0x35;
constexpr uint8_t kRegResetOk = 0x4D;
constexpr uint8_t kRegReset = 0x60;
constexpr uint8_t kWhoAmI = 0x05;
constexpr uint8_t kResetCmd = 0xB0;
constexpr uint8_t kResetOkVal = 0x80;
constexpr uint8_t kStatusADrdy = 0x01;
// CTRL1: ADDR_AI=1 (bit6) so a burst read walks the data registers — without
// it every read returns the same byte. BE=0 for little-endian, SensorDisable=0.
constexpr uint8_t kCtrl1 = 0x40;
// CTRL2: ±2 g (aFS 000) at 62.5 Hz (aODR 0111). ±2 g because this measures
// gravity, not motion, and the finer scale is worth having; 62.5 Hz is the
// slowest NORMAL-mode rate above the compass's own update rate (the low-power
// rates below it duty-cycle the part and add noise for no benefit here).
constexpr uint8_t kCtrl2 = 0x07;
constexpr uint8_t kCtrl3 = 0x00; // gyro off — nothing here needs it
// CTRL5: accel low-pass on, 2.66% of ODR (~1.7 Hz). Tilt is a slow quantity
// and hand tremor is not; filtering it here costs nothing.
constexpr uint8_t kCtrl5 = 0x01;
constexpr uint8_t kCtrl7On = 0x01; // aEN
constexpr uint8_t kCtrl7Off = 0x00;
constexpr float kGPerLsb = 1.0f / 16384.0f; // ±2 g
constexpr uint32_t kSampleMaxAge = 1000;
constexpr uint32_t kIdleSuspendMs = 2000;
constexpr uint32_t kReprobeEvery = 2000;
constexpr int kMaxBusErrors = 8;
constexpr uint32_t kWakeSettleMs = 55; // 3 ms + 3/ODR at 62.5 Hz
TwoWire* s_bus = nullptr;
bool s_present = false;
bool s_awake = false;
uint32_t s_next_probe_ms = 0;
uint32_t s_last_read_ms = 0;
uint32_t s_wake_ms = 0;
int s_errors = 0;
float s_x = 0, s_y = 0, s_z = 0;
uint32_t s_sample_ms = 0;
bool s_have_sample = false;
bool writeReg(uint8_t reg, uint8_t val) {
s_bus->beginTransmission(kAddr);
s_bus->write(reg);
s_bus->write(val);
return s_bus->endTransmission() == 0;
}
bool readRegs(uint8_t reg, uint8_t* out, uint8_t n) {
s_bus->beginTransmission(kAddr);
s_bus->write(reg);
if (s_bus->endTransmission(false) != 0) return false;
if (s_bus->requestFrom((int)kAddr, (int)n) != n) return false;
for (uint8_t i = 0; i < n; ++i) out[i] = (uint8_t)s_bus->read();
return true;
}
bool configure() {
if (!writeReg(kRegCtrl1, kCtrl1)) return false;
if (!writeReg(kRegCtrl2, kCtrl2)) return false;
if (!writeReg(kRegCtrl3, kCtrl3)) return false;
if (!writeReg(kRegCtrl5, kCtrl5)) return false;
if (!writeReg(kRegCtrl7, kCtrl7On)) return false;
s_awake = true;
s_wake_ms = millis();
// Read back the two that decide whether data is even parseable: without
// ADDR_AI the burst read silently returns six copies of one byte, which
// looks like a working sensor reporting nonsense.
uint8_t c1 = 0, c2 = 0;
if (!readRegs(kRegCtrl1, &c1, 1) || !readRegs(kRegCtrl2, &c2, 1)) return false;
return c1 == kCtrl1 && c2 == kCtrl2;
}
bool probe(bool log) {
uint8_t id = 0;
if (!readRegs(kRegWhoAmI, &id, 1)) {
if (log) Serial.println("M9 IMU: no answer at 0x6B (QMI8658 absent or rail not up)");
return false;
}
if (id != kWhoAmI) {
if (log) Serial.printf("M9 IMU: unexpected WHO_AM_I 0x%02X at 0x6B (want 0x05)\n", id);
return false;
}
// Soft reset, then wait long enough for either die: the QMI8658A finishes in
// ~15 ms, the C wants ~150 ms, and the M9's variant is not documented.
if (!writeReg(kRegReset, kResetCmd)) return false;
delay(160);
uint8_t ok = 0;
if (!readRegs(kRegResetOk, &ok, 1) || ok != kResetOkVal) {
if (log) Serial.printf("M9 IMU: reset flag 0x%02X (want 0x80)\n", ok);
return false;
}
if (!configure()) {
if (log) Serial.println("M9 IMU: QMI8658 found but configuration did not stick");
return false;
}
uint8_t rev = 0;
readRegs(kRegRevision, &rev, 1);
if (log) Serial.printf("M9 IMU: QMI8658 ok (id=0x05 rev=0x%02X, accel 62.5 Hz +/-2 g)\n", rev);
s_errors = 0;
s_have_sample = false;
// Nothing is asking for tilt yet: park it rather than run the accelerometer
// from boot. m9ImuRead() wakes it.
if (writeReg(kRegCtrl7, kCtrl7Off) && writeReg(kRegCtrl1, kCtrl1 | 0x01)) s_awake = false;
return true;
}
} // namespace
void m9ImuBegin(TwoWire& w) {
s_bus = &w;
s_present = probe(true);
s_next_probe_ms = millis() + kReprobeEvery;
}
bool m9ImuPresent() { return s_present; }
bool m9ImuRead(float* x, float* y, float* z) {
if (!s_bus) return false;
const uint32_t now = millis();
if (!s_present) {
if ((int32_t)(now - s_next_probe_ms) < 0) return false;
s_next_probe_ms = now + kReprobeEvery;
s_present = probe(false);
if (!s_present) return false;
}
s_last_read_ms = now;
if (!s_awake) {
// Leave power-down and re-enable the accelerometer. The first conversion
// is one turn-on time away, so this call reports nothing fresh and the
// caller's next poll gets data.
if (!writeReg(kRegCtrl1, kCtrl1)) return false;
if (!writeReg(kRegCtrl7, kCtrl7On)) return false;
s_awake = true;
s_wake_ms = now;
return false;
}
if ((now - s_wake_ms) < kWakeSettleMs) return false; // still settling
uint8_t st = 0;
bool ok = readRegs(kRegStatus0, &st, 1);
if (ok && (st & kStatusADrdy)) {
uint8_t b[6];
ok = readRegs(kRegAxL, b, 6);
if (ok) {
const int16_t rx = (int16_t)((uint16_t)b[0] | ((uint16_t)b[1] << 8));
const int16_t ry = (int16_t)((uint16_t)b[2] | ((uint16_t)b[3] << 8));
const int16_t rz = (int16_t)((uint16_t)b[4] | ((uint16_t)b[5] << 8));
s_x = rx * kGPerLsb;
s_y = ry * kGPerLsb;
s_z = rz * kGPerLsb;
s_sample_ms = now;
s_have_sample = true;
#if M9_IMU_DEBUG
{
static uint32_t last = 0;
if (now - last >= 1000) {
last = now;
Serial.printf("[IMU] x=%+.3f y=%+.3f z=%+.3f g |a|=%.3f\n", (double)s_x, (double)s_y,
(double)s_z, (double)sqrtf(s_x * s_x + s_y * s_y + s_z * s_z));
}
}
#endif
}
}
if (!ok) {
if (++s_errors >= kMaxBusErrors) {
s_present = false;
s_have_sample = false;
s_next_probe_ms = now + kReprobeEvery;
Serial.println("M9 IMU: lost the QMI8658 (bus errors), will re-probe");
}
return false;
}
s_errors = 0;
if (!s_have_sample || (now - s_sample_ms) > kSampleMaxAge) return false;
if (x) *x = s_x;
if (y) *y = s_y;
if (z) *z = s_z;
return true;
}
void m9ImuIdleTick() {
if (!s_bus || !s_present || !s_awake) return;
const uint32_t now = millis();
if ((now - s_last_read_ms) < kIdleSuspendMs) return;
// Accelerometer off, then the oscillator: CTRL1 bit0 is SensorDisable, which
// is the part's real power-down (SensorLib's powerDown() writes the wrong
// bit, so do not copy it).
if (writeReg(kRegCtrl7, kCtrl7Off) && writeReg(kRegCtrl1, kCtrl1 | 0x01)) {
s_awake = false;
s_have_sample = false;
}
}
#endif // HAS_M9_IMU && ESP32