Commit Graph
130 Commits
Author SHA1 Message Date
Christopher Van HooseandClaude Fable 5 9677dcfe19 Merge upstream beta_68 into the M9 compass / GPS work
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>
2026-08-22 02:43:55 -04:00
Christopher Van HooseandClaude Fable 5 9d477d32df Lua apps: implement the manifest icon; GPS Compass gets the location glyph
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>
2026-08-22 02:30:32 -04:00
Christopher Van HooseandClaude Fable 5 eb4ebe4271 GPS Compass: tighten the satellite meter, drop the now-pointless hint
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>
2026-08-22 02:24:30 -04:00
Christopher Van HooseandClaude Fable 5 4852f06033 GPS Compass: bake the MEASURED M9 axis mapping; no orientation press needed
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>
2026-08-22 02:21:53 -04:00
Christopher Van HooseandClaude Fable 5 34e1b0f04a GPS Compass: per-row units, imperial by default
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>
2026-08-22 01:56:11 -04:00
Christopher Van HooseandClaude Fable 5 d91737bb3f Lua apps on the firmware's black; GPS Compass: one-press north, swapped sides
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>
2026-08-22 01:52:27 -04:00
Christopher Van HooseandClaude Fable 5 44b824ff3d GPS Compass: status over the dial, target detail, centred hints; label align
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>
2026-08-22 01:45:44 -04:00
Christopher Van HooseandClaude Fable 5 cfb083e36f Lua host: keep keypad-nav focus off the app body; GPS Compass dial layout
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>
2026-08-22 01:32:57 -04:00
Christopher Van HooseandClaude Fable 5 0771e15452 Serial sideload for Lua apps: fput/fadd/fend CLI + scripts/sideload_app.py
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>
2026-08-22 01:15:56 -04:00
Christopher Van HooseandClaude Fable 5 b273f20e26 M9 compass (QMC6309) + GPS motion for Lua apps; GPS Compass Store app
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>
2026-08-22 00:53:16 -04:00
Kaj SchittecatandClaude Opus 5 b15d422bdc touch: beta_68 — Lua SDK gains the map, lists, packet delivery and discovery
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>
2026-08-21 21:50:01 +02:00
Kaj SchittecatandClaude Opus 5 a6464e7128 docs: explain Discover and wardriving
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>
2026-08-21 19:42:38 +02:00
Kaj SchittecatandClaude Opus 5 a6c447e7d7 docs + tools: sideloading wadamesh on the Tanmatsu
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>
2026-08-21 19:32:50 +02:00
Kaj SchittecatandClaude Opus 5 25c76a3798 i18n: the audit could not see strings reached through a table, and one was missing
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>
2026-08-21 19:19:21 +02:00
Kaj SchittecatandClaude Opus 5 812a206f3e docs: document the instruction budget, and finish covering the new SDK
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>
2026-08-21 18:53:06 +02:00
Kaj SchittecatandClaude Opus 5 42207bca2e docs + i18n: cover the new SDK surface before it ships
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>
2026-08-21 17:50:37 +02:00
Kaj SchittecatandClaude Opus 5 1c686553e7 i18n: pisti87's Hungarian, plus the compiled-in table I forgot to regenerate (#277)
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>
2026-08-21 16:35:00 +02:00
Kaj Schittecat f9780494d6 chore: refresh Mesh America catalog for beta_65 2026-08-19 16:24:31 +02:00
Kaj SchittecatandClaude Opus 5 4709c8197d i18n: cover the 29 strings that had drifted out of the language files (#277)
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>
2026-08-19 16:21:48 +02:00
Kaj SchittecatandClaude Opus 5 b3d2cea55b ui: remove the chat-store chip from the home screen
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>
2026-08-19 15:41:24 +02:00
Kaj SchittecatandClaude Opus 5 0c2db6d468 site: a real section for docs, SDK, maps and bug reports
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>
2026-08-14 19:35:37 +02:00
Kaj SchittecatandClaude Opus 5 8cc4a2b981 apps: add 2048 to the Store (pisti87, #265)
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>
2026-08-13 20:56:44 +02:00
Kaj SchittecatandClaude Opus 5 ea673f86a5 docs: last on_stop reference -> app.on_close
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 19:19:33 +02:00
Kaj SchittecatandClaude Opus 5 ca45bd4098 docs: correct the Lua SDK page, and document the beta_64 additions
The published SDK page was not merely out of date, it was wrong about the first
thing an app author does. It said to "define the callbacks you need as globals"
and named them on_start / on_stop. The runtime has never called those: an app is
a table you build and `return`, and the fields are on_open(w,h), on_tick,
on_input, on_message and on_close — which is what every app in the Store actually
does. Following the published example produced an app where nothing was ever
called and no error appeared, i.e. a blank page. That cost me an hour today
building SDK Test 1.0, and I changed my app instead of noticing the docs were
wrong; anyone else writing an app hit the same wall with less to go on.

Also corrected against the shipped code: ev.name does not exist (it is ev.key,
with ev.code alongside), and label/button take explicit coordinates rather than
the flow-layout signatures the page listed.

The intro's claim that "API v1 is read-only where the mesh is concerned… it
cannot transmit" stopped being true when wada.mesh.send landed, so it now
describes the actual guarantee: transmitting exists, and it stops and asks the
user by name first.

New material for beta_64: sys.epoch/datetime/beep/caps/battery/gps, the whole
wada.fs section, mesh.send, app.on_message, a Permissions section covering the
two separate grants and Settings > App permissions, and sandbox notes for the
things that actually bite — the ext calls are absent on small boards (check
sys.caps().sdk_ext), and the rate limits return false,"too fast" as a normal
answer rather than throwing.

Every signature here is taken from the registration block in LuaAppHost.cpp or
from SDK Test, which was verified green on hardware — not from the old page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 19:18:51 +02:00
Kaj SchittecatandClaude Opus 5 f6b005e1be apps: SDK Test 1.1 -- fix the entry point (1.0 rendered nothing)
My fault, and a good demonstration of why the bench test existed: 1.0 defined
app.start(), which the host never calls. The Lua app contract is on_open(w, h) /
on_input / on_tick / on_close, so 1.0 loaded cleanly and drew an empty screen --
no error, nothing to see.

Also takes the body width from on_open's argument instead of assuming 300 px, so
the rows wrap correctly on a Pager or a Tanmatsu rather than only on a T-Deck.

Shipped as 1.1 in its own directory: version dirs are immutable, and the catalog
bump is what makes a device fetch it at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 14:20:46 +02:00
Kaj SchittecatandClaude Opus 5 e51db4350e apps: publish "SDK Test" to the store
A bench tool for the extended Lua SDK, in the store so it can be installed from
the device instead of pulling the SD card -- and so anyone with a supported board
can check the SDK on hardware we do not own.

It reports what it finds rather than asserting silently: capabilities, clock,
battery, GPS, a filesystem round trip, and two things that must FAIL --
a second write inside the rate limit ("REFUSED as designed") and a
'../identity' path ("REJECTED"). A traversal that succeeded would print
"LEAKED - BUG", which is the one line nobody should ever see.

It also has a button that really transmits to Public, so the consent flow can be
walked end to end: first tap refused + prompt, Allow, send, then refused again
inside the 5 s airtime limit. The description says it transmits, in those words,
because a store listing should not surprise anyone with a radio.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 14:07:48 +02:00
Kaj SchittecatandClaude Opus 5 dfcc07aafe ui: say that "Overwrite oldest" spares favourites (#178)
The setting already behaves this way — BaseChatMesh's eviction loop skips any
contact carrying the favourite flag, so a starred contact is never the one
dropped when the table fills. The label just never said so, which left people
roaming between regions unable to tell whether starring a contact protected it.

Renamed to the reporter's own wording, "Overwrite oldest non-favorite", and
registered in all 13 language files. No behaviour change.

Reported by mikecarper.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 11:42:27 +02:00
Kaj Schittecat ccfc5c492a chore: refresh Mesh America catalog for beta_63 2026-08-13 11:34:31 +02:00
Kaj SchittecatandClaude Opus 5 b5ed24cddd i18n: translate the Discovered/Contacts sort sheet and the traffic panel (#257)
First pass at pisti87's list. These were never wrapped in TR(), which is why
uploading a translation did nothing for them — there was no key to match.

  Sort by / Sort discovered, and the filter rows Peers, Favorites,
  Has location, Direct (0-hop); the home traffic panel's
  "Traffic (since boot) / Sent / Recv"; and the contact list's "Heard <ago>".

Keys registered in all 13 language files as empty rows. audit-lang.py reports
0 missing and 0 unsafe. The two new format strings are covered by the #258
placeholder guard.

Deliberately NOT done in this pass — the rest of the list needs a check I did
not want to rush:

  - the map option table (Show coordinates, Show tile z/x/y, Tile debug
    overlay, ...) is a static initialiser, so TR() cannot go in the table; it
    has to be applied where the rows are consumed.
  - "Name (A-Z)" / "Recent message" / "Nearest first" are dropdown options.
    Translating an option string breaks any code that maps a selection back by
    comparing text rather than index, and we have shipped that exact bug before
    (channel send matching by name). Each needs its consumer read first.

Reported by pisti87.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 09:58:27 +02:00
Kaj SchittecatandClaude Opus 5 0fe3357dcb i18n + docs follow-up to #263: the new save-chip strings
@oumike's #263 replaces the save chip's floppy icon with words, which is the
right call — two people independently read the icon as ambiguous, and one of
them was the maintainer. Two gaps came with it, both fixed here rather than
blocking the merge:

  - "Saved %s", "Save FAIL x%u" and "Save migrating" were bare English
    literals. The chip previously had no words at all, so it was language
    neutral; as written it would have been permanently English in all 13
    languages and reopened the drift closed in d4ade2e. Wrapped in TR() and
    registered in every .lang file as empty rows. audit-lang.py: 0 missing,
    0 unsafe.
  - the user guide, published yesterday, describes a floppy-disk icon and lists
    the states as bare times. Reworded to match what the firmware now draws —
    docs and UI have to move together or the guide is worse than none.

"Save FAIL x%u" and "Saved %s" carry printf placeholders, so they are covered
by the format-safety guard added for #258: a translation that reorders or drops
them falls back to English instead of misreading the stack.

All 8 S3 envs build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 09:20:28 +02:00
Kaj SchittecatandClaude Opus 5 b5a70f43a5 docs: how to submit an app to the store, and what the safety check is
There was no written path for contributing a Lua app. pisti87 wrote a 2048 game
and ended up asking another contributor to upload it for him, in the comments of
an unrelated issue about map panning — which is the symptom of a missing
procedure, not of anyone doing something wrong.

deploy/apps/README.md is now the canonical process: file layout, the immutable
version-directory rule, the apps.json catalog row, and how to open the PR. It
also says an issue with the .lua attached is an acceptable way in, because
requiring git fluency would cost us apps from people who can clearly write them.

Both this and the site's Apps section state up front that every submission is
reviewed and safety-checked before it is added, and what is actually looked for:
anything touching node identity, keys or channel secrets (the one that gets a
hard no), flash-write patterns that trigger GC and stall both cores, blocking the
shared UI/mesh loop, unbounded memory on a 2 MB V4, and unrequested transmits.
Those criteria come from bugs this firmware has actually shipped, so they are
concrete rather than boilerplate.

Written to be inviting about it — the review exists because apps run on other
people's radios, not to gatekeep, and a rough app that works beats a perfect one
that never gets sent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 11:41:51 +02:00
Kaj SchittecatandClaude Opus 5 d41bcd3c0f i18n: register the three new scope strings, and fix a key that could never match
The #259 popup change introduced TR("Scope "), TR("my region") and
TR("another region"). Two problems, both caught by re-running the audit rather
than by eye:

  - none of the 13 language files had rows for them, which would have reopened
    the drift closed in d4ade2e. Added to all 13 as empty rows (the format's
    own untranslated marker), so translators see the gap and English falls
    through meanwhile.
  - the key was "Scope " with a trailing space. TR() strips icon-glyph prefixes,
    NOT trailing whitespace, so the lookup would never have matched a "Scope"
    row and that label was permanently English in every language. The space now
    lives in the format string where it belongs.

audit-lang.py: 0 missing and 0 unsafe across all 13.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 09:16:38 +02:00
Kaj SchittecatandClaude Opus 5 d192bf53db fix: never trust a translation as a printf format string (#258)
pisti87's T-Deck rebooted every time he logged into a repeater, but only in
Hungarian. The login clock-skew warning does:

  snprintf(msg, n, TR("Device clock differs from \"%s\" by %lu min%s"),
           name, minutes, suffix);

and the Hungarian row reordered the conversions to "%lu ... %s ... %s". Varargs
are positional, so snprintf read the name POINTER as an unsigned long and then
took the minute count -- the integer 3 -- as a char* and dereferenced address 3.
Instant panic, every login, Hungarian only. English fit the declared order, so
it never showed there.

Fixing the four bad Hungarian rows is not sufficient: TR() returns a format
string and translations come from .lang files that users download or hand-write,
so any file can crash any device. TR() now compares the ordered conversion
signatures of key and translation and falls back to the English key on a
mismatch -- the key IS the call site's format string, so it is always correct.
The scan runs only for keys containing '%', which is a small minority.

Also fixed the four rows (three were Hungarian-only crashes or dropped values),
bumped hu to v11, and taught audit-lang.py to fail the build on a mismatch so a
future translation PR cannot reintroduce this. Unit-checked that the audit
detects the original bad row and accepts the repaired one.

Reported by pisti87.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 08:14:05 +02:00
Kaj SchittecatandClaude Opus 5 32725edf11 docs: explain the save chip on the command centre
The floppy-disk icon and time in the traffic card's legend row read as a clock,
and nothing on the page said otherwise. Document what it actually is — when
chat history last reached storage — and all five states, including the -ND
suffix that marks a store which stopped saving and the red FAIL / amber
migrating cases, with the pointer to About -> Chat store for the detail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 21:17:39 +02:00
Kaj SchittecatandClaude Opus 5 c00734cfe8 apps: publish the pending language updates (de v9, nl v9, hu v10)
langs.json had not moved since the v8 publish, but four commits changed
language content after it: pisti87's Hungarian v9, the glyph swap that
followed, the 48 placeholder rows, and pisti87's German, Dutch and Hungarian
translations of those rows. The device asks langs.json for the version, then
fetches the immutable /apps/lang/<ver>/<code>.lang copy — so with the catalog
still reporting v8 for everything, every one of those updates was invisible on
device. That is the "it downloads an old version" report.

Publish the immutable v9 and v10 snapshots and bump the three languages that
changed. The other ten stay at v8: they only gained empty placeholder rows,
which fall back to English either way, so bumping them would cost every device
a download for no visible change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 11:17:46 +02:00
956bc9faeb i18n: German, Dutch and Hungarian translations for the 48 new UI strings (pisti87)
pisti87 translated the entire missing-key set for three languages and posted
them as 143 line comments on d4ade2e rather than a PR, so they were sitting
unmerged. Harvested by mapping each comment's diff position back to the
placeholder row it annotates — all 143 matched an empty row exactly, no
guesswork.

de 48/48, nl 48/48, hu 46/46 (two of his 47 Hungarian comments were variants of
the same string; 'Copy blocked: migration guard unavailable' is still open).
Version headers bumped so devices pick the files up; i18n_builtin.h regenerated.

Co-Authored-By: pisti87 <pisti87@users.noreply.github.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 08:55:27 +02:00
Kaj SchittecatandClaude Opus 5 d4ade2e616 i18n: add the 48 UI strings no language file had, as empty rows for translators
The firmware asked for these keys and no .lang file carried them, so they
rendered English in all 13 languages with nothing to show a translator that
they were missing. Mostly strings that shipped with a feature whose PR did not
touch the language files: the SD arbitration and migration warnings, the
Wi-Fi/BLE coexistence messages, the keyboard-navigation vocabulary
(Up/Down/Left/Right/Select/Scroll) and section names (General, Clock & time,
Sensors, Screen, Home, Messages, Keys, Compact, Snake).

They go in with an EMPTY translation, which is the format's own way of saying
'not translated yet': the runtime loader requires a non-empty value
(`if (*p && tab[1])`) and gen-lang-builtin.py requires `if k and v`, so both
skip these rows and the English falls through exactly as before. Confirmed:
i18n_builtin.h regenerates byte-identical. No `# ver:` bump for the same
reason — device behaviour is unchanged, so there is nothing to re-download.

Also fixes the audit's own row parser to match the loader: a line is a comment
only when it has NO tab. Testing for a leading '#' hid every key starting with
LVGL recolor markup ("#7A7F87 Wardrive: …#"), the exact class the loader has a
comment warning about.

Every language is now at 0 missing keys.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 10:24:56 +02:00
Kaj SchittecatandClaude Fable 5 e33bd41f40 touch: hu v9 — swap the one glyph the fonts lack (U+201E low quote)
Audited every non-ASCII codepoint in the v9 Hungarian file against the
extras-font ranges: full coverage (incl. Hungarian ő/ű, Latin Extended-A)
except a single „ low opening quote used once — swapped for the covered “
rather than regenerating three fonts for one glyph. Add U+201E to the
extras --symbols on the next font regen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 15:20:14 +02:00
Kaj SchittecatandClaude Fable 5 785a2df4e2 touch: Hungarian translation update v9 (pisti87) + lang store no longer downgrades (#247, #232)
hu.lang: 43 improved translations + 21 newly covered strings from pisti87,
applied as ver 9; builtin table regenerated.

Lang store (#247): the update check treated ANY version difference as
'update available' — a side-loaded NEWER file than the catalog got
DOWNGRADED on Update. Now numeric: offer only when the catalog is newer
(non-numeric versions keep the old inequality).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 15:14:48 +02:00
Kaj SchittecatandClaude Opus 4.8 ad6446b312 apps: publish the v8 language files at their immutable path
The Store fetches a language from /apps/lang/<ver>/<code>.lang, not from the
mutable copy at the top of the directory -- so bumping langs.json to v8 without
creating lang/8/ would have offered every device an Update that 404s.

Also normalised the "ver" spacing in langs.json back to the file's own style.
The device parser skips whitespace after the colon so both forms work, but
there is no reason for the file to be inconsistent with itself.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-05 00:54:43 +02:00
Kaj SchittecatandClaude Opus 4.8 dbff43dbd7 beta_59 prep: language files v8, the SDK docs page, and device-status corrections
The audit found three keys the Store work added or renamed since v7 -- Store,
the out-of-memory message and the Languages hint -- untranslated in all 13
languages. Filled, using each language's own word for the Use button so the
hint matches the control it points at, and bumped the files to v8. Every
language is back to full coverage at 856 keys.

New deploy/site/sdk.html: the wada.* reference. The beta ships a public API and
had no user-facing documentation for it, so nobody outside the repo could write
an app. Covers the lifecycle, all seven tables, the app format, side-loading and
how to publish, plus the two constraints that surprise people -- the mesh is
read-only in v1 and http_get is plain HTTP because TLS does not fit in the heap
left after Wi-Fi associates.

Site device list: the Attaky Core and the T-Display P4 are fully supported now
(Kaj's call), so their not-hardware-verified caveats are gone. Dropped the "one
of the two primary development boards" wording, which stopped being true once
the P4 joined the test loop. The terms section named only the T-Deck and Heltec
V4 as flashable when the flasher offers ten boards; it now points at the list on
the page instead of going stale again.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-05 00:37:22 +02:00
Kaj SchittecatandClaude Opus 4.8 cac3a44074 i18n: scan by literal, not by function name — 103 more wrapped incl. Remove channel (v7)
'Remove channel' was built by mk_full(), a local row helper my scanner did
not know about. Listing the functions that take UI text has the same blind
spot as grepping for the strings themselves: the one helper nobody wrote
down keeps its labels English forever.

So the scanner gained a --wide mode that inverts the question — every prose
literal in the file is suspect unless it is consumed by a known NON-UI sink
(logging, strcmp, paths, Lua glue, format-only args). That surfaced the
whole family of local builders: mk_full, mk_btn, mk_label, mk_ta, mk_switch,
setupHeader, setupBtn, fmActionBtn, ccToggle, make_launcher,
setAddChannelError. --fix now wraps prose in ANY argument of those helpers
rather than a hardcoded index, so their differing signatures don't matter.

103 calls wrapped: the channel sheet (Remove channel, Delete chat), every
contact action (Open chat, Ping, Message, Telemetry, Trace SNR, Admin,
Range test, Sightline, Block, Favorite, Reset path), the Profile/Radio
settings rows, auto-add and experimental switches, the add/join channel
menu and its errors, file-manager actions, control-center chips, the
launcher tiles and the whole first-boot wizard. 40 new keys translated in
all 13 languages; units and protocol tokens (MHz, SF, CR, TX, AF, BT, GPS,
DND, Wi-Fi) map to themselves. Files at ver 7, both tools green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 17:58:50 +02:00
Kaj SchittecatandClaude Opus 4.8 15415fd0f5 i18n: enumerate every user-visible literal instead of grepping for them (49 wrapped, v6)
My earlier sweeps searched for strings I could think of, which is why you
kept finding more. scripts/build/i18n-scan-unwrapped.py now parses every
call that takes user text — LVGL setters (label/checkbox/dropdown/roller/
placeholder/tab/table/msgbox), showAlert, showConfirm, appPageBegin,
createSettingsModal and the settings row helpers — splits the argument list
and reports any human-readable literal not wrapped in TR(). It found 55;
49 got wrapped (--fix does it mechanically), 6 skipped as brand/technical
(WADAMESH, CPU, PSK, OK, and two unit readouts). It now reports zero.

The 33 newly reachable keys are translated in all 13 languages: the Flood/
Zero-hop scope buttons, Share QR, Search contacts, Play / Set as
notification sound / Set as wallpaper, Probe now, On map, Install update,
Save update bin to SD, Choose .wav from files, Power, Clear history, the
Bluetooth/MQTT/Logs/System info sheet titles, both dropdown option lists,
the size preset list, Import, the URL bar, and Snake's New game.

Two format bugs behind the last stragglers: keys whose text begins with
LVGL recolor markup ('#7A7F87 Wardrive: ...#') were dropped as comment
lines by BOTH the device loader and the audit — a line is a comment only
when it has no tab, fixed in both. And several rows had been written with
literal '\xE2\x80\xA6' text where the file needs the real character; the
loader only unescapes \n \t \\, so those keys never matched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 17:46:00 +02:00
Kaj SchittecatandClaude Opus 4.8 7a034d303a lang: fix the download for real (PSRAM->SD + short reads), and say why before restarting
Three real bugs behind the endless 'Download failed', found by putting a
diagnostic on the device instead of guessing:

1. HTTP read quit early. The loop treated a gap in the stream as
   end-of-data, so a 49 KB file came back as 16 KB (n=16492 measured) and,
   worse, that truncated buffer was returned as success. It now reads to
   Content-Length, gives stalls a 4 s progress grace, and FAILS on a short
   read instead of handing back half a file.
2. SD writes from PSRAM died after the first 4 KB (wr=4096, measured twice).
   Writes now go through a small internal-RAM bounce buffer, which is what
   every proven-good SD writer in this firmware already does. Full 49344
   bytes written and installed, confirmed on the bench.
3. A truncated write still stamped the new '# ver:' (it is the file's FIRST
   line) while the appended rows were lost — so the tab claimed v4, offered
   no Update, and showed English. Downloads are atomic now: temp file,
   verify on-card size, then rename over the target. A failure leaves the
   old file and its old version untouched.

Plus: if a /lang write fails, an identical control write to /apps proves
whether the card is healthy, and if so /lang is rebuilt and the install
retried — self-healing the FAT damage that mid-write resets can leave.

Language changes now show 'Restarting to apply the language...' and reboot
on a timer, so the notice actually paints. showAlert() followed immediately
by rebootDevice() never rendered a frame — the device just went dark under
your finger. Applies to Update, Use, and the built-in picker alike.

The boot self-heal no longer toasts its failures (that was the 'download
failed' that greeted you when merely opening the store). Files at ver 5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 17:28:06 +02:00
Kaj SchittecatandClaude Opus 4.8 535471613d lang: chunked SD write fixes the false 'Download failed', audit sees glyph-prefixed TR, 16 more keys (v4)
The update download was actually succeeding: one 48 KB f.write on FAT can
return a short count without error, so the file landed while the code
reported failure — which also suppressed the reboot-to-apply (Kaj saw the
new version only after re-entering the tab and switching languages by
hand). The write is chunked with a real completion check now, and
s_langdl_ok is volatile.

The audit's TR() extractor required the argument to start with a quote, so
every TR(LV_SYMBOL_X "  text") call — all the sheet rows wrapped in the
previous commit — was invisible to it, and 'full coverage' was reported
while 16 keys had no rows anywhere: the chat/channel sheet actions (Mark
as read, Share secret, Region & scope, Chat icon, Delete history, Log in
again, Reset path), contacts overflow (Search, Auto-add settings, Blocked
list), URL menu (Open in web, Create QR), power menu (Power off, Download
mode), plus '(device behind)' and 'Copy internal data to SD'. The regex
now accepts the macro prefix and strips glyphs exactly like runtime TR().
All 16 translated in all 13 languages; files at ver 4, audit green at 731
keys per file.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 16:48:50 +02:00
Kaj SchittecatandClaude Opus 4.8 6a5738946f i18n: wrap the bare-literal sheets and buttons — months-old translations finally render
The channel/chat long-press sheets (Mark as read, Share secret, Region &
scope, Chat icon, Delete history, Log in again, Reset path, Blocked users),
the contacts overflow menu, the URL menu, the power menu rows, five confirm
buttons and four page labels were built from glyph-concatenated string
literals that never passed through TR() — so their translations, which have
existed in the table (now the .lang files) for months, never rendered. All
wrapped now; TR() strips the glyph prefix and rebuilds the label, which is
exactly the machinery it grew for. Only five keys were genuinely new
(Install, Map, Save report, Set, Stop) — translated in all 13 files, bumped
to ver 3 (immutable /apps/lang/3/), audit fully green.

Also fixes the audit appender's substring presence check: '© OpenStreetMap'
contains 'Map', which silently skipped the new Map row in every file.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 16:41:59 +02:00
Kaj SchittecatandClaude Opus 4.8 7ec5e92414 lang: language files are versioned like apps — Update button, immutable URLs
Each .lang carries a '# ver:' header (missing = v1) and langs.json carries
the catalog ver. An installed file whose ver differs from the catalog shows
a blue Update on its row (sub line shows 'code.lang vN'); updating the
ACTIVE language reboots on success to apply the fresh overlay, updating an
inactive one just refreshes the row. Downloads go to the immutable
/apps/lang/<ver>/<code>.lang path so the day-long edge cache can never
serve a stale file after a translation bump — same lesson as the flasher's
mutable-latest cache bite. The boot self-heal resolves the ver from the
catalog on the worker (flat-path fallback) and never reboots on its own.
Canonical files bumped to ver 2 (the full-coverage sweep).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 16:28:42 +02:00
Kaj SchittecatandClaude Opus 4.8 911d665be5 i18n: the baked-in translation table is gone — languages are files, full sweep included
The 727-row / 13-column kI18n table no longer compiles into the firmware.
TR() now consults only the loaded .lang overlay (bsearch) and falls back to
English. deploy/apps/lang/*.lang is the canonical translation source —
translators edit those files and PR them; scripts/build/i18n-audit.py
(replacing the retired generator) verifies every TR() key in the source is
covered by every language file, byte-accurate against C escapes. Reclaims
~400 KB of flash on every board: T-Deck 85.1->75.3%, V4 81.4->71.6%,
Pager 87.7->77.8%.

The sweep: 94 keys had no translations at all — everything Remote/web
access, the P4 antenna dialogs, history-limit texts, DND, keyboard-light
toasts, GPS/clock states and all the new Store/Language strings. All are
translated in all 13 languages now; the audit reports full coverage (707
keys per file).

Migration: a built-in language picked before this claims its file code at
boot; if the file is missing the download self-heals in the background
(that boot runs English, the next applies it) and retries every boot until
it lands. Wi-Fi-less devices stay English until they get one chance to
fetch — the honest cost of un-baking 400 KB.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 16:21:18 +02:00
Kaj SchittecatandClaude Opus 4.8 f3f421398c store: three tabs (Apps / Built-in / Languages) + languages as downloadable files
The Lua Store is now a segmented three-tab page. Apps keeps the catalog and
sideloaded scripts; Built-in holds the show/hide switches; Languages is new.
Opening is instant: the card re-listing that ran synchronously on open now
runs on the net worker (request/done flags like the catalog), and the app
cache pre-warms at boot. Card text no longer runs under the action button —
title and description both stop at the button column.

Languages as files: every translation column of the i18n table exports to a
.lang file (scripts/build/gen-lang-files.py -> apps/lang/<code>.lang on the
firmware host + langs.json catalog). The device downloads them from the
Languages tab into <data>/lang/, and at boot the active file loads into
PSRAM and overlays TR() lookups via binary search — the file wins, then the
built-in ui_lang column, then English, so a partial or hand-edited file
degrades gracefully. The file's '# base:' header names that fallback column.
Users can drop their own .lang files on the card (or edit a downloaded one)
for languages the firmware never shipped; switching reboots to apply, same
as the Settings picker (which now also clears the file overlay). Prefs v48
appends the active file code, trailing per the schema law.

This is stage one of un-baking translations from the image: the mechanism,
catalog and files ship now while the built-in table stays as the fallback;
the flash win lands when the table columns are dropped after field proof.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 15:40:35 +02:00
Kaj SchittecatandClaude Opus 4.8 2791a1aa12 lua: drop the two invented apps — the catalog is conversions of existing apps only
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 14:01:01 +02:00