oumike. ESP32-S3, 16 MB flash, 8 MB octal PSRAM, quad-SPI 320x240 panel via
LovyanGFX, GT911 touch, SX1262 at 22 dBm, GPS, microSD on SD_MMC. New board
file, new variant, one new env. Seeed start selling it next week and the
maintainers have the hardware; we do not.
Merged on their hardware bring-up rather than ours: display, touch, GPS,
microSD, battery and buttons are confirmed on-device by the author. What this
side can prove is that it does not cost anyone else anything, so the whole
matrix was built: all nine S3 envs including the new one, plus both ESP32-P4
targets.
The PR also carries accumulated touch-UI work from the author's development
line, which he flagged himself and offered to split out. Merged as-is because
the three-way merge came through with zero conflicts and nothing from today was
reverted: the prefs schema is still v54, and kb_force_legacy, boot_wifi_open,
the P4 gpsEnsureBigRxRing fallback and the chat-store migration logging are all
still present. Verified explicitly rather than assumed, since the diff against
main showed 1706 deletions purely because the branch predates today's merges.
Two bring-up findings worth keeping: Arduino's Serial1.setPins() takes (rx, tx),
so PIN_GPS_TX is the pin we receive on -- the same naming trap the V4-R8 and
Pager envs already carry, and it left this board's GPS listening on a silent
pin. And the panel is fixed landscape (CAP_ROTATABLE 0), so the keyboard's
rotate arrows are not built there.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three battery fixes plus the record in M9_PORT.md:
- The touchSleep battery saver was unreachable on the M9: the settings
switch and its callback were compiled only for the T-Deck and V4-R8,
and the pref defaults OFF with no other setter. Both #ifs now include
HAS_THINKNODE_M9 (the M9 is in batteryIsCharging's #else branch, so
the "USB powered" gate can never wrongly block there).
- WadaNmeaLocationProvider parked GPS_RESET asserted while the GPS was
stopped, per the core's convention. On the M9 that polarity is
inverted through R46 into NPN Q16, so "asserted" sourced base current
the entire time the GPS was OFF. Park the line LOW instead — the
zero-current level for both circuit styles; every start path still
pulses reset() explicitly, so the clean-reset guarantee holds.
- A mid-session GPS enable ran the whole session on the stock 256 B
Serial1 RX ring (the 4 KB TTFF ring is boot-gated on the persisted
pref, and setRxBufferSize is a no-op on a running UART) — and with
the saver newly reachable, one 50 ms park overflows that ring at the
M9's 115200 baud. gpsEnsureBigRxRing() cycles the UART to 4 KB from
both "gps on" funnels (toggleGPS, CMD_SET_CUSTOM_VAR) right after a
successful start, so a refused enable never spends the DRAM.
Also corrects both stale "keyboard S2 on the switched rail" comments
(schematic truth: always-on 3V3 via R3 — it decides the deep-sleep
drain budget). All 8 envs compile; on-device VERIFY list in M9_PORT.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MYDmpc53KpdmTLKzUNsX2s
An invalid SPIFFS forces want_full_sd above ("move the WHOLE store to SD when
the device has no usable SPIFFS"), and being on SD then SKIPS the repair -
so the partition stays unformatted forever, every boot logs
"E SPIFFS: mount failed, -10025", and internal storage reads 0 bytes.
It is not cosmetic. The migration path documents that it "never deletes its
SPIFFS sources, so a device that already adopted the card still has a
byte-identical copy here" - the design assumes internal flash holds a SECOND
copy of the identity. A device that went SD-native BECAUSE SPIFFS was broken
never had that copy, so /meshcomod/identity/_main.id on the card is the only
thing making it its node. On the ThinkNode M9 that card is soldered.
Reproduced on M9 hardware, and the obvious one-line fix is NOT applied:
changing the condition does repair the partition, but that boot then panicked
and looped in the Arduino Wi-Fi event task. Once the partition was formatted,
the same build with the line back as-is boots cleanly and repeatedly - so the
fault is the 3.375 MB format itself running at this point in setup(), racing
the event task, not a mounted SPIFFS. The real fix has to repair the
partition somewhere that can afford a multi-second blocking format.
Comment only; no behaviour change.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Christopher Van Hoose <cvhvisuals@icloud.com>
(cherry picked from commit ab86caa9385b6d63acce977d63e2b1a1731b7236)
The four capabilities that kept third-party apps a sketch of a built-in one:
* wada.map the firmware's own tiles, projection and cache inside an app
page, with its own capped pool so it never evicts the Map tab's
* wada.ui.list the missing "pick one of N" widget; rows are real buttons, so
keyboard and trackball nav walk them for free
* on_packet each frame delivered once instead of polling a 16-deep ring,
which sampled rather than observed
* wada.mesh.discover the active zero-hop probe, behind its own permission
because it spends every neighbour's airtime, not just ours
Plus the surface those need to be useful: packet identity reported only where
the frame actually carries it, exact micro-degree coordinates (Lua is built
LUA_32BITS, so its floats were quietly costing a metre), altitude and satellite
time, wada.geo, wada.ui.input, named and one-shot timers, http_post, windowed
fs.read, and wada.sys.env on a hardware gate rather than the memory one.
Fixes:
* Both ESP32-P4 targets could not link. g_wifi_last_disc_reason was defined in
src/main.cpp, which the IDF builds never compile, so all nine S3 envs stayed
green while Tanmatsu and T-Display P4 were dead.
* Map zoom level was invisible in +/- buttons mode; the readout was hidden with
the slider it was anchored to.
* Hungarian and Dutch held each other's "No SD card" translation.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Wi-Fi power save: remove the preference, always use DTIM modem sleep.
An R8 with saved credentials and Bluetooth enabled aborted every time Wi-Fi
associated. Coredump: crashing task "wifi", abort() inside pm_set_sleep_type,
with nimble_host and btController both live.
WIFI_PS_NONE is not a legal sleep mode while the BT controller is running --
modem sleep is precisely what yields airtime to BLE -- and the Wi-Fi PM blob
aborts rather than refusing. 844d8c3 made power save a preference defaulting
OFF on the V4-R8, which is why only that board crashed; the comment above the
apply site already stated the invariant it broke ("gives BLE coexistence
airtime").
The latency it was buying does not exist either. Profiling the companion link
on hardware: an app sync is ~40 CMD_GET_CHANNEL round trips (the device reports
MAX_GROUP_CHANNELS=40 and the app reads every slot individually), 165ms each,
with this firmware accounting for 28ms of 8264ms -- 0.3%. Sync cost is
round-trip COUNT, not link latency, so there was nothing to trade away.
Guarding the setting was considered and rejected: it would have to be silently
inert whenever Bluetooth is on, which is a confusing thing to leave in a
Settings screen. Removed instead -- toggle, prefs accessors and R8 default.
The stored wifi_ps NVS key is simply never read again; no migration needed.
Companion BLE: ATT MTU MAX_FRAME_SIZE -> MAX_FRAME_SIZE + 3.
A notification payload is ATT_MTU-3, so SerialBLEInterface::begin()'s
setMTU(MAX_FRAME_SIZE) left a full-length frame 3 bytes too big and NimBLE
silently dropped the overflow (ble_att_truncate_to_mtu). MyMesh::queueMessage()
clamps message text to exactly MAX_FRAME_SIZE, so maximum-length messages lost
their last 3 bytes -- over BLE only; USB/TCP/WS were unaffected. Applied from
the vendored transport rather than the core: setMTU() is a public static, so
MeshCore needs no change. Verified on an M9, which now negotiates 179.
All 8 environments build. R8 flashed and confirmed to be the booting slot
(otadata seq 1 -> ota_0); its boot log is not observable over USB because
opening the port drops that board into ROM download mode.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Heltec V4-R8 — "are we getting full performance?" audit. Compute side was already
at spec; six gaps closed (record: variants/heltec_v4/R8_AUDIT.md "Perf pass"):
- FEM LNA defaulted OFF on a KCT8103L board: prefs v49 defaults it ON on the R8
(one-time flip of existing installs, no new field). Toggle stays in Radio & Mesh.
- Display SPI 40 -> 80 MHz (LGFX_SPI_WRITE_HZ; -D to 40000000 steps back).
- Async DMA band flush (LGFXDisplay::flushBandRGB565): swap into a 12 KB internal
DMA buffer, one no-convert DMA per band, lv_disp_flush_ready at once so LVGL
renders band N+1 while band N drains; lv_disp_flush_is_last closes the frame
transaction so the shared micro-SD gets the bus between frames. Sync fallback.
- Wi-Fi modem power save is a pref (wifi_ps; toggle in Wi-Fi settings, live once
associated). Default ON everywhere (unchanged), OFF on the R8.
- micro-SD 4 -> 20 MHz after the proven mount, read-verified (probe-file head
captured at 4 MHz and byte-compared after the raise; falls back). Wired at all
three mount sites; no-op without SD_SPI_FAST_HZ. Note: SDFS::begin returns true
if already mounted, so every clock change goes through SD.end() first.
- R8 env boots at ESP32_CPU_FREQ=240 (setup() no longer runs at the V4's 80 MHz).
- Settings -> About "Perf:" row on the R8 (no serial console on that board):
"CPU 240 · TFT 80 MHz DMA · SD 20 MHz · LNA on" is the all-green reading.
ThinkNode M9 UI:
- Spectrum page root gets NAV_SKIP_FLAG: nothing on it is actionable, so the
d-pad no longer highlights the chart / scale box; Back closes it as before.
- Map "Show tile z/x/y" line defaults OFF (prefs v50, one-time flip; toggle kept).
- The tab bar is bypassed entirely on the M9: the accent "glow" indicator lived on
the screen, not in the bar, so hiding the zero-height bar left it glowing over
the content's bottom edge. No bar styling / gestures / key hints / indicator
are built on the M9 now.
Cross-compiled: R8, V4, M9, T-Deck. Flashed + booted on the user's R8 and M9.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Reported on a T-Deck straight after an OTA: the boot dead-ended on the fatal
"LoRa radio not detected" screen, and a manual reboot cleared it.
On boards with a peripheral power gate the LoRa module has no power until that
pin is driven high in Board::begin() — GPIO10 on the T-Deck — a few milliseconds
before the radio is probed. A cold start survives that because the rail was
already settled. A SOFTWARE reset does not, and an OTA ends in exactly one:
ESP.restart() releases the pin, the rail collapses, the app re-drives it and
probes the SX1262 while it is still powering up. The existing 150 ms retry does
not help because it never touches the rail — the module is latched in a bad
state, not merely slow, which is why only a real power cycle cleared it.
So do what the error screen would otherwise ask the user to do: bounce the rail
(LOW, drain, HIGH, wait out the power-on reset and crystal startup) and probe
once more. Guarded on PIN_PERF_POWERON, so boards without a gate are untouched,
and only reached when the alternative is halting on the fatal screen — a board
that probes normally never executes any of it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
startFrame()'s default background became UIColor::window_bkg in the 1.17
core, and boards whose DISPLAY_CLASS is a core driver link upstream's
palette — window_bkg=WHITE, primary_txt=BLACK. The boot splash grew a
white frame around the logo, and the radio-halt text would have drawn
black-on-black. Boot splash, halt screen, REMOTE placeholder and the
shutdown screen now pass explicit black/white/red instead of palette
entries whose values depend on which display driver got linked.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- UIColor migration: DisplayDriver::Color enum -> ColorVal/UIColor statics in the
6 driver headers + 3 driver .cpps + all main.cpp/UITask.cpp call sites.
- ONE palette definition: src/helpers/ui/UIColorPalette.cpp. Envs whose
DISPLAY_CLASS is a core driver (v4_tft, attaky, tdeck, M9) take the palette
the core driver .o carries; envs on our drivers (r8, rak via src_filter;
pagers via helpers/ui glob; both IDF apps via the whole-archive src glob)
compile ours. Never both — same-name core objects collide (core LGFXDisplay).
- 1.17 ESP32Board.cpp includes <target.h>: hand the core lib scope the libdeps
include paths it can't chain to (MicroNMEA everywhere, SensorLib on pagers).
- CustomLR1121Wrapper: setRxBoostedGainMode void->bool (1.17 base).
- IDF: fetch-deps.sh gains durable vendor-time patches (ESP32Board minimal
deep-sleep — no target.h on IDF; SerialBLEInterface esp-nimble-cpp 2.x port
incl. esp_mac.h). helpers/ethernet/ excluded in both meshcore CMakeLists.
- platformio.ini pins point at a LOCAL test tag (mc117-clean#core-v1.17.0-test);
swap to the real core-v1.17.0 before merging to main.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A dead or absent LoRa module halted boot behind the frozen logo with the only
clue on serial. Retry init once (transient SPI/reset flakes), then paint the
failure on the panel — 'LoRa radio not detected / wadamesh needs the LoRa
module' — before halting, so users aren't left staring at the wordmark.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Track Pager Wi-Fi/BLE ownership explicitly instead of treating Wi-Fi intent as an active association. Allow BLE with Wi-Fi off or unconfigured, and restore it after a bounded failed association while pausing automatic retry churn until Wi-Fi is explicitly retried.
Signed-off-by: Pixel Perfect <me@pixp.cc>
Swapping beginBle() for enableBle() at boot introduced a refusal branch
that only logs. The association-timeout branch beside it sets
s_pager_ble_after_wifi, so the two exits left different state for the
retry sites to read. The GOT_IP handler's ble_waiting also ORs
(wifiConfigGetBleEnabled() && !isBleStackBegun()) and so happened to
cover the want_wifi case, but relying on that made the marker's meaning
inconsistent.
Set it in both branches. The residual gap is unchanged and inherent: with
want_wifi false there is no association to retry after, so a BLE-only
boot that loses the heap guard stays deferred until the user toggles
Bluetooth, which takes UITask::enableBle()'s ordered-restart path.
Signed-off-by: Pixel Perfect <me@pixp.cc>
The boot gate was widened from origin's
if (SPIFFS.exists(id) && !SD.exists(sdid))
to just SPIFFS.exists(id), and the mig_busy branch now demands
kSdMigrationComplete before it will adopt a card that already has an
identity. That marker is new in this branch, so no card written by earlier
firmware can carry it.
Earlier firmware also cleared sd_mig_busy whenever the copy RETURNED rather
than when it succeeded, and it copied the identity in SPIFFS walk order
instead of last. A device whose migration died after the identity landed
therefore adopted the card anyway and has been running full-SD ever since,
with sd_mig_busy stuck at 1. On upgrade that device now computes
committed=false and boots from its months-stale SPIFFS snapshot: old node
name, old settings, old contacts, history rebound to internal, and the
secondary-store path then copies the stale contacts to the card root.
Recoverable via the Settings banner, but a bad first impression of an OTA.
Grant a one-time amnesty when the card identity is byte-identical to the
internal one. That proves the card holds THIS profile and is the newer
store, so adopting it is strictly closer to the user's live data. The
recovery banner stays up so a reconciling Copy-to-SD can still fill anything
the interrupted copy missed. A card holding a different identity is
untouched by this and still fails closed.
The byte comparison is hoisted out of the Pager-only block into
sdIdentityMatchesInternal() and meshcomodSdProfileMatchesInternal() now
calls it, so the two copies cannot drift. That also tightens one edge:
a zero-length identity used to compare equal to another zero-length
identity and now counts as a mismatch, which is the safe direction.
Also correct a comment that claimed the owner-marker cleanup removes "only
our own incomplete marker" — that branch runs precisely when the digest does
NOT match.
Signed-off-by: Pixel Perfect <me@pixp.cc>
sdProfileTreeContainsFile() re-stat'ed every recursive child with
SD.exists() before opening it. The child came straight from
openNextFile(), so it provably exists; the check can only ever produce a
false negative, and false is the single verdict that lets a residual
foreign subtree read as empty and the card get claimed. Every other
uncertainty in this function already fails closed.
Keep the check at the root, where "/meshcomod is absent" genuinely means
there is nothing to conflict with, and let the open() fail-closed path
govern below it.
Signed-off-by: Pixel Perfect <me@pixp.cc>
The RAM investigation and the language-download debugging left Serial probes
across three files: the BOOTMEM/MESHMEM/MEMPROBE macros and their 48 call
sites, plus the [STORE]/[APPCAT]/[LANGDL]/[LANGSEL] traces. They have served
their purpose and were flooding the companion USB-CDC, so they all go. Two
locals in the .lang installer (wr, wr3) existed only to feed a trace and go
with it; the control-write repair path that actually rebuilds /lang stays.
Separately, the map tile-cache help text read "256\xC3\x97256": the 256 after
the multiplication sign is all hex digits, so the compiler folded it into the
escape and emitted one out-of-range byte instead of "256x256". Breaking the
literal after the escape terminates it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Measured on the V4, bracketing each step of the post-mesh init block:
wifiConfigBegin() 172 bytes
serial_interface.begin(Serial, TCP, WS) 0 bytes
WiFi.mode()/WiFi.begin() -> esp_wifi_init 36,432 bytes
So gating the TCP and WebSocket servers (or MQTT, which already returns
early when disabled) frees nothing: listening sockets are cheap and the
cost only arrives per connected client. Wi-Fi is ~100 KB of the 289 KB
heap (36 KB init + ~64 KB associate), it is DMA memory that cannot live in
PSRAM, and it is not divisible by feature.
Conclusion for the V4: no application-level lever of size remains. The
options are restoring the native apps/languages so the board does not need
the network for them, rebuilding the Arduino libs with a custom sdkconfig
(Wi-Fi buffer counts + IRAM shadow), or accepting 95%.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Measured on the V4: post-spiffs 167496 -> 171316, pre-iface 121432 ->
126836. Verified on the T-Deck (8 MB PSRAM, the working reference) that
pushing ordinary allocations to PSRAM breaks nothing: boots, chats and the
store all fine, so the threshold is safe for every board.
Session total on the V4: ~12 KB internal reclaimed (6.7 KB statics + 5.4 KB
threshold).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The SDK ships CONFIG_SPIRAM_MALLOC_ALWAYSINTERNAL=4096, so every allocation
under 4 KB is forced into internal DRAM even with PSRAM free.
heap_caps_malloc_extmem_enable(1024) changes that at runtime; anything that
needs internal/DMA memory asks by capability and is unaffected.
Measured on the V4: post-spiffs 167496 -> 170292 free. Real but modest,
because most internal use is NOT generic malloc — Wi-Fi DMA (capability
tagged), 171 KB of static .bss/.data/IRAM-shadow, and task stacks.
Session total on the V4: ~9.5 KB internal reclaimed (6.7 KB statics to
PSRAM + 2.8 KB here), plus the worker-stack fix that made the store, map
tiles and the update check work there at all.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Measured on the V4: 5.8 KB free internal heap at steady state, 3.5 KB
largest block, while 843 KB of PSRAM sat idle. These four tables (the Lua
catalog, installed apps, the language catalog and its installed list) plus
Discover's per-row key/name snapshots only matter while their page is open,
yet lived permanently in .bss. They are pointers into PSRAM now, allocated
on first use with a malloc fallback; indexing is unchanged.
.dram0.bss 74168 -> 67448 bytes, verified in the ELF.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>