Files
wadamesh/variants/thinknode_m9/M9Compass.cpp
T
Christopher Van HooseandClaude Fable 5 4852f06033 GPS Compass: bake the MEASURED M9 axis mapping; no orientation press needed
The sensor's orientation on the board is documented nowhere, so it was
measured: held flat, logging the raw vector at four headings 90 deg apart
(M9_COMPASS_DEBUG, now off again) gives a hard-iron centre of
(-0.340, -3.378) and, after subtracting it,

    N x'=-0.055 y'=+0.310    E x'=+0.268 y'=-0.018
    S x'=+0.013 y'=-0.275    W x'=-0.225 y'=-0.016

so atan2(x, y) reads 350/94/177/266 at N/E/S/W -- 0/90/180/270 within a few
degrees, counting up clockwise. +Y is the device's top edge, +X its left.
That is now the default: correct after calibration alone.

This also explains the reversal reported on hardware. The auto-handedness
rule assumed a Z-out-of-screen sensor was the un-mirrored case; it is the
other way round -- held flat north of the magnetic equator, a Z-INTO-screen
sensor reads the downward field as POSITIVE z. Fixed, and the stored
orientation is versioned so values saved against the old formula are
discarded rather than pushing a correct default back off north.

The bias is real and large: ~-3.4 G on Y against a ~0.27 G horizontal
signal, which is why an uncalibrated device barely moves the dial, and why
the range is +/-32 G rather than +/-8 G. Meshtastic's implausible hardcoded
extrema were right after all.

Also: the satellite meter now sits beside the count instead of at the column
edge, and UITask::loop's coarse "ui:gps" stall bucket is split into
timers/threads/input/diag so a 450 ms hitch can be attributed.

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

213 lines
8.0 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ThinkNode M9 QMC6309 magnetometer driver — see M9Compass.h.
#if defined(HAS_M9_COMPASS) && defined(ESP32)
#include "M9Compass.h"
#include <Arduino.h>
#include <Wire.h>
// Bring-up aid: log the raw field vector once a second so the sensor's axis
// orientation on this board can be derived from readings at known headings
// (it is not documented anywhere, and Meshtastic's M9 driver never verified
// its own guess). Set to 0 once the mapping is baked into the default.
#ifndef M9_COMPASS_DEBUG
#define M9_COMPASS_DEBUG 0
#endif
namespace {
constexpr uint8_t kAddr = 0x7C; // the only address the part offers
constexpr uint8_t kRegChipId = 0x00;
constexpr uint8_t kRegData = 0x01; // X LSB .. Z MSB, 6 bytes, auto-increment
constexpr uint8_t kRegStatus = 0x09;
constexpr uint8_t kRegCtrl1 = 0x0A;
constexpr uint8_t kRegCtrl2 = 0x0B;
constexpr uint8_t kChipId = 0x90;
constexpr uint8_t kStatDrdy = 0x01;
constexpr uint8_t kStatOvfl = 0x02;
// CTRL2 = ODR 100 Hz (0b011<<4) | range ±32 G (0b00<<2) | set/reset on (0b00).
// 100 Hz so a consumer polling at 10 Hz always finds DRDY set and the
// low-pass below adds little lag (4 samples = 40 ms). ±32 G rather
// than ±8 G: Earth's field is only 0.25..0.65 G, but Meshtastic's M9 driver
// hard-codes calibration extrema around -6..-7.6 G per axis — if that
// on-board hard-iron bias is real it sits right at the ±8 G rail, and a
// saturated axis is worse than a coarser one. At 1000 LSB/G the resolution is
// still 1 mG (≈0.13° of heading in a 0.45 G horizontal field); the sensor's
// own noise floor (~2.5 mG at OSR 8) dominates either way.
constexpr uint8_t kCtrl2 = 0x30;
// CTRL1 = OSR2 (low-pass depth) 4 (0b010<<5) | OSR1 8 (0b00<<3) | normal mode
// (0b01). OSR1 8 is the low-noise oversampling; the low-pass depth is the
// heading's group delay, and 8 deep at 50 Hz (the datasheet's 0x61 example)
// read as sluggish on the dial — 4 deep at 100 Hz keeps the noise figure
// close (madflight measured 1.4 LSB σ at depth 16 vs 6.7 at depth 1) with a
// quarter of the lag. Normal mode honours the ODR (≈1 mA at 100 Hz/OSR 8);
// continuous mode free-runs at the maximum rate and is not needed here.
constexpr uint8_t kCtrl1 = 0x41;
constexpr float kGaussPerLsb = 1.0f / 1000.0f; // ±32 G range
constexpr uint32_t kOvflLogEvery = 10000; // ms between overflow log lines
constexpr uint32_t kSampleMaxAge = 1000; // ms a cached sample stays valid
constexpr uint32_t kReprobeEvery = 2000; // ms between probes while absent
constexpr int kMaxBusErrors = 8; // consecutive, before re-probing
TwoWire* s_bus = nullptr;
bool s_present = false;
uint32_t s_next_probe_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 s_ovfl = false;
uint32_t s_ovfl_log_ms = 0;
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;
}
// Register read with a repeated start between the address write and the read
// (the keyboard driver uses a full STOP because its controller wants one; the
// QMC6309 is a plain register-addressed part and takes either).
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() {
// Soft reset: the bit is NOT self-clearing, the datasheet requires the
// explicit 0x00 write afterwards. Reset restores every register to its POR
// value (suspend mode).
if (!writeReg(kRegCtrl2, 0x80)) return false;
if (!writeReg(kRegCtrl2, 0x00)) return false;
delay(10);
if (!writeReg(kRegCtrl2, kCtrl2)) return false;
if (!writeReg(kRegCtrl1, kCtrl1)) return false;
// Read back: one third-party driver (madflight) saw configuration writes not
// stick right after power-up and retries — do the same once rather than
// trusting the ACK.
uint8_t c1 = 0, c2 = 0;
if (!readRegs(kRegCtrl1, &c1, 1) || !readRegs(kRegCtrl2, &c2, 1)) return false;
if (c1 != kCtrl1 || c2 != kCtrl2) {
delay(5);
if (!writeReg(kRegCtrl2, kCtrl2) || !writeReg(kRegCtrl1, kCtrl1)) return false;
if (!readRegs(kRegCtrl1, &c1, 1) || !readRegs(kRegCtrl2, &c2, 1)) return false;
if (c1 != kCtrl1 || c2 != kCtrl2) return false;
}
return true;
}
// One probe attempt. Distinguishes "nothing answered" (rail not up yet, or no
// chip) from "answered with a foreign id" in the boot log, since both read as
// a dead compass from the app's side.
bool probe(bool log) {
uint8_t id = 0;
if (!readRegs(kRegChipId, &id, 1)) {
if (log) Serial.println("M9 compass: no answer at 0x7C (QMC6309 absent or rail not up)");
return false;
}
if (id != kChipId) {
if (log) Serial.printf("M9 compass: unexpected chip id 0x%02X at 0x7C (want 0x90)\n", id);
return false;
}
if (!configure()) {
if (log) Serial.println("M9 compass: QMC6309 found but configuration did not stick");
return false;
}
if (log) Serial.println("M9 compass: QMC6309 ok (id=0x90, 100 Hz, +/-32 G, OSR 8, LPF 4)");
s_errors = 0;
s_have_sample = false;
return true;
}
} // namespace
void m9CompassBegin(TwoWire& w) {
s_bus = &w;
s_present = probe(true);
s_next_probe_ms = millis() + kReprobeEvery;
}
bool m9CompassPresent() { return s_present; }
bool m9CompassRead(float* x, float* y, float* z, bool* overflow) {
if (overflow) *overflow = false;
if (!s_bus) return false;
const uint32_t now = millis();
if (!s_present) {
// Rail-powered parts can still be coming out of POR when radio_init()
// runs, so keep trying — quietly, one NACKed transaction every 2 s at most,
// and only while something actually asks for the compass.
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;
}
uint8_t st = 0;
bool ok = readRegs(kRegStatus, &st, 1);
if (ok && (st & kStatDrdy)) {
uint8_t b[6];
ok = readRegs(kRegData, 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 * kGaussPerLsb;
s_y = ry * kGaussPerLsb;
s_z = rz * kGaussPerLsb;
s_sample_ms = now;
s_have_sample = true;
// Overflow is kept, flagged, and logged (rate-limited) rather than
// dropped: silently discarding it would make a board with a huge
// hard-iron bias or a magnet nearby look exactly like a missing chip.
s_ovfl = (st & kStatOvfl) != 0;
#if M9_COMPASS_DEBUG
{
static uint32_t last = 0;
if (now - last >= 1000) {
last = now;
Serial.printf("[MAG] x=%.3f y=%.3f z=%.3f G%s\n", (double)s_x, (double)s_y, (double)s_z,
s_ovfl ? " OVFL" : "");
}
}
#endif
if (s_ovfl && (s_ovfl_log_ms == 0 || (now - s_ovfl_log_ms) > kOvflLogEvery)) {
s_ovfl_log_ms = now;
Serial.printf("M9 compass: OVFL raw=%d,%d,%d (axis beyond +/-32000 counts at +/-32 G)\n",
(int)rx, (int)ry, (int)rz);
}
}
}
if (!ok) {
// A burst of bus errors means the chip dropped off (rail cycled, bus
// wedged): forget it and let the probe path bring it back configured.
if (++s_errors >= kMaxBusErrors) {
s_present = false;
s_have_sample = false;
s_next_probe_ms = now + kReprobeEvery;
Serial.println("M9 compass: lost the QMC6309 (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;
if (overflow) *overflow = s_ovfl;
return true;
}
#endif // HAS_M9_COMPASS && ESP32