Files
wadamesh/variants/thinknode_m9/M9_PORT.md
T
Michael A. Cojocari 420e13cafb Improve M9 message actions and unlock gesture (#402)
Open the selected message action menu with a normal Enter press. Replace the lock-screen long wait with a d-pad-center double press while preserving directional behavior and the controller long-press fallback.

Signed-off-by: Michael A. Cojocari <michael.cojocari@gmail.com>
2026-09-04 14:00:17 -04:00

84 KiB
Raw Blame History

Heltec ThinkNode M9 — wadamesh port

Fresh port against main, built alongside (not replacing) the Heltec V4 TFT and LilyGo T-Deck envs. Env name: ThinkNode_M9_companion_radio_touch.

Hardware summary

ESP32-S3R8, LR1110 radio, 2.4" 240x320 ST7789 TFT (manufacturer-confirmed — an earlier attempt assumed ILI9341 based on a Meshtastic boot log that, per the manufacturer, was wrong), full QWERTY keyboard + d-pad + dedicated function buttons all on one I2C keyboard controller, no touchscreen. CC1167Q GPS, QMI8658 IMU, QMC6309 compass, PCF8563 RTC, 8 MB PSRAM, 16 MB flash.

Why this needed its own radio_init(), not std_init()

CustomLR1110 (the core's LR1110 wrapper) has no std_init() — unlike CustomSX1262/SX1268/LLCC68. Every existing LR1110 board in MeshCore (thinknode_m3, minewsemi_me25ls01) drives the radio by hand: SPI.setPins() → SPI.begin() → radio.begin(freq, bw, sf, cr, sync_word, power, preamble, tcxo) → setCRC()/explicitHeader() → optional RF-switch table / boosted-gain. variants/thinknode_m9/target.cpp::radio_init() follows that pattern exactly (see MeshCore variants/thinknode_m3/target.cpp as the reference it's modelled on).

The radio, the ST7789 panel, and the microSD slot all share one physical SPI bus. Neither LILYGO_TDECK nor HELTEC_LORA_V4_TFT is defined for this board, so ST7789LCDDisplay takes its default constructor branch (display(&SPI, ...)) — meaning the display uses the same global SPI instance as the radio, matching how the LR1110 reference boards do it (no separate HSPI/local SPIClass the way the T-Deck/Heltec-TFT branch does).

Verified pin map

(Originally cross-referenced from a Meshtastic thinknode_m9 boot log against the V1.0 schematic during early bring-up; several boot-log-derived entries later turned out wrong on direct schematic inspection — microSD interface type and the battery divider ratio both being corrected below are examples. Treat this table as schematic-verified, not Meshtastic-derived, going forward.)

Net GPIO Notes
LR1110 NSS 39
LR1110 IRQ (DIO1) 42 also getIRQGpio() / sleep wake source
LR1110 RESET 45
LR1110 BUSY 41
SPI SCLK 40 shared: radio + LCD + microSD
SPI MISO 38
SPI MOSI 47
LCD RESET 14
LCD DC (RS) 15 repurposed XTAL_32K_P
LCD CS 16 repurposed XTAL_32K_N
Backlight (BL_EN) 17 PNP transistor, active-LOW
Peripheral power rail 18 P-MOS, active-LOW (LCD/GPS/sensors)
microSD 48 (CS) Shared LoRa SPI bus (Arduino SD, same as T-Deck) — CS is GPIO48, schematic net SPICLK_N. NOT one of the GPIO33-37 octal-PSRAM lines; SPICLK_N/GPIO48 (and SPICLK_P/GPIO47) are only reserved on the R8V/R16V 1.8V-differential-clock variants — this board is a plain R8 (see Hardware summary), which uses the ordinary single-ended SPICLK instead, so GPIO48 is genuinely free. The earlier "CS=36 is octal-PSRAM-reserved" claim conflated physical package pin 36 with GPIO36 — they are not the same pin. Confirmed working on hardware.
Peripheral I2C SDA/SCL 7 / 6 RTC PCF8563 0x51, IMU 0x6b, compass 0x7c. The RTC is driven by src/helpers/HardwareRtcClock.*, NOT by the core's AutoDiscoverRTCClock probe (issue #383): that path read the calendar registers without ever consulting the chip's VL bit, so after a hard shutdown the stale contents came back as real wall time and the clock looked frozen at the moment the slider was pulled. The adapter refuses an integrity-lost read, clears a latched STOP bit, and reads back every write
Keyboard I2C SDA/SCL 20 / 21 controller @ 0x6c, own bus (Wire1)
Battery ADC 13 ADC2_CH2 (S3: ADC1=GPIO1-10, ADC2=GPIO11-20 — earlier "ADC1_CH2" label was wrong; ADC2 is Wi-Fi-arbitrated, see getBattMilliVolts's per-sample filter), 2:1 divider (manufacturer-confirmed)
GPS RX/TX 2 / 3 CC1167Q, UART1
GPS EN / ON_OFF / RST / 1PPS 11 / 10 / 5 / 4 EN+RST wired, both active-LOW (confirmed on schematic: same P-MOS circuit as the peripheral power rail for EN; GPS_RST1 -> R46 -> NPN Q16 base, GPIO HIGH turns Q16 on and pulls the module's reset line to GND, so HIGH asserts reset / LOW releases it — inverted vs. the library defaults, both now overridden in platformio.ini). ON_OFF + 1PPS unused
Buzzer 9 wired — simple GPIO piezo (THINKNODE_M9_BUZZER_PIN), Arduino tone()/noTone(), shares the Heltec V4 buzzer code path
ESP_WAKEUP (from KB MCU) 12 not wired; behaviour undocumented
KEY_LED 46 not wired
User button (BOOT) — does not exist (Deferred #6, schematic-confirmed): only a power-cut slider and a reset button, neither a GPIO. PIN_USER_BTN removed from the env — the old GPIO0 entry here contradicted Deferred #6

This turn's corrections vs. the earlier (incorrect) attempt:

  • Display is ST7789, not ILI9341.
  • LR1110 clock: TCXO mode at 3.3 V (bring-up build #2). Empirically settled on the tester's preproduction unit: Meshtastic's variant logs "using DIO3 as TCXO reference voltage at 3.300000 V" + "LR1110 init result 0" on this exact device, and our build #1 with tcxo=0 (the earlier active-oscillator theory) failed radio init with -707: the chip boots on internal RC (SPI alive) but the first command needing the 32 MHz clock is rejected. The schematic's "VTCXO rail" powering Y1 is evidently the LR1110's own TCXO-supply output.
  • Battery divider is 2:1, not 1:1 — equal-value resistors still halve the voltage regardless of their absolute value; getBattMilliVolts()'s 2 * multiplier was wrongly removed on the mistaken belief that "equal resistors" meant "no division." Restored.
  • GPS EN and RESET are both active-LOW, not the library's active-HIGH defaults — confirmed on schematic (EN is the same P-MOS circuit as the peripheral power rail; RESET is GPS_RST1 -> R46 -> NPN Q16, where GPIO HIGH asserts reset). Both PIN_GPS_EN_ACTIVE and PIN_GPS_RESET_ACTIVE are now overridden in platformio.ini. Fixed a total loss of GPS fix (module was held disabled/in-reset on every boot).
  • microSD CS is GPIO48, not "deferred pending SDMMC pins" — this board never needed SD_MMC at all; the original SPI-CS approach was correct, the pin number was just wrong (see the pin table above and "Bonus schematic finds" below). Confirmed working on hardware.

Keyboard (confirmed on hardware — M9Keyboard.h)

Write reg 0x01 then read one byte (single latched key — see the register-addressed-slave section below; the old "one raw byte per read" note here was the exact protocol misunderstanding that made builds 1-7 see no keys). The controller resolves shift/symbol layers itself:

Key Raw Key Raw
left 0xB4 left_message 0x81
up 0xB5 home 0x82
down 0xB6 sub_message 0x83
right 0xB7 sub_map 0x84
d-pad centre / enter 0x0D map 0x85
del (backspace) 0x08 hw_back 0x86
mic (triangle) 0x88 ctrl 0x90

Backspace (0x08) and Enter (0x0D) happen to match the values UITask.cpp::handleHwKey() already special-cases for the T-Deck, so typing, backspace, and Enter-to-send all work as-is. The d-pad/function-key bytes (0x81–0x90, 0xB4–0xB7) currently fall through unhandled when no text field is focused — see "Deferred" below.

What's wired in this patch

  • boards/thinknode_m9.json — ESP32-S3R8, 16 MB flash, 8 MB PSRAM.
  • variants/thinknode_m9/M9Board.{h,cpp} — board class: both active-low power rails (periph rail + backlight) via RefCountedDigitalPin, 2:1 battery divider. Deep sleep is TIMER-ONLY: the original "wake on LR1110 IRQ (GPIO42)" was electrically impossible (S3 RTC pads are GPIO0-21 — every rtc_gpio_*/ext1 call on 42/39 failed with ESP_ERR_INVALID_ARG, unchecked), so those calls were removed. The only RTC-capable wake candidate is ESP_WAKEUP GPIO12 from the keyboard MCU — needs hardware characterization (Deferred #6).
  • variants/thinknode_m9/target.{h,cpp} — manual LR1110 radio_init(), GPS/RTC/sensor wiring, ST7789 display instantiation, m9SharedSPI() (mirrors the T-Deck's tdeckSharedSPI() — used by the SD mount code below).
  • variants/thinknode_m9/M9Keyboard.{h,cpp} — Wire1 keyboard driver, ring buffer, hardware-confirmed keycode sentinels (M9_KEY_*).
  • variants/thinknode_m9/partitions_m9_touch.csv — 16 MB, dual A/B OTA slots (copy of the T-Deck's layout; same flash size).
  • platformio.ini — new [env:ThinkNode_M9_companion_radio_touch].
  • src/ui-touch/device_caps.h — new HAS_THINKNODE_M9 capability block; CAP_KEYPAD_NAV/CAP_SD/CAP_FILESYSTEM all 1 (see below).
  • src/ui-touch/UITask.cpp:
    • HAS_M9_KEYBOARD added alongside HAS_TDECK_KEYBOARD at every generic "is there a physical keyboard" gate (composer auto-focus, Enter-sends toggle, spacebar-lock, secondary-keyboard cycling hint, etc. — 15 sites), plus its own poll/drain branch in the per-tick loop (polls Wire1 directly from the UI thread — no core-0 hand-off needed, since this bus has no other device on it, unlike the T-Deck's touch+keyboard-shared bus).
    • microSD, extended from the T-Deck's existing pattern: the include block, fmIsSd(), the mount/format helpers (fmSdTryMount/fmSdDoFormat/ etc.), and the Files-manager settings row are all #if defined(HAS_TDECK_GT911) || defined(HAS_THINKNODE_M9), swapping tdeckSharedSPI() for m9SharedSPI() where the bus accessor is used. CAP_SD is 1. CS is GPIO48 (see pin table). Confirmed mounting, browsing, and reading on hardware. NOT yet extended: the wallpaper picker (in progress — its own implementation is fully board-agnostic already, just needs its enclosing guard split from the genuinely-T-Deck-only notification-sound chooser it currently shares a block with) and the notification-sound chooser itself (needs tdeckPlayNotifySlot(), T-Deck's I2S amp — M9 has a buzzer instead, real separate task).
    • D-pad keypad navigation, built on the SAME generic engine Tanmatsu and the T-Deck already share (navFifo, navMoveDir/navSwitchTab/ navPushTap, the focus group, the secondary LVGL KEYPAD indev) — CAP_KEYPAD_NAV now also covers HAS_THINKNODE_M9, the secondary-indev registration and s_kbd_nav-always-on logic were broadened from CAP_TRACKBALL-only, and a new #elif defined(HAS_M9_KEYBOARD) block in handleHwKey() (parallel to the T-Deck's WASDZ-letter block) maps the M9's fixed hardware d-pad/function-key bytes straight to those same primitives instead of going through the programmable letter table. UP/DOWN/LEFT/RIGHT move focus or pan the tab bar, the d-pad centre/Enter selects, the dedicated HW-back key backs out, and HOME/MAP/MESSAGE jump to those tabs. MIC and CTRL have no action bound yet.
  • Keypad nav corrected for cases the initial patch missed: the wizard was unreachable by d-pad at all (handleHwKey()'s touch-only setup-root swallow ran before M9's key handling); Enter on a focused button after leaving a field via arrows silently no-op'd (stale on-screen-keyboard binding used instead of the live group focus); there was no way to leave an edit field via the d-pad; HOME didn't close overlays before jumping tabs; and there was no wake-from-idle path (M9 has no touch to wake it the way T-Deck/Heltec V4 do). All fixed in UITask.cpp — see git history for specifics.
  • Backlight control (touchScreenBacklight()) had no M9 branch at all — the idle-off state tracked correctly but the physical backlight never dimmed. GPIO17 (BL_EN, PNP transistor) supports real PWM dimming despite being a simple digital-looking enable line — confirmed on hardware via LEDC, with inverted duty (PNP: lower duty on the base = more conduction = brighter). 5 kHz confirmed clean. An earlier, incorrect on/off-only implementation (m9SetBacklight(), ref-counted RefCountedDigitalPin) was replaced with applyBrightness()/LEDC, matching the existing HAS_BACKLIGHT_PWM pattern.
  • Buzzer (GPIO9) wired via the existing HELTEC_V4_BUZZER_PIN code path, widened to also accept THINKNODE_M9_BUZZER_PIN (same mechanism — Arduino tone()/noTone(), no separate enable line; BUZZER_EN is just GPIO9's net name, not a second pin). Confirmed working via the Settings > Sound toggle previews.
  • Commander (Home tab) landscape layout — the TX/RX chart width and the 5-button right-hand column's height-per-button math (Advert/Terminal/Files/ Apps/Control) both had sizing gates that didn't include HAS_THINKNODE_M9, so the chart drew full-width over the buttons and the button-count math assumed 4 slots when M9 (like T-Deck) actually renders 5 — pushing the last button off the bottom of the screen. Both gates fixed.
  • Home-tab drawer toggle self-conflict: M9_KEY_HOME's own "close everything on top" dismiss loop was closing the drawer itself (once added to the popup registry), then immediately re-reading the now-mutated s_home_drawer_mode flag and reopening it in the same keypress. Fixed by snapshotting the flag before the dismiss loop runs. Also fixed: HOME stopping early after dismissing an overlay reached via the Settings tab (rather than the Home-tab drawer) instead of still jumping to Home afterward.
  • Scroll-into-view during keypad nav used lv_obj_scroll_to_view() (checks only the immediate parent) instead of lv_obj_scroll_to_view_recursive() (walks every ancestor) — settings pages using the "grouped card" layout (createSettingsModal) nest controls 3+ levels below the actual scrollable container, so focus moving off-screen there never scrolled. Fixed; shared code, benefits every board.
  • Textarea focus highlight used the plain reverse-video fill instead of the bright outline+glow switches/sliders get, making it very hard to see which field was focused before entering edit mode. Added lv_textarea_class to that style branch. Shared code.
  • Dropdown-list keypad navigation (navOpenDropdown()) was never wired into the arrow-key dispatch on any board (Tanmatsu's navArrowAction, T-Deck's CAP_TRACKBALL block, or M9's m9HandleArrowKey/m9HandleNavKey) — UP/DOWN/ Enter/Back always fell through to page-level navMoveDir/popup-dismiss instead of moving the dropdown's own highlighted option. Added the missing checks to M9's handlers specifically (mirroring Tanmatsu's already-correct pattern for Enter/Back, which Tanmatsu never actually needed for UP/DOWN since it wasn't gapped there the same way). Still not fully working on hardware — traced the entire mechanism (group-focus detection, FIFO push, indev group assignment, LVGL's own dropdown LV_EVENT_KEY handler, loop ordering relative to lv_timer_handler()) and confirmed our dispatch code is correct up to and including the navPushTap(LV_KEY_DOWN) call itself — the dropdown still closes and focus moves to the next page element. Root cause not yet found; see Deferred list.

Audit pass (2026-08-19) — Back key restored + feature/perf/data fixes

A full audit traced every M9 input path and closed the following (all compile-verified; on-device validation still wanted for the UX items):

  • HW Back (0x86) restored in nav mode. Commit 0ea242c accidentally REPLACED case M9_KEY_HW_BACK: in m9HandleNavKey with the new case M9_KEY_ENTER_LONG: — since then Back only worked inside text-edit mode. Restored with a fuller ladder than the ad98f66 original: open dropdown list (ESC) → popup registry (incl. contacts select-mode) → full-screen app page (s_apppage_close: Lua apps, Snake, Web) → open chat → LV_KEY_ESC. This plus the registry-gate fixes below fully explains the old "some modals close via Back, some don't".
  • Lua-app hard trap fixed. isDismissKey() had no M9 arm (returned false for everything), so any Lua app consumed Back/Home with no touch fallback — the only escape was the power slider. M9 arm added (Back + Home are always firmware keys); HOME also closes app pages now. Keys are additionally kept from apps while the screen is locked/dark, so ENTER_LONG (the board's only unlock) can't be eaten by an app after auto-lock.
  • Popup-registry gates widened to M9: Files-manager overlays (image viewer, editor, prompts, actions, format overlay), Terminal command picker, fullscreen Files/Terminal view, wallpaper picker, and drawerPopupOpen's fullscreen-view term. These compiled and ran on M9 but were invisible to Back/Home and anyPopupOpen().
  • Dropdown keypad nav fixed: no navOpenDropdown() capture existed in any M9 key path (the doc's earlier claim it was added never matched a commit) — arrows now FIFO LV_KEY_UP/DOWN into the open list instead of navMoveDir defocusing (and thereby closing) it; Back sends ESC to the list first.
  • Textarea 3-4-press focus escape: edit-mode LEFT/RIGHT now fall through to navMoveDir when the caret is already at the text boundary.
  • Modal "breaks out" mitigation: the M9 drain now runs navMaybeRebuild() BEFORE dispatching keys (they used to dispatch against a one-tick-stale focus mirror), and closeSettingsModal() gained the idiomatic navDetachBeforeTreeMutation()/navMarkDirty() pair.
  • Lua apps get the d-pad: arrows → luaAppSteer (swipe events, T-Deck trackball parity), d-pad centre → synthetic centre tap (luaAppPress, new)
    • ev.key=="enter" (sendKey now maps '\r'). Snake and other store apps are playable, including "tap to retry".
  • New key bindings: GPS_LONG 0x87 → toggleGPS() + alert (matches the CC chip; NB if hardware shows the latched slot also emitting the 0x84 tap first, the Advert page will open too — needs on-device check); CTRL 0x90 → Control Center. MIC 0x88 and 0x89 deliberately left unbound (documented in M9Keyboard.h).
  • Keyboard backlight wired: m9KeyboardSetBacklight() (controller reg 0x02) is now driven from the drain-loop tick — off/on/auto via the CC "Keyboard" chip, write-on-change only. The old drain comment claiming "no backlight control exists" was wrong. (Audit pass 2 hardened this: the setter reports success and the cache only latches written-and-ACKed duties — see below.)
  • Message flash/wake wired: "Flash on new message" was a dead switch on M9 (producer/consumer were T-Deck-only). M9 now wakes (or lock-reveals) on message and pulses the keyboard light; the 10 s notify re-dim applies.
  • Terminal/editor Enter: the Enter-submit/newline gate excluded M9 — widened, so Enter runs the terminal command / inserts editor newlines.
  • "Store data on SD" honored: the boot data-store path in main.cpp excluded M9, so the (shown, persisted) toggle silently did nothing — extended, with m9SharedSPI() arms in both selector chains, plus the truth label, recovery-copy button and migration machinery in Settings.
  • SD-backed chat history: uiDataFsReady() had no M9 arm (fell into the V4 no-SD SPIFFS branch) — added the T-Deck-shaped SD arm plus the SD-history hardening gates (write-failure tell, remount re-flush).
  • Threads-index write made atomic (shared fix): saveThreadsToStorage() rewrote in place ("w"); a power-cut mid-write got the file quarantine-deleted at boot (chat list/unread/DM entries lost — the bulk of Deferred #9's symptom). Now tmp+rename via uiDataReplaceFile, orphan tmp swept at load.
  • Power menu: the "Power off" row is hidden on M9 — it armed ext0 wake on a user button this board doesn't have, leaving the device unrecoverable until a slider cycle. The slider IS the power-off. Reboot/Download/Cancel remain. PIN_USER_BTN removed from the env (no button exists; GPIO0 was a floating strapping pin being polled by the screen-lock branch).
  • "Save update bin to SD" (About) widened to M9 (OTA_BIN_NAME arm already existed).
  • Battery reads: GPIO13 is ADC2 (Wi-Fi-arbitrated) — getBattMilliVolts() now discards ADC2-blocked samples (< 1250 mV at the pin) per-sample and holds the last good reading. The battery log now follows the resolved ui-data backend instead of bare card presence (Deferred #8's battLogOnSd fix landed earlier in 4846d4f; this closes the "any card with /meshcomod hijacks the log" residue).
  • Perf: keyboard I2C poll throttled to 15 ms (was a blocking ~0.5 ms 100 kHz transaction EVERY free-running loop iteration — hundreds/s), with a bounded 3-read drain per poll so a second key struck inside the window isn't lost; SD CS (GPIO48) parked HIGH at boot (was floating across 80 MHz shared- bus traffic until the first lazy mount); RADIOLIB_DEBUG_BASIC bring-up flag dropped per its own note.

Follow-up fixes from the third on-device test round (same day) — "panning doesn't reload tiles": a multi-agent trace confirmed four contributors and ruled out the tile-slot pool, the tab-bar removal, and the key-drain timing:

  • Tile cache now prefers the BUILT-IN 16 GB microSD (every M9 has one). It used to cache into the 4.75 MB "tiles" partition, which fills after a few panned screens and has NO eviction — every later download then failed forever. Cache goes to SD /tiles (Launcher-T-Deck layout, merges with packs); the partition remains the fallback if the card wedges.
  • Auto-follow paused during pan mode: mapAutoFollowTick recenters on the CENTER-vs-fix delta, so the pan itself tripped it (the old mapNudge comment claiming "recenters on the next GPS move" was wrong) — with follow on, every nudge snapped back within one 250 ms tick. Follow resumes when pan exits.
  • Two self-heal repaints (M9-gated): (1) the fetch worker's already-on-disk skip now arms the rate-capped repaint for visible-zoom tiles (a tile whose read was transiently blocked stayed blank "one pan behind"); (2) clearing an SD fail-note (which blanks ALL tile reads for up to ~5 s per stamp) now arms the repaint too when the last render had gaps.
  • Offline UX: entering pan mode and hitting uncached areas with Wi-Fi off shows a one-per-session alert — the fetch queue silently no-ops offline and only a small corner label said why.
  • Still-relevant user guidance: a PNG tile pack on the card (/maps/osm or /tiles) is only read when Map options → "Tiles from SD card" is ON and the style is OSM; the toggle re-points and re-renders immediately.

Follow-up fixes from the second on-device test round (same day):

  • Back inside apps looked dead: apps launch from the app DRAWER, which stays open BENEATH the full-screen app page by design (closing the app returns you to it) — and the drawer is a PF_COUNT popup-registry row, so the restored Back ladder's anyPopupOpen() rung dismissed the invisible drawer under the app instead of the app itself (Home worked because its handler closes the app page first). The Back ladder now closes an open app page before the registry dismiss, EXCEPT when the Control Center or power menu is open (the only popups a key can open OVER an app page on this board — CTRL falls through to openControlCenter() in display-only apps).
  • Map pan mode added (see the resolved keypad-nav item above): Map key on the Map tab toggles arrows between focus-nav and mapNudge() panning.
  • The Chats/Map jump keys now close an open app page before switching tabs — the jump used to land invisibly beneath the still-covering page.

Follow-up fixes from the first on-device test round (same day):

  • D-pad in display-only Lua apps (Airtime, RF Monitor): these apps declare no on_input, but luaAppKey() consumed every key anyway — the d-pad was dead inside them. Keys are now forwarded to a Lua app only when it actually declares on_input (new luaAppHasOnInput()); otherwise the d-pad keeps its native meaning: arrows move focus between the app page's own LVGL widgets (Airtime's Reset button), Enter clicks them, and when focus has nowhere to go UP/DOWN page-scroll the app body (new luaAppScroll() — RF Monitor's feed).
  • Bottom tab bar removed on M9 (user request): it was tap-only chrome — navMaybeRebuild deliberately never adds it to the focus group, so on a touchless board it was uncontrollable dead space. TABBAR_H is now 0 for M9 and the btnmatrix is hidden; every tab remains reachable via the dedicated HOME/MESSAGE/MAP keys and the app drawer's Chats/Contacts/Map/Settings tiles. Content gains the 30 px row. The M9 draws no notification badges there; unread messages and newly discovered contacts instead blink small accent-colour envelope/person glyphs in the former Chats and Contacts nav slots.
  • Spectrum sweep crawl fixed (LR1110-specific): LR11x0::getRSSI(false) is not the SX126x's cheap register read — it internally re-arms RX and drops to standby around EVERY call, so the 6-read peak-hold per bin paid ~960 extra arm/disarm cycles per 160-bin sweep (each a multi-command SPI exchange with BUSY waits) and sampled the unsettled post-arm RSSI default rather than live channel power. The M9 sweep now holds RX through the dwell (getRSSI(false, /*skipReceive=*/true) + one explicit standby() per bin) and skips the pointless image recalibration the 24 MHz wrap-around jump triggered every sweep (setFrequency(f, /*skipCalibration=*/true)).

Audit pass 2 (2026-08-20) — second full sweep after the 08-19 fixes

A second 7-dimension multi-agent audit (each dimension adversarially verified: 30 findings, 29 confirmed, 1 refuted) over the tree WITH the 08-19 pass applied. All 29 fixed, compile-verified. On-device retest list at the end.

Key-trap / soft-lock class (all in UITask.cpp's M9 handlers):

  • SUB_MAP over a display-only Lua app orphaned the app (the worst find): 0x84 opened the Advert page without closing the app page, the Advert page's close stole the single s_apppage_close slot, and closing it nulled the hook — the Lua app underneath became unreachable by ANY key (reboot/slider only). SUB_MAP and SUB_MESSAGE now close an open app page first, like the LEFT_MESSAGE/MAP jump keys always did.
  • Back-ladder z-order redesign. The old ladder's app-page rung assumed "CC and power menu are the only popups that can stack over an app page" — false for the Lua send-permission confirm (which the app itself raises) and the registry walk closed the FIRST open row in declaration order, so Back with the CC open over e.g. a Files rename prompt silently discarded the prompt UNDERNEATH. New invariant: power menu and CC are always-frontmost rungs (power before CC — CTRL peels an open power menu before opening the CC so it holds in both stacking directions), the app-page rung defers to an open confirm modal, and SUB_MESSAGE/SUB_MAP run HOME's bounded dismiss-everything loop before opening their own overlay (refusing to stack over a null-close progress blocker) so nothing is ever left open beneath. HOME peels power/CC/confirm the same way. Arrows/Enter are also no longer forwarded to an on_input Lua app while a confirm modal is up — the send-permission dialog used to be UNANSWERABLE on M9 (keys went to the app; Back/Home killed the app under the dialog).
  • Map pan-mode flag could go stale: no popup guard (arrows panned the map hidden under the CC) and no tab-jump clear (HOME with pan on left the flag armed — the next Back anywhere was eaten by "Map pan off", and re-entering the map via the drawer tile arrived still panning). Pan now self-clears on any popup or tab leave; a stale flag is cleared silently, the toast only fires for a genuine pan exit.
  • Null-closer "blocker" popup rows now actually block: popupRegistryDismissTop() used to SKIP rows without a closer and dismiss whatever sat beneath the progress overlay (Back during bulk-delete exited select mode mid-operation; Back during SD format tore down the fullscreen Files view the format returns to). The walker now stops at the first open row; null-close rows refuse the dismiss. Shared fix (T-Deck had the same hole). Behavior note: a tab jump during a progress overlay now leaves the overlay up — intended blocker semantics.

Gate parity (compiled on M9 but wired only for the T-Deck):

  • Terminal RX mirror — incoming mesh traffic never appeared in the M9's terminal chat mode (TX echo only). Gate widened. (TLORA_PAGER also compiles the terminal and still lacks the mirror — upstream follow-up, not M9's.)
  • Accent + @-mention pickers popped up while typing but were UNPICKABLE (touch-only cells; the key-nav selection machinery is pager-only) — pure dead chrome on a touchless board, and the mention box could linger after leaving edit mode. Deliberate call: SUPPRESSED on M9 (auto-popups gated off, dead settings row compiled out, mentionBoxHide() added to Back's edit-mode branch). Porting the pager's key-nav selection is possible future work if accents are ever wanted on this keyboard.
  • Fullscreen Terminal/Files title (status-bar borrow), wallpaper-set caption refresh in the Device modal, and the map storage-error message (M9 now gets the SD guidance, not "reflash the tiles partition") — all widened.
  • Dead HAS_M9_KEYBOARD alternative removed from a CAP_TRACKBALL-only settings block (M9 nav is force-on at boot and must never grow an off-switch).

Keyboard backlight cache: static uint8_t s_kb_bl_last = 0xFF was meant as a never-written sentinel — but M9 duties are only 0/255 and 255 == 0xFF, so mode "On" restored from prefs skipped the first write EVERY BOOT (stuck on the controller's keypress-auto default until the first dim/wake cycle). The cache also latched duties whose I2C writes were dropped (controller still booting, NACK). m9KeyboardSetBacklight() now returns success (false on no bus / NACK), the cache is an int(-1) updated only on success — dropped writes retry until ACKed, at a 250 ms cadence so a found-then-wedged bus never pays an I2C transaction per free-running loop pass.

Board API (latent — nothing calls powerOff/enterDeepSleep on M9 today, but it shipped broken): enterDeepSleep()'s single rail release left GPIO18 driven ON (refcount was 2: board + display — display.turnOff() now drops the display's claim first), its backlight digitalWrite was inert (LEDC ch7 owned the pad since UI boot — pinMode() re-route first, V4-R8 idiom), and no hold meant even correct levels were lost when pads tristate in sleep (rtc_gpio_hold_en on 17/18, gpio_hold_en + gpio_deep_sleep_hold_en for non-RTC NSS GPIO39; unconditional hold release at the top of begin() — RTC-domain holds survive the RST button). It also now mirrors the base-class sequence it was hiding: radio powerOff() + NSS parked HIGH, GPS provider stop, Serial.flush(), stale wake sources cleared — previously a "powered off" M9 kept the LR1110 in RX (mA-scale drain with no wake source that could ever use it) until the slider cut the battery.

Radio/build hardening: the TCXO fallback in radio_init() still encoded the DISPROVEN tcxo=0 theory with a comment saying it "must stay 0" (a trap: the build only worked because platformio.ini defines the macro) — fallback is now 3.3f with the disproof recorded. patch_radiolib_lr11x0.py fail-closes: pattern drift with RadioLib present is a hard build error, and a pre-link check verifies the patch marker after libdeps exist (a fresh checkout's first pio run fails at link with a "patched — re-run" message rather than shipping an unpatched -706 binary; the second run builds clean). M9 env gained ENV_SKIP_GPS_DETECT=1 (the cold-booting CC1167Q could miss the 1 s NMEA probe → gps_detected false all session → GPS toggle + GPS_LONG key dead; every sibling soldered-GPS env already set it) and CORE_DEBUG_LEVEL=0 (ARDUHAL [E] spam on UART0, which doubles as the companion frame stream). Partition-CSV headroom prose refreshed (~2.99 MB firmware, ~0.88 MB headroom per slot). Refuted by the verifier, deliberately NOT applied: RADIOLIB_EXCLUDE_SX126X.

On-device retest list for this pass: Airtime/RF-Monitor + SUB_MAP then Back (no orphan); CTRL over a Files rename prompt then Back (CC closes, prompt survives); send-permission dialog from a store app (arrows/Enter answer it); map pan → HOME → Back elsewhere (no eaten press); "Keyboard light: On" applied immediately at boot; GPS working from a cold boot without toggling; terminal chat shows the peer's replies; Back during an SD format does nothing.

Still open / needs hardware (designed but deliberately NOT coded blind): ST7789 SLPIN/DISPOFF panel sleep on screen-off (shared-bus variant of the T-Deck's anti-burn-in path — a wrong sequence would look like a dead display); raising the SD operating clock above 4 MHz; deferring hist-flush during active input; Wire1 at 400 kHz; charging-detection (batteryIsCharging is compile-time false on M9); GPIO12 ESP_WAKEUP characterization for a real Power-off wake.

Compass (QMC6309) + GPS motion for Lua apps (2026-08-22)

First use of the magnetometer. The chip was documented (peripheral bus, 0x7C) but nothing ever talked to it; the QMI8658 IMU still has no driver.

  • Driver: M9Compass.{h,cpp} (HAS_M9_COMPASS=1 in the env). Probe = chip id 0x00 == 0x90; soft reset (0x0B=0x80 then the mandatory 0x0B=0x00 — the bit is not self-clearing); CTRL2 0x0B=0x30 (ODR 100 Hz, ±32 G, set/reset on), CTRL1 0x0A=0x41 (normal mode, OSR1 8, low-pass 4 — the datasheet's 0x61 example is low-pass 8 at 50 Hz, which read as sluggish on the dial), both read back and re-written once if they did not stick. Read path: status 0x09 (bit0 DRDY, cleared by the read; bit1 OVFL → sample kept but flagged, logged at most every 10 s), then 6 bytes from 0x01 little-endian int16, ×1/1000 → Gauss (±32 G chosen over ±8 G because of the bias magnitude question below; 1 mG/count is still ≈0.13° of heading). Synchronous on the UI thread from luaHostCompass() (three short transactions at 100 kHz, no poll hook), cached sample valid 1 s, re-probe every 2 s while absent (rail-powered part may still be in POR when radio_init() runs), eight consecutive bus errors → forget and re-probe. Boot log: M9 compass: QMC6309 ok (id=0x90, 100 Hz, +/-32 G, OSR 8, LPF 4) or the reason it is not. Register layout cross-checked against the Rev A datasheet and the SlimeVR/madflight/Tildagon drivers — NOT SensorLib, whose setOutputDataRate() writes the ODR into 0x0A (the OSR bits); Meshtastic inherits that bug and only works because it runs continuous mode.
  • Exposure: CAP_COMPASS (device_caps.h, hardware gate, caps().compass) → wada.sys.compass() = {x, y, z, ovfl} Gauss, sensor frame, uncalibrated. No heading on purpose: see the two unknowns below.
  • GPS motion: WadaNmeaLocationProvider (src/helpers/, HAS_GPS_MOTION=1) replaces the core MicroNMEALocationProvider in target.cpp — a line-for- line copy that also exposes RMC speed/course (the core keeps its parser private; patching libdeps is the build-fragile route this repo avoids). wadaGpsMotion() feeds wada.sys.gps().speed_kmh/course; course is absent under 1 km/h because MicroNMEA parses an empty course field as 0 (= north). gps() also returns nil while the GPS toggle is off.
  • App: deploy/apps/gpscompass/1.0 (Store catalog entry added; not baked into lua_builtin.h — CAP_BUILTIN_LUA_APPS also removes the Store > Apps tab). Keys: C start/finish calibration (auto-finishes after 20 s), O rotate the sensor frame 90°, F mirror it, X clear calibration, d-pad left/right or OK = cycle the target contact. Offsets/orientation persist in the app's KV store.

Both unknowns are now MEASURED (2026-08-22), not guessed. Held flat, logging the raw vector (M9_COMPASS_DEBUG in M9Compass.cpp) at four headings 90° apart:

heading raw x raw y raw z x−ox y−oy
N −0.395 −3.068 −2.768 −0.055 +0.310
E −0.072 −3.396 −2.704 +0.268 −0.018
S −0.327 −3.653 −2.752 +0.013 −0.275
W −0.565 −3.394 −2.754 −0.225 −0.016
  1. Axis orientation. Hard-iron centre (ox, oy) = (−0.340, −3.378); atan2(x−ox, y−oy) then reads 350° / 94° / 177° / 266° at N/E/S/W — 0/90/180/270 within a few degrees, counting UP clockwise. So +Y points at the device's top edge, +X to its left, and (right-handed) +Z into the screen. deploy/apps/gpscompass ships that as the default: correct after calibration alone, with no orientation press. Repeatability: returning to north landed within 0.066 G / 0.041 G of the first reading. NB the app's first auto-handedness rule had this INVERTED — it assumed a Z-out-of-screen sensor was the un-mirrored case — which is what made a correctly-defaulted device turn the wrong way. The dip test now reads: held flat, north of the magnetic equator, a Z-INTO-screen sensor sees the downward field as POSITIVE z.
  2. The hard-iron bias is real and large — about −0.34 G on X, −3.4 G on Y, −2.8 G on Z: ~7× Earth's field, which vindicates Meshtastic's otherwise implausible hardcoded extrema. The horizontal signal riding on it is only ~0.27 G, so an UNCALIBRATED M9 barely moves the dial — that is the expected symptom, not a fault. It also confirms the ±32 G range: at ±8 G the Y axis sits within half a scale of the rail before the user's own environment is added. Expect |B| ≈ 0.25–0.65 G once calibrated; "Field saturated" (OVFL, raw counts logged every 10 s) means a magnet is nearby.

Calibration must ROTATE THE DEVICE IN ONE PLACE. Carrying it around while turning it does not only rotate it, it also TRANSLATES it through the field of a laptop, a desk frame, anything ferrous — and that corrupts the fit. Measured consequence: a centre that moved 0.15 G between hand-tumbled sessions against a 0.26 G horizontal signal, i.e. tens of degrees of direction-dependent error, reported on hardware as "it drifts when rotating". The app now measures coverage from GRAVITY (the accelerometer, below) and refuses a sweep that never turned the device over, naming the axis.

IMU (QMI8658) + tilt compensation (2026-08-22)

variants/thinknode_m9/M9Imu.{h,cpp}, HAS_M9_IMU=1 → CAP_IMU → wada.sys.accel(). Accelerometer only — the gyro is most of the part's power budget and nothing here needs it. QMI8658 at 0x6B on the same peripheral bus; this board carries the A die (WHO_AM_I 0x05, REVISION_ID 0x7C). ±2 g at 62.5 Hz with the accel low-pass on, soft reset (0x60←0xB0) then a 160 ms wait covering both die variants and a 0x4D==0x80 check, same idle suspend as the compass.

CTRL1 bit6 (ADDR_AI) must be set and read back. With it clear the burst read from 0x35 silently returns six copies of one byte — a sensor that probes fine and reports nonsense.

Axes MEASURED (three attitudes, M9_IMU_DEBUG logging):

attitude reading conclusion
flat, screen up z = −1.02 +Z into the screen (down)
on bottom edge, top edge up x = +0.97 +X at the top edge (forward)
on left edge, right edge up y = +1.08 +Y at the right edge

So the IMU is already in the aerospace body frame (forward / right / down), and it agrees independently with the magnetometer's measured +Z-into-screen. The magnetometer maps into that frame as (fwd, right, down) = (my−oy, −(mx−ox), mz−oz).

Why tilt compensation was needed at all: the field dips ~60° here, so the vertical component is 1.6× the horizontal one and tipping the device leaks it into the pair the heading is made from — about 1.5° of heading per 1° of tilt. A hand-held reading wandered by tens of degrees. The app now rotates the field back into the horizontal plane using gravity (NXP AN4248 / ST AN3192) before taking the angle, shows the tilt angle, and says "too steep to read" past 55° instead of lying. Confirmed working on hardware.

Not copied from Meshtastic: its M9 driver passes both sensors through untransformed, carries a dead 180° constant, has its axis swaps commented out, and mirrors the heading on the sign of accel Z — so its compass flips when the device is turned over.

Hardware-verify recipe: flash; boot log shows the M9 compass: line; push the app over the console — the M9's card is soldered on, so scripts/sideload_app.py --port /dev/cu.wchusbserial10 --reboot deploy/apps/gpscompass/1.0 (the fput/fadd/fend CLI commands write into the Store's own /apps/ on the card; "Your own apps" lists it, the catalog copy arrives once deploy/apps is published); open the app; C, TUMBLING (not just spinning) the device through every orientation for 20 s; check |B|; set O/F against a known north; walk with GPS on and confirm Speed/Course populate above ~1 km/h; pick a contact and sanity-check the bearing against the map.

Serial-console notes learned doing this: the CH34x bridge resets the board whenever the port is opened (DTR/RTS, regardless of what pyserial asks), and the console is not serviced until [BOOT] ui ready; the UART interrupt is not IRAM-resident, so while the loop sits in a flash-cache pause only the 128-byte hardware FIFO buffers input and the middle of a longer line is lost — hence the sideload's short, self-checking lines. Done on 2026-08-22: M9 compass: QMC6309 ok on the first flash (0x7C answers, config sticks); app files pushed and listed by ls /apps; calibration + rotation confirmed working by Chris on the device the same night.

Two more M9 findings from that session: (1) the keypad-nav focus highlight painted the whole Lua app body white — the body is a clickable object on the top layer, so navCollect focused it and navFocusCb's reverse-video fill covered it (the canvas on top stayed dark). Fixed in the host with NAV_SKIP_FLAG on the body, the same exclusion the map's touch catcher uses. (2) Low-pass depth 8 at 50 Hz felt laggy; now 4 at 100 Hz.

Map re-open cost (2026-08-22) — measured and fixed

[STALL] ui:lvgl 2597ms on EVERY map open, not just the first: leaving the Map tab called freeMapTiles() (UITask.cpp, tab-change handler), so the next visit re-read and re-decoded all nine 256x256 JPEGs. Boards with >=4 MB PSRAM now leave the slots exactly as panning within the tab leaves them — the grid costs 9 x 128 KB = 1.15 MB, which only matters on the 2 MB V4 (already capped to a 4-tile pool by renderMapTiles), so that board keeps the old free.

Measured on the M9 after the change: cold open (first after boot) 2598 ms, every re-open 245 ms.

Gotchas for anyone revisiting this:

  • releaseMapTileSlot() is NOT a substitute: it clears in_use, so the next render treats the tile as absent and decodes it again. Keeping the slots fully intact is what makes renderMapTiles' pass-1 match-and-reposition hit.

  • The remaining cold-open cost is the SJPG decode, not the SD read. Raising the CPU to 240 MHz is already ruled out (RGB565 noise from the PSRAM bus, see onMapTabActivated). The open levers are decoding on core 0, or the M9's SD clock (still 4 MHz — see the deferred list below).

    SUPERSEDED by measurement, 2026-08-28. The decode-vs-read framing above is directionally right but was reasoning about the two SMALLER halves. Instrumented on hardware ([MAPPROF], audit pass 3 below), a 9-tile cold open split:

    total=2189ms probe=0ms render=2080ms
          [read=497ms  decode=539ms  other=1042ms  tiles=9]  markers=10ms
    

    other — the per-tile lv_refr_now() progressive paint — cost more than the SD reads and the JPEG decodes combined. On the immersive full-screen map each call composites the whole 240x320 panel and alpha-blends the transparent status + tab bars over it: ~115 ms a call, nine times. Throttling that to one paint per 350 ms (k_map_progressive_ms) is a bigger win than either lever named above, and it is board-independent. Measure before optimising here: the intuition that "it must be the decode" was wrong by a factor of two.

Audit pass 3 (2026-08-28) — cold map open + a real Back history

Third full sweep, prompted by two field reports: "the map doesn't load fast the first time" and "Back should go back to the previous navigation". Both were real; both had one identifiable cause. All eight PlatformIO envs compile-verified (the shared UITask.cpp is the blast radius); on-device validation still wanted.

Map, cold open. The 2026-08-22 pass above fixed the re-open cost and left cold open at 2598 ms, correctly attributing the bulk to the SJPG decode. What it did not catch is what runs in front of that decode:

  • bestAvailableZoom() was called unconditionally (onMapTabActivated) and its result discarded one line later whenever a zoom was persisted — which it is as soon as the user touches +/- once. It walks z19→z3 probing the card at each level, returning at the first hit. Now skipped when the answer cannot be used. It has no other caller, so this is pure dead-work removal.
  • The no-fix case was never first-open-only. s_map_view_inited only latches once a non-zero centre exists, so with no GPS fix and no Profile location the recenter guard never closed: the full 17-level walk ran against lat/lon 0,0 — tiles that cannot exist — on every map open, forever, and renderMapTiles then bailed to the placeholder and freed the slots anyway. The centre is not persisted across reboots either (only the zoom is), so every boot starts here.
  • The "Loading map…" hint could not paint. It was set after the probe, so the whole scan happened with the outgoing tab still frozen on the panel. Moved to the top of the function, and goToTab now uses LV_ANIM_OFF for the map tab so the flush is not racing a 200-400 ms slide. Zero ms saved, but it turns a frozen screen into visible progress — which is most of the reported bug.
  • tileExistsAt probed .png, the generic loader only opens .jpg. The zoom guard therefore advertised, and the auto-snap selected, levels that render blank. The .png leg is gone: the guard now mirrors the loader, and every probe costs half as much.

Map, data loss (ship this one regardless). tileCacheRemove(path_png) in the fetch worker — "drop any stale .png from an older build" — was written for the firmware's private LittleFS partition. UITask::begin re-points s_tile_fs at the SD root on this board with s_tile_root = "", so it resolved to SD.remove("/tiles/<z>/<x>/<y>.png"): the user's own offline map pack. The skip-check above it only ever tests the .jpg, so every queued tile whose .jpg was absent erased the co-located .png. Now gated on s_tile_fs == &s_tiles_fs, the same discriminator tileCacheMarkSdIo/tilesFsLowSpace use. Wi-Fi-only trigger, permanent loss.

Back. The ladder was a faithful copy of pagerNavGoBack minus one rung — else if (getActiveTab() != HOME_TAB_INDEX) navGoToMainTab(HOME_TAB_INDEX);. Without it Back fell through to navPushTap(LV_KEY_ESC), and nothing in this build consumes LV_KEY_ESC: the sole consumer is lv_dropdown, peeled several rungs above, and navMaybeRebuild deliberately keeps the tab bar out of the focus group. The press was consumed by the return true and nothing happened, on every bare tab.

Restoring that rung alone gives Home, not back, so this pass also adds the history that never existed anywhere in this UI (s_lv_tab_prev is a single transition latch; s_apppage_close is depth 1; the popup registry walks in static table order):

  • s_m9_nav[] — 12 entries, 24 B. Records main-tab switches only. Pushed from goToTab (the single choke point, and the only place that fires LV_EVENT_VALUE_CHANGED explicitly), popped by a new rung placed below every peel rung, so modals still close innermost-first and a null-close blocker row still stops Back at the registry rung.
  • Deliberately not stacked: the app drawer (a registry row rung 6 already closes, whose mode survives a tab switch and is restored on return to Home) and app pages / chats / settings sheets (all peeled above). Duplicating those here would double-close.
  • Never store an lv_obj_t* — settings sheets, app pages and the drawer are destroyed, often via del_async. Entries are tab indices; one that no longer applies is skipped, never a dead press.
  • s_m9_nav_replaying suppresses the re-push from a pop's own goToTab. The Home-fallback rung needs it too: without it that jump recorded the tab it was leaving and the next Back popped straight back — Back ping-ponging forever.
  • HOME clears the trail (it means "go to root"); MSG/MAP are lateral jumps and keep pushing.

Also closed in this pass, all confirmed and all reachable on this board:

  • openMeshContactDm bypassed the choke point — a bare lv_tabview_set_act with no event send, so tabChangedCb never ran, s_lv_tab_prev went stale, and the next real tab change computed leaving_inbox false and skipped the only code that hides the DM overlay. The chat stayed painted over whatever tab you moved to. The switch now fires the event, and moved above the overlay setup so tabChangedCb's own hideKb() cannot undo it.

  • Setup wizard Back was a dead key for the whole wizard, even on the three steps that draw a working on-screen Back wired to setupBackCb. The comment claimed "nothing to back out of"; the wizard is a four-step stack.

  • Contacts select mode outlived its tab. Its registry row has flags=0, so anyPopupOpen() never reported it and nothing cleared it on tab change — leaving Back to close it invisibly from another tab. ctExitSelectMode() now runs on leaving Contacts, the same discipline s_m9_map_pan already gets.

  • navGoToMainTab ignored key-blocker rows — the unconditional 8-iteration drain spun closing nothing and switched tabs out from under a running SD format or bulk delete. Now returns bool and stops. (The board's SUB_MESSAGE/SUB_MAP cases already did this correctly; HOME and navGoToMainTab did not.)

  • Two focus-group blackouts that made Back's result undrivable: the notification chip was a non-NAV_SKIP lv_layer_top child with nothing focusable inside, emptying the group for its ~1.1 s life on every incoming message; and the Spectrum page's NAV_SKIP_FLAG root hid it from navTopHasVisibleChild, re-rooting the group onto the app drawer still open beneath it (d-pad moved an unseen ring over hidden tiles, OK launched them).

    The Spectrum fix needs BOTH halves — NAV_PASSTHRU_FLAG alone is a trap. Passthru excludes only the container; navCollect still recurses into the subtree. lv_chart does not clear LV_OBJ_FLAG_CLICKABLE (LVGL 8.4 sets it in the base lv_obj constructor; only lv_label and lv_img/lv_canvas clear it), and lv_obj_remove_style_all() on the five colour-ramp swatches removes styles, not flags. So passthru-on-root alone handed the page six focus stops where it previously had zero, and navFocusCb's reverse-video fill painted a solid white block straight over the live trace — caught on hardware within a minute of flashing. The chart and each swatch now carry NAV_SKIP_FLAG so the group ends up genuinely empty, which is what the original comment always claimed. Anything added to this page later must do the same, or it becomes a focus stop by default.

    Known consequence: with the page as the focus container, useTop is true and navAddStatusBarActions() is skipped, so the "‹ Spectrum" chevron is no longer a d-pad target. Back (hardware key / registry row) still closes the page on every board, which is the documented exit.

  • ctExitSelectMode must not go through ctSetSelectMode(false). That setter ends in s_ct_list_force = true; refreshContactsList(); — the full teardown+rebuild measured at ~1.3 s with a large list (#82). Called from tabChangedCb as the user LEAVES Contacts, inside the tabview's VALUE_CHANGED chain, that freezes the tab they are moving to, on every board. It now restores the toolbars and only ARMS s_ct_list_force; the rebuild lands on the way back in, where tabChangedCb already calls refreshContactsList() and the user can see it.

  • Blocker check must precede the app-page close. With navGoToMainTab now refusing to switch while a null-close progress row owns the screen, M9_KEY_LEFT_MESSAGE and M9_KEY_MAP — which called s_apppage_close() first — would have destroyed the page and then not jumped, leaving the user with neither. Both now drain-and-bail before closing anything, the order SUB_MESSAGE / SUB_MAP already used.

Measured on hardware after the above ([MAPPROF], a HAS_M9_KEYBOARD-only Serial.printf in onMapTabActivated with micros() accumulators around loadTileJpeg and the decode — diagnostic scaffolding, strip or gate it before release):

cold open (first after boot) re-open
before this pass 2598 ms 245 ms
after dead-work removal 2189 ms 141-165 ms
after repaint throttle 1507 ms ~150-190 ms
split, before throttle read 497 / decode 539 / other 1042 / markers 10 render 5-6, tiles=0
split, after throttle read 499 / decode 539 / other 358 / markers 10 render 7, tiles=0

The throttle moved other and nothing else (read 497->499, decode 539->539 are run-to-run noise), which is the confirmation that it cut compositing rather than starving the load path. Cold open is down 42% from the 2598 ms baseline; the [STALL] ui:lvgl bucket for the same action fell 2497 ms -> 1822 ms.

With compositing no longer dominant, the two levers named in the superseded note above finally ARE the top of the list, and they are now roughly equal in size: decode 539 ms (core-0 decoding) and read 499 ms (the 4 MHz SD clock — one line, SD_SPI_FAST_HZ, which only the V4-R8 env sets today). Treat the SD clock with care: this board shares one SPI peripheral across radio + display + card, and deferred item 4 records that the M9's mount-ladder timing margins were never characterised.

Three things this settled that static reading could not:

  1. probe=0ms confirms the bestAvailableZoom guard fires. The 2598→2189 delta (~410 ms) is the scan that used to run and be discarded — the top of the 100-500 ms band the audit estimated.
  2. other dominates. See the correction under "Map re-open cost" above. The progressive per-tile repaint, not decode and not SD, was the biggest single cost. Now throttled via k_map_progressive_ms.
  3. The retained-slot path works exactly as designed — a re-open reports tiles=0, render=5-6ms: a true pure blit. Note that ~135 ms of the ~150 ms re-open is now the "Loading map…" hint's lv_refr_now, which fires even when the render will not block. Suppressing it when a pure blit is predictable (same zoom + centre as the last render, and s_map_last_missing == 0) is the obvious next trim, and is NOT done.

Stall attribution had to be fixed first, or none of these numbers meant what they said: uiCp("ui:input") lives inside #if CAP_TRACKBALL, which is 0 on the M9, while the keyboard drain sits between the ui:threads and ui:diag checkpoints. So on the one board whose only input is the d-pad, every keypress-driven action — map opens included — was billed to ui:threads. A cold map open was being reported as a slow chat-threads refresh. The M9 now has its own uiCp("ui:input"). (Work that reaches LVGL via navPushTap still lands in ui:lvgl, since the handler runs inside lv_timer_handler; that is correct.)

Still open on the map, in win/risk order: a priority lane for visible tiles (xQueueSendToFront — today the nine tiles you are looking at queue behind the 29-tile background prefetch pyramid on every pan), a retry pump so a dropped fetch heals without a manual pan, and the SD clock below.

Battery pass (2026-09-02) — saver unreachable, GPS-off base current

Battery-focused sweep of the board code plus every shared power path the M9 compiles. Verified sound and left alone: getBattMilliVolts()'s ADC2 per-sample filter + 2:1 math; backlight-off (ledcWrite(ch, 255) is special-cased by the framework to duty 256 = constant HIGH — checked in esp32-hal-ledc.c, so "off" is truly off, no 1/256 PWM sliver); compass/IMU suspend-on-idle actually ticked from the UI loop; the 15 ms keyboard poll; the 20 s default screen timeout with the 240→80 MHz drop; buzzer noTone + high-Z; the (latent) deep-sleep hold sequence; battery-log flash wear (~century-scale on SPIFFS at the 5-min cadence). Three findings, two fixed now:

  1. Battery saver was unreachable — FIXED. The touchSleep idle throttle was fully wired on the M9 (hooks installed + pref restored under HAS_TOUCH_UI, every gate predicate functional) but the only enable UI — the Settings→Battery switch — was compiled for T-Deck + V4-R8 only, and the pref defaults OFF with no console path either. So an idle, screen-off, standalone M9 free-spun its loop forever; the whole subsystem was dead code. Both #ifs (settings row + sleepIdleToggleCb) now include HAS_THINKNODE_M9. The M9 is arguably the safest board for it: the throttle is a plain vTaskDelay in the loop task, wake is the keyboard poll which runs through it (≤50 ms latency on a dark screen), and the M9 sits in batteryIsCharging()'s #else branch (compile-time false), so the "USB powered" gate can never block — even stronger than the R8, whose runtime voltage heuristic still can (the R8's widening rationale was that its heuristic doesn't block in practice, not compile-time falsity). Matching the R8 precedent, no status-bar indicator (the amber battery tint stays T-Deck-only); the settings row shows the live block reason. VERIFY on hardware — and note the obvious probe is a decoy: the Battery chart's CPU-MHz series sits at 80 on ANY screen-off M9 (that drop belongs to the pre-existing screen timeout, not the throttle), and keypress wake also works saver-off. The discriminating readout is the Battery chart window's "Idle power-save" stats card (wakeCount / % asleep — unguarded, present on the M9). The FULL gate must pass or the counters sit at 0 and the feature looks broken: saver on, screen off, no companion client, AND the BLE + Wi-Fi radios actually off (BLE is on by default in this companion build — "standalone" is not enough; the settings row's block reason names the offender), on battery, mesh idle. Under that regime the counters must MOVE (on a boot where the saver was never engaged they never leave 0). Then confirm a keypress still wakes the dark screen (≤50 ms extra latency is expected).

  2. GPS-off burned Q16 base current — FIXED, magnitude needs the bench. WadaNmeaLocationProvider's constructor and stop() followed the core's convention of parking GPS_RESET asserted. Zero-cost on a direct-wired active-LOW reset — but the M9's reset is inverted (GPS_RST1 → R46 → NPN Q16, GPIO HIGH = asserted), so "asserted" held GPIO5 HIGH sourcing (3.3 V − Vbe)/R46 continuously whenever the GPS was OFF (its default state; initBasicGPS() stops it at every boot) — for a module whose rail EN had already cut. Both sites now park the line LOW, the zero-current level for both circuits (LOW = asserted on direct-wired boards, so nothing changes there; the provider is M9-only today anyway). The clean-reset guarantee is intact: every start path calls begin() then reset(), which pulses the line explicitly. VERIFY on hardware: R46's value (read it off the board or the schematic) decides whether this saved ~0.3 mA (10k) or ~2.7 mA (1k); and confirm GPS still acquires after an off→on→off→on toggle cycle.

  3. Screen-off leaves the ST7789 panel driving — still open (already on the deferred list). The M9 branch of touchScreenBacklight() kills only the backlight; SLPIN/DISPOFF panel sleep remains "designed, not coded blind" (shared-SPI variant of the T-Deck path). The battery angle strengthens the case: SLPIN stops the oscillator/booster/LC drive (~1–2 mA class) for every screen-off hour, on top of the anti-burn-in argument.

  4. Saver × GPS: mid-session enable ran NMEA into the stock 256 B ring — FIXED (found by this pass's adversarial review of finding 1). The 4 KB Serial1 RX ring from the TTFF fix is installed at boot ONLY when gps_enabled was already persisted, and setRxBufferSize is a no-op on a running UART — so the session where GPS is first enabled ran on 256 B (~22 ms of slack at the M9's 115200 baud, the only saver board at that rate BY DEFAULT — the baud is NVS-overridable on every touch board, so a T-Deck/R8 dialed up to 115200 hits the same wall; at their defaults, 38400 / 9600 can't overflow in one park). With the saver newly reachable, every 50 ms park spans ~576 line-rate bytes: a 1 Hz NMEA burst landing inside a park lost its tail. The damage is host-side — MicroNMEA discards checksum-failed sentences, so the host's parsed fix goes stale (worst case, with every burst clipped, it never sees an intact sentence until reboot); the receiver itself keeps its own acquisition regardless. Root cause fixed rather than gating the saver (a screen-off standalone tracker is exactly the saver's target regime): gpsEnsureBigRxRing() in main.cpp cycles the UART (end → 4 KB ring → begin at the initBasicGPS pins/baud) and is called from BOTH mid-session "gps on" funnels — UITask::toggleGPS() (keyboard GPS_LONG chord, CC chip, settings switch) and the companion CMD_SET_CUSTOM_VAR("gps") handler in MyMesh.cpp — right AFTER a successful start (so a refused enable on a GPS-less V4/R8 never spends the 4 KB; the just-started cycle is benign — everything Serial1 runs on loopTask, worst case one torn checksum-rejected sentence on the EN-less boards whose modules stream even while "stopped"). Boot-persisted GPS-on takes the unchanged boot path; GPS-off boots still pay 0 extra DRAM until GPS actually starts. VERIFY on hardware: enable GPS mid-session (no reboot) with the saver on, screen off → a fix should still arrive and hold.

Also corrected: BOTH copies of the "keyboard S2 sits on the switched peripheral rail" claim — M9Keyboard.cpp begin() and the keyboard-backlight cache comment in UITask.cpp's M9 branch — now match the schematic-verified note in platformio.ini (always-on 3V3 via R3). It matters: a deep-sleeping M9 still feeds the S2; only the slider cuts it.

Deferred — hardware-verify list

These are left intentionally unset/unwired rather than guessed:

  1. RF-switch DIO table — confirmed working. Pin assignment (DIO5/DIO6) was schematic-confirmed; the per-mode HIGH/LOW truth table was carried from convention rather than a switch-IC datasheet (part number not legible on the schematic) — but bidirectional radio communication is now confirmed working on real hardware, so the table is correct as-is. No longer open.

  2. GPS pins + baud — confirmed and fixed. RX/TX were swapped (PIN_GPS_RX=3, PIN_GPS_TX=2, not 2/3) and the baud rate needed to be GPS_BAUD_RATE=115200, not the library's 9600 default. Both confirmed via hardware testing with GPS_NMEA_DEBUG=1 (raw-sentence passthrough) — remember to pull that debug flag back out for release builds. EN/RESET polarity (both active states inverted from the library defaults) was fixed earlier and is also confirmed.

  3. Display rotation. VERIFIED: DISPLAY_ROTATION=1 (3 was 180 deg off on hardware, bring-up #6).

  4. microSD mount-ladder timing. Mounting/browsing/reading confirmed working on hardware; the specific timing margins on M9 (vs. the T-Deck electrical characteristics the ladder was originally tuned against) haven't been separately characterized — if mounting ever becomes flaky, start here.

  5. Wallpaper picker — done. Guard split completed (settings row, forward declarations, implementation block all extended to M9, separated cleanly from the genuinely-T-Deck-only I2S notification-sound chooser). Confirmed working on hardware, including the dedicated Settings > Lock browsing UI.

  6. Deep-sleep wake source has NO real GPIO on M9 — MITIGATED in the 2026-08-19 audit pass, GPIO12 characterization still open. M9 has no BOOT/user button at all (only a physical power-cut slider and a reset button, neither a GPIO — schematic-confirmed). The Power menu's "Power off" row is now HIDDEN on M9 (it deep-slept with ext0 armed on the nonexistent button — device unrecoverable until a slider cycle; the slider IS the power-off), and PIN_USER_BTN was removed from the env. M9Board::enterDeepSleep() was cleaned to timer-only wake — its old "LR1110 DIO1 (GPIO42) wake" was electrically impossible (S3 RTC pads are GPIO0-21; every rtc_gpio_*/ext1 call failed unchecked). Still open, hardware-required: characterize ESP_WAKEUP (GPIO12, from the keyboard MCU — RTC-capable): edge vs. level, polarity, pulse width. Once known, a real graceful Power-off can return via ext0/ext1 on GPIO12.

  7. KEY_LED (GPIO46) unwired; MIC deliberately unbound; CTRL now bound. CTRL (0x90) opens the Control Center (2026-08-19 pass; the controller latches a single key so CTRL can never chord). GPS_LONG (0x87) toggles GPS. MIC (0x88) and 0x89 are deliberately unbound — see M9Keyboard.h. KEY_LED still has no driver.

  8. Battery reading — largely addressed (2026-08-19 pass), one hardware question left. Three separate things were tangled here: (a) the battLogOnSd /meshcomod check landed back in 4846d4f (2026-07-08) — the "identified but not yet applied" note that used to sit here was stale; the empty-history symptom was that pre-fix bug. The log selection is now keyed off the RESOLVED ui-data backend (uiDataFsIsSdCard()), so a card that merely has a /meshcomod dir (telemetry/discover logs mkdir it on any card) can no longer hijack the log. (b) GPIO13 is ADC2 (not ADC1 as the pin table used to claim) and ADC2 is Wi-Fi-arbitrated: blocked samples return ~offset-mV garbage — getBattMilliVolts() now filters per-sample and holds the last good reading. (c) Still open, hardware: whether "stuck at charging voltage after unplug" beyond that is pack/charger chemistry at the divider node — needs the raw-mV serial print on a real unit. Related: batteryIsCharging() is compile-time FALSE on M9 (no charge detection at all); widening the T-Deck's voltage-threshold detection to M9 needs hardware confirmation that the charger raises the divider node the same way.

  9. Message data loss on power cycle — root cause found and fixed (2026-08-19 pass), bench verification wanted. saveThreadsToStorage() rewrote the threads index in place (mode "w"): a hard power-cut mid-write left a short file the next boot quarantine-DELETED — chat list, unread counts and DM entries gone (threads rebuild by name for channels, so the worst visible loss was exactly "channel messages disappeared" plus list state). Now tmp+rename (uiDataReplaceFile), orphan tmp swept at load. Residual, by design: messages inside the ~5 s SPIFFS coalesce window before a cut are lost. Verify with a bench power-cut loop while a channel floods.

  10. Commander (Home tab) landscape layout — fixed (chart width, 5-button column height math). Control-center overflow (6+ toggles not fitting) — also fixed (row/chip sizing extended to M9, matching T-Deck's existing 2-row wrap grid). Both confirmed working.

  11. Keypad-nav quirks — all but map panning addressed in the 2026-08-19 audit pass (fixes compile-verified; retest each on hardware):

    • Dropdown-list navigation: FIXED. The real root cause was simpler than the old note here claimed — NO navOpenDropdown() dispatch existed in any committed M9 key path (git log -S navOpenDropdown shows only the pager and Tanmatsu commits; the "Serial-verified dispatch" described below was never committed). m9HandleArrowKey now captures an open dropdown and FIFOs LV_KEY_UP/DOWN into it (Tanmatsu's navPump pattern), and the restored Back sends LV_KEY_ESC to the list before the popup ladder.
    • Textarea 3-4-press focus escape: FIXED (probable cause). Edit-mode LEFT/RIGHT consumed arrows as silent caret moves that no-op forever at the text boundary; they now fall through to navMoveDir when the caret doesn't move. If a pure-UP/DOWN reproduction survives on hardware, the cause is elsewhere (controller-side latching?) — retest.
    • Modal "breaks out": FIXED (two-part). Keys were dispatched against a one-tick-stale focus mirror (Enter's tree mutations land inside lv_timer_handler at the END of a tick) — the M9 drain now runs navMaybeRebuild() first; and closeSettingsModal() gained navDetachBeforeTreeMutation() + navMarkDirty() (the diagnosis below was necessary but not sufficient — the stale-mirror window was the part that survived the automatic sig-rebuild).
    • Home key not closing some overlays / Back inconsistent per-modal: FIXED. Fully explained by (a) the 0ea242c HW_BACK regression (nav-mode Back did NOTHING at all in the shipped tree) and (b) popup-registry rows compiled out on M9 (Files overlays, Terminal picker, fullscreen view, wallpaper picker) plus app pages (Lua/Snake/Web) not being registry rows — HOME and Back now close app pages via s_apppage_close.
    • Snake d-pad: FIXED. On M9 Snake is the store Lua app (native SnakeGame is compiled out under CAP_LUA_APPS) and it only listens for swipe/tap events no M9 path generated; the d-pad now feeds luaAppSteer (swipes) and the d-pad centre sends a synthetic tap + ev.key=="enter", so start/steer/retry all work — for every store app, not just Snake.
    • Map panning: FIXED (second on-device round). Pressing the dedicated Map key while ALREADY on the Map tab toggles pan mode — arrows pan via mapNudge(), Map or Back exits (with toasts). A fresh entry to the tab always starts in nav mode. (Chosen over the old ENTER_LONG idea: the Map key re-press is discoverable and was a no-op before.)

    Resolved this pass:

    • Lock-screen unlock: double-pressing the d-pad center within 700 ms now unlocks; the first press reveals the lock screen and any other key cancels the sequence, so directional navigation is unchanged. The controller's M9_KEY_ENTER_LONG remains a compatibility fallback. Also fixed: the lock screen briefly showing the previous app screen before painting over it on wake (lockscreenReveal() was turning the backlight on before the lock overlay had actually been built/flushed — reordered + added lv_refr_now()), and lockscreenReveal() itself being a complete no-op for M9 (#if defined(HAS_TDECK_GT911) wrapped the whole function body — widened to #if CAP_LOCK_SCREEN).
    • Chat-message context menu opens with a normal Enter press on the focused message. Other long-press controls, including the SD row's "hold: format", still use the generic M9_KEY_ENTER_LONG mechanism, which fires LV_EVENT_LONG_PRESSED on the focused widget.
    • Home-button self-conflict: M9_KEY_HOME's "close everything on top" dismiss loop was closing the app drawer itself (once registered as a popup), then immediately reading the now-mutated s_home_drawer_mode flag and reopening it in the same keypress. Fixed by snapshotting the flag before the dismiss loop runs.
  12. Spectrum app LR1110 pass (2026-08-20) — fixes compile-verified, three on-device checks wanted:

    • Between-bin standby now sends the raw LR1110 SetStandby(0x01) (STDBY_XOSC) so the DIO3-powered TCXO stays up across bins — the vendored RadioLib's RADIOLIB_LR11X0_STANDBY_XOSC define equals STANDBY_RC (both 0x00, upstream define bug), so the named constant would silently re-select RC and re-pay the ~5 ms TCXO startup per bin. VERIFY: a micros() log around spectrumSweepChunk() should show ~5.5 ms/bin (~1 s full sweep), roughly half the pre-fix time.
    • Open now runs one span-wide calibrateImageRejection(start, stop) (begin() only calibrated mesh ±4 MHz); restore re-runs the mesh's own ±4 MHz cal from a true STDBY_RC. VERIFY: mesh RX sensitivity unchanged after a Spectrum session (image cal is back to the begin()-time band).
    • Opening Spectrum during an in-flight mesh TX now waits (bounded by the dispatcher's own 1.5x-airtime budget, capped 6 s) instead of truncating the packet mid-air; the consumed TX-done means the dispatcher logs that packet as a timed-out send — stats/log blemish only. VERIFY: open the app while a long send is on air; the send should complete (watch for the dispatcher's timeout warning, and no partial burst on a monitor). Also fixed in the same pass: sweep clamps now 150-960 MHz (LR1110 range; setFrequency failures skip the bin instead of mis-attributing RSSI), a 0-dBm failed-read sentinel guard in the peak-hold (shared with SX126x), config commands now issued from standby on open, and the stale SX126x-era comments/readout. Bin pitch (150 kHz) vs RBW (62.5 kHz) = ~42% span coverage is documented at the constants, deliberately unchanged.
  13. App sync after a power cycle — fixed (2026-08-20), bench verification wanted. User report: the V4-R8 "remembers the chats and syncs them when connecting to an app (MeshCore / MeshCore One)" but the M9 does not. Two separate stores are involved. (a) The on-device chat store (UITask threads/segments) — its power-cut loss was Deferred #9 above, fixed on this branch but NOT in any released build up to beta_66. (b) The companion sync ring (MyMesh::history_ring + per-client cursors, what CMD_SYNC_NEXT_MESSAGE / SyncSince replay to an app that connects later) was RAM-only on EVERY board, so any power cycle emptied it. On the V4 that mostly goes unnoticed (USB-powered / left on; its menu power-off deep-sleeps and also loses RAM), but the M9's only "off" is the slider — a hard VBAT cut — so every M9 session started with an empty ring and the app got NO_MORE_MESSAGES for everything received while it was away. Now persisted on all boards (shared code, MyMesh.cpp "Companion sync-history persistence"): /synchist append-only log of the message frames (~180 B per message, coalesced 3 s / 10 s max, compacted tmp+swap once it holds > 2x the ring) and /synccur per-client last_delivered_seq + next_seq (tiny tmp+swap, coalesced 5 s / 30 s max, flushed immediately on app disconnect). Both live on DataStore::getHotDataFS() — the SD card under /meshcomod when the store adopted it, else SPIFFS — and are restored in MyMesh::begin(). All reboot / power-off / download-mode / SD-copy paths flush them next to persistHistoryNow(). VERIFY on hardware: receive a few DMs + channel messages with the phone app disconnected, slide the M9 off, slide it on, connect the app — the messages should arrive. Residuals by design: messages inside the 3 s coalesce before a hard cut are not replayed (the on-device store has its own 2-5 s window), and a cut inside the cursor window can make the app re-receive a few already-seen messages.

Keyboard: register-addressed I2C slave (protocol build #8, USB-pad release build #9)

"No keys do anything" root cause: the keyboard is a separate ESP32-S2 running Elecrow's matrix-scanner firmware (ThinkNode-M9-KB-platformio, provided by Kaj 2026-07-06) as an I2C slave @0x6C with addressed registers: 0x00 HW ver (0x03), 0x01 KEY VALUE (0x00 = none, single latched slot), 0x02 backlight duty, 0xFE FW ver (0x10). A key read must WRITE the register address (0x01) first, then read one byte. The contributed patch's "one raw byte per read" protocol read the last-addressed register instead — register 0x00 after the controller's reset, i.e. a constant 0x03, never a key. Driver fixed in M9Keyboard.cpp (write-then-read + version-register boot probe with serial log + Wire fallback probe + 1 s re-probe + backlight setter). The controller resolves shift/sym/alt layers itself and sends final ASCII; long-press codes 0x87 (from 0x84) and 0xA3 (from Enter) exist but are not yet bound in the UI.

Build #8 was still dead. Schematic verification (V1.0 sheet, high-res crops): keyboard bus host GPIO20 = ESP32-2_SDA / GPIO21 = ESP32-2_SCL (through R2/R1 series, pullups R73/R72, S2 on always-on 3V3) — wiring correct. The real blocker: GPIO19/20 are the S3's native USB D-/D+ pads, owned from reset by the ROM's USB-Serial-JTAG peripheral (D+ pullup on GPIO20 = SDA). The M9's console is an external UART bridge, so nothing ever released them. M9Board::begin() now clears USB_SERIAL_JTAG_CONF0.USB_PAD_ENABLE before any bus init (build #9). Bonus schematic finds: SD_CS = GPIO48 (the patch's "36" misread package pin 36 = SPICLK_N = GPIO48; SD IS on the shared SPI bus), keyboard wake pulse ESP32_WAKEUP = host GPIO12, KEY_LED = host GPIO46, LCD_TE = GPIO19. If #9 is still dead, suspect a BLANK keyboard S2 on preprod units - flash it with the ThinkNode-M9-KB-platformio project via the J6 header (carries ESP32-2 UART/EN/BOOT).

Radio init -706: old LR1110 transceiver firmware (SOLVED, build #5 — CONFIRMED on hardware)

Tester log 2026-07-06: Base FW version: 3.3 (0x0303, the original release), DriveDiosInSleepMode unsupported (old LR11x0 FW), skipping, LR1110: hw=0x22 device=0x01 fw=3.3 wifi=2.1 gnss=0.0 errors=0x0000, [BOOT] radio ok, UI up. Build #6 fixed the panel orientation: DISPLAY_ROTATION 3 -> 1 (was 180 deg off). Build #7 fixed the "content a bit left and down": UITask's per-board s_ui_rotation overrides (T-Deck, Tanmatsu) had no M9 entry, so LVGL rendered the PORTRAIT default 240x320 into the landscape 320x240 panel window. Fix = s_ui_rotation = LV_DISP_ROT_90 under HAS_THINKNODE_M9 (ROT_90 -> panel rotation 1). Rule of thumb: a new landscape board needs BOTH the DISPLAY_ROTATION build flag (splash) AND the UITask s_ui_rotation override (LVGL UI).

The -707 -> -706 progression decoded: -707 (CMD_FAIL) with tcxo=0 was the calibration failing on a dead 32 MHz clock; with TCXO 3.3 V the calibration passes and init reaches driveDiosInSleepMode (opcode 0x012A, added in Semtech transceiver FW 0x0308) which RadioLib 7.x sends unconditionally in LR11x0::config(). Preprod M9 chips run older FW and answer CMD_PERR -> RADIOLIB_ERR_SPI_CMD_INVALID (-706), aborting an otherwise healthy init. Meshtastic works on the same unit because it pins an older RadioLib that never sends the command. Fix: scripts/build/patch_radiolib_lr11x0.py (extra_script on the M9 env) makes config() skip that optional command on old FW; radio_init() now prints LR1110: hw=.. device=.. fw=X.Y ... errors=0x.... in both outcomes, and the env carries RADIOLIB_DEBUG_BASIC=1 during bring-up. NOT a shared-SPI problem: PERR is a well-formed chip reply, so the bus is clean.