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>