Files
wadamesh/variants/thinknode_m9/target.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

170 lines
7.6 KiB
C++

#include "target.h"
#include <Arduino.h>
ThinkNodeM9Board board;
// LR1110 has no std_init() helper in the core (unlike CustomSX1262/SX1268/
// LLCC68) — confirmed against MeshCore's own LR1110 boards (thinknode_m3,
// minewsemi_me25ls01). Module() takes the same (NSS, IRQ/DIO1, RESET, BUSY,
// spi) signature either way. M9: NSS=39, DIO1=42, RESET=45, BUSY=41,
// SCLK=40, MISO=38, MOSI=47 — all on the SAME physical SPI bus as the LCD
// (CS=16) and the microSD slot (CS=48), so radio and display share the
// literal global `SPI` object — matching how T-Deck/Heltec V4's radio (a
// plain default-constructed `static SPIClass spi;`, not an explicit-host
// instance) and the LR1110 reference boards (thinknode_m3, me25ls01, which
// also pass the global `SPI` to their Module()) both do it. The bus itself
// is begun once, in ThinkNodeM9Board::begin() (see M9Board.cpp for why that
// has to happen there rather than mirroring T-Deck/Heltec's "begin it inside
// radio_init()" exactly — their displays self-init on a separate dedicated
// bus, M9's display is on ST7789LCDDisplay's "default" branch and needs the
// global SPI already begun before display.begin() runs).
RADIO_CLASS radio =
new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, SPI);
WRAPPER_CLASS radio_driver(radio, board);
ESP32RTCClock fallback_clock;
ClockFloorRTC rtc_clock(
fallback_clock); // wraps AutoDiscover: monotonic send-timestamp floor (#89)
// Wadamesh's own provider rather than the core's MicroNMEALocationProvider:
// identical behaviour, plus the RMC speed/course the core keeps private (see
// src/helpers/WadaNmeaLocationProvider.h for why it is a copy, not a subclass).
WadaNmeaLocationProvider gps(Serial1, &rtc_clock);
EnvironmentSensorManager sensors(gps);
#ifdef DISPLAY_CLASS
// periph_power gates the LCD/GPS/sensor rail (GPIO18, active-low P-MOS).
// Passing &board.periph_power lets ST7789LCDDisplay::begin()/turnOff()
// claim/release it alongside the panel's own lifecycle. The backlight
// (GPIO17, PNP) is handled separately by ThinkNodeM9Board — see M9Board.h
// for why it's NOT routed through PIN_TFT_LEDA_CTL.
DISPLAY_CLASS display(&board.periph_power);
// (No MomentaryButton here: the M9 has NO user/BOOT button — schematic-
// confirmed, only a power-cut slider and reset. PIN_USER_BTN is undefined for
// this env so UITask's button poll compiles out too.)
#endif
#ifndef LORA_CR
#define LORA_CR 5
#endif
bool radio_init() {
fallback_clock.begin();
rtc_clock.begin(Wire); // peripheral I2C bus (7/6) — RTC PCF8563 @ 0x51
#if defined(HAS_M9_KEYBOARD)
m9KeyboardBegin(); // own bus, Wire1 (20/21) — no contention with Wire
#endif
#if defined(HAS_M9_COMPASS)
// QMC6309 on the same peripheral bus as the RTC, behind the GPIO18 rail that
// board.begin() claimed well before this runs. Absent / still-booting chips
// are re-probed lazily from the read path, so a miss here is not fatal.
m9CompassBegin(Wire);
#endif
#ifdef LR11X0_DIO3_TCXO_VOLTAGE
float tcxo = LR11X0_DIO3_TCXO_VOLTAGE;
#else
// Fallback = the hardware-confirmed value, NOT 0. An earlier theory (Y1 =
// self-powered active oscillator, so disable RadioLib's TCXO bias) was
// disproven on the real unit: with tcxo=0 the chip boots on its internal
// RC (SPI alive) but the first command needing the true 32 MHz clock is
// rejected — radio init fails -707. The schematic's "VTCXO" rail feeding
// Y1 is the LR1110's own TCXO-supply output, so DIO3 must drive it at
// 3.3 V — see the LR11X0_DIO3_TCXO_VOLTAGE comment in platformio.ini for
// the empirical confirmation.
float tcxo = 3.3f;
#endif
// SPI bus itself was already begun once in ThinkNodeM9Board::begin() (the
// display needs it before display.begin() runs) — this is the manual
// equivalent of what CustomSX1262/SX1268's std_init() would otherwise do,
// since CustomLR1110 has no std_init() helper.
int status = radio.begin(LORA_FREQ, LORA_BW, LORA_SF, LORA_CR,
RADIOLIB_LR11X0_LORA_SYNC_WORD_PRIVATE,
LORA_TX_POWER, 16, tcxo);
if (status != RADIOLIB_ERR_NONE) {
// One retry after a settle: the first begin() enables the TCXO supply, and
// if Y1's startup outruns RadioLib's fixed internal wait the first
// calibration can fail while the second attempt finds a stable clock.
delay(150);
status = radio.begin(LORA_FREQ, LORA_BW, LORA_SF, LORA_CR,
RADIOLIB_LR11X0_LORA_SYNC_WORD_PRIVATE, LORA_TX_POWER,
16, tcxo);
}
// Print the chip's own identification in BOTH outcomes: device type,
// transceiver FW revision and pending error flags. Preprod M9 units ship
// with old LR1110 firmware (pre-0x0308), which is exactly what this line
// catches — see scripts/build/patch_radiolib_lr11x0.py for the matching
// old-FW tolerance in RadioLib's config(). Safe to call even after a
// failed begin(): the SPI link is configured before the failing step.
{
LR11x0VersionInfo_t vinfo;
uint16_t chip_errors = 0;
if (radio.getVersionInfo(&vinfo) == RADIOLIB_ERR_NONE) {
radio.getErrors(&chip_errors);
Serial.printf("LR1110: hw=0x%02X device=0x%02X fw=%u.%u wifi=%u.%u "
"gnss=%u.%u errors=0x%04X\n",
vinfo.hardware, vinfo.device, vinfo.fwMajor, vinfo.fwMinor,
vinfo.fwMajorWiFi, vinfo.fwMinorWiFi, vinfo.fwGNSS,
vinfo.almanacGNSS, chip_errors);
} else {
Serial.println("LR1110: getVersionInfo failed (chip not answering)");
}
}
if (status != RADIOLIB_ERR_NONE) {
Serial.print("ERROR: radio init failed: ");
Serial.println(status);
return false;
}
radio.setCRC(2);
radio.explicitHeader();
// RF-switch DIO table: pin assignment is SCHEMATIC-CONFIRMED
// (Think_Node_M9_V1_0.pdf, the LR1110/U7 block) — DIO5 (pin 20) -> R20 -> net
// RFSW0_V1 -> switch IC (U8) V1; DIO6 (pin 19) -> R19 -> net RFSW1_V2 -> U8
// V2. DIO7/DIO8 are unconnected on this board (no net, dangling stubs) —
// unlike t1000-e/me25ls01's 4-pin DIO5-8 scheme, this is a plain 2-pin switch
// into a single antenna (U8 RFC -> C74 -> L12 -> ANT1), matching
// thinknode_m3's table shape exactly. What's NOT independently re-derived:
// the per-mode HIGH/LOW truth table below — U8's part number isn't printed on
// the schematic, so this reuses the conventional STBY/RX/TX/TX_HP polarity
// every other MeshCore LR1110 board's 2-pin table uses (thinknode_m3, same
// shape). If TX/RX work but seem swapped or dead, flip the RX/TX_HP rows here
// first.
#ifdef RF_SWITCH_TABLE
static const uint32_t rfswitch_dios[Module::RFSWITCH_MAX_PINS] = {
RADIOLIB_LR11X0_DIO5, // -> RFSW0_V1 (U8 V1)
RADIOLIB_LR11X0_DIO6, // -> RFSW1_V2 (U8 V2)
RADIOLIB_NC, RADIOLIB_NC, RADIOLIB_NC};
static const Module::RfSwitchMode_t rfswitch_table[] = {
{LR11x0::MODE_STBY, {LOW, LOW}}, {LR11x0::MODE_RX, {HIGH, LOW}},
{LR11x0::MODE_TX, {HIGH, HIGH}}, {LR11x0::MODE_TX_HP, {LOW, HIGH}},
{LR11x0::MODE_TX_HF, {LOW, LOW}}, {LR11x0::MODE_GNSS, {LOW, LOW}},
{LR11x0::MODE_WIFI, {LOW, LOW}}, END_OF_MODE_TABLE,
};
radio.setRfSwitchTable(rfswitch_dios, rfswitch_table);
#endif
#ifdef RX_BOOSTED_GAIN
radio.setRxBoostedGainMode(RX_BOOSTED_GAIN);
#endif
return true;
}
mesh::LocalIdentity radio_new_identity() {
RadioNoiseListener rng(radio);
return mesh::LocalIdentity(&rng);
}
SPIClass *m9SharedSPI() {
return &SPI; // global instance, shared by radio + display + SD; begun once in
// M9Board::begin()
}
bool wadaGpsMotion(float *speed_kmh, float *course_deg) {
return gps.motion(speed_kmh, course_deg);
}