Compare commits

...
31 Commits
Author SHA1 Message Date
Kaj SchittecatandClaude Opus 5 de45b1b158 release: beta_65 notes
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 19:22:47 +02:00
Kaj SchittecatandClaude Opus 5 ea673f86a5 docs: last on_stop reference -> app.on_close
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 19:19:33 +02:00
Kaj SchittecatandClaude Opus 5 ca45bd4098 docs: correct the Lua SDK page, and document the beta_64 additions
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>
2026-08-13 19:18:51 +02:00
Kaj SchittecatandClaude Opus 5 b014865ada feat: share your position with chosen contacts, on request (#266)
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>
2026-08-13 19:08:06 +02:00
Kaj SchittecatandClaude Opus 5 fee132425c fix: recover from "LoRa radio not detected" on the first boot after an update
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>
2026-08-13 18:46:28 +02:00
Kaj SchittecatandClaude Opus 5 17643435ea fix: leaving an app page by the "<" no longer also opens the status bar
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>
2026-08-13 18:40:56 +02:00
Kaj SchittecatandClaude Opus 5 2effe9497a release: beta_64 notes
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 17:07:14 +02:00
Kaj SchittecatandClaude Opus 5 763229b9c9 core: verify in-place contact writes by reading them back (#222)
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>
2026-08-13 17:06:13 +02:00
Kaj SchittecatandClaude Opus 5 a96900d7ee core: patch changed contact records in place instead of rewriting the table (#222)
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>
2026-08-13 16:36:37 +02:00
Kaj SchittecatandClaude Opus 5 6518ebf391 feat(lua): app.on_message, behind its own permission
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>
2026-08-13 16:00:12 +02:00
Kaj SchittecatandClaude Opus 5 8a555226e7 feat: Settings -> App permissions (review and revoke what apps may do)
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>
2026-08-13 15:46:31 +02:00
Kaj SchittecatandClaude Opus 5 27d89ccb60 fix: the Store no longer throws you back to the top while you scroll
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>
2026-08-13 14:37:49 +02:00
Kaj SchittecatandClaude Opus 5 f6b005e1be apps: SDK Test 1.1 -- fix the entry point (1.0 rendered nothing)
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>
2026-08-13 14:20:46 +02:00
Kaj SchittecatandClaude Opus 5 e51db4350e apps: publish "SDK Test" to the store
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>
2026-08-13 14:07:48 +02:00
Kaj SchittecatandClaude Opus 5 cb044e490d feat(lua): hardware keys reach Lua apps (on_input type="key")
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>
2026-08-13 13:25:45 +02:00
Kaj SchittecatandClaude Opus 5 8df7fa3b12 feat(lua): wada.mesh.send with per-app user consent (gated)
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>
2026-08-13 13:13:00 +02:00
Kaj SchittecatandClaude Opus 5 5353d3a899 feat(lua): wada.fs -- scoped, rate-limited file access (gated)
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>
2026-08-13 12:52:52 +02:00
Kaj SchittecatandClaude Opus 5 ad31f2e581 feat(lua): CAP_LUA_SDK_EXT gate + sys.caps/battery/gps
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>
2026-08-13 12:42:18 +02:00
Kaj SchittecatandClaude Opus 5 0ad28ce7db feat(lua): wada.sys.epoch, datetime and beep (#245)
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>
2026-08-13 12:26:40 +02:00
Kaj SchittecatandClaude Opus 5 7e0bed04ec fix: accented characters in text-field placeholders were boxes
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>
2026-08-13 12:13:38 +02:00
Kaj SchittecatandClaude Opus 5 dfcc07aafe ui: say that "Overwrite oldest" spares favourites (#178)
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>
2026-08-13 11:42:27 +02:00
Kaj SchittecatandClaude Opus 5 cbdf809932 release: cumulative stable notes for beta_63 (covers beta_51..63)
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>
2026-08-13 11:36:52 +02:00
Kaj Schittecat ccfc5c492a chore: refresh Mesh America catalog for beta_63 2026-08-13 11:34:31 +02:00
Kaj SchittecatandClaude Opus 5 b5ed24cddd i18n: translate the Discovered/Contacts sort sheet and the traffic panel (#257)
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>
2026-08-13 09:58:27 +02:00
Kaj SchittecatandClaude Opus 5 e1db4f50f2 feat: let a companion app opt into the full RX log over BLE (#256)
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>
2026-08-13 09:30:19 +02:00
Kaj SchittecatandClaude Opus 5 0fe3357dcb i18n + docs follow-up to #263: the new save-chip strings
@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>
2026-08-13 09:20:28 +02:00
Kaj b733dbd99e Merge pull request #263 from oumike: Pager shortcuts, portrait layout fixes, theme accents
Minor general theme tweak, keyboard shortcuts for the pager build, UI tweaks on the heltec build.
2026-08-13 09:11:19 +02:00
Michael A. Cojocari 9a358954d1 Heltec tweaks. 2026-08-12 20:44:48 -04:00
Michael A. Cojocari 7152400e4c Keyboard shortcuts for the pager build. 2026-08-12 18:52:52 -04:00
Michael A. Cojocari 168ecebcdb Merge remote-tracking branch 'upstream/main' 2026-08-12 15:58:38 -04:00
Michael A. Cojocari eecc8219c4 Wifi icon matches theme. 2026-08-12 15:55:08 -04:00
36 changed files with 1848 additions and 144 deletions

No files matched your search

+26 -6
View File
@@ -21,6 +21,21 @@ 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.
## 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 |
@@ -28,16 +43,16 @@ normally and these combos step out of the way.
| 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) |
| 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 |
| 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
@@ -193,9 +208,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.
@@ -216,6 +235,7 @@ dimmable brightness curve.
| Input | Action |
|---|---|
| M / C / H / A / S (top-level main screens only) | Open Mail / Contacts / Home / Map / Settings |
| Turn encoder (or Fn tap = NEXT only) | 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 |
@@ -236,4 +256,4 @@ dimmable brightness curve.
| Space (double-tap) | Switch keyboard language |
| Space (hold 1s) | Lock screen |
| Q / E (slider focused) | Decrease / increase |
| W / A / S / D (Map tab) | Pan map north/west/south/east |
| W / A / X / D (Map tab) | Pan map north/west/south/east |
+2 -1
View File
@@ -1,5 +1,6 @@
{"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.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."}
{"id":"snake","name":"Snake","ver":"1.0","desc":"The classic, in Lua. Swipe to steer."},
{"id":"sdktest","name":"SDK Test","ver":"1.1","desc":"Developer tool. Checks the extended Lua SDK on this board: capabilities, clock, battery, GPS, file access and its limits, keyboard input. Has a button that TRANSMITS a test message to Public (asks permission first)."}
]}
+12
View File
@@ -973,3 +973,15 @@ zoom %d мащаб %d
Scope
another region
my region
Save FAIL x%u
Save migrating
Saved %s
Sort discovered
Sort by
Peers
Favorites
Has location
Direct (0-hop)
Heard %s
Traffic (since boot)\n Sent %u flood, %u direct\n Recv %u flood, %u direct
Overwrite oldest non-favorite
+12
View File
@@ -973,3 +973,15 @@ zoom %d Zoom %d
Scope
another region
my region
Save FAIL x%u
Save migrating
Saved %s
Sort discovered
Sort by
Peers
Favorites
Has location
Direct (0-hop)
Heard %s
Traffic (since boot)\n Sent %u flood, %u direct\n Recv %u flood, %u direct
Overwrite oldest non-favorite
+12
View File
@@ -973,3 +973,15 @@ zoom %d ζουμ %d
Scope
another region
my region
Save FAIL x%u
Save migrating
Saved %s
Sort discovered
Sort by
Peers
Favorites
Has location
Direct (0-hop)
Heard %s
Traffic (since boot)\n Sent %u flood, %u direct\n Recv %u flood, %u direct
Overwrite oldest non-favorite
+12
View File
@@ -973,3 +973,15 @@ zoom %d zoom %d
Scope
another region
my region
Save FAIL x%u
Save migrating
Saved %s
Sort discovered
Sort by
Peers
Favorites
Has location
Direct (0-hop)
Heard %s
Traffic (since boot)\n Sent %u flood, %u direct\n Recv %u flood, %u direct
Overwrite oldest non-favorite
+12
View File
@@ -973,3 +973,15 @@ zoom %d zoom %d
Scope
another region
my region
Save FAIL x%u
Save migrating
Saved %s
Sort discovered
Sort by
Peers
Favorites
Has location
Direct (0-hop)
Heard %s
Traffic (since boot)\n Sent %u flood, %u direct\n Recv %u flood, %u direct
Overwrite oldest non-favorite
+12
View File
@@ -993,3 +993,15 @@ zoom %d zoom %d
Scope
another region
my region
Save FAIL x%u
Save migrating
Saved %s
Sort discovered
Sort by
Peers
Favorites
Has location
Direct (0-hop)
Heard %s
Traffic (since boot)\n Sent %u flood, %u direct\n Recv %u flood, %u direct
Overwrite oldest non-favorite
+12
View File
@@ -973,3 +973,15 @@ zoom %d zoom %d
Scope
another region
my region
Save FAIL x%u
Save migrating
Saved %s
Sort discovered
Sort by
Peers
Favorites
Has location
Direct (0-hop)
Heard %s
Traffic (since boot)\n Sent %u flood, %u direct\n Recv %u flood, %u direct
Overwrite oldest non-favorite
+12
View File
@@ -973,3 +973,15 @@ zoom %d zoom %d
Scope
another region
my region
Save FAIL x%u
Save migrating
Saved %s
Sort discovered
Sort by
Peers
Favorites
Has location
Direct (0-hop)
Heard %s
Traffic (since boot)\n Sent %u flood, %u direct\n Recv %u flood, %u direct
Overwrite oldest non-favorite
+12
View File
@@ -973,3 +973,15 @@ zoom %d zoom %d
Scope
another region
my region
Save FAIL x%u
Save migrating
Saved %s
Sort discovered
Sort by
Peers
Favorites
Has location
Direct (0-hop)
Heard %s
Traffic (since boot)\n Sent %u flood, %u direct\n Recv %u flood, %u direct
Overwrite oldest non-favorite
+12
View File
@@ -973,3 +973,15 @@ zoom %d zoom %d
Scope
another region
my region
Save FAIL x%u
Save migrating
Saved %s
Sort discovered
Sort by
Peers
Favorites
Has location
Direct (0-hop)
Heard %s
Traffic (since boot)\n Sent %u flood, %u direct\n Recv %u flood, %u direct
Overwrite oldest non-favorite
+12
View File
@@ -973,3 +973,15 @@ zoom %d масштаб %d
Scope
another region
my region
Save FAIL x%u
Save migrating
Saved %s
Sort discovered
Sort by
Peers
Favorites
Has location
Direct (0-hop)
Heard %s
Traffic (since boot)\n Sent %u flood, %u direct\n Recv %u flood, %u direct
Overwrite oldest non-favorite
+12
View File
@@ -973,3 +973,15 @@ zoom %d зум %d
Scope
another region
my region
Save FAIL x%u
Save migrating
Saved %s
Sort discovered
Sort by
Peers
Favorites
Has location
Direct (0-hop)
Heard %s
Traffic (since boot)\n Sent %u flood, %u direct\n Recv %u flood, %u direct
Overwrite oldest non-favorite
+12
View File
@@ -973,3 +973,15 @@ zoom %d масштаб %d
Scope
another region
my region
Save FAIL x%u
Save migrating
Saved %s
Sort discovered
Sort by
Peers
Favorites
Has location
Direct (0-hop)
Heard %s
Traffic (since boot)\n Sent %u flood, %u direct\n Recv %u flood, %u direct
Overwrite oldest non-favorite
+1
View File
@@ -0,0 +1 @@
{"id":"sdktest","name":"SDK Test","ver":"1.1","desc":"Developer tool. Checks the extended Lua SDK on this board: capabilities, clock, battery, GPS, file access and its limits, keyboard input. Has a button that TRANSMITS a test message to Public (asks permission first)."}
+106
View File
@@ -0,0 +1,106 @@
-- SDK self-test. Exercises everything added to the extended SDK so the results
-- can be read off the screen instead of inferred from a build log.
-- Published to the store as a developer/bench tool.
local ui, sys, store, timer = wada.ui, wada.sys, wada.store, wada.timer
local C = ui.colors
local app = {}
local rows, keyline, sendline = {}, nil, nil
local W = 300 -- replaced with the real body width in on_open
local function row(y, text, color)
local l = ui.label(text, 6, y, 12, color or C.text)
l:width(W - 12)
rows[#rows + 1] = l
return l
end
local function yn(v) return v and "yes" or "NO" end
function app.on_open(w, h)
W = w or 300
ui.scroll(true)
local y = 4
-- 1. capability report --------------------------------------------------
local c = sys.caps()
row(y, "caps: sdk_ext=" .. yn(c.sdk_ext) .. " kbd=" .. yn(c.keyboard) ..
" touch=" .. yn(c.touch) .. " sd=" .. yn(c.sd), C.accent); y = y + 16
-- 2. time (#245) --------------------------------------------------------
local ep = sys.epoch()
if ep then
local d = sys.datetime()
row(y, string.format("time: %d %04d-%02d-%02d %02d:%02d:%02d",
ep, d.year, d.month, d.day, d.hour, d.min, d.sec), C.good)
else
row(y, "time: nil (clock not set yet - expected with no GPS/NTP)", C.sub)
end
y = y + 16
if not c.sdk_ext then
row(y, "extended SDK is OFF on this board - nothing further to test.", C.sub)
return
end
-- 3. battery ------------------------------------------------------------
local b = sys.battery()
row(y, string.format("battery: %d mV %d%% charging=%s", b.mv, b.pct, yn(b.charging)),
b.mv > 0 and C.good or C.bad); y = y + 16
-- 4. gps ----------------------------------------------------------------
local g = sys.gps()
if g then
row(y, string.format("gps: %.5f, %.5f sats=%d", g.lat, g.lon, g.sats), C.good)
else
row(y, "gps: nil (no fix - correct indoors)", C.sub)
end
y = y + 16
-- 5. filesystem round trip + rate limit ---------------------------------
local stamp = tostring(sys.millis())
local ok, err = wada.fs.write("selftest.txt", "hello " .. stamp)
local back = ok and wada.fs.read("selftest.txt") or nil
row(y, "fs write: " .. yn(ok) .. (err and (" (" .. err .. ")") or "") ..
" read back: " .. (back == ("hello " .. stamp) and "MATCH" or tostring(back)),
ok and C.good or C.bad); y = y + 16
local ok2, err2 = wada.fs.write("selftest.txt", "again") -- immediately: must be refused
row(y, "fs rate limit: " .. (ok2 == false and err2 == "too fast"
and "REFUSED as designed" or ("UNEXPECTED " .. tostring(ok2) .. " " .. tostring(err2))),
(ok2 == false) and C.good or C.bad); y = y + 16
local bad = wada.fs.write("../identity", "x")
row(y, "fs traversal '../identity': " .. (bad == false and "REJECTED" or "LEAKED - BUG"),
bad == false and C.good or C.bad); y = y + 16
local files = wada.fs.list()
row(y, "fs list: " .. #files .. " file(s)" ..
(files[1] and (" first=" .. files[1].name .. " " .. files[1].size .. "B") or ""),
C.text); y = y + 20
-- 6. keys ---------------------------------------------------------------
keyline = row(y, "keys: press any key on the keyboard...", C.sub); y = y + 20
-- 7. mesh send (consent) -------------------------------------------------
sendline = row(y, "mesh.send: not tried yet", C.sub); y = y + 22
ui.button("Send to Public", 6, y, 130, 30, function()
local sok, serr = wada.mesh.send("Public", "wadamesh SDK self-test")
sendline:set("mesh.send: " .. tostring(sok) .. " " .. tostring(serr))
sendline:color(sok and C.good or C.bad)
end)
ui.button("Beep", 146, y, 70, 30, function()
sys.beep()
end)
end
-- keys and touch both arrive here
function app.on_input(ev)
if ev.type == "key" and keyline then
keyline:set("keys: got '" .. tostring(ev.key) .. "' code=" .. tostring(ev.code))
keyline:color(C.good)
end
end
return app
+16 -16
View File
@@ -17,19 +17,19 @@
"role": "gui",
"title": "Touch UI",
"version": {
"beta_50": {
"notes": "Fixed the on-device updater downloading the wrong firmware on several boards. The V4-R8, ThinkNode M9, RAK Tap V2, T-LoRa Pager and Attaky now each pull their own build instead of the Heltec V4 TFT one.\nT-Display P4: the \"Get test builds (beta)\" toggle and the on-device update now appear. They were hidden before, so the P4 could only be updated over USB. Newly enabled, please test and report.\nLock screen fixed: tap Backspace to peek the screen without unlocking, hold Backspace to unlock. The clock and text are repositioned to fit the wide screen and stay readable.\nOn-screen keyboard now works with the detachable keyboard module: the keys stay down while the module is attached, and the module's # key brings them back up for symbols. The POWER button toggles the screen and wakes the board.\nBoard fixes: correct LoRa clock (no false TCXO), GPS baud, and sensor probing that was colliding with the touch and keyboard chips.",
"beta_63": {
"notes": "Hungarian devices no longer reboot when logging in to a repeater. The warning shown when your clock disagrees with a server is assembled from a translated sentence, and the Hungarian translation had reordered the two values inside it. The firmware then read the contact name as a number and a number as a memory address, and followed it - an instant reboot, every login, for Hungarian users only, because English happened to match the order the code expected. Fixed at the root rather than in the one file: a translation whose value placeholders do not match the original is now ignored and the English is shown instead, so no language file can crash a device, including one you write yourself. The four affected Hungarian lines are corrected and already published. Reported by pisti87.\nThe T-Lora Pager no longer crashes while scrolling back through chat history. The chat list rebuilds itself as you scroll, and the timer that drives that rebuild was being destroyed and recreated on every boundary crossing; a fast scroll could reach it after it was gone. It is now kept alive and paused instead, and the keyboard focus is detached before the old rows are torn down rather than during. By PixPMusic (PR #234), from two crash reports that decoded to the same fault. Reported by AtomAlex12 and PixPMusic.\nMessages no longer arrive in a nameless \"#unknown\" thread. A channel slot that was never set up holds an all-zero key, and the firmware treated it as a real channel: an app asking to send on a channel the device did not have would transmit a message encrypted with that all-zero key, and every other device in range could decrypt it against its own unused slots and file it under a channel with no name. Both halves are fixed - a device will no longer send on an unconfigured slot, and will no longer decrypt anything against one. The same change fixes a rarer fault where an unused slot could crowd a real channel out of the decryption attempt entirely and the message was silently dropped. Reported by D-Melhede, and seen by Marshal W7TER and PixPMusic.\nThe right-arrow character now renders instead of an empty box. It was never present in the bundled fonts: the font build asked Noto Sans for it, and Noto Sans does not contain it, and the tool omits a missing character rather than complaining. It is now taken from Montserrat, which does have it and is already the interface typeface. This affected every language, not only Hungarian, and around 137 places in the interface. Reported by pisti87.\nA message's Info panel no longer labels a per-message value as \"Scope\". That four-digit number is a checksum computed from the message itself under the sender's region key, so it is different for every message and cannot be read as a region - which is exactly how it was being read. It now names your region when the value verifies against your own region key, says another region when it does not, and keeps the raw number in brackets. Reported by D-Melhede.\nThere is now a written procedure for submitting your own app to the store, covering the file layout, versioning, and what the review checks for. Every submission is read before it is added. See deploy/apps/README.md.\nThe user guide explains the floppy-disk chip on the command centre: it is the time your chat history last saved successfully, not a clock, and it turns red or amber when saving is failing or still migrating.",
"files": [
{
"type": "flash-wipe",
"name": "wadamesh-heltec-v4-tft-merged.bin",
"url": "https://firmware.wadamesh.com/releases/TOUCH/beta_50/wadamesh-heltec-v4-tft-merged.bin",
"url": "https://firmware.wadamesh.com/releases/TOUCH/beta_63/wadamesh-heltec-v4-tft-merged.bin",
"title": "Full install (bootloader + firmware)"
},
{
"type": "flash-update",
"name": "wadamesh-heltec-v4-tft.bin",
"url": "https://firmware.wadamesh.com/releases/TOUCH/beta_50/wadamesh-heltec-v4-tft.bin",
"url": "https://firmware.wadamesh.com/releases/TOUCH/beta_63/wadamesh-heltec-v4-tft.bin",
"title": "Update (app only)"
}
]
@@ -49,19 +49,19 @@
"role": "gui",
"title": "Touch UI",
"version": {
"beta_50": {
"notes": "Fixed the on-device updater downloading the wrong firmware on several boards. The V4-R8, ThinkNode M9, RAK Tap V2, T-LoRa Pager and Attaky now each pull their own build instead of the Heltec V4 TFT one.\nT-Display P4: the \"Get test builds (beta)\" toggle and the on-device update now appear. They were hidden before, so the P4 could only be updated over USB. Newly enabled, please test and report.\nLock screen fixed: tap Backspace to peek the screen without unlocking, hold Backspace to unlock. The clock and text are repositioned to fit the wide screen and stay readable.\nOn-screen keyboard now works with the detachable keyboard module: the keys stay down while the module is attached, and the module's # key brings them back up for symbols. The POWER button toggles the screen and wakes the board.\nBoard fixes: correct LoRa clock (no false TCXO), GPS baud, and sensor probing that was colliding with the touch and keyboard chips.",
"beta_63": {
"notes": "Hungarian devices no longer reboot when logging in to a repeater. The warning shown when your clock disagrees with a server is assembled from a translated sentence, and the Hungarian translation had reordered the two values inside it. The firmware then read the contact name as a number and a number as a memory address, and followed it - an instant reboot, every login, for Hungarian users only, because English happened to match the order the code expected. Fixed at the root rather than in the one file: a translation whose value placeholders do not match the original is now ignored and the English is shown instead, so no language file can crash a device, including one you write yourself. The four affected Hungarian lines are corrected and already published. Reported by pisti87.\nThe T-Lora Pager no longer crashes while scrolling back through chat history. The chat list rebuilds itself as you scroll, and the timer that drives that rebuild was being destroyed and recreated on every boundary crossing; a fast scroll could reach it after it was gone. It is now kept alive and paused instead, and the keyboard focus is detached before the old rows are torn down rather than during. By PixPMusic (PR #234), from two crash reports that decoded to the same fault. Reported by AtomAlex12 and PixPMusic.\nMessages no longer arrive in a nameless \"#unknown\" thread. A channel slot that was never set up holds an all-zero key, and the firmware treated it as a real channel: an app asking to send on a channel the device did not have would transmit a message encrypted with that all-zero key, and every other device in range could decrypt it against its own unused slots and file it under a channel with no name. Both halves are fixed - a device will no longer send on an unconfigured slot, and will no longer decrypt anything against one. The same change fixes a rarer fault where an unused slot could crowd a real channel out of the decryption attempt entirely and the message was silently dropped. Reported by D-Melhede, and seen by Marshal W7TER and PixPMusic.\nThe right-arrow character now renders instead of an empty box. It was never present in the bundled fonts: the font build asked Noto Sans for it, and Noto Sans does not contain it, and the tool omits a missing character rather than complaining. It is now taken from Montserrat, which does have it and is already the interface typeface. This affected every language, not only Hungarian, and around 137 places in the interface. Reported by pisti87.\nA message's Info panel no longer labels a per-message value as \"Scope\". That four-digit number is a checksum computed from the message itself under the sender's region key, so it is different for every message and cannot be read as a region - which is exactly how it was being read. It now names your region when the value verifies against your own region key, says another region when it does not, and keeps the raw number in brackets. Reported by D-Melhede.\nThere is now a written procedure for submitting your own app to the store, covering the file layout, versioning, and what the review checks for. Every submission is read before it is added. See deploy/apps/README.md.\nThe user guide explains the floppy-disk chip on the command centre: it is the time your chat history last saved successfully, not a clock, and it turns red or amber when saving is failing or still migrating.",
"files": [
{
"type": "flash-wipe",
"name": "wadamesh-tdeck-merged.bin",
"url": "https://firmware.wadamesh.com/releases/TOUCH/beta_50/wadamesh-tdeck-merged.bin",
"url": "https://firmware.wadamesh.com/releases/TOUCH/beta_63/wadamesh-tdeck-merged.bin",
"title": "Full install (bootloader + firmware)"
},
{
"type": "flash-update",
"name": "wadamesh-tdeck.bin",
"url": "https://firmware.wadamesh.com/releases/TOUCH/beta_50/wadamesh-tdeck.bin",
"url": "https://firmware.wadamesh.com/releases/TOUCH/beta_63/wadamesh-tdeck.bin",
"title": "Update (app only)"
}
]
@@ -81,19 +81,19 @@
"role": "gui",
"title": "[LR1121] Touch UI",
"version": {
"beta_50": {
"notes": "Fixed the on-device updater downloading the wrong firmware on several boards. The V4-R8, ThinkNode M9, RAK Tap V2, T-LoRa Pager and Attaky now each pull their own build instead of the Heltec V4 TFT one.\nT-Display P4: the \"Get test builds (beta)\" toggle and the on-device update now appear. They were hidden before, so the P4 could only be updated over USB. Newly enabled, please test and report.\nLock screen fixed: tap Backspace to peek the screen without unlocking, hold Backspace to unlock. The clock and text are repositioned to fit the wide screen and stay readable.\nOn-screen keyboard now works with the detachable keyboard module: the keys stay down while the module is attached, and the module's # key brings them back up for symbols. The POWER button toggles the screen and wakes the board.\nBoard fixes: correct LoRa clock (no false TCXO), GPS baud, and sensor probing that was colliding with the touch and keyboard chips.",
"beta_63": {
"notes": "Hungarian devices no longer reboot when logging in to a repeater. The warning shown when your clock disagrees with a server is assembled from a translated sentence, and the Hungarian translation had reordered the two values inside it. The firmware then read the contact name as a number and a number as a memory address, and followed it - an instant reboot, every login, for Hungarian users only, because English happened to match the order the code expected. Fixed at the root rather than in the one file: a translation whose value placeholders do not match the original is now ignored and the English is shown instead, so no language file can crash a device, including one you write yourself. The four affected Hungarian lines are corrected and already published. Reported by pisti87.\nThe T-Lora Pager no longer crashes while scrolling back through chat history. The chat list rebuilds itself as you scroll, and the timer that drives that rebuild was being destroyed and recreated on every boundary crossing; a fast scroll could reach it after it was gone. It is now kept alive and paused instead, and the keyboard focus is detached before the old rows are torn down rather than during. By PixPMusic (PR #234), from two crash reports that decoded to the same fault. Reported by AtomAlex12 and PixPMusic.\nMessages no longer arrive in a nameless \"#unknown\" thread. A channel slot that was never set up holds an all-zero key, and the firmware treated it as a real channel: an app asking to send on a channel the device did not have would transmit a message encrypted with that all-zero key, and every other device in range could decrypt it against its own unused slots and file it under a channel with no name. Both halves are fixed - a device will no longer send on an unconfigured slot, and will no longer decrypt anything against one. The same change fixes a rarer fault where an unused slot could crowd a real channel out of the decryption attempt entirely and the message was silently dropped. Reported by D-Melhede, and seen by Marshal W7TER and PixPMusic.\nThe right-arrow character now renders instead of an empty box. It was never present in the bundled fonts: the font build asked Noto Sans for it, and Noto Sans does not contain it, and the tool omits a missing character rather than complaining. It is now taken from Montserrat, which does have it and is already the interface typeface. This affected every language, not only Hungarian, and around 137 places in the interface. Reported by pisti87.\nA message's Info panel no longer labels a per-message value as \"Scope\". That four-digit number is a checksum computed from the message itself under the sender's region key, so it is different for every message and cannot be read as a region - which is exactly how it was being read. It now names your region when the value verifies against your own region key, says another region when it does not, and keeps the raw number in brackets. Reported by D-Melhede.\nThere is now a written procedure for submitting your own app to the store, covering the file layout, versioning, and what the review checks for. Every submission is read before it is added. See deploy/apps/README.md.\nThe user guide explains the floppy-disk chip on the command centre: it is the time your chat history last saved successfully, not a clock, and it turns red or amber when saving is failing or still migrating.",
"files": [
{
"type": "flash-wipe",
"name": "wadamesh-tlora-pager-lr1121-merged.bin",
"url": "https://firmware.wadamesh.com/releases/TOUCH/beta_50/wadamesh-tlora-pager-lr1121-merged.bin",
"url": "https://firmware.wadamesh.com/releases/TOUCH/beta_63/wadamesh-tlora-pager-lr1121-merged.bin",
"title": "Full install (bootloader + firmware)"
},
{
"type": "flash-update",
"name": "wadamesh-tlora-pager-lr1121.bin",
"url": "https://firmware.wadamesh.com/releases/TOUCH/beta_50/wadamesh-tlora-pager-lr1121.bin",
"url": "https://firmware.wadamesh.com/releases/TOUCH/beta_63/wadamesh-tlora-pager-lr1121.bin",
"title": "Update (app only)"
}
]
@@ -104,19 +104,19 @@
"role": "gui",
"title": "[SX1262] Touch UI",
"version": {
"beta_50": {
"notes": "Fixed the on-device updater downloading the wrong firmware on several boards. The V4-R8, ThinkNode M9, RAK Tap V2, T-LoRa Pager and Attaky now each pull their own build instead of the Heltec V4 TFT one.\nT-Display P4: the \"Get test builds (beta)\" toggle and the on-device update now appear. They were hidden before, so the P4 could only be updated over USB. Newly enabled, please test and report.\nLock screen fixed: tap Backspace to peek the screen without unlocking, hold Backspace to unlock. The clock and text are repositioned to fit the wide screen and stay readable.\nOn-screen keyboard now works with the detachable keyboard module: the keys stay down while the module is attached, and the module's # key brings them back up for symbols. The POWER button toggles the screen and wakes the board.\nBoard fixes: correct LoRa clock (no false TCXO), GPS baud, and sensor probing that was colliding with the touch and keyboard chips.",
"beta_63": {
"notes": "Hungarian devices no longer reboot when logging in to a repeater. The warning shown when your clock disagrees with a server is assembled from a translated sentence, and the Hungarian translation had reordered the two values inside it. The firmware then read the contact name as a number and a number as a memory address, and followed it - an instant reboot, every login, for Hungarian users only, because English happened to match the order the code expected. Fixed at the root rather than in the one file: a translation whose value placeholders do not match the original is now ignored and the English is shown instead, so no language file can crash a device, including one you write yourself. The four affected Hungarian lines are corrected and already published. Reported by pisti87.\nThe T-Lora Pager no longer crashes while scrolling back through chat history. The chat list rebuilds itself as you scroll, and the timer that drives that rebuild was being destroyed and recreated on every boundary crossing; a fast scroll could reach it after it was gone. It is now kept alive and paused instead, and the keyboard focus is detached before the old rows are torn down rather than during. By PixPMusic (PR #234), from two crash reports that decoded to the same fault. Reported by AtomAlex12 and PixPMusic.\nMessages no longer arrive in a nameless \"#unknown\" thread. A channel slot that was never set up holds an all-zero key, and the firmware treated it as a real channel: an app asking to send on a channel the device did not have would transmit a message encrypted with that all-zero key, and every other device in range could decrypt it against its own unused slots and file it under a channel with no name. Both halves are fixed - a device will no longer send on an unconfigured slot, and will no longer decrypt anything against one. The same change fixes a rarer fault where an unused slot could crowd a real channel out of the decryption attempt entirely and the message was silently dropped. Reported by D-Melhede, and seen by Marshal W7TER and PixPMusic.\nThe right-arrow character now renders instead of an empty box. It was never present in the bundled fonts: the font build asked Noto Sans for it, and Noto Sans does not contain it, and the tool omits a missing character rather than complaining. It is now taken from Montserrat, which does have it and is already the interface typeface. This affected every language, not only Hungarian, and around 137 places in the interface. Reported by pisti87.\nA message's Info panel no longer labels a per-message value as \"Scope\". That four-digit number is a checksum computed from the message itself under the sender's region key, so it is different for every message and cannot be read as a region - which is exactly how it was being read. It now names your region when the value verifies against your own region key, says another region when it does not, and keeps the raw number in brackets. Reported by D-Melhede.\nThere is now a written procedure for submitting your own app to the store, covering the file layout, versioning, and what the review checks for. Every submission is read before it is added. See deploy/apps/README.md.\nThe user guide explains the floppy-disk chip on the command centre: it is the time your chat history last saved successfully, not a clock, and it turns red or amber when saving is failing or still migrating.",
"files": [
{
"type": "flash-wipe",
"name": "wadamesh-tlora-pager-sx1262-merged.bin",
"url": "https://firmware.wadamesh.com/releases/TOUCH/beta_50/wadamesh-tlora-pager-sx1262-merged.bin",
"url": "https://firmware.wadamesh.com/releases/TOUCH/beta_63/wadamesh-tlora-pager-sx1262-merged.bin",
"title": "Full install (bootloader + firmware)"
},
{
"type": "flash-update",
"name": "wadamesh-tlora-pager-sx1262.bin",
"url": "https://firmware.wadamesh.com/releases/TOUCH/beta_50/wadamesh-tlora-pager-sx1262.bin",
"url": "https://firmware.wadamesh.com/releases/TOUCH/beta_63/wadamesh-tlora-pager-sx1262.bin",
"title": "Update (app only)"
}
]
+6 -6
View File
@@ -172,13 +172,13 @@
</ul>
<p>The bar along the bottom is always there — it switches the five main tabs (Chat, Contacts, Home, Map, Settings) from anywhere. The top bar shows unread count, time, signal and battery.</p>
<h3>The save chip <span class="tag">Cmdr</span></h3>
<p>On the command centre, the TX/RX traffic chart has a legend row underneath it: packet totals on the left, and on the right a small chip with a <b>floppy-disk icon</b> and a time. That chip is not about the radio — it is about your <b>chat history</b>, and it answers one question: are your messages actually reaching storage?</p>
<p>On the command centre, the TX/RX traffic chart has a legend row underneath it: packet totals on the left, and on the right a small chip reading <b>Saved</b> and a time. That chip is not about the radio — it is about your <b>chat history</b>, and it answers one question: are your messages actually reaching storage?</p>
<ul>
<li><code>14:32</code> — the moment history last saved successfully, in 12- or 24-hour form to match your clock setting. It is a timestamp, not a running clock, so it only moves when a save happens.</li>
<li><code>14:32-2D</code> — that last successful save was <b>two days ago</b>. Saving has quietly stopped; the suffix exists so a stale store cannot pass for a fresh one at a glance.</li>
<li><code>--:--</code> — nothing has saved yet, or the save happened before the clock was set, so there is no meaningful time to show.</li>
<li><code>FAIL x3</code>, in red — writes are failing right now, with a count. Recent messages are at risk of being lost on reboot.</li>
<li><code>migrating</code>, in amber — an older history file is still being converted to the current segmented format. This is normal after an update and clears itself.</li>
<li><code>Saved 14:32</code> — the moment history last saved successfully, in 12- or 24-hour form to match your clock setting. It is a timestamp, not a running clock, so it only moves when a save happens.</li>
<li><code>Saved 14:32-2D</code> — that last successful save was <b>two days ago</b>. Saving has quietly stopped; the suffix exists so a stale store cannot pass for a fresh one at a glance.</li>
<li><code>Saved --:--</code> — nothing has saved yet, or the save happened before the clock was set, so there is no meaningful time to show.</li>
<li><code>Save FAIL x3</code>, in red — writes are failing right now, with a count. Recent messages are at risk of being lost on reboot.</li>
<li><code>Save migrating</code>, in amber — an older history file is still being converted to the current segmented format. This is normal after an update and clears itself.</li>
</ul>
<p>A time that keeps pace with your conversations means history is safe. Red, amber, or a time that has stopped moving is worth a look at <b>Settings &rarr; About &rarr; Chat store</b>, which names the storage backend in use and the exact stage and error a failing save is hitting.</p>
</div>
+67 -20
View File
@@ -140,8 +140,10 @@
<a href="#mesh">wada.mesh</a>
<a href="#net">wada.net</a>
<a href="#store">wada.store</a>
<a href="#fs">wada.fs</a>
<a href="#sys">wada.sys</a>
<a href="#timer">wada.timer</a>
<a href="#perms">Permissions</a>
<div class="grp">Shipping</div>
<a href="#format">App format</a>
<a href="#install">Install yours</a>
@@ -162,7 +164,7 @@
<p>An app is one Lua chunk. The firmware gives it a screen, an input stream, read access to the mesh, a little key/value storage and an HTTP fetch. That is deliberately the whole surface — roughly thirty functions, all under the <code>wada.</code> table.</p>
<p>LVGL is <b>not</b> exposed. Binding a UI toolkit wholesale would freeze us out of ever changing it, and would hand every app enough rope to wedge the device. Instead there is a small set of widgets we own and can keep stable across firmware versions.</p>
<p>Two of the apps that ship in the Store — <b>RF Monitor</b> and <b>Airtime</b> — are written against exactly this API and nothing else. They are the reference implementations; read them if a doc paragraph is ambiguous.</p>
<div class="note"><p><b>API v1 is read-only where the mesh is concerned.</b> An app can see contacts, the packet log and radio statistics. It cannot transmit. That line is deliberate: a buggy or hostile app must not be able to put packets on the air in your name.</p></div>
<div class="note"><p><b>An app cannot put packets on the air behind your back.</b> Reading the mesh — contacts, the packet log, radio statistics — is free. Transmitting is not: <code>wada.mesh.send</code> exists, but the first call from a given app stops and asks you, by name, and a refusal is remembered. Same for reading your incoming messages. See <a href="#perms">Permissions</a>.</p></div>
</div></div>
</section>
@@ -170,25 +172,29 @@
<h2>Your first app</h2>
<div class="row"><div class="prose">
<p>Save this as <code>hello.lua</code>:</p>
<pre><code>local n = 0
<pre><code>local app = {}
local status
local n = 0
function on_start()
wada.ui.label("Hello from Lua", 16)
status = wada.ui.label("tick 0", 12)
status:color(wada.ui.colors.sub)
function app.on_open(w, h)
wada.ui.label("Hello from Lua", 6, 4, 16, wada.ui.colors.text)
status = wada.ui.label("tick 0", 6, 26, 12, wada.ui.colors.sub)
wada.timer.every(1000)
end
function on_tick()
function app.on_tick()
n = n + 1
status:set("tick " .. n)
end
function on_input(ev)
function app.on_input(ev)
if ev.type == "key" then
wada.sys.toast("you pressed " .. tostring(ev.name))
wada.sys.toast("you pressed " .. tostring(ev.key))
end
end</code></pre>
end
return app</code></pre>
<div class="note"><p><b>The last line matters.</b> An app is a table you build and <code>return</code>; the firmware calls the <code>on_*</code> fields on that table. Callbacks defined as plain globals are never found, so the app opens to a blank page with no error. If nothing happens, check for <code>return app</code> first.</p></div>
<p>Copy it to <code>/apps/hello.lua</code> on the device's SD card (or internal storage on boards without a card), open the app drawer, and it is there. No manifest is required for a file you side-load yourself — see <a href="#install">Install yours</a>.</p>
</div></div>
</section>
@@ -196,16 +202,17 @@ end</code></pre>
<section class="doc" id="lifecycle">
<h2>App lifecycle</h2>
<div class="row"><div class="prose">
<p>Define the callbacks you need as globals. All four are optional.</p>
<p>Build a table, put the callbacks you need on it, and <code>return</code> it from the file. All of them are optional.</p>
<table class="api">
<tr><th>Callback</th><th>When it runs</th></tr>
<tr><td>on_start()</td><td>Once, when the app opens. Build your UI here.</td></tr>
<tr><td>on_tick()</td><td>On the cadence set by <code>wada.timer.every(ms)</code>. Never faster than 33&nbsp;ms.</td></tr>
<tr><td>on_input(ev)</td><td>For every touch, key or trackball event. See <a href="#input">wada.input</a>.</td></tr>
<tr><td>on_stop()</td><td>Once, when the app closes. Persist anything you care about here.</td></tr>
<tr><td>app.on_open(w, h)</td><td>Once, when the app opens. Build your UI here. <code>w</code>&nbsp;/&nbsp;<code>h</code> are the usable body size in pixels — use them instead of assuming a screen size, since the boards differ.</td></tr>
<tr><td>app.on_tick()</td><td>On the cadence set by <code>wada.timer.every(ms)</code>. Never faster than 33&nbsp;ms.</td></tr>
<tr><td>app.on_input(ev)</td><td>For every touch, key or trackball event. See <a href="#input">wada.input</a>.</td></tr>
<tr><td>app.on_message(m)</td><td>An incoming mesh message, if the user granted the app the read permission. See <a href="#perms">Permissions</a>.</td></tr>
<tr><td>app.on_close()</td><td>Once, when the app closes. Persist anything you care about here.</td></tr>
</table>
<p>Every callback runs inside a guarded call on the UI thread. If your code raises an error the app is closed with a toast rather than taking the firmware down with it — but it does mean a slow callback stalls the whole UI, so keep them short. There is an instruction budget: an app that spins forever is stopped.</p>
<p>Widgets you create in <code>on_start</code> are destroyed for you when the app closes. You do not free anything.</p>
<p>Widgets you create in <code>on_open</code> are destroyed for you when the app closes. You do not free anything.</p>
</div></div>
</section>
@@ -215,8 +222,8 @@ end</code></pre>
<p>Widgets are created in order, top to bottom, on the app's page. Every constructor returns a handle with methods you can call later.</p>
<table class="api">
<tr><th>Call</th><th>Returns / does</th></tr>
<tr><td>wada.ui.label(text, size)</td><td>A text line. <code>size</code> is a class, not a pixel height: 12, 14 or 16.</td></tr>
<tr><td>wada.ui.button(text, fn)</td><td>A tappable button; <code>fn</code> is called with no arguments.</td></tr>
<tr><td>wada.ui.label(text, x, y, size, color)</td><td>A text line placed at <code>x, y</code>. <code>size</code> is a class, not a pixel height: 12, 14 or 16. <code>color</code> takes a <code>wada.ui.colors</code> value.</td></tr>
<tr><td>wada.ui.button(text, x, y, w, h, fn)</td><td>A tappable button at <code>x, y</code> sized <code>w &times; h</code>; <code>fn</code> is called with no arguments.</td></tr>
<tr><td>wada.ui.canvas(w, h)</td><td>A drawing surface. See the canvas methods below.</td></tr>
<tr><td>wada.ui.chart(points)</td><td>A line chart sized to the page. The Airtime and RF Monitor primitive.</td></tr>
<tr><td>wada.ui.scroll(on)</td><td>Allow the page to scroll when content is taller than the screen.</td></tr>
@@ -258,7 +265,7 @@ end</code></pre>
<table class="api">
<tr><th>ev.type</th><th>Fields</th></tr>
<tr><td>"touch"</td><td><code>ev.x</code>, <code>ev.y</code> in page coordinates.</td></tr>
<tr><td>"key"</td><td><code>ev.name</code> — the key label on boards with a keyboard.</td></tr>
<tr><td>"key"</td><td><code>ev.key</code> — a one-character string for printable keys (<code>"w"</code>), or a name for the rest: <code>up</code>, <code>down</code>, <code>left</code>, <code>right</code>, <code>enter</code>, <code>esc</code>, <code>backspace</code>. <code>ev.code</code> carries the raw value. Boards with a keyboard only; the key that closes the app is never delivered, so an app cannot trap you inside it.</td></tr>
<tr><td>"dir"</td><td><code>ev.dir</code> — one of <code>up</code>, <code>down</code>, <code>left</code>, <code>right</code>, <code>select</code>. Trackball, D-pad and swipes all arrive here.</td></tr>
</table>
<p>Handle <code>"dir"</code> if you want your app to work on every board. A touch-only board sends swipes as directions, and a keyboard board sends its navigation keys the same way, so one branch covers both.</p>
@@ -274,6 +281,7 @@ end</code></pre>
<tr><td>wada.mesh.rx_log()</td><td>Recent packets: <code>{ago_ms, type, rssi, snr, hops}</code>. The RF Monitor feed.</td></tr>
<tr><td>wada.mesh.stats()</td><td><code>{rssi, noise, rx_air_s, tx_air_s, rx_pkts, rx_err, tx_budget_ms, rx_events, rx_dropped, tx_pkts, freq, bw, sf, duty_pct}</code>.</td></tr>
<tr><td>wada.mesh.self()</td><td>This node: <code>{name, lat, lon}</code>.</td></tr>
<tr><td>wada.mesh.send(channel, text) <span class="chip">ext</span></td><td>Send to a channel by name. Returns <code>ok, err</code>. Needs the send permission — the first call prompts the user. <code>err</code> is <code>"denied"</code> if they refused, <code>"too fast"</code> inside the 5&nbsp;second floor, <code>"too long"</code> past 180 characters.</td></tr>
</table>
<p>These are snapshots taken when you call them, not live views — call again on each tick to refresh.</p>
</div></div>
@@ -298,7 +306,23 @@ end</code></pre>
<tr><td>wada.store.set(key, value)</td><td>Write a string.</td></tr>
</table>
<p>Keys are namespaced per app, so two apps cannot collide. Keep it small — the budget is about 2&nbsp;KB per app, and the underlying store silently drops oversized values. High scores and settings, not logs.</p>
<p>Writes hit flash. Do them in <code>on_stop()</code> or on a real user action, never on every tick.</p>
<p>Writes hit flash. Do them in <code>app.on_close()</code> or on a real user action, never on every tick.</p>
</div></div>
</section>
<section class="doc" id="fs">
<h2>wada.fs <span class="chip">ext</span></h2>
<div class="row"><div class="prose">
<p>A private folder per app, for things too big or too structured for <a href="#store">wada.store</a> — a log, a track, a cache. Paths are plain names inside your own folder: an app cannot name a file outside it, and cannot see another app's files. <code>..</code> and absolute paths are rejected rather than sanitised.</p>
<table class="api">
<tr><th>Call</th><th>Returns / does</th></tr>
<tr><td>wada.fs.read(name)</td><td>File contents as a string, or <code>nil</code>.</td></tr>
<tr><td>wada.fs.write(name, data)</td><td>Replace a file. Returns <code>ok, err</code>.</td></tr>
<tr><td>wada.fs.append(name, data)</td><td>Append. Returns <code>ok, err</code>.</td></tr>
<tr><td>wada.fs.list()</td><td>Array of <code>{name, size}</code>.</td></tr>
<tr><td>wada.fs.remove(name)</td><td>Delete a file.</td></tr>
</table>
<p>Writes are rate-limited to roughly one per second and files are capped at 32&nbsp;KB. A write inside the window returns <code>false, "too fast"</code> — that is the normal answer, not a failure, so check it. The limit is not arbitrary: on a board with no card this lands on the same internal flash the mesh uses, and an app looping on writes would stall the whole device.</p>
</div></div>
</section>
@@ -310,6 +334,12 @@ end</code></pre>
<tr><td>wada.sys.board()</td><td><code>{w, h, touch, keyboard, trackball, gps}</code> — size and capabilities.</td></tr>
<tr><td>wada.sys.toast(msg)</td><td>Brief on-screen message.</td></tr>
<tr><td>wada.sys.random(n)</td><td>Integer in 1..n.</td></tr>
<tr><td>wada.sys.epoch()</td><td>Unix time in seconds, or <code>nil</code> when the clock has not been set yet (no GPS fix and no NTP). Always handle the <code>nil</code>.</td></tr>
<tr><td>wada.sys.datetime()</td><td><code>{year, month, day, hour, min, sec, wday}</code> in local time. <code>wday</code> is 0 for Sunday.</td></tr>
<tr><td>wada.sys.beep()</td><td>Short beep on boards with a buzzer; silent elsewhere, and silent when the user has sound off.</td></tr>
<tr><td>wada.sys.caps()</td><td><code>{sdk_ext, keyboard, touch, sd}</code>. Check <code>sdk_ext</code> before using anything marked <span class="chip">ext</span> below.</td></tr>
<tr><td>wada.sys.battery() <span class="chip">ext</span></td><td><code>{mv, pct, charging}</code>.</td></tr>
<tr><td>wada.sys.gps() <span class="chip">ext</span></td><td><code>{lat, lon, sats}</code>, or <code>nil</code> with no fix — which is the normal indoor case, so handle it.</td></tr>
</table>
</div></div>
</section>
@@ -325,6 +355,21 @@ end</code></pre>
</div></div>
</section>
<section class="doc" id="perms">
<h2>Permissions</h2>
<div class="row"><div class="prose">
<p>Two things an app can ask for reach past its own window, so they are not granted by installing it:</p>
<table class="api">
<tr><th>Permission</th><th>Unlocks</th></tr>
<tr><td>Send</td><td><code>wada.mesh.send</code> — transmitting in your name.</td></tr>
<tr><td>Read messages</td><td><code>app.on_message</code> — seeing messages as they arrive.</td></tr>
</table>
<p>They are separate: granting one does not grant the other. The prompt names the app and appears on the first attempt, not at install time, so you are asked at the moment it is obvious what the app wants it for. A refusal is remembered — the app does not get to ask again on a loop — and the call simply returns <code>false, "denied"</code>, which a well-written app should handle rather than break on.</p>
<p><b>Settings &rarr; App permissions</b> lists every installed app and what it holds, and revokes with one switch. Granting happens where an app asks; that page is for review and for taking it back.</p>
<div class="note"><p><b>Write for the denial.</b> The user can say no, or revoke later. Treat a permitted send as the lucky path, not the assumption.</p></div>
</div></div>
</section>
<section class="doc" id="format">
<h2>App format</h2>
<div class="row"><div class="prose">
@@ -377,6 +422,8 @@ end</code></pre>
<div class="row"><div class="prose">
<p>Apps run in a restricted environment. These are removed: <code>io</code>, <code>os</code>, <code>require</code>, <code>dofile</code>, and loading new chunks at runtime. Available: <code>math</code>, <code>string</code>, <code>table</code>, and the usual <code>pairs</code>, <code>ipairs</code>, <code>select</code>, <code>pcall</code>, <code>tostring</code>, <code>tonumber</code>.</p>
<p>Memory comes from a capped pool in PSRAM, so an app that allocates without bound fails its own allocation rather than starving the radio or the UI. There is an instruction budget on every callback for the same reason.</p>
<p><b>The extended calls are not on every board.</b> Anything marked <span class="chip">ext</span> above &mdash; <code>wada.fs</code>, <code>wada.mesh.send</code>, <code>sys.battery</code>, <code>sys.gps</code> &mdash; needs a board with the memory to carry it, so the Heltec V4 keeps its RAM for the mesh instead. Call <code>wada.sys.caps().sdk_ext</code> and degrade gracefully rather than assuming; an app that hard-depends on them without checking simply errors out on the small boards.</p>
<p>Rate limits are part of the contract, not a rainy-day guard: <code>wada.fs</code> writes are about one per second with a 32&nbsp;KB file cap, and <code>wada.mesh.send</code> has a 5&nbsp;second floor and a 180-character limit. They return <code>false, "too fast"</code> rather than throwing, and hitting them is expected &mdash; handle it.</p>
<p>None of this makes a hostile app safe, which is why the catalog is curated. It makes an <i>honest</i> app that has a bug survivable: it gets closed, and the device keeps carrying traffic.</p>
</div></div>
</section>
+48
View File
@@ -0,0 +1,48 @@
# beta_63 promoted to STABLE — cumulative over beta_51..beta_63 (previous stable: beta_50)
# One user-facing note per non-blank, non-# line; # lines are section comments.
# Curated from the 13 beta notes: test-build warnings dropped, statements that
# later changed (the save chip, the core version) corrected to what now ships.
# --- Read this before updating -----------------------------------------------------------
Read this one first if you are coming from beta_50: the chat-history format on the SD card changed during this run, and the change is ONE-WAY. Updating is safe and your history is converted for you, but if you later flash a firmware older than beta_52 it will not be able to read that history. Back up first if that matters to you.
Chats now keep the newest 250 messages each by default. If you had a chat with more than that, the extra is trimmed on first run. The limit is yours to change under Settings > Chats > "Keep per chat (messages)" - 100, 250, 500, 1000 or off.
If you ever picked a region preset, your radio was transmitting at roughly 9x less power than it should have been. That is fixed. Expect noticeably better range, and re-check your power setting if you had raised it to compensate.
Your identity no longer lives on the SD card, so swapping or removing the card can never cost you your node identity again.
# --- The big changes ----------------------------------------------------------------------
The MeshCore core moved from 1.16 to 1.17. Every board is rebased on it. This is the largest under-the-hood change since 1.16, and it lays the groundwork for listen-before-talk: the radio can check whether the channel is busy before transmitting. About reports v1.17.4-touch.
WADAMESH runs apps written in Lua. They install from a Store on the device, appear in the app drawer, and can be updated or removed from there. RF Monitor and Airtime are now Lua apps rather than firmware pages, and both gained features as a result. You can write your own: drop a bare .lua file into /apps/ on the card and it shows up.
Translations are no longer baked into the firmware - they download from the Store's Languages tab, so a language can be fixed or completed without a new firmware release. Every language got a full sweep of around 150 strings that had quietly stayed English, and Romanian and Hungarian joined as full interface languages.
Major SD storage rework, contributed by the community: the device recovers from a wedged or removed card at runtime instead of playing dead until reboot, and chat history is stored in append-friendly segments. A large chat history no longer slows the whole interface down.
Storage writes run on a dedicated background worker, so a big save can no longer make the interface stutter or the device freeze.
# --- Crashes and data loss fixed ----------------------------------------------------------
Hungarian devices no longer reboot when logging in to a repeater. A translated sentence had its two values in the wrong order, and the firmware followed a number as if it were a memory address. Beyond fixing the text, a translation that does not match the original is now ignored rather than trusted - no language file can crash a device, including one you write yourself.
The T-Lora Pager no longer crashes while scrolling back through chat history.
Heltec V4 units no longer freeze in a busy mesh. Two separate causes: every advert heard used to trigger a storage write, and later, a full contact list made every new contact delete a stored record on the receive path. Both are off the packet path now.
Settings can no longer silently reset themselves. An interrupted settings write could truncate the file and lose everything in it.
Messages no longer arrive in a nameless "#unknown" thread, and channel messages can no longer be silently dropped by an unused channel slot.
Fixed the 3-minute boot with an SD card inserted, and formatting a card on the V4-R8 or ThinkNode M9 no longer hangs forever.
Blocking someone now actually works - three separate bugs, and unblocking now fully clears the block.
# --- Messaging and the interface ------------------------------------------------------------
Sends retry until the mesh has actually heard them, under Settings > Radio & Mesh > "Retry sends until heard" (off by default).
Message bubbles show the time on the top row next to the sender, so more messages fit on screen.
The chat list preview no longer shows an old message and never updates.
Accented and non-Latin characters render everywhere, and the arrow character that used to be an empty box now renders - it had never been in the bundled fonts, in any language.
Message details show the region a message was scoped to, rather than a raw four-digit number that changed on every message.
Files app shows each file's modified date, and Copy and Paste work on every board.
The Discover app shows the distance to a node when both sides have a position, and no longer freezes on names containing emoji.
The long-press accent picker gained ae/AE on the A key for Danish and Norwegian.
The lock screen and the unlock animation carry the WADAMESH name.
# --- Per board -------------------------------------------------------------------------------
T-Display P4: the clock, text size, profile export and backups all work again, and the whole-screen blue flash is fixed - it was SD card access delaying the display, not the display itself.
T-Lora Pager: new UI size presets (Small, Medium, Large), Wi-Fi and Bluetooth hand off cleanly without a reboot, map tiles load reliably from SD, and the radio oscillator is driven at the voltage LilyGo's own reference specifies.
Heltec V4: around 17 KB of internal memory reclaimed, measured step by step, which is what made the Store usable on a board with 2 MB of PSRAM.
Heltec V4 Expansion Kit V2: the buzzer works.
GPS is genuinely fixed - the firmware never actually read what the module was sending. The GPS settings page now shows live satellite count and signal strength.
New LoRa antenna setting under Settings > Radio.
# --- Community ---------------------------------------------------------------------------------
This run is heavily community work: PixPMusic, Vybo, pisti87, Yazutsu, scratchdiver, jacobpretorius, oumike, codemonkeybr, attakygit, Steve Glenner, Fr Cyril and mikecarper all contributed code or translations, alongside everyone who filed a report with enough detail to find the cause.
+30
View File
@@ -0,0 +1,30 @@
# beta_64 — test build. Stable stays beta_63.
# One user-facing note per non-blank, non-# line; # lines are section comments.
# --- The big one: the Heltec V4 freeze, properly this time --------------------------------
Heltec V4 units that freeze when a new contact arrives should be markedly better. beta_62 moved one storage delete off the packet path and that helped, but a reporter came back with the tell that mattered: the freezes got rarer AND longer. Rarer was the fix working; longer was the half nobody had measured.
Here is what was actually costing the time. A contact record is 152 bytes and the table holds up to 2000 of them, so saving contacts rewrote the entire 304 KB file on every single change - and the crash-safe write keeps a second copy of the same size alongside it while it swaps. Roughly 600 KB of flash churn because one contact changed. On a board with no card that lands on internal flash, and its garbage collection suspends the flash cache, which stalls both CPU cores at once. That is why the whole device went away rather than just dropping a packet.
The table never actually changes in bulk. Replacing the oldest contact overwrites one slot, and hearing from a node you already know touches one entry. So the firmware now compares each record against what is already stored and writes back only the ones that differ. A new contact replacing an old one writes 152 bytes instead of 304,000, and a save with nothing to change writes nothing at all.
Because that is a new way of writing the file, it checks its own work: every patched record is read back and compared, and on any disagreement the firmware falls back to the old whole-file write instead. Your contact list cannot end up half-written by the new path.
Contact deletes that overflowed the queue used to leak their stored record permanently. Fixed, and the queue is four times deeper.
About > System info gained a Contact store section: how many contacts, how much space they use, how full internal flash is, and what the last save cost. If you report a freeze, that panel is the useful photograph.
# --- Apps can do much more now -------------------------------------------------------------
Lua apps gained a substantially larger toolkit: the real date and time, the buzzer, battery state, GPS position, a scoped and rate-limited private folder each, hardware key presses, and the ability to send mesh messages and receive replies.
Anything that reaches outside an app now asks you first, per app, once. Sending mesh messages and reading incoming messages are separate permissions, so granting one does not grant the other, and refusing is remembered rather than nagged.
New page: Settings > App permissions. Every installed app and exactly what it may do, with a switch to revoke anything you granted. Granting happens where the app asks for it; this page is for reviewing and taking it back.
The extended toolkit is limited to boards with the memory to carry it, so the Heltec V4 keeps its resources for the mesh. Apps see which features exist and can adapt.
A "SDK Test" app is in the Store. It exercises every new call and reports pass or fail per line - useful if you are writing apps, or if you want to check your own board.
# --- Fixes ------------------------------------------------------------------------------------
Accented and non-Latin characters showed as empty boxes in the grey hint text inside empty text fields - the Wi-Fi password box, the MQTT bridge fields, and about two dozen others. They render properly now, in every language.
A translated line whose placeholders do not match the English original is ignored instead of used. A mismatch there let a language file crash the device, and language files now download from the Store, so this closes that off for good - including for files you write yourself.
Messages on a channel could be silently dropped when an unused channel slot happened to match first. Channel sends also match by name at the moment of transmission, so a message can no longer go out encrypted to the wrong channel.
The Store no longer jumps back to the top while you are scrolling it.
"Overwrite oldest" now says plainly that favourites are never overwritten.
A companion app can opt into receiving the full radio log over Bluetooth.
More of the interface is translatable: the Discovered and Contacts sort menus, and the traffic panel.
# --- Community ------------------------------------------------------------------------------
Pager keyboard shortcuts, portrait layout fixes, a theme-matching Wi-Fi icon and Heltec UI tweaks (oumike, PR #263).
Thanks to Yoss101 and pisti87, whose reports drove the contact-store work - particularly Yoss101 for coming back to say a fix had only half worked, which is the report that found the rest of it. Thanks to pisti87 also for the character-rendering reports, and to istvan for finding the last text field with boxes in it.
+14
View File
@@ -0,0 +1,14 @@
# beta_65 — test build. Stable stays beta_63.
# One user-facing note per non-blank, non-# line; # lines are section comments.
# --- Fixes for what beta_64 testers hit -------------------------------------------------
Updating no longer leaves the device stuck on "LoRa radio not detected". On boards where the radio sits behind a peripheral power switch, an update ends in a software reset that drops that power and then checks for the radio before it has finished coming back up - so the first boot after an update could dead-end on the error screen until you power-cycled by hand. The firmware now does that power-cycle itself and tries again, so the update just works.
Leaving an app by the back arrow no longer also pulls the status bar dropdown open. One tap did two things: it went back, and then the same tap fell through to the dropdown toggle.
# --- Position sharing, without broadcasting it ----------------------------------------------
You can now share your position with specific contacts instead of putting it in your advert. An advert is broadcast in the clear to every node in range; this is sent only when a contact you chose asks for it, and it is encrypted to them. Settings > Mesh > "Share my location when asked" - Never, Chosen contacts only, or Anyone who asks - and you pick the contacts from each contact's own menu, where you can also take it back later. Requested by janmashat.
# --- For people writing apps -------------------------------------------------------------
The Lua SDK documentation at wadamesh.com/sdk.html was wrong about the most basic thing and is now correct. It said to define your callbacks as globals named on_start and on_stop; the firmware has never called those. An app is a table you build and return, with on_open, on_tick, on_input, on_message and on_close. Following the old page gave you an app where nothing ran and nothing was reported - a blank screen. If you tried to write an app and gave up, that is why, and it is worth another go.
The page also now documents everything added in beta_64: the clock, the buzzer, battery, GPS, per-app file storage, sending mesh messages, receiving them, and the permission model behind the last two.
The app and language Store is served from a place nothing was publishing to, so translations sat at an older version than the ones in the project and a published app never appeared at all. Fixed, with the Hungarian, German and Dutch updates now live along with the SDK Test app.
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env bash
# wadamesh app+language store deploy: publish deploy/apps/ to the firmware VPS.
#
# The device reads BOTH catalogs from the firmware host, NOT the site host:
# http://firmware.wadamesh.com/apps/apps.json <- Store > Apps
# http://firmware.wadamesh.com/apps/langs.json <- Store > Languages
# http://firmware.wadamesh.com/apps/lang/<ver>/<code>.lang
# so this tree lives under the FIRMWARE web root, alongside releases/ and latest*/.
#
# WHY THIS SCRIPT EXISTS: there wasn't one. release.sh rsyncs out/firmware/ (which
# does not contain apps/) and deploy-site.sh rsyncs deploy/site/ — so the store was
# only ever updated by someone remembering to rsync it by hand, and twice they did
# not. Translations sat at v8 on the server while the repo had v9/v10/v11, and a
# published app never appeared at all. Editing deploy/apps/ is not publishing it;
# running this is.
#
# Usage:
# WADAMESH_VPS=user@your-vps scripts/deploy-apps.sh # deploy
# WADAMESH_VPS=user@your-vps scripts/deploy-apps.sh --dry-run # preview only
#
# Optional:
# WADAMESH_FW_PATH=/srv/wadamesh/firmware # override the firmware web root
#
# The VPS target comes from the environment — never commit it.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
SRC="$ROOT/deploy/apps/"
DEST="${WADAMESH_VPS:?set WADAMESH_VPS=user@host}"
DEST_PATH="${WADAMESH_FW_PATH:-/srv/wadamesh/firmware}/apps"
DRY=""
[ "${1:-}" = "--dry-run" ] && DRY="--dry-run"
# Sanity-check the catalogs before shipping them: a device that gets malformed JSON
# shows an empty store with no clue why, and the versioned .lang dir each catalog
# entry points at has to actually exist or the download 404s on the device.
python3 - "$ROOT" <<'PY'
import json, os, sys
root = sys.argv[1]
apps_dir = os.path.join(root, "deploy", "apps")
langs = json.load(open(os.path.join(apps_dir, "langs.json")))["langs"]
missing = [f"{l['code']} v{l['ver']}" for l in langs
if not os.path.isfile(os.path.join(apps_dir, "lang", str(l["ver"]), l["code"] + ".lang"))]
if missing:
sys.exit("ABORT: langs.json points at files that do not exist: " + ", ".join(missing))
json.load(open(os.path.join(apps_dir, "apps.json"))) # parse check
print(f"catalogs ok: {len(langs)} languages, every referenced .lang present")
PY
# --delete mirrors the repo: the store is generated content, the repo is the source
# of truth, and stale versioned dirs left on the server are how a device ends up
# offered a language file nothing points at any more.
rsync -avz $DRY --delete "$SRC" "$DEST:$DEST_PATH/"
if [ -z "$DRY" ]; then
echo
echo "deployed deploy/apps/ -> $DEST:$DEST_PATH"
echo "verify:"
echo " curl -s http://firmware.wadamesh.com/apps/langs.json"
echo " curl -s http://firmware.wadamesh.com/apps/apps.json"
fi
+150
View File
@@ -496,6 +496,140 @@ File file = openRead(_getContactsChannelsFS(), "/contacts3");
}
}
#if defined(ESP32)
// A full contacts write REWRITES THE WHOLE TABLE — 152 bytes per contact, so 304 KB at
// MAX_CONTACTS=2000 — and the atomic swap below keeps a same-sized .tmp alongside it, so
// the peak cost is ~608 KB of a card-less V4's 3.375 MB SPIFFS volume that is already
// carrying one blob file per contact. SPIFFS garbage collection suspends the flash cache,
// which stalls BOTH cores, and it gets dramatically worse the fuller the volume — that is
// the #222 freeze, and taking the eviction blob delete off the packet path only removed
// the smaller half of it (the reporter still saw stalls, and LONGER ones, because this
// rewrite is what was left).
//
// But the table only ever CHANGES one slot at a time: eviction replaces contacts[oldest]
// in place (the array is unsorted and never shifts), and an advert refresh touches a
// single entry. So compare each record we would write against the record already on disk
// and patch only the ones that actually differ — 152 bytes instead of 304 KB, with no
// .tmp and no free-space spike. Reads never trigger GC, so the scan itself is cheap.
//
// Returns true only if the live file is now fully up to date. False means "not
// applicable" — the caller must fall back to the full atomic rewrite. This never
// truncates or renames, and the fallback rewrite repairs a partial patch, so a false
// return always ends with a valid list on disk.
bool DataStore::saveContactsInPlace(DataStoreHost* host, bool (*filter)(const ContactInfo& c)) {
static const size_t REC = 152; // MUST match the record packing in saveContacts
static const size_t CHUNK_RECS = 16;
FILESYSTEM* fs = _getContactsChannelsFS();
if (!fs->exists(_rp("/contacts3"))) return false; // no live file yet -> full write
// Count first, with no I/O at all. A table that SHRANK needs records dropped off the
// end and this FS API has no truncate, so bail BEFORE touching the file — that keeps
// the fallback a clean rewrite instead of a half-patched one.
uint32_t nrec = 0;
{
uint32_t idx = 0;
ContactInfo c;
while (host->getContactForSave(idx, c)) {
if (!filter || filter(c)) nrec++;
idx++;
}
}
File f = fs->open(_rp("/contacts3"), "r+");
if (!f) return false;
const size_t fsz = f.size();
if (fsz == 0 || (fsz % REC) != 0 || (fsz / REC) > nrec) { f.close(); return false; }
const uint32_t nfile = fsz / REC; // records currently on disk
uint8_t* wbuf = (uint8_t*)malloc(REC * CHUNK_RECS);
uint8_t* rbuf = (uint8_t*)malloc(REC * CHUNK_RECS);
if (!wbuf || !rbuf) { free(wbuf); free(rbuf); f.close(); return false; }
const uint32_t t0 = millis();
bool ok = true;
uint32_t base = 0; // record number of wbuf[0]
size_t fill = 0; // records packed in wbuf
uint32_t written = 0; // records actually re-written (the cost)
// Reconcile one slab: read what is there, and write back only the records that differ.
// Records at or past the old end of file are appends (the table grew), written in
// ascending order so each one extends the file contiguously.
auto reconcile = [&](uint32_t at, size_t count) -> bool {
const size_t off = (size_t)at * REC;
const size_t bytes = count * REC;
size_t in_file = (at < nfile) ? ((size_t)(nfile - at) * REC) : 0;
if (in_file > bytes) in_file = bytes;
if (in_file) {
if (!f.seek(off)) return false;
if ((size_t)f.read(rbuf, in_file) != in_file) return false;
if (in_file == bytes && memcmp(rbuf, wbuf, bytes) == 0) return true; // slab unchanged
}
bool touched = false;
for (size_t k = 0; k < count; k++) {
const size_t ro = k * REC;
if (ro + REC <= in_file && memcmp(rbuf + ro, wbuf + ro, REC) == 0) continue;
if (!f.seek(off + ro)) return false;
if (f.write(wbuf + ro, REC) != REC) return false;
written++;
touched = true;
}
// Read back what we just wrote and PROVE it landed. Positioned writes through
// fopen("r+") are a far less travelled path than the whole-file rewrite this
// replaces, it differs per filesystem (SPIFFS / FAT / LittleFS), and the data at
// stake is the operator's contact list — the one thing worth more than the freeze
// this fixes. If the read-back disagrees for any reason at all, bail and let the
// caller do the full atomic rewrite, which is the known-good path. Costs one extra
// read of only the slab that changed, and reads never trigger GC.
if (touched) {
if (!f.seek(off)) return false;
if ((size_t)f.read(rbuf, bytes) != bytes) return false;
if (memcmp(rbuf, wbuf, bytes) != 0) return false;
}
return true;
};
uint32_t idx = 0;
ContactInfo c;
uint8_t unused = 0;
while (ok) {
if (!host->getContactForSave(idx, c)) break;
idx++;
if (filter && !filter(c)) continue;
uint8_t* p = wbuf + fill * REC;
memcpy(p, c.id.pub_key, 32); p += 32;
memcpy(p, (uint8_t *)&c.name, 32); p += 32;
*p++ = c.type;
*p++ = c.flags;
*p++ = unused;
memcpy(p, (uint8_t *)&c.sync_since, 4); p += 4;
memcpy(p, (uint8_t *)&c.out_path_len, 1); p += 1;
memcpy(p, (uint8_t *)&c.last_advert_timestamp, 4); p += 4;
memcpy(p, c.out_path, 64); p += 64;
memcpy(p, (uint8_t *)&c.lastmod, 4); p += 4;
memcpy(p, (uint8_t *)&c.gps_lat, 4); p += 4;
memcpy(p, (uint8_t *)&c.gps_lon, 4); p += 4;
fill++;
if (fill == CHUNK_RECS) { ok = reconcile(base, fill); base += fill; fill = 0; }
}
if (ok && fill > 0) { ok = reconcile(base, fill); base += fill; fill = 0; }
f.close();
free(wbuf);
free(rbuf);
// base is the number of records reconciled; if it disagrees with the pre-count the
// table changed underneath us — let the caller rewrite rather than trust the file.
if (!(ok && base == nrec)) return false;
_cs_recs = (uint16_t)(written > 0xFFFF ? 0xFFFF : written);
_cs_ms = (uint16_t)((millis() - t0) > 0xFFFF ? 0xFFFF : (millis() - t0));
_cs_in_place = true;
_cs_any = true;
return true;
}
#endif
void DataStore::saveContacts(DataStoreHost* host, bool (*filter)(const ContactInfo& c)) {
#if defined(HAS_TDISPLAY_P4)
if (!p4OnStorageTask()) {
@@ -514,6 +648,12 @@ void DataStore::saveContacts(DataStoreHost* host, bool (*filter)(const ContactIn
// that calls saveContacts stays balanced. On SD-routed devices this is cheap
// (FAT has no such GC); it matters most for card-less SPIFFS devices.
WdtHeavyGuard _wdt;
// Patch the live file in place when only individual records changed — the normal case
// by far, and the one that was freezing card-less V4s (#222). Falls through to the full
// atomic rewrite below whenever that is not provably safe.
if (saveContactsInPlace(host, filter)) return;
const uint32_t t0_full = millis(); // the expensive path — worth reporting when it runs
#endif
#if defined(ESP32)
// Write to a TEMP file and swap it in only after it is FULLY written, so a partial
@@ -528,6 +668,7 @@ void DataStore::saveContacts(DataStoreHost* host, bool (*filter)(const ContactIn
#endif
File file = openWrite(_getContactsChannelsFS(), kContactsWriteTarget);
bool wrote_ok = false; // true only if every record reached the file
uint32_t recs_out = 0; // records written by this (full) rewrite
if (file) {
bool ok = true; // cleared on any short write (partial file)
uint32_t idx = 0;
@@ -563,6 +704,7 @@ void DataStore::saveContacts(DataStoreHost* host, bool (*filter)(const ContactIn
memcpy(p, (uint8_t *)&c.gps_lat, 4); p += 4;
memcpy(p, (uint8_t *)&c.gps_lon, 4); p += 4;
fill += REC;
recs_out++;
if (fill == REC * CHUNK_RECS) {
ok = (file.write(buf, fill) == fill);
fill = 0;
@@ -592,6 +734,7 @@ void DataStore::saveContacts(DataStoreHost* host, bool (*filter)(const ContactIn
if (!success) { ok = false; break; } // write failed -> keep the live file
recs_out++;
idx++; // advance to next contact
}
}
@@ -615,6 +758,13 @@ void DataStore::saveContacts(DataStoreHost* host, bool (*filter)(const ContactIn
cfs->remove(tmp); // discard the partial temp; the live contact list is untouched
}
}
{
const uint32_t el = millis() - t0_full;
_cs_recs = (uint16_t)(recs_out > 0xFFFF ? 0xFFFF : recs_out);
_cs_ms = (uint16_t)(el > 0xFFFF ? 0xFFFF : el);
_cs_in_place = false;
_cs_any = true;
}
#endif
}
+15
View File
@@ -64,6 +64,21 @@ public:
bool savePrefs(const NodePrefs& prefs, double node_lat, double node_lon);
void loadContacts(DataStoreHost* host);
void saveContacts(DataStoreHost* host, bool (*filter)(const ContactInfo& c) = NULL);
#if defined(ESP32)
// Patch only the contact records that changed, in place. True = live file is up to
// date; false = caller must do the full atomic rewrite. See the definition for why.
bool saveContactsInPlace(DataStoreHost* host, bool (*filter)(const ContactInfo& c));
// Cost of the most recent contacts save, for the About diagnostics (#222). A full
// rewrite here is the expensive path that stalls both cores on a card-less board.
uint16_t lastContactsSaveRecs() const { return _cs_recs; }
uint16_t lastContactsSaveMs() const { return _cs_ms; }
bool lastContactsSaveInPlace() const { return _cs_in_place; }
bool lastContactsSaveValid() const { return _cs_any; }
private:
uint16_t _cs_recs = 0, _cs_ms = 0;
bool _cs_in_place = false, _cs_any = false;
public:
#endif
void loadChannels(DataStoreHost* host);
void saveChannels(DataStoreHost* host);
void migrateToSecondaryFS();
+20 -1
View File
@@ -2329,7 +2329,12 @@ void MyMesh::onContactOverwrite(const uint8_t* pub_key) {
// it, so a burst of evictions cannot chain GC passes back to back.
if (_pending_del_n < PENDING_DEL_MAX) {
memcpy(_pending_del[_pending_del_n++], pub_key, PUB_KEY_SIZE);
} // queue full: the blob is orphaned, harmless — reclaimed on the next wipe
} else {
// Overflow orphans the blob for good — no other path deletes it — so it is not the
// "harmless" case the first cut of this assumed. Count it so a device that is
// actually hitting the wall can say so instead of silently leaking flash.
if (_orphaned_blobs < 0xFFFF) _orphaned_blobs++;
}
if (_serial->isConnected()) {
out_frame[0] = PUSH_CODE_CONTACT_DELETED;
memcpy(&out_frame[1], pub_key, PUB_KEY_SIZE);
@@ -4559,6 +4564,20 @@ void MyMesh::handleCmdFrame(size_t len) {
char *np = strchr(sp, ':'); // look for separator char
if (np) {
*np++ = 0; // modify 'cmd_frame', replace ':' with null
// #256: "ble.rxlog:1" opts THIS session's BLE companion into the full
// per-packet RX log (0x88). It is not a sensor setting, so intercept it
// before the sensor dispatch below. Coverage/region apps need it because
// the transport codes and the full relay path exist only in that frame —
// RESP_CODE_CHANNEL_MSG_RECV_V3 carries neither. Off by default and never
// persisted, so the #46/#54 fix stands for everyone who does not ask.
// Requested by the KiekR author (marcelverdult), who traced it for us.
#if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION)
if (strcmp(sp, "ble.rxlog") == 0) {
MultiTransportCompanionInterface::bleSetRxLogFirehose(np[0] == '1');
writeOKFrame();
return;
}
#endif
bool success = sensors.setSettingValue(sp, np);
if (success) {
#if ENV_INCLUDE_GPS == 1
+33 -1
View File
@@ -145,6 +145,8 @@ public:
NodePrefs *getNodePrefs();
uint32_t getBLEPin();
bool setBLEPin(uint32_t pin); // user-chosen 6-digit pairing code (persisted; applies next boot)
uint32_t getOrphanedBlobs() const { return _orphaned_blobs; } // see PENDING_DEL_MAX (#222)
DataStore* getStore() { return _store; } // for the About diagnostics
// Live device info accessors (used by the touch Settings → Device modal to
// mirror the web client's "Device (live)" panel — public key prefix, channel
@@ -265,10 +267,15 @@ protected:
void onContactOverwrite(const uint8_t* pub_key) override;
// Blob deletes queued by onContactOverwrite and drained from loop() — never
// from packet handling, see #222 (SPIFFS GC stalls both cores).
static const uint8_t PENDING_DEL_MAX = 8;
// 8 was too shallow: a drop here does not just skip work, it ORPHANS the blob file
// permanently (nothing else ever deletes it), and orphans accumulate on the one
// resource that drives GC cost — how full the volume is. 32 costs 1 KB of RAM and
// needs a 16-second eviction burst to overflow.
static const uint8_t PENDING_DEL_MAX = 32;
uint8_t _pending_del[PENDING_DEL_MAX][PUB_KEY_SIZE];
uint8_t _pending_del_n = 0;
uint32_t _next_pending_del_at = 0;
uint16_t _orphaned_blobs = 0; // queue overflows — each one leaks a blob file for good
void drainPendingBlobDeletes();
bool onContactPathRecv(ContactInfo& from, uint8_t* in_path, uint8_t in_path_len, uint8_t* out_path, uint8_t out_path_len, uint8_t extra_type, uint8_t* extra, uint8_t extra_len) override;
void onDiscoveredContact(ContactInfo &contact, bool is_new, uint8_t path_len, const uint8_t* path) override;
@@ -964,6 +971,31 @@ public:
* evictable when the table filled (#178): the star lived only in
* TouchPrefs, invisible to the core. Returns false if the contact is gone;
* a no-op flag state is success without a save. */
// Per-contact telemetry permissions (#266: share position with chosen contacts
// instead of broadcasting it in every advert).
//
// MeshCore packs these into contact.flags ABOVE the favourite bit — the request
// handler does `contact.flags >> 1` before masking with TELEM_PERM_* — so the bit
// for a given TELEM_PERM_x lives at (x << 1) here. They only take effect for a
// category whose telemetry_mode_* is TELEM_MODE_ALLOW_FLAGS; ALLOW_ALL ignores
// them and answers everyone, DENY answers nobody.
static uint8_t contactTelemBit(uint8_t telem_perm) { return (uint8_t)(telem_perm << 1); }
bool uiGetContactTelemetryPerm(const uint8_t pub_key[32], uint8_t telem_perm) {
ContactInfo* slot = lookupContactByPubKey(pub_key, PUB_KEY_SIZE);
return slot && (slot->flags & contactTelemBit(telem_perm)) != 0;
}
bool uiSetContactTelemetryPerm(const uint8_t pub_key[32], uint8_t telem_perm, bool on) {
ContactInfo* slot = lookupContactByPubKey(pub_key, PUB_KEY_SIZE);
if (!slot) return false;
const uint8_t bit = contactTelemBit(telem_perm);
const uint8_t nf = on ? (uint8_t)(slot->flags | bit) : (uint8_t)(slot->flags & ~bit);
if (nf == slot->flags) return true;
slot->flags = nf;
saveContacts();
return true;
}
bool uiSetContactFavorite(const uint8_t pub_key[32], bool fav) {
ContactInfo* slot = lookupContactByPubKey(pub_key, PUB_KEY_SIZE);
if (!slot) return false;
@@ -11,6 +11,7 @@
#define PUSH_CODE_LOG_RX_DATA 0x88
bool MultiTransportCompanionInterface::s_ble_rxlog_once = false;
bool MultiTransportCompanionInterface::s_ble_rxlog_all = false; // #256: opt-in, per session
MultiTransportCompanionInterface::MultiTransportCompanionInterface()
: _tcp_port(0), _ws_port(0), _tcp_started(false), _ws_started(false), _tcp_enabled(true), _isEnabled(false), _broadcast(false), _last_reply_target(REPLY_TARGET_USB), _ota_tcp_suspended(false), _ota_ws_suspended(false), _ota_ws_listen_paused(false)
@@ -548,7 +549,11 @@ size_t MultiTransportCompanionInterface::writeFrameToAll(const uint8_t src[], si
// blanket skip landed in beta_23 — issue #94), and a few echoes per send are
// nowhere near the flood that caused #46/#54.
const bool rxlog = (len > 0 && src[0] == PUSH_CODE_LOG_RX_DATA);
const bool ble_pass = rxlog && s_ble_rxlog_once;
// #256: a companion that wants the whole firehose (coverage/region mapping —
// region and the full path exist ONLY in this frame) can ask for it per
// session with CMD_SET_CUSTOM_VAR "ble.rxlog:1". Default stays off, so the
// #46/#54 behaviour above is unchanged for every app that does not opt in.
const bool ble_pass = rxlog && (s_ble_rxlog_once || s_ble_rxlog_all);
if (rxlog) s_ble_rxlog_once = false; // consume the one-shot either way
const bool skip_ble = rxlog && !ble_pass;
if (!skip_ble && _ble_begun && _ble_enabled && _ble.isConnected() && _ble.writeFrame(src, len) != len)
@@ -94,6 +94,13 @@ public:
* gets exactly the few frames the app needs and none of the flood (#94).
* Same-thread set-then-consume (both in the mesh loop), so no atomics. */
static void bleAllowNextRxLog() { s_ble_rxlog_once = true; }
/** Opt a BLE companion into the FULL per-packet RX log (#256).
* Off by default and NOT persisted: an app asks for it after connecting, via
* CMD_SET_CUSTOM_VAR "ble.rxlog:1". Deliberately session-scoped — a stored
* flag would silently reinstate the #46/#54 flood for someone who tried a
* coverage app once and moved on, on a link that cannot afford it. */
static void bleSetRxLogFirehose(bool on) { s_ble_rxlog_all = on; }
static bool bleRxLogFirehose() { return s_ble_rxlog_all; }
bool companionUnsolicitedPushesBroadcastToAll() const override { return _broadcast; }
size_t checkRecvFrame(uint8_t dest[]) override;
@@ -105,6 +112,7 @@ public:
private:
static bool s_ble_rxlog_once; // one-shot BLE pass for the next RX-log frame (#94)
static bool s_ble_rxlog_all; // app opted into the whole RX log over BLE (#256); session-scoped
public:
private:
+26
View File
@@ -740,6 +740,32 @@ void setup() {
bool radio_ok = radio_init();
if (!radio_ok) { delay(150); radio_ok = radio_init(); } // one retry: transient SPI/reset flakes
#if defined(PIN_PERF_POWERON)
// Last resort: do what the error screen would otherwise ask the USER to do —
// power-cycle the module — by bouncing the rail that feeds it.
//
// On boards with a peripheral power gate (T-Deck, GPIO10) the LoRa module is only
// powered once that pin is driven high in Board::begin(), a few milliseconds before
// it gets probed. A cold start survives that because the rail was already settled;
// a SOFTWARE reset does not. An OTA update ends in ESP.restart(), which releases the
// pin, collapses the rail, and re-drives it immediately — so the radio is probed
// while it is still coming up, and the boot dead-ends on "LoRa radio not detected"
// until the user power-cycles by hand (reported on a T-Deck straight after an
// update; a reboot cleared it). A brief, deliberate LOW gives the module a clean
// power-on reset regardless of how this boot was reached.
//
// Only reached when the alternative is halting with the fatal screen, so it cannot
// regress a healthy boot: a board that probes fine never runs any of this.
if (!radio_ok) {
Serial.println("[BOOT] radio not detected — power-cycling the peripheral rail and retrying");
digitalWrite(PIN_PERF_POWERON, LOW);
delay(120); // long enough for the rail to actually drain
digitalWrite(PIN_PERF_POWERON, HIGH);
delay(250); // POR + crystal startup before the module answers SPI
radio_ok = radio_init();
if (radio_ok) Serial.println("[BOOT] radio came up after the power-cycle");
}
#endif
if (!radio_ok) {
// A dead or absent LoRa module used to halt behind the frozen boot logo,
// with the only clue on serial (#244). Say it on the panel instead.
+341
View File
@@ -10,7 +10,11 @@
#include <lvgl.h>
#include <esp_heap_caps.h>
#include <FS.h>
#include <time.h>
#include <math.h> // isnan for optional sensor fields // wada.sys.epoch/datetime (#245)
#include "AppPage.h"
#include "device_caps.h"
#include "helpers/esp32/WdtHeavyGuard.h" // wada.fs writes can trigger SPIFFS GC
extern "C" {
#include "lua.h"
@@ -22,6 +26,7 @@ extern "C" {
// to keep this TU decoupled from the 47k-line UITask.cpp).
extern const lv_font_t* luaHostFontForSize(int size_class); // 12/14/16 -> g_font_*
extern void luaHostToast(const char* msg, int ms); // showAlert passthrough
extern bool luaHostBeep(); // notification chime; false = no sounder / muted
extern fs::FS* luaHostAppFs(); // /apps storage root FS (may be null)
extern void luaHostAppPath(char* out, size_t cap, const char* rel); // prefixes the store root
extern int luaHostContactAt(int idx, char* name, size_t name_cap, int* type, uint32_t* secs_ago,
@@ -32,6 +37,16 @@ extern void luaHostRadioStats(float* rssi, float* noise, uint32_t* rx_air_s, uin
extern void luaHostRadioStats2(uint32_t* rx_evt, uint32_t* rx_drop, uint32_t* tx_pkts,
float* freq, float* bw, int* sf, int* duty_pct);
extern void luaHostSelfInfo(char* name, size_t name_cap, double* lat, double* lon);
#if CAP_LUA_SDK_EXT
extern void luaHostBattery(uint16_t* mv, int* pct, bool* charging);
// No HDOP: MeshCore's LocationProvider interface does not expose one, and a
// fabricated accuracy figure is worse than none for anything that would use it.
extern bool luaHostGps(double* lat, double* lon, int* sats);
extern int luaHostSendPerm(const char* app_id); // 1 grant, -1 refused, 0 unasked
extern bool luaHostReadPerm(const char* app_id); // may be shown incoming messages
extern void luaHostRequestSendPerm(const char* app_id, const char* app_name); // raises the consent prompt
extern bool luaHostMeshSendChannel(const char* chan_name, const char* text);
#endif
// DO NOT name the network client type here. What "WiFiClient" means depends on the
// board: a real class on the S3 envs, a `using WiFiClient = NetworkClient` alias
// under arduino-esp32 3.x (Tanmatsu), and a #define to C6Client on the T-Display P4
@@ -95,6 +110,8 @@ struct Host {
int ref_app = LUA_NOREF; // the table the chunk returned
int ref_btncb = LUA_NOREF; // { [btn lightuserdata] = fn } button callbacks
bool store_dirty = false;
uint32_t fs_last_write_ms = 0; // wada.fs write rate limit (#222 lesson)
uint32_t mesh_last_send_ms = 0; // wada.mesh.send airtime rate limit
bool in_lua = false; // re-entrancy guard (dismiss from inside a callback)
bool want_close = false;
char id[24] = "";
@@ -439,6 +456,82 @@ int uiTextH(lua_State* L) {
}
int sysMillis(lua_State* L) { lua_pushinteger(L, (lua_Integer)millis()); return 1; }
// wada.sys.epoch() -> Unix seconds, or nil when the clock has never been set.
// nil rather than 0 on purpose: an app that stamps a log wants to be able to
// tell "no clock yet" from "1970", and the device genuinely boots without one
// until GPS, NTP or a mesh peer supplies the time (#245).
int sysEpoch(lua_State* L) {
const time_t now = time(nullptr);
if (now < 1700000000) { lua_pushnil(L); return 1; } // same sane-clock floor the mesh uses
lua_pushinteger(L, (lua_Integer)now);
return 1;
}
// wada.sys.datetime() -> table in LOCAL time (the timezone the user picked),
// because every use for it is display. Fields match pisti87's request in #245.
// nil under the same unset-clock rule as epoch().
int sysDatetime(lua_State* L) {
const time_t now = time(nullptr);
struct tm tmv;
if (now < 1700000000 || !localtime_r(&now, &tmv)) { lua_pushnil(L); return 1; }
lua_createtable(L, 0, 7);
lua_pushinteger(L, tmv.tm_year + 1900); lua_setfield(L, -2, "year");
lua_pushinteger(L, tmv.tm_mon + 1); lua_setfield(L, -2, "month");
lua_pushinteger(L, tmv.tm_mday); lua_setfield(L, -2, "day");
lua_pushinteger(L, tmv.tm_hour); lua_setfield(L, -2, "hour");
lua_pushinteger(L, tmv.tm_min); lua_setfield(L, -2, "min");
lua_pushinteger(L, tmv.tm_sec); lua_setfield(L, -2, "sec");
lua_pushinteger(L, tmv.tm_wday); lua_setfield(L, -2, "wday"); // 0 = Sunday
return 1;
}
// wada.sys.beep() -> true if a sound was actually produced. #245 asked for a
// melody player; what the firmware has is one notification chime, and only some
// boards have a sounder at all. Exposing the truthful primitive beats inventing
// a melody engine an app could not rely on: the return value lets an app fall
// back to a visual cue instead of silently doing nothing.
int sysBeep(lua_State* L) { lua_pushboolean(L, luaHostBeep()); return 1; }
// wada.sys.caps() -> what THIS board actually offers. Apps must branch on this
// rather than assume: the extended SDK is absent on low-resource boards (see
// CAP_LUA_SDK_EXT in device_caps.h), and a store app runs on all of them.
int sysCaps(lua_State* L) {
lua_createtable(L, 0, 4);
lua_pushboolean(L, CAP_LUA_SDK_EXT); lua_setfield(L, -2, "sdk_ext");
lua_pushboolean(L, CAP_KEYBOARD); lua_setfield(L, -2, "keyboard");
lua_pushboolean(L, CAP_TOUCH); lua_setfield(L, -2, "touch");
lua_pushboolean(L, CAP_SD); lua_setfield(L, -2, "sd");
return 1;
}
#if CAP_LUA_SDK_EXT
// wada.sys.battery() -> { mv, pct, charging }
int sysBattery(lua_State* L) {
uint16_t mv = 0; int pct = -1; bool chg = false;
luaHostBattery(&mv, &pct, &chg);
lua_createtable(L, 0, 3);
lua_pushinteger(L, mv); lua_setfield(L, -2, "mv");
lua_pushinteger(L, pct); lua_setfield(L, -2, "pct");
lua_pushboolean(L, chg); lua_setfield(L, -2, "charging");
return 1;
}
// wada.sys.gps() -> { lat, lon, sats } or nil with NO fix.
// nil rather than stale coordinates: wada.mesh.self() already hands out the last
// known position, and an app plotting a track needs to know the difference.
int sysGps(lua_State* L) {
double lat = 0, lon = 0; int sats = 0;
if (!luaHostGps(&lat, &lon, &sats)) { lua_pushnil(L); return 1; }
lua_createtable(L, 0, 3);
lua_pushnumber(L, lat); lua_setfield(L, -2, "lat");
lua_pushnumber(L, lon); lua_setfield(L, -2, "lon");
lua_pushinteger(L, sats); lua_setfield(L, -2, "sats");
return 1;
}
// NOT exposed: wada.sys.sensors(). The only environment-sensor source in the
// firmware is LocalEnvSnapshot, which is #if defined(HAS_EXPANSION_KIT) -- the
// Heltec V4 Expansion Kit. That is precisely the board CAP_LUA_SDK_EXT excludes,
// so the function would return nil on every board that could call it. Sensor
// access belongs on a HARDWARE gate (does this board have the rail?) rather than
// this MEMORY gate, and wants its own caps() entry when it lands.
#endif // CAP_LUA_SDK_EXT
int sysToast(lua_State* L) { luaHostToast(luaL_checkstring(L, 1), (int)luaL_optinteger(L, 2, 1500)); return 0; }
int sysRandom(lua_State* L) {
// esp_random-backed: apps should not have to seed math.random for games
@@ -492,6 +585,51 @@ int timerStop(lua_State* L) {
// Store: a Lua table mirrored to /apps/<id>.sav as "k=v" lines (strings and
// numbers only). Loaded at launch, flushed on dismiss when dirty — tiny, rare,
// and off the hot path (touch_perf: never spam flash from the UI thread).
#if CAP_LUA_SDK_EXT
// wada.mesh.send(channel, text) -> true | false, reason
//
// The ONLY write path an 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 node name.
//
// Three gates, in order:
// 1. per-app consent, granted by the user once and bound to THIS app id
// 2. an airtime rate limit, far stricter than the filesystem one
// 3. a length cap, so one call cannot occupy the channel
//
// The app never sees a prompt API and cannot pre-approve itself: the first call
// without a grant fails AND raises the prompt, so consent is always something
// the user did, not something the app asked for at a convenient moment.
static const uint32_t kMeshSendMinGapMs = 5000; // per app
static const size_t kMeshSendMaxLen = 180; // one LoRa text payload
int meshSend(lua_State* L) {
const char* chan = luaL_checkstring(L, 1);
size_t len = 0;
const char* text = luaL_checklstring(L, 2, &len);
if (!s_h) { lua_pushboolean(L, 0); lua_pushstring(L, "no app"); return 2; }
if (len == 0 || len > kMeshSendMaxLen) {
lua_pushboolean(L, 0); lua_pushstring(L, "bad length"); return 2;
}
const int perm = luaHostSendPerm(s_h->id);
if (perm != 1) {
if (perm == 0) luaHostRequestSendPerm(s_h->id, s_h->id); // ask once, never in a loop
lua_pushboolean(L, 0);
lua_pushstring(L, perm == 0 ? "permission requested" : "permission denied");
return 2;
}
const uint32_t now = millis();
if (s_h->mesh_last_send_ms && (uint32_t)(now - s_h->mesh_last_send_ms) < kMeshSendMinGapMs) {
lua_pushboolean(L, 0); lua_pushstring(L, "too fast"); return 2;
}
const bool ok = luaHostMeshSendChannel(chan, text);
if (ok) s_h->mesh_last_send_ms = now; // only a real transmission starts the clock
lua_pushboolean(L, ok);
if (!ok) { lua_pushstring(L, "no such channel"); return 2; }
return 1;
}
#endif
int storeGet(lua_State* L) {
lua_getfield(L, LUA_REGISTRYINDEX, "wada.storetab");
lua_pushvalue(L, 1);
@@ -511,6 +649,124 @@ int storeSet(lua_State* L) {
return 0;
}
#if CAP_LUA_SDK_EXT
// ---- wada.fs: scoped file access -------------------------------------------
// Every app gets ONE directory, /apps/<id>.d/, and cannot address anything
// outside it. Store apps are user-submitted, so the name check is a security
// boundary rather than tidiness: no '/', no '..', no leading dot, and a strict
// [A-Za-z0-9._-] whitelist. Rejecting outright beats sanitising -- a sanitiser
// that "fixes" ../../identity into something valid is how these go wrong.
static const size_t kFsMaxFile = 32u * 1024u; // per file
static const uint32_t kFsMinGapMs = 1000; // between writes, per app
static bool fsSafeName(const char* n) {
if (!n || !*n || n[0] == '.') return false;
size_t len = 0;
for (const char* p = n; *p; ++p, ++len) {
const char c = *p;
const bool ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') || c == '.' || c == '_' || c == '-';
if (!ok) return false;
}
return len <= 32;
}
static bool fsPath(char* out, size_t cap, const char* name) {
if (!s_h || !fsSafeName(name)) return false;
char rel[96];
snprintf(rel, sizeof rel, "/apps/%s.d/%s", s_h->id, name);
luaHostAppPath(out, cap, rel);
return true;
}
static void fsEnsureDir() {
fs::FS* fs = luaHostAppFs();
if (!fs || !s_h) return;
char d[80], rel[64];
luaHostAppPath(d, sizeof d, "/apps"); fs->mkdir(d);
snprintf(rel, sizeof rel, "/apps/%s.d", s_h->id);
luaHostAppPath(d, sizeof d, rel); fs->mkdir(d);
}
// Shared by write and append. Rate-limited on purpose: on internal flash a burst
// of small writes triggers garbage collection, and a GC pass suspends the flash
// cache and stalls BOTH cores -- the fault behind #222 and the beta_25 bootloop.
// An app that ignores the advice in deploy/apps/README.md and writes every frame
// gets `false, "too fast"` instead of wedging the device. The guard covers the
// case where a write DOES trigger GC, so it stalls briefly instead of rebooting.
static int fsWriteCommon(lua_State* L, const char* mode) {
const char* name = luaL_checkstring(L, 1);
size_t len = 0;
const char* data = luaL_checklstring(L, 2, &len);
char path[128];
if (!fsPath(path, sizeof path, name)) { lua_pushboolean(L, 0); lua_pushstring(L, "bad name"); return 2; }
if (len > kFsMaxFile) { lua_pushboolean(L, 0); lua_pushstring(L, "too big"); return 2; }
const uint32_t now = millis();
if (s_h->fs_last_write_ms && (uint32_t)(now - s_h->fs_last_write_ms) < kFsMinGapMs) {
lua_pushboolean(L, 0); lua_pushstring(L, "too fast"); return 2;
}
fs::FS* fs = luaHostAppFs();
if (!fs) { lua_pushboolean(L, 0); lua_pushstring(L, "no storage"); return 2; }
fsEnsureDir();
bool ok = false;
{
WdtHeavyGuard guard; // a GC pass here must stall, not reboot
File f = fs->open(path, mode);
if (f) { ok = (f.write((const uint8_t*)data, len) == len); f.close(); }
}
s_h->fs_last_write_ms = now;
lua_pushboolean(L, ok);
if (!ok) { lua_pushstring(L, "write failed"); return 2; }
return 1;
}
int fsWrite(lua_State* L) { return fsWriteCommon(L, "w"); }
int fsAppend(lua_State* L) { return fsWriteCommon(L, "a"); }
int fsRead(lua_State* L) {
const char* name = luaL_checkstring(L, 1);
char path[128];
if (!fsPath(path, sizeof path, name)) { lua_pushnil(L); return 1; }
fs::FS* fs = luaHostAppFs();
if (!fs) { lua_pushnil(L); return 1; }
File f = fs->open(path, "r");
if (!f) { lua_pushnil(L); return 1; }
size_t n = f.size();
if (n > kFsMaxFile) n = kFsMaxFile; // never hand Lua an unbounded buffer
luaL_Buffer b;
char* dst = luaL_buffinitsize(L, &b, n);
const size_t got = f.read((uint8_t*)dst, n);
f.close();
luaL_pushresultsize(&b, got);
return 1;
}
int fsRemove(lua_State* L) {
const char* name = luaL_checkstring(L, 1);
char path[128];
fs::FS* fs = luaHostAppFs();
if (!fs || !fsPath(path, sizeof path, name)) { lua_pushboolean(L, 0); return 1; }
lua_pushboolean(L, fs->remove(path));
return 1;
}
int fsList(lua_State* L) {
lua_newtable(L);
fs::FS* fs = luaHostAppFs();
if (!fs || !s_h) return 1;
char d[80], rel[64];
snprintf(rel, sizeof rel, "/apps/%s.d", s_h->id);
luaHostAppPath(d, sizeof d, rel);
File dir = fs->open(d);
if (!dir || !dir.isDirectory()) return 1;
int i = 0;
for (File e = dir.openNextFile(); e; e = dir.openNextFile()) {
if (e.isDirectory()) continue;
lua_createtable(L, 0, 2);
const char* nm = strrchr(e.name(), '/');
lua_pushstring(L, nm ? nm + 1 : e.name()); lua_setfield(L, -2, "name");
lua_pushinteger(L, (lua_Integer)e.size()); lua_setfield(L, -2, "size");
lua_rawseti(L, -2, ++i);
}
dir.close();
return 1;
}
#endif // CAP_LUA_SDK_EXT
void storePath(char* out, size_t cap) {
char rel[40];
snprintf(rel, sizeof rel, "/apps/%s.sav", s_h->id);
@@ -587,6 +843,38 @@ void sendInput(const char* type, const char* dir, int x, int y) {
serviceDeferredClose();
}
// Key delivery reuses on_input rather than adding an on_key callback: an app
// already branches on ev.type, and one entry point keeps a Lua app's input model
// to a single place. type="key", with `key` as a one-character string for
// printable input and a name ("up", "enter", ...) for the rest, so an app can
// write ev.key == "w" without knowing scancodes.
void sendKey(int key) {
if (!s_h || !s_h->L) return;
if (!pushCallback(s_h, "on_input")) return;
lua_State* L = s_h->L;
lua_createtable(L, 0, 2);
lua_pushstring(L, "key"); lua_setfield(L, -2, "type");
char buf[8];
const char* named = nullptr;
switch (key) {
case LV_KEY_UP: named = "up"; break;
case LV_KEY_DOWN: named = "down"; break;
case LV_KEY_LEFT: named = "left"; break;
case LV_KEY_RIGHT: named = "right"; break;
case LV_KEY_ENTER: named = "enter"; break;
case LV_KEY_ESC: named = "esc"; break;
case '\b': case 127: named = "backspace"; break;
default: break;
}
if (named) { lua_pushstring(L, named); }
else if (key >= 32 && key < 127) { buf[0] = (char)key; buf[1] = '\0'; lua_pushstring(L, buf); }
else { lua_pushnil(L); }
lua_setfield(L, -2, "key");
lua_pushinteger(L, key); lua_setfield(L, -2, "code"); // raw, for anything unmapped
guardedCall(s_h, 1);
serviceDeferredClose();
}
void gestureCb(lv_event_t* e) {
lv_dir_t d = lv_indev_get_gesture_dir(lv_indev_get_act());
const char* dir = (d == LV_DIR_TOP) ? "up" : (d == LV_DIR_BOTTOM) ? "down"
@@ -745,6 +1033,14 @@ void openWada(lua_State* L) {
lua_pushcfunction(L, sysToast); lua_setfield(L, -2, "toast");
lua_pushcfunction(L, sysBoard); lua_setfield(L, -2, "board");
lua_pushcfunction(L, sysRandom); lua_setfield(L, -2, "random");
lua_pushcfunction(L, sysEpoch); lua_setfield(L, -2, "epoch"); // #245
lua_pushcfunction(L, sysDatetime); lua_setfield(L, -2, "datetime"); // #245
lua_pushcfunction(L, sysBeep); lua_setfield(L, -2, "beep"); // #245
lua_pushcfunction(L, sysCaps); lua_setfield(L, -2, "caps"); // feature detection
#if CAP_LUA_SDK_EXT
lua_pushcfunction(L, sysBattery); lua_setfield(L, -2, "battery");
lua_pushcfunction(L, sysGps); lua_setfield(L, -2, "gps");
#endif
lua_setfield(L, -2, "sys");
lua_newtable(L); // wada.timer
@@ -757,11 +1053,24 @@ void openWada(lua_State* L) {
lua_pushcfunction(L, storeSet); lua_setfield(L, -2, "set");
lua_setfield(L, -2, "store");
#if CAP_LUA_SDK_EXT
lua_newtable(L); // wada.fs (scoped to /apps/<id>.d)
lua_pushcfunction(L, fsRead); lua_setfield(L, -2, "read");
lua_pushcfunction(L, fsWrite); lua_setfield(L, -2, "write");
lua_pushcfunction(L, fsAppend); lua_setfield(L, -2, "append");
lua_pushcfunction(L, fsList); lua_setfield(L, -2, "list");
lua_pushcfunction(L, fsRemove); lua_setfield(L, -2, "remove");
lua_setfield(L, -2, "fs");
#endif
lua_newtable(L); // wada.mesh (read-only)
lua_pushcfunction(L, meshContacts); lua_setfield(L, -2, "contacts");
lua_pushcfunction(L, meshRxLog); lua_setfield(L, -2, "rx_log");
lua_pushcfunction(L, meshStats); lua_setfield(L, -2, "stats");
lua_pushcfunction(L, meshSelf); lua_setfield(L, -2, "self");
#if CAP_LUA_SDK_EXT
lua_pushcfunction(L, meshSend); lua_setfield(L, -2, "send"); // consent-gated
#endif
lua_setfield(L, -2, "mesh");
lua_newtable(L); // wada.net
@@ -821,6 +1130,38 @@ void hostTeardown(); // fwd
// ---------------------------------------------------------------------------
bool luaAppIsOpen() { return s_h != nullptr; }
// Returns true when the running app took the key. False lets the firmware keep
// its own handling, so an app that does not implement on_input changes nothing.
#if CAP_LUA_SDK_EXT
// Incoming channel message -> app.on_message{channel, sender, text}. Permission
// checked HERE rather than at subscribe time, so revoking it in Settings stops
// delivery immediately instead of at the next launch.
static void deliverMessage(const char* channel, const char* sender, const char* text) {
if (!s_h || !s_h->L) return;
if (!luaHostReadPerm(s_h->id)) return;
if (!pushCallback(s_h, "on_message")) return;
lua_State* L = s_h->L;
lua_createtable(L, 0, 3);
lua_pushstring(L, channel ? channel : ""); lua_setfield(L, -2, "channel");
lua_pushstring(L, sender ? sender : ""); lua_setfield(L, -2, "sender");
lua_pushstring(L, text ? text : ""); lua_setfield(L, -2, "text");
guardedCall(s_h, 1);
serviceDeferredClose();
}
#endif
bool luaAppKey(int key) {
if (!s_h || !s_h->L || key <= 0) return false;
sendKey(key);
return true;
}
#if CAP_LUA_SDK_EXT
void luaAppMessage(const char* channel, const char* sender, const char* text) {
deliverMessage(channel, sender, text);
}
#endif
void luaAppSteer(int dx, int dy) {
if (!s_h) return;
const char* dir = (dy < 0) ? "up" : (dy > 0) ? "down" : (dx < 0) ? "left" : (dx > 0) ? "right" : nullptr;
+7
View File
@@ -20,8 +20,15 @@ bool luaAppLaunchFile(const char* id, const char* title, const char* embedded, s
bool luaAppIsOpen();
void luaAppDismiss(); // THE close path (AppPage back hook)
void luaAppSteer(int dx, int dy); // trackball/keypad direction -> on_input
bool luaAppKey(int key); // hardware key -> on_input; true = app consumed it
void luaAppMessage(const char* channel, const char* sender, const char* text); // -> on_message
// Per-app permission bits, stored as a decimal mask in /apps/perms.kv.
#define LUA_PERM_SEND 1
#define LUA_PERM_READ 2
#else
inline bool luaAppIsOpen() { return false; }
inline void luaAppDismiss() {}
inline void luaAppSteer(int, int) {}
inline bool luaAppKey(int) { return false; }
inline void luaAppMessage(const char*, const char*, const char*) {}
#endif
+677 -92
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -490,6 +490,7 @@ public:
void setPathHashMode(uint8_t mode);
void setExperimentalFlags(uint8_t multi_acks, uint8_t client_repeat, uint8_t rx_boosted);
void setTelemetryAllow(bool on); // answer mesh telemetry requests (battery+env; location stays separate)
void setLocationTelemetryMode(uint8_t mode); // TELEM_MODE_* — position on request (#266)
/** Meshcomod CLI on device: `wifi on` / `wifi off`. */
bool setWifiRadio(bool on);
bool isTcpEnabled() const { return _serial && _serial->isTcpEnabled(); }
+30
View File
@@ -251,6 +251,36 @@
#define CAP_LUA_APPS 1
#endif
// ---- Extended Lua SDK ------------------------------------------------------
// The BASE SDK (drawing, timers, key/value store, read-only mesh + radio stats,
// http_get) is board-agnostic and ships everywhere CAP_LUA_APPS is on.
//
// The EXTENDED SDK is the part with real running cost, and it is gated:
// wada.sys.battery/gps/sensors live device state
// wada.fs.* scoped file read/write/list under /apps
// wada.net.http_post + wifi_scan
// wada.mesh.send + on_message the only WRITE path into the mesh
// wada.ui.input/list + on_key text entry, lists, key events
//
// Why it is not everywhere: these add per-app RAM (an inbound message queue, a
// scan buffer, retained Lua callbacks) and invite apps that hold buffers and do
// I/O. The 2 MB-PSRAM Heltec V4 already runs at ~95% internal RAM with Wi-Fi up
// — the same headroom problem that gates CAP_WEB_BROWSER and forces
// CAP_BUILTIN_LANGS there — so it gets the base SDK only.
//
// TO GATE A FUTURE LOW-RESOURCE BOARD, pick either:
// * define WADA_LOW_RESOURCE_BOARD in the board's block (also gates future extras), or
// * define CAP_LUA_SDK_EXT 0 in the board's block (gates only this)
// Apps must feature-detect with wada.sys.caps().sdk_ext rather than assume.
#ifndef CAP_LUA_SDK_EXT
#if !CAP_LUA_APPS || defined(WADA_LOW_RESOURCE_BOARD) || \
(defined(HELTEC_LORA_V4_TFT) && !defined(HELTEC_LORA_V4_R8))
#define CAP_LUA_SDK_EXT 0 // 2 MB V4 (and anything marked low-resource)
#else
#define CAP_LUA_SDK_EXT 1 // 8 MB boards incl. the V4-R8
#endif
#endif
// Compile the translations into the image (i18n_builtin.h, generated from
// deploy/apps/lang/*.lang) instead of relying on downloading a .lang file.
// ON for boards where the Lua Store is not dependable — the V4 runs at ~95%