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>
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>
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>
pisti87 found "Settings → Quick replies" drawing a tofu box, in English as well
as Hungarian. It is not a language bug and it is not the beta_62 fallback-chain
fix falling short — the glyph simply was not in any font we ship.
gen-touch-fonts.sh asked $noto_sans for the symbol set, and Noto Sans does not
contain U+2190-2193 or U+2260/2264/2265 (verified directly against the release
the script pins). lv_font_conv omits a glyph its source font lacks rather than
failing, so those seven characters silently never made it into extras_font_*,
in every language, since the fonts were first generated this way.
Scope is much wider than the one line reported: 137 uses of → in the touch UI
plus arrows in all 13 .lang files. pisti87 happened to open one of them.
Fixed by cutting the seven from Montserrat, which has all of them, is already
the primary UI face and already the first --font in this script — so no new
dependency, no new licence line, and the arrow matches the text beside it. They
had to be REMOVED from the Noto Sans symbol list as well: with the codepoint
claimed by a later font that cannot supply it, the Montserrat pass produced
nothing and the regenerated files came back byte-identical apart from a comment.
Noto Sans Symbols 2 was the obvious candidate and does NOT have them either —
it errors outright when asked, which is how that was ruled out.
Not visually confirmed: I have no way to see the glyph render from here. The
regenerated fonts grew ~390 lines per size and lv_font_conv accepted the request
(it hard-errors when the source font has none of the symbols, as Symbols 2 did),
so the glyphs are in. Worth a look on a device before the beta ships.
Reported by pisti87.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The #259 popup change introduced TR("Scope "), TR("my region") and
TR("another region"). Two problems, both caught by re-running the audit rather
than by eye:
- none of the 13 language files had rows for them, which would have reopened
the drift closed in d4ade2e. Added to all 13 as empty rows (the format's
own untranslated marker), so translators see the gap and English falls
through meanwhile.
- the key was "Scope " with a trailing space. TR() strips icon-glyph prefixes,
NOT trailing whitespace, so the lookup would never have matched a "Scope"
row and that label was permanently English in every language. The space now
lives in the format string where it belongs.
audit-lang.py: 0 missing and 0 unsafe across all 13.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
D-Melhede noticed every message from the same sender in the same channel showed
a different four-digit scope, and reasonably concluded we were reading the wrong
field. The field was right; the label was wrong.
transport_codes[0] is not a region identifier. The sender computes it as
HMAC-SHA256(region key, payload type + payload) truncated to 16 bits, so it is
a per-PACKET authentication code and necessarily differs for every message —
two posts in one channel cannot share one. Printing it as "Scope NNNN" invited
exactly the reading it got.
A receiver cannot decode a region out of that number, but it can VERIFY one:
recompute the code with a region key it holds and compare (this is how the core's
RegionMap::findMatch identifies a packet's region). wadamesh keeps only its own
region key, so the honest answer is binary, and that is now what the popup says:
the configured region name when the code verifies against our key, "another
region" when it does not. The hex stays in parentheses for anyone cross-checking
a capture.
The check runs at RX, where the packet still exists, and is carried on a new
MSG_META_SCOPE_HOME bit. Also answers the second half of the report — the scope
now reads as text instead of only hex.
Reported by D-Melhede.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Messages were arriving in a nameless "#unknown" thread on unrelated devices.
Root cause is a single missing check, with two amplifiers:
CMD_SEND_CHANNEL_TXT_MSG accepted any in-range channel index, because
getChannel() returns true for a slot that was never configured — whose secret
is all zeroes. Sending on one transmits a group message encrypted with an
all-zero key. Every other device holds that same all-zero secret in its own
spare slots (with hash byte 0x00), so searchChannelsByHash offers them as
candidates and MACThenDecrypt genuinely succeeds — the sender used the same
key. findChannelIdx then matches the first empty slot, whose name is "", and
the UI renders an empty channel name as "#unknown". That is why two people on
two different devices received the identical messages, and why some were empty.
Three fixes, all receiver- and sender-side hardening around one invariant: a
slot with an all-zero secret is not a channel.
- the app-send path rejects an unconfigured slot (ERR_CODE_NOT_FOUND) instead
of broadcasting on a zero key. A wadamesh device can no longer be a source.
- searchChannelsByHash (virtual, so no core change needed) skips unconfigured
slots, so they can never decrypt anything. This also fixes a SEPARATE latent
bug: for a real channel whose hash byte is 0x00, up to four empty slots could
fill the 4-entry candidate array and starve the real channel out, silently
dropping the message — roughly 1 in 256 channels.
- a failed lookup no longer silently becomes slot 0, and an unresolved channel
is labelled rather than handed to the UI as an empty string.
Our own touch UI already refused to send on a nameless slot, so the emitting
device was an app with a stale channel list or another firmware; not reproduced
on hardware. All 8 S3 envs build.
Reported by D-Melhede, and seen by Marshal W7TER and PixPMusic.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pisti87's T-Deck rebooted every time he logged into a repeater, but only in
Hungarian. The login clock-skew warning does:
snprintf(msg, n, TR("Device clock differs from \"%s\" by %lu min%s"),
name, minutes, suffix);
and the Hungarian row reordered the conversions to "%lu ... %s ... %s". Varargs
are positional, so snprintf read the name POINTER as an unsigned long and then
took the minute count -- the integer 3 -- as a char* and dereferenced address 3.
Instant panic, every login, Hungarian only. English fit the declared order, so
it never showed there.
Fixing the four bad Hungarian rows is not sufficient: TR() returns a format
string and translations come from .lang files that users download or hand-write,
so any file can crash any device. TR() now compares the ordered conversion
signatures of key and translation and falls back to the English key on a
mismatch -- the key IS the call site's format string, so it is always correct.
The scan runs only for keys containing '%', which is a small minority.
Also fixed the four rows (three were Hungarian-only crashes or dropped values),
bumped hu to v11, and taught audit-lang.py to fail the build on a mismatch so a
future translation PR cannot reintroduce this. Unit-checked that the audit
detects the original bad row and accepts the repaired one.
Reported by pisti87.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
When the contact table is full, every incoming advert from an unknown node
evicts the oldest contact, and onContactOverwrite() removed that contact's
stored blob inline — on the mesh receive path. On a Heltec V4 the store is
internal SPIFFS, where an unlink can trigger garbage collection, and SPIFFS GC
suspends the flash cache: both cores stall for the duration. That is the
reported symptom exactly — the whole device locks, Bluetooth and TCP drop, the
screen will not wake, and it comes back on its own once GC finishes. It gets
worse the fuller the table is (reports at ~350, ~600 and ~2000 contacts), and a
plain V4 has no SD slot, so there is no storage-side workaround.
Queue the delete instead and drain it from loop(), one blob per tick and no
faster than every 500 ms, under the same WdtHeavyGuard saveContacts uses. A
burst of evictions can no longer chain GC passes back to back, and a slow pass
stalls briefly instead of tripping the watchdog. If the queue (8 deep) fills,
the blob is left orphaned — harmless, and reclaimed on the next wipe.
Reported by Yoss101 and pisti87.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Non-ASCII text was rendering as tofu boxes across the UI in any language that
needs them: Hungarian 'K□z□ss□gi profil' sat under a perfectly rendered 'Rádió
és Mesh' title, same characters, same screen. LVGL's theme puts a font on each
widget, so a label that sets none of its own lands on the stock
LV_FONT_MONTSERRAT_* — which carries no fallback chain. The title looked fine
only because it sets &g_font_16 explicitly.
79 of this file's 543 lv_label_create sites set no font, so this was never a
Hungarian bug: every Greek, Cyrillic, Russian, Ukrainian and Bulgarian user hit
the same boxes across the whole UI, and #232 was the only report.
useChainedFont() reads what a label actually RESOLVES to and swaps a raw
Montserrat for its chained twin, so a label inheriting a larger font from its
parent keeps that size — a blind 'set g_font_14 everywhere' would have shrunk
those. Buttons re-supply the theme font to their child labels, so styleButton()
sets the chained font alongside the text colour it already sets, and
settingsRowLabel() no longer leaves font==nullptr to inherit.
Two global approaches were tried and verified dead on device: a text_font style
on the screen/top/sys layers, and re-pointing the theme's font. Both lose to the
per-widget theme style. Noted in the code so nobody retries them.
Verified on a T-Deck in Hungarian.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pisti87 translated the entire missing-key set for three languages and posted
them as 143 line comments on d4ade2e rather than a PR, so they were sitting
unmerged. Harvested by mapping each comment's diff position back to the
placeholder row it annotates — all 143 matched an empty row exactly, no
guesswork.
de 48/48, nl 48/48, hu 46/46 (two of his 47 Hungarian comments were variants of
the same string; 'Copy blocked: migration guard unavailable' is still open).
Version headers bumped so devices pick the files up; i18n_builtin.h regenerated.
Co-Authored-By: pisti87 <pisti87@users.noreply.github.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A full card failed partway through with a bare 'SD write failed' — the same
message the DMA-buffer short-write used to produce, so it pointed at the wrong
subsystem, and the percentage it died at was just however far the remaining
space stretched. The tile cache is the usual culprit: it fills the card while
each individual tile stays small enough to keep succeeding.
Now it refuses up front with 'need N MB, M MB free'.