Files
wadamesh/variants/thinknode_m9/M9Board.cpp
T
Christopher Van HooseandClaude Fable 5 1b7d793ceb M9: audit pass 2 — 29 verified fixes (nav soft-locks, gate parity, deep sleep, build hardening)
Second 7-dimension adversarially-verified audit over the 08-19 tree.
Headliners: SUB_MAP over a display-only Lua app orphaned the app page
(key-only soft-lock); Back-ladder z-order redesign (CC/power always-
frontmost, confirm-modal deference, Lua key-forward suppressed while a
confirm is up — the send-permission dialog was unanswerable); map pan
flag could go stale across tab jumps/popups; null-close progress rows
now genuinely block the registry dismiss (shared fix); terminal RX
mirror, fullscreen title, wallpaper caption, storage-error guidance
widened to M9; accent/@-mention pickers suppressed (dead chrome on a
touchless board); kb-backlight cache only latches ACKed duties (0xFF
sentinel == duty 255 skipped the first write every boot); deep sleep
actually drops the rails now (display refcount, LEDC pin re-route,
RTC/digital holds) and powers off radio+GPS; TCXO fallback no longer
codifies the disproven 0.0f; RadioLib old-FW patch fail-closes at
link; ENV_SKIP_GPS_DETECT + CORE_DEBUG_LEVEL=0 added to the env.

Full round log in M9_PORT.md 'Audit pass 2 (2026-08-20)'. All four
touch envs (M9, T-Deck, V4-R8, pager) compile clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 08:07:37 -04:00

187 lines
9.7 KiB
C++

#include "M9Board.h"
#include "soc/usb_serial_jtag_reg.h"
#include "target.h" // radio_driver / sensors / display externs for enterDeepSleep
// (same source the base ESP32Board.cpp pulls them from)
#include <Arduino.h>
#include <SPI.h>
void ThinkNodeM9Board::begin() {
// Release the S3's native USB pads. GPIO19/20 are USB D-/D+ and the ROM's
// USB-Serial-JTAG peripheral owns those pads from reset, including a D+
// pull-up ON GPIO20. The M9's console goes through an external UART bridge
// (U3) instead, and the schematic reuses GPIO20/21 as the KEYBOARD I2C bus
// (nets ESP32-2_SDA/SCL) and GPIO19 as LCD_TE — so without this the
// keyboard SDA line is clamped by the USB PHY and the controller never
// answers (bring-up #8: probe found nothing on a correctly-wired bus).
REG_CLR_BIT(USB_SERIAL_JTAG_CONF0_REG, USB_SERIAL_JTAG_USB_PAD_ENABLE);
ESP32Board::begin(); // attaches PIN_VBAT_READ, brings up Wire on
// PIN_BOARD_SDA/SCL (7/6)
// Undo the pad holds enterDeepSleep() arms (backlight/rail/NSS): a held pad
// silently ignores every pinMode/digitalWrite below, so a timer wake would
// otherwise come up with both rails latched OFF and the radio's NSS stuck.
// The holds live in the RTC domain and survive ANY reset short of the power
// slider actually cutting VBAT — the RST button included — so release them
// on every boot rather than gating on ESP_RST_DEEPSLEEP like HeltecV4Board
// does (harmless no-ops on a cold boot).
rtc_gpio_hold_dis((gpio_num_t)PIN_TFT_BL_EN);
rtc_gpio_hold_dis((gpio_num_t)PIN_PERIPH_POWER);
gpio_deep_sleep_hold_dis();
gpio_hold_dis((gpio_num_t)P_LORA_NSS); // digital pad (39 > RTC range), own API
// The one place this board's init genuinely has to differ from T-Deck/
// Heltec V4's pattern: their displays use ST7789LCDDisplay's dedicated-
// instance branch (LILYGO_TDECK / HELTEC_LORA_V4_TFT), which begins its
// own SPI bus internally — so neither board's board.begin() touches SPI
// at all, and the radio's bus only gets begun later, inside radio_init().
// M9's display is on the class's "default" branch (display(&SPI, ...)) —
// the only safe branch available without borrowing another board's
// identity macro (HELTEC_LORA_V4_TFT is also checked in shared UITask.cpp
// against HeltecV4Board-only methods, which would be a compile error here)
// — and that branch does NOT begin the bus itself, it assumes the caller
// already has. So a single bare SPI.begin() has to happen here, before
// display.begin() runs (next, in main.cpp). Everything else in this
// function mirrors TDeckBoard::begin()/HeltecV4Board::begin() as closely
// as M9's actual hardware allows — no CS-deselect dance, no NSS pre-drive:
// neither working board does either, and they don't need it.
SPI.begin(P_LORA_SCLK, P_LORA_MISO, P_LORA_MOSI, PIN_TFT_CS);
// Shared-SPI bus discipline: park the bus devices' chip-selects HIGH before
// any traffic flows (LR1110 NSS=39, TFT CS=16). NOTE: do NOT touch GPIO35-37
// on this board — the S3R8's octal PSRAM owns them (driving GPIO36 as a GPIO
// wedged the PSRAM bus and hung boot right after prefs init, bring-up #3).
// SCHEMATIC TRUTH (read from the V1.0 sheet, bring-up #9): the microSD *is*
// on this shared SPI bus with CS = GPIO48 — the patch's "SD CS = 36" misread
// the S3's PACKAGE pin 36 (SPICLK_N = GPIO48) as GPIO36.
pinMode(PIN_TFT_CS, OUTPUT);
digitalWrite(PIN_TFT_CS, HIGH);
pinMode(P_LORA_NSS, OUTPUT);
digitalWrite(P_LORA_NSS, HIGH);
// Park the SD's CS HIGH too: the card mounts lazily from the UI, so from
// reset until then it would otherwise watch 80 MHz display + radio traffic
// with its CS floating (no pull at reset on GPIO48) — a card that samples CS
// low can drive MISO into the LR1110's reads. SD.begin later re-runs the
// same pinMode, so this is purely the boot-window fix.
pinMode(PIN_SD_CS, OUTPUT);
digitalWrite(PIN_SD_CS, HIGH);
// Bring up both rails at boot and leave them claimed for now. Display
// power-down (turnOff()) releases periph_power via the pointer passed into
// ST7789LCDDisplay's constructor in target.cpp; the backlight is ours to
// manage explicitly until UI-side sleep/backlight-timeout wiring exists for
// this board (TODO — see M9_PORT.md).
periph_power.begin();
backlight.begin();
periph_power.claim();
backlight.claim();
// NB: no user/BOOT button pinMode here — this board has NO user button at
// all (schematic-confirmed: only a power-cut slider and a reset button,
// neither a GPIO). PIN_USER_BTN is deliberately not defined for this env;
// the old GPIO0 pull-up + UITask's screen-lock poll on it risked phantom
// locks from a floating strapping pin.
}
void ThinkNodeM9Board::enterDeepSleep(uint32_t secs, int pin_wake_btn) {
// Deep-sleep wake on this board is TIMER-ONLY. The earlier "LR1110 DIO1
// (GPIO42) wake" was electrically impossible: ESP32-S3 RTC pads are GPIO0-21,
// so every rtc_gpio_* call on GPIO42/39 returned ESP_ERR_INVALID_ARG
// (unchecked) and esp_sleep_enable_ext1_wakeup rejected the mask — sleep was
// entered with NO wake source at all when secs==0. The only RTC-capable wake
// candidate is ESP_WAKEUP (GPIO12, pulsed by the keyboard MCU), whose
// edge/polarity/pulse width are undocumented — wire ext0/ext1 to it only
// after characterizing it on hardware (M9_PORT.md Deferred #6).
(void)pin_wake_btn; // no wakeable button exists on this board
// Everything below mirrors the base ESP32Board::enterDeepSleep sequence this
// override hides — the rewrite exists only to drop the base's impossible
// rtc_gpio/ext1 wake calls (see above), not its power-downs.
//
// Display first: ST7789LCDDisplay::begin() holds its own claim on
// periph_power (via the pointer passed in target.cpp), so without turnOff()
// the release below would only drop the refcount 2->1 and GPIO18 would
// still be driven LOW (rail ON) when the CPU halts.
#ifdef DISPLAY_CLASS
display.turnOff();
#endif
// The LR1110 hangs off the always-on 3V3 rail, NOT the GPIO18-gated one,
// and with no wake source that could ever answer a packet, RX through deep
// sleep is pure drain (the exact failure V4-R8's power-off path fixed) —
// put the radio to sleep and park NSS high. GPIO39 is beyond the S3's RTC
// pads (GPIO0-21), so the base class's rtc_gpio_hold_en would return
// ESP_ERR_INVALID_ARG here; the digital-pad hold (latched through the sleep
// by gpio_deep_sleep_hold_en below) is the S3 equivalent.
radio_driver.powerOff();
digitalWrite(P_LORA_NSS, HIGH);
gpio_hold_en((gpio_num_t)P_LORA_NSS);
// Tell the GPS to stop while its rail is still up (the rail cut below kills
// it regardless, but stop() parks the provider's state cleanly).
if (sensors.getLocationProvider() != NULL) {
sensors.getLocationProvider()->stop();
}
Serial.flush();
// Clear stale wake sources before arming the timer, so a leftover from
// PM/auto-light-sleep can't ghost-wake us — same guard as the base class.
esp_sleep_disable_wakeup_source(ESP_SLEEP_WAKEUP_ALL);
if (secs > 0) {
esp_sleep_enable_timer_wakeup(secs * 1000000ULL);
}
// Turn both active-LOW rails off. The backlight pad needs a re-route first:
// UITask's applyBrightness attached LEDC ch7 to GPIO17, and a bare
// digitalWrite doesn't take the pad back from the LEDC matrix signal (which
// is why RefCountedDigitalPin's release() alone can't darken it) — same
// pinMode dance as the V4-R8 power-off path.
pinMode(PIN_TFT_BL_EN, OUTPUT); // re-route from LEDC ch7 back to plain GPIO
backlight.release(); // refcount 1->0: drives HIGH = backlight off
periph_power.release(); // 1->0 now the display let go: rail off
// Latch the OFF levels through the sleep — the pads tristate the moment
// esp_deep_sleep_start() runs and both active-LOW gates would drift back
// on. GPIO17/18 ARE RTC pads, so their hold lives in the RTC domain and
// survives deep sleep on its own; NSS (held above) additionally needs the
// global digital-pad latch. All three are released again in begin().
rtc_gpio_hold_en((gpio_num_t)PIN_TFT_BL_EN);
rtc_gpio_hold_en((gpio_num_t)PIN_PERIPH_POWER);
gpio_deep_sleep_hold_en();
esp_deep_sleep_start(); // CPU halts here and never returns
}
// secs=0 -> no timer, and no GPIO wake exists on this board: deep sleep with
// no wake source, i.e. genuinely off until the physical power slider cycles.
// (The touch UI's Power menu hides its Power-off row on M9 for this reason;
// nothing in the companion build calls this — it exists as the board API.)
void ThinkNodeM9Board::powerOff() { enterDeepSleep(0); }
uint16_t ThinkNodeM9Board::getBattMilliVolts() {
#if defined(PIN_VBAT_READ)
analogReadResolution(12);
// GPIO13 is ADC2_CH2 on the S3 (not ADC1 — the port doc's table was wrong),
// and ADC2 is arbitrated against Wi-Fi: a sample taken while Wi-Fi owns the
// unit times out and the HAL returns raw 0, which the calibration converts
// to a small offset-mV value. Unfiltered, those samples dragged the average
// toward 0 whenever Wi-Fi was busy (status bar snapping to 0%, garbage in
// the battery log). Filter per-sample — half of 8 good samples still
// averages fine — and hold the last good reading when every sample failed.
static uint16_t s_last_good_mv = 0;
uint32_t sum = 0;
int n = 0;
const int kSamples = 8;
for (int i = 0; i < kSamples; i++) {
const uint32_t mv = analogReadMilliVolts(PIN_VBAT_READ);
if (mv >= 1250) { sum += mv; n++; } // < 2.5 V at the pack through the 2:1 divider is impossible — ADC2-blocked read
}
if (n == 0) return s_last_good_mv;
s_last_good_mv = (uint16_t)(2 * (sum / n));
return s_last_good_mv;
#else
return 0;
#endif
}