cvhviz. The M9's magnetometer was documented on the board and nothing had ever
talked to it. The driver is written from the datasheet's register map rather
than SensorLib, whose setOutputDataRate() writes the ODR into the OSR bits, and
the axis orientation is measured on hardware at four headings rather than
inherited from a declaration Meshtastic marks unverified and never uses. The
+-32 G range looks absurd for a 0.5 G planet until you measure the board's own
hard-iron bias at about 7x Earth's field.
Also carries several fixes found while testing on hardware: every Lua app opened
on a white page on keypad-nav boards (the focus highlight harvested the app body
as a target and reverse-video filled the page), a use-after-free in the Lua net
worker when an app closed mid-request, an unfreed http_get buffer, canvas pixel
buffers GC'd while LVGL still drew from them, one RTC I2C read per contact, and
map re-open costing 2.5 s on every visit.
Three changes on merge:
- The map tile-keep gate read `total && total < 4 MB`, so a board reporting
zero PSRAM -- the most constrained case there is -- landed on the roomy side
of the test and kept its tiles. Dropped the non-zero guard.
- gpscompass is 55 KB of Lua, more than every other app combined, and it wants
a magnetometer the seeded boards do not have. The author deliberately left
it out of lua_builtin.h; that intent now lives in the catalog as
"seed": false rather than in whether someone remembers to regenerate, since
the generator runs from a pre-build hook as of this branch.
- consoleModeToggleCb was defined inside a !HAS_TANMATSU region while the
Settings row that binds it compiles on every board, so the Tanmatsu link
broke. Moved it out. The console boot path is gated on CAP_CONSOLE alone, so
the switch now does what it says there too.
Built on all seven S3 envs plus both ESP32-P4 targets.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pisti87 reported a long list of text that stays English whatever the language,
and said the strings were in his language file and still did not appear (#257).
Both halves are true, and the reason is the audit.
The extractor only ever recognised TR("literal"). Three very common shapes were
therefore invisible:
mk_row_btn("Reload tiles in view", cb) // helper TR()s its parameter
for (auto& r : rows) TR(r.label) // literal lives in a local table
TR(contactsSortOptName(m)) // helper returns one of several
All three translate correctly at runtime, so the source looks properly wrapped.
But the literal at the call site was never emitted as a key, so it never entered
a .lang file, so no translator could ever supply it -- and adding it by hand
did nothing, because the audit's key list is what the files are checked against.
That is 51 strings across the map options sheet, the sort sheets, the contacts
filters and the home launcher.
The audit now understands all three, plus tr("...") in the Lua apps, and the
newly visible keys are in all thirteen files as placeholders so translators can
see them. 1017 keys, up from 966.
Four strings were genuinely raw and are now wrapped: the reader's idle status,
the Discover empty feed, the crash-report export button and Paste (move/copy).
Lua apps had no way to translate anything at all, so every built-in was hard
English regardless of the device language. wada.sys.tr() gives them the same
table the interface uses; airtime 1.4 is the first to use it, with the
`sys.tr or identity` fallback so it still runs on older firmware.
Two more instances of the drift this issue is really about:
- gen-lua-builtin.py read out/firmware/apps/, which nothing writes -- the
deploy rsyncs deploy/apps/ straight to the VPS. So the mirror was stale and
the two apps added in beta_68 were never baked in: boards that cannot reach
the Store shipped without them. It reads the canonical directory now, and
regenerates from the same pre-build hook as the language table.
- Baking a row whose translation equals its key does nothing, since TR()
returns the key on a miss. Skipping them takes the header from 1.11 MB to
939 KB and gives the V4 back 16 KB of flash, which matters at 89%.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The map About/credits sheet was 820 bytes of raw English built with snprintf and
no TR() anywhere in it, so it stayed English in every language (#257). It is now
three keys: the two attribution headers (the OpenTopoMap variant is credited
separately because its style is CC-BY-SA) and the body, kept whole rather than
split per paragraph so translators get prose instead of fragments. The buffer
grows 820 -> 2048 because Hungarian runs about 1.5x English here and the
Cyrillic and Greek files are two bytes a letter.
Hungarian text from pisti87 (#257). Two edits to what he posted, both flagged on
the issue: the hard line breaks he inserted at the English wrap points are gone,
because the label wraps itself and a fixed break lands mid-sentence on any other
panel width; and the header reads "Terkep adatok" rather than "Map adatok",
which looked like a copy-paste artifact given the rest is fully translated. The
OpenTopoMap variant is derived from his own wording and is his to correct.
Also raw, from the same report: the Discovered auto-add hint and the four type
words it interpolates. The hint buffer goes to 240 bytes and the type list to
128, since the translated plurals are longer than "chats, repeaters".
The reason none of that would have shipped: gen-lang-builtin.py exists so the
baked-in table and the .lang files the store serves cannot drift, and its
docstring promises a pre-build step that runs it. Nothing ran it. Editing a
.lang and building produced an image carrying the OLD translations, silently.
It is now a real pre: hook on all seven PlatformIO envs and a line in both IDF
build scripts, regenerating only when a .lang is newer than the header.
deploy-apps.sh grew the matching check for the other half of that path: a
catalog version that disagrees with the file's own "# ver:" publishes
translations to a version no device asks for.
All thirteen languages snapshot to v17 -- the merged region and SD work added
keys to every file, not just Hungarian.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
oumike. Closes#271.
Region presets stop being freq/BW/SF typed in by hand: the list carries the
legal frequency and duty-cycle for each region, so picking one sets a
coherent set rather than three fields that have to agree.
oumike. Closes#309 (pisti87's request).
Bounded to 192 entries, card-rooted paths validated, and it reuses the existing
SD mount and health lifecycle rather than opening its own.
oumike. Verified the reported regression is real: e2d07d8 converted two of the
regions guarding 'Save update bin to SD' to CAP_SD && CAP_OTA and left another
on the old three-board list, which excludes the T-Lora Pager.
Closes#289.
The declination model landed as 4.7 KB of constants pasted into a Lua app,
generated by a script that lived in out/ -- which is gitignored, holds firmware
bins, and is where the app's own "Regenerate:" comment pointed. So the pointer
dangled for anyone who cloned the repo, and nobody but me could answer the
first fair question a reviewer would ask about that block of magic numbers:
where did it come from, and how do I know it is right.
scripts/wmm/ WMM.COF + NOAA's 100 official test values (both upstream
and unmodified), the float64 reference, the generator,
verify.py, and a README covering provenance, regeneration
and how to move to WMM2030.
scripts/lua-harness/ the host harness, with run.sh so it is one command.
Neither goes in test/: that is PlatformIO's directory and a harness with a
main.c would be swept into `pio test`. scripts/ already holds this repo's dev
tooling, test_companion_serial.py included.
The block in the app is now genuinely generated rather than hand-pasted:
scripts/wmm/gen_lua.py --update <app> rewrite it
scripts/wmm/gen_lua.py --check <app> fail, with a diff, if it drifted
--check catches coefficients updated without regenerating, or a block edited by
hand. The generator owns the `local declination / do ... end` wrapper too, and
that is the point: the tables are named G/H/GD/HD, gpscompass uses a global H
for the screen height, and an unscoped `local H` silently ate it. Hand-wrapping
is how that happened, so hand-wrapping is now not a step.
Verification, all reproducible from a clean clone:
scripts/wmm/verify.py 100 NOAA values, worst D error 0.005 deg
scripts/lua-harness/run.sh 10 scenarios, incl. the generated Lua in
the device's own LUA_32BITS interpreter --
0.0002 deg vs NOAA, worst tick 12k of 100k
Also refreshes the LUA_APPS.md paragraph, which still advertised the O and F
keys that were removed and quoted harness numbers from before tilt
compensation.
Every contact and waypoint sat about 22 degrees west of where it belonged,
including nodes whose positions were known to be correct. The markers were
never wrong: bearings computed from coordinates are relative to TRUE north,
the magnetometer measures MAGNETIC north, and the app was drawing one against
the other. The gap between them is the local declination.
Builds the real World Magnetic Model into the app rather than a lookup table
or a hand-entered offset: WMM2025 to degree 12, coefficients and code in
4.4 KB, reproducing NOAA's own calculator to 0.0002 degrees at six sites
worldwide -- verified in this interpreter, on the module as it actually ships
in the app file, not on a copy. The secular-variation terms are included, so
it stays exact across its 2025-2030 window instead of drifting a tenth of a
degree a year from a frozen snapshot.
It is evaluated from the GPS fix, or from the node's own stored position when
there is no fix yet, and only re-evaluated once the position has moved 2 km --
about 8k VM instructions, against a 100k budget per tick. The result persists,
so a reboot starts from the last known declination rather than from zero.
Also:
- A ("set north") no longer double-counts. The offset it stores is a residual
on top of the model, but pressed with no position at all it silently
swallows the whole declination; that is now remembered and given back when
the first fix arrives, so the direction the user fixed stays fixed.
- Headings and bearings carry their reference: 013 deg T, or an amber M while
the declination is unknown. The dial's repaint key includes it, or the first
fix would leave it claiming MAGNETIC until the heading happened to move.
- WMM ships a caution model with its coefficients; under 6000 nT of horizontal
field the app now says the heading is unreliable instead of showing a
confident wrong number.
- Diagnostics take the full-width bottom row for the declination and the
position it was computed at, which is how a wrong marker gets traced to a
bad fix rather than a bad model.
Harness: two new scenarios -- the model against NOAA reference values, and the
no-fix alignment path. The latter caught a real one: the device store takes
strings and numbers, and a boolean flag would have thrown on hardware.
Flip (F) and the "Flip" button are gone, and the stored `mirror` setting with
them. Rotate (O) had already gone. Both existed to let a user hand-tune the
sensor handedness back when it was unknown; both frames are measured now, so
the keys could only break a correct compass. A future board with different
mounting gets a measured mapping, not a key it has to be discovered.
The accelerometer has a zero-g offset -- this one reads ~0.08 g high on Y lying
flat, which is 4.6 degrees of tilt that is not there, applied by the tilt
correction against a gravity vector that is wrong by that much, and wrong in
the DEVICE's frame so it moves the heading differently at every heading.
Rotating the device every way already sweeps a 1 g sphere for the
accelerometer alongside the field sphere for the magnetometer, so the same fit
recovers it at no cost to the user: same gesture, one more set of running sums,
and it is only accepted when the sphere it fits is close to the 1 g it must be.
Simulated with a 0.08 g bias injected: recovered to within 0.02 g, and a flat
device then reads 0 degrees of tilt instead of 5.
Diagnostics: rows were wrapping onto each other. They are shorter now, and
while diagnostics are on the four rows take the whole panel width (the key
column is reclaimed) -- the harness fails if any row exceeds what fits. The
status line also reports the vertical field (d+0.42), which is the measurement
that says whether the magnetometer's Z sign -- inferred, never measured -- is
right: north of the equator, held flat, it must be positive.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
Both sensors' 1 Hz debug lines were still on from the axis measurements; off
now (the flags stay, they are how the next board gets measured). Documents the
QMI8658 in M9_PORT.md -- the three attitudes and what each proved, the ADDR_AI
trap, and why tilt compensation was needed at all -- and corrects the
calibration instruction from "tumble it" to "rotate it in one place", which is
the distinction that was corrupting the fit. SDK page gains wada.sys.accel()
and wada.sys.keep_awake().
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
#308 chat_open suppressed the chat's back chevron and cog over an app page, and
the comment right above it says why: otherwise they sit over the page's own
"‹ title" and swallow its back tap. But a settings detail page sets
s_settings_open_cat, not s_apppage_title, so the guard never fired for it.
Opening Settings from inside a chat therefore left TWO back chevrons on the left
of the tall bar, with the chat's clickable cog next to one of them, so a tap
near the wrong chevron went to channel settings or nowhere. Extend the guard.
SDK Test 1.5 (store): uses wada.ui.text_lines() where the firmware has it and
keeps the 1.4 character estimate where it does not, so it lays out correctly on
beta_68 as well as on the next build. It also reports which path it took, since
showing what the board offers is the app's whole job.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A two-axis magnetic heading assumes the device is level. At this latitude the
field dips ~60 degrees, so the vertical component is 1.6x the horizontal one
and tipping the device leaks it into the pair the heading is made from: about
1.5 degrees of heading per degree of tilt. That is what "it drifts" was once
the calibration was sound -- it was the hand holding it.
Driver: variants/thinknode_m9/M9Imu.{h,cpp}, QMI8658 at 0x6B on the peripheral
bus, accelerometer only (the gyro is most of the power budget and nothing here
needs it): +/-2 g at 62.5 Hz with the low-pass on, soft reset with a 160 ms
wait that covers both die variants, and the same idle-suspend as the compass so
it costs nothing when unused. CTRL1's ADDR_AI bit is set and read back -- with
it clear the burst read silently returns six copies of one byte, which looks
like a working sensor reporting nonsense. HAS_M9_IMU -> CAP_IMU ->
wada.sys.accel(), caps().accel.
Axes MEASURED, not guessed, by holding three attitudes and logging:
flat, screen up z = -1.02 -> +Z into the screen (down)
on bottom edge, top up x = +0.97 -> +X at the top edge (forward)
on left edge, right up y = +1.08 -> +Y at the right edge
So the IMU is already in the aerospace body frame, and it agrees with the
magnetometer's independently measured +Z-into-screen. (Meshtastic's M9 driver
passes both sensors through untransformed and mirrors the heading on the sign
of accel Z, so its compass flips when the device is turned over. Not copied.)
Heading now rotates the field back into the horizontal plane using gravity
(NXP AN4248 / ST AN3192) before taking the angle, and reports the tilt angle;
past 55 degrees it says "too steep to read" rather than lying. Calibration
returns to a 3D fit because tilt compensation needs the vertical offset too --
but the instruction is now "turn it every way ON ONE SPOT", and the
accelerometer VERIFIES it: coverage is measured by how far gravity swung, so a
flat spin is refused by name ("turn it nose over tail") instead of silently
fitting a degenerate sphere. Simulated in the harness against a modelled M9 in
a known attitude: offsets recovered exactly, and the heading holds within 3
degrees through 20 degrees of roll and pitch.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two faults, one of them mine from an hour ago.
CAL_SECS is 20 s and the M9's default screen timeout is also 20 s. The tick
pause added in 5a4a5a8 stopped the app the instant the screen blanked, which
is the same instant the calibration was due to finish -- so sampling stopped
half way, the auto-finish (inside on_tick) never ran, and the partial sweep was
fitted and saved without complaint. New wada.sys.keep_awake() holds the screen
AND the tick for a measuring app, and is released when the app closes.
The tumbling instruction was wrong too. Hard-iron calibration assumes the
device ROTATES in a uniform field; carrying it through the air also TRANSLATES
it through the field gradients of a laptop and a desk. Measured consequence: a
centre that moved 0.15 G between sessions against a 0.26 G horizontal signal,
i.e. tens of degrees of direction-dependent error -- which is what "it drifts
when rotating" was. The same device rotated flat in one place fitted a circle
to within 4%, so calibration is now exactly that: a 2D circle fit on x/y, which
is the only thing a heading uses.
And a bad fit can no longer be saved silently, which is how the broken one got
in. calib_solve refuses, with a reason, when the turn is partial (each axis
must span >= 1.4r), when the radius is outside Earth's real horizontal range
(0.08-0.45 G), or when the circle is too distorted (residual > 18% of r --
computed in closed form from the sums already collected). The progress line
shows coverage rather than a countdown.
Also: mag_norm is now the HORIZONTAL magnitude, which lets the app say "Hold it
level" when it departs from the calibrated radius -- the tell-tale for tilt,
which is the accuracy ceiling until the IMU is driven.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* #305 the home Wi-Fi status was decided from WiFi.getMode(), not from what the
user asked for, so anything leaving the radio in STA made it announce
"Starting…" indefinitely with Wi-Fi switched off. Gate on
wifiConfigGetRadioEnabled().
* #304 the Wi-Fi and Bluetooth status strings were raw literals and stayed
English everywhere. Wrapped in TR(). Most already had translations sitting
unused in the language files, so this costs translators nothing for
"Connecting…", "SSID not found", "Off" and "Built-in"; only "Starting…",
"Auth failed", "Link lost" and "Init…" are new keys.
* #257 "Built-in" had two code paths and only the one you do NOT see went
through TR(), which is why it looked inconsistent to the reporter.
* #307 the theme-colour buttons were fixed at 124/84 px, measured against
"Save & restart" / "Reset". Hungarian's "Mentés és újraindítás" ran off both
edges. They size to their label now, in a wrapping row, so an over-long pair
drops to two lines rather than clipping.
* #306 the contact action sheet decided scrollability from a hand-maintained
grid_items tally that the location-sharing button (#266) was never added to.
When the tally is short the body is left unscrollable and the overflow is
unreachable. Decide from the height the buttons actually reached instead, so
the tally can drift harmlessly.
SDK: wada.ui.text_w / text_lines. An app could ask how tall a line is but not
how wide, so anything laying out its own rows had to guess whether a string
would wrap; guessing wrong draws the next row on top. That is exactly what
happened to the SDK Test app (fixed separately as store version 1.4, with
Nearby hardened the same way).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
beta_68 added named timers, so pausing only on_tick left the same problem one
API over: a repeating timer polling a sensor kept running into a dark screen.
One-shots still fire -- those are scheduled app logic, and skipping one would
drop the work rather than defer it, since the slot is released around the call.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
North was wrong on hardware, and the device's own saved state said why. Its
stored centre was (-0.3875, -3.465) where four measured headings put the true
centre at (-0.340, -3.378): out by 0.099 G against a horizontal field of only
0.27 G. Replaying the measured readings through that centre gives -1 deg of
error at north, -12 at east, -18 at south and +22 at west -- errors that swing
with direction, which is a wrong centre, not a wrong formula. align and mirror
were both 0, so the axis mapping was doing its job.
Min/max midpoints only find the centre from a COMPLETE sweep and are skewed by
whichever extreme was missed; with a bias ~7x the field here, a tenth of a
Gauss of skew is a third of the whole signal. Calibration now fits the sphere
that the samples actually lie on -- |p - c|^2 = r^2 is linear in (c, r^2-|c|^2),
so it is a 4x4 solve over running sums with no sample storage, which matters
inside a 256 KB app heap. Coordinates are accumulated relative to the first
sample because Lua here is single-precision and squaring raw values near 3.5 G
throws away the precision the fit depends on.
The fit is checked before it is saved: a singular system (a flat spin, where
the centre is undetermined along z) or a radius outside Earth's 0.25-0.65 G
band is refused with a reason, leaving the previous calibration alone -- the
old code would happily save the garbage. The toast now reports the fitted field
strength, which is the number that says whether a calibration is any good, and
the progress line shows the sample count.
Also adds a D key: the calibration in use, the raw vector, the horizontal
components and the stored align/mirror, so a wrong heading can be read off the
screen instead of guessed at. Harness: the fit recovers a simulated bias
exactly, and a flat-only spin is refused without touching the saved values.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
beta_68 expanded the Lua SDK (map, lists, packet delivery, discovery, private
messages/rooms, native crypto) across the same files as this branch, so four
files conflicted. Nothing was dropped from either side:
- wada.sys.gps(): both widenings merged into one binding. Upstream's
fix_time / lat_e6 / lon_e6 and our speed_kmh / course now share a signature,
and our stricter gate wins -- the call returns nil when the user has GPS
switched off, not just when there is no fix.
- Altitude is upstream's `alt_m` alone. The resolution first carried `alt`
beside it to protect a shipped app, but gpscompass has never been published
to the store (it exists only in this branch and on a bench device), so
carrying a duplicate key into the API forever was the wrong trade: the app
reads alt_m instead.
- sysCaps() carries all twelve feature flags (upstream's seven, our compass,
the four originals) with a matching table hint.
- wada.geo (upstream) and wada.sys.compass (ours) both survive; upstream's
"no board has a magnetometer" note is corrected in the code and on the SDK
page, since the M9 now does.
- hostTeardown frees upstream's new POST payload buffer as well as the fetch
buffer, under the same in-flight guard.
- The catalog keeps all three new apps: upstream's wardrive and nearby, ours
gpscompass (8 total, every referenced file present).
M9, V4, V4-R8, T-Deck and Pager all compile; the Lua host harness passes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every installed Lua app drew the same generic play glyph in the drawer, and
the manifest's "icon" -- promised in LUA_APPS.md since the plan was written --
was never parsed. It is now read from <id>.json and mapped to a glyph by NAME
(gps / radio / chart / game / ...), not by codepoint: a name is reviewable in
a store submission, the device's JSON scanner only takes quoted strings
anyway, and an app can never ship a glyph the UI fonts lack -- anything
unrecognised falls back to the generic symbol. A bare side-loaded .lua has no
manifest and keeps that symbol too.
GPS Compass declares "icon":"gps", so it now shows the location pin the Map
tile uses.
Also corrected deploy/site/sdk.html, whose manifest table documented
"version", "min_api", "description" and "boards" -- the device has never
parsed any of them -- and described icon as "a single character".
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The meter sat too far from the count: the reserve used text_w's
0.55-per-character estimate, which is for mixed text, while the digits and
spaces in "99 sats" run nearer 0.39 of the line height. Sized properly it now
sits beside the number, and still holds still as the count changes.
"A set north" is no longer advertised. The axis mapping is measured, so a
calibrated device points north on its own; A and F remain for an unknown
board or a stubborn environment, they just do not need to be on screen.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The sensor's orientation on the board is documented nowhere, so it was
measured: held flat, logging the raw vector at four headings 90 deg apart
(M9_COMPASS_DEBUG, now off again) gives a hard-iron centre of
(-0.340, -3.378) and, after subtracting it,
N x'=-0.055 y'=+0.310 E x'=+0.268 y'=-0.018
S x'=+0.013 y'=-0.275 W x'=-0.225 y'=-0.016
so atan2(x, y) reads 350/94/177/266 at N/E/S/W -- 0/90/180/270 within a few
degrees, counting up clockwise. +Y is the device's top edge, +X its left.
That is now the default: correct after calibration alone.
This also explains the reversal reported on hardware. The auto-handedness
rule assumed a Z-out-of-screen sensor was the un-mirrored case; it is the
other way round -- held flat north of the magnetic equator, a Z-INTO-screen
sensor reads the downward field as POSITIVE z. Fixed, and the stored
orientation is versioned so values saved against the old formula are
discarded rather than pushing a correct default back off north.
The bias is real and large: ~-3.4 G on Y against a ~0.27 G horizontal
signal, which is why an uncalibrated device barely moves the dial, and why
the range is +/-32 G rather than +/-8 G. Meshtastic's implausible hardcoded
extrema were right after all.
Also: the satellite meter now sits beside the count instead of at the column
edge, and UITask::loop's coarse "ui:gps" stall bucket is split into
timers/threads/input/diag so a 450 ms hitch can be attributed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Up and down move a selection between the ALT and SPD rows (the selected row's
key turns accent), and OK switches that row's units -- feet/miles or metres/km
for altitude, which the target's range follows too, and mph or km/h for speed.
Imperial is the default. On touch boards a tap on the row itself does the same,
while a tap on the dial keeps stepping the target. Both settings persist.
No "enter" handler on purpose: the host answers OK with a synthetic down/up
pair AND an enter key event, so acting on both would switch the units twice
and read as nothing happening.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Lua app page painted itself 0x0E1216 while the rest of the firmware
paints pure black (COLOR_BG), so every app read as a lighter panel floating
over the UI. The page is now black and wada.ui.colors gains `panel` for the
raised surface an app draws on top of it -- the compass dial uses it, so the
instrument stands out instead of the page doing it.
Rotate/flip are gone from the user's side. They existed because the QMC6309's
axis orientation on the M9 is undocumented, which is not the user's problem to
solve by trial and error. `A` now does the whole job: whether the heading runs
clockwise or anticlockwise follows from which way the sensor's Z axis faces,
and that shows in the sign of the vertical field -- Earth's field dips down
north of the magnetic equator and up south of it -- so with a position (a fix,
or the node's last known one) the app reads the handedness off the sensor and
the press only has to set the offset. `F` stays as the fallback for a flat
field or no position at all.
Layout: stats column on the left, dial on the right (its status line above
it, the hint centred along the bottom).
The host harness now tumbles the simulated device during calibration rather
than spinning it flat -- min/max on an axis that never moves subtracts the
true field along it, which is precisely the case the handedness check has to
detect and refuse, and the flat mock was hiding it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Layout, from on-device feedback: the magnetometer/heading-source line moved
from the stats column to a centred line over the dial; the stats panel now
starts at the top of the column, and the rows that freed up went to the
target -- name, range + bearing, how far to turn ("56 deg right", "ahead")
and when the contact was last heard. The key hint is centred along the
bottom edge of the view and spells the actions out ("C calibrate O rotate
F flip <> target"). The heading's DIGITS are centred with the degree sign
hanging off their right edge, so the number does not appear to shift as the
reading crosses 100/200; the width estimate also counts characters rather
than bytes now, which is what put it half a glyph off (the degree sign is
two bytes in UTF-8).
Host: label:width(px) takes an optional alignment ("center"/"right") -- an
app cannot measure glyphs, so this is the only way for it to centre a line
exactly. Also excluded the app ROOT from keyboard-nav focus: excluding only
the body moved the reverse-video highlight up one level instead of removing
it, which is why the page was still white.
sideload_app.py retries fput/fend as well as fadd -- the same UART byte loss
that garbles a long line can garble a short one ("Error: unknown command").
Calibration now reports the measured field strength in its toast, which is
the number that says whether the calibration is any good (Earth: 0.25-0.65 G).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
On the ThinkNode M9 every Lua app opened on a white page: the app body is a
clickable object (touch boards need its press events) on the top layer, so
navCollect harvested it as a leaf focus target and navFocusCb's reverse-video
fill painted it solid under the app's widgets -- the canvas on top stayed
dark, which is what gave it away. NAV_SKIP_FLAG would also hide an app's own
buttons from the d-pad, so this adds NAV_PASSTHRU_FLAG (AppPage.h, shared by
both TUs): clickable, never a target itself, children still collected.
M9 compass: low-pass depth 8 at 50 Hz (the datasheet's 0x61 example) read as
sluggish on the dial; now depth 4 at 100 Hz (CTRL1 0x41, CTRL2 0x30). The
app ticks at 100 ms with lighter smoothing to match.
GPS Compass app rebuilt in the RF Monitor's look: a dial with rings,
10/30/90-degree graduations, red north, a lubber mark and the heading in
the centre; a key/value panel (FIX/LAT/LON/ALT/SPD/TGT) with a 10-cell
satellite meter; compact strings where the column is narrow at large fonts.
Confirmed on the M9 by Chris: the dial turns and calibration holds
(gpscompass.sav persists across reboots).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The ThinkNode M9's microSD is soldered on, so the SDK's "drop it on the card"
route does not exist there and the Store can only fetch from its own host.
Three console commands write into the same /apps (or /lang) the Store uses:
fput /apps/<name> open (truncate); names [A-Za-z0-9._-]
fadd <off> <len> <sum> <base64> append a chunk, every field verified
fend close
Same physical-access trust level as the existing "rm"/"erase", narrower scope
(two directories). DataStore gains a root-aware mkdirRooted(); the CLI line
buffer grows from 80 to 200 bytes.
Why the chunks are self-checking and short: the UART interrupt is not
IRAM-resident, so while the loop is inside a flash-cache pause only the
128-byte hardware FIFO buffers console input and the middle of a longer line
is lost -- observed on the M9 as a 197-char line echoed back as 120. Offset,
decoded length and byte sum reject a damaged line; the host re-sends. The
host script also waits for "[BOOT] ui ready" because the CH34x bridge resets
the board whenever the port is opened and the console is not serviced before
the UI is up.
Verified on the M9: gpscompass.lua (19055 B) + .json pushed, sizes confirmed
by the device and by `ls /apps` after a reboot. M9 and V4-R8 envs compile.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Firmware
- variants/thinknode_m9/M9Compass.{h,cpp}: QMC6309 at 0x7C on the peripheral
bus. Probe by chip id, soft reset with the explicit clear, CTRL2 0x20
(50 Hz, +/-32 G, set/reset on), CTRL1 0x61 (normal, OSR 8/8), read-back
verified. Synchronous read from the Lua host bridge, 1 s cache, lazy
re-probe while the rail-powered part is still in POR, OVFL kept + flagged +
logged. HAS_M9_COMPASS=1 in the env -> new hardware gate CAP_COMPASS.
- src/helpers/WadaNmeaLocationProvider.h: Wadamesh-owned copy of the core
MicroNMEALocationProvider that also exposes RMC speed/course (the core
keeps its parser private; no libdeps patch). M9 target.cpp builds on it,
HAS_GPS_MOTION=1, wadaGpsMotion() for UITask.
- wada.sys.gps(): + alt (m), + speed_kmh/course where the board provides
them (course only while moving -- an empty RMC course parses as 0), and
nil while the user has GPS switched off. wada.sys.compass(): {x,y,z,ovfl}
Gauss, sensor frame, uncalibrated, registered only where CAP_COMPASS;
caps().compass. Calibration, axis mapping and the heading maths live in
the app so they can be adjusted per user without a firmware cut.
- Host: luaHostContactAt reads the RTC once per contacts() walk instead of
once per contact (an I2C transaction each on the M9); pressCb reports
press coordinates in body content space (scroll offset folded in).
App
- deploy/apps/gpscompass/1.0 + apps.json: rotating rose with a fixed index,
live fix readout, bearing/range to a selected contact, magnetometer
heading with hard-iron calibration (C), frame rotate/mirror (O/F, the
M9's sensor orientation is undocumented), GPS-course fallback on every
other board, saturation warning. Not baked into lua_builtin.h on purpose
(CAP_BUILTIN_LUA_APPS also removes the Store > Apps tab).
Verified: M9, V4, V4-R8, T-Deck compile (M9 flash +2 KB); two adversarial
review passes, all confirmed findings fixed; host Lua harness (vendored
Lua, LUA_32BITS) -- calibration recovers a simulated bias exactly, heading
error 0 deg, worst tick ~10k of the 100k budget. Hardware validation list
(axis orientation, bias magnitude, 0x7C ACK) in M9_PORT.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The other half of the request. The previous commit made a scoped message
resolvable to a region NAME; this is the list that decides which names are on
offer. Until now the registry only filled itself from our own default region and
per-channel scope overrides, so a region you can see traffic from but do not
participate in could never be named -- which is most of them.
Settings -> Radio, under the region-scope field, opens "Known regions": a field
plus Add, and a row per region with Remove. Deliberately placed next to the
region we SEND under, because the distinction is the confusing part -- that field
is the region we transmit in, this list is the regions we can RECOGNISE, and a
region only has to be named to be recognised, since its key is SHA256 of that
name. You do not need to be in a region to identify its traffic.
Remove is a RETIRE, not a delete. The slot and name stay bound; the region simply
stops being matched from that point on. Freeing the slot for reuse would hand it
to the next region added and silently relabel every message already received
under the old one -- the exact failure #271 warned about, and the reason slots
were never list indices. Re-adding a retired region revives its original slot, so
its old and new messages stay one region rather than splitting in two.
The persisted slot file gains an active mask and a magic bump (RGS1 -> RGS2). An
RGS1 file still loads, with every assigned slot treated as live, which is what it
meant before the mask existed.
Two failures are reported rather than silently doing nothing: the list being full
(all 14 slot bindings used, retired ones included -- they are permanent), and a
name that canonicalises to nothing, like blank or a bare "#".
Not board-gated -- the page uses the standard app-page chrome, so it inherits the
tall title bar, the back chevron and focus-group navigation on the keyboard-only
boards the same way Blocked users does. Laid out and reasoned about on the T-Deck
for now; the short-panel boards want a look on real glass before anyone calls it
done there. The page title is untranslated, matching every other app page here
(Blocked users, Spectrum, Discover).
All 8 S3 envs build. Four new strings in 13 .lang files, English both sides per
4709c81; the audit's 3 remaining gaps are pre-existing on main from the beta_68
SDK work. Not hardware-tested: adding a region is verifiable on-device, but
whether a message then resolves to it needs real scoped traffic from a second
node.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Message details could only ever say "my region" or "another region". The request
was a user-maintained region list so the scope line resolves to a real name.
The mechanism was already here. transport_codes[0] is HMAC(region key, payload)
truncated to 16 bits -- per-packet, not a region id, which is why the raw hex
changes on every message (#259) and no code-to-name table can work. The only way
to name a region is to recompute the code with a key you hold and compare, while
the packet is still in hand. MyMesh has done that since #259, for exactly one
key: our own default scope. This widens it from one key to N.
Storage and key derivation are MeshCore's RegionMap + TransportKeyStore, which
already ship in every build (helpers/*.cpp is in the library's src filter) and
were simply never instantiated here. A public "#tag" region's key is plain
SHA256("#tag"), which is byte-for-byte what MyMesh::setRegionScope already
derives for our own region, so a name is the whole record.
Two things are deliberately not reused:
RegionMap::findMatch() returns the FIRST match. A 16-bit tag collides at roughly
N/65534 per packet with N keys live, and a confident wrong region name is worse
than none, so the loop here counts every match and reports ambiguity instead of
picking one.
RegionEntry::id is a uint16 that climbs as regions are added, and per-message
storage has 4 bits. So messages record a SLOT (1..14, 15 = ambiguous, 0 =
unknown) assigned once and never renumbered -- deleting or reordering regions
cannot silently relabel old history, which #271 called out specifically.
Those 4 bits are the free top nibble of meta_flags, which is already persisted at
a fixed offset. No record growth, no history version bump, and no segment
migration: messages written before a region was registered read back slot 0,
which is the honest "unknown" state for a message received before we could name
it. Note this fills meta_flags -- bits 0-3 are the existing semantics, 4-7 are
now the slot -- so the next per-message bit does need a record change.
The registry seeds itself: the default region in MyMesh::begin(), per-channel
scope overrides in UITask::begin() (touch prefs, readable only on that side), and
both save paths register on change so naming starts from the next packet rather
than the next boot. No list-management UI yet; that sits cleanly on top.
Scope reads "#denmark", or "ambiguous (several regions matched)", or falls back
to the old my-region / another-region answer when nothing matched.
Known limits, both honest states rather than bugs: private "$region" keys never
match, because TransportKeyStore::loadKeysFor/saveKeysFor are unimplemented stubs
in MeshCore 1.10 -- when the core fills them in, getTransportKeysFor() starts
returning keys and this works unchanged. And codes[1], the reply-region hint, is
still shown under "Scope" as #157 left it; giving it its own line needs a spare
meta bit, which no longer exists.
T-Deck, T-Lora Pager, ThinkNode M9, Heltec V4 and RAK Tap V2 all build. One new
string in 13 .lang files, English both sides per 4709c81; the audit's 3 other
gaps are pre-existing on main from the beta_68 SDK work. Not hardware-tested --
matching is only exercised by real scoped traffic from a second node.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The four capabilities that kept third-party apps a sketch of a built-in one:
* wada.map the firmware's own tiles, projection and cache inside an app
page, with its own capped pool so it never evicts the Map tab's
* wada.ui.list the missing "pick one of N" widget; rows are real buttons, so
keyboard and trackball nav walk them for free
* on_packet each frame delivered once instead of polling a 16-deep ring,
which sampled rather than observed
* wada.mesh.discover the active zero-hop probe, behind its own permission
because it spends every neighbour's airtime, not just ours
Plus the surface those need to be useful: packet identity reported only where
the frame actually carries it, exact micro-degree coordinates (Lua is built
LUA_32BITS, so its floats were quietly costing a metre), altitude and satellite
time, wada.geo, wada.ui.input, named and one-shot timers, http_post, windowed
fs.read, and wada.sys.env on a hardware gate rather than the memory one.
Fixes:
* Both ESP32-P4 targets could not link. g_wifi_last_disc_reason was defined in
src/main.cpp, which the IDF builds never compile, so all nine S3 envs stayed
green while Tanmatsu and T-Display P4 were dead.
* Map zoom level was invisible in +/- buttons mode; the readout was hidden with
the slider it was anchored to.
* Hungarian and Dutch held each other's "No SD card" translation.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Last item on oumike's list. deploy/flasher/ carried two manifests and no pager,
so the board was missing from the standalone install page.
It now has a card per radio variant, both pointing at latest-beta -- the pager is
a beta-feed board, so latest/ is the wrong pointer for it, unlike the two stable
boards already there. Each card names its radio and says so again in the body,
because flashing an LR1121 build onto an SX1262 unit leaves the user with a
device whose radio does not come up, and the two SKUs look identical.
version is set to "beta" rather than a tag. These manifests point at a rolling
feed, so any tag hardcoded here is wrong the moment the next build ships -- which
is what happened to the two existing files, both still claiming beta_1. Worth
fixing those separately or dropping the local copies entirely.
Which leads to the caveat, now written down in deploy/README.md: no deploy script
publishes deploy/flasher/. deploy-site.sh ships deploy/site/ only, and
flasher.wadamesh.com 301s to the apex, so the page users actually reach is
deploy/site/index.html -- which has covered every board for a while. The README
also had the redirect backwards, claiming the apex redirects to the flasher.
So this is parity for a directory that may be dead. Kaj reached the same
conclusion independently in e2d07d8 and left it alone; the files are cheap and
the ticket asked for them, but the real decision is wire it up or delete it, and
that is now stated where someone will find it.
Also refreshes TLORA_PAGER_PORT.md, which still listed the release pipeline as
TODO (both envs have been in release.sh and gen-flasher-meta.py for a while) and
still described CAP_SD/CAP_FILESYSTEM as 0 by design in four places, along with a
deferred-SD list that has almost entirely landed. Only lock-screen wallpaper SD
scanning is genuinely still T-Deck/M9-only. Adds a dated status section at the
top; the milestone narrative below it is kept, since the war stories are why
several non-obvious things are the way they are.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Pager's File Manager only drew an SD row when the card mounted. A card the
board can see but cannot read -- exFAT out of the box, or a damaged FAT --
produced no row at all, so the user could not tell "no card" from "card the
firmware refuses", and had no way to retry short of a reboot.
The Pager is the board that can actually tell those apart: it has a card-detect
line on the expander (XL9555 ch10), which the T-Deck does not. So a detected but
unmounted card now draws greyed as "SD card (unreadable - tap to retry)", and
tapping it clears the mount backoff and retries. An absent card still draws
nothing, which is the existing deliberate behaviour.
The retry is the same single 4 MHz attempt fmSdTryMount() already makes. No retry
ladder, no teardown of the live shared display/radio bus -- both of which that
function's comment forbids on this board, and both of which were learned the hard
way in M7.
In-app formatting stays off for the Pager. That was already true but read as an
oversight, so the reasoning now sits on the format-helper guard: f_mkfs needs an
SD.end() + sdcard_init/uninit lifecycle teardown on that shared bus; with no
touchscreen the T-Deck's hold-to-format gesture maps to holding ENTER, which is
an undiscoverable way to erase a card on a device where ENTER is how you move;
and it has never been run on Pager hardware. Formatting on a computer costs
30 seconds and carries none of that. The Arduino SD library never calls
spi->begin()/end(), so it is probably safe -- "probably" is not enough for a
one-way whole-card erase.
Two new strings, added to all 13 .lang files with English on both sides.
i18n-audit: 954 keys, nothing missing. Pager and T-Deck build.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Gating "Save update bin to SD" on CAP_SD gave it to the Pager, the ThinkNode M9
and the Heltec V4-R8 as well as the T-Deck. The two strings it shows still name
bmorcelli's Launcher -- "For Launcher installs: ..." on the button, and
"Saved: %s\nFlash it from the Launcher." when it finishes -- which is the T-Deck's
update path and means nothing on the other three. Those users get told to open
something that does not exist on their device.
The obvious fix is to reword the strings, and that is the trap: both are
translated in all 13 .lang files, TR() looks rows up by their English text, and
editing the English orphans every translation of them silently. So the T-Deck
keeps its strings byte-identical and the other boards get their own keys, chosen
at compile time. Two new keys, added to all 13 files with English on both sides,
which is this repo's convention for a row awaiting a translator (see 4709c81).
i18n-audit: 13/13 languages, 952 keys, nothing missing. audit-lang: no UNSAFE
format mismatches, which matters here because SDFW_SAVED_FMT is handed to
snprintf as the format string. T-Deck (the #if arm), Pager, M9 and V4-R8 (the
#else arm) all build.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Neither was documented. The Discover app has been in since beta_47 and the user
guide never mentioned it, so the only way to find out what it did was to open it.
The section leads with the thing that makes it different from everything else in
the firmware: Discover ASKS rather than listens. It broadcasts a request that
neighbouring repeaters answer directly, so a reply proves reachability from the
exact spot you are standing, which is also what makes the wardrive data mean
something.
Explicitly separates two names that are one word apart and are not the same
thing: the "Discovered" list in the Contacts overflow is passive, built from
overheard adverts, while the "Discover" app is active. Cross-linked from the
Contacts section where that confusion actually happens, and from the Map section
where the coverage dots show up with no explanation of where they came from.
Covers what a user needs to actually do one: SD card in, wait for a GPS fix, open
Discover and leave it open, then read the map. States the sampling rule (~15 m of
movement, or 20 seconds standing still) so the behaviour is predictable rather
than mysterious, and says plainly that it transmits continuously while open, so
it costs power and airtime and is not something to leave running all day.
Documents the two real limitations rather than leaving them to be discovered: the
map keeps only the newest 160 samples in memory, and the CSV on the card is the
only part that survives a reboot; and there is no on-device viewer for that log,
so reading it back means taking the card to a computer.
Live on wadamesh.com/docs.html.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was no way for anyone but me to put a self-built wadamesh on a Tanmatsu.
The repo carried tanmatsu/build.sh and nothing that could install what it built:
the AppFS writer lived only on my machine, so a fresh clone produced a binary and
no path onto the device. Anyone trying would land on `idf.py flash`, which
replaces the launcher OS.
tanmatsu/tools/ now carries the loop, generalised out of my local copy:
fetch-appfs.sh one-time, clones badge.team's esp32-component-appfs
dump-pristine.sh one-time, dumps YOUR device's AppFS partition as the baseline
tan_deploy.py builds the write-images and works out the changed sectors
tan_flash.sh detects the P4, writes app then metadata, verifies the commit
sermon.py non-resetting serial monitor
Three things had to change before this could work for anybody else. Absolute
paths to my checkout are gone. The hardcoded MAC of MY Tanmatsu is replaced by
detecting whichever port answers as an ESP32-P4, which is also more robust since
the board exposes two ports whose names move between replugs. And tan_flash.sh no
longer invokes tan_deploy.py twice: the second run would have read the metadata
the first had just written and bumped the version an extra time.
appfs.py is deliberately NOT vendored. It is badge.team's and the copy in
circulation has no licence header, so it is cloned instead. dev_appfs.bin is not
committed either: it is 8 MB and device-specific, and using someone else's dump
risks overwriting apps you have, since the deploy writes only what differs from
it. Both are gitignored.
Written up in TANMATSU_SIDELOAD.md and as a page on the site, linked from the
user guide, the homepage resources section and the README. The guide leads with
the model rather than the commands, because the failure that matters is not a
typo, it is not knowing that this board runs wadamesh as an app under a launcher
and that flashing it normally destroys that launcher.
Not re-verified on hardware: the tooling is the same loop that has been deploying
to my Tanmatsu, with the paths and device detection generalised. The first person
to run it on another machine is the real test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pisti87 asked whether the obsolete entries in the language files will be removed
(#262). Measuring it first was the right move, because the tool that would have
driven that cleanup was wrong.
source_keys() only ever matched TR("literal"). Strings reached indirectly, as in
TR(kSettingsCats[c].label), were invisible to it, so every settings-category name
looked unreferenced: About, Backups, Language, MQTT bridge, App permissions and
the rest. Anyone pruning the .lang files on that output would have deleted live
translations for some of the most visible labels in the UI.
The audit now also pulls literals out of any table whose name appears inside a
TR(...) subscript. Deliberately greedy: over-collecting keeps a translation alive,
under-collecting deletes one.
That immediately found a real gap it had been hiding. "App permissions", the
settings category added with the permissions page, was missing from all 13
languages and the old audit reported everything as covered. Added, builtin
regenerated, published as language v15.
Also adds --obsolete, the reverse check pisti87 actually needs: rows in each .lang
file that no TR() key matches. It reports and never deletes, and says plainly that
these are candidates to check rather than a delete list, for the reason above.
Numbers now: 950 keys reachable, hu.lang carries 1044 rows, 94 unreferenced. The
earlier figure was 119, so 25 of those were the false positives just fixed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The page said only "there is an instruction budget: an app that spins forever is
stopped", which is why an app developer had to ask what it actually is. Now there
is a section answering the four questions that were asked: 100,000 Lua VM
instructions per callback, counted as VM OPCODES by Lua's own LUA_MASKCOUNT hook,
re-armed per callback rather than per session, 5x for on_open, and native wada.*
call time does not count against it.
It also says what to do when you hit it, because the honest answer is usually not
"optimise your Lua": a pure-Lua HMAC genuinely exceeds 100,000 instructions and
the fix is wada.crypto, which the section links to. The alternative, splitting a
long job across ticks using state on the app table, is spelled out too.
Audited the whole page against the firmware afterwards rather than trusting that
I had covered everything: every one of the 50 registered wada.* names and all
five lifecycle callbacks now appear on the page. The stale "ext" list in Sandbox
limits also gained send_dm.
Live on wadamesh.com/sdk.html.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pre-release tidy-up, so the same mistake as last week does not repeat: the SDK
page described a lifecycle the firmware never had, and shipping an expanded SDK
against a page describing the old two-permission model would be the same thing
again.
sdk.html now documents wada.mesh.send_dm and wada.mesh.channels, the m.kind field
on on_message, all four permissions with why they are four rather than one, and a
new wada.crypto section including why it exists (pure-Lua HMAC does not fit the
instruction budget) and why it is on every board rather than ext-gated.
i18n: 11 keys were missing across all 13 languages, six from the SDK work and
five from PR #287 which added strings without lang entries. All covered; audit
reports 924 keys with nothing missing, builtin fallback regenerated, published as
language v14 and the store is live.
Also removes an em dash from "Wi-Fi off, new map areas can't download", new in
#287 and not yet translated, so the key could be corrected for free.
All 8 S3 boards build.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pisti87 pointed at a commit of his (dc94f44) with 64 Hungarian rows, including
translations for the strings added over the last few releases.
He put them in src/ui-touch/i18n_builtin.h, which says "DO NOT EDIT" at the top
because it is GENERATED from deploy/apps/lang/*.lang. That turns out to be my
fault rather than his: yesterday I added 29 keys to the .lang files and never
ran gen-lang-builtin.py, so the compiled-in fallback still had the old table and
a device that has not downloaded a pack still showed English. Editing the
generated file was a reasonable guess at where the strings lived.
So: his rows are merged into deploy/apps/lang/hu.lang, the canonical source, and
the builtin is regenerated from it (13 languages, 12514 rows). Both paths agree
again.
Four of his rows would not have compiled - two used a tab where a comma belongs,
and two left the quotes inside the English unescaped, one of which had pasted
"Nincs válasz." into the middle of the English key. Reconstructed rather than
dropped; the intent was unambiguous in each.
A fifth pointed at something real: he translated "Battery life: geathering
data..." and no such key exists. Ours has no typo, but more importantly it was
never wrapped in TR() at all - a bare literal snprintf'd straight into a
user-visible buffer, which the unwrapped-literal scanner does not look at since
it only inspects LVGL setters. It is wrapped now, along with the one literal the
scanner did flag (the UI size dropdown), so the scan is at zero and both strings
are translatable for the first time.
Audit: 916 keys covered in all 13 languages. Published as language v13 and the
store is live.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pisti87 listed the untranslated text he was seeing. Running the audit tool from
#254 found 29 keys missing from all 13 languages, which is a superset of his
list: the position-sharing strings and the note under them, the app-permission
prompts and page, the 1-character spam filter, the map "Max dots" and "%d of %d
on map", the room "Join w/ password" and its escalation prompt, and the new
path-hash hint. Most of them are mine from today.
All 13 are now complete: the audit reports 914 keys covered with nothing
missing. Untranslated rows carry the English on both sides, which is the
existing convention in these files (49 rows already looked like that), so a
translator sees the row and knows what to fill rather than the key simply not
existing.
Hungarian gets the one real translation: pisti87 supplied the telemetry-note
text in the issue, so it ships translated rather than English.
Published as language v12 for every language, and the store is live. Note the
canonical files the audit checks are the FLAT deploy/apps/lang/*.lang; the
numbered directories are published snapshots, so both were updated.
Also removes an em dash from the position-sharing note. It was written before
that rule landed and had no translations yet, so the key could be corrected for
free. The remaining em dashes in older strings are deliberately left alone:
changing those keys would orphan their existing translations in all 13 files,
which is a decision worth making on purpose rather than as a side effect.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It reads "Save FAIL" permanently on the Tanmatsu, so on that board the one
indicator meant to make a sick store impossible to miss was instead crying wolf
at every user on every boot. An indicator that is wrong on a whole board is
worse than no indicator: it trains people to ignore it, and it is the first
thing they ask about.
Removed rather than board-gated. Everything it showed, and more, already lives
on Settings > About > Chat store: the backend in use, the segment count and byte
total, when history last saved, and the exact stage plus errno of a failing
save. The chip was a shortcut to a question most people never need to ask.
Also removes the now-unreachable homeStoreChipText() and the per-tick refresh
that ran on every home-screen frame.
Docs updated in the same commit: the user guide had a whole section explaining
how to read the chip, which would otherwise describe something nobody can see.
Replaced with a pointer to the About panel that holds the real answer.
All 8 S3 boards build.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
These four destinations only existed as the small round buttons floating at the
right edge. People were not finding them, so the user guide, the SDK, the offline
map tiles and the issue tracker were effectively invisible to anyone who did not
already know they were there.
New "Docs, tools and help" section between the install steps and the community
videos, with a card each: what it is, why you would want it, and where it goes.
Same four icons as the buttons, so once you have seen the section the rail reads
as a shortcut to it rather than as unexplained decoration.
Reuses the existing card look; the only new CSS is scoped to .rescard/.rescards.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Community submission, reviewed before publishing rather than taken on trust.
The whole API surface it touches is ui.label / ui.canvas / ui.colors, sys.random,
timer.every(100) and two store keys for the high score. No network, no mesh
transmit, no filesystem, no dynamic code loading (load/loadstring/require/dofile),
no _G, metatable, debug or coroutine tricks, and no long-string or numeric-escape
obfuscation. Persistence is one bounded value. Nothing in it can reach past its
own window, so it needs no permission grant.
It also uses the real lifecycle contract — an app table with on_open/on_tick/
on_input/on_close, returned from the chunk — which is worth noting because the
published SDK page told people to do it a different way until today. He got it
right by reading the shipped apps.
Parse-checked against the firmware's own vendored Lua 5.4.7 rather than a system
interpreter, so the syntax is verified by the same parser the device runs. Not
run on hardware here; pisti87 reports it working on a T-Deck Plus.
deploy-apps.sh now also verifies that every apps.json entry has its
<id>/<ver>/<id>.lua and .json on disk. An entry pointing at a missing file lists
in the Store and then fails to install with nothing on screen to explain why —
the same failure the language check already prevents.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The published SDK page was not merely out of date, it was wrong about the first
thing an app author does. It said to "define the callbacks you need as globals"
and named them on_start / on_stop. The runtime has never called those: an app is
a table you build and `return`, and the fields are on_open(w,h), on_tick,
on_input, on_message and on_close — which is what every app in the Store actually
does. Following the published example produced an app where nothing was ever
called and no error appeared, i.e. a blank page. That cost me an hour today
building SDK Test 1.0, and I changed my app instead of noticing the docs were
wrong; anyone else writing an app hit the same wall with less to go on.
Also corrected against the shipped code: ev.name does not exist (it is ev.key,
with ev.code alongside), and label/button take explicit coordinates rather than
the flow-layout signatures the page listed.
The intro's claim that "API v1 is read-only where the mesh is concerned… it
cannot transmit" stopped being true when wada.mesh.send landed, so it now
describes the actual guarantee: transmitting exists, and it stops and asks the
user by name first.
New material for beta_64: sys.epoch/datetime/beep/caps/battery/gps, the whole
wada.fs section, mesh.send, app.on_message, a Permissions section covering the
two separate grants and Settings > App permissions, and sandbox notes for the
things that actually bite — the ext calls are absent on small boards (check
sys.caps().sdk_ext), and the rate limits return false,"too fast" as a normal
answer rather than throwing.
Every signature here is taken from the registration block in LuaAppHost.cpp or
from SDK Test, which was verified green on hardware — not from the old page.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
My fault, and a good demonstration of why the bench test existed: 1.0 defined
app.start(), which the host never calls. The Lua app contract is on_open(w, h) /
on_input / on_tick / on_close, so 1.0 loaded cleanly and drew an empty screen --
no error, nothing to see.
Also takes the body width from on_open's argument instead of assuming 300 px, so
the rows wrap correctly on a Pager or a Tanmatsu rather than only on a T-Deck.
Shipped as 1.1 in its own directory: version dirs are immutable, and the catalog
bump is what makes a device fetch it at all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A bench tool for the extended Lua SDK, in the store so it can be installed from
the device instead of pulling the SD card -- and so anyone with a supported board
can check the SDK on hardware we do not own.
It reports what it finds rather than asserting silently: capabilities, clock,
battery, GPS, a filesystem round trip, and two things that must FAIL --
a second write inside the rate limit ("REFUSED as designed") and a
'../identity' path ("REJECTED"). A traversal that succeeded would print
"LEAKED - BUG", which is the one line nobody should ever see.
It also has a button that really transmits to Public, so the consent flow can be
walked end to end: first tap refused + prompt, Allow, send, then refused again
inside the 5 s airtime limit. The description says it transmits, in those words,
because a store listing should not surprise anyone with a radio.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>