Known regions (#449, DenRalle on Tanmatsu): s_regions_modal was in no popup
registry at all. Without a row the Back ladder never peels it, so the page
stays on screen, and the open-popup COUNT stays zero, so whatever you open
next opens underneath it. Both halves of the report ("screen remains
visible", "pages open only in the background"), one missing line.
Idle power-save (#450, jrote1): the Battery page in the report shows the
gate blocked on "Wi-Fi on" with Wi-Fi disabled and cycles stuck at 0.
tsWifiOff() ANDed the wifiConfigGetRadioEnabled() pref with the real radio
state, and that pref DEFAULTS TO ENABLED when its NVS key was never written
-- so anyone who had not explicitly toggled Wi-Fi in this firmware read as
"Wi-Fi on" permanently. The gate is asking whether the modem draws power,
and WiFi.getMode() == WIFI_OFF answers exactly that on its own.
The other half of the same bug is in main.cpp: the power-down ran only if
wifi_started, but a scan or the version check can bring STA up without
going through that state machine, and then disabling Wi-Fi skipped
mode(WIFI_OFF) and left the modem running. getMode() != WIFI_OFF proves the
driver is inited, which is what the guard actually wanted to test, so it
closes the leak without reintroducing the low-heap esp_wifi_init crash the
original comment guards against. The block is one-shot behind
wifiConfigConsumeApplyRequest(), not per-loop.
Follow-ups from reviewing the merged PRs:
- #457 moved the Lua app page to the slim bar, which exposed that bar_h was
measured BEFORE appPageBegin* installs the chrome -- so the body was sized
against the OUTGOING page's bar. Install first, then measure (#236 again,
one step earlier).
- #453 makes /meshcore-*.json downloadable, and those carry "private_key"
plus every channel secret. The transfer is opt-in, LAN-only and rate
limited (3 tries, 30 s lockout), so the surface is sound -- but it is
plain HTTP, so say so on the page and flag backup rows in the list.
Reported-by: DenRalle, jrote1
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two things, both found by looking rather than by being told.
A Heltec V4-R8 crash dump from beta_77 decodes to a data race in the Arduino
core's Wi-Fi event list. Correcting for the unwinder mangling Xtensa return
addresses, the event task was inside _eventCallback copying a WiFiEventCbList
entry, iterating the callback vector, while loopTask was still in setup() inside
_M_realloc_insert growing that same vector through WiFi.onEvent(). The
reallocation frees the buffer the event task is walking, so it then calls
through a dangling function pointer, which is the garbage program counter in the
dump.
Neither side locks, and the vector is the core's, not ours. What is ours is the
ordering: both handlers were registered AFTER the stack was started, one of them
after WiFi.begin(), so a STA_DISCONNECTED arriving during boot association lands
in the window. A failed first association is ordinary, which is why this happens
at all. Both now register before WiFi.mode(WIFI_STA), so nothing mutates the
vector once events can flow and the race is impossible rather than unlikely.
This is a crash during setup(), so on an unlucky unit it is a bootloop, which
makes it worth more than its single report.
The moved handler drops its WIFI_DEBUG_PRINTLN tracing, which comes from a
header not in scope that early. The prints compile to nothing in a release build
and the reconnect flag they accompanied is unchanged.
Separately: the location-privacy setting shipped in beta_77 on the wrong page.
It was inside the Sensors section rather than GPS, because the insertion was
anchored to a comment belonging to the Sensors block, and that section is
described in its own code as V4-with-kit only. So on most boards it was not
reachable at all, which is exactly what two people reported: they went looking
and correctly concluded it was not there. It now sits in the GPS section, is
named "Location privacy" rather than "Position in adverts" (nobody searching for
a privacy control scans for the word "adverts"), and offers four buttons instead
of a cycling one, so every option and the current choice are visible without
touching anything. Given the failure here was discoverability, showing the most
without interaction is the point.
All nine S3 envs and both ESP32-P4 targets green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>