Files
wadamesh/variants/thinknode_m9/M9Compass.cpp
T
Christopher Van HooseandClaude Fable 5 b273f20e26 M9 compass (QMC6309) + GPS motion for Lua apps; GPS Compass Store app
Firmware
- variants/thinknode_m9/M9Compass.{h,cpp}: QMC6309 at 0x7C on the peripheral
  bus. Probe by chip id, soft reset with the explicit clear, CTRL2 0x20
  (50 Hz, +/-32 G, set/reset on), CTRL1 0x61 (normal, OSR 8/8), read-back
  verified. Synchronous read from the Lua host bridge, 1 s cache, lazy
  re-probe while the rail-powered part is still in POR, OVFL kept + flagged +
  logged. HAS_M9_COMPASS=1 in the env -> new hardware gate CAP_COMPASS.
- src/helpers/WadaNmeaLocationProvider.h: Wadamesh-owned copy of the core
  MicroNMEALocationProvider that also exposes RMC speed/course (the core
  keeps its parser private; no libdeps patch). M9 target.cpp builds on it,
  HAS_GPS_MOTION=1, wadaGpsMotion() for UITask.
- wada.sys.gps(): + alt (m), + speed_kmh/course where the board provides
  them (course only while moving -- an empty RMC course parses as 0), and
  nil while the user has GPS switched off. wada.sys.compass(): {x,y,z,ovfl}
  Gauss, sensor frame, uncalibrated, registered only where CAP_COMPASS;
  caps().compass. Calibration, axis mapping and the heading maths live in
  the app so they can be adjusted per user without a firmware cut.
- Host: luaHostContactAt reads the RTC once per contacts() walk instead of
  once per contact (an I2C transaction each on the M9); pressCb reports
  press coordinates in body content space (scroll offset folded in).

App
- deploy/apps/gpscompass/1.0 + apps.json: rotating rose with a fixed index,
  live fix readout, bearing/range to a selected contact, magnetometer
  heading with hard-iron calibration (C), frame rotate/mirror (O/F, the
  M9's sensor orientation is undocumented), GPS-course fallback on every
  other board, saturation warning. Not baked into lua_builtin.h on purpose
  (CAP_BUILTIN_LUA_APPS also removes the Store > Apps tab).

Verified: M9, V4, V4-R8, T-Deck compile (M9 flash +2 KB); two adversarial
review passes, all confirmed findings fixed; host Lua harness (vendored
Lua, LUA_32BITS) -- calibration recovers a simulated bias exactly, heading
error 0 deg, worst tick ~10k of the 100k budget. Hardware validation list
(axis orientation, bias magnitude, 0x7C ACK) in M9_PORT.md.

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

191 lines
7.1 KiB
C++

// ThinkNode M9 QMC6309 magnetometer driver — see M9Compass.h.
#if defined(HAS_M9_COMPASS) && defined(ESP32)
#include "M9Compass.h"
#include <Arduino.h>
#include <Wire.h>
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 50 Hz (0b010<<4) | range ±32 G (0b00<<2) | set/reset on (0b00).
// 50 Hz so a consumer polling at 10 Hz always finds DRDY set. ±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 = 0x20;
// CTRL1 = OSR2 (low-pass depth) 8 (0b011<<5) | OSR1 8 (0b00<<3) | normal mode
// (0b01). This is the datasheet's own worked example ("0x61: normal mode,
// OSR1=8, OSR2=8"). Normal mode honours the ODR above (≈0.5 mA at 50 Hz);
// continuous mode free-runs at the maximum rate and is not needed here.
constexpr uint8_t kCtrl1 = 0x61;
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, 50 Hz, +/-32 G, OSR 8/8)");
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 (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