Files
wadamesh/variants/thinknode_m9/M9Compass.cpp
T
Christopher Van HooseandClaude Fable 5 cfb083e36f Lua host: keep keypad-nav focus off the app body; GPS Compass dial layout
On the ThinkNode M9 every Lua app opened on a white page: the app body is a
clickable object (touch boards need its press events) on the top layer, so
navCollect harvested it as a leaf focus target and navFocusCb's reverse-video
fill painted it solid under the app's widgets -- the canvas on top stayed
dark, which is what gave it away. NAV_SKIP_FLAG would also hide an app's own
buttons from the d-pad, so this adds NAV_PASSTHRU_FLAG (AppPage.h, shared by
both TUs): clickable, never a target itself, children still collected.

M9 compass: low-pass depth 8 at 50 Hz (the datasheet's 0x61 example) read as
sluggish on the dial; now depth 4 at 100 Hz (CTRL1 0x41, CTRL2 0x30). The
app ticks at 100 ms with lighter smoothing to match.

GPS Compass app rebuilt in the RF Monitor's look: a dial with rings,
10/30/90-degree graduations, red north, a lubber mark and the heading in
the centre; a key/value panel (FIX/LAT/LON/ALT/SPD/TGT) with a 10-cell
satellite meter; compact strings where the column is narrow at large fonts.
Confirmed on the M9 by Chris: the dial turns and calibration holds
(gpscompass.sav persists across reboots).

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

195 lines
7.4 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>
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 (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