- The catalog parse stopped at 16 rows and the catalog has 16, so a 17th app
(Blackjack, #681) was silently never listed; installed apps had the same
cap. Both are 40 now, and the catalog buffer grows from 8 KB to 24 KB so
the JSON is not cut off. PSRAM only.
- wada.ui.canvas raised a Lua error above 480 px, which killed apps that size
a canvas from on_open(w, h) on the T-Display P4 (body ~575 tall), Tanmatsu
and CrowPanel 5 (800 wide) (Discord). Sides are now clamped to the screen's
long side instead.
- button:bg(fill [, border]) colours a button (or boxes a label), so apps no
longer fake coloured buttons from panels that keys cannot focus (Discord).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Spectrum: stat tiles (peak, floor, our channel, busy), plot and waterfall drawn
at screen size (the lv_chart and zoomed waterfall were the page's render cost),
dark-to-yellow waterfall, peak hold, our-channel marker, Band/Channel and
Live/1 min/10 min chips. RF Monitor 1.4 (tiles, filled chart, typed feed, packet
detail card; the old type table did not match MeshCore payload types) and
Airtime 1.6 (big figure, tiles, duty line, rx/tx split).
Lua SDK: ui.panel, bold labels and a size-24 figure font, colors.warn/border,
app.on_audio (fires with the screen off), read-only wada.sd.list and sd: audio on
the SD_MMC boards (P4, Tanmatsu, Wio L2), caps panel/audio_events/epaper, a
"music" icon and an "audio" Store requirement. button:set() no longer writes
label fields into the button object (heap corruption, every earlier firmware).
The SDK page's input table described events the firmware never sent.
Music 1.0 (Store app): plays MP3/WAV from the card's Music folder, shuffle,
repeat, keeps going through the list with the screen off. Verified on the
T-Display P4 end to end.
Store: the update check also fetches the app catalog and badges the Store tile
with the number of installed apps that have a newer version. Baked Lua apps are
raw-deflated and inflated by the S3 ROM (14.6 KB back on the V4); the V4 language
table keeps only texts of apps it can run.
Fixes: the SD-card notice no longer fires when the card mounts fine (#667); the
Control Center clock keeps time while open (#666); M9 keyboard brightness (#664);
the battery widget's number no longer sits on a ring too small for it (Discord);
M9 shortcuts doc follows #669's composer order. New texts in all 14 languages.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Contact nicknames: TouchPrefsStore nick blob (48 x 38 B), display-only lookups
(contactShownName, threadShownName, senderShownName), Contact options >
Nickname on the shared text prompt (an empty OK clears it).
Control panel restyled in the launcher's tile language, with a Battery saver
tile; keyboard tooltips (navSetTip), an amber cursor and touch long-press
tooltips (the T-Deck touch read hid them on every poll).
T-LoRa Pager: M/C/A/S on Home, H/S tap and hold, Backspace = Back, N = jump to
new, held Enter = long press, per-tab focus memory, Power off confirm; LVGL
-O2 and IRAM, INT-gated keyboard, idle yield, status-bar setter guards, instant
tabs; PagerGps (u-blox software standby for rests, GPS rail cut when off),
panel sleep, amp off at boot, power-off parks the radio (NSS held), GPS, panel
and keyboard light.
Fixes: the DM duplicate check keys on the attempt and a path reset clears it,
the direct timeout masks the packed path byte, web remote hops (#636);
changelog over http (#638); Lua SDK send budgets and duplicate guard (#606);
GPS speed note (#613, CAP_GPS_BAUD_AUTO); emoji chat icon (#640); default
wallpaper (#626).
Heltec V4: Tetris, 2048 and Breakout unseeded and the built-in Lua stripped of
comments (identical bytecode), 27 KB freed; the baked language table is pinned
to beta_92's files (scripts/build/lang-pin-beta92) so it still fits.
Translations: 39 new keys in 14 languages, language store v24.
Site: theme, dark mode, aurora and guide refresh, SDK send limits, nicknames.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Home is a grid of arrangeable widgets (24 kinds, presets, a layout per
orientation, Lua app widgets via wada.widget); Unread, Chat and Mentions
get a large size. Theme > Background puts an aurora (still by default, or
moving), a colour, a picture or nothing behind every screen, with glass
cards and a smoked-glass top bar. A GPS icon in the bar, a GPS battery
saver (u-blox GNSS stop/start where there is no power switch), and a
battery saver that no longer waits for Bluetooth and Wi-Fi to be off.
Also: navigation bar position, every settings page on the redesign's
cards, a faster app drawer, slower screensaver drift for burn-in, text
wrapping at spaces only, the V4-R8 Store Apps tab, and SDK Test marked
extended-SDK only (keeps the 2 MB V4 at 99.8%).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
beta_86 put the emoji picker on the long-press menu, which covers the firmware's
own field and so covers wada.ui.input(). It does not cover an app that builds its
own buffer from key events, because there is no textarea to long-press -- and
that is exactly the case Jade asked about: their composer shows a pad-bytes
counter the firmware's field cannot, so it takes the custom path and the menu
never applies to it.
wada.ui.emoji(cb) opens the picker and hands the glyph back as UTF-8, nil if
dismissed. Same discipline as wada.ui.input's callback: one ref, one call,
released either way, refused if one is already open.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported by Jade as "screen memory errors, worse the longer the app has been
open", with an app that rebuilds its screens on a 120 ms debounce.
Button and list-row callbacks live in one registry table keyed by the LVGL
object POINTER. clear() calls lv_obj_clean(), which frees every one of those
objects, and bumps s_ui_gen so stale handles go inert, but it never touched the
table. So each rebuild added an entry and kept the Lua closure, with its
upvalues, alive forever. Unbounded, and worst for exactly the apps that are
built properly: a list of twenty contacts redrawn on a timer leaks twenty
closures a go.
It was also a correctness bug in waiting. LVGL reuses freed addresses, so a new
button created WITHOUT a callback could land on an address a dead one left
behind and fire that stale closure on the next tap.
clear() now replaces the table. Every entry in it describes an object
lv_obj_clean just destroyed, so there is nothing there worth keeping.
Found by reading the host after Jade asked what the memory budget was; the
budget was never the problem.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Some ThinkNode M9 cards shipped with a dormant Windows worm (Elecrow
security advisory, September 2026). An infected card seen since carries
autorun.inf in the root, launching xlfqf.pif on open, explore and autoplay
with random-junk comment lines in between: the Sality autorun pattern.
- SD Scan store app (deploy/apps/sdscan/1.0, requires "sd", not seeded):
walks the card a small page per tick, lists what it finds and why, and
removes it after a confirmation screen with Cancel first. It says on
every screen that it only removes files it recognises and that
formatting the card is the safe fix. On older firmware it still finds
threats by name but cannot remove them.
- Firmware: wada.sd.check(path) and wada.sd.remove(path), plus paging for
wada.sd.list(path, start, max) and caps().sd_clean. What counts as a
threat lives in SdThreat.h: autorun.inf, Windows program, script and
shortcut extensions, or a real MZ+PE header under any name. remove()
classifies again in firmware and refuses anything else, so no app can
use it to delete tiles, backups or chat history. It clears read-only,
hidden and system first, because FAT refuses to delete a read-only file.
- A warning when a card with Windows malware in its top folder is mounted,
at boot or on insert, offering SD Scan (or the Store).
- Tests: test/test_sd_threat.cpp, and SD Scan harness scenarios including
the real infected card's root. Removal checked on a T-Deck.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Kaj's T-Deck coredump: abort() on core 1, decoding to luaG_callerror ->
abort() reached from luaAppPress(). luaG_callerror is "attempt to call a ...
value", and the abort is Lua's DEFAULT panic function, because lua_atpanic was
never installed anywhere in this file. So any error reaching Lua with no
protected frame resets the device.
The callback itself was never the problem: guardedCall wraps it in pcall with a
traceback handler and turns a fault into a toast plus a clean close, which is
the host's whole contract. The hole is everything that runs BEFORE that pcall
is armed:
- pushCallback() used lua_getfield, which honours __index. An app table
carrying a metatable whose __index is not callable raises exactly
luaG_callerror, unprotected. It is now a raw lookup, which is also the
correct semantics: callbacks are plain fields on the table the chunk
returned, and no shipped app uses setmetatable.
- the argument marshalling in sendInput/sendKey pushes a table and several
values before the pcall exists, and those raise on stack growth or OOM.
lua_checkstack appeared ZERO times in this file; pushCallback now reserves
the room up front, and checkstack reports failure rather than raising.
lua_atpanic is installed as well. It cannot prevent the abort -- Lua aborts once
a panic function returns and there is no safe frame to unwind to -- but the
error text now reaches the serial log and a toast instead of vanishing, which is
the difference between a reproducible report and a mystery reset.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Known regions (#449, DenRalle on Tanmatsu): s_regions_modal was in no popup
registry at all. Without a row the Back ladder never peels it, so the page
stays on screen, and the open-popup COUNT stays zero, so whatever you open
next opens underneath it. Both halves of the report ("screen remains
visible", "pages open only in the background"), one missing line.
Idle power-save (#450, jrote1): the Battery page in the report shows the
gate blocked on "Wi-Fi on" with Wi-Fi disabled and cycles stuck at 0.
tsWifiOff() ANDed the wifiConfigGetRadioEnabled() pref with the real radio
state, and that pref DEFAULTS TO ENABLED when its NVS key was never written
-- so anyone who had not explicitly toggled Wi-Fi in this firmware read as
"Wi-Fi on" permanently. The gate is asking whether the modem draws power,
and WiFi.getMode() == WIFI_OFF answers exactly that on its own.
The other half of the same bug is in main.cpp: the power-down ran only if
wifi_started, but a scan or the version check can bring STA up without
going through that state machine, and then disabling Wi-Fi skipped
mode(WIFI_OFF) and left the modem running. getMode() != WIFI_OFF proves the
driver is inited, which is what the guard actually wanted to test, so it
closes the leak without reintroducing the low-heap esp_wifi_init crash the
original comment guards against. The block is one-shot behind
wifiConfigConsumeApplyRequest(), not per-loop.
Follow-ups from reviewing the merged PRs:
- #457 moved the Lua app page to the slim bar, which exposed that bar_h was
measured BEFORE appPageBegin* installs the chrome -- so the body was sized
against the OUTGOING page's bar. Install first, then measure (#236 again,
one step earlier).
- #453 makes /meshcore-*.json downloadable, and those carry "private_key"
plus every channel secret. The transfer is opt-in, LAN-only and rate
limited (3 tries, 30 s lockout), so the surface is sound -- but it is
plain HTTP, so say so on the page and flag backup rows in the list.
Reported-by: DenRalle, jrote1
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both reported on the T-Display P4 by D-Melhede, and neither is P4-specific once
you look: they are hardcoded pixel values in a UI whose text scales.
#236, a Lua app's first line cut in half by the status bar: the app host set its
body offset from a hardcoded 44, commented as matching the AppPage tall bar.
That is only true where STATUSBAR_H is its 22 default. The bar is
SB_TOP_PAD + SB_ROW*2 on the round-corner phone panel and SC(22) everywhere
else, so it grows with the UI scale, and anywhere it exceeded 44 the app drew
under it. The host now asks for the height the bar actually has. The same
mistake was already fixed elsewhere in the file, with a comment saying to
measure with statusBarCurH() rather than assume; the Lua host had not caught up.
That also means this was never only a P4 bug. Any board at Large or Huge scale
had it, which includes the Tanmatsu, whose default has been Large since v29.
#238, the drawer options not fitting: the card is laid out in raw pixels while
the text inside it scales, so the contents outgrew the box. Now scaled with SC(),
which is identity at 100% and therefore changes nothing on a board that was
already correct, and the card is additionally clamped to the sheet's height so a
larger scale cannot push it off screen.
All nine S3 envs and both ESP32-P4 targets green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three things Jade reported, the first of which is blocking an app submission.
Missing keys on the T-Deck. A Lua app there never received A, P, Q, Enter or
Backspace. The rule that withholds them exists so an app can never swallow its
own exit, which is right on a board where a reserved key IS the only way out.
On the T-Deck it is not: an app page is closed by tapping the back bar, and the
app host is not in the popup registry, so those keys closed nothing while an app
was running. They were simply dead inside every app. They are now forwarded.
The M9 keeps its reservation, because its reserved keys are sentinel bytes for
Back and Home that are never typed text and it has no touch to tap out with, and
the Pager already reserved nothing. That is why this was T-Deck-only.
The trackball click. With an app open the trackball branch forwarded motion and
explicitly discarded the button, so an app could be steered but never clicked.
It now delivers the same synthetic centre tap the touchless boards' select key
sends, on the press edge: the flag is a per-frame "held" level, so forwarding it
raw would have fired a tap every frame the button stayed down.
Repeat counts. The firmware already counts repeaters heard rebroadcasting an
outgoing flood, keyed on a fingerprint taken at transmit time; it is the refresh
glyph on a sent bubble. wada.mesh.send() now returns that fingerprint as its
second value on success, and wada.mesh.repeats(fp) reads the count back. Additive,
so an app reading only the first return is unaffected. The count grows as repeats
arrive, so it is polled rather than read once, and a direct message has none
because it is not flooded. Documented on the SDK page.
Built on all nine S3 envs and both ESP32-P4 targets.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The SDK documented building widgets in on_open() and gave no way to take them
down, so an app with a second screen drew it on top of the first. pisti87 hit
this building a multi-screen app and had been working around it with buttons;
he also noted that ui.list() has clear() while the page it sits on does not.
The reason this is not just lv_obj_clean() is the handles. WidgetUd holds a raw
lv_obj_t*, guarded only by `if (u->obj)`, so a Lua variable still referring to a
label from the screen just cleared would sail past that check into freed memory.
Every handle now records the generation it was created in, clear() bumps the
generation, and the accessors null the pointer of any handle from an older one.
The null guard that every method already has then turns a stale call into a
no-op. Given the day this codebase has had with use-after-frees, a clear() that
invited one would not have been worth shipping.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both reported by Jade, and both were arbitrary caps rather than real limits.
wada.mesh.contacts() scanned the first 200 contacts, returned at most 100, and
said nothing about having truncated. On a device holding 2000 contacts most of
them were simply unreachable. It takes an offset and a limit now, defaulting to
the old 100 and capped at 250 per call, because every entry is an eight-field
Lua table and building thousands in one go would exhaust the per-app heap. The
window moves, so everything is reachable by asking more than once, and the new
wada.mesh.contact_count() means an app can page without probing for the end.
The app source limit was 64 KB, which an ordinary program can reach. It is
192 KB now. The source sits in PSRAM only until luaL_loadbuffer has compiled it,
so this is a transient allocation, not a per-app cost, and the compiled chunk
still has to fit the 256 KB app heap. The Store's download buffer was the other
half of that ceiling and moves with it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
oumike. Closes#315 (pisti87's request).
Two conflicts, both resolved by keeping BOTH sides rather than choosing:
- sdRuntimeLifecycleBusy() gained an audio-playback source here and a web
reader source in #317. They are independent consumers of the same card and
both have to gate the mount lifecycle. The reader's self-exclusion is kept:
it calls this from its own task while holding the card and would otherwise
deadlock against itself.
- The Lua harness caps table needed sd_list from #312 as well as the audio
flags, and the test order needed the wardrive suite from #324 as well as
audio_api.
Built on all eight S3 envs and both ESP32-P4 targets.
cvhviz. The M9's magnetometer was documented on the board and nothing had ever
talked to it. The driver is written from the datasheet's register map rather
than SensorLib, whose setOutputDataRate() writes the ODR into the OSR bits, and
the axis orientation is measured on hardware at four headings rather than
inherited from a declaration Meshtastic marks unverified and never uses. The
+-32 G range looks absurd for a 0.5 G planet until you measure the board's own
hard-iron bias at about 7x Earth's field.
Also carries several fixes found while testing on hardware: every Lua app opened
on a white page on keypad-nav boards (the focus highlight harvested the app body
as a target and reverse-video filled the page), a use-after-free in the Lua net
worker when an app closed mid-request, an unfreed http_get buffer, canvas pixel
buffers GC'd while LVGL still drew from them, one RTC I2C read per contact, and
map re-open costing 2.5 s on every visit.
Three changes on merge:
- The map tile-keep gate read `total && total < 4 MB`, so a board reporting
zero PSRAM -- the most constrained case there is -- landed on the roomy side
of the test and kept its tiles. Dropped the non-zero guard.
- gpscompass is 55 KB of Lua, more than every other app combined, and it wants
a magnetometer the seeded boards do not have. The author deliberately left
it out of lua_builtin.h; that intent now lives in the catalog as
"seed": false rather than in whether someone remembers to regenerate, since
the generator runs from a pre-build hook as of this branch.
- consoleModeToggleCb was defined inside a !HAS_TANMATSU region while the
Settings row that binds it compiles on every board, so the Tanmatsu link
broke. Moved it out. The console boot path is gated on CAP_CONSOLE alone, so
the switch now does what it says there too.
Built on all seven S3 envs plus both ESP32-P4 targets.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pisti87 reported a long list of text that stays English whatever the language,
and said the strings were in his language file and still did not appear (#257).
Both halves are true, and the reason is the audit.
The extractor only ever recognised TR("literal"). Three very common shapes were
therefore invisible:
mk_row_btn("Reload tiles in view", cb) // helper TR()s its parameter
for (auto& r : rows) TR(r.label) // literal lives in a local table
TR(contactsSortOptName(m)) // helper returns one of several
All three translate correctly at runtime, so the source looks properly wrapped.
But the literal at the call site was never emitted as a key, so it never entered
a .lang file, so no translator could ever supply it -- and adding it by hand
did nothing, because the audit's key list is what the files are checked against.
That is 51 strings across the map options sheet, the sort sheets, the contacts
filters and the home launcher.
The audit now understands all three, plus tr("...") in the Lua apps, and the
newly visible keys are in all thirteen files as placeholders so translators can
see them. 1017 keys, up from 966.
Four strings were genuinely raw and are now wrapped: the reader's idle status,
the Discover empty feed, the crash-report export button and Paste (move/copy).
Lua apps had no way to translate anything at all, so every built-in was hard
English regardless of the device language. wada.sys.tr() gives them the same
table the interface uses; airtime 1.4 is the first to use it, with the
`sys.tr or identity` fallback so it still runs on older firmware.
Two more instances of the drift this issue is really about:
- gen-lua-builtin.py read out/firmware/apps/, which nothing writes -- the
deploy rsyncs deploy/apps/ straight to the VPS. So the mirror was stale and
the two apps added in beta_68 were never baked in: boards that cannot reach
the Store shipped without them. It reads the canonical directory now, and
regenerates from the same pre-build hook as the language table.
- Baking a row whose translation equals its key does nothing, since TR()
returns the key on a miss. Skipping them takes the header from 1.11 MB to
939 KB and gives the V4 back 16 KB of flash, which matters at 89%.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two small conflicts, both additive on each side: upstream added ui.text_w /
ui.text_lines and a "measure" capability while this branch added the panel
colour and the compass/accel caps. Everything from both is kept and the table
hints match the merged key counts.
GPS Compass now uses ui.text_w where the firmware offers it and keeps the
character estimate as the fallback. That estimate is what once put the heading
digits off centre -- it counted UTF-8 bytes, so the degree sign read as two
characters -- and measuring removes the guess entirely, for the dial's centred
text and the satellite meter's reserve alike.
M9, V4, V4-R8, T-Deck and Pager all build; the app harness passes, including
the tilt check.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A two-axis magnetic heading assumes the device is level. At this latitude the
field dips ~60 degrees, so the vertical component is 1.6x the horizontal one
and tipping the device leaks it into the pair the heading is made from: about
1.5 degrees of heading per degree of tilt. That is what "it drifts" was once
the calibration was sound -- it was the hand holding it.
Driver: variants/thinknode_m9/M9Imu.{h,cpp}, QMI8658 at 0x6B on the peripheral
bus, accelerometer only (the gyro is most of the power budget and nothing here
needs it): +/-2 g at 62.5 Hz with the low-pass on, soft reset with a 160 ms
wait that covers both die variants, and the same idle-suspend as the compass so
it costs nothing when unused. CTRL1's ADDR_AI bit is set and read back -- with
it clear the burst read silently returns six copies of one byte, which looks
like a working sensor reporting nonsense. HAS_M9_IMU -> CAP_IMU ->
wada.sys.accel(), caps().accel.
Axes MEASURED, not guessed, by holding three attitudes and logging:
flat, screen up z = -1.02 -> +Z into the screen (down)
on bottom edge, top up x = +0.97 -> +X at the top edge (forward)
on left edge, right up y = +1.08 -> +Y at the right edge
So the IMU is already in the aerospace body frame, and it agrees with the
magnetometer's independently measured +Z-into-screen. (Meshtastic's M9 driver
passes both sensors through untransformed and mirrors the heading on the sign
of accel Z, so its compass flips when the device is turned over. Not copied.)
Heading now rotates the field back into the horizontal plane using gravity
(NXP AN4248 / ST AN3192) before taking the angle, and reports the tilt angle;
past 55 degrees it says "too steep to read" rather than lying. Calibration
returns to a 3D fit because tilt compensation needs the vertical offset too --
but the instruction is now "turn it every way ON ONE SPOT", and the
accelerometer VERIFIES it: coverage is measured by how far gravity swung, so a
flat spin is refused by name ("turn it nose over tail") instead of silently
fitting a degenerate sphere. Simulated in the harness against a modelled M9 in
a known attitude: offsets recovered exactly, and the heading holds within 3
degrees through 20 degrees of roll and pitch.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two faults, one of them mine from an hour ago.
CAL_SECS is 20 s and the M9's default screen timeout is also 20 s. The tick
pause added in 5a4a5a8 stopped the app the instant the screen blanked, which
is the same instant the calibration was due to finish -- so sampling stopped
half way, the auto-finish (inside on_tick) never ran, and the partial sweep was
fitted and saved without complaint. New wada.sys.keep_awake() holds the screen
AND the tick for a measuring app, and is released when the app closes.
The tumbling instruction was wrong too. Hard-iron calibration assumes the
device ROTATES in a uniform field; carrying it through the air also TRANSLATES
it through the field gradients of a laptop and a desk. Measured consequence: a
centre that moved 0.15 G between sessions against a 0.26 G horizontal signal,
i.e. tens of degrees of direction-dependent error -- which is what "it drifts
when rotating" was. The same device rotated flat in one place fitted a circle
to within 4%, so calibration is now exactly that: a 2D circle fit on x/y, which
is the only thing a heading uses.
And a bad fit can no longer be saved silently, which is how the broken one got
in. calib_solve refuses, with a reason, when the turn is partial (each axis
must span >= 1.4r), when the radius is outside Earth's real horizontal range
(0.08-0.45 G), or when the circle is too distorted (residual > 18% of r --
computed in closed form from the sums already collected). The progress line
shows coverage rather than a countdown.
Also: mag_norm is now the HORIZONTAL magnitude, which lets the app say "Hold it
level" when it departs from the calibrated radius -- the tell-tale for tilt,
which is the accuracy ceiling until the IMU is driven.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* #305 the home Wi-Fi status was decided from WiFi.getMode(), not from what the
user asked for, so anything leaving the radio in STA made it announce
"Starting…" indefinitely with Wi-Fi switched off. Gate on
wifiConfigGetRadioEnabled().
* #304 the Wi-Fi and Bluetooth status strings were raw literals and stayed
English everywhere. Wrapped in TR(). Most already had translations sitting
unused in the language files, so this costs translators nothing for
"Connecting…", "SSID not found", "Off" and "Built-in"; only "Starting…",
"Auth failed", "Link lost" and "Init…" are new keys.
* #257 "Built-in" had two code paths and only the one you do NOT see went
through TR(), which is why it looked inconsistent to the reporter.
* #307 the theme-colour buttons were fixed at 124/84 px, measured against
"Save & restart" / "Reset". Hungarian's "Mentés és újraindítás" ran off both
edges. They size to their label now, in a wrapping row, so an over-long pair
drops to two lines rather than clipping.
* #306 the contact action sheet decided scrollability from a hand-maintained
grid_items tally that the location-sharing button (#266) was never added to.
When the tally is short the body is left unscrollable and the overflow is
unreachable. Decide from the height the buttons actually reached instead, so
the tally can drift harmlessly.
SDK: wada.ui.text_w / text_lines. An app could ask how tall a line is but not
how wide, so anything laying out its own rows had to guess whether a string
would wrap; guessing wrong draws the next row on top. That is exactly what
happened to the SDK Test app (fixed separately as store version 1.4, with
Nearby hardened the same way).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
beta_68 added named timers, so pausing only on_tick left the same problem one
API over: a repeating timer polling a sensor kept running into a dark screen.
One-shots still fire -- those are scheduled app logic, and skipping one would
drop the work rather than defer it, since the slot is released around the call.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two answers to "does the compass app matter for battery life", both yes-ish
and both now fixed.
The driver put the QMC6309 into normal mode at boot and left it there, so it
converted continuously from power-on whether or not anything read it: ~1 mA at
100 Hz / OSR 8, forever, on a 2300 mAh battery. It is now parked in suspend
after configuration and woken on demand, with m9CompassIdleTick() (called from
the M9's existing per-loop branch) suspending it again two seconds after the
last read. Waking is a single register write since suspend preserves the
configuration; the waking call reports "nothing fresh" and the caller's next
poll gets data, which callers already handle because the chip may be absent.
Separately, lv_timer_handler() runs unconditionally, so a Lua app's timer kept
firing while the display slept -- GPS Compass polled the magnetometer at 10 Hz
into a dark screen, which would have held the sensor awake even after the fix
above. The host now skips the tick while the screen is off and resumes on wake;
dt comes from millis(), so an app sees one long frame rather than a broken
clock. Documented on the SDK page.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
luaNetWorkerService() cleared s_net_pending as its FIRST statement and only
then made the HTTP call, so for the length of a round trip nothing told the UI
thread a request was in flight. An app closing in that window ran hostTeardown,
saw !s_net_pending, and freed s_net_buf / s_net_body while the worker task on
the other core was still reading and writing them.
The flag now stays set for the whole request and is cleared at the end, just
before s_net_done is announced -- that ordering also means a teardown observing
done=true can never also observe pending=true and hold the buffers forever. The
caller runs one service() per loop pass and re-checks afterwards, so holding it
across the call cannot re-enter, and http_get/http_post already reject a second
fetch while it is set.
Found while merging beta_68: upstream's comment said the buffers were
deliberately not freed at teardown because the worker might still be using
them, which was the right hazard behind a guard that could not actually see it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
beta_68 expanded the Lua SDK (map, lists, packet delivery, discovery, private
messages/rooms, native crypto) across the same files as this branch, so four
files conflicted. Nothing was dropped from either side:
- wada.sys.gps(): both widenings merged into one binding. Upstream's
fix_time / lat_e6 / lon_e6 and our speed_kmh / course now share a signature,
and our stricter gate wins -- the call returns nil when the user has GPS
switched off, not just when there is no fix.
- Altitude is upstream's `alt_m` alone. The resolution first carried `alt`
beside it to protect a shipped app, but gpscompass has never been published
to the store (it exists only in this branch and on a bench device), so
carrying a duplicate key into the API forever was the wrong trade: the app
reads alt_m instead.
- sysCaps() carries all twelve feature flags (upstream's seven, our compass,
the four originals) with a matching table hint.
- wada.geo (upstream) and wada.sys.compass (ours) both survive; upstream's
"no board has a magnetometer" note is corrected in the code and on the SDK
page, since the M9 now does.
- hostTeardown frees upstream's new POST payload buffer as well as the fetch
buffer, under the same in-flight guard.
- The catalog keeps all three new apps: upstream's wardrive and nearby, ours
gpscompass (8 total, every referenced file present).
M9, V4, V4-R8, T-Deck and Pager all compile; the Lua host harness passes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Map re-open cost ~2.5 s of blocked UI on the M9, every visit. Measured with
the existing stall instrumentation: [STALL] ui:lvgl 2597ms and 2479ms on two
consecutive opens -- identical, so it was paying full price each time rather
than only on the first paint. Cause: leaving the Map tab called freeMapTiles(),
throwing away all nine decoded tiles, so the next open re-read and re-decoded
nine JPEGs. The grid costs 9 x 128 KB = 1.15 MB of PSRAM, which only matters
on the 2 MB V4 -- the board renderMapTiles already caps to a 4-tile pool -- so
boards with room now keep the slots exactly as panning within the tab leaves
them and the re-open is renderMapTiles' match-and-reposition path, no decode.
Note releaseMapTileSlot() would NOT have worked here: it clears in_use, so the
next render treats the tile as absent and decodes it again.
Lua host teardown, from auditing "do apps close properly":
- the http_get buffer (up to 64 KB of PSRAM) was a static that no one freed, so
an app that fetched left it allocated after closing until some later app
happened to fetch again. Released on teardown unless a fetch is in flight.
- cvGc's comment claimed it freed canvas pixels only when the app was closing;
it freed unconditionally, so a canvas whose Lua handle went out of scope
could be collected while LVGL was still drawing from that buffer. Canvas
handles are now pinned in the registry for the app's lifetime (there is no
API to destroy one early), so the pixels die with the state. All three
shipped canvas apps keep references, so this was latent, not live.
- both the open and close log lines now report free PSRAM: the Lua heap
counter excludes canvas buffers, so "leaked=0" alone never proved an app
had given everything back.
M9, V4, V4-R8, T-Deck and Pager all compile. The map timing still needs the
after-measurement on hardware.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Lua app page painted itself 0x0E1216 while the rest of the firmware
paints pure black (COLOR_BG), so every app read as a lighter panel floating
over the UI. The page is now black and wada.ui.colors gains `panel` for the
raised surface an app draws on top of it -- the compass dial uses it, so the
instrument stands out instead of the page doing it.
Rotate/flip are gone from the user's side. They existed because the QMC6309's
axis orientation on the M9 is undocumented, which is not the user's problem to
solve by trial and error. `A` now does the whole job: whether the heading runs
clockwise or anticlockwise follows from which way the sensor's Z axis faces,
and that shows in the sign of the vertical field -- Earth's field dips down
north of the magnetic equator and up south of it -- so with a position (a fix,
or the node's last known one) the app reads the handedness off the sensor and
the press only has to set the offset. `F` stays as the fallback for a flat
field or no position at all.
Layout: stats column on the left, dial on the right (its status line above
it, the hint centred along the bottom).
The host harness now tumbles the simulated device during calibration rather
than spinning it flat -- min/max on an axis that never moves subtracts the
true field along it, which is precisely the case the handedness check has to
detect and refuse, and the flat mock was hiding it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Layout, from on-device feedback: the magnetometer/heading-source line moved
from the stats column to a centred line over the dial; the stats panel now
starts at the top of the column, and the rows that freed up went to the
target -- name, range + bearing, how far to turn ("56 deg right", "ahead")
and when the contact was last heard. The key hint is centred along the
bottom edge of the view and spells the actions out ("C calibrate O rotate
F flip <> target"). The heading's DIGITS are centred with the degree sign
hanging off their right edge, so the number does not appear to shift as the
reading crosses 100/200; the width estimate also counts characters rather
than bytes now, which is what put it half a glyph off (the degree sign is
two bytes in UTF-8).
Host: label:width(px) takes an optional alignment ("center"/"right") -- an
app cannot measure glyphs, so this is the only way for it to centre a line
exactly. Also excluded the app ROOT from keyboard-nav focus: excluding only
the body moved the reverse-video highlight up one level instead of removing
it, which is why the page was still white.
sideload_app.py retries fput/fend as well as fadd -- the same UART byte loss
that garbles a long line can garble a short one ("Error: unknown command").
Calibration now reports the measured field strength in its toast, which is
the number that says whether the calibration is any good (Earth: 0.25-0.65 G).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
On the ThinkNode M9 every Lua app opened on a white page: the app body is a
clickable object (touch boards need its press events) on the top layer, so
navCollect harvested it as a leaf focus target and navFocusCb's reverse-video
fill painted it solid under the app's widgets -- the canvas on top stayed
dark, which is what gave it away. NAV_SKIP_FLAG would also hide an app's own
buttons from the d-pad, so this adds NAV_PASSTHRU_FLAG (AppPage.h, shared by
both TUs): clickable, never a target itself, children still collected.
M9 compass: low-pass depth 8 at 50 Hz (the datasheet's 0x61 example) read as
sluggish on the dial; now depth 4 at 100 Hz (CTRL1 0x41, CTRL2 0x30). The
app ticks at 100 ms with lighter smoothing to match.
GPS Compass app rebuilt in the RF Monitor's look: a dial with rings,
10/30/90-degree graduations, red north, a lubber mark and the heading in
the centre; a key/value panel (FIX/LAT/LON/ALT/SPD/TGT) with a 10-cell
satellite meter; compact strings where the column is narrow at large fonts.
Confirmed on the M9 by Chris: the dial turns and calibration holds
(gpscompass.sav persists across reboots).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Firmware
- variants/thinknode_m9/M9Compass.{h,cpp}: QMC6309 at 0x7C on the peripheral
bus. Probe by chip id, soft reset with the explicit clear, CTRL2 0x20
(50 Hz, +/-32 G, set/reset on), CTRL1 0x61 (normal, OSR 8/8), read-back
verified. Synchronous read from the Lua host bridge, 1 s cache, lazy
re-probe while the rail-powered part is still in POR, OVFL kept + flagged +
logged. HAS_M9_COMPASS=1 in the env -> new hardware gate CAP_COMPASS.
- src/helpers/WadaNmeaLocationProvider.h: Wadamesh-owned copy of the core
MicroNMEALocationProvider that also exposes RMC speed/course (the core
keeps its parser private; no libdeps patch). M9 target.cpp builds on it,
HAS_GPS_MOTION=1, wadaGpsMotion() for UITask.
- wada.sys.gps(): + alt (m), + speed_kmh/course where the board provides
them (course only while moving -- an empty RMC course parses as 0), and
nil while the user has GPS switched off. wada.sys.compass(): {x,y,z,ovfl}
Gauss, sensor frame, uncalibrated, registered only where CAP_COMPASS;
caps().compass. Calibration, axis mapping and the heading maths live in
the app so they can be adjusted per user without a firmware cut.
- Host: luaHostContactAt reads the RTC once per contacts() walk instead of
once per contact (an I2C transaction each on the M9); pressCb reports
press coordinates in body content space (scroll offset folded in).
App
- deploy/apps/gpscompass/1.0 + apps.json: rotating rose with a fixed index,
live fix readout, bearing/range to a selected contact, magnetometer
heading with hard-iron calibration (C), frame rotate/mirror (O/F, the
M9's sensor orientation is undocumented), GPS-course fallback on every
other board, saturation warning. Not baked into lua_builtin.h on purpose
(CAP_BUILTIN_LUA_APPS also removes the Store > Apps tab).
Verified: M9, V4, V4-R8, T-Deck compile (M9 flash +2 KB); two adversarial
review passes, all confirmed findings fixed; host Lua harness (vendored
Lua, LUA_32BITS) -- calibration recovers a simulated bias exactly, heading
error 0 deg, worst tick ~10k of the 100k budget. Hardware validation list
(axis orientation, bias magnitude, 0x7C ACK) in M9_PORT.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The four capabilities that kept third-party apps a sketch of a built-in one:
* wada.map the firmware's own tiles, projection and cache inside an app
page, with its own capped pool so it never evicts the Map tab's
* wada.ui.list the missing "pick one of N" widget; rows are real buttons, so
keyboard and trackball nav walk them for free
* on_packet each frame delivered once instead of polling a 16-deep ring,
which sampled rather than observed
* wada.mesh.discover the active zero-hop probe, behind its own permission
because it spends every neighbour's airtime, not just ours
Plus the surface those need to be useful: packet identity reported only where
the frame actually carries it, exact micro-degree coordinates (Lua is built
LUA_32BITS, so its floats were quietly costing a metre), altitude and satellite
time, wada.geo, wada.ui.input, named and one-shot timers, http_post, windowed
fs.read, and wada.sys.env on a hardware gate rather than the memory one.
Fixes:
* Both ESP32-P4 targets could not link. g_wifi_last_disc_reason was defined in
src/main.cpp, which the IDF builds never compile, so all nine S3 envs stayed
green while Tanmatsu and T-Display P4 were dead.
* Map zoom level was invisible in +/- buttons mode; the readout was hidden with
the slider it was anchored to.
* Hungarian and Dutch held each other's "No SD card" translation.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Apps could post to a channel and read channel traffic. They can now hold a whole
conversation.
wada.mesh.send_dm(to, text) -> ok, "sent"|"room"|err
wada.mesh.channels() -> names of the configured channels
app.on_message(m) -> m.kind is "channel" | "dm" | "room"
send_dm takes a contact NAME as it appears in wada.mesh.contacts(). A room server
is a contact too, so one call covers both and the second return says which it was.
Sends are mirrored into the on-device thread and register the expected ACK exactly
as a composer send does, so an app-sent message is a real message rather than
something that leaves the radio with no local trace. Private channels needed no
work: channel send already matches any configured channel by name, and channels()
now lets an app discover them instead of being told the name. Names only - the
channel secret is never exposed to Lua.
Permissions go from two to four, split the way the risk actually splits:
Post to channels as me LUA_PERM_SEND
Read channel messages LUA_PERM_READ
Send private messages as me LUA_PERM_DM_SEND
Read private messages LUA_PERM_DM_READ
Posting to a channel you are already in is not the same act as writing to one
person as you, and channel traffic is not somebody's DMs. Each is granted on its
own. The DM prompt is worded harder than the channel one because a direct message
lands in a private thread looking exactly like something the user typed.
Fixes a latent bug this would have made real: luaPermWrite() wrote a WHOLE mask,
so the send prompt silently revoked a read grant given in Settings. Harmless with
one bit, wrong with four. Writes are per-bit now.
on_message also moved: it ran before the sender/body parsing, so it could only
ever be handed a channel post. It now sits after the block and spam filters, so
an app sees exactly the messages the user does, and DMs and room posts reach it.
Also adds wada.crypto (sha256, sha1, hmac_sha256, hmac_sha1, hex). Tiamat and
Jade's OTP app was dying on "instruction budget exceeded" doing HMAC-SHA1 in pure
Lua, which is a legitimate use of 100k VM instructions rather than a runaway: SHA-1
is 80 bit-twiddling rounds per block and the interpreter overhead multiplies it.
mbedTLS is already linked, so the same work is now microseconds and a few VM
instructions. Deliberately NOT gated on CAP_LUA_SDK_EXT: it is pure computation
over data the app already holds, reads nothing of the user's and transmits nothing,
so the small boards get it too.
All 8 S3 boards build. Not yet exercised on hardware.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
Lua apps could see touch and trackball direction, and nothing else. On the four
boards with a keyboard -- T-Deck, T-Lora Pager, ThinkNode M9, Attaky -- that made
most app ideas impossible: no text entry, no shortcuts, no game controls, on the
devices whose whole point is having keys.
Keys now arrive through the EXISTING on_input callback as type="key" rather than
a second callback, so an app keeps one input model and one branch. `key` is a
one-character string for printable input and a name ("up", "enter", "backspace")
otherwise, so an app writes ev.key == "w" without knowing scancodes; `code`
carries the raw value for anything unmapped.
Hooked once in handleHwKey(), which is the shared dispatch for all four
keyboards, so no per-board work and no per-board drift.
The dismiss key is deliberately NOT forwarded. An app must never be able to trap
the user by swallowing its own exit, by bug or by design -- the same reason the
send permission is a user decision rather than a manifest claim.
luaAppKey() returning false leaves the firmware's own handling untouched, so an
app without on_input behaves exactly as before. All 8 S3 envs build, including
the V4 which has no hardware keyboard at all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The first WRITE path a store app has into the mesh, and the one API where a bad
app costs other people rather than just its own device: LoRa is a shared channel,
and anything sent goes out under the user's own node name -- to a reader it is
indistinguishable from the user typing it.
So it is consent-gated, and the grant is bound to ONE app id. Approving a beacon
app grants nothing to any other app; re-installing under a different id asks
again. Grants live in /apps/perms.kv, a plain text file the user can inspect or
delete to reset every decision.
The app cannot bypass or pre-empt it. It never sees a prompt API: wada.mesh.send
simply fails until a grant exists, and the FIRST refusal raises the dialog. So
consent is always something the user did, never something the app asked for at a
moment of its choosing.
Deny-by-default and crash-safe: the refusal is written BEFORE the dialog opens,
so a power cut mid-prompt cannot leave a grant behind, and the app cannot
re-prompt in a loop -- its next call reads -1 and fails silently.
The dialog says what actually matters. "Send messages" understates it, so it
names the node the messages will carry and states they cannot be told apart from
messages the user typed.
Two further limits, since consent is not a blank cheque: 5 s minimum between
sends per app (airtime is shared, and far scarcer than flash), and a 180-char
cap so one call cannot occupy the channel. The rate clock only starts on a real
transmission, so failed sends cannot be used to game it.
Channels are matched BY NAME, not by a cached slot index -- a stale slot
transmits on the WRONG key, which is the bug behind the composer's channel-send
fix and part of #260.
A review caught one defect before commit: the original "one prompt at a time"
guard was only cleared on Allow, so a cancelled dialog would have blocked every
OTHER app from ever prompting. Removed -- the on-disk refusal already prevents
re-prompting.
All 8 S3 envs build; meshSend/luaHostMeshSendChannel verified absent from the V4
image and present on T-Deck.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Apps had wada.store, a key/value table flushed once on exit. Anything larger --
a log, a track, an exported CSV -- was impossible. wada.fs adds read/write/
append/list/remove behind CAP_LUA_SDK_EXT.
Two things make this safe rather than just possible.
SCOPE. Every app gets exactly one directory, /apps/<id>.d/, and cannot address
anything outside it. Store apps are user-submitted, so the name check is a
security boundary: no '/', no '..', no leading dot, strict [A-Za-z0-9._-], 32
chars. It REJECTS rather than sanitises -- a sanitiser that "fixes"
../../identity into something valid is how these go wrong. Checked against a
traversal list including '../identity', '/etc/passwd', 'sub/../x' and an
embedded NUL.
RATE LIMIT. This is the API where an app can do the thing that has twice taken
this firmware down: on internal flash a burst of small writes triggers garbage
collection, and a GC pass suspends the flash cache and stalls BOTH cores (#222,
and the beta_25 bootloop). deploy/apps/README.md already asks authors not to
write in a loop; asking is weaker than enforcing, so writes are capped at one
per second per app and return `false, "too fast"` otherwise. The write itself
runs under WdtHeavyGuard, so a pass that DOES trigger GC stalls briefly instead
of rebooting. Reads are capped at 32 KB so an app can never be handed an
unbounded buffer.
All 8 S3 envs build. Gate re-verified by symbol: fsWrite/fsWriteCommon exist
only in the T-Deck image, not the V4 TFT one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
First slice of the extended SDK, and the gate the rest hangs off.
The BASE SDK (drawing, timers, store, read-only mesh, http_get) stays on every
board. The EXTENDED SDK -- live device state, file access, mesh writes, richer
input -- is gated by CAP_LUA_SDK_EXT, which is OFF on the 2 MB Heltec V4. That
board already runs at ~95% internal RAM with Wi-Fi up; it is the same headroom
problem that gates CAP_WEB_BROWSER and forces CAP_BUILTIN_LANGS there.
A future low-resource board opts out with ONE line in its own block, either
WADA_LOW_RESOURCE_BOARD (also gates future extras) or CAP_LUA_SDK_EXT 0.
In this slice:
wada.sys.caps() -> { sdk_ext, keyboard, touch, sd } feature detection
wada.sys.battery() -> { mv, pct, charging } gated
wada.sys.gps() -> { lat, lon, sats } or nil gated
caps() ships on EVERY board including the V4 -- an app has to be able to ask
before it branches, so the one call that reports absence can never be absent.
gps() returns nil without a fix rather than the last known position, which
wada.mesh.self() already gives: a track logger has to tell those apart.
Two things deliberately NOT shipped rather than shipped broken:
wada.sys.sensors() -- the only environment-sensor source in the firmware is
LocalEnvSnapshot, which is #if defined(HAS_EXPANSION_KIT), i.e. the Heltec V4
Expansion Kit. That is exactly the board this gate excludes, so the call would
have returned nil on every board able to make it. Sensor access belongs on a
HARDWARE gate, not this memory gate.
GPS altitude -- the LocationProvider holding it is a private UITask member with
no public accessor, unlike getGpsFix()/getGpsSats(). That header change is
worth making deliberately, not in passing.
Verified by symbol, not by eye: luaHostBattery/luaHostGps are ABSENT from the
V4 TFT image and PRESENT in the V4-R8 and T-Deck ones -- which also proves the
same-family R8 carve-out resolves correctly. All 8 S3 envs build.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pisti87 asked for time and buzzer access from Lua. wada.sys had millis, toast,
board and random -- an app could measure elapsed time but never say what time
it was, and could not make a sound at all.
wada.sys.epoch() -> Unix seconds, or nil
wada.sys.datetime() -> { year month day hour min sec wday }, or nil
wada.sys.beep() -> true if a sound was actually produced
Three decisions worth recording:
nil, not 0, when the clock has never been set. The device genuinely boots
without a clock until GPS, NTP or a mesh peer supplies one, and an app stamping
a log needs to tell "no clock yet" from "1970". Uses the same sane-clock floor
the mesh already applies.
datetime() is LOCAL time, in the timezone the user picked, because every use for
it is display. epoch() stays UTC seconds for arithmetic.
beep() is one chime, not the melody player the issue sketched. What exists is a
single per-board notification chime -- and some boards have no sounder. Routing
Lua through the same uiPlaySlot() the UI uses means an app cannot be louder or
different from the rest of the firmware, and it honours the user's sound setting
instead of overriding it. Returning whether sound actually happened lets an app
fall back to a visual cue rather than silently doing nothing, which a fake
melody API could not.
All 8 S3 envs build -- the beep path is per-board, so that matters here.
Requested by pisti87.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three things, all "the Store only ever ran on a 320 px T-Deck".
LINK FIX (supersedes 7e5eac4, which treated the symptom). LuaAppHost.cpp
forward-declared `class WiFiClient;`. That declares a BRAND NEW incomplete
class, which is only correct where WiFiClient really is a class of that name --
it is a #define to C6Client on the T-Display P4 and a `using WiFiClient =
NetworkClient` alias under arduino-esp32 3.x on the Tanmatsu. So the file
emitted luaNetWorkerService under a signature the caller never referenced and
BOTH P4 targets failed to link, with three different mangled names, while all 8
S3 envs built clean -- which is why it stayed hidden until the first P4 build.
The TU now refuses to name the type at all: UITask owns the networking and
passes the objects as opaque handles through luaStoreHttpGetOpaque, casting
them back on its side. This kills the whole class of bug rather than adding a
third per-board alias.
LAYOUT. Cards measure their text instead of assuming "title + two description
lines" -- lv_txt_get_size on both title and description, height floored so it
can never be shorter than the icon chip or the action button. The title wraps
instead of truncating and the description sits below its ACTUAL height, which
is what was overlapping. Measuring beats reading geometry back, which returns 0
before layout. This fixes every board for translations longer than the English.
LV_LABEL_LONG_DOT only ellipsizes when the HEIGHT is bounded; with just a width
it silently wraps, which hit the card title, the language rows, the sideloaded
rows and the tab labels. Tab cells are now sized in proportion to their text,
not even thirds: an even third on the P4 (284 px logical, scaled fonts) is
~90 px and "Languages" does not fit that, while "Apps" left half its cell
empty. The bar height follows the font, floored at 30 so S3 stays identical.
FEEDBACK. The pressed button's caption becomes a spinner for the whole
download, not a 1.2 s toast, so a slow link no longer reads as a dead tap. It
is repainted from the in-flight id after a rebuild, since a card scan finishing
mid-download would otherwise restore a plain Get button. The caption is hidden
rather than deleted -- this runs inside the button's own event.
Drawer tile is "Store", short enough for every panel.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The T-Display P4 failed to link with an undefined reference to
luaNetWorkerService(C6Client&, HTTPClient&).
On that board the C6 runs ESP-AT, so UITask.cpp does #define WiFiClient
C6Client to route sockets through AT-over-SDIO. LuaAppHost.cpp forward-declares
WiFiClient without the alias, so it emitted the definition as
_Z19luaNetWorkerServiceR10WiFiClientR10HTTPClient while the caller referenced
the C6Client& overload -- the symbol was present, just under the wrong type.
Confirmed by reading both object files.
Mirroring the alias fixes it. This TU only forwards the reference to
luaStoreHttpGet, so an incomplete type is sufficient. Every S3 board built
fine throughout, which is why it went unnoticed until the first P4 build of
the Lua work.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>