The M9 gets the V4-R8's text-size presets verbatim, because it is the same
240x320 panel and two tables for one screen size is two things to keep in step.
Fonts grow, geometry does not: rows, cards and controls keep their dimensions
and stay reachable, which is why the labels say "Large text" rather than a
percentage.
⚠️ The migration matters more than the feature. ui_scale has been in the saved
blob since v8 and every M9 carries the large-screen default of 1 that this board
never applied, so exposing the selector without resetting it once would bring
every M9 back from the update rendering Large text nobody chose. Prefs go to v65
with that one-time reset, which is the same correction v63 had to make for the
V4-R8 after it happened to them.
The P4 can run landscape. Its MIPI-DSI panel has no MADCTL to rotate, so LVGL
rotates each flushed area into panel coordinates, exactly as the Tanmatsu
already does on the same silicon; the difference is that the P4 reads the saved
preference instead of pinning one rotation. Portrait stays the default and the
existing Orientation button does the rest.
The rotated branch of the P4 touch mapping had never run: it was carried "for
parity" while the panel was portrait-only, and it clamped rotated coordinates
against the portrait bounds, so every landscape touch past x=284 would have
pinned to the screen edge. It clamps against the swapped space now.
Landscape is UNTESTED on hardware: Kaj did not have his P4 with him, and it
ships that way deliberately, with the release notes saying so and asking P4
owners to report smoothness and touch accuracy near the edges.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two things Kaj hit on a T-Deck within a minute of the first flash.
Taps that missed a checkbox went through the card to whatever sat behind the
popup. The card had LV_OBJ_FLAG_CLICKABLE cleared, copied from the version
picker, and a non-clickable object is invisible to hit-testing, so the tap
carried on to the page underneath. The card is clickable again, and tap-outside
now compares the point against the card's own area rather than assuming nothing
inside it can reach the backdrop. Events do not bubble in LVGL 8 unless asked,
so the clear was never needed for its stated purpose.
The card is also explicitly scrollable and capped to the panel height. Eleven
rows do not fit 240 px, and a card taller than its backdrop puts its own
controls outside the area that catches taps.
"Report this build" reads like reporting a fault with it, which is the opposite
of what the button is for. It is "Send a test report" now, "Send another test
report" once you have, and the form is titled "Test report: <tag>". Renamed in
the docs, the site, the issue template and the tracking-issue text too, so the
instruction people are given matches the button they are looking for.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Not cut. The slice is staged and every target builds; this is the changelog for
whenever it goes out. It leads with reporting from the device, then the T-Deck
Max OTA fix and the instruction that a Max on beta_84 must be flashed over USB
rather than from the device, because publishing a newer build is what exposes
that bug.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four things the merges needed.
The POI menu from #549 was not in the popup registry. Every modal in this file
registers its closer there, and one that does not cannot be dismissed by Back or
by the screen-lock teardown: it stays on screen while the page behind it
changes. That is the same failure #553 describes for Known Regions, which #550
fixes in the same slice.
Czech had no POI string. It was added after #549 was opened, so thirteen
language files got the row and the fourteenth did not.
Auto keyboard backlight never switched off when the screen timeout is Never.
#550 changed Auto to follow the screen timeout instead of a fixed three-second
window, which is right, except that Never then means lit forever. Auto is the
default mode, so a device left on a desk would burn its keyboard backlight until
the battery gave out, where the old window at least ended. The Never case is
bounded to a minute; mode On is still there for a permanently lit keyboard.
Compact chat rows have vertical padding on the T-Deck Pro and Max (#563).
Compact mode leans on the alternating row tint to show where a message ends and
e-paper has no tint, so messages ran together, worst when one wrapped.
Not fixed here: #562, the Pro terminal drawing black on black. The console
already special-cases e-ink, the Pro's palette is white paper and black ink,
LovyanGFX treats equal fore and back as transparent rather than a filled block,
and consoleBegin gets the display before anything renders. The remaining suspect
is the pre-LVGL boot screen, which fills black on purpose because "our pre-LVGL
screens are always dark" and inks the whole panel on e-paper. That needs the
device to confirm, and guessing at an e-paper fix is how a regression ships.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A promote spans every test build published since the last stable, and reports
keep arriving on a build after the next one has been cut, so showing only the
newest beta hid exactly the evidence the decision needs. The page now carries a
chip per build above stable and switches between them.
They never merge. A report vouches for the build it was sent from and for
nothing cut after it; averaging them would let somebody sign off code they
never ran.
The verdict got stricter, because testing it showed it was hollow. "Is anybody
running this board" was answered by the opt-in install count, which most people
will leave off, so a single green board could declare a whole release ready.
A board now also counts as out there once anybody has ever reported on it, on
any build: reports are not opt-in, and somebody who reported on beta_85 still
owns that board when beta_86 lands. So silence on a board we have heard from
before blocks the promote, and silence on a board nobody has ever reported on
does not.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The matrix was only reachable from a corner button and a line buried in the
channel explainer. It now has a card in "Docs, tools and help", next to the
others, which is where people actually look.
The tracking issue is dropped: the matrix lives on the site and no write
credential for it exists anywhere. The publishing code stays in place and
dormant, so it is one line in /etc/wadamesh-reports.env if that changes, and
the service and deploy script now say publishes:false is the intended state
rather than a missing piece.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nginx and the service were both adding Access-Control-Allow-Origin to the
matrix response. Two of them is not a stronger permission: the CORS check
fails on a duplicate and the browser rejects the response, so
wadamesh.com/beta could not read firmware.wadamesh.com at all. Only the
service sets it now, verified from the live origin.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Settings, About gains "Report this build": works or a problem, five tick
boxes for what you actually exercised, how long you have run it, an optional
line. It posts to the report service and lands on wadamesh.com/beta, which is
what the next promote is decided on. Ticking matters: "it works" from somebody
who watched it boot and "it works" from somebody who messaged on it all week
are not the same claim, and a board only goes green on two of the second kind.
"Report a bug" draws a QR for a prefilled GitHub issue form. The issue is filed
from the reporter's phone under their own account, so no write token has to
exist in a public firmware image and the reporter gets the replies.
"Count this device" is off by default. It lets the update check say which board
and version it is running, so a board with no reports can be told apart from a
board nobody owns. Those need opposite responses and nothing could tell them
apart before.
Mechanics: the POST rides the existing core-0 tile/update worker and its
WiFiClient/HTTPClient, like the version check, because a second pair on that
~8 KB stack overflows it. The device id is a salted SHA-256 of the public key
truncated to 64 bits, so a second report replaces the first without saying who
sent it. Sending is two taps and the second lists every field that leaves.
Prefs v64 appends report_ping + report_done_n at the tail, which the schema's
trailing static_assert now checks.
Also here: scripts/build/matrix-check.sh prints what testers reported for a tag,
and release.sh runs it before a --promote. It never blocks; a board nobody owns
can never go green. With no reports at all it now says so rather than reporting
a clean bill of health, which is the habit this is meant to replace.
Built on all eleven S3 envs and the T-Display P4.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A beta goes stable today because it has been out a while and nobody shouted.
With 13 board images, most of them in hands we never hear from, "nobody
shouted" mostly means nobody with that board was listening. This makes the
evidence explicit: testers report per board per tag from the device, and the
promote decision reads off a grid.
The device cannot talk to GitHub itself. TLS does not fit (mbedTLS wants ~30 KB
of heap for a handshake and ~5 KB survives Wi-Fi association on the 2 MB
boards), and a token in a GPL image is a token everybody has. So the split is:
* Structured test reports go to a small service on the firmware VPS over the
plain HTTP the device already speaks, and it holds the only write
credential. SQLite is the database; the per-tag tracking issue is rendered
from it and rewritten in place, so GitHub can be down or rebuilt at will.
* Bug reports do not come through here at all. The device draws a QR that
opens .github/ISSUE_TEMPLATE/bug.yml prefilled with board, version and
diagnostics, and the reporter files it under their own GitHub account:
right attribution, GitHub's own spam controls, no token.
Green needs two separate devices whose owners ran the build for a day or more.
A board nobody runs cannot go green and does not block a promote; a board
somebody runs and has not vouched for does. That rule is the whole point: it
distinguishes untested from unowned, which "nobody shouted" cannot.
wadamesh.com/beta.html renders the same JSON for the promote decision.
Two things found while deploying, both repo-vs-box drift that would have bitten
somebody eventually:
* The repo's copy of the firmware vhost was missing /apps/ and /bringup/,
which had been added on the box and never committed. Deploying it would
have 404'd the Lua app and language store for every device. The repo copy
is now taken from the box, and the deploy script refuses to push a vhost
that has fewer locations than the live one.
* The repo's tile-transcode unit named a venv interpreter that does not exist
on the box (python3.14 there has no ensurepip, so venv cannot bootstrap pip
at all). The live unit uses the system python; the repo now says so too.
The firmware side (the report page, the QR, the opt-in ping) is not in this
commit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Max env defines HAS_TDECK_PRO as well as HAS_TDECK_MAX, because it reuses
the Pro's display and touch drivers, and the OTA name table tested
HAS_TDECK_PRO first. So OTA_BIN_NAME on a Max resolved to wadamesh-tdeck-pro:
the update check found the right release, then downloaded and flashed the Pro
image. Both are ESP32-S3, so the image check passes and the board comes up with
its peripheral power rails, resets and fuel gauge unhandled, which needs a USB
reflash to undo.
This is the failure the comment above that table warns about, and the third
time the table has been wrong (Wio Tracker L2 in beta_80, T-Deck Pro before
it). The Max branch now precedes the Pro branch, the same way the V4-R8 branch
precedes the V4-TFT one.
Shipped in beta_84, so a Max on that build must be updated over USB rather than
from the device.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replace the freeform seconds field with practical duration stops from 30 seconds through one hour and Never, normalizing legacy values to the nearest stop.
Refs #560
Move backup export/import into one workflow, improve narrow settings controls, and make T-Deck Pro/Max dropdowns and lock state e-paper appropriate.
Refs #559
The front page still advertised 11 languages and listed the original
built-in set; the docs said twelve in two places and thirteen in a third.
langs.json carries 14 downloadable languages, so 15 with English.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The promote reused the single-tag notes, so the stable feed and the GitHub
body only covered beta_83 itself. This is the curated beta_66..83 delta,
136 notes: per-build framing and the still-open lists dropped, supersessions
collapsed to what actually ships.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A board added mid-cycle (the T-Deck Max) has no images in an older tag, so
promoting that tag copied a file that does not exist and, with set -e, took
the whole promotion down. It is skipped with a note now, and the flasher
manifest generator leaves out any board with no image in the channel it is
writing, rather than handing the flasher a 404.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Wi-Fi and BLE together at boot leave about 28% of internal RAM free on this
board, so it starts with BLE only; the Settings toggle persists any choice.
HAS_TDECK_MAX gated.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
E-paper adaptations for the Max, all behind HAS_TDECK_MAX: no fades, the
page repainted before power-off so no black frame is left on the glass, a
sleep banner, tap to wake, and the keyboard-backlight chord.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review of #531 turned up four things that had to be fixed before release:
- An unusable region name no longer discards the radio settings. The save
returned early, so frequency, bandwidth, SF, CR and TX power were thrown
away, and the rule rejects names with capitals or a "$private" scope, so
anyone carrying an older region could not change their radio at all. On the
pager that path is the silent blur save, so it failed with nothing on screen.
Now only the region is skipped, and it says so.
- Restored useChainedFont() on the telemetry Show button: without the fallback
chain that label is boxes in Russian, Ukrainian, Bulgarian, Serbian, Greek.
- Region scanning installs unknown repeaters as transient contacts so their
encrypted replies can be matched. They were never removed, so each scan left
up to 16 nameless entries in the contact table (and in Contacts). They are
dropped when the scan ends or the page closes.
- One radio request per scan tick. The loop ran through all 16 repeaters in a
single 200 ms tick, which is that many key derivations back to back.
The V4-R8 also did not build (its src/ files see a vendored lv_conf.h, so the
new text-size fonts were missing) and then did not fit at 101.3%. It builds at
85.6% now: the board no longer carries the compiled-in translations it
inherited from the V4, which it never needed, since with 8 MB of PSRAM its
store works like any other 8 MB board.
T-Deck Max (#545) joins the release matrix: build list, flasher manifest,
DEVICES.md and the site's board list. Board names in the flasher lost their
em dashes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
M9 Back and Bluetooth-keyboard Esc stop at the locked app-drawer root, M9
focus fixes, optional M5Stack CardKB on the V4 and V4-R8, V4-R8 touch and
text-size accessibility, Wio L2 SD retry, high-contrast day and night themes
(#544), channel region discovery (#541) and the Czech translation
contributed in #540 by brebtatv.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
New board: the T-Deck Pro's ESP32-S3 with the peripheral power enables and
resets behind an XL9555 expander and a BQ27220 fuel gauge. Reuses the Pro's
display and touch drivers; the two additions to them are HAS_TDECK_MAX gated,
so the Pro build is unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A built-in USB Files app (T-Deck and Heltec V4 for now) lets the page at
files.wadamesh.com browse, upload, download, rename and delete files on the
SD card, internal storage and the map tiles over Web Serial. File level, not
USB mass storage: the device keeps its filesystems mounted, the radio keeps
running, and the firmware enforces what may change. Live data (identity,
contacts, settings, history; /meshcomod on the card) is download-only.
- UsbFilesProtocol.h: framed messages (E7 5A, CRC32), a resyncing parser,
path checks and the access policy; host tests in test/.
- UsbFilesSession: one request at a time (both Arduino USB serial drivers
drop bytes when their RX queue is full), RX queue sized per session, SD
data through a DMA-capable bounce buffer, FatFs listings with sizes, a
beacon so the page never talks first, SD Scan's malware verdict per file.
- The companion link and MyMesh's console step off USB while it runs.
- Website: labelled side buttons, a USB Files button and card; the
files.wadamesh.com page, vhost and deploy script.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An infected M9 card turned up with 373 findings. Every list row is a focus
stop on a keyboard board, and the firmware's key navigation collects at most
160 of them per screen, so in 1.0 the Remove all and Scan again buttons below
the list could not be reached on an M9 at all.
1.1 puts the buttons above the list, first in focus order, on the results
and failed-removal screens, and shows at most 60 rows plus an "and N more"
row (the title carries the full count). App-only: no firmware change.
Harness: a 373-finding scenario, and a check on every list screen that the
buttons come first and the focus stops stay under 160. It fails on 1.0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
SD Scan for the ThinkNode M9 SD-card worm, the firmware calls behind it,
and the startup warning.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Some ThinkNode M9 cards shipped with a dormant Windows worm (Elecrow
security advisory, September 2026). An infected card seen since carries
autorun.inf in the root, launching xlfqf.pif on open, explore and autoplay
with random-junk comment lines in between: the Sality autorun pattern.
- SD Scan store app (deploy/apps/sdscan/1.0, requires "sd", not seeded):
walks the card a small page per tick, lists what it finds and why, and
removes it after a confirmation screen with Cancel first. It says on
every screen that it only removes files it recognises and that
formatting the card is the safe fix. On older firmware it still finds
threats by name but cannot remove them.
- Firmware: wada.sd.check(path) and wada.sd.remove(path), plus paging for
wada.sd.list(path, start, max) and caps().sd_clean. What counts as a
threat lives in SdThreat.h: autorun.inf, Windows program, script and
shortcut extensions, or a real MZ+PE header under any name. remove()
classifies again in firmware and refuses anything else, so no app can
use it to delete tiles, backups or chat history. It clears read-only,
hidden and system first, because FAT refuses to delete a read-only file.
- A warning when a card with Windows malware in its top folder is mounted,
at boot or on insert, offering SD Scan (or the Store).
- Tests: test/test_sd_threat.cpp, and SD Scan harness scenarios including
the real infected card's root. Removal checked on a T-Deck.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Bluetooth keyboards on every S3 board, PR #523 from oumike, and the web
unlock / console escape / paste key / M9 update notice fixes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Web interface: unlock a manually locked screen (#506). The device publishes
its lock state over the mirror socket and the page shows an Unlock button
while it is set. The lock screen absorbs taps by design and only a held
trackball or BOOT press unlocks on the device, so a browser had no way back
from a screen it had locked itself.
- Console mode: hold the panel for three seconds to leave it (#507). The
banner and `help` both say so. Console mode also applies the "Older keyboard
protocol" setting now: that is applied in the graphical startup path, which
console mode returns before, so the console ran on protocol detection alone.
On a T-Deck that needs the older protocol every keystroke there is garbage,
which is why `ui` could not be typed and the reporter had to side-load a
second firmware to get the device back.
- Console mode: a touch wakes a dark panel again on the touch-only boards,
where the keyboard and button wake paths sit below the console branch's
return. The waking press is swallowed so it cannot also type.
- Paste into a key field lifts the key out of the surrounding text (#526): a
32 hex digit channel secret or a 64 hex digit public key, spaced keys
included. Before, the field filled with prose and the length cap cut the key
off.
- ThinkNode M9: a waiting firmware update is visible (#443). The red "!" over
the bottom-bar gear is built in the #else of that board's block, so the M9
had no update signal at all; it gets a third slot in its own notice row,
steady amber rather than blinking. The Settings tile in the app drawer
carries an "!" on every board.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Pager: the SD card mounts again after the card was used on a computer
(#522): one SD-rail power cycle and retry, and power-off unmounts and powers
the card down
- At a glance: emoji render instead of squares (#521)
- Transfer page: upload an offline OSM tile folder to /tiles on the SD card
(#515), with strict path checks and resumable, atomic writes
- T-Deck Pro: touch release debounce, hardware-timed chat-row holds, and a
typed space no longer starts the screen lock
- Heltec V4 with the original Expansion Kit: the IO button goes back (tap)
or home (hold), larger keys with a magnified preview, and larger status-bar
targets (#525)
Bluetooth serves either the phone app or a keyboard (Settings > Bluetooth).
Pairing lists keyboards in pairing mode and pairs with or without a code;
the paired keyboard reconnects by itself.
Keys are read the way phones and computers read them: the keyboard's Report
Map says which report carries the keys and how, so media keys and touchpads
are left alone, and the boot protocol is only the fallback. Key positions are
translated with the chosen layout (US, UK, German, French, Belgian), with
AltGr and dead keys.
While a keyboard is connected:
- text goes into the focused field, and touchscreen-only boards keep the
on-screen keyboard down (a second tap on a field brings it up anyway)
- the arrows, Tab, Enter and Page Up/Down drive the focus highlight
- Esc is the back button; a "Back key" setting picks another key for
keyboards whose Esc key types a character
- tab hotkeys and their hints over the tab bar are switched on
- Command tapped alone opens the emoji picker in a chat
- the status bar shows a keyboard instead of the Bluetooth glyph
The Bluetooth page is now a flex column with the choice on top, and the
settings page refit keeps it scrollable when its content grows.
Settings: prefs v61 (mode, layout, paired keyboard) and v62 (Back key).
Tests: test/test_hid_report_map.cpp, test_touch_prefs_schema.cpp.
Not on the P4 boards (Tanmatsu, T-Display P4).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pressing down past the last message page-scrolls the chat list with an
LVGL animation. In a long chat the list uses compressed scroll
coordinates, and chatVirtRemap1To1Scroll re-anchored the position on
every LV_EVENT_SCROLL with lv_obj_scroll_to_y(LV_ANIM_OFF). That deletes
the running animation from inside its own step. LVGL 8.4 reads the
animation again after the step, and when the step was also its last
(the UI loop was busy for longer than the animation) it finished the
freed animation and freed it a second time. By the next round that
memory belonged to something else, and anim_timer called a garbage
get_value_cb: the jump to 0x00020000 in the beta_79 and beta_80 M9 dumps,
both at the same call site.
- chatVirtRemap1To1Scroll follows a scroll that an animation drives and
leaves the re-anchor to chatVirtOnScrollEnd. The page scroll now also
completes on long chats; the early re-anchor stopped it after a frame.
- scripts/build/patch_lvgl_anim_uaf.py skips LVGL's completion check when
the animation list changed during the step, as LVGL 9 does. Applied to
every touch env as a pre-script and to the vendored P4 copy
(fetch-deps.sh, build.sh). Idempotent, fails closed on source drift.
- test/lvgl_anim_uaf/run.sh reproduces it on the host under
AddressSanitizer: stock LVGL with the old handler reports the
use-after-free in anim_timer, and either fix alone runs clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It was listed as fully supported, but it is community-maintained and not
tested on every release, and every build before beta_80 fetched the wrong file
when updating itself. The badge now says so, in the same form as the V4-R8
card, and points owners on older builds at the website to update.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The "System Information" key fix (6bb98a1) changed the flat packs in
deploy/apps/lang/, but devices never download those: they fetch the immutable
copy at apps/lang/<ver>/<code>.lang, keyed on the version in langs.json. With
everything still at v21, the store kept serving the old key from lang/21/ and
no device re-downloaded anything, so the fix reached only the Heltec V4, which
bakes the table into its firmware.
Bumped "# ver:" and the catalog to 22 in all 13 languages and snapshotted the
packs into lang/22/, the documented three-step publish. The generator ignores
the version line, so the baked table and the published beta_80 firmware are
unaffected.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds LilyGo_TDeck_Pro_companion_radio_touch to release.sh (as
wadamesh-tdeck-pro), a flasher manifest labelled "(experimental)", and a
website card following the V4-R8 pattern: experimental in the heading,
"Partially supported", beta only.
The card says plainly what is known: contributed through #469, all the main
hardware works, but it has been tested on a single v1.1 unit, v1.0 units are
the least proven, and the e-paper display redraws rather than updating
instantly.
The site is not deployed with this commit: the card's install button points at
manifest-tdeck-pro.json, which only exists once a beta containing the board is
published.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The per-board OTA_BIN_NAME chain ended in a bare #else naming the Heltec V4
TFT, so any board nobody had added to it silently downloaded the V4 image.
Update.end() only checks that an image is valid for an ESP32-S3, which the V4
build is, so nothing stopped it being flashed.
The Seeed Wio Tracker L2 shipped exactly like that. Its build defines only
HAS_WIO_TRACKER_L2, which the chain never tested, and the released beta_79
image carries "wadamesh-heltec-v4-tft" as its update name: an on-device update
on a Wio L2 installs Heltec V4 firmware onto Wio hardware and needs a USB
reflash to recover. The T-Deck Pro (HAS_TDECK_PRO) was in the same position
and would have done the same the moment it was published.
Every board is now listed explicitly, the V4 TFT included (the V4-R8 also
defines HELTEC_LORA_V4_TFT, and its branch already comes first), and anything
unlisted is an #error. Forgetting this table is now a build break instead of
a field brick.
Verified on all twelve OTA-capable targets: each compiles, and each binary
asks for exactly the artifact release.sh publishes for it (checked
mechanically: 10 PlatformIO envs plus both T-Display P4 SKUs, no mismatches).
Units already on beta_77 to beta_79 still have the wrong name compiled in, so
Wio L2 owners must update from the website over USB rather than on the device
until they are on a build with this fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#458 renamed TR("System info") to TR("System Information") and updated the
translation table to match, so the title stayed translated. But
src/ui-touch/i18n_builtin.h is GENERATED ("DO NOT EDIT") by
scripts/build/gen-lang-builtin.py from deploy/apps/lang/*.lang, and the rename
never reached those packs. Every regeneration since then -- the PlatformIO pre-hook
and the IDF build.sh both run it -- quietly wrote the old key back, so the source
asked for "System Information" while all thirteen tables answered "System info",
and the page title fell back to English in every language.
That is also why #517 appeared to revert the rename: its copy of the header was a
freshly regenerated one, faithfully reproducing the stale packs.
Rekeyed the thirteen source packs, then regenerated. The generated table changed by
exactly one key per language and nothing else, which confirms it was otherwise in
step with the packs, and the fix now survives the build's own regeneration, which is
the thing that used to undo it. The corrected packs reach devices that download a
language once the app store is republished with the next beta.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Consolidated development PR from @oumike (with @aigarslv and @tmetz1987),
taken up to 703afb9: everything except the final merge that brought in the
MQTT Observer (#518, 618ffd0). That feature is held back for a separate
decision and lives on its own branch.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The MeshCore founders' givealittle fundraiser has closed (reported on
Discord by jason000008), so the banner was sending visitors to a page that no
longer takes donations. Removed the markup, its styles and its dismiss script
together.
The corner button rails need no change: .fab-rail already falls back to
top:16px, which is exactly what the banner script set once it was dismissed,
so everyone now sees the layout returning visitors already had.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1.1 logs a survey to CSV and notes in its own docs that there is no on-device
viewer for it, so checking coverage meant pulling the card and opening a laptop.
1.2 adds a second screen that reads the run's own log back and plots it on the
firmware's basemap via wada.map, with each stop coloured by whichever direction
of the link you ask about. Colour cycles through how well we heard them, how well
they heard us, and the worst imbalance.
Design notes a reviewer may want up front.
- A sweep writes one row per responder against a single GPS fix, so a corner
where ten nodes answered is ten rows sharing one coordinate. The loader
aggregates by STOP, not by row, keeping the best link in each direction and the
largest-magnitude single-reply asymmetry at that stop. Drawing a raw row would
have coloured the marker by whichever node answered first, which is arrival
order and none of the three metrics the button offers.
- The log is read in 4 KB windows, one per tick, and finishes only on a read that
yields nothing. A callback gets 100,000 VM opcodes and parsing a whole log in
one would exceed it; and since queued rows keep draining while the screen is
open, treating a short read as end-of-file silently dropped everything appended
mid-walk.
- Stops are capped. Past the cap the reservoir halves in place and widens its
stride, and it carries the newest stop across each halving and appends the
final position when the walk ends, so the drawn route ends where the run did.
Simulated: a 5,000-stop run keeps 315 stops spread 79/78/79/79 across the four
quarters of the route and ends on stop 5,000. A prefix cap would have drawn the
first 400 and nothing after.
- Drawing is strided to at most ~150 stops. Each marker and segment is an LVGL
object created synchronously and the host puts no ceiling on an app's view
(k_map_markers_max belongs to the Map tab), so the bound has to come from the
app, on a loop the radio shares.
- The map view is closed before any ui.clear(), routed through one helper so the
order cannot be broken by a later edit. See the note in drop_map: MapUd is the
only userdata type without a generation field, so clearing with a view open
strands the one-view counter and leaves a stale pointer. Reported separately.
- ui.canvas argchecks 1..480 on both dimensions where map.view allows 1..800, so
the bare-plot fallback asked for 792 px on an 800 px board and would have
raised inside the very branch that exists to keep the app standing. Clamped,
and the call is guarded.
- Probing pauses while the review screen is open, per the store's guidance that a
survey should probe on a cadence its user chose. Entering banks the in-flight
probe only if it is still inside its harvest window, and otherwise drops it, on
the same terms the tick path uses; both call one shared predicate so they
cannot drift apart. Either way the discovery set is cleared so nothing crosses
the pause.
- Zoom and centre are fitted in Web Mercator units, matching latLonToWorldPx:
latitude costs 1/cos(lat) times as many pixels as longitude, and the visual
centre is the inverse of the mean projected y rather than the mean latitude.
Both matter on a long north-south run. A run straddling the antimeridian is
fitted on latitude alone, with the centre longitude unwrapped onto 0..360
before averaging so it lands near the line rather than on the far side of the
world, and the card says it happened.
- Layout reserves every fixed element and gives the plot the remainder, because
on_open is handed the body height rather than the panel height, and a
half-height plot pushed the control row off the bottom. The two-line label
reservations were checked against the shipped glyph tables at the narrowest
label width, which is what shortened two of the strings.
- The manifest gains "icon": "map". 1.1 shipped without one, so the drawer tile
falls back to the generic app symbol; an app cannot ship artwork, and of the
sixteen glyph names map is the one that describes what 1.2 now does. Drop it if
you would rather the tile stayed as it is.
TWO FIXES HERE BELONG TO 1.1, NOT TO THE REVIEW SCREEN. Both corrupt the file the
new screen reads, which is why they are here, but say the word and I will pull
them out and send 1.2 as the viewer alone.
- Ticks pause while the display sleeps; millis() does not. On waking, the harvest
deadline is long past, the radio still holds replies heard at the old position,
and sys.gps() reports the new one, so those replies were logged in the wrong
place. Pocket the device, walk 400 m, wake it. Sweeps dropped this way are now
counted on the status line, alongside a sweep discarded because the survey was
stopped mid-listen - two causes, one counter, and the label says dropped rather
than naming either.
- Renaming a run wrote the previous run's queued rows into the new file, and
wrote_header being process-local appended a second header mid-file whenever an
existing log was reopened. The on-device reader skips those lines; a
spreadsheet does not.
TESTED ON: nothing. I have a T-Deck Plus but have not run this, so treat every
runtime claim above as read from the firmware source rather than observed. Some
specifics are worth naming rather than hiding. The body-height figure and the
capability, argcheck and projection details above come from reading
src/ui-touch/, not from the SDK page, which is out of date in at least two places
(it documents an input event type the firmware never emits, which is why this
version has no on_input, and it omits both argcheck ranges). The riskiest
unverified behaviour is whether a map view can be created again after being
closed on a screen switch; that is guarded by pcall with a canvas fallback. Label
wrapping at the narrowest width is calculated, not seen.
Written with AI assistance, then reviewed by nine independent passes and two
cross-vendor panels. Every finding that survived verification is fixed. The one
that mattered most was the arrival-order colour above, which the first panel
missed and the second caught.
ONE QUESTION, not decided here. 1.2 is 34,159 bytes against 1.1's 8,477, and
wardrive already requires sdk_ext, so boards that cannot run it still pay for it
in the baked catalog. "seed": false looks right to me, but the README calls that
your knob, so the row is left exactly as it was.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: tmetz1987 <tylerzmetzger@gmail.com>
Same QWERTY deck as English (no letters removed, so loanwords/names still type normally) with the three most common extra letters - s-caron, c-caron, z-caron - promoted directly onto the on-screen deck, matching how Romanian's a-breve/s-cedilla/t-cedilla were added. The remaining eight - a-macron, e-macron, g-cedilla, i-macron, k-cedilla, l-cedilla, n-cedilla, u-macron - reach the physical T-Deck keyboard via the number row (same trick as Romanian's digit row) and the touch keyboard's existing accent popups on their base letter.
Also fixes kbLayoutCode()'s k_codes[] table, which was missing an "RO" entry (so the on-screen language-cycle button mislabeled Romanian as "EN"); LV is appended alongside the fix.
All 22 upper/lower Latvian glyphs sit in Latin Extended-A (U+0100-017F), already covered by the bundled extras_* fallback fonts, so no font changes are needed.
Two Discord reports a day apart on different boards: museifu696 (M9, beta_78)
had no charging symbol at all, and thesupergeek (V4-R8) found charge state not
being detected after comparing against a T-Deck.
For the M9 this was structural. batteryIsCharging lived inside
#if defined(HAS_TDECK_GT911) || defined(HELTEC_LORA_V4_R8)
whose #else returned a flat false, so on the M9 -- and the Pager, RAK Tap, Wio
L2, Attaky, P4 and Tanmatsu -- no voltage could ever produce a charging icon.
That split was a real, evidence-based decision, but it was about the Heltec V4
alone: its noisier ADC path made the EMA drag the average down so the threshold
never tripped (the note above the block explains it). The #else then answered
the question for every OTHER board too, purely by falling through, and nobody
had evaluated those.
So share only the verdict, not the sampler. The EMA stays gated exactly as
before, which means battery PERCENTAGE on the direct-read boards is untouched
and the V4's original complaint cannot recur: the direct read is what it keeps,
and its plugged-in reading already sat high.
Sharing is sound rather than assumed: the same mv feeds batteryPercentFromMv(),
so a board reporting the USB rail instead of the battery would already show a
permanent 100% and someone would have said so.
Kept deliberately PURE. batteryMvSmoothed()'s charge_flip calls this twice with
different arguments to compare the old and new verdicts, so a latching Schmitt
trigger would corrupt itself; flap is bounded by the 20 s publish hold and the
50 mV margin instead. The threshold against each board's uncalibrated ADC still
wants on-device confirmation, as the V4-R8 note already says.
Two follow-ons: kBattChargingMv (4250) is removed, dead and misleading since the
test actually uses batteryFullMv() + 50; and the idle-power-save comment that
reasoned from "the M9 is in batteryIsCharging's #else branch, so the USB-powered
gate can never block" is corrected, because it now can, which is the point of
that gate.
Reported-by: museifu696, thesupergeek
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Kaj's T-Deck coredump: abort() on core 1, decoding to luaG_callerror ->
abort() reached from luaAppPress(). luaG_callerror is "attempt to call a ...
value", and the abort is Lua's DEFAULT panic function, because lua_atpanic was
never installed anywhere in this file. So any error reaching Lua with no
protected frame resets the device.
The callback itself was never the problem: guardedCall wraps it in pcall with a
traceback handler and turns a fault into a toast plus a clean close, which is
the host's whole contract. The hole is everything that runs BEFORE that pcall
is armed:
- pushCallback() used lua_getfield, which honours __index. An app table
carrying a metatable whose __index is not callable raises exactly
luaG_callerror, unprotected. It is now a raw lookup, which is also the
correct semantics: callbacks are plain fields on the table the chunk
returned, and no shipped app uses setmetatable.
- the argument marshalling in sendInput/sendKey pushes a table and several
values before the pcall exists, and those raise on stack growth or OOM.
lua_checkstack appeared ZERO times in this file; pushCallback now reserves
the room up front, and checkstack reports failure rather than raising.
lua_atpanic is installed as well. It cannot prevent the abort -- Lua aborts once
a panic function returns and there is no safe frame to unwind to -- but the
error text now reaches the serial log and a toast instead of vanishing, which is
the difference between a reproducible report and a mystery reset.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#464 (PD0RCM, T-Deck Plus): "I can hear the sample sound and the volume change,
but not incoming messages." That board has the hardware, and previews and
arrivals go through the same playback function on it, so the audio path was
never the problem.
Every preview on the Sound page played UNCONDITIONALLY: the sample, the volume
steps, and each per-type toggle. An arriving message checks the master Sound
switch and Do Not Disturb. So with the master off the page chimes at you the
entire time you configure it while nothing ever sounds on arrival, which reads
exactly like a broken notification path and is almost certainly what he hit.
A preview is now silent precisely when a real notification would be, and says
which of the two silenced it rather than just going quiet. The master switch
keeps its own confirmation chime, because that fires exactly when you turn it
ON, which is the one case where a sound is the right answer.
Telemetry position (v59, honza_87628, via Discord): the advert displacement of
#399 was extended to telemetry answers because a privacy setting a telemetry
request walks straight around is a hole rather than a feature. He then made the
fair counter-argument that an answer is not a broadcast: it is encrypted to one
contact who already holds the permission, and it only goes out because they
asked. Losing the ability to give trusted contacts a real fix is a real loss.
So make it a choice instead of an assumption. New tail field telem_loc_exact,
OFF by default, so the private behaviour is what you get unless you say
otherwise, and the broadcast advert stays displaced either way. The switch sits
directly under "Share my location when asked", where the decision belongs.
Schema 58 -> 59. The host test enumerates every appended field and caught the
change, as designed; all three of its migration assertions are updated and it
passes.
Reported-by: PD0RCM, honza_87628
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
jesshampshire asked for the Discover pane to present like the Chats and
Contacts panes. It was the only one of the three without a leading icon, so a
repeater and an ordinary peer looked identical until you read the meta line
under the name.
Same glyph set and the same 22 px column the Contacts rows use: antenna for a
repeater, loop for a room, person for everything else, rendered in g_font_16 so
the FontAwesome PUA codepoints resolve through its fallback chain. The name and
meta labels shift right by that column and lose the width to it.
The type stays spelled out in the meta line as well: the icon cannot tell a
sensor or chat node from a plain peer, so dropping the word to buy the width
back would lose information on exactly the rows where it matters. On the narrow
boards that line ellipsises as it already did.
Requested-by: jesshampshire
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#467 (Buko84): leaving a page stashes the focused row so the cursor can go back
where it was, and it stashed it as a raw pointer. Reading a channel's unread
messages clears its badge, which rebuilds every thread row -- so on the way out
that pointer is either dead or, worse, recycled by the allocator onto a
DIFFERENT row, which is how the cursor lands on an unrelated channel. With no
unread messages nothing rebuilds and it works, which is exactly the split he
identified.
Identify the row by the slot it occupied instead. A list rebuilt in place keeps
its order, so the slot is reliable where the address is not; the pointer is only
consulted when the number of targets actually changed. The restore also scrolls
that row back into view now: the rest of the rebuild deliberately runs with
focus-scroll suppressed, which is right for the transient focus during
recollect, but it left the restored cursor off-screen whenever the rebuild had
also reset the list's scroll position.
#465 (jrote1): his follow-up screenshot shows the gate passing (178406 cycles,
last wake timer) but only 15% asleep. 178406 parks at the 50 ms throttle is
~2.5 h, which against 15% puts uptime near 16.5 h -- so every park runs its full
length and the parks are not the problem. The device simply was not ELIGIBLE
for the other 85%, and nothing reported which of the six gate conditions was
holding it shut.
So charge the wall time between checks to whichever condition was blocking at
that moment, and surface the biggest one ("held by mesh busy 62%") on the
Battery page and the live Lock-settings diag. gatePasses() becomes gateBlocker()
returning the condition rather than a bool. Gaps over 5 s are dropped rather
than blamed on a condition, and the clock is stamped AFTER a park so a completed
park is never mis-attributed to the next blocker.
Also: device_caps.h computed CAP_SOUND from HAS_UI_SOUND, which UITask.cpp does
not define until ~1400 lines AFTER that header is included -- so CAP_SOUND was 0
on every board. Nothing consumed it, so it was silent rather than broken, but
any future `#if CAP_SOUND` would have compiled notification sound out
everywhere. The board list moves into device_caps.h (every macro involved is a
-D on the command line, so all of them are genuinely visible there) and
HAS_UI_SOUND now derives from CAP_SOUND. Verified per board: on for T-Deck,
V4-TFT, V4-R8, M9, Pager and P4; off for RAK Tap, Wio L2 and Attaky, which is
the pre-existing behaviour, unchanged.
Reported-by: Buko84, jrote1
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Piguyraspberry reported the P4 lock screen never appearing and its settings
page rendering blank. Both are the same thing: the board declares
CAP_LOCK_SCREEN but nothing behind it was ever wired for it.
- nothing can REACH the lock. The control-center Lock button is compiled
only for HAS_TDECK_GT911; the other triggers are the Tanmatsu volume key
and the Pager's own hold handler.
- nothing can LEAVE it. Every unlock path is a physical-control branch
(T-Deck trackball hold, Tanmatsu Vol-, M9 d-pad, Pager Backspace, Wio
wake button) and the P4 matches none. The on-screen hint even falls
through to "hold the trackball to unlock" on a board with no trackball.
- the DSEC_LOCK settings body is gated on HAS_TDECK_GT911 || HAS_THINKNODE_M9
rather than on the capability, which is why the page came up empty.
The Heltec V4-R8 sits in exactly this position and is deliberately 0, with a
comment saying enabling it is a feature rather than a cap flip. Same call
here. The settings card is already skipped under !CAP_LOCK_SCREEN, so this
removes the empty page rather than papering over it.
#451 stays open as the tracking issue for actually building it: a touch
reveal + hold-to-unlock path, a P4 hint string, and capability gates on the
settings section.
Reported-by: Piguyraspberry
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
Buko84 reported the #433 Contacts delay still happening on the M9, with a
precise tell: smooth when nothing is being discovered, "hanging in the
middle" when something is.
That is exactly what the advert path did. Every advert sets
s_ct_contacts_dirty, and while the Contacts tab was visible that forced a
full teardown+rebuild of the list past its count-cache -- ~1.3 s at ~570
contacts -- once every 2.5 s for as long as adverts kept arriving. With no
adverts the flag never sets, refreshContactsList() hits the cache and
returns immediately, and the tab feels fine. #433 moved the rebuild after
the 150 ms tab slide, so the slide now paints first and the freeze lands
mid-transition instead of before it.
Everything an advert actually changes on a row that is already rendered is
the name-fill of #73 and the Heard age, so write both in place instead:
ctRefreshAgeLabelsInPlace() becomes ctRefreshRowsInPlace() and also
re-renders the name (re-picking LONG_DOT vs LONG_SCROLL_CIRCULAR from the
name-column geometry the last full build recorded). A brand-new or deleted
contact changes getNumContacts(), which the count safety net right below
catches on its own, so the #73 guarantee still holds.
Ordering and filter eligibility do still need a real rebuild -- an advert
can move a contact in the last-heard sort, or hand it the GPS fix the "has
location" filter wants -- so that converges on the same 60 s cadence the
age tick already uses rather than every 2.5 s.
Reported-by: Buko84
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three fixes to the setting shipped in beta_78, all from users reading it
critically after release.
The offset leaked the true position if you changed the radius. jesshampshire
spotted it on #399: bearing was derived from the node identity alone, so it was
the same at every radius, and switching from 100 m to 1 km moved the reported
point further along the SAME ray from where you actually are. Anyone who saw
both could intersect them and recover the exact location, which is the one thing
this setting exists to prevent. The radius is now folded into the derivation, so
each setting lands on an unrelated bearing. Checked: the three radii now differ
by 225 degrees of bearing, while a given radius stays stable, so the original
property holds too. That stability is deliberate and not laziness: a fresh random
offset per advert would scatter points around the true position and averaging a
night of them would recover the centre.
Telemetry answered with the exact position, spotted by honza_87628. querySensors
adds a GPS entry from the live coordinates, so a node that asked for telemetry
got the real location while adverts were displaced, making the setting a hole
rather than a feature. The displacement now applies across that call and the true
values are restored immediately after, so the local map and GPS page are
unaffected.
Selecting a radius broke the settings page. Reported by honza_87628 and confirmed
by Buko84 on an M9: the callback called buildDeviceSettings(), which is the entry
point for OPENING a category, so it stacked a second page over the live one. The
back button went stale, Close did nothing, and the change looked reverted because
you were seeing the page underneath. The four buttons are now repainted in place.
All nine S3 envs and both ESP32-P4 targets green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A release with twenty-odd notes pushed the whole install flow off the screen, so
the box that is meant to be a summary had become the page.
It is a native <details> now, collapsed on load, reading "See what's new in
beta_78 - 24 changes". The count matters: a bare "click here" asks people to
gamble a tap, while a number lets them decide whether they care. Native rather
than scripted so it opens with the keyboard, works without JavaScript, and does
not need a click handler; the JS only fills the count and forces it closed on
each channel switch, so flipping stable/beta cannot leave a stale panel open.
The default disclosure triangle is hidden and replaced with one that rotates,
because that marker renders differently in every browser.
Markup verified as balanced and the element confirmed to carry no "open"
attribute, which is what makes it start collapsed. I could not render it in a
browser to check visually: the harness refuses to navigate to localhost, and a
file:// page is served as a static snapshot with no JavaScript, so the count and
the collapse behaviour are unverified on screen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Buko84: the notification text is too big on the M9. It is, and the fix was
already made once for the same reason.
28 px was chosen so the message is legible from across a room, which it is, but
on a 240x320 panel it fills the glance and crowds out the message it is supposed
to be showing. The T-Deck was moved to 20 px for exactly this and the M9 has the
same size panel; nobody widened the gate at the time.
Three things had to change, not one: the extern declaration, the 20 px Latin
fallback font's own build gate, and a LV_FONT_MONTSERRAT_20 flag on the M9 env,
which was not building that size at all. Missing any of them is a link error
rather than a small font, which is why it is worth naming.
All nine S3 envs and both ESP32-P4 targets green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two changes to the at-a-glance notification, both asked for by Kaj.
The title now carries the sender and the hop count, not just the thread. A
channel post reads "#public - Alice - 3 hops"; a direct message reads
"Alice - direct" without naming the sender twice, since a DM is already titled
with them. Zero hops says "direct" rather than "0 hops", and 0xFF is
OUT_PATH_UNKNOWN, an advert that arrived by flood, so it is omitted rather than
printed as "255 hops" -- the same trap that produced the Discovered list bug.
The point is that a count off a dark screen tells you nothing you can act on,
whereas a named contact one hop away against channel chatter from five hops out
is the reason to pick the device up at all.
Muted channels no longer light the screen. That was arguably backwards: muting
silenced the chime while the message still woke the display and printed itself
in large type, which is the louder half of the notification rather than the
quieter one. Muting is a statement about a conversation, so it now covers both.
Mentions keep their own mute bit, matching how the chime already treats them, so
a channel muted for chatter still surfaces an @-mention. If mute should instead
mean nothing at all gets through, that is a one-line change.
All nine S3 envs and both ESP32-P4 targets green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Buko84 again, and the same defect I fixed for the lock-screen picker: each swatch
set its colour for the default state only, so the theme was free to repaint the
focused one. On a d-pad board the swatch you are standing on is always focused,
so it turned white and hid the colour you were choosing.
I fixed the lock-screen picker and missed this one, which is the second instance
of the same mistake in the same file. The colour is now pinned across the
focused, pressed and checked states here too, and focus draws as a ring outside
the swatch so it can never cover the colour.
The swatches are also scaled rather than raw pixels now, the same class as #238.
All nine S3 envs and both ESP32-P4 targets green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five new user-facing strings arrived with #429/#439/#440/#441/#442/#444/#445.
All nine S3 envs and both ESP32-P4 targets green on the merged result.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Conflict with the M9 pending-focus block from #440 resolved by keeping both and
making the ordering explicit. #442 reworks the focus-hint logic so a hint
belonging to the page behind a newly opened overlay cannot suppress the
new-chat composer fallback (#436); #440's block places focus deliberately and
sets focus_set. Without a guard the reworked chain fell through to the
just-opened-chat branch and moved that focus, which is precisely what #440's
block exists to prevent, so the chain now has an explicit first arm for it.
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>
dreirund reported the device as frozen at "Download mode... reflash over USB".
It was not frozen: that is the state working as designed, waiting to be flashed
with its own UI stopped. They accepted that once told, and made the fair point
that the button should say what it will do.
So the menu entry now reads "Download mode (wait for USB flash)", and the
message shown before the UI stops says that RESET cancels, which is the piece of
information whose absence turned a working feature into a bug report. It also
stays on screen long enough to be read.
A state a user cannot leave without already knowing the way out is a bug in the
telling, even when the code is right.
All nine S3 envs and both ESP32-P4 targets green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Written now so cutting it is a build and a publish rather than a writing session.
Not tagged and not published.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#238 was a card laid out in raw pixels while the text inside it scaled, so the
contents outgrew the box. That is a class rather than one bug, so I swept the
file for containers sized in raw pixels large enough to hold text and found
eight more: the lock and unlock progress cards, the pager locking bar, the
on-screen keyboard's text mirror, and the accent-colour hex entry row.
None of these has been reported. They are latent rather than theoretical: the
same conditions that produced #238 apply, and #418 is a live report of
translated labels overflowing their buttons, so a longer translation inside a
fixed box is a real case rather than an imagined one.
SC() is identity at 100%, so this changes nothing on a board that already looks
correct, and only grows the box where the text inside it has already grown.
Also swept for the other documented recurring trap in this UI, a geometry read
taken before layout has run. Zero instances left, so that one is genuinely
closed rather than merely quiet.
All nine S3 envs and both ESP32-P4 targets green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Filed as a code trace rather than a reproduction, with the caveat that it would
rather be challenged than sat on. Challenged, and it holds: every link is still
there in current main.
sdHealthTick asserts the tile-backend pause and then returns while holding it if
the SD lifecycle is busy. Holding it across busy ticks is deliberate, so queued
tile requests cannot keep winning the boundary while recovery waits, and for
every busy reason but one that is fine.
The exception is a pending About-page SD-usage rescan. The tile worker services
that request near the top of its loop but takes the backend lease later in the
same pass, so a request armed in between can only be cleared by the very task
the pause has parked. The flag keeps sdRuntimeLifecycleBusy() true, the tick
keeps returning, the pause keeps the worker parked, and nothing else can break
it: the card is locked out until reboot. Opening Settings, About is enough to
arm it.
So for that one reason the pause is released and the worker allowed to drain.
Recovery is not abandoned, it retries on the next tick; it just stops holding
the door shut on the only task that can open it. That is the same reasoning as
the reader excluding itself from this predicate a few lines above, with a
comment saying it would otherwise deadlock, so the pattern already had a
precedent in this file.
All nine S3 envs and both ESP32-P4 targets green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both reported on the T-Display P4 by D-Melhede, and neither is P4-specific once
you look: they are hardcoded pixel values in a UI whose text scales.
#236, a Lua app's first line cut in half by the status bar: the app host set its
body offset from a hardcoded 44, commented as matching the AppPage tall bar.
That is only true where STATUSBAR_H is its 22 default. The bar is
SB_TOP_PAD + SB_ROW*2 on the round-corner phone panel and SC(22) everywhere
else, so it grows with the UI scale, and anywhere it exceeded 44 the app drew
under it. The host now asks for the height the bar actually has. The same
mistake was already fixed elsewhere in the file, with a comment saying to
measure with statusBarCurH() rather than assume; the Lua host had not caught up.
That also means this was never only a P4 bug. Any board at Large or Huge scale
had it, which includes the Tanmatsu, whose default has been Large since v29.
#238, the drawer options not fitting: the card is laid out in raw pixels while
the text inside it scales, so the contents outgrew the box. Now scaled with SC(),
which is identity at 100% and therefore changes nothing on a board that was
already correct, and the card is additionally clamped to the sheet's height so a
larger scale cannot push it off screen.
All nine S3 envs and both ESP32-P4 targets green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Twelve new user-facing strings arrived with #413. Added as placeholder rows so
the audit is clean and translators have the keys to fill in.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
s_nav_drawer_corner and s_nav_drawer_gear are declared inside #if CAP_KEYPAD_NAV
but were assigned unconditionally, in both drawer-close paths and twice while
building the grid. On a board without keypad navigation the declarations are not
there and the assignments are, so the Heltec V4 TFT and the Wio Tracker L2 did
not compile.
Same trap as twice already today. It is invisible on the board the change was
written for and only the matrix finds it, which is the argument for building the
matrix rather than the board on the desk.
All nine S3 envs and both ESP32-P4 targets green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Conflict with #407 resolved in favour of the newer gesture: #407 changed the M9
unlock from a hold to a double-press, and #409 branched before that and still
described the hold. The M9 strings keep #407's wording; #409's Wio Tracker L2
arm is taken as written, since that board's gesture is not what changed.
Conflict with #414 resolved by keeping both intents: the theme chip's icon stays
the sun in either state, which is #414's point and matches the DND chip since
#396 (the fill carries the state, so a changing glyph reads as a different
control), while taking the keypad-nav id #412 adds. #412 branched before that
decision rather than disagreeing with it.
Requested by dreirund: with no GPS fix, no Wi-Fi and no companion app there was
no way to set the clock at all. That is not cosmetic, because message timestamps
are built from it, so a device used purely offline could not hold a correct one.
Settings, Clock now takes local time as "YYYY-MM-DD HH:MM". mktime() reads it
through the configured zone, the same zone the clock is displayed in, so what you
type is what you see rather than something you convert to UTC yourself, and
tm_isdst is left at -1 so summer time is not an hour out. The field is prefilled
with the current reading, which makes a correction an edit of a digit or two
rather than typing a full stamp on a device keyboard.
Rejected values are refused the same way every other clock source is: below the
send-timestamp floor is not accepted, so a mistyped year cannot walk the ratchet
backwards.
All nine S3 envs and both ESP32-P4 targets green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#415: a Lua app was covered by the app drawer whenever a message arrived.
Reported by jadestarwatcher, reproduced by mysterywavi, both on T-Deck. Mine,
from the #393 badge fix. Apps launched from the drawer deliberately leave it
alive underneath (appTileCb says so outright), and my badge refresh rebuilt it on
every unread-count change, with openAppDrawer() ending in a move_foreground that
threw it on top of the running app.
The refresh now runs only when nothing is drawn in front of the drawer, tested by
sibling order rather than by listing the tools, so a tool added later is covered
without anyone remembering to update a list. The status bar is excluded, since it
legitimately floats above the drawer at all times and treating it as covering
would have stopped the badges refreshing at all. Leaving the signature stale is
what makes it self-healing: the check runs again each tick, so the count is right
by the time the drawer is back in front, with no need to hook every close path.
Scroll position is now preserved across the rebuild too, which my change had also
been resetting.
#410: on the M9 the accent variants appeared but could not be selected. Also mine,
from the #387 work. The picker was handled below m9HandleArrowKey, which takes
LEFT and RIGHT for the caret and returns, so the arrows never reached it: the box
was drawn and nothing could touch it. Lifted above that call, as its own function
rather than a second copy of the logic.
#399, requested by @Danie10: an option to advertise a position near you rather
than your address. Settings, GPS, cycling exact / 100 m / 250 m / 1 km.
The displacement is derived from the node identity, so it is the same offset
every time. That is the whole point rather than an implementation detail: a fresh
random offset per advert would scatter points around the true position, and
averaging a night of them would recover the centre exactly. A fixed displacement
instead looks like a node that sits somewhere else, which is what a manually set
location already looks like. It applies only to our own adverts; the map and the
GPS page keep the real fix, because the aim is to tell other people less, not to
lie to yourself.
Schema v57. The host test caught the new field the moment it was added, which is
what it is for.
All nine S3 envs and both ESP32-P4 targets green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Drive the L2 I2C backlight during sleep and wake, poll its expander button reliably, and remove irreversible software power-off. Make a two-second wake-button hold lock or unlock, and keep Home and empty-map chrome clear and readable.
Signed-off-by: Michael A. Cojocari <michael.cojocari@gmail.com>
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>
Restrict the drawer settings gear to Up or Right from the top-right tile so other top-row Up presses stay put. Hide the M9 unread-message blinker while the drawer is frontmost.
Signed-off-by: Michael A. Cojocari <michael.cojocari@gmail.com>
Day/Night themes, the Seeed Wio Tracker L2, the M9 panel actually sleeping,
P4 Wi-Fi honouring its own setting, three Lua SDK fixes, and reboot notices
that are visible.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The forward declaration went in among the keypad-nav ones, which sit inside
#if CAP_KEYPAD_NAV. On every board without keypad navigation the declaration
vanished while the three calls stayed, so the Heltec V4 TFT and the Wio Tracker
L2 did not compile. Same trap the popup-registry declaration already documents.
Moved to where every touch board sees it. Caught by building the matrix rather
than the one board in front of me, which is the whole reason for building it.
All nine S3 envs and both ESP32-P4 targets green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Changing the theme restarted the device with nothing on screen, which reads as a
crash rather than as the setting working. Kaj reported it on hardware.
The cause is that showAlert() only CREATES the notice; it is painted by the next
LVGL frame, and rebootDevice() never returns to one. So announcing a reboot and
then calling it immediately shows nothing at all. My first attempt at this was
exactly that mistake, and it changed nothing on the device.
The language switch already solved it, and the comment there names the failure:
the device went dark under the user's finger, so the reboot is scheduled on a
one-shot timer and the notice gets a frame to paint. That is now a shared
rebootWithNotice() helper, and the theme switch uses it.
Screen rotation and remote mode had the identical defect. Both announced a
reboot and then rebooted immediately, so neither notice had ever been visible;
both now go through the same helper. Any future setting that reboots should.
Verified on a T-Deck: clean software reset, no panic, and the earlier crash
report did not recur. The coredump partition was erased first, so that is a real
observation rather than an absent one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three things Jade reported, the first of which is blocking an app submission.
Missing keys on the T-Deck. A Lua app there never received A, P, Q, Enter or
Backspace. The rule that withholds them exists so an app can never swallow its
own exit, which is right on a board where a reserved key IS the only way out.
On the T-Deck it is not: an app page is closed by tapping the back bar, and the
app host is not in the popup registry, so those keys closed nothing while an app
was running. They were simply dead inside every app. They are now forwarded.
The M9 keeps its reservation, because its reserved keys are sentinel bytes for
Back and Home that are never typed text and it has no touch to tap out with, and
the Pager already reserved nothing. That is why this was T-Deck-only.
The trackball click. With an app open the trackball branch forwarded motion and
explicitly discarded the button, so an app could be steered but never clicked.
It now delivers the same synthetic centre tap the touchless boards' select key
sends, on the press edge: the flag is a per-frame "held" level, so forwarding it
raw would have fired a tap every frame the button stayed down.
Repeat counts. The firmware already counts repeaters heard rebroadcasting an
outgoing flood, keyed on a fingerprint taken at transmit time; it is the refresh
glyph on a sent bubble. wada.mesh.send() now returns that fingerprint as its
second value on success, and wada.mesh.repeats(fp) reads the count back. Additive,
so an app reading only the first return is unaffected. The count grows as repeats
arrive, so it is polled rather than read once, and a direct message has none
because it is not flooded. Documented on the SDK page.
Built on all nine S3 envs and both ESP32-P4 targets.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
oumike. A firmware-wide appearance setting: Night stays the default and renders
exactly as before, Day is a low-glare light palette with its own semantic roles
for text, panels, fields, borders, controls, focus, charts, status colours and
chat surfaces. Selecting a mode restarts, so every LVGL object is rebuilt from
one coherent palette rather than half-repainted. There is a Night/Day selector
in Settings, Display, the Control Center Theme chip is now a direct toggle whose
sun/moon icon shows the active mode, and the standalone console UI has a
matching Day palette.
Main already carried the palette scaffolding for this, pinned to Night behind a
comment saying it was waiting on this branch, so the merge mostly replaces those
stubs with the real thing.
The prefs schema needed care. The branch appended theme_mode as v54, but v54 and
v55 were taken by boot_wifi_time/boot_wifi_open and loud_alerts before this
merged, so the field moves to the tail behind them and the version becomes v56,
with the trailing-field assert and the migration step moved to match. A packed
struct read back at the wrong offsets is the failure this schema's asserts exist
to prevent, so the invariants from both sides are kept rather than one replacing
the other.
That also surfaced a stale test: the v53 case asserted that v53 plus exactly two
bytes was the whole struct, which stopped being true when loud_alerts landed and
was not caught because the host test was not run then. It now counts every byte
appended since and asserts the new fields come back at their defaults. The
schema host tests pass. While there, the size comment said "about 500 bytes";
the struct is 114.
Built on all nine S3 envs and both ESP32-P4 targets.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
oumike. This is the actual merge: the previous commit claimed it and did not
contain it. The merge had refused because the working tree still held the
swatch fix, and the error was hidden by a filter on the command's output, so
only the swatch change was committed under a message describing both.
What lands here is oumike's work. Keypad navigation could skip a whole row on
the Send advert screen: candidates were scored as axis distance plus an off-axis
penalty with no notion of rows, so a wide dropdown two rows down beat the narrow
right-aligned switch directly beneath. navMoveDir now finds the nearest row band
first and only scores within it, which is direction-generic and so fixes the
same skip sideways.
Also: the focus highlight was invisible on two dropdowns created without inline
styling, since reverse-videoing to white on a stock-light-theme card is white on
white; an open dropdown list was clipped by the title bar because the existing
clamp callback was never wired to those two; and the Do Not Disturb chip now
uses the crescent moon the status bar already uses instead of a slashed bell
sitting beside the Sound chip.
navMoveDir is shared by every keypad board, so the whole matrix was rebuilt on
the real merge rather than on the earlier commit that lacked it: all nine S3
envs and both ESP32-P4 targets, all green, with both sides of the merge verified
present in the tree.
Tested on hardware by the author on T-Deck, ThinkNode M9 and T-LoRa Pager.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
oumike. Keypad navigation could skip a whole row on the Send advert screen,
because candidates were scored on axis distance plus an off-axis penalty with no
notion of rows, so a wide dropdown two rows down beat the narrow right-aligned
switch directly beneath. navMoveDir now establishes the nearest row band first
and only scores within it. That is shared navigation code, so the whole matrix
was built: all nine S3 envs and both ESP32-P4 targets, all green, and nothing
from today's work was reverted by the merge.
Also in it: the focus highlight was invisible on two dropdowns that were created
bare, since reverse-videoing to white on a stock-light-theme card is white on
white; an open dropdown list was clipped by the title bar because the existing
clamp callback was never wired to those two; and the Do Not Disturb chip now
uses the crescent moon the status bar already uses, instead of a slashed bell
sitting next to the Sound chip. Tested by the author on T-Deck, M9 and Pager.
Separately, the lock-screen colour swatches (#389, Buko84). Each swatch had its
colour set only for the default state, which left the theme free to repaint the
focused and pressed ones. On a d-pad board the swatch you are standing on is by
definition focused, so it turned white and you could not see the colour you were
about to choose, which is what Buko84 described. The colour is now pinned across
those states and focus shows as a ring outside the swatch rather than a fill
over it.
That is the picker, not the reported black lock text, which is still open.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four small UI fixes, all reported on the ThinkNode M9 build.
Keypad nav could skip a row. navMoveDir ranked candidates by
"primary + 8 * cross" (gap along the pressed axis, plus a heavy penalty
for being off-axis), with no notion of rows. On Send advert, DOWN from
the Flood button landed on the "Flood every" dropdown two rows below it:
the dropdown is wide enough to overlap Flood's x-range, so its cross
penalty was 0, while the "On boot" switch directly beneath is narrow and
right-aligned, so its horizontal gap out-weighed a whole extra row of
distance. navMoveDir now walks the candidates twice — the first pass
takes the nearest element's rectangle as the row (or column) band, the
second discards anything lying entirely past that band before applying
the existing score. A press can no longer jump over an intervening
focusable, while alignment still decides within a row. Split the shared
geometry into navDirCandidate() and navDirMetrics() so both passes use it.
The focus highlight was invisible on the advert dropdowns. LVGL's stock
theme here is the light one (nothing calls lv_theme_default_init, and
lv_conf.h leaves LV_THEME_DEFAULT_DARK at its default), so a dropdown
with no explicit styling renders as a near-white card — and navFocusCb's
highlight reverse-videos the focused widget to COLOR_TEXT, painting white
on white. The settings pages never hit this because they set COLOR_PANEL
inline on every dropdown they build; the two on Send advert were created
bare.
An open dropdown list was clipped by the title bar. lv_dropdown's
constructor parents its list to lv_obj_get_screen(), which for a page on
lv_layer_top is that layer, so the list sat below the status bar in the
child order and got covered where they overlapped. clampDropdownListCb
already exists for exactly this ("wire onto every settings dropdown") and
was simply never wired here.
Both of those are now handled by one styleDropdown() helper — panel
colours and the 12px font on the button and its list, an accent-filled
selected row (LVGL draws it in LV_PART_SELECTED with the CHECKED state,
which the stock theme fills with its own blue primary rather than the UI
accent), and the clamp — applied to the two advert dropdowns. The
settings pages keep their inline styling for now.
Do Not Disturb gets its usual crescent moon. The control-center chip used
a bell that gained a slash when DND was on, which reads as "notification
sound" and collided with the Sound chip beside it; the status bar already
used the moon. The chip's own fill (solid accent on, faint off) carries
the state, so the glyph no longer changes with it. No new font asset:
sleepicons_font already carries U+F186 and is spliced into the g_font_16
fallback chain that ccToggle renders its icon in.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
jrote1 is fitting a battery-backed DS3231 to the T-Deck's I2C bus so the device
keeps time across a full power-off without needing GPS or Wi-Fi. The RTC adapter
already had the hard parts, refusing an untrustworthy read and revalidating every
write, so this teaches it a third chip rather than adding a second path.
Three things differ from the soldered PCF parts and all three are handled by
normalising into the existing decoder rather than duplicating its rules:
- It lives at 0x68 rather than 0x51. That is also what makes it safe to add: it
cannot be mistaken for either chip an existing board carries.
- Day-of-week and day-of-month are swapped in its calendar block.
- It reports "my time is not trustworthy" as OSF in a status register instead of
in bit 7 of seconds, so that bit is folded into the one the decoder already
refuses on. A module whose backup cell went flat is therefore rejected the
same way a powered-off M9 is, rather than having its frozen calendar believed.
0x68 is a busy address, so identification requires a positive DS3231 trait
rather than an ACK: its temperature register pair, whose low byte is a quarter
degree in the top two bits and zero elsewhere, with a high byte in a range a
working chip could be at. A module that has never been set by us can also arrive
in 12-hour mode, which is converted rather than decoded as if it were 24-hour.
Transparent when no module is fitted, which is nearly every T-Deck: reads come
from the software clock, writes stop there, and the probe logs that nothing
answered. CAP_HARDWARE_RTC stays 0 for the board, since this is an aftermarket
addition rather than something a T-Deck has, so the cold-boot network time sync
stays on offer for everyone else.
Unverified on hardware: neither of us has the module wired yet. The host tests
for the shared decoder still pass.
Built on all nine S3 envs and both ESP32-P4 targets.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The M9's screen was never really going off (#390, Buko84, who spotted the lock
screen still faintly visible in bright light). Backlight off is not screen off:
the ST7789 keeps refreshing the same static image behind a dark backlight, which
is exactly what retains an image into the glass. The panel-sleep command the
T-Deck and V4 use was a no-op stub on this board and the M9's backlight path
never called it anyway. Both halves are fixed. The M9 takes the display driver's
default constructor branch, on the global SPI instance it shares with the radio
and with no PIN_TFT_SCL/SDA defined, so the command goes over that same bus
object inside a transaction rather than a second one on pins this board does not
declare.
The Discovered list showed impossible hop counts (#394, jesshampshire). 0xFF is
OUT_PATH_UNKNOWN, meaning an advert arrived by flood with no known path, and it
was being printed as a number: "255 hop". It now says the path is unknown, says
"direct" for zero hops, and pluralises, so a two-hop node no longer reads as
"2 hop".
Keyboard backlight controls are hidden on the older T-Decks (#382,
jesshampshire), which have no controllable backlight, so those controls did
nothing while still looking like settings. The "Older keyboard protocol" switch
added in beta_74 already declares which keyboard is fitted, so it gates these
too rather than asking the same question twice. The other half of that request,
swallowing the backlight key combo so it does not type a stray character, needs
to know which character actually appears; asked on the issue.
Also removed a duplicated pair of declarations in the prefs header.
Built on all nine S3 envs and both ESP32-P4 targets.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Buko84 asked whether the M9's notification sounds can be made louder. There is
no volume setting because there is nothing to turn: the buzzer on these boards
is a bare piezo on a GPIO driven by tone(), a fixed-duty square wave, so
amplitude is simply whatever the part does at that pitch.
Frequency is the lever that does exist. A piezo is far louder near its mechanical
resonance, typically around 4 kHz, than at the 1 to 2.6 kHz the chime uses, so
"Loud alerts" in Settings, Sound plays the same three-note shape shifted into
that band. Same chime, so it still reads as the same alert rather than a new one,
and toggling it plays it so the difference is audible while the switch is still
under your finger.
Off by default and opt-in, because the exact resonant peak varies by part and
this has not been measured on an M9. Schema v55, field appended at the tail.
Built on all nine S3 envs and both ESP32-P4 targets.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nordic letters on the M9 (#387, leon699). The letters were never missing: ø is
in the 'o' accent set and å and æ are in the 'a' set. The picker that reaches
them was switched off on this board because it is tap-to-pick and the M9 has no
touch, and the key-driven path built for the Pager was gated to the Pager. The
M9 does have a d-pad, and its mention box already uses it, so the accent box now
works the same way: left and right walk the variants while the box is up, Enter
takes one, Back or Backspace dismisses. There is no arming step, because the box
only exists for the moment after typing a base letter, so the arrows go back to
the caret as soon as it closes. The Accent popups setting is now shown on the M9
as well, since it finally controls something there.
Found while doing it: the key-selection cap was 8 while the 'a' set holds 9
variants, æ having been appended for #237 without bumping it. So æ specifically
was unreachable by key selection on the Pager too, which is the exact letter
class this issue is about.
The app drawer's message count was stale after unlocking (#393, Buko84). The
badges are a snapshot taken when the grid is built and nothing refreshed them,
so a message arriving while the drawer was open stayed invisible until you
navigated away and back, which is precisely what the reporter found. The refresh
tick now rebuilds on an actual change of the counts, not on every tick.
Also a guard, prompted by chasing a wrong theory: the whole prefs struct is
persisted as one blob, and the file-backed store rejects any value over 2048
bytes silently, so crossing that ceiling would stop every preference saving with
nothing in the log. It is about 500 bytes today and grows most releases, so the
ceiling is now a build error instead of a future support thread.
Built on all nine S3 envs and both ESP32-P4 targets.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Wi-Fi came back on after a power cycle on the T-Display P4 while every switch
in the UI read off, and the radio really was on: it downloaded a map tile
(#373, wb6zsu, with photographs of both states). The C6 runs its own AT
firmware with auto-connect enabled, so it rejoins the last access point by
itself at power-on, before the firmware ever asks it to. The loop that enforces
the radio pref only acts on a change, and on the first pass it recorded the
state instead of applying it, so "off" never became a transition and the join
the C6 made on its own was never torn down. That is also why toggling Wi-Fi on
and then off fixed it: that made a transition. The state is now applied on the
first pass, once the AT link is actually up. The Tanmatsu carried the same
first-pass logic and got the same fix.
The map's own location marker is a bare white glyph and vanishes on light
basemaps (#366, 100monkeys). The contact markers a few lines below it already
carry a dark border for exactly this reason and self never got one, so it now
gets the same: a dark chip behind the glyph, which reads on any basemap without
spending a colour that already means something else.
Wi-Fi passwords can be revealed while typing (#381, jesshampshire). Entering a
PSK blind on a device keyboard is a real failure mode: the reporter needed about
ten attempts to join their own network. The toggle sits on the password label's
line so it costs no vertical space, and it switches the keyboard mirror too, or
the characters stay masked in the one field being looked at.
The composer shows a character count (#350, jrote1). The 160-character cap was
silent: typing just stops, which reads as a broken keyboard. It appears only in
the last quarter and marks the limit when reached, so an ordinary short message
carries no readout. Counted in codepoints to match how the cap is enforced;
by bytes an emoji would read as four characters.
Built on all nine S3 envs and both ESP32-P4 targets.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The PR added the board and its env, which makes it build. It does not make it
ship. Three lists decide that and none of them knew about it: release.sh ENVS
(what gets built and named into a release), gen-flasher-meta.py BOARDS (which
manifests the web flasher gets) and the flasher page itself, which lists boards
by hand.
Without these the board compiles cleanly forever and never appears in a release
or on the flasher, which looks like nothing is wrong.
Named wadamesh-wio-tracker-l2, its own Seeed Studio section on the flasher,
marked beta-channel-only like the Attaky was on arrival, since stable is still
beta_65 and this board has never been in a stable build.
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>
oumike's #384closes#374: MeshCore seeds _txt_last_ts from an unrestricted
random uint32 and sendMessage() then raises the real RTC timestamp above it, so
outbound direct messages carried future dates on most boots. Patched in the
vendored core, fail-closed and idempotent.
#386closes#383: hardware RTC for the Pager's PCF85063A and the M9's PCF8563
with validation on both sides, time retained through a full power-off, and an
opt-in cold-boot Wi-Fi time sync. Prefs schema to v54, appended at the tail with
the assert updated.
cvhviz's #385 makes the M9's battery saver reachable at all (the machinery was
wired but the only enable switch was compiled for two other boards), fixes
GPS-off leakage, and enlarges the GPS UART ring mid-session.
Two build problems, neither visible to the authors:
- #384 wires its core patch into the IDF builds as a fail-closed VERIFY, and
the vendored copy in this checkout predated it. Applying the same patch step
fixes it; the error already said to run fetch-deps.sh, which is the right
design.
- #385 calls gpsEnsureBigRxRing() from UITask.cpp and MyMesh.cpp, which every
target compiles, while the definition is in src/main.cpp, which the two
ESP32-P4 targets never compile. Both P4 builds failed to link. A no-op
definition for those boards lives in UITask.cpp now, which is the standing
rule for anything shared between the UI and the Arduino main. A no-op is
honest here: neither P4 drives GPS through Serial1.
Built on all eight S3 envs and both ESP32-P4 targets.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
byrdo1308's Tanmatsu sits permanently on "migration pending, Last save FAIL",
and his boot capture contains NO migration lines at all. That is the useful
fact: the migration is not failing verification, it is bailing before anything
loggable happens.
There were two silent early returns and no success line either, so from outside
"never attempted", "gave up immediately" and "ran and failed" were
indistinguishable. Now each says which:
starting
no scratch buffer (allocation failed)
read-back verify FAILED across N segment(s); old file kept, will retry
cannot create the commit marker; old file kept
OK, N segment(s), M bytes
His other answer narrows it further and is worth recording: contacts and
Discovered DO persist across reboots on that device, so FFat works and this is
specific to the chat store's migration rather than the filesystem.
No behaviour change, only diagnostics. His next boot log should name the step.
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
Read the caveat before taking this one: it does NOT fix a live bug. The
sweep was suspected of causing repeated ~1.7 s ui:lvgl stalls that appear
only while the Spectrum page is open. Instrumenting it disproved that -
measured on M9 hardware the sweep costs 5181-5904 us/bin, so the constant's
own "~5.5 ms/bin -> ~44 ms UI block" estimate was right all along and eight
bins really is on budget.
That measurement is the "re-verify with a micros() log on hardware" the
standby(0x01) comment has been asking for; the verified figure is now
recorded at the constant so nobody re-derives it, along with a pointer that
the real cost is in the REDRAW path (spectrumPushWaterfall /
spectrumDrawTrace - ~1.8 s of LVGL compute against only ~190 ms of panel
flush), not the sweep.
What remains is a cheap guard: bound the tick by wall clock rather than a
fixed bin count, so a slower radio or a busier bus can never turn it into a
multi-second block. It is clamped so it can only ever REDUCE bins per tick,
never raise them above SPEC_CHUNK - on hardware it simply settles at 7-8,
i.e. today's behaviour, on every board. Hysteresis stops it flapping 7<->8,
since 8 x ~5.5 ms sits inside the jitter band of the budget.
Drop this commit if you would rather not carry the guard; nothing else
depends on it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Christopher Van Hoose <cvhvisuals@icloud.com>
The one that matters most is #354: tileCacheRemove() was written for the
firmware's own LittleFS cache but resolves through s_tile_fs, which is
re-pointed at the SD card root on every board that caches tiles there. So it
was calling SD.remove() on the user's own offline map pack, permanently, for
every queued tile whose .jpg happened to be absent. Gated to the internal
partition now.
The rest: remount through the full ladder after an in-device format (#359),
live labels no longer overlap the rows beneath (#357), hidden and inert widgets
stay out of the d-pad focus group (#358), keypress work is attributed in the
stall ring (#360), and GPS acquisition is measured from when it started rather
than when you looked (#365).
Built on all eight S3 envs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
s_ign_names_migrated was set before the work, so a failed re-open for write
(the same NVS failure ignWriteAll returns false for) latched a fold that never
happened. From then on ign_nm2 does not exist and ign_nm is never consulted
again, so ignNamesReadAll returns 0: every name block silently stops firing for
the rest of the boot. Worse, the next Block tap writes a fresh ign_nm2 holding
only that one entry, and the next boot's migration sees ign_nm2 present, skips,
and the old list is orphaned for good.
Latch on success instead, and remove ign_nm only after the new key actually
holds the list -- keeping it costs nothing, since nothing but this function
reads it and this function reads it at the correct old stride.
The "nothing to do" case still latches on the first call, so the steady-state
cost the latch exists for is unchanged.
Widening UiSegMsg from 233 to 240 bytes is safe to READ in both directions --
the header is self-describing and readHistoryRec prefix-reads or skips the
surplus. Appending is not. uiSegAppendRecords writes sizeof(UiSegMsg) into
whatever file the table points at, and after an upgrade that file is the
still-non-full active segment whose header says 233:
record 100: reader reads at 23316, record starts at 23316 (off by 0)
record 101: reader reads at 23549, record starts at 23556 (off by 7)
record 102: reader reads at 23782, record starts at 23796 (off by 14)
Every record after the first appended one is read at the wrong stride. The
damage is silent and it escalates: garbage bubbles, and a `seq` read out of a
long name's tail bytes lands far in the future, which makes the monotonic
filter drop every later segment and leaves _ui_seq_next poisoned. The next
compaction writes the result back for good.
Only a non-full segment can ever receive an append, so flag exactly those for
the existing rewrite_open repair when their on-disk width is not ours. Step 0
of segBuildJob runs that before any append and lays down a fresh header at the
current width; a full old-width segment is left alone, since it reads correctly
from its own header. The synchronous drain was never exposed -- it turns every
append into a whole-segment rewrite already.
The mirror case is documented on UiSegMsg rather than fixed: an older build
reading this record is fine, but an older build APPENDING to it writes 233-byte
records into a 240-byte-header file. That write belongs to the old firmware and
no change here can prevent it -- a version bump would only turn it into an
outright rejection.
MeshCore names run to 31 characters (ContactInfo::name[32]), but
UIMessage::sender held 24, so every longer name was cut on the way in.
d304d3f made the cut visible instead of letting it push the author into
the message body; this makes it stop happening. MAX_SENDER_NAME goes to
31 — exactly the wire width, so nothing is lost.
Truncation could never fix three of these. "Ack" and "Mention" insert
@[<sender>] into the composer, and textMentionsMe needs the whole name
followed by ']', so a mention of a long-named node never reached them.
Block-by-name compares the stored name against the sender on the RX
path; for ROOM posts those two came from different widths already — the
check sees the untruncated 31-char author while the stored entry came
from the 24-char field — so a room author with a long name has simply
been unblockable. Info and the chat-list preview showed a cut name.
The obvious change — widening UiHistoryMsg::sender — would have been
quietly destructive, so the on-disk width is frozen at 25 instead and
the overflow is APPENDED to UiSegMsg, after seq. sender sits between
thread and text, so widening it in place shifts text for every record
already on disk, and nothing would have caught that: the segment store
is what actually holds messages now, and uiSegOpenValidated checks only
magic/version/rec_size — it never consults k_ui_history_version or
k_ui_history_min_version, which guard the legacy files alone. An old
segment would have passed validation, prefix-read into the new offsets,
lost the first 8 characters of every message body and read seq as 0 (so
loadMsgsFromSegments renumbers the lot), and the next compaction would
have written that back permanently.
Appending at the tail is the evolution path UiSegHeader already
documents, and it is correct in both directions: an old 233-byte record
zero-fills the new field and yields its original short name, and older
firmware reading a new 240-byte record copies the first 233 bytes and
ignores the tail. That second half is why k_ui_seg_version STAYS 1 —
bumping it would make older firmware reject the file outright and
quarantine a history it could have read. So there is no migration, no
version bump, and no wiped chat history. static_asserts now pin the
offsets an already-written record depends on.
TOUCH_IGNORED_NAME_LEN has to move in lockstep (28 -> 32) or blocking
breaks: a slot narrower than a sender stores a cut name the full-width
check can never match, and the block sits in Settings doing nothing.
Its NVS blob has no header — the entry count is its length divided by
the slot width — so re-slotting in place would mangle every entry after
the first and the next write would make that stick. The key is renamed
ign_nm -> ign_nm2 and the old blob folded in once at first read, which
is unambiguous in a way that sniffing the blob length is not (28 and 32
share multiples at 224 and 448, both inside the 16-entry cap). The
invariant the header only stated in prose is a static_assert now.
Split/rejoin is a host-tested header next to ChannelSenderSplit, with
the field-width bounds spelled out: a corrupt record can leave the name
field unterminated, and the message body is what follows it on disk.
Signed-off-by: Ruben Laban <ruben@tun0.nl>
The split accepted any "X: " prefix once the length test came out, so a post
that carries no author but does contain ": " — "the repeater at Ouderkerk is
back up: full quieting again" — got read as an author of 36 characters. That
invents a sender AND hides the start of the body.
The old `slen <= MAX_SENDER_NAME` test used to catch this as a side effect;
dropping it for long names dropped the plausibility check with it. Restore the
check at the WIRE width (31 — MeshCore keeps every name in a char[32]) rather
than the destination width, so a real 31-char name still splits and truncates
while a 32-plus "prefix" is left in the body where it belongs.
A static_assert pins kMaxWireName >= MAX_SENDER_NAME, so widening the field can
never leave the split rejecting names the field holds fine.
A channel post arrives on the wire as "SenderName: body" — the author is
in the text, not a separate field. Both split sites gave up when the name
was longer than MAX_SENDER_NAME (24), and giving up meant the sender fell
back to from_name, which for a channel is the CHANNEL name. The bubble
then read
#channel date
nick: message
instead of the author over their own message. MeshCore names run to 31
chars (ContactInfo::name[32]), so this hit every post from a node with a
25+ char name — the "occasionally" in the report.
Truncate the label instead of rejecting the split, backing off to a UTF-8
character boundary so a multi-byte name is never cut mid-sequence (the
missing-glyph sanitiser would render the tail as '*'), and mark the cut
with "..." so a shortened name does not read as a different node.
Widening MAX_SENDER_NAME is the real cure but not an option here: sender[]
sits in the MIDDLE of the persisted UiHistoryMsg record, and this file
format only tolerates fields APPENDED to the end — moving `text` would
force k_ui_history_min_version up and discard everyone's chat history.
The two copies of the split are now one host-tested header, matching how
Utf8Text/ReaderContent are factored.
Signed-off-by: Ruben Laban <ruben@tun0.nl>
s_gps_acq_since is stamped in exactly one place: lazily inside gpsStatusStr(),
on its first call. And gpsStatusStr() only runs from the GPS settings page
(build + live refresh) and the Control Center GPS chip (build + refresh).
So the "GPS: searching Nm" figure measured time since the user first LOOKED at
the status, not since the receiver was switched on. Boot with GPS enabled, walk
around for twenty minutes, then open the GPS page: it reports "searching 0m02s"
as if the module had just come up. That reads exactly like "GPS only starts when
I navigate into the GPS view" — which is how it was reported.
The receiver itself was fine. UITask::begin already re-applies the saved
gps_enabled pref at boot, and the M9 sets ENV_SKIP_GPS_DETECT so the detect race
cannot suppress it.
Stamp the clock where the state actually changes — in toggleGPS() and at the
boot resume — and leave the lazy stamp in gpsStatusStr() as a fallback. millis()
can be 0 at boot and gpsStatusStr treats 0 as "not started", so both sites use a
non-zero sentinel.
This matters beyond cosmetics: the comment above that string explains it
deliberately reports elapsed acquisition time because the driver exposes no
satellites-in-view, no SNR and no HDOP — the elapsed figure IS the diagnostic.
It was reporting the wrong quantity.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Christopher Van Hoose <cvhvisuals@icloud.com>
Cold map open, Back navigation, the SD map-pack deletion, the label overlaps
and the measurements behind them - including the correction to the earlier
"the remaining cold-open cost is the SJPG decode" note, which was reasoning
about the two smaller halves.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Christopher Van Hoose <cvhvisuals@icloud.com>
(cherry picked from commit 68fe4dbc09f450cc1e1545573cf9c53658bcf840)
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)
uiCp("ui:input") lives inside #if CAP_TRACKBALL, which device_caps.h sets to
0 for the M9 - while the M9's 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 was billed to whichever bucket happened to be open,
which is ui:threads. A cold map open was reported to the user as a slow
chat-threads refresh, and the About page's stall ring - which exists so a
field reporter can photograph it - was actively misleading.
Give the M9 its own checkpoints, and split what was one coarse bucket into
the three things it actually contains:
ui:navreb nav focus-group rebuild (walks the widget tree)
ui:kbpoll I2C keyboard drain + compass/IMU idle ticks
ui:keys handleHwKey: the real work (page builds, list rebuilds, ...)
uiCp() is two loads and a compare, so this costs nothing. Work that reaches
LVGL via navPushTap still lands in ui:lvgl, since the handler runs inside
lv_timer_handler; that is correct and worth knowing when reading the ring.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Christopher Van Hoose <cvhvisuals@icloud.com>
(cherry picked from commit 9ba3eac6d72bcba85bee58cc39d740fd24c9ba41)
Upstream #343: formatting the microSD from the device reports that there is
no card present.
The format worker's remount was a single bare SD.begin at 4 MHz with no
settle - but sdcard_uninit() immediately above it issues GO_IDLE_STATE, so
the card is back in SPI idle and has to re-run its whole power-up handshake.
That is exactly what fmSdTryMount's ladder exists for: seven rungs from
4 MHz down to 400 kHz with 40-900 ms settles, plus an upward renegotiation
after a slow success. Its own comment flags the timing as UNVERIFIED on M9,
which is why the format path must not hand-roll a weaker version of it.
Also stop claiming the card is absent when it is not. f_mkfs succeeding and
the remount missing are different outcomes; reporting "no card" for the
second sends people hunting a hardware fault that is not there. Track the
mkfs result separately and word the two cases differently, and clear the
10 s mount backoff on the failure path so fmShowRoots() below does not hide
the SD row for ten seconds while the toast says to reinsert it.
Not reproduced on hardware: doing so means formatting the card, and on the
M9 that card is soldered and holds the only copy of the node identity.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Christopher Van Hoose <cvhvisuals@icloud.com>
(cherry picked from commit b85eca606d417f212614d634571f3f3d55802e23)
Two places where the keyboard-nav collector picked the wrong container, so
Back's result was a screen the d-pad could not drive.
- The background-notification chip was a non-NAV_SKIP child of lv_layer_top
with nothing focusable inside (it is not CLICKABLE). navTopHasVisibleChild
therefore re-rooted the group onto it and navCollect found nothing, so the
focus group came up EMPTY for the chip's ~1.1 s life - d-pad and OK inert,
status-bar actions skipped - on every incoming message.
- The Spectrum page's NAV_SKIP_FLAG root hid it from the same two functions,
so the collector decided the top layer showed nothing and re-rooted onto the
app drawer still open BENEATH the full-screen page: the d-pad moved an
unseen focus ring across hidden tiles and OK launched them.
NAV_PASSTHRU_FLAG keeps the page as the focus container - but note that
passthru only excludes the CONTAINER, and navCollect still recurses. In
LVGL 8 lv_chart does not clear LV_OBJ_FLAG_CLICKABLE the way lv_label and
lv_img do, and lv_obj_remove_style_all does not touch flags, so the chart
and the five colour-ramp swatches became focus stops and were painted
reverse-video - a white block straight over the live trace. They now carry
NAV_SKIP_FLAG so the group ends up genuinely empty, which is what the
original comment always claimed. Anything added to that page later must do
the same or it becomes a focus stop by default.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Christopher Van Hoose <cvhvisuals@icloud.com>
(cherry picked from commit 2c1dfe6ea10c7f2eaa002d1a6e07aa96b9118ecb)
Follow-up to the label-overlap sweep: the seven lower-severity hits, same
root pattern as before - a label measured once at build time, or given no
width at all, then handed text of a different length.
Note how many only bite in a NON-ENGLISH build. i18n_builtin.h carries
German, Hungarian and Dutch, all markedly longer than English, and these
layouts were sized against English alone. The Bluetooth one already
overflows in English.
Overlaps (text grows over what is beneath):
- First-boot wizard header: the title has LONG_WRAP at width sw-84, but y
advanced a hardcoded 24 px, so a wrapped title ran through the blurb and
every step's content under it.
- Radio & Mesh: the duty-cycle/time-on-air readout has a width (so it wraps)
but a fixed SC(22) advance; a 4-digit time-on-air - any SF11/SF12 preset -
puts its second line through the MESH divider.
- Add contact / the three channel modals: the error label is EMPTY at build,
so 24 px was reserved for text that did not exist; a validation message
then wrapped over the submit button. Pin the height to the reservation.
- Contacts > Discovered: the auto-add hint names every enabled type TWICE,
so with several enabled it wraps past its fixed SC(38) onto the first card.
- App drawer > cog: "App drawer as home" had no width and ran under its own
right-aligned switch.
Clips (no width set, so the label cannot wrap and runs off the edge):
- Settings > Bluetooth pairing hint - give it a width so it wraps.
- Channel "Region & scope" hint - one line, ellipsized: the textarea below is
pinned at SC(72) and this sits at SC(54), so there is no room for a second
line and wrapping would print over the input.
Two findings from the sweep are deliberately NOT changed:
- The Discover feed's LV_LABEL_LONG_CLIP is load-bearing, not an oversight:
the comment records that one line per row is what makes the tap-to-add
Y->row mapping work. Its clipped trailing columns are a content-priority
question, not a layout bug.
- Compact chat rows bake their offsets into the virtualised list
(s_chat_virt.offsets) while the delivery glyph is in one state; a later
glyph change grows the row into the next. Fixing that means re-measuring
rows on delivery-state change, which is the chat virtualisation and
deserves its own change with its own testing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Christopher Van Hoose <cvhvisuals@icloud.com>
(cherry picked from commit fe35be577c705f9d0774877aae5879afe24c241d)
Most pages here lay out children with a manual cursor - lv_obj_set_pos then
y += height - so nothing reflows. Four labels were measured ONCE at build
time and then re-texted with content of a different line count, printing
over whatever sat below.
- Settings > GPS: gps_status is refreshed every tick. Built with GPS off it
is a one-line "GPS: off", so y advanced ~22 px; toggling GPS on - the
obvious thing to do on that page - swaps in "GPS: searching Nm" plus a
~100-character cold-start sentence that wraps to several lines and lands
on the "GPS serial baud" row. Reserve the tallest possible string instead,
probing BOTH long variants since which one wins depends on the language.
- Settings > About: three children each pinned to the measured height of the
one above. The live tier gains a hard newline the moment a chat-store write
fails, and the slow tier grows as the microSD scan publishes and the stall
ring fills from 0 to 6 rows. Neither has a fixed upper bound, so a
worst-case reserve is not possible - use a flex column and let it reflow.
- Settings > Wi-Fi and Files: both use LV_LABEL_LONG_DOT, which only
ellipsizes against the object's HEIGHT - and a label defaults to
LV_SIZE_CONTENT. With no height set, a long SSID or a deep path wrapped and
grew instead of getting its dots, running into the network list and under
the file list respectively. Both had a comment promising one line; pin it.
Found by sweeping the other views after the GPS report; the remaining hits
were lower-severity clips and are left for a follow-up.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Christopher Van Hoose <cvhvisuals@icloud.com>
(cherry picked from commit 2c8805c0f7bd43ee1432ff3cdf2f500bbf27c589)
The M9's Back ladder was a copy of pagerNavGoBack MINUS its last rung, so
once every layer was peeled it fell through to navPushTap(LV_KEY_ESC) - and
nothing in this build consumes LV_KEY_ESC. The only consumer is lv_dropdown,
peeled several rungs earlier, and navMaybeRebuild deliberately keeps the tab
bar out of the focus group. The press was swallowed by the return true and
NOTHING happened, on every bare tab.
Restoring the Pager's rung alone only gets you Home, so this 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 - and 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.
- Never stores an lv_obj_t*: settings sheets, app pages and the drawer are
destroyed, often via del_async. A stale entry 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, or that jump records the tab it is leaving
and the next Back pops straight back to it - Back ping-ponging forever.
- The app drawer is deliberately NOT stacked: it is a registry row the ladder
already closes, and its mode survives a tab switch.
Fixed alongside, all reachable on this board:
- openMeshContactDm switched tabs with a bare lv_tabview_set_act and no event
send, so tabChangedCb never ran, s_lv_tab_prev went stale, and the NEXT tab
change skipped the only code that hides the DM overlay - the chat stayed
painted over whatever tab you moved to.
- Back was a dead key for the whole first-boot wizard, even on the three steps
that draw a working on-screen Back wired to setupBackCb.
- Contacts select mode outlived its tab, so Back closed it INVISIBLY from
another tab. It now clears on leaving Contacts, the discipline map pan mode
already had. Deliberately not via ctSetSelectMode(false), which ends in a
forced ~1.3 s list rebuild (#82) - that would freeze the tab being entered.
- navGoToMainTab ignored the registry's key-blocker rows and switched tabs out
from under a running SD format or bulk delete. It now returns bool and stops;
M9_KEY_LEFT_MESSAGE and M9_KEY_MAP check before destroying an app page.
Verified on M9 hardware.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Christopher Van Hoose <cvhvisuals@icloud.com>
(cherry picked from commit 4f84ec6268737413de6684aebdc5bcc97da77819)
Measured on hardware, a 9-tile cold open was 2598 ms. Four changes, in
descending order of what they actually bought:
- renderMapTiles repainted the whole screen after EVERY tile. On the
immersive map each lv_refr_now also alpha-blends the transparent status
and tab bars over the full panel: ~115 ms a call, nine times. That cost
more than the SD reads and the JPEG decodes COMBINED (measured
read=497ms decode=539ms other=1042ms). Throttle it to one paint per
k_map_progressive_ms, first tile still immediate. Saves ~684 ms.
This corrects the standing assumption that the remaining cold-open cost
was the decode - it was not, and nobody had counted the compositing.
- onMapTabActivated called bestAvailableZoom() unconditionally and then
discarded the result whenever a zoom was persisted. Worse, with no GPS
fix the centre stays 0,0, s_map_view_inited never latches, and the whole
17-level probe re-ran on EVERY open rather than just the first. Skip it
when the answer cannot be used. Saves ~410 ms.
- The "Loading map..." hint was set AFTER that probe, so the scan ran with
the outgoing tab still frozen on the panel. Move it to the top, and skip
the tab-slide for the map so the flush is not racing the animation.
- tileExistsAt probed .png while the generic loader only ever opens .jpg,
so the zoom guard advertised - and the auto-snap selected - levels that
render blank. Mirror the loader.
Also gate s_tile_fetch_dirty on the visible zoom, matching the rule the
already-cached path above it already used: the 29-tile background prefetch
pyramid was driving full grid re-renders for tiles that are never on screen.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Christopher Van Hoose <cvhvisuals@icloud.com>
(cherry picked from commit a14ab185c9e2f1a3c54ee285d486e1c2b7a05901)
tileCacheRemove(path_png) in the fetch worker was written to drop a stale
.png from an older build on the firmware's own LittleFS cache partition.
But s_tile_fs is re-pointed at the SD card ROOT on every board that caches
tiles to the card (the M9 always; the R8, T-Deck and Pager under various
conditions), with s_tile_root "" — so unguarded it resolved to
SD.remove("/tiles/<z>/<x>/<y>.png"), deleting 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.
Confirmed on M9 hardware: the boot log reports
"[TILE] caching Wi-Fi tiles on SD /tiles", i.e. exactly this aliasing.
Gate it on s_tile_fs == &s_tiles_fs, the same discriminator
tileCacheMarkSdIo() and tilesFsLowSpace() already use.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Christopher Van Hoose <cvhvisuals@icloud.com>
(cherry picked from commit bccd7f33f27d4a5cee9bd1f72ffc23ca989e7a55)
Vybo reports no change in beta_73. The runtime demotion I added is not firing on
his device, and I still do not have the one serial line that would say why. So
rather than guess a third time, give him a switch.
Settings > Keyboard > "Older keyboard protocol" skips raw detection entirely and
uses the protocol every T-Deck controller understands. The point is that it is
reachable BY TOUCH: when detection guesses wrong the keyboard types the wrong
letters, so any remedy that needs the keyboard is not a remedy. It applies
immediately, without a reboot, because the driver re-reads the flag every poll
and will drop out of raw mode on the spot.
The cost of turning it on is modifier latching, which a controller that needs
this switch cannot do anyway.
Schema goes to v53, appended at the tail with the existing static_assert
updated to point at the new last field.
This does not replace finding the real cause; it stops people being stuck while
I do. Automatic detection is unchanged and still the default.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
jrote1: symbol and alt latch, shift does not, so a capital letter still means
holding shift down while reaching for the key.
Shift was a bare "is it held" test while the other two ran through
LatchedModifier. It has its own now, so it behaves identically: tap once to
capitalise the next letter, tap twice to lock until you tap again.
The board has two shift keys and they share one latch. Whichever you press arms
it and either releases it, which is what someone alternating hands would expect.
The one-shot is consumed by the key it capitalises, in the same place symbol and
alt are consumed, so it cannot leak onto the following letter.
Still to do from that issue: an on-screen indication of which modifier is
currently latched. That is a UI change rather than a driver one and wants its
own look at where it belongs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
oumike. The board has nine onboard buttons and only POWER was wired; the front
D-pad and SELECT did nothing. They hang off an AW9523 expander rather than
direct GPIO, so the driver drains an expander queue in attakyNavPump().
Adds ATTAKY_MESH_SERIES to CAP_KEYPAD_NAV, which makes it the first board
carrying that flag with no physical keyboard. He flagged that himself; the
matrix build is what proves nothing behind the flag assumes a keyboard.
jrote1: a route lists resolved repeater names, and when a name is wrong or two
repeaters share one there is nothing left to identify the hop by. The hash is
what the node is actually keyed by on the wire, so it is the part that stays
true exactly when you are reading a route to work out where a message went.
Each hop now reads "3A Repeater Name" rather than just the name. The unresolved
case is unchanged, since it was already showing the hash.
Also pisti87's two corrections (#347): "Known regions" had never been
translated, and the Bluetooth pairing hint gets his shorter wording. Languages
to v21.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
bradhawkins85: a T-Deck on BW 62.5 shows 62 in the remote web settings, and
applying anything from that form sends 62 back to the radio. So opening the page
and pressing Apply silently reconfigures the radio onto a bandwidth the mesh is
not using.
Two independent halves, both needed:
- The settings JSON emitted "bw":%.0f, so 62.5 was already 62 before the
browser saw it. Now %.1f.
- The form's bandwidth input had no step attribute, so it defaults to step=1
and a decimal is invalid. The frequency field beside it has step=0.001; this
one was simply missed. Now step=0.1.
The apply path was never wrong: it parses with %f and passes a float through.
Only the outbound direction lost the fraction, which is why it looked like a
display quirk while actually changing the radio.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Vybo's older T-Deck types garbage on beta_71 and beta_72: "I get some keyboard
codes when typing". His controller has the pre-backlight firmware and does not
speak the raw column protocol, but the probe concluded it did.
A legacy controller answers a five-byte read with the pressed character in byte
0 and padding after it, and every lowercase letter has its high bit clear, so
"five bytes came back and no high bit is set" passes. Read as a column bitmap,
0x61 ('a') sets bits 0, 5 and 6 of column 0 and emits "q "; 0x6d ('m') emits
"qa ". That is exactly what he sees. Worse, the retry window I added in beta_72
gave a legacy controller more chances to look plausible, which is why it did not
help him.
The probe cannot fix this alone. Idle, both controllers answer with zeros, and
requiring proof of a real matrix before committing means an idle boot never
finds any, falls back to legacy, and everyone silently loses modifier latching.
Tried that first; it cost latching on a device where raw mode was working.
So the decision stays optimistic and becomes reversible. Raw mode now tests each
frame: three or more bits in byte 0, a printable value, and every other column
empty is not something a key matrix can report, but it is precisely an ASCII
character. On that, the driver returns the controller to key mode, switches to
legacy, and delivers the character.
Checked against the real tables rather than assumed: all 26 lowercase letters
set three or more bits, so any letter demotes on the FIRST press. Only '0', ' '
and '!' do not, and space renders correctly anyway since column 0 bit 5 IS
space. Going the other way needs three or more of {q, w, a, space} held at once
and nothing else; the printable-range test drops q+w+a (0x0B), and what remains
is a fumble that costs a reboot rather than a wrong character.
Worst case for a misdetected board is now one odd keystroke instead of a
keyboard that types nonsense until it is reflashed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"Mark read" was an untranslated placeholder. "Join the public channel" had the
long form, which he wants shortened to fit the button.
His text for the second reads "nyílvános"; the file already uses "nyilvános"
everywhere else, so this follows the file rather than introducing a second
spelling. Easy to change back if the long i was deliberate.
The other line numbers in that issue are from his own build and no longer point
at what he means, so they need his source rather than mine to resolve.
Languages go to v20.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Buko84 reports accent popups gone on beta_71, with the settings toggle doing
nothing. Introduced by the mention-autocomplete merge (#313).
composerSuggestRefresh() used to do both jobs in order: show the @mention picker
if the token warrants it, otherwise show the accent box. It was renamed to
composerMentionRefresh() and lost its accentBoxMaybeShow() tail. One of its two
callers got a replacement call added next to it; the other did not.
The one that did not is the composer textarea's own VALUE_CHANGED, which is
exactly the path a PHYSICAL keyboard takes. So accents kept working from the
on-screen keyboard and died on the T-Deck and the Pager, which is also why the
settings toggle looked broken: it gates a function nothing was calling any more.
Restored the fall-through. Calling it from both paths is harmless, since
accentBoxMaybeShow() hides the previous box before deciding whether to show one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"Does it still build?" spans two toolchains here. The eight S3 boards come from
platformio.ini, but the Tanmatsu and the T-Display P4 are standalone ESP-IDF apps
with their own build.sh wrappers — release.sh notes that in a comment and then
builds only the PlatformIO half. A change that broke an IDF-only board stayed
invisible until someone cut a release by hand.
build-all-targets.sh builds all ten, uploads nothing, and prints one table.
The env list is read from platformio.ini rather than hard-coded, so adding a board
to that file is enough and this cannot drift out of step with it (release.sh has
to hard-code its copy because it also needs the per-board binary names). A missing
project-local ESP-IDF is a SKIP rather than a failure, so a laptop with only
PlatformIO still gets the eight boards checked and a note pointing at
tanmatsu/fetch-deps.sh. Failures do not stop the run — which boards are broken is
the question being asked — and the exit status is non-zero if any target failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The contact action sheet could message, ping, telemetry, range-test, favorite,
share location, reset path, block and delete — but not hand the contact itself
to anyone. The plumbing was already there and only the companion protocol could
reach it: shareContactZeroHop() has backed CMD_SHARE_CONTACT all along.
So this is a button and a wrapper. uiShareContact() sits next to
uiResetContactPath() and looks the contact up in the live table the same way;
actionSheetShareContactCb mirrors actionSheetResetPathCb exactly — fetch by
index, close the sheet before acting, toast the result. No refreshContactsList():
unlike Reset path and Delete this does not touch the contact table, it only puts
a packet on the air.
Zero-hop, deliberately. Flooding a third party's advert spends the whole mesh's
airtime on a packet nobody asked for, and it re-advertises someone else's node
well beyond the room you are standing in. Zero-hop is what the companion command
already does and keeps the action to "hand this to whoever is next to me". A
flood variant is a different airtime trade-off and does not ride along with this.
Gated on !from_map like Share my loc and Block, so the map-marker sheet stays
compact, and placed after Reset path so Block and the full-width Delete keep the
bottom of the sheet.
grid_items goes 6 -> 7 for the non-from_map case. That is housekeeping, not the
fix — as the #306 note at the bottom of the function says, scrollability is
decided from the height the buttons actually reached, and that is untouched. The
#266 Share my loc row is still missing from the same tally; fixing it here would
widen the diff into behaviour this issue put out of scope.
Three new strings across the thirteen language files (Contact gone already
existed), inserted in sorted position and translated in each file's own style for
its neighbours — Contact added for the success toast, Send failed / Save failed
for the failure one, Share QR / Share channel for the button. i18n_builtin.h is
regenerated, not hand-edited; it costs ~2 KB of .rodata, which the V4 feels most.
Audit clean on all thirteen (1034 -> 1037 keys). T-Deck and Heltec V4 TFT build.
Not yet checked on hardware: a second node in direct range actually adding the
contact, and the smallest panel at the largest UI scale.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
gadgeteerza's ThinkNode M9 entered console mode on beta_70 and could not leave.
The prompt painted, "type 'ui' to go back" was on screen, and no key did
anything. A power cycle came straight back, the pinhole reset came straight
back, and reflashing failed. From the user's side the device was gone.
CAP_CONSOLE was gated on DISPLAY_CLASS: it reasoned entirely about whether
there was something to draw on, and never about whether the user could type.
The console has exactly two input drains, the T-Deck keyboard and the on-screen
keypad behind CAP_TOUCH. The M9 has an I2C keyboard and no touchscreen, so it
had neither, and nothing catches this at runtime either: the panic self-heal
only fires on ESP_RST_PANIC, and a console with no input has not crashed.
Two changes. The M9's keyboard is drained in the console loop, so that board
keeps the feature. And CAP_CONSOLE now requires CAP_CONSOLE_INPUT as well as a
display, so a board with no drain does not offer the mode at all rather than
offering a one-way door.
That guard immediately caught a second one: the T-Lora Pager has a keyboard, no
console drain for it, and was one Settings toggle away from the same state. It
no longer shows the option.
Built on all eight S3 envs and both ESP32-P4 targets. Verified by string
presence per image: T-Deck, M9 and V4 keep console mode; the Pager no longer
carries it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pisti87 pointed at UITask.cpp:22021 twice, and he was right both times. sigCell()
does not translate its argument, so "Signal", "Signal (stale)" and "nothing heard
yet" were raw at the call site and the card read English in every language.
Easy to miss because the home-graph signal popup has its own TR()'d copies of the
same three strings, so the text is translated in one place and not the other. It
also explains why "nothing heard yet" matched no key when his translation for it
did not appear: there was no key, only a literal.
Hungarian supplied by him.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The SDK documented building widgets in on_open() and gave no way to take them
down, so an app with a second screen drew it on top of the first. pisti87 hit
this building a multi-screen app and had been working around it with buttons;
he also noted that ui.list() has clear() while the page it sits on does not.
The reason this is not just lv_obj_clean() is the handles. WidgetUd holds a raw
lv_obj_t*, guarded only by `if (u->obj)`, so a Lua variable still referring to a
label from the screen just cleared would sail past that check into freed memory.
Every handle now records the generation it was created in, clear() bumps the
generation, and the accessors null the pointer of any handle from an older one.
The null guard that every method already has then turns a stale call into a
no-op. Given the day this codebase has had with use-after-frees, a clear() that
invited one would not have been worth shipping.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both reported by Jade, and both were arbitrary caps rather than real limits.
wada.mesh.contacts() scanned the first 200 contacts, returned at most 100, and
said nothing about having truncated. On a device holding 2000 contacts most of
them were simply unreachable. It takes an offset and a limit now, defaulting to
the old 100 and capped at 250 per call, because every entry is an eight-field
Lua table and building thousands in one go would exhaust the per-app heap. The
window moves, so everything is reachable by asking more than once, and the new
wada.mesh.contact_count() means an app can page without probing for the end.
The app source limit was 64 KB, which an ordinary program can reach. It is
192 KB now. The source sits in PSRAM only until luaL_loadbuffer has compiled it,
so this is a transient allocation, not a per-app cost, and the compiled chunk
still has to fit the 256 KB app heap. The Store's download buffer was the other
half of that ceiling and moves with it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
jrote1 flashed the June 2025 C3 keyboard firmware, which supports raw column
frames, and still got no modifier latching.
The probe asked for a raw frame exactly once, on the first poll after boot, and
anything other than five bytes with every high bit clear committed the device to
legacy mode for the rest of the session. The first I2C read after boot is the
least trustworthy one there is: the C3 is still starting, so a short or garbled
frame is ordinary rather than exceptional. There was no recovery except a reboot,
and a reboot re-rolls the same dice.
It now retries for 1.5 seconds before settling for legacy. A single-byte answer
is still delivered as a keystroke while probing, so nothing typed in that window
is lost, and a genuinely old controller ends up in the same place a little later.
The fallback message also says what it actually saw -- how many bytes came back,
and whether a column had its high bit set. "Update your keyboard firmware" to
someone who just did is not a diagnosis, and this is the same lesson as #274:
a failure path that says nothing produces a bug report with nothing in it.
Verified on a T-Deck with the newer firmware: still raw mode, latching works.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
beta_69 published Wardrive and Nearby. Both need the extended SDK, and the
Heltec V4 does not have it, so the most common board in the mesh showed a
prominent Get button for two apps that download, install, open, and then refuse
to do anything. matthewjk reported exactly that on Discord and had to be told it
was a hardware limit.
An app can now declare `"requires"` in the catalog. The Store greys the button,
labels it N/A and says so on the row, rather than letting someone spend a slow
download to find out. Wardrive and Nearby declare sdk_ext.
An unknown requirement allows the install: a newer catalog must not disable apps
on firmware that has never heard of the capability name, which would be exactly
the wrong way round.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
oumike. Closes#315 (pisti87's request).
Two conflicts, both resolved by keeping BOTH sides rather than choosing:
- sdRuntimeLifecycleBusy() gained an audio-playback source here and a web
reader source in #317. They are independent consumers of the same card and
both have to gate the mount lifecycle. The reader's self-exclusion is kept:
it calls this from its own task while holding the card and would otherwise
deadlock against itself.
- The Lua harness caps table needed sd_list from #312 as well as the audio
flags, and the test order needed the wardrive suite from #324 as well as
audio_api.
Built on all eight S3 envs and both ESP32-P4 targets.
pisti87 read v18 and called it chaos. He was right, and it was mine.
Two defects in the extractor I added yesterday:
- It joined every string literal in a call argument, so a ternary became one
key. `cut ? "Paste (move)" : "Paste (copy)"` shipped as the single key
"Paste (move)Paste (copy)", and with it "Unblock Block",
"Unfav Favorite", "Other networksNetworks", "Batteryactivityon" and
"Stop sharing loc Share my loc". None of those strings exist anywhere in
the firmware. Literals are now grouped only when genuinely adjacent, which
is what the compiler concatenates, so both branches become their own key.
- It scanned raw source, comments included. That is how "Geblokkeerde
gebruikers" -- Dutch, appearing only inside a comment about how a long
translation degrades -- became a KEY in the Hungarian file. Comments are
stripped now, string literals preserved.
202 invented rows removed across the thirteen files. Only rows that were both
unknown to the extractor AND still untranslated were touched, so no
translator's work could be lost either way.
Also his: the curly quotes in the Hungarian credits render as boxes because no
bundled font carries U+201E/U+201D. Five values de-curled. And five strings he
found raw are wrapped: the Wi-Fi rescan and hidden-network rows, the update
check, and the downgrade prompt. The two Wi-Fi rows build their label at
runtime, since TR() returns a pointer and cannot join a glyph literal at
compile time.
Languages go to v19. v18 is deleted rather than left behind: it was only ever
correct for about a day and everything in it is superseded.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
dreirund's beta_69 core dump decodes cleanly. Open Radio & mesh, go to Known
regions, add and remove a few, leave the page: the device crashes and reboots.
obj_del_core sends LV_EVENT_DELETE, then runs the destructor, and the
destructor's lv_group_remove_obj refocuses the group. That delivers
LV_EVENT_DEFOCUSED to the object which is already half torn down. Seven
handlers in this file save on blur, and the one bound to the signal-poll
interval field did exactly what it is supposed to: read the textarea's text,
clamp it, write it back, and raise a toast -- on memory the destructor was in
the middle of freeing.
The regions overlay is deleted asynchronously, which is why it takes leaving
that page rather than opening it, and why it looked like the page itself was
broken. pisti87 reported the same screen as impossible to exit in #314, which
is the same crash seen from the other side.
DELETE always arrives before the destructor, so the fix is to record the object
in a DELETE handler and have every save-on-blur handler ignore a blur for it.
The handler goes on every settings field via attachSettingsTaEvents rather than
only the ones that save, since that is what makes the signal reliable, and the
recorded pointer is only ever compared, never dereferenced.
Decoded against a beta_69 T-Deck ELF rebuilt from the tag:
radioSigPollSaveCb <- event_send_core(LV_EVENT_DEFOCUSED)
<- focus_next_core <- lv_group_remove_obj
<- lv_obj_destructor <- obj_del_core <- lv_obj_del_async_cb
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Options > Display previously only had "At a glance while locked"
(opt-in, off by default). Add a master "At a glance" switch above it
(on by default, matching the previously-unconditional behavior) that
gates the whole feature, and move the while-locked switch out of
Options > Lock screen to sit under it.
The while-locked switch's own saved preference is left alone when the
master is toggled off -- only its editability follows the master, so
re-enabling the master always restores its true prior state rather
than resetting it.
Signed-off-by: Tesso M Costa <tesso.martins@gmail.com>
oumike. Closes#301.
Suggestions come from identities whose named adverts the device actually heard
this session, not the stored contact list, so the list stays short and is
evidence the node is reachable rather than a name someone once saved.
The advert-path cache it reads was 16 entries with no validity flag and no
ordering beyond insertion, so it grew an explicit used flag, the advertised node
type and a monotonic receive sequence. Reads take a snapshot under a short
critical section and sort it outside the lock, which is the right shape: the
cache is written from packet receive and read from the UI thread.
Token parsing works from the real LVGL caret through a UTF-8 codepoint-to-byte
conversion rather than assuming one byte per character, does not fire on
email-like text, and replaces only the active token.
Built on all eight S3 envs and both ESP32-P4 targets.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Lua app and language store shares a document root with the firmware but is
published by deploy-apps.sh from deploy/apps/, which is the canonical copy.
release.sh pulls the published tree into out/firmware/ before building, so
apps/ arrives as a local mirror, and the push at the end sends that mirror
back.
Today that reverted the store to sdktest 1.2 and language v15 and removed two
apps, minutes after deploy-apps.sh had published sdktest 1.6, language v18,
airtime 1.4 and gpscompass. It is also the same stale directory that
gen-lua-builtin.py was reading until yesterday, so this mirror has now caused
two separate failures and holds nothing anyone wants.
Excluded in both directions. The firmware release never publishes the store.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported on Discord: granting an app full permissions and then watching
wada.mesh.send come back "permission denied", with no dialog ever shown.
The Settings switches pack (app index, permission bit) into one pointer as
(i << 4) | bit. LUA_PERM_PROBE is 16, which does not fit in four bits, so the
probe switch overflowed straight into the index field: toggling it for app i
decoded as app i+1 with bit 0. That changes no permission, but it still writes
app i+1's record, and the mere presence of a record means "asked".
An app that has been asked and not granted is refused outright rather than
prompting, which is the correct rule and exactly what made this invisible: an
app the user had never touched became permanently unable to send, silently,
because they flipped a probe switch on the app above it in the list.
The shift is 8 now, with a static_assert tying it to the widest LUA_PERM_* bit
so the next permission added cannot reintroduce this. Anyone already affected
can flip the switch in Settings > App permissions, which now writes what it
says it does.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The extern for consoleHostRebootToUi went inside the CAP_TOUCH block, so the
M9 (console, no touchscreen) stopped compiling. Every board with a console can
leave it; the declaration follows CAP_CONSOLE's reach, not the panel's.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Typing `ui` or `exit` rebooted straight back into the console.
touchPrefsSetConsoleMode() does not write the pref, it QUEUES an A/B snapshot:
SdNvsPrefs deliberately keeps filesystem I/O off the calling thread, and
touchPrefsFlush() is what forces the queue out. The console called
ESP.restart() directly, so the queued write was discarded and the next boot
still read "console". The Settings toggle never showed this because it goes
through rebootDevice(), whose last act is that flush.
The console now exits through the same path, which also means it leaves on the
same terms as everything else: chat history, the Discovered ring and the sync
replay ring are persisted first. rebootDevice()'s save-failure branch called
lv_refr_now() unconditionally, which is not survivable with no LVGL, so that
one call is now conditional; showAlert was already console-aware.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
christianprim reported that on a T-Deck, telemetry for a contact fails and that
contact's position never updates on the map, while the phone app gets both from
the same contact.
Every device-originated STATUS and TELEMETRY request chains a blank-password
LOGIN in front of the request. That exists for a good reason: a repeater will
not decrypt a PAYLOAD_TYPE_REQ from a sender that is not in its ACL, and the
ACL is only populated by handleLoginReq. But it was applied to every contact
regardless of type, and a chat contact is another companion, which has no LOGIN
handler at all.
The two fire-and-forget helpers merely wasted a packet. The manual Request
button goes through uiSendRequestAfterGuestLogin, which deliberately does NOT
send the request until the LOGIN is acknowledged -- so against a companion it
armed a wait for a LOGIN-OK that can never arrive, the request was never sent,
and the deadline expired as a failure. The phone app sends the request directly,
which is why it always worked.
The stale map position is the same bug: a contact's position is refreshed from
the CayenneLPP GPS field in a telemetry reply, and there was no reply.
contactNeedsGuestLogin() now decides, and it decides on the only thing that
matters: whether the far end keeps an ACL. Repeaters, room servers and sensors
still get the LOGIN; chat contacts get the request itself, immediately.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
33 of the pairs he posted apply directly to keys that only became visible when
the extractor learned about helper-wrapped and table-held literals. The rest
did not, and the reasons are worth recording rather than dropping:
- Four were escaped in his markdown, so the key read `Node\\nRegion` against a
real key of `Node\nRegion`. Matched after unescaping.
- `Export crash report` is `Export crash report (%uK)` in source; the size is
part of the label. Applied with the conversion appended.
- `Distance` and `Heard` are column headers he read off the screen; the keys
are `Distance: km` and `Recently heard`. Not guessed.
- `Sent only when that contact asks...` is a translation of wording the
English has since changed. Applying it would ship a Hungarian sentence that
no longer describes what the setting does.
- `nothing heard yet` and `Other (hidden) network...` match no key at all,
which usually means a raw string somewhere the audit still cannot see.
88 Hungarian rows are still English: the remainder of the newly visible keys,
plus console mode, which is new.
Languages go to v18. The v17 snapshot is deleted rather than kept: it was cut
before the audit fix added 116 keys, so it was already wrong, and it never
reached a device.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
wb6zsu found the T-Display P4 freezes on Home > Cmdr > Control > Power > Power
off, recoverable only with the hardware switch, under a toast telling him to
click a trackball the board does not have.
Both halves are the same bug. The wake source is armed inside `#if
defined(PIN_USER_BTN)`, which platformio.ini sets per env and the two ESP32-P4
targets never see, so the P4 ran esp_deep_sleep_start() with no wake source
configured: the device is off and nothing short of the switch brings it back.
That is not a freeze, but it is indistinguishable from one.
The Power-off row was gated on `!HAS_THINKNODE_M9` instead -- a board name
standing in for "has a wakeable button", correct for the one board it was
written against and silently wrong for every board added since. It now follows
PIN_USER_BTN, the same symbol that arms the wake, so the row exists exactly when
it can be undone: the M9, the P4 and the Tanmatsu drop it, and any future board
gets the right answer without anyone remembering this.
The toast names the control the board actually has rather than assuming a
trackball.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
cvhviz. The M9's magnetometer was documented on the board and nothing had ever
talked to it. The driver is written from the datasheet's register map rather
than SensorLib, whose setOutputDataRate() writes the ODR into the OSR bits, and
the axis orientation is measured on hardware at four headings rather than
inherited from a declaration Meshtastic marks unverified and never uses. The
+-32 G range looks absurd for a 0.5 G planet until you measure the board's own
hard-iron bias at about 7x Earth's field.
Also carries several fixes found while testing on hardware: every Lua app opened
on a white page on keypad-nav boards (the focus highlight harvested the app body
as a target and reverse-video filled the page), a use-after-free in the Lua net
worker when an app closed mid-request, an unfreed http_get buffer, canvas pixel
buffers GC'd while LVGL still drew from them, one RTC I2C read per contact, and
map re-open costing 2.5 s on every visit.
Three changes on merge:
- The map tile-keep gate read `total && total < 4 MB`, so a board reporting
zero PSRAM -- the most constrained case there is -- landed on the roomy side
of the test and kept its tiles. Dropped the non-zero guard.
- gpscompass is 55 KB of Lua, more than every other app combined, and it wants
a magnetometer the seeded boards do not have. The author deliberately left
it out of lua_builtin.h; that intent now lives in the catalog as
"seed": false rather than in whether someone remembers to regenerate, since
the generator runs from a pre-build hook as of this branch.
- consoleModeToggleCb was defined inside a !HAS_TANMATSU region while the
Settings row that binds it compiles on every board, so the Tanmatsu link
broke. Moved it out. The console boot path is gated on CAP_CONSOLE alone, so
the switch now does what it says there too.
Built on all seven S3 envs plus both ESP32-P4 targets.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pisti87 reported a long list of text that stays English whatever the language,
and said the strings were in his language file and still did not appear (#257).
Both halves are true, and the reason is the audit.
The extractor only ever recognised TR("literal"). Three very common shapes were
therefore invisible:
mk_row_btn("Reload tiles in view", cb) // helper TR()s its parameter
for (auto& r : rows) TR(r.label) // literal lives in a local table
TR(contactsSortOptName(m)) // helper returns one of several
All three translate correctly at runtime, so the source looks properly wrapped.
But the literal at the call site was never emitted as a key, so it never entered
a .lang file, so no translator could ever supply it -- and adding it by hand
did nothing, because the audit's key list is what the files are checked against.
That is 51 strings across the map options sheet, the sort sheets, the contacts
filters and the home launcher.
The audit now understands all three, plus tr("...") in the Lua apps, and the
newly visible keys are in all thirteen files as placeholders so translators can
see them. 1017 keys, up from 966.
Four strings were genuinely raw and are now wrapped: the reader's idle status,
the Discover empty feed, the crash-report export button and Paste (move/copy).
Lua apps had no way to translate anything at all, so every built-in was hard
English regardless of the device language. wada.sys.tr() gives them the same
table the interface uses; airtime 1.4 is the first to use it, with the
`sys.tr or identity` fallback so it still runs on older firmware.
Two more instances of the drift this issue is really about:
- gen-lua-builtin.py read out/firmware/apps/, which nothing writes -- the
deploy rsyncs deploy/apps/ straight to the VPS. So the mirror was stale and
the two apps added in beta_68 were never baked in: boards that cannot reach
the Store shipped without them. It reads the canonical directory now, and
regenerates from the same pre-build hook as the language table.
- Baking a row whose translation equals its key does nothing, since TR()
returns the key on a miss. Skipping them takes the header from 1.11 MB to
939 KB and gives the V4 back 16 KB of flash, which matters at 89%.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The map About/credits sheet was 820 bytes of raw English built with snprintf and
no TR() anywhere in it, so it stayed English in every language (#257). It is now
three keys: the two attribution headers (the OpenTopoMap variant is credited
separately because its style is CC-BY-SA) and the body, kept whole rather than
split per paragraph so translators get prose instead of fragments. The buffer
grows 820 -> 2048 because Hungarian runs about 1.5x English here and the
Cyrillic and Greek files are two bytes a letter.
Hungarian text from pisti87 (#257). Two edits to what he posted, both flagged on
the issue: the hard line breaks he inserted at the English wrap points are gone,
because the label wraps itself and a fixed break lands mid-sentence on any other
panel width; and the header reads "Terkep adatok" rather than "Map adatok",
which looked like a copy-paste artifact given the rest is fully translated. The
OpenTopoMap variant is derived from his own wording and is his to correct.
Also raw, from the same report: the Discovered auto-add hint and the four type
words it interpolates. The hint buffer goes to 240 bytes and the type list to
128, since the translated plurals are longer than "chats, repeaters".
The reason none of that would have shipped: gen-lang-builtin.py exists so the
baked-in table and the .lang files the store serves cannot drift, and its
docstring promises a pre-build step that runs it. Nothing ran it. Editing a
.lang and building produced an image carrying the OLD translations, silently.
It is now a real pre: hook on all seven PlatformIO envs and a line in both IDF
build scripts, regenerating only when a .lang is newer than the header.
deploy-apps.sh grew the matching check for the other half of that path: a
catalog version that disagrees with the file's own "# ver:" publishes
translations to a version no device asks for.
All thirteen languages snapshot to v17 -- the merged region and SD work added
keys to every file, not just Hungarian.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
oumike. Closes#271.
Region presets stop being freq/BW/SF typed in by hand: the list carries the
legal frequency and duty-cycle for each region, so picking one sets a
coherent set rather than three fields that have to agree.
oumike. Closes#309 (pisti87's request).
Bounded to 192 entries, card-rooted paths validated, and it reuses the existing
SD mount and health lifecycle rather than opening its own.
oumike. Verified the reported regression is real: e2d07d8 converted two of the
regions guarding 'Save update bin to SD' to CAP_SD && CAP_OTA and left another
on the old three-board list, which excludes the T-Lora Pager.
Closes#289.
Reported from a regional packet monitor: a burst of unattributable Control
packets whenever someone had WadaMesh Discover open. Correct on both counts.
They are ours, and they cannot be attributed: a NODE_DISCOVER_REQ carries a type
filter, a random tag and a since-timestamp, and no sender identity at all.
The page swept every 4 seconds for as long as it stayed open, and scanning
defaults to ON when it opens. A probe is not one packet: it is our zero-hop
broadcast PLUS a reply from every node in earshot. So an open Discover page put
a burst on the whole neighbourhood every four seconds, indefinitely.
Worth noting the inconsistency that made this obvious: wada.mesh.discover()
enforces a 15 second floor on third-party apps, with a comment explaining that a
probe spends everyone else's airtime. The built-in was doing it nearly 4x
faster.
Now 4s, 4s, 8s, 15s, then 30s. The first seconds are when someone is actually
looking, so those stay fast and the steady state goes quiet. Opening the page or
pressing Scan resets to the fast cadence, because both mean somebody is looking
again. The existing TX-budget gate is unchanged.
Reported by kevin77.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both toggles (Settings > General and the VNC page) now say EXPERIMENTAL on the
row itself, not only in the paragraph under it, and the description mentions the
self-heal so the risk reads as bounded rather than vague.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
That is why the greens were unreadable, and it was my mistake at the root: I
built a light-on-dark palette and then painted it onto whatever UIColor
resolved to.
src/helpers/ui/UIColorPalette.cpp defines wadamesh's dark palette and its own
comment warns that the core's driver .cpps 'carry upstream definitions that stay
unlinked only while nothing references them'. The core's ST7789LCDDisplay.cpp
sets window_bkg = WHITE. The console referenced UIColor and got that one.
Fixed by not depending on which definition wins: the console now owns both ends,
painting its own near-black background and its own RGB565 palette, with the
values taken from the firmware's actual theme colours rather than invented neon.
Only the e-ink fallback still consults UIColor, where a single ink colour is the
correct answer.
Banner: the slash-and-underscore figlet art was a smear at a 6 px cell on a
240 px panel. Replaced with a ruled header, which reads at any size on any
board. A login banner exists to say what the machine is, legibly.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Colour, properly this time. A line can carry THREE coloured segments, because
the three parts of a message answer different questions: WHERE it arrived
(channel, cyan), WHO said it (sender, yellow) and WHAT they said (grey). A DM
has no channel, so its sender takes the magenta slot and stands out in a busy
feed. Same treatment for stored history, contacts and unread.
The palette is explicit RGB565, deliberately NOT the UIColor theme names:
several of those resolve to the same value on a given board, so 'channel' and
'sender' would have come out identical. e-ink falls back to one ink colour,
where anything else would be invisible.
Banner: it WAS printing, just scrolling off. Boot output was seventeen lines on
a panel that fits fewer. The art is now three lines, identity is one, and the
menu is three-per-row instead of two, so the whole thing fits.
Prompt: 'node:recipient$' instead of a bare caret. Where you are and who you are
talking to is what a shell prompt is for, and 'to <name>' now visibly changes it.
Trackball scrolls the scrollback, accumulated so one flick does not race past
everything, and it wakes a dark screen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Neither ran. Both live in UITask::loop() below the console early return, so the
panel stayed lit indefinitely (battery, and burn-in on the panels that suffer
from it) and the keyboard never lit at all.
Console mode now does both itself, using the same prefs and helpers as the UI:
* Idle past the screen-timeout setting -> backlight off, CPU to 80 MHz, and the
render is skipped entirely while dark. There is nothing to draw on a dark
panel, so an idle console now costs almost nothing.
* Keyboard backlight off / on / auto, and dark whenever the screen is.
* A key pressed on a dark screen WAKES rather than types, the same as a touch
does in the UI. Otherwise the character you used to wake it ended up in the
command you were typing.
And the part that would have made the above silently useless:
_screen_timeout_ms is assigned at line 49804, while the console returns at
49431, so it was still at its constructor value and the timeout could never
fire. The console branch now reads the pref itself and prints it at boot, so a
wrong value is visible rather than mysterious.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Enumerated from the real thing: the tab list, the APPACT_* action enum, the 19
settings categories, and CommonCLI's own command table.
Headline finding: the node CLI already covers far more than expected (advert,
clock, gps, region, get/set, log, neighbors, ota, power, sensor, time, ver), so
the console needed far fewer new commands than the UI has screens.
Also records what the remaining gaps have in common: they are either Phase 4
(anything Lua), not console-shaped (Display/Keyboard/Lock screen are settings
for a UI that is not running), or the same list-and-act pattern (files, backups,
quick replies, contact actions) which is worth building once rather than six
times.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Colour: each scrollback line carries a ConsoleColor, so meaning picks the colour
rather than the caller's taste. Incoming channel posts, DMs, your own echoed
command, successes, warnings and failures are now distinguishable at a glance.
The palette maps onto the core's UIColor names so the console tracks the rest of
the firmware instead of inventing values.
Banner: an ASCII mark, the node name and build, then the quick menu, in the
login-banner tradition. 19 columns wide so it does not break on the V4's 240 px
panel.
Quick menu: numbered, because typing 1 beats typing 'contacts' and because a
numbered list is how you find out what exists. A bare digit runs the item; it is
checked before everything else so the node CLI cannot shadow it.
The audit (full table in CONSOLE_MODE.md) compared every UI surface against what
the console offered. The node CLI already covers more than expected: advert,
clock, gps, region, get/set, log, neighbors, ota, power, sensor, time, ver. What
it does NOT cover, now added:
chat <name> read a thread (does NOT mark it read)
stat radio + traffic, the Signal page's numbers
batt battery
wifi radio state
discover / discovered the active probe, with BOTH link directions
luaHostBattery and luaHostMeshDiscover were CAP_LUA_SDK_EXT-gated, so the V4 --
the board this exists for -- failed to link. Widened to || CAP_CONSOLE, same as
the send path earlier.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Incoming messages now appear in the console as they arrive, for every known
channel and DM, without opening anything. On by default, persisted (v52
console_monitor), toggled with 'monitor on|off'.
The unread requirement drove the design. The previous version returned early
from newMsgImpl and printed, which skipped ALL the bookkeeping: no store, no
unread, no filters. Now console mode allocates the same chat store the UI does
(allocMessageStore(), extracted from begin() so both paths share it) and the
message takes the NORMAL path — block list, 1-character spam filter, sender
parsing, appendMessage with mark_unread = true. The console print happens AFTER
that append.
So watching a message scroll past is not reading it. It is stored, it is
counted, and it stays unread until the thread is actually opened:
unread what is waiting, per thread. Listing does not clear it.
read <name> clear one thread, deliberately.
That also means the monitor inherits the spam and block filtering for free, and
history survives a switch back to the graphical UI.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Root cause, from the coredump against a matching elf:
#0 UITask::findOrCreateThread (name="#test", channel=true) UITask.cpp:48598
_ui_threads is 'PSRAM-allocated in begin()' at line 49732 — AFTER the console
early-return at the top of begin(). So in console mode it is null, and the first
message to arrive dereferenced it. Because the panic rebooted straight back into
console mode, it then did that on every boot: a loop, with no way to the UI from
the device. The self-heal added earlier is what broke the loop.
This is the 'everything that assumes the UI exists' hard part from CONSOLE_MODE.md
showing up for real. showAlert and loop() were handled; the message-arrival path
was not.
* findOrCreateThread, appendMessage and the two inline accessors in UITask.h now
tolerate a null table instead of assuming begin() ran to completion.
* newMsgImpl prints the message to the console in console mode. That also
supplies the receiving half, which was missing: it could send but nothing
arriving was ever shown.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The overlap: the node CLI hands its whole reply to the terminal sink as ONE
buffer containing '\n' (its help text is a dozen lines in a single string).
consoleWriteLine stored that as one ring entry, so the text renderer drew the
breaks itself while our y-cursor still advanced by a single row, and every later
line landed on top of the one before it. Worsening down the screen, which is
what the photo shows. Now split on newlines first, then wrap each piece to the
panel width, so no stored line can be wider than the panel and the renderer
never wraps one on its own.
The bootloop: the device panicked, rebooted into console mode, and panicked
again, with no way back to the UI from the device. Console mode now checks
esp_reset_reason() and, if the previous boot panicked, comes up graphical and
clears the pref. That is the third way out the plan called for, and the only one
that needs the user to know nothing.
Honest about attribution: it boots clean now, but I cannot prove the newline bug
was the panic. The serial cut off right after 'serial_interface ok' because
those prints were not flushed, so the fault may have been later than it looked.
Boot breadcrumbs added (flushed) so the next one localises itself.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
startFrame() is display.fillScreen() and endFrame() is a no-op, so drawing is
direct to the panel with no buffer. render() did a full clear-and-repaint, and
it was called for the cursor blink twice a second AND for every keypress, so the
entire screen flashed continuously.
Split into three levels, cheapest that covers what changed:
* scrollback changed -> full render (the only case that clears)
* typing -> repaint the input row only, which never touches the
scrollback above it or the keypad below
* cursor blink -> one fillRect on the cursor cell
An idle console now paints one character cell every half second instead of the
whole panel. Blink also moved to 530 ms so it does not beat against the loop.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* 'mem' prints free DRAM and PSRAM plus the largest PSRAM block. This is the
number the whole feature is justified by, so it is a command rather than
something you have to instrument a build to see: read it in the console,
reboot into the UI, read it there. Phase 0, self-service.
* contacts / chans / to <name> / msg <text>.
The send path is REUSED, not reimplemented. luaHostMeshSendChannel matches the
channel by name at transmit time, and a cached slot index is how messages once
went out encrypted to the wrong channel; duplicating that logic here would have
been an invitation to reintroduce it. It was gated on CAP_LUA_SDK_EXT, which is
off on the V4 -- exactly the board console mode exists for -- so the guard is
now CAP_LUA_SDK_EXT || CAP_CONSOLE.
'to' stores a name rather than an index for the same reason: an index goes
stale the moment the contact list changes underneath it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The declination model landed as 4.7 KB of constants pasted into a Lua app,
generated by a script that lived in out/ -- which is gitignored, holds firmware
bins, and is where the app's own "Regenerate:" comment pointed. So the pointer
dangled for anyone who cloned the repo, and nobody but me could answer the
first fair question a reviewer would ask about that block of magic numbers:
where did it come from, and how do I know it is right.
scripts/wmm/ WMM.COF + NOAA's 100 official test values (both upstream
and unmodified), the float64 reference, the generator,
verify.py, and a README covering provenance, regeneration
and how to move to WMM2030.
scripts/lua-harness/ the host harness, with run.sh so it is one command.
Neither goes in test/: that is PlatformIO's directory and a harness with a
main.c would be swept into `pio test`. scripts/ already holds this repo's dev
tooling, test_companion_serial.py included.
The block in the app is now genuinely generated rather than hand-pasted:
scripts/wmm/gen_lua.py --update <app> rewrite it
scripts/wmm/gen_lua.py --check <app> fail, with a diff, if it drifted
--check catches coefficients updated without regenerating, or a block edited by
hand. The generator owns the `local declination / do ... end` wrapper too, and
that is the point: the tables are named G/H/GD/HD, gpscompass uses a global H
for the screen height, and an unscoped `local H` silently ate it. Hand-wrapping
is how that happened, so hand-wrapping is now not a step.
Verification, all reproducible from a clean clone:
scripts/wmm/verify.py 100 NOAA values, worst D error 0.005 deg
scripts/lua-harness/run.sh 10 scenarios, incl. the generated Lua in
the device's own LUA_32BITS interpreter --
0.0002 deg vs NOAA, worst tick 12k of 100k
Also refreshes the LUA_APPS.md paragraph, which still advertised the O and F
keys that were removed and quoted harness numbers from before tilt
compensation.
Every contact and waypoint sat about 22 degrees west of where it belonged,
including nodes whose positions were known to be correct. The markers were
never wrong: bearings computed from coordinates are relative to TRUE north,
the magnetometer measures MAGNETIC north, and the app was drawing one against
the other. The gap between them is the local declination.
Builds the real World Magnetic Model into the app rather than a lookup table
or a hand-entered offset: WMM2025 to degree 12, coefficients and code in
4.4 KB, reproducing NOAA's own calculator to 0.0002 degrees at six sites
worldwide -- verified in this interpreter, on the module as it actually ships
in the app file, not on a copy. The secular-variation terms are included, so
it stays exact across its 2025-2030 window instead of drifting a tenth of a
degree a year from a frozen snapshot.
It is evaluated from the GPS fix, or from the node's own stored position when
there is no fix yet, and only re-evaluated once the position has moved 2 km --
about 8k VM instructions, against a 100k budget per tick. The result persists,
so a reboot starts from the last known declination rather than from zero.
Also:
- A ("set north") no longer double-counts. The offset it stores is a residual
on top of the model, but pressed with no position at all it silently
swallows the whole declination; that is now remembered and given back when
the first fix arrives, so the direction the user fixed stays fixed.
- Headings and bearings carry their reference: 013 deg T, or an amber M while
the declination is unknown. The dial's repaint key includes it, or the first
fix would leave it claiming MAGNETIC until the heading happened to move.
- WMM ships a caution model with its coefficients; under 6000 nT of horizontal
field the app now says the heading is unreliable instead of showing a
confident wrong number.
- Diagnostics take the full-width bottom row for the declination and the
position it was computed at, which is how a wrong marker gets traced to a
bad fix rather than a bad model.
Harness: two new scenarios -- the model against NOAA reference values, and the
no-fix alignment path. The latter caught a real one: the device store takes
strings and numbers, and a boolean flag would have thrown on hardware.
Keys did nothing on the T-Deck because nothing was scanning them. The touch and
keyboard poll task is started inside UITask::loop(), below the console early
return AND behind 'if (!g_lv.ready) return;' — and g_lv.ready is false in console
mode, since that is the LVGL flag and LVGL is never initialised. So
tdeckKeyboardBegin() was never called and the ring the drain reads was always
empty.
Console mode now starts it once itself. One call covers both: the background
poll task owns the shared I2C bus and scans the touch panel and, on the T-Deck,
the keyboard. Which is exactly why it must not be polled from two places.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
I added it to openVncPage(), which is the Remote app's page and is not reachable
from Settings at all. Reported as 'I don't see the settings option', which is
exactly right.
It is now in Settings > General, above 'Run setup again': a boot mode belongs
with the other device-level switches, not behind an app. The VNC page keeps its
copy since remote mode lives there too.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two things that made it untestable, both found by asking what a user would
actually do rather than by the compiler.
* No way to enter it. Added a Settings > Remote & console toggle mirroring the
Remote UI one, since that is also a boot mode: set the pref, then reboot via
rebootDevice() so chat history is flushed rather than lost to the reset.
* Typing did nothing. The physical-keyboard drain lives inside UITask::loop()
BELOW the console early-return, so it never ran. Console mode now drains the
same buffer itself. T-Deck codes are ASCII with backspace 8 / enter 13, which
is what consoleKey already expects.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Prefs v51 adds console_mode. It goes at the ACTUAL tail of the struct, after
lang_file. I first appended it after retry_echo, which is mid-struct and
exactly the v44 mistake the schema comments warn about; the existing
static_assert caught it at compile time. Added a second assert pinning
console_mode immediately after lang_file, so the next person to append is told
by the compiler rather than by a field of corrupted installs.
* The pref fails safe by construction: only the value 1 means console, so a
corrupt or unreadable byte boots the graphical UI. The <51 migration forces it
to 0 rather than inheriting whatever was in that position.
* UITask gains a headless personality rather than #if through main.cpp:
begin() decides once from the pref and returns before lv_init() and before the
draw buffer is allocated, loop() runs consoleLoop() instead of the graphical
path, and showAlert() becomes a console line so main.cpp's ~20 Wi-Fi and
Bluetooth reports keep working untouched.
* 'ui' or 'exit' in the console clears the pref and reboots. That is one of the
three ways out in CONSOLE_MODE.md; the boot-key escape is next.
Also Phase 1's on-screen key grid, since the V4 cannot use console mode without
one: three layers (lower/upper/symbols) rather than a shift key that changes
every glyph, so a key's label is always what it types. Compiled out where
CAP_KEYBOARD is set.
All 9 S3 envs, the Tanmatsu and the T-Display P4 build.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Flip (F) and the "Flip" button are gone, and the stored `mirror` setting with
them. Rotate (O) had already gone. Both existed to let a user hand-tune the
sensor handedness back when it was unknown; both frames are measured now, so
the keys could only break a correct compass. A future board with different
mounting gets a measured mapping, not a key it has to be discovered.
The accelerometer has a zero-g offset -- this one reads ~0.08 g high on Y lying
flat, which is 4.6 degrees of tilt that is not there, applied by the tilt
correction against a gravity vector that is wrong by that much, and wrong in
the DEVICE's frame so it moves the heading differently at every heading.
Rotating the device every way already sweeps a 1 g sphere for the
accelerometer alongside the field sphere for the magnetometer, so the same fit
recovers it at no cost to the user: same gesture, one more set of running sums,
and it is only accepted when the sphere it fits is close to the 1 g it must be.
Simulated with a 0.08 g bias injected: recovered to within 0.02 g, and a flat
device then reads 0 degrees of tilt instead of 5.
Diagnostics: rows were wrapping onto each other. They are shorter now, and
while diagnostics are on the four rows take the whole panel width (the key
column is reclaimed) -- the harness fails if any row exceeds what fits. The
status line also reports the vertical field (d+0.42), which is the measurement
that says whether the magnetometer's Z sign -- inferred, never measured -- is
right: north of the equator, held flat, it must be positive.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
src/ui-touch/ConsoleUI.{h,cpp}, new and self-contained. Draws straight to the
panel through DisplayDriver and reads input from the board drivers, so nothing
in it needs LVGL initialised.
What is here:
* Scrollback as a flat ring in PSRAM. No per-line allocation, so a chatty
command cannot fragment the heap the way a strdup-per-line log would. Long
replies wrap to the panel width rather than being truncated, because a reply
that runs off the edge is the same as no reply on a screen this size.
* Render only when something changed. That is the point of the mode: an idle
console costs a millis() comparison, not a render pass.
* Line height derived from the measured character cell (DisplayDriver has
getTextWidth but no text height, and the drivers scale a fixed 6x8 cell), so
it stays right at another scale instead of being a hardcoded guess.
* Commands go to the_mesh.runLocalCli(); replies arrive through the existing
terminal sink. Only clear/help/ui are handled locally. Anything CommonCLI
already answers belongs there, not reimplemented here.
* Touch: scroll strips wired against heltecV4CapTouchGetLive, guarded on
CAP_TOUCH so the Tanmatsu (which excludes that driver entirely) still builds.
The key grid is next.
CAP_CONSOLE is on wherever there is a DISPLAY_CLASS to draw on. It does NOT
imply booting into it; that is Phase 2.
Builds on all 5 S3 envs checked plus the Tanmatsu.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The 'console or a lighter skin' open decision was me reopening a settled
question. Removed.
The PSRAM section credited the draw buffer as a major saving. It is not:
LV_DRAW_BUF_LINES is 24, so the buffer is 37.5 KB on the 800 px panels and
11.2 KB on the V4. The real saving is the object tree, which is what Phase 0
measures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two small conflicts, both additive on each side: upstream added ui.text_w /
ui.text_lines and a "measure" capability while this branch added the panel
colour and the compass/accel caps. Everything from both is kept and the table
hints match the merged key counts.
GPS Compass now uses ui.text_w where the firmware offers it and keeps the
character estimate as the fallback. That estimate is what once put the heading
digits off centre -- it counted UTF-8 bytes, so the degree sign read as two
characters -- and measuring removes the guess entirely, for the dial's centred
text and the satellite meter's reserve alike.
M9, V4, V4-R8, T-Deck and Pager all build; the app harness passes, including
the tilt check.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Touch boards get a drawn on-screen keypad rather than requiring a hardware
keyboard. The V4 is the board this feature exists for, so it has to be usable
there. The console draws its own keys and reads touch straight from the board
driver, which is already independent of LVGL (lvglTouchRead is only an
adapter feeding it).
* wada.ui gets a console backend behind the same names. This is far more
capable than 'no UI' implied: console mode still owns a real pixel panel, and
writePixelsRGB565 already exists outside LVGL on four of the six display
drivers. So canvas works (the app's buffer is already RGB565) and map works
(tiles are already decoded RGB565, and the projection has no LVGL in it).
What is actually lost is LVGL's interaction model, not its drawing: scroll
containers, focus groups, flex layout. The backend reimplements a simpler
version of those, which is the bounded real work.
LGFXDisplay and ST7796LCDDisplay need a writePixelsRGB565 wrapper over
LovyanGFX's pushImage; until then caps().canvas is false there and apps adapt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both sensors' 1 Hz debug lines were still on from the axis measurements; off
now (the flags stay, they are how the next board gets measured). Documents the
QMI8658 in M9_PORT.md -- the three attitudes and what each proved, the ADDR_AI
trap, and why tilt compensation was needed at all -- and corrects the
calibration instruction from "tumble it" to "rotate it in one place", which is
the distinction that was corrupting the fit. SDK page gains wada.sys.accel()
and wada.sys.keep_awake().
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Grounded in what is already there rather than a wish list: CommonCLI is the
command surface, runLocalCli + setTerminalSink already run one and capture its
output, DisplayDriver is a complete non-LVGL text renderer that is already
linked, and Remote mode is a working precedent for the panel being painted
without LVGL driving it.
Records one thing that changes how the feature should be described: LVGL's
object heap AND draw buffer are both PSRAM (LV_MEM_CUSTOM_ALLOC lvglPsramAlloc,
MALLOC_CAP_SPIRAM), so the saving is PSRAM and CPU rather than internal DRAM.
That is still the right answer for the V4, whose 2 MB of PSRAM is exactly what
map tiles and the extended SDK fight over.
Phase 0 is measurement, deliberately, so the numbers come before the promises.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#308 chat_open suppressed the chat's back chevron and cog over an app page, and
the comment right above it says why: otherwise they sit over the page's own
"‹ title" and swallow its back tap. But a settings detail page sets
s_settings_open_cat, not s_apppage_title, so the guard never fired for it.
Opening Settings from inside a chat therefore left TWO back chevrons on the left
of the tall bar, with the chat's clickable cog next to one of them, so a tap
near the wrong chevron went to channel settings or nowhere. Extend the guard.
SDK Test 1.5 (store): uses wada.ui.text_lines() where the firmware has it and
keeps the 1.4 character estimate where it does not, so it lays out correctly on
beta_68 as well as on the next build. It also reports which path it took, since
showing what the board offers is the app's whole job.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A two-axis magnetic heading assumes the device is level. At this latitude the
field dips ~60 degrees, so the vertical component is 1.6x the horizontal one
and tipping the device leaks it into the pair the heading is made from: about
1.5 degrees of heading per degree of tilt. That is what "it drifts" was once
the calibration was sound -- it was the hand holding it.
Driver: variants/thinknode_m9/M9Imu.{h,cpp}, QMI8658 at 0x6B on the peripheral
bus, accelerometer only (the gyro is most of the power budget and nothing here
needs it): +/-2 g at 62.5 Hz with the low-pass on, soft reset with a 160 ms
wait that covers both die variants, and the same idle-suspend as the compass so
it costs nothing when unused. CTRL1's ADDR_AI bit is set and read back -- with
it clear the burst read silently returns six copies of one byte, which looks
like a working sensor reporting nonsense. HAS_M9_IMU -> CAP_IMU ->
wada.sys.accel(), caps().accel.
Axes MEASURED, not guessed, by holding three attitudes and logging:
flat, screen up z = -1.02 -> +Z into the screen (down)
on bottom edge, top up x = +0.97 -> +X at the top edge (forward)
on left edge, right up y = +1.08 -> +Y at the right edge
So the IMU is already in the aerospace body frame, and it agrees with the
magnetometer's independently measured +Z-into-screen. (Meshtastic's M9 driver
passes both sensors through untransformed and mirrors the heading on the sign
of accel Z, so its compass flips when the device is turned over. Not copied.)
Heading now rotates the field back into the horizontal plane using gravity
(NXP AN4248 / ST AN3192) before taking the angle, and reports the tilt angle;
past 55 degrees it says "too steep to read" rather than lying. Calibration
returns to a 3D fit because tilt compensation needs the vertical offset too --
but the instruction is now "turn it every way ON ONE SPOT", and the
accelerometer VERIFIES it: coverage is measured by how far gravity swung, so a
flat spin is refused by name ("turn it nose over tail") instead of silently
fitting a degenerate sphere. Simulated in the harness against a modelled M9 in
a known attitude: offsets recovered exactly, and the heading holds within 3
degrees through 20 degrees of roll and pitch.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two faults, one of them mine from an hour ago.
CAL_SECS is 20 s and the M9's default screen timeout is also 20 s. The tick
pause added in 5a4a5a8 stopped the app the instant the screen blanked, which
is the same instant the calibration was due to finish -- so sampling stopped
half way, the auto-finish (inside on_tick) never ran, and the partial sweep was
fitted and saved without complaint. New wada.sys.keep_awake() holds the screen
AND the tick for a measuring app, and is released when the app closes.
The tumbling instruction was wrong too. Hard-iron calibration assumes the
device ROTATES in a uniform field; carrying it through the air also TRANSLATES
it through the field gradients of a laptop and a desk. Measured consequence: a
centre that moved 0.15 G between sessions against a 0.26 G horizontal signal,
i.e. tens of degrees of direction-dependent error -- which is what "it drifts
when rotating" was. The same device rotated flat in one place fitted a circle
to within 4%, so calibration is now exactly that: a 2D circle fit on x/y, which
is the only thing a heading uses.
And a bad fit can no longer be saved silently, which is how the broken one got
in. calib_solve refuses, with a reason, when the turn is partial (each axis
must span >= 1.4r), when the radius is outside Earth's real horizontal range
(0.08-0.45 G), or when the circle is too distorted (residual > 18% of r --
computed in closed form from the sums already collected). The progress line
shows coverage rather than a countdown.
Also: mag_norm is now the HORIZONTAL magnitude, which lets the app say "Hold it
level" when it departs from the calibrated radius -- the tell-tale for tilt,
which is the accuracy ceiling until the IMU is driven.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* #305 the home Wi-Fi status was decided from WiFi.getMode(), not from what the
user asked for, so anything leaving the radio in STA made it announce
"Starting…" indefinitely with Wi-Fi switched off. Gate on
wifiConfigGetRadioEnabled().
* #304 the Wi-Fi and Bluetooth status strings were raw literals and stayed
English everywhere. Wrapped in TR(). Most already had translations sitting
unused in the language files, so this costs translators nothing for
"Connecting…", "SSID not found", "Off" and "Built-in"; only "Starting…",
"Auth failed", "Link lost" and "Init…" are new keys.
* #257 "Built-in" had two code paths and only the one you do NOT see went
through TR(), which is why it looked inconsistent to the reporter.
* #307 the theme-colour buttons were fixed at 124/84 px, measured against
"Save & restart" / "Reset". Hungarian's "Mentés és újraindítás" ran off both
edges. They size to their label now, in a wrapping row, so an over-long pair
drops to two lines rather than clipping.
* #306 the contact action sheet decided scrollability from a hand-maintained
grid_items tally that the location-sharing button (#266) was never added to.
When the tally is short the body is left unscrollable and the overflow is
unreachable. Decide from the height the buttons actually reached instead, so
the tally can drift harmlessly.
SDK: wada.ui.text_w / text_lines. An app could ask how tall a line is but not
how wide, so anything laying out its own rows had to guess whether a string
would wrap; guessing wrong draws the next row on top. That is exactly what
happened to the SDK Test app (fixed separately as store version 1.4, with
Nearby hardened the same way).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
beta_68 added named timers, so pausing only on_tick left the same problem one
API over: a repeating timer polling a sensor kept running into a dark screen.
One-shots still fire -- those are scheduled app logic, and skipping one would
drop the work rather than defer it, since the slot is released around the call.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two answers to "does the compass app matter for battery life", both yes-ish
and both now fixed.
The driver put the QMC6309 into normal mode at boot and left it there, so it
converted continuously from power-on whether or not anything read it: ~1 mA at
100 Hz / OSR 8, forever, on a 2300 mAh battery. It is now parked in suspend
after configuration and woken on demand, with m9CompassIdleTick() (called from
the M9's existing per-loop branch) suspending it again two seconds after the
last read. Waking is a single register write since suspend preserves the
configuration; the waking call reports "nothing fresh" and the caller's next
poll gets data, which callers already handle because the chip may be absent.
Separately, lv_timer_handler() runs unconditionally, so a Lua app's timer kept
firing while the display slept -- GPS Compass polled the magnetometer at 10 Hz
into a dark screen, which would have held the sensor awake even after the fix
above. The host now skips the tick while the screen is off and resumes on wake;
dt comes from millis(), so an app sees one long frame rather than a broken
clock. Documented on the SDK page.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
North was wrong on hardware, and the device's own saved state said why. Its
stored centre was (-0.3875, -3.465) where four measured headings put the true
centre at (-0.340, -3.378): out by 0.099 G against a horizontal field of only
0.27 G. Replaying the measured readings through that centre gives -1 deg of
error at north, -12 at east, -18 at south and +22 at west -- errors that swing
with direction, which is a wrong centre, not a wrong formula. align and mirror
were both 0, so the axis mapping was doing its job.
Min/max midpoints only find the centre from a COMPLETE sweep and are skewed by
whichever extreme was missed; with a bias ~7x the field here, a tenth of a
Gauss of skew is a third of the whole signal. Calibration now fits the sphere
that the samples actually lie on -- |p - c|^2 = r^2 is linear in (c, r^2-|c|^2),
so it is a 4x4 solve over running sums with no sample storage, which matters
inside a 256 KB app heap. Coordinates are accumulated relative to the first
sample because Lua here is single-precision and squaring raw values near 3.5 G
throws away the precision the fit depends on.
The fit is checked before it is saved: a singular system (a flat spin, where
the centre is undetermined along z) or a radius outside Earth's 0.25-0.65 G
band is refused with a reason, leaving the previous calibration alone -- the
old code would happily save the garbage. The toast now reports the fitted field
strength, which is the number that says whether a calibration is any good, and
the progress line shows the sample count.
Also adds a D key: the calibration in use, the raw vector, the horizontal
components and the stored align/mirror, so a wrong heading can be read off the
screen instead of guessed at. Harness: the fit recovers a simulated bias
exactly, and a flat-only spin is refused without touching the saved values.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
luaNetWorkerService() cleared s_net_pending as its FIRST statement and only
then made the HTTP call, so for the length of a round trip nothing told the UI
thread a request was in flight. An app closing in that window ran hostTeardown,
saw !s_net_pending, and freed s_net_buf / s_net_body while the worker task on
the other core was still reading and writing them.
The flag now stays set for the whole request and is cleared at the end, just
before s_net_done is announced -- that ordering also means a teardown observing
done=true can never also observe pending=true and hold the buffers forever. The
caller runs one service() per loop pass and re-checks afterwards, so holding it
across the call cannot re-enter, and http_get/http_post already reject a second
fetch while it is set.
Found while merging beta_68: upstream's comment said the buffers were
deliberately not freed at teardown because the worker might still be using
them, which was the right hazard behind a guard that could not actually see it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
beta_68 expanded the Lua SDK (map, lists, packet delivery, discovery, private
messages/rooms, native crypto) across the same files as this branch, so four
files conflicted. Nothing was dropped from either side:
- wada.sys.gps(): both widenings merged into one binding. Upstream's
fix_time / lat_e6 / lon_e6 and our speed_kmh / course now share a signature,
and our stricter gate wins -- the call returns nil when the user has GPS
switched off, not just when there is no fix.
- Altitude is upstream's `alt_m` alone. The resolution first carried `alt`
beside it to protect a shipped app, but gpscompass has never been published
to the store (it exists only in this branch and on a bench device), so
carrying a duplicate key into the API forever was the wrong trade: the app
reads alt_m instead.
- sysCaps() carries all twelve feature flags (upstream's seven, our compass,
the four originals) with a matching table hint.
- wada.geo (upstream) and wada.sys.compass (ours) both survive; upstream's
"no board has a magnetometer" note is corrected in the code and on the SDK
page, since the M9 now does.
- hostTeardown frees upstream's new POST payload buffer as well as the fetch
buffer, under the same in-flight guard.
- The catalog keeps all three new apps: upstream's wardrive and nearby, ours
gpscompass (8 total, every referenced file present).
M9, V4, V4-R8, T-Deck and Pager all compile; the Lua host harness passes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every installed Lua app drew the same generic play glyph in the drawer, and
the manifest's "icon" -- promised in LUA_APPS.md since the plan was written --
was never parsed. It is now read from <id>.json and mapped to a glyph by NAME
(gps / radio / chart / game / ...), not by codepoint: a name is reviewable in
a store submission, the device's JSON scanner only takes quoted strings
anyway, and an app can never ship a glyph the UI fonts lack -- anything
unrecognised falls back to the generic symbol. A bare side-loaded .lua has no
manifest and keeps that symbol too.
GPS Compass declares "icon":"gps", so it now shows the location pin the Map
tile uses.
Also corrected deploy/site/sdk.html, whose manifest table documented
"version", "min_api", "description" and "boards" -- the device has never
parsed any of them -- and described icon as "a single character".
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The meter sat too far from the count: the reserve used text_w's
0.55-per-character estimate, which is for mixed text, while the digits and
spaces in "99 sats" run nearer 0.39 of the line height. Sized properly it now
sits beside the number, and still holds still as the count changes.
"A set north" is no longer advertised. The axis mapping is measured, so a
calibrated device points north on its own; A and F remain for an unknown
board or a stubborn environment, they just do not need to be on screen.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The sensor's orientation on the board is documented nowhere, so it was
measured: held flat, logging the raw vector at four headings 90 deg apart
(M9_COMPASS_DEBUG, now off again) gives a hard-iron centre of
(-0.340, -3.378) and, after subtracting it,
N x'=-0.055 y'=+0.310 E x'=+0.268 y'=-0.018
S x'=+0.013 y'=-0.275 W x'=-0.225 y'=-0.016
so atan2(x, y) reads 350/94/177/266 at N/E/S/W -- 0/90/180/270 within a few
degrees, counting up clockwise. +Y is the device's top edge, +X its left.
That is now the default: correct after calibration alone.
This also explains the reversal reported on hardware. The auto-handedness
rule assumed a Z-out-of-screen sensor was the un-mirrored case; it is the
other way round -- held flat north of the magnetic equator, a Z-INTO-screen
sensor reads the downward field as POSITIVE z. Fixed, and the stored
orientation is versioned so values saved against the old formula are
discarded rather than pushing a correct default back off north.
The bias is real and large: ~-3.4 G on Y against a ~0.27 G horizontal
signal, which is why an uncalibrated device barely moves the dial, and why
the range is +/-32 G rather than +/-8 G. Meshtastic's implausible hardcoded
extrema were right after all.
Also: the satellite meter now sits beside the count instead of at the column
edge, and UITask::loop's coarse "ui:gps" stall bucket is split into
timers/threads/input/diag so a 450 ms hitch can be attributed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Map re-open cost ~2.5 s of blocked UI on the M9, every visit. Measured with
the existing stall instrumentation: [STALL] ui:lvgl 2597ms and 2479ms on two
consecutive opens -- identical, so it was paying full price each time rather
than only on the first paint. Cause: leaving the Map tab called freeMapTiles(),
throwing away all nine decoded tiles, so the next open re-read and re-decoded
nine JPEGs. The grid costs 9 x 128 KB = 1.15 MB of PSRAM, which only matters
on the 2 MB V4 -- the board renderMapTiles already caps to a 4-tile pool -- so
boards with room now keep the slots exactly as panning within the tab leaves
them and the re-open is renderMapTiles' match-and-reposition path, no decode.
Note releaseMapTileSlot() would NOT have worked here: it clears in_use, so the
next render treats the tile as absent and decodes it again.
Lua host teardown, from auditing "do apps close properly":
- the http_get buffer (up to 64 KB of PSRAM) was a static that no one freed, so
an app that fetched left it allocated after closing until some later app
happened to fetch again. Released on teardown unless a fetch is in flight.
- cvGc's comment claimed it freed canvas pixels only when the app was closing;
it freed unconditionally, so a canvas whose Lua handle went out of scope
could be collected while LVGL was still drawing from that buffer. Canvas
handles are now pinned in the registry for the app's lifetime (there is no
API to destroy one early), so the pixels die with the state. All three
shipped canvas apps keep references, so this was latent, not live.
- both the open and close log lines now report free PSRAM: the Lua heap
counter excludes canvas buffers, so "leaked=0" alone never proved an app
had given everything back.
M9, V4, V4-R8, T-Deck and Pager all compile. The map timing still needs the
after-measurement on hardware.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Up and down move a selection between the ALT and SPD rows (the selected row's
key turns accent), and OK switches that row's units -- feet/miles or metres/km
for altitude, which the target's range follows too, and mph or km/h for speed.
Imperial is the default. On touch boards a tap on the row itself does the same,
while a tap on the dial keeps stepping the target. Both settings persist.
No "enter" handler on purpose: the host answers OK with a synthetic down/up
pair AND an enter key event, so acting on both would switch the units twice
and read as nothing happening.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Lua app page painted itself 0x0E1216 while the rest of the firmware
paints pure black (COLOR_BG), so every app read as a lighter panel floating
over the UI. The page is now black and wada.ui.colors gains `panel` for the
raised surface an app draws on top of it -- the compass dial uses it, so the
instrument stands out instead of the page doing it.
Rotate/flip are gone from the user's side. They existed because the QMC6309's
axis orientation on the M9 is undocumented, which is not the user's problem to
solve by trial and error. `A` now does the whole job: whether the heading runs
clockwise or anticlockwise follows from which way the sensor's Z axis faces,
and that shows in the sign of the vertical field -- Earth's field dips down
north of the magnetic equator and up south of it -- so with a position (a fix,
or the node's last known one) the app reads the handedness off the sensor and
the press only has to set the offset. `F` stays as the fallback for a flat
field or no position at all.
Layout: stats column on the left, dial on the right (its status line above
it, the hint centred along the bottom).
The host harness now tumbles the simulated device during calibration rather
than spinning it flat -- min/max on an axis that never moves subtracts the
true field along it, which is precisely the case the handedness check has to
detect and refuse, and the flat mock was hiding it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Layout, from on-device feedback: the magnetometer/heading-source line moved
from the stats column to a centred line over the dial; the stats panel now
starts at the top of the column, and the rows that freed up went to the
target -- name, range + bearing, how far to turn ("56 deg right", "ahead")
and when the contact was last heard. The key hint is centred along the
bottom edge of the view and spells the actions out ("C calibrate O rotate
F flip <> target"). The heading's DIGITS are centred with the degree sign
hanging off their right edge, so the number does not appear to shift as the
reading crosses 100/200; the width estimate also counts characters rather
than bytes now, which is what put it half a glyph off (the degree sign is
two bytes in UTF-8).
Host: label:width(px) takes an optional alignment ("center"/"right") -- an
app cannot measure glyphs, so this is the only way for it to centre a line
exactly. Also excluded the app ROOT from keyboard-nav focus: excluding only
the body moved the reverse-video highlight up one level instead of removing
it, which is why the page was still white.
sideload_app.py retries fput/fend as well as fadd -- the same UART byte loss
that garbles a long line can garble a short one ("Error: unknown command").
Calibration now reports the measured field strength in its toast, which is
the number that says whether the calibration is any good (Earth: 0.25-0.65 G).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
On the ThinkNode M9 every Lua app opened on a white page: the app body is a
clickable object (touch boards need its press events) on the top layer, so
navCollect harvested it as a leaf focus target and navFocusCb's reverse-video
fill painted it solid under the app's widgets -- the canvas on top stayed
dark, which is what gave it away. NAV_SKIP_FLAG would also hide an app's own
buttons from the d-pad, so this adds NAV_PASSTHRU_FLAG (AppPage.h, shared by
both TUs): clickable, never a target itself, children still collected.
M9 compass: low-pass depth 8 at 50 Hz (the datasheet's 0x61 example) read as
sluggish on the dial; now depth 4 at 100 Hz (CTRL1 0x41, CTRL2 0x30). The
app ticks at 100 ms with lighter smoothing to match.
GPS Compass app rebuilt in the RF Monitor's look: a dial with rings,
10/30/90-degree graduations, red north, a lubber mark and the heading in
the centre; a key/value panel (FIX/LAT/LON/ALT/SPD/TGT) with a 10-cell
satellite meter; compact strings where the column is narrow at large fonts.
Confirmed on the M9 by Chris: the dial turns and calibration holds
(gpscompass.sav persists across reboots).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The ThinkNode M9's microSD is soldered on, so the SDK's "drop it on the card"
route does not exist there and the Store can only fetch from its own host.
Three console commands write into the same /apps (or /lang) the Store uses:
fput /apps/<name> open (truncate); names [A-Za-z0-9._-]
fadd <off> <len> <sum> <base64> append a chunk, every field verified
fend close
Same physical-access trust level as the existing "rm"/"erase", narrower scope
(two directories). DataStore gains a root-aware mkdirRooted(); the CLI line
buffer grows from 80 to 200 bytes.
Why the chunks are self-checking and short: the UART interrupt is not
IRAM-resident, so while the loop is inside a flash-cache pause only the
128-byte hardware FIFO buffers console input and the middle of a longer line
is lost -- observed on the M9 as a 197-char line echoed back as 120. Offset,
decoded length and byte sum reject a damaged line; the host re-sends. The
host script also waits for "[BOOT] ui ready" because the CH34x bridge resets
the board whenever the port is opened and the console is not serviced before
the UI is up.
Verified on the M9: gpscompass.lua (19055 B) + .json pushed, sizes confirmed
by the device and by `ls /apps` after a reboot. M9 and V4-R8 envs compile.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Firmware
- variants/thinknode_m9/M9Compass.{h,cpp}: QMC6309 at 0x7C on the peripheral
bus. Probe by chip id, soft reset with the explicit clear, CTRL2 0x20
(50 Hz, +/-32 G, set/reset on), CTRL1 0x61 (normal, OSR 8/8), read-back
verified. Synchronous read from the Lua host bridge, 1 s cache, lazy
re-probe while the rail-powered part is still in POR, OVFL kept + flagged +
logged. HAS_M9_COMPASS=1 in the env -> new hardware gate CAP_COMPASS.
- src/helpers/WadaNmeaLocationProvider.h: Wadamesh-owned copy of the core
MicroNMEALocationProvider that also exposes RMC speed/course (the core
keeps its parser private; no libdeps patch). M9 target.cpp builds on it,
HAS_GPS_MOTION=1, wadaGpsMotion() for UITask.
- wada.sys.gps(): + alt (m), + speed_kmh/course where the board provides
them (course only while moving -- an empty RMC course parses as 0), and
nil while the user has GPS switched off. wada.sys.compass(): {x,y,z,ovfl}
Gauss, sensor frame, uncalibrated, registered only where CAP_COMPASS;
caps().compass. Calibration, axis mapping and the heading maths live in
the app so they can be adjusted per user without a firmware cut.
- Host: luaHostContactAt reads the RTC once per contacts() walk instead of
once per contact (an I2C transaction each on the M9); pressCb reports
press coordinates in body content space (scroll offset folded in).
App
- deploy/apps/gpscompass/1.0 + apps.json: rotating rose with a fixed index,
live fix readout, bearing/range to a selected contact, magnetometer
heading with hard-iron calibration (C), frame rotate/mirror (O/F, the
M9's sensor orientation is undocumented), GPS-course fallback on every
other board, saturation warning. Not baked into lua_builtin.h on purpose
(CAP_BUILTIN_LUA_APPS also removes the Store > Apps tab).
Verified: M9, V4, V4-R8, T-Deck compile (M9 flash +2 KB); two adversarial
review passes, all confirmed findings fixed; host Lua harness (vendored
Lua, LUA_32BITS) -- calibration recovers a simulated bias exactly, heading
error 0 deg, worst tick ~10k of the 100k budget. Hardware validation list
(axis orientation, bias magnitude, 0x7C ACK) in M9_PORT.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The other half of the request. The previous commit made a scoped message
resolvable to a region NAME; this is the list that decides which names are on
offer. Until now the registry only filled itself from our own default region and
per-channel scope overrides, so a region you can see traffic from but do not
participate in could never be named -- which is most of them.
Settings -> Radio, under the region-scope field, opens "Known regions": a field
plus Add, and a row per region with Remove. Deliberately placed next to the
region we SEND under, because the distinction is the confusing part -- that field
is the region we transmit in, this list is the regions we can RECOGNISE, and a
region only has to be named to be recognised, since its key is SHA256 of that
name. You do not need to be in a region to identify its traffic.
Remove is a RETIRE, not a delete. The slot and name stay bound; the region simply
stops being matched from that point on. Freeing the slot for reuse would hand it
to the next region added and silently relabel every message already received
under the old one -- the exact failure #271 warned about, and the reason slots
were never list indices. Re-adding a retired region revives its original slot, so
its old and new messages stay one region rather than splitting in two.
The persisted slot file gains an active mask and a magic bump (RGS1 -> RGS2). An
RGS1 file still loads, with every assigned slot treated as live, which is what it
meant before the mask existed.
Two failures are reported rather than silently doing nothing: the list being full
(all 14 slot bindings used, retired ones included -- they are permanent), and a
name that canonicalises to nothing, like blank or a bare "#".
Not board-gated -- the page uses the standard app-page chrome, so it inherits the
tall title bar, the back chevron and focus-group navigation on the keyboard-only
boards the same way Blocked users does. Laid out and reasoned about on the T-Deck
for now; the short-panel boards want a look on real glass before anyone calls it
done there. The page title is untranslated, matching every other app page here
(Blocked users, Spectrum, Discover).
All 8 S3 envs build. Four new strings in 13 .lang files, English both sides per
4709c81; the audit's 3 remaining gaps are pre-existing on main from the beta_68
SDK work. Not hardware-tested: adding a region is verifiable on-device, but
whether a message then resolves to it needs real scoped traffic from a second
node.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Message details could only ever say "my region" or "another region". The request
was a user-maintained region list so the scope line resolves to a real name.
The mechanism was already here. transport_codes[0] is HMAC(region key, payload)
truncated to 16 bits -- per-packet, not a region id, which is why the raw hex
changes on every message (#259) and no code-to-name table can work. The only way
to name a region is to recompute the code with a key you hold and compare, while
the packet is still in hand. MyMesh has done that since #259, for exactly one
key: our own default scope. This widens it from one key to N.
Storage and key derivation are MeshCore's RegionMap + TransportKeyStore, which
already ship in every build (helpers/*.cpp is in the library's src filter) and
were simply never instantiated here. A public "#tag" region's key is plain
SHA256("#tag"), which is byte-for-byte what MyMesh::setRegionScope already
derives for our own region, so a name is the whole record.
Two things are deliberately not reused:
RegionMap::findMatch() returns the FIRST match. A 16-bit tag collides at roughly
N/65534 per packet with N keys live, and a confident wrong region name is worse
than none, so the loop here counts every match and reports ambiguity instead of
picking one.
RegionEntry::id is a uint16 that climbs as regions are added, and per-message
storage has 4 bits. So messages record a SLOT (1..14, 15 = ambiguous, 0 =
unknown) assigned once and never renumbered -- deleting or reordering regions
cannot silently relabel old history, which #271 called out specifically.
Those 4 bits are the free top nibble of meta_flags, which is already persisted at
a fixed offset. No record growth, no history version bump, and no segment
migration: messages written before a region was registered read back slot 0,
which is the honest "unknown" state for a message received before we could name
it. Note this fills meta_flags -- bits 0-3 are the existing semantics, 4-7 are
now the slot -- so the next per-message bit does need a record change.
The registry seeds itself: the default region in MyMesh::begin(), per-channel
scope overrides in UITask::begin() (touch prefs, readable only on that side), and
both save paths register on change so naming starts from the next packet rather
than the next boot. No list-management UI yet; that sits cleanly on top.
Scope reads "#denmark", or "ambiguous (several regions matched)", or falls back
to the old my-region / another-region answer when nothing matched.
Known limits, both honest states rather than bugs: private "$region" keys never
match, because TransportKeyStore::loadKeysFor/saveKeysFor are unimplemented stubs
in MeshCore 1.10 -- when the core fills them in, getTransportKeysFor() starts
returning keys and this works unchanged. And codes[1], the reply-region hint, is
still shown under "Scope" as #157 left it; giving it its own line needs a spare
meta bit, which no longer exists.
T-Deck, T-Lora Pager, ThinkNode M9, Heltec V4 and RAK Tap V2 all build. One new
string in 13 .lang files, English both sides per 4709c81; the audit's 3 other
gaps are pre-existing on main from the beta_68 SDK work. Not hardware-tested --
matching is only exercised by real scoped traffic from a second node.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
Last item on oumike's list. deploy/flasher/ carried two manifests and no pager,
so the board was missing from the standalone install page.
It now has a card per radio variant, both pointing at latest-beta -- the pager is
a beta-feed board, so latest/ is the wrong pointer for it, unlike the two stable
boards already there. Each card names its radio and says so again in the body,
because flashing an LR1121 build onto an SX1262 unit leaves the user with a
device whose radio does not come up, and the two SKUs look identical.
version is set to "beta" rather than a tag. These manifests point at a rolling
feed, so any tag hardcoded here is wrong the moment the next build ships -- which
is what happened to the two existing files, both still claiming beta_1. Worth
fixing those separately or dropping the local copies entirely.
Which leads to the caveat, now written down in deploy/README.md: no deploy script
publishes deploy/flasher/. deploy-site.sh ships deploy/site/ only, and
flasher.wadamesh.com 301s to the apex, so the page users actually reach is
deploy/site/index.html -- which has covered every board for a while. The README
also had the redirect backwards, claiming the apex redirects to the flasher.
So this is parity for a directory that may be dead. Kaj reached the same
conclusion independently in e2d07d8 and left it alone; the files are cheap and
the ticket asked for them, but the real decision is wire it up or delete it, and
that is now stated where someone will find it.
Also refreshes TLORA_PAGER_PORT.md, which still listed the release pipeline as
TODO (both envs have been in release.sh and gen-flasher-meta.py for a while) and
still described CAP_SD/CAP_FILESYSTEM as 0 by design in four places, along with a
deferred-SD list that has almost entirely landed. Only lock-screen wallpaper SD
scanning is genuinely still T-Deck/M9-only. Adds a dated status section at the
top; the milestone narrative below it is kept, since the war stories are why
several non-obvious things are the way they are.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Pager's File Manager only drew an SD row when the card mounted. A card the
board can see but cannot read -- exFAT out of the box, or a damaged FAT --
produced no row at all, so the user could not tell "no card" from "card the
firmware refuses", and had no way to retry short of a reboot.
The Pager is the board that can actually tell those apart: it has a card-detect
line on the expander (XL9555 ch10), which the T-Deck does not. So a detected but
unmounted card now draws greyed as "SD card (unreadable - tap to retry)", and
tapping it clears the mount backoff and retries. An absent card still draws
nothing, which is the existing deliberate behaviour.
The retry is the same single 4 MHz attempt fmSdTryMount() already makes. No retry
ladder, no teardown of the live shared display/radio bus -- both of which that
function's comment forbids on this board, and both of which were learned the hard
way in M7.
In-app formatting stays off for the Pager. That was already true but read as an
oversight, so the reasoning now sits on the format-helper guard: f_mkfs needs an
SD.end() + sdcard_init/uninit lifecycle teardown on that shared bus; with no
touchscreen the T-Deck's hold-to-format gesture maps to holding ENTER, which is
an undiscoverable way to erase a card on a device where ENTER is how you move;
and it has never been run on Pager hardware. Formatting on a computer costs
30 seconds and carries none of that. The Arduino SD library never calls
spi->begin()/end(), so it is probably safe -- "probably" is not enough for a
one-way whole-card erase.
Two new strings, added to all 13 .lang files with English on both sides.
i18n-audit: 954 keys, nothing missing. Pager and T-Deck build.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Gating "Save update bin to SD" on CAP_SD gave it to the Pager, the ThinkNode M9
and the Heltec V4-R8 as well as the T-Deck. The two strings it shows still name
bmorcelli's Launcher -- "For Launcher installs: ..." on the button, and
"Saved: %s\nFlash it from the Launcher." when it finishes -- which is the T-Deck's
update path and means nothing on the other three. Those users get told to open
something that does not exist on their device.
The obvious fix is to reword the strings, and that is the trap: both are
translated in all 13 .lang files, TR() looks rows up by their English text, and
editing the English orphans every translation of them silently. So the T-Deck
keeps its strings byte-identical and the other boards get their own keys, chosen
at compile time. Two new keys, added to all 13 files with English on both sides,
which is this repo's convention for a row awaiting a translator (see 4709c81).
i18n-audit: 13/13 languages, 952 keys, nothing missing. audit-lang: no UNSAFE
format mismatches, which matters here because SDFW_SAVED_FMT is handed to
snprintf as the format string. T-Deck (the #if arm), Pager, M9 and V4-R8 (the
#else arm) all build.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
e2d07d8 added the four missing boards to the support matrix, which was the right
call -- the docs were listing five of ten. Three details in it do not match what
we actually publish.
The T-Lora Pager's display is listed as AMOLED. It is a 2.33" IPS ST7796U LCD at
480x222 (TLORA_PAGER_PORT.md's hardware table, and the ST7796LCDDisplay driver
it boots). The AMOLED is the T-Display P4, one row down.
The Pager, the Heltec V4-R8 and the T-Display P4 are all marked Stable. All
three install from latest-beta on the site and all three carry data-dl-betaonly
there; only the T-Deck and the Heltec V4 + TFT are on the stable feed. Marking a
board Stable in DEVICES.md while the install button pulls a beta build tells a
user the wrong thing about what they are getting. All three now say
"Beta (new in beta_45)", which is the release the three of them debuted in.
Also adds the per-board feature note the Pager was missing: the two radio SKUs
and what happens if you flash the wrong one, and the microSD behaviour
(detected-but-unreadable cards offer a retry; formatting stays a host-side job).
The V4-R8, T-Display P4 and Attaky rows still have no feature note. Left for
someone who has the hardware.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
e2d07d8 moved this feature off a board-name list and onto CAP_SD && CAP_OTA,
which is the right gate. It changed two of the six #if regions the feature is
spread across. The other four still carried the old three-board list, so the
T-Lora Pager (CAP_SD=1, not on that list) got the button and the state without
the worker behind it.
The result is not a build failure, which is why "all 8 S3 boards build" did not
catch it: sdFwWorkerRun's only caller sits behind the same gate that excluded
the worker, so nothing link-errors. It fails at runtime instead. Pressing the
button sets s_sdfw_request and starts the 400 ms poll timer, nothing ever
services the request, so s_sdfw_state stays 1 and the row sits at
"Saving to SD... 0%" forever. Then closeSettingsCategory -- also still on the
old list -- does not null s_sdfw_status_lbl when the sheet is freed, and the
timer keeps calling lv_label_set_text on the dead object. Open About, tap the
button, leave settings, and it is a use-after-free every 400 ms.
Verified by building the pager before and after: on the unfixed tree
sdFwWorkerRun's strings ("can't create /BINS", "SD open failed") are absent
from firmware.elf while the button is present; after, both are in.
All six regions are now character-for-character identical, with a comment at
the worker saying so and why a partial widening still compiles. Pager, T-Deck
and ThinkNode M9 all build.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Neither was documented. The Discover app has been in since beta_47 and the user
guide never mentioned it, so the only way to find out what it did was to open it.
The section leads with the thing that makes it different from everything else in
the firmware: Discover ASKS rather than listens. It broadcasts a request that
neighbouring repeaters answer directly, so a reply proves reachability from the
exact spot you are standing, which is also what makes the wardrive data mean
something.
Explicitly separates two names that are one word apart and are not the same
thing: the "Discovered" list in the Contacts overflow is passive, built from
overheard adverts, while the "Discover" app is active. Cross-linked from the
Contacts section where that confusion actually happens, and from the Map section
where the coverage dots show up with no explanation of where they came from.
Covers what a user needs to actually do one: SD card in, wait for a GPS fix, open
Discover and leave it open, then read the map. States the sampling rule (~15 m of
movement, or 20 seconds standing still) so the behaviour is predictable rather
than mysterious, and says plainly that it transmits continuously while open, so
it costs power and airtime and is not something to leave running all day.
Documents the two real limitations rather than leaving them to be discovered: the
map keeps only the newest 160 samples in memory, and the CSV on the card is the
only part that survives a reboot; and there is no on-device viewer for that log,
so reading it back means taking the card to a computer.
Live on wadamesh.com/docs.html.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was no way for anyone but me to put a self-built wadamesh on a Tanmatsu.
The repo carried tanmatsu/build.sh and nothing that could install what it built:
the AppFS writer lived only on my machine, so a fresh clone produced a binary and
no path onto the device. Anyone trying would land on `idf.py flash`, which
replaces the launcher OS.
tanmatsu/tools/ now carries the loop, generalised out of my local copy:
fetch-appfs.sh one-time, clones badge.team's esp32-component-appfs
dump-pristine.sh one-time, dumps YOUR device's AppFS partition as the baseline
tan_deploy.py builds the write-images and works out the changed sectors
tan_flash.sh detects the P4, writes app then metadata, verifies the commit
sermon.py non-resetting serial monitor
Three things had to change before this could work for anybody else. Absolute
paths to my checkout are gone. The hardcoded MAC of MY Tanmatsu is replaced by
detecting whichever port answers as an ESP32-P4, which is also more robust since
the board exposes two ports whose names move between replugs. And tan_flash.sh no
longer invokes tan_deploy.py twice: the second run would have read the metadata
the first had just written and bumped the version an extra time.
appfs.py is deliberately NOT vendored. It is badge.team's and the copy in
circulation has no licence header, so it is cloned instead. dev_appfs.bin is not
committed either: it is 8 MB and device-specific, and using someone else's dump
risks overwriting apps you have, since the deploy writes only what differs from
it. Both are gitignored.
Written up in TANMATSU_SIDELOAD.md and as a page on the site, linked from the
user guide, the homepage resources section and the README. The guide leads with
the model rather than the commands, because the failure that matters is not a
typo, it is not knowing that this board runs wadamesh as an app under a launcher
and that flashing it normally destroys that launcher.
Not re-verified on hardware: the tooling is the same loop that has been deploying
to my Tanmatsu, with the paths and device detection generalised. The first person
to run it on another machine is the real test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pisti87 asked whether the obsolete entries in the language files will be removed
(#262). Measuring it first was the right move, because the tool that would have
driven that cleanup was wrong.
source_keys() only ever matched TR("literal"). Strings reached indirectly, as in
TR(kSettingsCats[c].label), were invisible to it, so every settings-category name
looked unreferenced: About, Backups, Language, MQTT bridge, App permissions and
the rest. Anyone pruning the .lang files on that output would have deleted live
translations for some of the most visible labels in the UI.
The audit now also pulls literals out of any table whose name appears inside a
TR(...) subscript. Deliberately greedy: over-collecting keeps a translation alive,
under-collecting deletes one.
That immediately found a real gap it had been hiding. "App permissions", the
settings category added with the permissions page, was missing from all 13
languages and the old audit reported everything as covered. Added, builtin
regenerated, published as language v15.
Also adds --obsolete, the reverse check pisti87 actually needs: rows in each .lang
file that no TR() key matches. It reports and never deletes, and says plainly that
these are candidates to check rather than a delete list, for the reason above.
Numbers now: 950 keys reachable, hu.lang carries 1044 rows, 94 unreferenced. The
earlier figure was 119, so 25 of those were the false positives just fixed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The page said only "there is an instruction budget: an app that spins forever is
stopped", which is why an app developer had to ask what it actually is. Now there
is a section answering the four questions that were asked: 100,000 Lua VM
instructions per callback, counted as VM OPCODES by Lua's own LUA_MASKCOUNT hook,
re-armed per callback rather than per session, 5x for on_open, and native wada.*
call time does not count against it.
It also says what to do when you hit it, because the honest answer is usually not
"optimise your Lua": a pure-Lua HMAC genuinely exceeds 100,000 instructions and
the fix is wada.crypto, which the section links to. The alternative, splitting a
long job across ticks using state on the app table, is spelled out too.
Audited the whole page against the firmware afterwards rather than trusting that
I had covered everything: every one of the 50 registered wada.* names and all
five lifecycle callbacks now appear on the page. The stale "ext" list in Sandbox
limits also gained send_dm.
Live on wadamesh.com/sdk.html.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two corrections from Kaj, both mine.
istvan is pisti87. I had been thanking them as two people across beta_64 and the
beta_65 stable notes, which splits one person's contribution in half and reads as
if a second reporter existed. Merged into pisti87 everywhere: both notes files,
the source comment that cited the report, both published GitHub bodies, and the
on-device stable notes, which are generated from the cumulative notes file and
had to be regenerated and redeployed.
And in beta_67 I turned Discord names into GitHub @-mentions. "@Jade" resolves to
an unrelated GitHub account, so a stranger got tagged on our release, and the
handle credited was not the person who did the work. The names stay, as plain
text, and the @ is gone.
The rule I should have been following: an @-mention is only safe when the handle
came from GitHub itself, i.e. the author of a PR, issue or commit. @cvhviz,
@pisti87, @oumike, @wb6zsu, @Yoss101 all came from GitHub and are fine. A name
seen in chat is a display name, not a handle, and mapping one to the other is a
guess that lands on somebody real.
Not rewritten: commit messages already pushed that mention istvan. History stays
as it is; the user-facing credits are what matter and those are corrected.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pre-release tidy-up, so the same mistake as last week does not repeat: the SDK
page described a lifecycle the firmware never had, and shipping an expanded SDK
against a page describing the old two-permission model would be the same thing
again.
sdk.html now documents wada.mesh.send_dm and wada.mesh.channels, the m.kind field
on on_message, all four permissions with why they are four rather than one, and a
new wada.crypto section including why it exists (pure-Lua HMAC does not fit the
instruction budget) and why it is on every board rather than ext-gated.
i18n: 11 keys were missing across all 13 languages, six from the SDK work and
five from PR #287 which added strings without lang entries. All covered; audit
reports 924 keys with nothing missing, builtin fallback regenerated, published as
language v14 and the store is live.
Also removes an em dash from "Wi-Fi off, new map areas can't download", new in
#287 and not yet translated, so the key could be corrected for free.
All 8 S3 boards build.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Apps could post to a channel and read channel traffic. They can now hold a whole
conversation.
wada.mesh.send_dm(to, text) -> ok, "sent"|"room"|err
wada.mesh.channels() -> names of the configured channels
app.on_message(m) -> m.kind is "channel" | "dm" | "room"
send_dm takes a contact NAME as it appears in wada.mesh.contacts(). A room server
is a contact too, so one call covers both and the second return says which it was.
Sends are mirrored into the on-device thread and register the expected ACK exactly
as a composer send does, so an app-sent message is a real message rather than
something that leaves the radio with no local trace. Private channels needed no
work: channel send already matches any configured channel by name, and channels()
now lets an app discover them instead of being told the name. Names only - the
channel secret is never exposed to Lua.
Permissions go from two to four, split the way the risk actually splits:
Post to channels as me LUA_PERM_SEND
Read channel messages LUA_PERM_READ
Send private messages as me LUA_PERM_DM_SEND
Read private messages LUA_PERM_DM_READ
Posting to a channel you are already in is not the same act as writing to one
person as you, and channel traffic is not somebody's DMs. Each is granted on its
own. The DM prompt is worded harder than the channel one because a direct message
lands in a private thread looking exactly like something the user typed.
Fixes a latent bug this would have made real: luaPermWrite() wrote a WHOLE mask,
so the send prompt silently revoked a read grant given in Settings. Harmless with
one bit, wrong with four. Writes are per-bit now.
on_message also moved: it ran before the sender/body parsing, so it could only
ever be handed a channel post. It now sits after the block and spam filters, so
an app sees exactly the messages the user does, and DMs and room posts reach it.
Also adds wada.crypto (sha256, sha1, hmac_sha256, hmac_sha1, hex). Tiamat and
Jade's OTP app was dying on "instruction budget exceeded" doing HMAC-SHA1 in pure
Lua, which is a legitimate use of 100k VM instructions rather than a runaway: SHA-1
is 80 bit-twiddling rounds per block and the interpreter overhead multiplies it.
mbedTLS is already linked, so the same work is now microseconds and a few VM
instructions. Deliberately NOT gated on CAP_LUA_SDK_EXT: it is pure computation
over data the app already holds, reads nothing of the user's and transmits nothing,
so the small boards get it too.
All 8 S3 boards build. Not yet exercised on hardware.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Mine, from the fuel-gauge change earlier today.
bq27220ReadU16() returned 0 on an I2C failure, which is indistinguishable from a
real reading of zero. The voltage reader had always defended against that by
range-checking (2500-4600 mV) and keeping the last good value, but the new
state-of-charge reader accepted anything <= 100 — so every lost transaction was
taken as a genuine 0%, and the display alternated between 0 and the real figure
as reads succeeded and failed.
The gauge shares this I2C bus with the XL9535 expander, the RTC and the touch
controller, so an occasional lost transaction is normal rather than exceptional.
Encoding that as a valid measurement was the mistake.
The transport now returns false when the gauge does not answer, and both readers
keep their previous good value on a failure. Once the gauge has answered once,
the percentage never falls back to the voltage curve either, so the number cannot
jump between two different scales.
Reported by Kaj on hardware. P4 builds.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
oumike's gap list. Three of the four are closed here.
"Save update bin to SD" was gated on a list of board names that had grown to
three entries and still left the T-Lora Pager out, despite it having both a card
and OTA. It is now gated on the capability that actually decides it, CAP_SD &&
CAP_OTA (plus the ESP32 / companion conditions it already carried), which is what
device_caps.h exists for: a new board with a card no longer has to remember to
add itself to a list.
Both halves were gated separately - the helper block and the button that uses it
- so widening only the button broke the pager build on undefined symbols. They
are now character-for-character identical, which is the point: they cannot drift
apart again.
Docs were further behind than the issue said. DEVICES.md listed five boards and
we ship ten: the T-Lora Pager, Heltec V4-R8, T-Display P4 and Attaky Mesh Series
were all missing from the support matrix, and the Pager was still sitting under
"Requested boards" with an open issue link. README listed five envs and claimed
the repo covers "the two boards". Both now match what release.sh actually builds.
Still open from #289: the static deploy/flasher/ page carries only two manifests.
Left alone for now - flasher.wadamesh.com redirects to the site flasher, which
generates its manifests from gen-flasher-meta.py and already covers every board,
so that directory may simply be dead. Worth confirming before adding files to it.
All 8 S3 boards build.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
w7aaf put tile packs in MAPS/osm/z/x/y and the device drew nothing. pisti87 could
not reproduce it on his own card, which is the shape of a case-resolution
difference rather than a wrong path.
It is our inconsistency. The card bootstrap CREATES "/MAPS" and the README we
write onto the card says "MAPS/ offline map tile packs", but both the tile loader
and the zoom-availability probe only ever asked for "/maps". FAT is nominally
case-insensitive, but whether that lookup resolves depends on the card's
long-filename entries and the FATFS build, so a user who followed our own
instructions to the letter could end up with a directory the loader never looked
in — while the same layout worked fine for someone else.
Both spellings are now tried, upper first since that is the one we create, in
tileFromSd() and in tileExistsAt(). The second one matters as much as the first:
it drives zoom reachability, so leaving it behind would have advertised zoom
levels as available and then drawn nothing.
Documentation left as MAPS/, which is what the device itself creates.
All 8 S3 boards build.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pisti87 pointed at a commit of his (dc94f44) with 64 Hungarian rows, including
translations for the strings added over the last few releases.
He put them in src/ui-touch/i18n_builtin.h, which says "DO NOT EDIT" at the top
because it is GENERATED from deploy/apps/lang/*.lang. That turns out to be my
fault rather than his: yesterday I added 29 keys to the .lang files and never
ran gen-lang-builtin.py, so the compiled-in fallback still had the old table and
a device that has not downloaded a pack still showed English. Editing the
generated file was a reasonable guess at where the strings lived.
So: his rows are merged into deploy/apps/lang/hu.lang, the canonical source, and
the builtin is regenerated from it (13 languages, 12514 rows). Both paths agree
again.
Four of his rows would not have compiled - two used a tab where a comma belongs,
and two left the quotes inside the English unescaped, one of which had pasted
"Nincs válasz." into the middle of the English key. Reconstructed rather than
dropped; the intent was unambiguous in each.
A fifth pointed at something real: he translated "Battery life: geathering
data..." and no such key exists. Ours has no typo, but more importantly it was
never wrapped in TR() at all - a bare literal snprintf'd straight into a
user-visible buffer, which the unwrapped-literal scanner does not look at since
it only inspects LVGL setters. It is wrapped now, along with the one literal the
scanner did flag (the UI size dropdown), so the scan is at zero and both strings
are translatable for the first time.
Audit: 916 keys covered in all 13 languages. Published as language v13 and the
store is live.
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>
User report: the M9 "does not remember chats or sync them to the app" while
the V4-R8 does. The companion sync ring (MyMesh::history_ring + per-client
cursors) was RAM-only on every board — invisible on a USB-powered V4 that
rarely loses power, fatal on the M9 whose only off is the hard-cut slider.
Shared fix: the ring is now an append log (/synchist) plus client cursors
(/synccur) on DataStore::getHotDataFS(), restored in MyMesh::begin() and
flushed by persistSyncHistoryNow() on the paths that lose RAM (deep sleep,
power-off, explicit save). Record + bench-test recipe: M9_PORT.md Deferred #13.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
User-facing key map and mode tour in the TLORA_PAGER_SHORTCUTS.md mold:
inputs, the close-what-you-see Back rule, function-key table with hold
actions, map pan, editing, lock screen, apps, and a user-terms summary
of the beta_66-branch navigation improvements. Linked from the README
board list and the DEVICES.md feature note.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Spec-check of the sweep on the LR1110 (two analysts + adversarial
verify): per-bin standby() parked in STDBY_RC and re-paid the ~5 ms
DIO3-TCXO startup every bin (~10 ms/bin, ~2x the budget) — bins now
park in STDBY_XOSC via the raw 0x01 byte (RadioLib's STANDBY_XOSC
define is bugged identical to STANDBY_RC); one CalibImage over the
whole swept span per open (begin() only calibrates mesh_freq +/-4 MHz,
not the +/-12 span the old comment claimed), mesh cal restored exactly
on close; sweep clamps corrected to the driver-enforced 150-960 MHz on
BOTH driver families and a failed retune now skips the bin instead of
mis-attributing the previous bin's RSSI; failed-read 0 dBm sentinel no
longer wins the peak-hold (one SPI hiccup squashed the auto-scaled
trace for ~30 s); config block enters standby first; opening Spectrum
mid-TX now waits (bounded, dispatcher's own airtime budget, 6 s cap)
for the packet to finish instead of truncating it on the air; stale
SX126x-era comments refreshed, dead gain readout removed, bin-pitch
vs RBW coverage (42%) documented as the deliberate trade-off it is.
On-device verification wanted: sweep timing (micros() log), mesh RX
sensitivity after a Spectrum session, mid-TX open completing the send.
All four touch envs compile clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Second 7-dimension adversarially-verified audit over the 08-19 tree.
Headliners: SUB_MAP over a display-only Lua app orphaned the app page
(key-only soft-lock); Back-ladder z-order redesign (CC/power always-
frontmost, confirm-modal deference, Lua key-forward suppressed while a
confirm is up — the send-permission dialog was unanswerable); map pan
flag could go stale across tab jumps/popups; null-close progress rows
now genuinely block the registry dismiss (shared fix); terminal RX
mirror, fullscreen title, wallpaper caption, storage-error guidance
widened to M9; accent/@-mention pickers suppressed (dead chrome on a
touchless board); kb-backlight cache only latches ACKed duties (0xFF
sentinel == duty 255 skipped the first write every boot); deep sleep
actually drops the rails now (display refcount, LEDC pin re-route,
RTC/digital holds) and powers off radio+GPS; TCXO fallback no longer
codifies the disproven 0.0f; RadioLib old-FW patch fail-closes at
link; ENV_SKIP_GPS_DETECT + CORE_DEBUG_LEVEL=0 added to the env.
Full round log in M9_PORT.md 'Audit pass 2 (2026-08-20)'. All four
touch envs (M9, T-Deck, V4-R8, pager) compile clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pisti87 listed the untranslated text he was seeing. Running the audit tool from
#254 found 29 keys missing from all 13 languages, which is a superset of his
list: the position-sharing strings and the note under them, the app-permission
prompts and page, the 1-character spam filter, the map "Max dots" and "%d of %d
on map", the room "Join w/ password" and its escalation prompt, and the new
path-hash hint. Most of them are mine from today.
All 13 are now complete: the audit reports 914 keys covered with nothing
missing. Untranslated rows carry the English on both sides, which is the
existing convention in these files (49 rows already looked like that), so a
translator sees the row and knows what to fill rather than the key simply not
existing.
Hungarian gets the one real translation: pisti87 supplied the telemetry-note
text in the issue, so it ships translated rather than English.
Published as language v12 for every language, and the store is live. Note the
canonical files the audit checks are the FLAT deploy/apps/lang/*.lang; the
numbered directories are published snapshots, so both were updated.
Also removes an em dash from the position-sharing note. It was written before
that rule landed and had no translations yet, so the key could be corrected for
free. The remaining em dashes in older strings are deliberately left alone:
changing those keys would orphan their existing translations in all 13 files,
which is a decision worth making on purpose rather than as a side effect.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two reports of remote management, telemetry and ping all failing against a
repeater or room server while the same operations work from other MeshCore
clients, with no error anywhere: "Logging in..." disappears and nothing happens.
Multi-byte path hashing fails exactly like that. sendFloodScoped() stamps
path_hash_mode + 1 bytes of repeater hash onto unicast floods, which is the
login / status / telemetry / ack path specifically, while channel traffic takes
a different overload. A repeater that predates the feature, or does not
implement it, drops 2- and 3-byte-hash packets SILENTLY, so the request never
arrives and nothing on either side reports a failure. randelreiss said outright
he is running 3-byte hashes (#280).
It defaults to 1 byte and is opt-in, so this is behaving as designed. What is
not reasonable is that someone who turned it on has no way to connect that
choice to "remote management stopped working" weeks later. The timeout already
had the operator's attention and was spending it on password, server and clock;
it now names the path-hash setting and where to change it when the setting is
not on its default.
Does not change any send behaviour. This is diagnosis, not a fix for the drop
itself, and it is deliberately worded as a thing to try rather than a verdict:
neither reporter has confirmed the cause yet.
All 8 S3 boards build.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
From a field coredump. Identified as beta_65 on a Heltec V4-R8 TFT by matching
the app ELF SHA256 the dump carries (486cd81c7ccb9f45) against the released
images, since the reporter did not know the version.
assert failed: xQueueGenericSend queue.c:832
(... || pxQueue->u.xSemaphore.xMutexHolder == xTaskGetCurrentTaskHandle())
panic_abort <- __assert_func <- xQueueGenericSend
<- TwoWire::beginTransmission
<- Adafruit_I2CDevice::write(stop=false) <- write_then_read
<- Adafruit_BME280::read24 <- readTemperature <- readPressure
<- query_bme280 <- EnvironmentSensorManager::querySensors
<- UITask::getLocalEnvSnapshot <- getLocalEnvSummary
<- buildHomeEnvSummary <- refreshStatusLabels <- UITask::loop
getLocalEnvSnapshot() ran a live I2C conversation with the BME280 every call,
and refreshStatusLabels calls it on the ~1 Hz home refresh. So a V4-R8 with the
Expansion Kit held a repeated-start I2C transaction every second, forever, on
the UI thread.
Adafruit reads the sensor with write_then_read, which puts Arduino's TwoWire in
nonStop mode holding its lock across two calls. If that sequence does not
complete — a slow or unhappy sensor is enough — nonStop stays set, and the next
beginTransmission takes its "release the lock" branch for a lock this task no
longer holds. FreeRTOS asserts and the device panics. The defect is in TwoWire's
lock accounting, which is not ours to fix; polling it at 1 Hz is what turned a
rare bus hiccup into a reboot.
The queried half is now cached and refreshed at most every 3 seconds. These are
room temperature and pressure — nothing needs them re-measured every frame. GPS
and battery stay live, being cheap. A failed query stamps the cache too, so a
sick bus is not retried in a storm.
Also takes a blocking I2C round trip off the UI thread's per-refresh path.
All 8 S3 boards build. Not reproduced on hardware: it needs an Expansion Kit and
a sensor unhappy enough to stall mid-transaction.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It reads "Save FAIL" permanently on the Tanmatsu, so on that board the one
indicator meant to make a sick store impossible to miss was instead crying wolf
at every user on every boot. An indicator that is wrong on a whole board is
worse than no indicator: it trains people to ignore it, and it is the first
thing they ask about.
Removed rather than board-gated. Everything it showed, and more, already lives
on Settings > About > Chat store: the backend in use, the segment count and byte
total, when history last saved, and the exact stage plus errno of a failing
save. The chip was a shortcut to a question most people never need to ask.
Also removes the now-unreachable homeStoreChipText() and the per-tick refresh
that ran on every home-screen frame.
Docs updated in the same commit: the user guide had a whole section explaining
how to read the chip, which would otherwise describe something nobody can see.
Replaced with a pointer to the About panel that holds the real answer.
All 8 S3 boards build.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported as new contacts never appearing on the map even though the number in
the bottom right was right.
Both halves of that are literally true. The marker array was 32 entries with
slot 0 reserved for self, so 31 contact dots was the ceiling, and the loop gave
up silently at that point. The bottom-right label counts every contact with a
position and had no cap at all. So past 31 the two disagreed permanently, with
nothing on screen to say why. Contacts are appended to the table and the loop
walks it in order, so the dots that never got drawn were the newest ones, which
is exactly how it looked from the outside.
Ceiling is now 256 rather than 31. Markers are only built for contacts inside
the viewport, so that is how many can be visible at once, not a limit on the
contact list. It costs 8 bytes of static RAM per slot.
The self->contact link LINES keep their own separate ceiling of 32. Each line
holds a persistent 2-point array LVGL does not copy, so they are more expensive
per slot, and a screen carrying hundreds of dotted lines is unreadable long
before it is slow. Raising the dot ceiling deliberately does not drag that up
with it.
New setting, Map options > "Max dots", cycling All / 200 / 100 / 50 / 25.
Default All. The lower steps are there for a board that struggles with a crowded
viewport, not because the map should be quietly limiting itself.
And when it does clip, the label now says "31 of 45 on map" instead of "45 on
map". A number that disagrees with the screen should explain itself rather than
leave the operator to work out that the map is lying.
All 8 S3 boards build.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Most mesh spam right now is a 1-byte payload, because that is the cheapest
message there is to send, and it still costs every receiver in range a chat
entry, a notification and a chime. Nobody sends a single character on purpose,
which is what makes this safe to drop outright where a general length threshold
would not be.
Off by default. It filters other people's traffic, so it is the operator's
decision rather than ours.
Two details that matter for it to actually work:
It tests the parsed BODY, not the raw text. A channel post arrives on the wire
as "SenderName: body", so the raw string is never short even when the message
is; filtering `text` would have missed every channel spam, which is where most
of it lands. `body` is the parsed message for a channel and the whole text for
a DM.
It sits with the block filters and BEFORE the chime, so a dropped message makes
no sound either. A spam filter that still beeps has not filtered anything.
Leading and trailing whitespace is stripped before measuring, so " x " counts as
one character rather than three.
Placed in newMsgImpl, the single point every arrival funnels through, so it
covers DMs, channels and room posts without three separate guards.
All 8 S3 boards build.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@D-Melhede saw a P4 claim 71% and then die, with Meck-P4 reading 11% on the same
pack; a full charge never quite reached 100%; and "calibrate 100% here" made it
permanently worse. @wb6zsu watched it read 51%, then 100% the instant USB went in.
All of that is one cause. The P4 carries a BQ27220 fuel gauge, which coulomb-counts
against its learned pack profile and therefore knows the actual state of charge, but
we only ever read its Voltage() register and pushed that through the generic
3.30-4.20 V linear curve. Terminal voltage is charger-driven, so it pins at 100% the
moment USB is connected however empty the pack is, sits under 100% on a rested full
pack, and calibrating "full" while charging captures a charger rail as the reference
and bakes the error in for good. The old comment conceded the limit in as many words:
"the real cell % isn't observable here". On this board it is.
Now reads StateOfCharge() and uses it directly, falling back to the voltage curve
only when the gauge does not answer or reports out of range, so a dead gauge is no
worse than today rather than a made-up number.
The register is REG 0x2C, taken from LilyGo's own SensorLib (GaugeBQ27220.hpp
"StateOfCharge; // % - REG2C") rather than from memory, since guessing a register
here would produce confident nonsense.
Does NOT address the charging/LED half of that report: whether the pack charges
correctly and what drives the red/green LED is the BQ25896 charger, which we still
do not talk to. Tracked separately in the issue.
P4 builds; T-Deck and V4 unaffected (the gauge path is board-gated).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@AtomAlex12 reported a pager where app installs work all day and a language pack
has never once downloaded: /apps healthy with 12 files, /lang empty forever, and
nothing on serial to explain any of it.
The discriminator is size. An app download asks for a 64 KB buffer and fetches a
few KB; a language download asks for a CONTIGUOUS 128 KB PSRAM buffer while the
Store page is up and fetches ~60 KB. Both of the ways that can fail returned a
bare false with no logging and no message, so the two are indistinguishable from
the outside, which is why the report could only say "it never works".
Two changes:
Allocation now steps down 128 -> 96 -> 72 KB and then falls back to internal
heap, the same ladder the reader already uses for its own large buffer, so a
fragmented or busy PSRAM heap no longer means no languages at all.
Every failure path now names itself, and the toast says which: not enough
memory, server or connection failed, empty reply, or could not write to storage.
A ~60 KB transfer dropping mid-body fails where a 3 KB app does not, and the old
generic "Download failed" hid exactly that difference. The reason is cleared at
the start of each attempt so a stale one can never be reported against a fresh
try.
Verified the server side first: both URL shapes the worker builds, versioned
/apps/lang/<ver>/<code>.lang and flat /apps/lang/<code>.lang, serve 200. This is
device-side, as the reporter concluded.
All 8 S3 boards build.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@wb6zsu measured a T-Display P4 on an HP E8285A test set and found wadamesh
never exceeds +14.1 dBm however high the power is set, with the steps going
non-monotonic near the top. Meck-P4 reached +21.0 dBm on the same board, so it
was ours, not the hardware. @stephen-62 independently reported almost negligible
transmit power, unable to reach nearby repeaters.
The P4 build sets LORA_TX_POWER=22 but never set SX126X_CURRENT_LIMIT, so the
SX1262 kept its default over-current protection of 60 mA. Delivering +22 dBm
draws around 118 mA, so the PA was being current-limited long before it reached
the requested power, which is exactly the shape of the measurements: a hard
ceiling near +14 dBm and steps that stop tracking near the limit.
Every S3 board has always set SX126X_CURRENT_LIMIT=140. The P4 was the one
target that never did. It now matches them.
Ruled out while diagnosing: the class is CustomSX1262, not the low-power SX1261,
and the SKY13453 RF path switch is driven correctly from onBeforeTransmit /
onAfterTransmit on the XL9535, which is also consistent with +14 dBm reaching
the connector rather than a switch fault.
NOT yet re-measured on hardware. wb6zsu has the test set, so his numbers are the
verification that matters here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
These four destinations only existed as the small round buttons floating at the
right edge. People were not finding them, so the user guide, the SDK, the offline
map tiles and the issue tracker were effectively invisible to anyone who did not
already know they were there.
New "Docs, tools and help" section between the install steps and the community
videos, with a card each: what it is, why you would want it, and where it goes.
Same four icons as the buttons, so once you have seen the section the rail reads
as a shortcut to it rather than as unexplained decoration.
Reuses the existing card look; the only new CSS is scoped to .rescard/.rescards.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Community submission, reviewed before publishing rather than taken on trust.
The whole API surface it touches is ui.label / ui.canvas / ui.colors, sys.random,
timer.every(100) and two store keys for the high score. No network, no mesh
transmit, no filesystem, no dynamic code loading (load/loadstring/require/dofile),
no _G, metatable, debug or coroutine tricks, and no long-string or numeric-escape
obfuscation. Persistence is one bounded value. Nothing in it can reach past its
own window, so it needs no permission grant.
It also uses the real lifecycle contract — an app table with on_open/on_tick/
on_input/on_close, returned from the chunk — which is worth noting because the
published SDK page told people to do it a different way until today. He got it
right by reading the shipped apps.
Parse-checked against the firmware's own vendored Lua 5.4.7 rather than a system
interpreter, so the syntax is verified by the same parser the device runs. Not
run on hardware here; pisti87 reports it working on a T-Deck Plus.
deploy-apps.sh now also verifies that every apps.json entry has its
<id>/<ver>/<id>.lua and .json on disk. An entry pointing at a missing file lists
in the Store and then fails to install with nothing on screen to explain why —
the same failure the language check already prevents.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The published SDK page was not merely out of date, it was wrong about the first
thing an app author does. It said to "define the callbacks you need as globals"
and named them on_start / on_stop. The runtime has never called those: an app is
a table you build and `return`, and the fields are on_open(w,h), on_tick,
on_input, on_message and on_close — which is what every app in the Store actually
does. Following the published example produced an app where nothing was ever
called and no error appeared, i.e. a blank page. That cost me an hour today
building SDK Test 1.0, and I changed my app instead of noticing the docs were
wrong; anyone else writing an app hit the same wall with less to go on.
Also corrected against the shipped code: ev.name does not exist (it is ev.key,
with ev.code alongside), and label/button take explicit coordinates rather than
the flow-layout signatures the page listed.
The intro's claim that "API v1 is read-only where the mesh is concerned… it
cannot transmit" stopped being true when wada.mesh.send landed, so it now
describes the actual guarantee: transmitting exists, and it stops and asks the
user by name first.
New material for beta_64: sys.epoch/datetime/beep/caps/battery/gps, the whole
wada.fs section, mesh.send, app.on_message, a Permissions section covering the
two separate grants and Settings > App permissions, and sandbox notes for the
things that actually bite — the ext calls are absent on small boards (check
sys.caps().sdk_ext), and the rate limits return false,"too fast" as a normal
answer rather than throwing.
Every signature here is taken from the registration block in LuaAppHost.cpp or
from SDK Test, which was verified green on hardware — not from the old page.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Requested by janmashat: sharing position currently means putting it in the
advert, which broadcasts it in the clear to every node in range, forever — poor
privacy for something as sensitive as where you are.
Almost all of the alternative was already built and unreachable. MeshCore answers
telemetry on REQUEST, encrypted to the contact that asked, with three independent
permission categories (battery, location, environment) each of which can be denied,
allowed for everyone, or allowed only for contacts carrying a permission bit in
contact.flags. The GPS emitter exists (EnvironmentSensorManager::querySensors adds
LPP GPS when the requester has TELEM_PERM_LOCATION), and the receiving end has
worked since #27 — an LPP_GPS field in a response is persisted with uiSetContactGps
and the contact appears on the map.
The one missing piece was any way to grant it: nothing in the touch UI ever wrote
telemetry_mode_loc, so location telemetry was permanently denied, and the
per-contact ALLOW_FLAGS mode was unreachable because nothing set those bits. The
existing switch is all-or-nothing over battery+environment only, with a comment
noting location "keeps its own separate setting" — a setting that was never built.
So: Settings > Mesh gains "Share my location when asked" (Never / Chosen contacts
only / Anyone who asks, ordered to match TELEM_MODE_*), and a contact's own menu
gains "Share my loc", which sets that contact's location bit and flips to the
per-contact mode if sharing was off entirely — otherwise the grant would silently
do nothing. Revocable from the same place.
Two behaviours made explicit rather than left to surprise people: nothing is
broadcast (it is sent only when that contact asks, encrypted to them), and
MeshCore only answers requests at all when base telemetry is allowed, so enabling
location implies answering — the note under the dropdown says so, and the setter
turns the master switch on rather than leaving a control that does nothing.
No protocol change, so this interoperates with the MeshCore app and other MeshCore
nodes rather than forking behaviour. All 8 S3 boards build.
Co-Authored-By: Claude Opus 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>
Reported by Istvan on a T-Deck: back out of an app and it goes back correctly,
but the top dropdown opens at the same time.
beta_49 widened statusBarReaderBackCb from the Reader page to EVERY app page, so
the bar now closes a page on touch-DOWN (the cap-touch swipe detector can abort
the CLICKED, which used to trap people on touch-only boards). The comment claimed
the CLICKED that follows was then "a no-op" because close() clears
s_apppage_close. It is not: statusBarTapCb merely skips its app-page branch and
falls through every remaining branch to the control-center toggle at the end. So
the same tap went back AND popped the dropdown. Harmless while this was
Reader-only; wrong for every app page since beta_49.
Swallow the CLICKED that belongs to a press already used to go back, the same way
s_sb_shot_done suppresses the click after a screenshot hold. Timestamped rather
than a plain flag: the entire reason for closing on touch-DOWN is that the
matching CLICKED sometimes never arrives, and a sticky bool would then eat the
next genuine bar tap — a stale timestamp just expires.
Also adds scripts/deploy-apps.sh, which should have existed all along. The device
reads the app and language catalogs from firmware.wadamesh.com/apps/, a tree that
neither release.sh (out/firmware/) nor deploy-site.sh (deploy/site/) ships, so it
was only ever updated when someone remembered to rsync it by hand -- and twice
nobody did. Istvan is on hu.lang v8 while the repo has v11, and the SDK Test app
published earlier today never appeared in the store at all. The script validates
both catalogs first (parse, and every version a catalog points at must exist)
because a malformed one leaves a device with an empty store and no explanation.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The in-place patch replaces a whole-file rewrite with positioned writes through
fopen("r+") + fseek. That is a much less travelled path than the rewrite it
replaces, it behaves differently per filesystem (SPIFFS / FAT / LittleFS), and
the data at stake is the operator's contact list — worth more than the freeze
this fixes. I could not exercise it on real SPIFFS from the bench (the V4's USB
is the ROM serial/JTAG peripheral, so the companion protocol is unreachable and
the app's own console goes to UART0), so rather than ship it on the strength of
a code read, it now checks its own work: after patching a slab, read those bytes
back and compare. Any disagreement at all returns false and the caller falls
through to the full atomic rewrite, which is the known-good path.
A filesystem that accepts a positioned write and silently drops it is exactly
the failure that would otherwise corrupt a contact list while reporting success.
The test harness now simulates one and asserts the fast path refuses it.
Also adds the save cost to About > System info ("last save: in-place, 1 rec,
12 ms" vs "FULL rewrite, 431 rec, ..."), so which path ran is observable in the
field instead of only inferable. Confirmed rendering on a V4: 431/2000 contacts
(~63 KB), internal flash 338/3169 KB (10%) — which also corrects the earlier
assumption that these volumes run near-full; at 2000 contacts it projects to
roughly 60%. The mechanism is the size of each write, not the fullness.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Yoss101 reported the beta_62/63 fix helped but did not cure it: the freezes got
rarer AND longer. Rarer is the blob-delete queue working; longer is what was
left, and it was always the bigger half.
MAX_CONTACTS is 2000 and a contact record is 152 bytes, so saveContacts was
rewriting a 304 KB file on every change, with a same-sized .tmp alongside it for
the atomic swap. On a card-less V4 that is ~608 KB of peak churn on a 3.375 MB
SPIFFS volume that is already carrying one blob file per contact. SPIFFS GC cost
scales with how full the volume is, and GC suspends the flash cache, which stalls
BOTH cores — so the fuller the table, the longer the device is simply gone.
The table never actually changes in bulk: eviction replaces contacts[oldest] in
place (the array is unsorted and never shifts) and an advert refresh touches one
entry. So compare each record against what is already on disk and write back only
the ones that differ. An eviction now writes 152 bytes instead of 304 KB, an
unchanged table writes nothing, and there is no .tmp and no free-space spike.
Reads never trigger GC, so the compare scan is cheap.
Falls back to the full atomic rewrite whenever the mapping is not provably safe
(no live file, ragged size, or a table that shrank — there is no truncate here),
and never truncates or renames, so a fallback always leaves a valid list on disk.
Verified against the real function source with an in-memory FS harness: steady
state, eviction, growth, shrink, filtered anon slots, missing file, ragged file,
and 167 scattered changes all match a full rewrite byte-for-byte.
Also: the blob-delete queue silently ORPHANED a blob on overflow — nothing else
ever deletes it, so it leaked flash permanently on the one metric that drives GC
cost. Depth 8 -> 32, and overflows are now counted and surfaced.
New About > diagnostics block "Contact store": contact count, approximate on-disk
size, internal-flash used percentage, and orphaned blobs. The used percentage is
the number that predicts these freezes, so a reporter can photograph it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The receive half of wada.mesh.send. An app can now be handed incoming channel
messages as { channel, sender, text }, which is what lets one hold a
conversation rather than just talk: auto-responders, bots, relays, games played
across the mesh.
READING is a SEPARATE permission from sending, and that is the point. They are
different risks -- one speaks in your name, the other sees your conversations --
and an app that already has http_get plus unrestricted read could quietly ship
your chat somewhere. So permissions became a bitmask (1 = send, 2 = read) and
the settings page grew a switch per permission rather than one per app.
The on-disk format is unchanged for existing installs: the value was already
written as "1" for send, which is the same as the new mask. Entry PRESENCE still
records "we asked", so "not asked yet" stays distinguishable from "refused".
Read permission raises NO prompt. Unlike send, it would fire on someone else's
traffic arriving, so a dialog would appear unbidden with no action to attach it
to. An app asks for it in Settings -> App permissions instead, which is also
where it is taken back.
Permission is rechecked PER MESSAGE, not at subscribe time, so revoking it stops
delivery immediately rather than at the app's next launch.
All 8 S3 envs build. One linker error caught in the process: luaAppMessage was
defined inside the file's anonymous namespace, giving it internal linkage --
moved out beside the other public entry points.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
You could GRANT a Lua app permission to transmit but not review or take one
back, except by deleting /apps/perms.kv by hand. That is acceptable on a bench
and not in a release: "which apps can send as me?" has to be answerable, and
answerable without knowing a file path.
Lists every INSTALLED app plus anything already recorded in perms.kv, each with
a switch and its state in words.
Three deliberate choices:
"Not asked yet" reads differently from "refused". One is a decision the user
made, the other is not, and an off switch alone would collapse them.
Removed apps still appear if a decision exists for them. Otherwise reinstalling
silently inherits an old yes, and the grant about to be used would be invisible.
The switch grants as well as revokes, so a permission can be given ahead of time
instead of only in reply to a prompt.
Compiled out entirely where CAP_LUA_SDK_EXT is off -- no dead settings entry on
a board where nothing can request a permission.
Verified end to end on a T-Deck: cancel at the prompt -> app shows "refused" and
send fails -> toggle on in this page -> send returns true and transmits. All 8
S3 envs build.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported on device: open the Store, start scrolling, and it jumps back to the
top a second later "when the status of the buttons update".
luaStoreRebuildList() does lv_obj_clean() and rebuilds every row, and it is
called from three ASYNC completions -- the catalog fetch, the SD card scan and
the language catalog -- which land a second or two after the Store opens. That
is exactly when someone is scrolling it.
It now records the scroll offset and restores it after the rows exist, so the
value is clamped against the NEW content height rather than the old one.
This also explains a second report that looked unrelated: an app near the BOTTOM
of the catalog appeared to have no Update button. The button was correct; the
rebuild was scrolling it off screen before it could be seen. Verified on a
T-Deck -- the app in question updated and now runs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
My fault, and a good demonstration of why the bench test existed: 1.0 defined
app.start(), which the host never calls. The Lua app contract is on_open(w, h) /
on_input / on_tick / on_close, so 1.0 loaded cleanly and drew an empty screen --
no error, nothing to see.
Also takes the body width from on_open's argument instead of assuming 300 px, so
the rows wrap correctly on a Pager or a Tanmatsu rather than only on a T-Deck.
Shipped as 1.1 in its own directory: version dirs are immutable, and the catalog
bump is what makes a device fetch it at all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A bench tool for the extended Lua SDK, in the store so it can be installed from
the device instead of pulling the SD card -- and so anyone with a supported board
can check the SDK on hardware we do not own.
It reports what it finds rather than asserting silently: capabilities, clock,
battery, GPS, a filesystem round trip, and two things that must FAIL --
a second write inside the rate limit ("REFUSED as designed") and a
'../identity' path ("REJECTED"). A traversal that succeeded would print
"LEAKED - BUG", which is the one line nobody should ever see.
It also has a button that really transmits to Public, so the consent flow can be
walked end to end: first tap refused + prompt, Allow, send, then refused again
inside the 5 s airtime limit. The description says it transmits, in those words,
because a store listing should not surprise anyone with a radio.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Lua apps could see touch and trackball direction, and nothing else. On the four
boards with a keyboard -- T-Deck, T-Lora Pager, ThinkNode M9, Attaky -- that made
most app ideas impossible: no text entry, no shortcuts, no game controls, on the
devices whose whole point is having keys.
Keys now arrive through the EXISTING on_input callback as type="key" rather than
a second callback, so an app keeps one input model and one branch. `key` is a
one-character string for printable input and a name ("up", "enter", "backspace")
otherwise, so an app writes ev.key == "w" without knowing scancodes; `code`
carries the raw value for anything unmapped.
Hooked once in handleHwKey(), which is the shared dispatch for all four
keyboards, so no per-board work and no per-board drift.
The dismiss key is deliberately NOT forwarded. An app must never be able to trap
the user by swallowing its own exit, by bug or by design -- the same reason the
send permission is a user decision rather than a manifest claim.
luaAppKey() returning false leaves the firmware's own handling untouched, so an
app without on_input behaves exactly as before. All 8 S3 envs build, including
the V4 which has no hardware keyboard at all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The first WRITE path a store app has into the mesh, and the one API where a bad
app costs other people rather than just its own device: LoRa is a shared channel,
and anything sent goes out under the user's own node name -- to a reader it is
indistinguishable from the user typing it.
So it is consent-gated, and the grant is bound to ONE app id. Approving a beacon
app grants nothing to any other app; re-installing under a different id asks
again. Grants live in /apps/perms.kv, a plain text file the user can inspect or
delete to reset every decision.
The app cannot bypass or pre-empt it. It never sees a prompt API: wada.mesh.send
simply fails until a grant exists, and the FIRST refusal raises the dialog. So
consent is always something the user did, never something the app asked for at a
moment of its choosing.
Deny-by-default and crash-safe: the refusal is written BEFORE the dialog opens,
so a power cut mid-prompt cannot leave a grant behind, and the app cannot
re-prompt in a loop -- its next call reads -1 and fails silently.
The dialog says what actually matters. "Send messages" understates it, so it
names the node the messages will carry and states they cannot be told apart from
messages the user typed.
Two further limits, since consent is not a blank cheque: 5 s minimum between
sends per app (airtime is shared, and far scarcer than flash), and a 180-char
cap so one call cannot occupy the channel. The rate clock only starts on a real
transmission, so failed sends cannot be used to game it.
Channels are matched BY NAME, not by a cached slot index -- a stale slot
transmits on the WRONG key, which is the bug behind the composer's channel-send
fix and part of #260.
A review caught one defect before commit: the original "one prompt at a time"
guard was only cleared on Allow, so a cancelled dialog would have blocked every
OTHER app from ever prompting. Removed -- the on-disk refusal already prevents
re-prompting.
All 8 S3 envs build; meshSend/luaHostMeshSendChannel verified absent from the V4
image and present on T-Deck.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Apps had wada.store, a key/value table flushed once on exit. Anything larger --
a log, a track, an exported CSV -- was impossible. wada.fs adds read/write/
append/list/remove behind CAP_LUA_SDK_EXT.
Two things make this safe rather than just possible.
SCOPE. Every app gets exactly one directory, /apps/<id>.d/, and cannot address
anything outside it. Store apps are user-submitted, so the name check is a
security boundary: no '/', no '..', no leading dot, strict [A-Za-z0-9._-], 32
chars. It REJECTS rather than sanitises -- a sanitiser that "fixes"
../../identity into something valid is how these go wrong. Checked against a
traversal list including '../identity', '/etc/passwd', 'sub/../x' and an
embedded NUL.
RATE LIMIT. This is the API where an app can do the thing that has twice taken
this firmware down: on internal flash a burst of small writes triggers garbage
collection, and a GC pass suspends the flash cache and stalls BOTH cores (#222,
and the beta_25 bootloop). deploy/apps/README.md already asks authors not to
write in a loop; asking is weaker than enforcing, so writes are capped at one
per second per app and return `false, "too fast"` otherwise. The write itself
runs under WdtHeavyGuard, so a pass that DOES trigger GC stalls briefly instead
of rebooting. Reads are capped at 32 KB so an app can never be handed an
unbounded buffer.
All 8 S3 envs build. Gate re-verified by symbol: fsWrite/fsWriteCommon exist
only in the T-Deck image, not the V4 TFT one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
First slice of the extended SDK, and the gate the rest hangs off.
The BASE SDK (drawing, timers, store, read-only mesh, http_get) stays on every
board. The EXTENDED SDK -- live device state, file access, mesh writes, richer
input -- is gated by CAP_LUA_SDK_EXT, which is OFF on the 2 MB Heltec V4. That
board already runs at ~95% internal RAM with Wi-Fi up; it is the same headroom
problem that gates CAP_WEB_BROWSER and forces CAP_BUILTIN_LANGS there.
A future low-resource board opts out with ONE line in its own block, either
WADA_LOW_RESOURCE_BOARD (also gates future extras) or CAP_LUA_SDK_EXT 0.
In this slice:
wada.sys.caps() -> { sdk_ext, keyboard, touch, sd } feature detection
wada.sys.battery() -> { mv, pct, charging } gated
wada.sys.gps() -> { lat, lon, sats } or nil gated
caps() ships on EVERY board including the V4 -- an app has to be able to ask
before it branches, so the one call that reports absence can never be absent.
gps() returns nil without a fix rather than the last known position, which
wada.mesh.self() already gives: a track logger has to tell those apart.
Two things deliberately NOT shipped rather than shipped broken:
wada.sys.sensors() -- the only environment-sensor source in the firmware is
LocalEnvSnapshot, which is #if defined(HAS_EXPANSION_KIT), i.e. the Heltec V4
Expansion Kit. That is exactly the board this gate excludes, so the call would
have returned nil on every board able to make it. Sensor access belongs on a
HARDWARE gate, not this memory gate.
GPS altitude -- the LocationProvider holding it is a private UITask member with
no public accessor, unlike getGpsFix()/getGpsSats(). That header change is
worth making deliberately, not in passing.
Verified by symbol, not by eye: luaHostBattery/luaHostGps are ABSENT from the
V4 TFT image and PRESENT in the V4-R8 and T-Deck ones -- which also proves the
same-family R8 carve-out resolves correctly. All 8 S3 envs build.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pisti87 asked for time and buzzer access from Lua. wada.sys had millis, toast,
board and random -- an app could measure elapsed time but never say what time
it was, and could not make a sound at all.
wada.sys.epoch() -> Unix seconds, or nil
wada.sys.datetime() -> { year month day hour min sec wday }, or nil
wada.sys.beep() -> true if a sound was actually produced
Three decisions worth recording:
nil, not 0, when the clock has never been set. The device genuinely boots
without a clock until GPS, NTP or a mesh peer supplies one, and an app stamping
a log needs to tell "no clock yet" from "1970". Uses the same sane-clock floor
the mesh already applies.
datetime() is LOCAL time, in the timezone the user picked, because every use for
it is display. epoch() stays UTC seconds for arithmetic.
beep() is one chime, not the melody player the issue sketched. What exists is a
single per-board notification chime -- and some boards have no sounder. Routing
Lua through the same uiPlaySlot() the UI uses means an app cannot be louder or
different from the rest of the firmware, and it honours the user's sound setting
instead of overriding it. Returning whether sound actually happened lets an app
fall back to a visual cue rather than silently doing nothing, which a fake
melody API could not.
All 8 S3 envs build -- the beep path is per-board, so that matters here.
Requested by pisti87.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pisti87's photos show "Halozat nev" as "H[]l[]zat n[]v" in the hidden-network
SSID field, and the MQTT username hint as a lone box. Typed text in the same
field is fine.
LVGL draws a text area's placeholder from LV_PART_TEXTAREA_PLACEHOLDER, and that
part does not inherit the LV_PART_MAIN font the creation sites set. So the
placeholder fell back to the theme's plain Montserrat, which has no accents.
27 placeholders in the UI; the part was styled in exactly none of them.
This is the same family as the beta_62 tofu fix but a part that sweep never
touched -- it covered ~79 labels, and a placeholder is a different LVGL part.
Routed every call through taSetPlaceholder(), which sets the text and then gives
the placeholder part the chained face resolved from whatever MAIN already
carries. It therefore cannot change how any field looks, only which glyphs it
can draw. Placeholders are frequently set BEFORE the site assigns MAIN's font,
so an unrecognised font falls back to g_font_14 -- the size those sites end up
using anyway.
Builds on V4, T-Deck and Pager. Reported by pisti87.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The setting already behaves this way — BaseChatMesh's eviction loop skips any
contact carrying the favourite flag, so a starred contact is never the one
dropped when the table fills. The label just never said so, which left people
roaming between regions unable to tell whether starring a contact protected it.
Renamed to the reporter's own wording, "Overwrite oldest non-favorite", and
registered in all 13 language files. No behaviour change.
Reported by mikecarper.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Curated rather than concatenated: beta_52's test-build warnings are dropped,
and two statements that later changed are corrected to what actually ships —
the save chip is text not a floppy icon (#263), and About reports v1.17.4-touch
not v1.17.0. The upgrade-affecting items lead: the one-way chat-history format
change, the 250-message default cap, and the region-preset TX power fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
First pass at pisti87's list. These were never wrapped in TR(), which is why
uploading a translation did nothing for them — there was no key to match.
Sort by / Sort discovered, and the filter rows Peers, Favorites,
Has location, Direct (0-hop); the home traffic panel's
"Traffic (since boot) / Sent / Recv"; and the contact list's "Heard <ago>".
Keys registered in all 13 language files as empty rows. audit-lang.py reports
0 missing and 0 unsafe. The two new format strings are covered by the #258
placeholder guard.
Deliberately NOT done in this pass — the rest of the list needs a check I did
not want to rush:
- the map option table (Show coordinates, Show tile z/x/y, Tile debug
overlay, ...) is a static initialiser, so TR() cannot go in the table; it
has to be applied where the rows are consumed.
- "Name (A-Z)" / "Recent message" / "Nearest first" are dropdown options.
Translating an option string breaks any code that maps a selection back by
comparing text rather than index, and we have shipped that exact bug before
(channel send matching by name). Each needs its consumer read first.
Reported by pisti87.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Since beta_23 the per-packet RX log (PUSH_CODE_LOG_RX_DATA, 0x88) is kept off
BLE, because it floods a ~16 frame/sec link and starves chat and admin traffic
(#46, #54). That is still the right default and is unchanged here.
But it is also the ONLY frame carrying the transport codes and the full relay
path — RESP_CODE_CHANNEL_MSG_RECV_V3 has neither — so coverage and region
mapping apps have had no way to reconstruct either over BLE. @marcelverdult,
who writes KiekR, traced this through our source and asked for an opt-in rather
than a revert.
A companion can now request it per session:
CMD_SET_CUSTOM_VAR "ble.rxlog:1" (and "ble.rxlog:0" to stop)
Chosen over a user-facing setting because the tradeoff belongs to the app, not
the user: an app that wants the firehose knows it wants it, and nobody else
should have to understand the question.
Deliberately NOT persisted. A stored flag would silently reinstate the #46/#54
flood for someone who tried a coverage app once and moved on — on the one link
that cannot absorb it. Asking again after each connect is cheap for an app and
is the safe default for everyone else.
The existing #94 one-shot (echoes of our own sends, so "Repeats heard" keeps
working) is untouched and still applies when the firehose is off.
Builds on T-Deck, V4 and Pager.
Requested by @marcelverdult.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@oumike's #263 replaces the save chip's floppy icon with words, which is the
right call — two people independently read the icon as ambiguous, and one of
them was the maintainer. Two gaps came with it, both fixed here rather than
blocking the merge:
- "Saved %s", "Save FAIL x%u" and "Save migrating" were bare English
literals. The chip previously had no words at all, so it was language
neutral; as written it would have been permanently English in all 13
languages and reopened the drift closed in d4ade2e. Wrapped in TR() and
registered in every .lang file as empty rows. audit-lang.py: 0 missing,
0 unsafe.
- the user guide, published yesterday, describes a floppy-disk icon and lists
the states as bare times. Reworded to match what the firmware now draws —
docs and UI have to move together or the guide is worse than none.
"Save FAIL x%u" and "Saved %s" carry printf placeholders, so they are covered
by the format-safety guard added for #258: a translation that reorders or drops
them falls back to English instead of misreading the stack.
All 8 S3 envs build.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
New Options > Lock screen toggle lets the at-a-glance overlay fire while
the device is manually locked too, not just unlocked+idle-dimmed -- off
by default since it makes message text readable off a locked device.
Persisted via TouchPrefsStore (touchPrefsGetGlanceWhenLocked). When it
fires from a locked state, mirrors lockscreenReveal()'s peek path (panel
lit, _manual_lock left alone) rather than wakeScreen() (which would
silently clear the lock).
Also fixes the glance body label: LV_LABEL_LONG_DOT only wraps/dot-
ellipsizes across multiple lines when the label has a fixed height, so
left at auto-height it was effectively single-line everywhere, cropping
longer messages instead of continuing to a second line. Now sized to
the actual space available below it for the current board's resolution
and font.
Adds a T-Deck-only experiment shrinking the glance body font from 28px
to 20px (extras_lat_20 gate widened past Tanmatsu-only to cover it);
easy to revert in isolation per the comments at each site.
Signed-off-by: Tesso M Costa <tesso.martins@gmail.com>
While unlocked but idle-dimmed to off, an incoming message (DND
permitting) briefly lights a plain black overlay with the sender/
channel name and message text, then auto-dims after a fixed window
without opening the chat or marking the message read. Board-agnostic,
distinct from the T-Deck's existing opt-in msgFlash.
extras_lat_28 (accented Latin/em-dash/ellipsis at 28px) moves from
Tanmatsu-only to every board, since the glance body needs that
coverage everywhere.
Signed-off-by: Tesso M Costa <tesso.martins@gmail.com>
2026-07-28 09:36:36 -06:00
419 changed files with 163620 additions and 5129 deletions
A boot mode where the device runs with **no LVGL**: a text console driven by
typed commands, with core mesh functionality and Lua apps. For boards that
struggle under the UI, and for people who would rather type than tap.
Regular mode is unaffected and you can go back at any time.
---
## Short answer
**Possible: yes, and considerably more of it already exists than you would
expect.** Four of the five pieces are built and shipping today.
**A lot of work: no, not for a usable console.** The work is not the console
itself, it is everything that currently assumes the UI is there. Realistic
shape: a useful console in a few days of work, a polished one that runs Lua
apps in a couple of weeks.
**Saves resources: yes, but not the resource you might think.** See
[What it actually saves](#what-it-actually-saves) below, because this changes
how the feature should be sold and to whom.
---
## What already exists
This is the reason the estimate is not larger.
| Piece | Where | State |
|---|---|---|
| **Command surface** | `CommonCLI::handleCommand` (MeshCore core) | Done. The full command set the node already answers over serial and over the mesh. |
| **Run a command locally, capture its output** | `MyMesh::runLocalCli()` + `MyMesh::setTerminalSink()` | Done. Feed it a line, get reply lines back through a callback. |
| **Text rendering with no LVGL** | `DisplayDriver` (core): `startFrame`, `setCursor`, `print`, `printWordWrap`, `getTextWidth`, `fillRect`, `drawTextEllipsized`, `endFrame`, UTF-8 to blocks | Done, and linked into the touch build already. |
| **A mode where LVGL does not drive the panel** | Remote mode (`s_remote_mode` in `UITask.cpp`) | Done and shipping. It paints the physical screen with `display.drawTextCentered(...)` directly while LVGL renders elsewhere. This is the exact precedent. |
| **An on-device terminal** | `homeTerminalCb` / `s_term_log_box` in `UITask.cpp` | Done, but built out of LVGL widgets. The *behaviour* is the model; the widgets are what console mode replaces. |
So the console does not need a new command language, a new renderer, or a new
way of talking to the mesh. It needs a different **front end** over machinery
that is already there and already tested.
---
## What it actually saves
Worth being precise, because the obvious assumption is wrong in one direction
and right in another.
### PSRAM: a real saving, and it is the object tree, not the draw buffer
Everything LVGL allocates is PSRAM: `include/lv_conf.h` sets `LV_MEM_CUSTOM 1`
with `LV_MEM_CUSTOM_ALLOC lvglPsramAlloc`, so every object, style and font cache
entry is a PSRAM allocation, and the draw buffer is `MALLOC_CAP_SPIRAM` too.
**The draw buffer is not the prize.**`LV_DRAW_BUF_LINES` is 24, so it is
37.5 KB on the 800 px panels and **11.2 KB on the V4**. Worth having back, not
worth building a mode for.
The saving is the **object tree**: every screen, tab, list row, label, style and
cached glyph, all of it live for the life of the session. That is the number
Phase 0 exists to measure, because it is the one nobody here can quote from
memory.
For scale on the V4, its PSRAM is also carrying up to four 128 KB map tiles, so
whatever LVGL is holding sits alongside that. Console mode frees the LVGL half
outright.
**This matters most exactly where you said it hurts.** The Heltec V4 has 2 MB of
PSRAM, and PSRAM pressure there is a recurring source of real bugs: map tiles
fragmenting the heap, the tile pool needing a cull and a cap, the extended Lua
SDK gated off the board entirely for want of memory. Console mode gives that
board its PSRAM back.
### Internal DRAM: a smaller saving than it looks
The scarce resource on these boards is internal DRAM, and LVGL is largely not
in it. What *is* in it is UITask's static data: the contact cache, message
rings, layout tables. Most of that is data the console still needs to do the
same job, so it does not simply disappear.
Two caveats worth keeping honest:
- There is a fallback at boot: if the PSRAM draw-buffer allocation fails,
`malloc()` puts it in internal DRAM instead. On a board already under
pressure that is exactly when it happens, so for the worst-off devices the
DRAM saving is real after all.
- A meaningful DRAM number needs measuring, not guessing. See Phase 0.
### CPU: a real saving, and probably the one users feel
`lv_timer_handler()` runs on every pass of the main loop, alongside input-device
polling and any animation or scroll in flight, and a redraw blits through SPI.
A console redraws only when a line is added.
This is also the mechanism behind several bugs we have already fixed: the loop
being stalled by the UI is what starved the GPS UART ring, and what made SPIFFS
garbage collection visible as a freeze. Less time in the UI is less exposure to
that whole class of problem.
**How to describe the feature honestly:** it buys back PSRAM and CPU. On the
V4 that is the difference between comfortable and not. It is not a fix for
internal-DRAM exhaustion on its own.
---
## The hard parts
Not the console. These three.
### 1. Everything that assumes the UI exists
`src/main.cpp` calls `ui_task.showAlert(...)` in roughly twenty places to report
Wi-Fi and Bluetooth outcomes, and `ui_task.loop()` unconditionally. UITask is
also the owner of things the console still wants: the storage root, the Lua app
host, the update check.
The wrong fix is `#if` sprinkled through main. The right one is that UITask
keeps its role as the front end and gains a **headless personality**: the same
entry points, LVGL never initialised, `showAlert` becoming a console line.
### 2. Lua apps: the `wada.ui` translator
Decided: `wada.ui` gets a **console backend** behind the same names, so an app
keeps working without knowing which mode it is in.
This turns out to be far more achievable than "no UI" suggests, because console
mode is **not a text-only terminal**. It still owns a real pixel panel. The
thing being removed is LVGL's object tree, not the ability to draw.
The enabler is a raw framebuffer blit that already exists outside LVGL:
```
ST7789LCDDisplay::writePixelsRGB565(x, y, w, h, const uint16_t* pixels)
```
Present today on `ST7789LCDDisplay` (V4 TFT, T-Deck), `TanmatsuDisplay`,
`RM69A10Display` and `HI8561Display`. Missing only on `LGFXDisplay` (V4-R8) and
`ST7796LCDDisplay` (the pagers), and LovyanGFX has `pushImage` underneath, so
those are a wrapper method rather than new work.
That changes the answer on the two APIs that looked impossible:
| API | Console backend | Why it works |
|---|---|---|
| `label` | Text at a cursor. | `print` / `printWordWrap` / `drawTextEllipsized`. |
| `button` | A highlighted line with a selection index; activated by key or tap. | Text plus `fillRect` for the highlight. |
| `list` | Numbered lines, one selected. | Same. |
| `input` | A prompt, using the console's own keypad. | Phase 1 already builds it. |
| `text_w` / `text_lines` | `getTextWidth`, honestly measured. | Already the right shape. |
| `chart` | Drawn directly. | It is bars and lines; `fillRect` does it. |
| **`canvas`** | **Works.** The app already gets an RGB565 buffer and draws into it; console mode blits that buffer instead of handing it to LVGL. | `writePixelsRGB565`. |
| **`map`** | **Works.** Tiles are already decoded to RGB565 buffers (`MapTile.rgb565`); the projection and cache are ours and have no LVGL in them. | Same blit, plus markers as `fillRect`. |
**What is genuinely lost is interaction, not drawing.** LVGL supplies scrolling
containers, a focus group, flex layout and event bubbling. The console backend
has to provide its own much simpler version: a line cursor, a selection index,
a scroll offset. That is the real work in this phase, and it is bounded.
So the honest expectation is that **most apps work**, including the graphical
ones, and a few that lean on LVGL behaviour we do not reimplement will not.
Apps already feature-detect through `wada.sys.caps()`, so anything that cannot
be supported is reported rather than broken, and `caps().console` lets an app
adapt its layout deliberately.
### 3. Getting back out
A boot flag that lands you in a console with no way back is a device that feels
bricked, and it will happen to somebody. This needs to be designed, not bolted
on. See [Safety](#safety).
---
## Plan
Each phase is shippable and useful on its own. Stop after any of them and
nothing is half-built.
### Phase 0: measure first
Before building anything, get the numbers the feature is being justified by.
- Free internal DRAM and free PSRAM at the home screen, on a V4 and a T-Deck.
- The same with `lv_timer_handler()` stubbed out and nothing built.
- Loop iterations per second, both ways.
Cheap, and it either confirms the case or changes what to build. If the DRAM
saving turns out to be negligible on the V4 and the PSRAM saving large, that is
worth knowing before the docs promise anything.
**Output:** real numbers in this file.
### Phase 1: the console front end
A `ConsoleUI` that owns the panel through `DisplayDriver`, with no LVGL.
- Scrollback ring in PSRAM, N lines, prune oldest.
- Prompt line, cursor, word wrap via `printWordWrap`.
- Input from the hardware keyboard where there is one.
- **An on-screen keypad on touch boards.** Decided: the V4 is the board that
most needs its resources back, so console mode has to be usable there without
a keyboard. The existing on-screen keyboard is LVGL and cannot be reused, so
the console draws its own: `fillRect` + `drawRect` + `print` for the keys, and
touch read straight from the board driver (`heltecV4CapTouchCheck()` and its
equivalents), which is already independent of LVGL. `lvglTouchRead` is only an
adapter feeding LVGL, so nothing needs to be untangled first.
- Commands go straight to `the_mesh.runLocalCli()`; output arrives through
`setTerminalSink`. This part is close to free.
- One touch affordance, as you said: a back/exit target.
At the end of this phase the device is usable over the mesh from a console.
### Phase 2: boot into it, and back out
- A pref, `touchPrefsGetBootMode()`, read early in `setup()`.
- In console mode: never call `lv_init()`, never allocate the draw buffer, never
build the UI. This is where the saving comes from, so it has to be a genuine
skip and not a hidden LVGL instance.
-`ui_task.showAlert()` and friends route to the console.
- Commands to switch: `ui` reboots into the graphical mode, `console` reboots
into this one. Also a Settings toggle in normal mode.
### Phase 3: the things the CLI does not cover
`CommonCLI` is a node CLI, not a messenger. Console mode needs conversational
commands, some of which the existing on-device terminal already has (`to <name>`
sets a recipient):
-`msg`, `to`, `reply`, unread counts, reading a thread's scrollback
-`contacts`, `chans`, `discover` with the results table
-`apps`, `run <id>`
Most of this is a thin layer over calls that already exist for the UI.
### Phase 4: Lua apps in the console
- A `WADA_UI_CONSOLE` backend behind the same `wada.ui` names, per the table
above.
-`caps()` gains `console`, `canvas`, `map` so apps can adapt, and the docs say
which apps work.
- Store apps audited; those that cannot work say so on launch rather than
failing oddly.
### Phase 5: polish
- Command history and tab completion.
- Colour, where the panel has it.
- A `help` that is actually good, since there is no discoverable UI.
- Docs page, and a section in the user guide.
---
## Safety
The failure that matters is a device that boots to a console the owner cannot
leave. Three independent ways out, because one is not enough:
1.**A command.**`ui` then reboot.
2.**A key held at boot.** Any boot with the key down forces graphical mode
regardless of the pref, the same shape as the existing recovery paths.
3.**The web flasher and the serial CLI** can always clear the pref.
And the pref should be stored so that a corrupt or unreadable value means
*graphical*, never console. Fail toward the mode everyone can use.
---
## Risks
| Risk | Containment |
|---|---|
| Console mode rots because nobody builds it | It compiles into every build from Phase 1, and SDK Test runs in it. A broken console fails the build, not a user. |
| Two front ends drift apart | The console consumes the same `runLocalCli` and the same MyMesh accessors the UI does. Do not let it grow a private copy of anything. |
| Touch-only boards get a console they cannot type into | Decide in Phase 1. Requiring a keyboard is a legitimate answer. |
| It quietly degrades normal mode | Everything new is behind the boot pref, and the graphical path keeps its current code path unchanged. Phase 0's numbers are the regression check. |
| Lua apps half-work and look broken | `caps()` first, refuse to launch second, document third. Less likely now that canvas and map can actually be drawn. |
| Two display drivers lack a raw blit | `LGFXDisplay` and `ST7796LCDDisplay` need a `writePixelsRGB565`; LovyanGFX's `pushImage` does the work. Until then `caps().canvas` is false on those boards and apps adapt. |
---
## Effort
Honest, assuming no surprises:
| Phase | Effort |
|---|---|
| 0 measure | Half a day |
| 1 console front end (incl. the drawn keypad) | 3 to 4 days |
| 2 boot mode and back out | 1 to 2 days |
| 3 messaging commands | 2 to 3 days |
| 4 `wada.ui` console backend | 4 to 6 days |
| 5 polish and docs | 2 days |
**A console you can actually use on the mesh: Phases 0 to 3, about a week.**
Phase 4 is the one that can grow, because it is the one with a design question
in it rather than just code.
---
## Open decisions
Worth settling before Phase 1 rather than during it.
1.~~Keyboard required?~~**Decided: no.** Touch boards get a drawn on-screen
keypad, because the V4 is exactly the board this feature is for.
2.**Do Lua apps matter in v1?** Phases 0 to 3 are worth shipping without them.
The translator is now a known quantity rather than a question mark, so it can
follow safely.
3.**Does this replace the Terminal app** in graphical mode eventually, or do
both stay?
---
## Audit: every UI surface, and its console equivalent
Done by enumerating the real thing rather than from memory: the tab list, the
app-drawer action enum (`APPACT_*`), the 19 settings categories, and the node
CLI's own command table in `CommonCLI.cpp`.
**The single most useful finding: the node CLI already covers far more than
| LilyGo T-Deck Pro | ESP32-S3, SX1262 | 3.1" 240x320 e-paper touch, TCA8418 QWERTY | Development build only | Experimental | Initial target; display, touch, keyboard, radio, GPS and microSD validation pending (#62) |
| LilyGo T-Deck Max | ESP32-S3, SX1262 | 3.1" 240x320 e-paper touch, TCA8418 QWERTY | Web flasher | Experimental (new in beta_84) | The Pro's hardware with the peripheral power rails and resets on an XL9555 expander and a BQ27220 fuel gauge; contributed and tested by the author of PR #545 |
| Tanmatsu | ESP32-P4 + ESP32-C6, SX1262 | 4" 800x480, 69-key keyboard (no touch) | Tanmatsu app store on the device (runs under the badge.team launcher, not web-flashable) | Store tracks the test channel | Fully supported; LoRa + Wi-Fi + Bluetooth simultaneously, standalone and companion in one |
| Elecrow ThinkNode M9 | ESP32-S3, LR1110 | 2.4" 240x320 (no touch), I2C QWERTY + d-pad | Web flasher | Beta (new in beta_38) | Hardware-complete community port by ded (#138): GPS, microSD, buzzer, lock screen, d-pad navigation |
| RAK WisMesh Tap V2 (RAK3312) | ESP32-S3, SX1262 | Touch display (LovyanGFX, 30+ fps) | Web flasher | Beta (new in beta_38) | Early community port by Ethac.chen (#136); core mesh, chat and map working |
| LilyGo T-Lora Pager | ESP32-S3, LR1121 or SX1262 | 2.33" IPS LCD, 480x222 (no touch), QWERTY + rotary encoder | Web flasher (pick the build matching your radio) | Beta (new in beta_45) | Fully supported; keyboard-first navigation, microSD, map tile packs |
| Heltec V4-R8 + Expansion Kit V2 | ESP32-S3 (8 MB octal PSRAM), SX1262 | 2.4" touch (CHSC6x) | Web flasher | Beta (new in beta_45) | As the V4 plus microSD and the Expansion Kit sensors; buzzer supported |
| LilyGo T-Display P4 | ESP32-P4 + ESP32-C6, SX1262 | AMOLED or TFT-LCD, touch | Web flasher (AMOLED) or the .bin for the LCD SKU | Beta (new in beta_45) | Two screen SKUs; fuel-gauge battery reporting, full TX power |
| Attaky Mesh Series | ESP32-S3, SX1262 | Touch display + front D-pad | Web flasher | Beta (new in beta_47) | Community port by attakygit (#158/#169); detachable keyboard supported; the front D-pad + SELECT navigate the UI |
## Feature notes per board
- **T-Deck**: the everything device: touch, physical keyboard, trackball cursor
- Tanmatsu (ESP32-P4) — built from `tanmatsu/` (ESP-IDF), ships via the Tanmatsu app store — [sideload guide](TANMATSU_SIDELOAD.md) for running your own build
| **OK / Enter** | Activate the focused item; send a message; run a terminal command; newline in the editor | **Long-press the focused item / unlock the lock screen** |
The **MSG**, **HOME**, **@**, **ADV**, **MAP**, and **CTRL** shortcuts remain
active while typing or using a picker. Back, the d-pad, and OK remain contextual
so they can leave edit mode, move the caret or selection, and activate controls.
## Map pan mode
On the Map tab, press **MAP** again: the arrows now pan the map instead of
moving focus. **MAP** or **BACK** exits. Pan mode switches itself off whenever
you leave the map or anything opens over it, so a stale pan can never eat a
key press later. Auto-follow pauses while you pan and resumes when you exit.
Entering the map fresh always starts in normal navigation.
## Files and offline maps
From the Home tab, press **HOME** to open the app drawer. **Files** browses and
organizes the M9's SD card. **Transfer** starts an authenticated browser session
for uploads and downloads over the same Wi-Fi network.
To install an offline map, open Transfer, visit the displayed URL, enter the
six-digit code, choose **Offline OSM map (/tiles)**, and select an unpacked
folder containing `z/x/y.png` tiles. After it completes, open Map options,
select OSM, and enable **Tiles from SD card**. See the
[offline map tile guide](README.md#offline-map-tiles) for the QGIS export steps
and map-provider policy requirements.
## Typing & editing
Inside a text field the left/right arrows move the caret — and when the caret
is already at the edge of the text, the same press steps focus *out* of the
field, so you're never stuck. Enter sends (toggleable under Settings), Back
leaves the field. In the Terminal, Enter runs the command; in the text editor
it inserts a newline.
While a chat message is focused and newer messages are below it, a down arrow
appears at the right edge. Press **Right** to select it, then **OK** to jump to
the newest message.
Channels, direct messages, and room conversations place **#** and emoji to the
right of the message input. Press Right to move from the input to **#**, and
Right again to move to emoji. Press **OK** on either button to open its picker.
In the symbol grid, use all four arrows to move the highlight and **OK** to
insert the selected character.
The Create/Join channel forms use the same **#** action beside every text
field. Right moves from the field to **#**; Left returns to editing the field.
Password fields use the same control, including Wi-Fi, MQTT, and repeater login.
After inserting a symbol, focus returns to the originating text field.
## Lock screen
**Hold OK** to unlock — the keyboard's hardware long-press stands in for the
hold-to-unlock other boards do by touch. With "Flash on new message" enabled,
an incoming message wakes (or lock-reveals) the screen and pulses the keyboard
backlight.
## Inside apps
Store apps (Snake and friends) get the d-pad natively: arrows steer, OK taps
the centre — start, steer, and tap-to-retry all work. Display-only apps
(Airtime, RF Monitor) keep normal navigation: arrows move between the app's
own buttons or scroll the feed. **Back and Home always escape an app** — no
app can trap the keys, even while the screen is locked.
## Recent improvements (beta_66 branch)
If you last used the M9 on an earlier beta, the notable navigation changes:
- **Back now actually goes back** — once everything covering the screen was
closed, Back had nothing left to do and the press simply died on any bare
tab. It now walks back through the screens you visited, then Home. Jumps that
used to strand you ("Show on map" from Discover or Contacts, a route replay
from a chat, the Home unread line, the ✉ badge, app-drawer tiles) all return
properly. **Back also works in the first-boot wizard** now, stepping back
through its screens like the on-screen Back button.
- **Back restored and made consistent** — it had regressed to doing nothing
outside text fields, and could close hidden popups beneath the Control
Center. Both fixed; the close-what-you-see rule above now holds everywhere.
- **The map opens faster the first time** — the cold open was doing a
whole-card zoom scan whose result was thrown away, and (with no GPS fix) was
repeating that scan on *every* open. The "Loading map…" hint now appears
immediately instead of after the scan, so the board no longer looks frozen on
the previous screen while the first tiles decode.
- **The d-pad no longer goes dead for a second** when a message arrives, and
the Spectrum page no longer steers an invisible cursor around the app drawer
underneath it.
- **Apps can't strand you** — opening Advert/Mentions over a running app used
to orphan it with no key path back; app permission dialogs are now
answerable with the d-pad.
- **Map pan cleans up after itself** (see above — it used to leak across tab
jumps).
- **Keyboard light "On" applies at boot**, not after the first dim cycle.
- **Terminal chat shows incoming replies**, not just your own sent lines.
- **GPS works from a cold boot** — the toggle (and ADV-hold) no longer depends
`updatePagerAltShiftChord()` toggles persistent Caps Lock ONLY while a text
field is actually being edited (same "ta" derivation as `handleHwKey()`'s
TLORA_PAGER branch), and jumps straight Home (`navGoToMainTab`) everywhere
else — toggling Caps Lock with no field to see it change in was reported as
surprising/purposeless outside of typing.
TLORA_PAGER branch), and is a no-op everywhere else — toggling Caps Lock
with no field to see it change in was reported as surprising/purposeless
outside of typing. Alt+Backspace owns the Home shortcut.
See `PagerKeyboard.cpp`'s matrix-legend comment for the one documented
edge case (Alt+Shift+O or Alt+Shift+L all three held phantom-ghosts a
stray 'q'/'a' — a diode-less-matrix limitation, not a bug, and not the
@@ -601,7 +656,7 @@ existing boards. Notes:
**Gate:** all three envs build — verified.
- [x] ⑥ **UITask wiring**: `device_caps.h` got its `TLORA_PAGER` cap block (no
touch, no rotate, no large-screen, GPS 1, OTA 1, lock-screen 1; `CAP_SD`/
`CAP_FILESYSTEM`corrected to 0 — see below) plus widened `CAP_KEYBOARD`/
`CAP_FILESYSTEM`set to 0 at the time — both are 1 today, see risk 1h) plus widened `CAP_KEYBOARD`/
`CAP_KEYPAD_NAV` derivations to recognize `HAS_PAGER_KEYBOARD`/
`TLORA_PAGER`. `UITask.cpp` changes, all pager-gated: KEYPAD indev
registered by widening the Tanmatsu branch's `#if` to
@@ -624,9 +679,10 @@ existing boards. Notes:
vs. the milestone doc's assumptions, all found by compiling rather than
assumed — see risks 1h/1i/1j:
- `CAP_SD`/`CAP_FILESYSTEM` looked right from the hardware (real microSD
slot) but the actual mount code is hardcoded to `HAS_TDECK_GT911`, not
truly `CAP_SD`-generic — set back to 0, real support is unscheduled
follow-up.
slot) but the mount code was hardcoded to `HAS_TDECK_GT911`, not truly
`CAP_SD`-generic — set back to 0 for this milestone. *(Superseded: the
call sites were widened over the following passes and both macros are 1
today — risk 1h.)*
- `navMaybeRebuild()` (populates the LVGL focus group every screen) wasn't
reachable on the pager's own cap combination — added a
`#elif defined(TLORA_PAGER)` arm; without it the KEYPAD indev would have
@@ -653,11 +709,23 @@ existing boards. Notes:
above): still untested. **SD storage: DONE** — real mount, browse,
read/write/delete, and insert/remove detection all confirmed on hardware
(risk 1h). (DataStore/NVS-level SD use — routing contacts/channels to the
card — is a separate, still-deferred feature, not part of this bring-up
gate.) Remove the temporary `[DISP]` register readback in
card — was a separate feature outside this bring-up gate; it has since
landed, see risk 1h.) Remove the temporary `[DISP]` register readback in
`ST7796LCDDisplay::begin()` once the USB piece passes.
- [~] ⑧ **On-device UI pass** — ACTIVE: nav-coverage audit screen by screen (every interactive control reachable via focus group — the Tanmatsu work paved this), chat layout at 222 px, map pan via encoder/keys, fonts legibility at 480-wide, plus fixing any weird UI/keyboard behaviors surfaced by manual testing (human provides photos/repro steps).
- [] ⑨ **Release pipeline**: add the env:binname pair to `release.sh``ENVS`, flasher manifest (`deploy/flasher/manifest-tlora-pager.json`), OTA env name via `FIRMWARE_OTA_ENV`. Separate PR.
- [x] ⑨ **Release pipeline** — DONE (verified against the scripts 2026-08-20).
`tlora_pager_sx1262_...:wadamesh-tlora-pager-sx1262`); both manifests are
generated by `scripts/build/gen-flasher-meta.py`
(`manifest-tlora-pager-{lr1121,sx1262}.json`); both envs set
`FIRMWARE_OTA_ENV`. The install page (`deploy/site/index.html`, which
`flasher.wadamesh.com` 301s to) carries an install button + .bin download for
each variant off the beta feed. Shipped to users in **beta_45**.
One leftover, not user-facing: the legacy static `deploy/flasher/index.html`
still lists only the T-Deck and Heltec V4 cards and has no pager manifests
next to it. That page is no longer the install path — decide whether to add
the two cards for parity or retire the directory.
- [x] ⑩ (Optional, cheap) `tlora_pager_sx1262_...` env for SX1262-variant owners — **DONE, hardware-verified 2026-07-13** (`tlora_pager_sx1262_companion_radio_touch`, all 4 envs green; see `TLORA_PAGER_PORT_MILESTONES.md`'s Milestone 10 for the full writeup, including the `target.h` missing-include bug found + fixed). Radio gate (M7 step 3) confirmed on real hardware: user sent + received messages on public, a custom `#` channel, and DM.
## UI-changes inventory (what actually changes in `src/ui-touch/`)
@@ -673,15 +741,17 @@ existing boards. Notes:
| `navMaybeRebuild()` reachability | Added `#elif defined(TLORA_PAGER)` arm — was unreachable, would've left focus group permanently empty | small | done (bug fix) |
| Vertical budget | Audit `STATUSBAR_H`, `TABBAR_H`, `CHAT_KB_H`, modal/chat height helpers for 222 px | **the real work** | deferred to M8 on-device (risk 8) |
| Focus-nav coverage | Screen-by-screen pass that every control is in `s_nav_group` | medium, on-device | deferred to M8 |
| device_caps.h | New `CAP_*` block: no touch, hw keyboard, encoder, 480×222, GPS, OTA, lock-screen; SD/filesystem left 0 by design — real SD support added via widened individual call sites instead (see risk 1h) | trivial | done |
| device_caps.h | New `CAP_*` block: no touch, hw keyboard, encoder, 480×222, GPS, OTA, lock-screen. `CAP_SD`/`CAP_FILESYSTEM` were 0 while SD support was staged in per call site, and are now **1** (see risk 1h) | trivial | done |
| microSD (file manager + WAV picker) | `fmSdTryMount()`/`fmShowRoots()`/`fmIsAudio()` etc. widened to `\|\| defined(TLORA_PAGER)`; DataStore SD routing, chat-history cap, tile/wallpaper/backup SD use still deferred | medium | done, scoped (risk 1h) |
| microSD (file manager + WAV picker) | `fmSdTryMount()`/`fmShowRoots()`/`fmIsAudio()` etc. widened to `\|\| defined(TLORA_PAGER)`; DataStore routing, 5000-msg history, tiles and backup export all landed later. Only wallpaper SD scanning is still T-Deck/M9-only | medium | done (risk 1h) |
| microSD recovery row | `fmSdPagerRetryMountCb` — a detected-but-unmountable card renders greyed with a tap-to-retry that clears the mount backoff. No in-app format on this board, by decision | small | done |
| Save update bin to SD | About-page `/BINS` firmware export widened to the pager (`sdFwWorkerRun`); board-specific wording kept as separate TR() keys so the T-Deck's translated Launcher strings are not orphaned | small | done |
| Chat bubble Enter/Backspace (`handleHwKey()` ~28808) | Enter on a focused bubble opens the Ack/Mention/Copy/Info/Block menu (`navEnterBubble()`); Backspace jumps to the newest message when the scroll-to-bottom button is showing | small | done |
| Shift/Caps (`PagerKeyboard.cpp`) | Held Shift = momentary uppercase; held Alt+Shift = chord (see below for what it does) | small | done |
| @-mention picker keyboard nav | Auto-arms the instant the list appears (no Fn+Space needed); same encoder walk/confirm/cancel as the accent popup (`s_mentionnav_active`/`mentionNavRestyle()`/`mentionNavConfirm()`) | medium | done |
| Encoder short click on a focused bubble | Mirrors keyboard Enter — opens the same action menu instead of a plain ENTER a bubble doesn't react to | small | done |
| Fn+Shift chord effect | Toggles Caps Lock while editing a text field; jumps to Home (`navGoToMainTab`) everywhere else — `PagerKeyboard.cpp` only reports the chord, `UITask.cpp` decides the effect | small | done |
| Fn+Shift chord effect | Toggles Caps Lock while editing a text field; no-op everywhere else. Fn+Backspace jumps Home — `PagerKeyboard.cpp` only reports the chords, `UITask.cpp` decides the effects | small | done |
| Map pan (WASD) + slider nudge (Q/E) | WASD pans the Map tab (`mapNudge()`, shared with Tanmatsu's Ctrl+Arrow); Q/E adjust a focused slider (moved off D/F to avoid colliding with WASD) | small | done |
| App-drawer tile focus highlight | `NAV_ACCENTFOCUS_FLAG` makes `navFocusCb` use the tile's own accent tint instead of the generic white reverse-fill, on every board | small | done |
| SX1262-variant env | `tlora_pager_sx1262_companion_radio_touch` — same board/pins, `CustomSX1262`/`CustomSX1262Wrapper` instead of the LR1121 classes | medium | done (worklist ⑩) |
@@ -721,10 +791,16 @@ MQTT, OTA) is resolution-agnostic or already keyed off caps.
different register layout (RTClib's `RTC_PCF8563` driver would misread its
registers), so calling `rtc_clock.begin(Wire)` would silently produce
garbage timestamps instead of a clean fallback. `target.cpp`'s `radio_init()`
deliberately skips that call — same time behavior as T-Deck/Heltec V4 (ESP32
software clock) rather than a false "RTC found" that's actually wrong. Real
PCF85063A support (SensorLib's `SensorPCF85063`) is unscheduled follow-up
work, not part of any milestone ①–⑩ yet.
still deliberately skips that call.
**RESOLVED (issue #383).** The board now drives the real PCF85063A through
`src/helpers/HardwareRtcClock.*`, a board-declared adapter (no address-only
auto-detection) that proves the chip's identity with the same RAM-register
test SensorLib uses, honours its OS integrity bit, validates every BCD nibble
and calendar field, checks the epoch against `ClockFloorRTC`'s bounds, and
reads back every write. It seeds the ESP32 system clock once at boot and is
never touched on the hot path, so the shared I²C bus carries no extra traffic
per message or UI tick. That is what gives the Pager the powered-off retention
Ripple always had.
1e. **`-D CGRAM_OFFSET=1` must land in Milestone ④'s pager env flags.**
This panel's 222px glass is narrower than the ST7796 controller's 320px
GRAM; TFT_eSPI's `ST7796_Rotation.h` only applies the required 49px
@@ -776,29 +852,51 @@ MQTT, OTA) is resolution-agnostic or already keyed off caps.
page-scroll backward/up instead. Fixed by negating once in
`PagerEncoder.cpp`'s `pagerEncoderReadDelta()` (the driver, not UI code)
so every consumer inherits the corrected sense from one place.
1h. **`CAP_SD`/`CAP_FILESYSTEM` are 0 for the pager despite real microSD
hardware — a latent gap in `device_caps.h`'s own abstraction, not a pager
(press the knob) to select. It's your only pointing device, so almost
every screen is navigable with turn + click alone.
Everything below only fires while you're **not** actively typing into a text
field — if a field is focused and you're typing into it, letters type
normally and these combos step out of the way.
Unless a row explicitly says it applies to a text field, the shortcuts below
only fire while you're **not** actively typing. Letters continue to type
normally while a field is active.
## Bottom menu shortcuts
The five main screens show their keyboard shortcuts directly in the bottom
bar:
```text
[mail] M [contacts] C [home] H [map] A [settings] S
```
Press **M**, **C**, **H**, **A**, or **S** to jump to that screen. These
shortcuts only work while one of the five main screens is at its top level.
They do nothing inside an open chat, settings detail, app page, or popup, so a
letter cannot unexpectedly close the inner screen. They also remain inactive
while editing a text field, where the keys type normally.
## Rotary encoder
| Gesture | Action | Keyboard equivalent |
|---|---|---|
| Turn | Move focus to the next/previous item on screen | **Fn (Alt) tapped alone** moves forward one step (NEXT only — no keyboard way to go backward) |
| Turn | Move focus to the next/previous item on screen | none — encoder only |
| Short click | Select / confirm the focused item | **Enter** |
| Hold ~1 s, then release | **Back**: closes a popup → closes an open chat → goes Home → Esc (whichever applies first) | **Backspace held ~1 s** |
| **Fn (Alt) + turn**, on a main tab | Jump directly between the 5 main tabs (Chats / Contacts / Home / Map / Settings) | none — encoder only |
| **Fn (Alt) + turn**, on a main tab | Move between the 5 main tabs (Mail / Contacts / Home / Map / Settings) | **M / C / H / A / S** jumps directly |
| **Fn (Alt) + turn**, inside a settings page or chat | Scroll the page up/down | none — encoder only |
| **Fn (Alt) + short click**, with a text field focused | Open the special-character picker | none — encoder only |
| Turn, with a dropdown open | Scroll through the dropdown's options | none — encoder only |
| Turn, with the accent picker open | Cycle through the accent variants (see below) | none — encoder only (Fn+Space to enter is shared) |
| Turn, with the @-mention list open | Cycle through matching contacts (see below) | none — encoder only |
| Turn, at the oldest/newest **loaded** message in an open chat | Loads more history in that direction and keeps going — see [Chat screen](#chat-screen) | none — encoder only |
The knob doubles as your only way to reach the bottom tab bar — since
there's no touch to tap an icon, **Fn+turn** while on any main tab is the
fastest way to switch sections.
Since there's no touch to tap the bottom bar, use its visible **M / C / H / A /
S** shortcuts to jump directly or **Fn+turn** to move through the tabs in
order.
## Chat screen
@@ -78,29 +94,19 @@ text box → Send**, in that order, in both directions — nothing is skipped.
Turning past the △ chip (toward the messages) always jumps straight to the
newest message, regardless of channel or DM.
### Backspace and Fn (Alt) — leaving vs. catching up
### Backspace — catching up
These two keys are your shortcuts in and out of "reading mode" on this
screen, and they do different things depending on whether you've got unread
messages:
**Backspace (tap)** jumps to whichever message needs your attention:
- **Fn (Alt) tapped alone** — jumps straight to the **newest** message and
moves focus into the text box, ready to type a reply. This is the
deliberate "I'm done reading, let me respond" gesture — it works regardless
of where your focus currently is in the chat.
- **Backspace (tap)** — jumps to whichever message needs your attention:
- If there are unread messages, it jumps to the first one — right below the
**"NEW ----"** divider — and selects it, so you can then turn forward
through your unread messages in order, oldest-of-the-unread first.
- If nothing is unread, it jumps to the newest message instead (same
destination as Fn+Alt, but leaves focus on the message itself rather than
the text box).
- If there are unread messages, it jumps to the first one — right below the
**"NEW ----"** divider — and selects it, so you can then turn forward
through your unread messages in order, oldest-of-the-unread first.
- If nothing is unread, it jumps to the newest message instead (same
destination, with focus left on the message).
Backspace's override above only applies while you're inside an open chat and
*not* actively editing the text box — the usual rule from the top of this
guide; with a field focused, Backspace deletes a character as normal.
Fn (Alt) tap's override fires the same way whether or not the text box
already has focus, since landing there is the whole point of the gesture.
## Keyboard layout
@@ -113,7 +119,9 @@ a s d f g h j k l [Enter]
[Space]
```
Hold **Fn (Alt)** for numbers/symbols instead:
Hold **Fn (Alt)** for numbers/symbols, or tap it once to apply this layer to
the next key only. Double-tap Fn to lock the symbol layer; tap it again to
return to letters.
```
1 2 3 4 5 6 7 8 9 0
@@ -122,6 +130,15 @@ Hold **Fn (Alt)** for numbers/symbols instead:
[Space]
```
### Symbols not on the keyboard
While any text field is focused, including a Wi-Fi or repeater password:
1. Hold **Fn (Alt)** and short-click the encoder to open **Special characters**.
2. Turn the encoder to highlight a symbol such as `%`, `<`, `>`, or `€`.
3. Short-click the encoder or press **Enter** to insert it.
4. Hold the encoder for about one second to close the picker without inserting.
### Shift and Caps Lock
- **Hold Shift + a letter**: that letter (or letters, for as long as you
@@ -147,7 +164,7 @@ Hold **Fn (Alt)** for numbers/symbols instead:
| **Space** (tap) | Types a space | — |
| **Space** (double-tap, within 250 ms) | Switches between English and your configured secondary keyboard layout | — |
| **Space** (hold ~1 s) | — | Locks the screen (shows a "Locking…" progress bar; tapping any key cancels) |
| **Fn (Alt)** tapped alone (press+release, nothing else) | — | Moves focus to the next field/item — a keyboard-only substitute for a turn of the encoder. In an open chat: jumps to the newest message and focuses the text box instead — see [Chat screen](#chat-screen) |
| **Fn (Alt)**(tap / double-tap) | Next key uses symbols / lock symbols until tapped again | Same |
The **BOOT** button (top of the device) instantly wakes the screen from
idle-dim. It does *not* unlock a screen you've manually locked with the
@@ -193,9 +210,13 @@ While the **Map** tab is active:
- **W** — pan north
- **A** — pan west
- **S** — pan south
- **X** — pan south
- **D** — pan east
`X` replaces the usual `S` position because **S** is the visible Settings
shortcut. Pressing **A** on another main screen opens Map; once Map is already
active, **A** pans west.
Each press shifts the view a quarter-screen and reloads tiles/markers as
needed.
@@ -206,23 +227,39 @@ needed.
- **Off** — always dark
- **On** — always lit
- **Auto** (default) — lights up on any keypress, turns off ~3 seconds after
the last one
- **Auto** (default) — lights up on any keypress and follows the configured
**Screen timeout**; `0 = never` keeps it lit until the screen is turned off
Unlike the T-Deck, this is a plain on/off strip under the keys, not a
dimmable brightness curve.
## Leaving Remote Mode
Remote Mode replaces the normal device UI with a physical-screen placeholder
showing the browser address and an **EXIT REMOTE** button. While that screen is
active, normal navigation is paused. Press the **encoder knob** to activate the
button, turn off Remote Mode, and reboot back to the normal screen. You can also
press **Space twice within 3 seconds**; after the first press, the button changes
to **press SPACE again**. The browser's **Exit remote** button performs the same
action.
This Remote Mode gesture is separate from the normal 250 ms Space double-tap
that changes keyboard language.
## Quick reference
| Input | Action |
|---|---|
| Turn encoder (or Fn tap = NEXT only) | Move focus |
| M / C / H / A / S (top-level main screens only) | Open Mail / Contacts / Home / Map / Settings |
| Turn encoder | Move focus |
| Turn, at the loaded edge of a chat | Load more history, keep moving — only exits the list at the true oldest/newest message |
| Click encoder (or Enter) | Select / confirm |
| Hold encoder ~1s (or hold Backspace ~1s) | Back |
| Fn + turn (main tab) | Switch tabs |
| Fn + turn (page/chat) | Scroll |
| Fn tap alone | Next field (in a chat: jump to newest message + focus the text box) |
| Fn + encoder click (text field focused) | Open special-character picker |
| Fn tap alone | Use the symbol layer for the next key |
| Fn double-tap | Lock the symbol layer; tap Fn again to unlock |
{"id":"2048","name":"2048","ver":"1.2","desc":"The classic sliding-tile puzzle. Swipe to move, tap to restart. Keeps your best score. Contributed by pisti87."}
{"id":"monitor","name":"RF Monitor","ver":"1.3","desc":"Scrollable: RSSI/noise chart with dBm axis, link margin, RX rate, radio params, heard feed."},
{"id":"airtime","name":"Airtime","ver":"1.3","desc":"Utilization bars with a % axis, rx/tx split, duty ceiling, budget. Reset button top-right."},
{"id":"snake","name":"Snake","ver":"1.0","desc":"The classic, in Lua. Swipe to steer."}
]}
{
"apps":[
{
"id":"monitor",
"name":"RF Monitor",
"ver":"1.3",
"desc":"Scrollable: RSSI/noise chart with dBm axis, link margin, RX rate, radio params, heard feed."
},
{
"id":"airtime",
"name":"Airtime",
"ver":"1.4",
"desc":"Utilization bars with a % axis, rx/tx split, duty ceiling, budget. Reset button top-right."
},
{
"id":"snake",
"name":"Snake",
"ver":"1.0",
"desc":"The classic, in Lua. Swipe to steer."
},
{
"id":"sdktest",
"name":"SDK Test",
"ver":"1.7",
"desc":"Developer tool. Checks the extended Lua SDK on this board: capabilities, clock, battery, GPS, private file access, audio API, read-only SD listing, crypto against published RFC vectors, channel discovery, and buttons that TRANSMIT test messages."
},
{
"id":"2048",
"name":"2048",
"ver":"1.2",
"desc":"The classic sliding-tile puzzle. Swipe to move, tap to restart. Keeps your best score. Contributed by pisti87."
},
{
"id":"wardrive",
"name":"Wardrive",
"ver":"1.2",
"desc":"LoRa coverage survey. Probes every 20s and logs each reply with GPS position, altitude and BOTH link directions (how well you heard them, how well they heard you) to a CSV, then plots the run back on the map so you can check coverage before driving it again. Reference app for wada.mesh.discover.",
"requires":"sdk_ext"
},
{
"id":"nearby",
"name":"Nearby",
"ver":"1.0",
"desc":"Your contacts on a real map, sorted by distance, with bearing and a live frame counter. Reference app for wada.map, wada.ui.list, wada.geo and app.on_packet.",
"requires":"sdk_ext"
},
{
"id":"gpscompass",
"name":"GPS Compass",
"ver":"1.0",
"desc":"Compass rose: magnetometer or GPS course, live fix, bearing to contacts",
"seed":false
},
{
"id":"sdscan",
"name":"SD Scan",
"ver":"1.1",
"desc":"Finds and removes Windows malware on the SD card, like the M9 worm.",
File diff suppressed because it is too large.
Load diff
Loaded 100 of 419 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.