Park the magnetometer when idle, and stop Lua apps ticking into a dark screen

Two answers to "does the compass app matter for battery life", both yes-ish
and both now fixed.

The driver put the QMC6309 into normal mode at boot and left it there, so it
converted continuously from power-on whether or not anything read it: ~1 mA at
100 Hz / OSR 8, forever, on a 2300 mAh battery. It is now parked in suspend
after configuration and woken on demand, with m9CompassIdleTick() (called from
the M9's existing per-loop branch) suspending it again two seconds after the
last read. Waking is a single register write since suspend preserves the
configuration; the waking call reports "nothing fresh" and the caller's next
poll gets data, which callers already handle because the chip may be absent.

Separately, lv_timer_handler() runs unconditionally, so a Lua app's timer kept
firing while the display slept -- GPS Compass polled the magnetometer at 10 Hz
into a dark screen, which would have held the sensor awake even after the fix
above. The host now skips the tick while the screen is off and resumes on wake;
dt comes from millis(), so an app sees one long frame rather than a broken
clock. Documented on the SDK page.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Christopher Van Hoose
2026-08-22 09:01:19 -04:00
co-authored by Claude Fable 5
parent 08c9f2b822
commit 5a4a5a8ab6
4 changed files with 55 additions and 1 deletions
+7 -1
View File
@@ -29,7 +29,8 @@ extern "C" {
// to keep this TU decoupled from the 47k-line UITask.cpp).
extern const lv_font_t* luaHostFontForSize(int size_class); // 12/14/16 -> g_font_*
extern void luaHostToast(const char* msg, int ms); // showAlert passthrough
extern bool luaHostBeep(); // notification chime; false = no sounder / muted
extern bool luaHostBeep();
extern bool luaHostScreenOn(); // false = display asleep; app ticks pause // notification chime; false = no sounder / muted
extern fs::FS* luaHostAppFs(); // /apps storage root FS (may be null)
extern void luaHostAppPath(char* out, size_t cap, const char* rel); // prefixes the store root
extern int luaHostContactAt(int idx, char* name, size_t name_cap, int* type, uint32_t* secs_ago,
@@ -893,6 +894,11 @@ int sysBoard(lua_State* L) {
void tickTimerCb(lv_timer_t* t) {
(void)t;
if (!s_h || !s_h->L) return;
// Screen asleep: nothing the app draws is visible, and an app polling a
// sensor would keep the hardware awake for a dark screen. Skip the tick and
// let it resume on wake — dt is derived from millis(), so the app sees the
// pause as one long frame rather than a broken clock.
if (!luaHostScreenOn()) { s_h->last_tick = 0; return; }
uint32_t now = millis();
uint32_t dt = s_h->last_tick ? now - s_h->last_tick : 0;
s_h->last_tick = now;
+9
View File
@@ -46532,6 +46532,12 @@ static bool uiDataFsReady() {
#if CAP_LUA_APPS
// ---- Lua app host bridges (LuaAppHost.cpp externs) ----
// The host lives in its own TU; these three shims are its only view of UITask.
// A Lua app's timer keeps firing while the screen is off — lv_timer_handler runs
// unconditionally — so an app polling a sensor at 10 Hz went on doing it into a
// dark screen. Nothing it draws can be seen, so the host skips the tick; the app
// resumes on wake and sees the gap through sys.millis() like any other pause.
bool luaHostScreenOn() { return g_lv.task ? !g_lv.task->isScreenOff() : true; }
const lv_font_t* luaHostFontForSize(int size_class) {
return size_class <= 12 ? &g_font_12 : size_class <= 14 ? &g_font_14 : &g_font_16;
}
@@ -52568,6 +52574,9 @@ void UITask::loop() {
// the current tree before draining (cheap: sig-compare early-out).
if (s_kbd_nav || s_tb_nav) navMaybeRebuild();
m9KeyboardPoll();
#if defined(HAS_M9_COMPASS)
m9CompassIdleTick(); // park the magnetometer when no app is reading it
#endif
for (int kbi = 0; kbi < 12; ++kbi) {
int key = m9KeyboardReadKey();
if (key <= 0) break;
+34
View File
@@ -44,10 +44,14 @@ constexpr uint8_t kCtrl2 = 0x30;
// 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;
// CTRL1 with MODE = 00: suspend. Registers keep their values, so waking is a
// single write of kCtrl1 — no reconfiguration.
constexpr uint8_t kModeSuspend = 0x40;
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 kIdleSuspendMs = 2000; // no reads for this long -> suspend the chip
constexpr uint32_t kReprobeEvery = 2000; // ms between probes while absent
constexpr int kMaxBusErrors = 8; // consecutive, before re-probing
@@ -61,6 +65,11 @@ uint32_t s_sample_ms = 0;
bool s_have_sample = false;
bool s_ovfl = false;
uint32_t s_ovfl_log_ms = 0;
// The part measures continuously in normal mode (~1 mA at 100 Hz / OSR 8) —
// worth having while an app is reading the compass, pure waste the rest of the
// time, which is nearly always. So it is parked in suspend and woken on demand.
bool s_awake = false;
uint32_t s_last_read_ms = 0;
bool writeReg(uint8_t reg, uint8_t val) {
s_bus->beginTransmission(kAddr);
@@ -90,6 +99,7 @@ bool configure() {
delay(10);
if (!writeReg(kRegCtrl2, kCtrl2)) return false;
if (!writeReg(kRegCtrl1, kCtrl1)) return false;
s_awake = true;
// 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.
@@ -124,6 +134,9 @@ bool probe(bool log) {
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;
// Nothing is reading it yet: park it rather than burn ~1 mA from boot to the
// first app that asks. m9CompassRead() wakes it.
if (writeReg(kRegCtrl1, kModeSuspend)) s_awake = false;
return true;
}
@@ -152,6 +165,17 @@ bool m9CompassRead(float* x, float* y, float* z, bool* overflow) {
if (!s_present) return false;
}
s_last_read_ms = now;
if (!s_awake) {
// Waking costs one register write; the first conversion lands a sample
// period later, so this call reports "nothing fresh" and the caller's next
// poll gets real data. Callers already handle a miss (the chip may be
// absent), so this needs no special case at the other end.
if (!writeReg(kRegCtrl1, kCtrl1)) return false;
s_awake = true;
return false;
}
uint8_t st = 0;
bool ok = readRegs(kRegStatus, &st, 1);
if (ok && (st & kStatDrdy)) {
@@ -209,4 +233,14 @@ bool m9CompassRead(float* x, float* y, float* z, bool* overflow) {
return true;
}
void m9CompassIdleTick() {
if (!s_bus || !s_present || !s_awake) return;
const uint32_t now = millis();
if ((now - s_last_read_ms) < kIdleSuspendMs) return;
if (writeReg(kRegCtrl1, kModeSuspend)) {
s_awake = false;
s_have_sample = false; // whatever is cached is stale by the time we wake
}
}
#endif // HAS_M9_COMPASS && ESP32
+5
View File
@@ -44,4 +44,9 @@ bool m9CompassPresent();
* compass", but they are not a usable heading. */
bool m9CompassRead(float* x_gauss, float* y_gauss, float* z_gauss, bool* overflow = nullptr);
/** Call periodically (cheap: one comparison until it acts). Suspends the chip
* a couple of seconds after the last read, so it only draws its ~1 mA while
* something is actually using the compass. The next read wakes it. */
void m9CompassIdleTick();
#endif