177 Commits
Author SHA1 Message Date
Kaj SchittecatandClaude Opus 5 2ee888c2c0 Lua SDK: the T-Deck's missing keys, its trackball click, and send repeats
Three things Jade reported, the first of which is blocking an app submission.

Missing keys on the T-Deck. A Lua app there never received A, P, Q, Enter or
Backspace. The rule that withholds them exists so an app can never swallow its
own exit, which is right on a board where a reserved key IS the only way out.
On the T-Deck it is not: an app page is closed by tapping the back bar, and the
app host is not in the popup registry, so those keys closed nothing while an app
was running. They were simply dead inside every app. They are now forwarded.
The M9 keeps its reservation, because its reserved keys are sentinel bytes for
Back and Home that are never typed text and it has no touch to tap out with, and
the Pager already reserved nothing. That is why this was T-Deck-only.

The trackball click. With an app open the trackball branch forwarded motion and
explicitly discarded the button, so an app could be steered but never clicked.
It now delivers the same synthetic centre tap the touchless boards' select key
sends, on the press edge: the flag is a per-frame "held" level, so forwarding it
raw would have fired a tap every frame the button stayed down.

Repeat counts. The firmware already counts repeaters heard rebroadcasting an
outgoing flood, keyed on a fingerprint taken at transmit time; it is the refresh
glyph on a sent bubble. wada.mesh.send() now returns that fingerprint as its
second value on success, and wada.mesh.repeats(fp) reads the count back. Additive,
so an app reading only the first return is unaffected. The count grows as repeats
arrive, so it is polled rather than read once, and a direct message has none
because it is not flooded. Documented on the SDK page.

Built on all nine S3 envs and both ESP32-P4 targets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 09:28:19 +02:00
Kaj SchittecatandClaude Opus 5 2a8b62c6a7 Merge PR #375: Day and Night themes (#296)
oumike. A firmware-wide appearance setting: Night stays the default and renders
exactly as before, Day is a low-glare light palette with its own semantic roles
for text, panels, fields, borders, controls, focus, charts, status colours and
chat surfaces. Selecting a mode restarts, so every LVGL object is rebuilt from
one coherent palette rather than half-repainted. There is a Night/Day selector
in Settings, Display, the Control Center Theme chip is now a direct toggle whose
sun/moon icon shows the active mode, and the standalone console UI has a
matching Day palette.

Main already carried the palette scaffolding for this, pinned to Night behind a
comment saying it was waiting on this branch, so the merge mostly replaces those
stubs with the real thing.

The prefs schema needed care. The branch appended theme_mode as v54, but v54 and
v55 were taken by boot_wifi_time/boot_wifi_open and loud_alerts before this
merged, so the field moves to the tail behind them and the version becomes v56,
with the trailing-field assert and the migration step moved to match. A packed
struct read back at the wrong offsets is the failure this schema's asserts exist
to prevent, so the invariants from both sides are kept rather than one replacing
the other.

That also surfaced a stale test: the v53 case asserted that v53 plus exactly two
bytes was the whole struct, which stopped being true when loud_alerts landed and
was not caught because the host test was not run then. It now counts every byte
appended since and asserts the new fields come back at their defaults. The
schema host tests pass. While there, the size comment said "about 500 bytes";
the struct is 114.

Built on all nine S3 envs and both ESP32-P4 targets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 09:11:54 +02:00
Kaj SchittecatandClaude Opus 5 12384c97b7 M9 panel sleep, Discovered hop counts, and hiding dead keyboard controls
The M9's screen was never really going off (#390, Buko84, who spotted the lock
screen still faintly visible in bright light). Backlight off is not screen off:
the ST7789 keeps refreshing the same static image behind a dark backlight, which
is exactly what retains an image into the glass. The panel-sleep command the
T-Deck and V4 use was a no-op stub on this board and the M9's backlight path
never called it anyway. Both halves are fixed. The M9 takes the display driver's
default constructor branch, on the global SPI instance it shares with the radio
and with no PIN_TFT_SCL/SDA defined, so the command goes over that same bus
object inside a transaction rather than a second one on pins this board does not
declare.

The Discovered list showed impossible hop counts (#394, jesshampshire). 0xFF is
OUT_PATH_UNKNOWN, meaning an advert arrived by flood with no known path, and it
was being printed as a number: "255 hop". It now says the path is unknown, says
"direct" for zero hops, and pluralises, so a two-hop node no longer reads as
"2 hop".

Keyboard backlight controls are hidden on the older T-Decks (#382,
jesshampshire), which have no controllable backlight, so those controls did
nothing while still looking like settings. The "Older keyboard protocol" switch
added in beta_74 already declares which keyboard is fitted, so it gates these
too rather than asking the same question twice. The other half of that request,
swallowing the backlight key combo so it does not type a stray character, needs
to know which character actually appears; asked on the issue.

Also removed a duplicated pair of declarations in the prefs header.

Built on all nine S3 envs and both ESP32-P4 targets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 01:09:53 +02:00
Kaj SchittecatandClaude Opus 5 3b9c2298ba Louder notification chime on buzzer boards (#388)
Buko84 asked whether the M9's notification sounds can be made louder. There is
no volume setting because there is nothing to turn: the buzzer on these boards
is a bare piezo on a GPIO driven by tone(), a fixed-duty square wave, so
amplitude is simply whatever the part does at that pitch.

Frequency is the lever that does exist. A piezo is far louder near its mechanical
resonance, typically around 4 kHz, than at the 1 to 2.6 kHz the chime uses, so
"Loud alerts" in Settings, Sound plays the same three-note shape shifted into
that band. Same chime, so it still reads as the same alert rather than a new one,
and toggling it plays it so the difference is audible while the switch is still
under your finger.

Off by default and opt-in, because the exact resonant peak varies by part and
this has not been measured on an M9. Schema v55, field appended at the tail.

Built on all nine S3 envs and both ESP32-P4 targets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 00:52:38 +02:00
Kaj SchittecatandClaude Opus 5 102c239c69 Four reported issues: P4 Wi-Fi on after power cycle, plus three UI fixes
Wi-Fi came back on after a power cycle on the T-Display P4 while every switch
in the UI read off, and the radio really was on: it downloaded a map tile
(#373, wb6zsu, with photographs of both states). The C6 runs its own AT
firmware with auto-connect enabled, so it rejoins the last access point by
itself at power-on, before the firmware ever asks it to. The loop that enforces
the radio pref only acts on a change, and on the first pass it recorded the
state instead of applying it, so "off" never became a transition and the join
the C6 made on its own was never torn down. That is also why toggling Wi-Fi on
and then off fixed it: that made a transition. The state is now applied on the
first pass, once the AT link is actually up. The Tanmatsu carried the same
first-pass logic and got the same fix.

The map's own location marker is a bare white glyph and vanishes on light
basemaps (#366, 100monkeys). The contact markers a few lines below it already
carry a dark border for exactly this reason and self never got one, so it now
gets the same: a dark chip behind the glyph, which reads on any basemap without
spending a colour that already means something else.

Wi-Fi passwords can be revealed while typing (#381, jesshampshire). Entering a
PSK blind on a device keyboard is a real failure mode: the reporter needed about
ten attempts to join their own network. The toggle sits on the password label's
line so it costs no vertical space, and it switches the keyboard mirror too, or
the characters stay masked in the one field being looked at.

The composer shows a character count (#350, jrote1). The 160-character cap was
silent: typing just stops, which reads as a broken keyboard. It appears only in
the last quarter and marks the limit when reached, so an ordinary short message
carries no readout. Counted in codepoints to match how the cap is enforced;
by bytes an emoji would read as four characters.

Built on all nine S3 envs and both ESP32-P4 targets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 00:16:39 +02:00
Kaj SchittecatandClaude Opus 5 92b2ed12c9 Ship the Wio Tracker L2: release plumbing, not just the build
The PR added the board and its env, which makes it build. It does not make it
ship. Three lists decide that and none of them knew about it: release.sh ENVS
(what gets built and named into a release), gen-flasher-meta.py BOARDS (which
manifests the web flasher gets) and the flasher page itself, which lists boards
by hand.

Without these the board compiles cleanly forever and never appears in a release
or on the flasher, which looks like nothing is wrong.

Named wadamesh-wio-tracker-l2, its own Seeed Studio section on the flasher,
marked beta-channel-only like the Attaky was on arrival, since stable is still
beta_65 and this board has never been in a stable build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 23:59:06 +02:00
Kaj SchittecatandClaude Opus 5 c625d38244 Merge #384 (DM timestamps), #385 (M9 battery), #386 (hardware RTC), and fix the P4 link
oumike's #384 closes #374: MeshCore seeds _txt_last_ts from an unrestricted
random uint32 and sendMessage() then raises the real RTC timestamp above it, so
outbound direct messages carried future dates on most boots. Patched in the
vendored core, fail-closed and idempotent.

#386 closes #383: hardware RTC for the Pager's PCF85063A and the M9's PCF8563
with validation on both sides, time retained through a full power-off, and an
opt-in cold-boot Wi-Fi time sync. Prefs schema to v54, appended at the tail with
the assert updated.

cvhviz's #385 makes the M9's battery saver reachable at all (the machinery was
wired but the only enable switch was compiled for two other boards), fixes
GPS-off leakage, and enlarges the GPS UART ring mid-session.

Two build problems, neither visible to the authors:

  - #384 wires its core patch into the IDF builds as a fail-closed VERIFY, and
    the vendored copy in this checkout predated it. Applying the same patch step
    fixes it; the error already said to run fetch-deps.sh, which is the right
    design.
  - #385 calls gpsEnsureBigRxRing() from UITask.cpp and MyMesh.cpp, which every
    target compiles, while the definition is in src/main.cpp, which the two
    ESP32-P4 targets never compile. Both P4 builds failed to link. A no-op
    definition for those boards lives in UITask.cpp now, which is the standing
    rule for anything shared between the UI and the Arduino main. A no-op is
    honest here: neither P4 drives GPS through Serial1.

Built on all eight S3 envs and both ESP32-P4 targets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 23:28:37 +02:00
Kaj SchittecatandClaude Opus 5 d33e5dec31 Merge cvhviz's M9 fix series (#354, #357, #358, #359, #360, #365)
The one that matters most is #354: tileCacheRemove() was written for the
firmware's own LittleFS cache but resolves through s_tile_fs, which is
re-pointed at the SD card root on every board that caches tiles there. So it
was calling SD.remove() on the user's own offline map pack, permanently, for
every queued tile whose .jpg happened to be absent. Gated to the internal
partition now.

The rest: remount through the full ladder after an in-device format (#359),
live labels no longer overlap the rows beneath (#357), hidden and inert widgets
stay out of the d-pad focus group (#358), keypress work is attributed in the
stall ring (#360), and GPS acquisition is measured from when it started rather
than when you looked (#365).

Built on all eight S3 envs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 10:35:04 +02:00
Michael A. Cojocari 952075a22e Day/Night 2026-08-31 09:37:45 -04:00
Kaj SchittecatandClaude Opus 5 41c7b94a1d keyboard: a way out of a wrong protocol guess that does not need the keyboard (#351)
Vybo reports no change in beta_73. The runtime demotion I added is not firing on
his device, and I still do not have the one serial line that would say why. So
rather than guess a third time, give him a switch.

Settings > Keyboard > "Older keyboard protocol" skips raw detection entirely and
uses the protocol every T-Deck controller understands. The point is that it is
reachable BY TOUCH: when detection guesses wrong the keyboard types the wrong
letters, so any remedy that needs the keyboard is not a remedy. It applies
immediately, without a reboot, because the driver re-reads the flag every poll
and will drop out of raw mode on the spot.

The cost of turning it on is modifier latching, which a controller that needs
this switch cannot do anyway.

Schema goes to v53, appended at the tail with the existing static_assert
updated to point at the new last field.

This does not replace finding the real cause; it stops people being stuck while
I do. Automatic detection is unchanged and still the default.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 18:36:42 +02:00
Kaj SchittecatandClaude Opus 5 fff611692f touch: show the hop hash beside the repeater name in a route (#348, #347)
jrote1: a route lists resolved repeater names, and when a name is wrong or two
repeaters share one there is nothing left to identify the hop by. The hash is
what the node is actually keyed by on the wire, so it is the part that stays
true exactly when you are reading a route to work out where a message went.

Each hop now reads "3A Repeater Name" rather than just the name. The unresolved
case is unchanged, since it was already showing the hash.

Also pisti87's two corrections (#347): "Known regions" had never been
translated, and the Bluetooth pairing hint gets his shorter wording. Languages
to v21.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 17:37:24 +02:00
Kaj SchittecatandClaude Opus 5 afecfc0224 i18n: two Hungarian strings pisti87 asked for (#340)
"Mark read" was an untranslated placeholder. "Join the public channel" had the
long form, which he wants shortened to fit the button.

His text for the second reads "nyílvános"; the file already uses "nyilvános"
everywhere else, so this follows the file rather than introducing a second
spelling. Easy to change back if the long i was deliberate.

The other line numbers in that issue are from his own build and no longer point
at what he means, so they need his source rather than mine to resolve.

Languages go to v20.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 09:38:22 +02:00
Michael A. CojocariandClaude Opus 5 45447caa7f touch: hand a contact to the node next to you (#321)
The contact action sheet could message, ping, telemetry, range-test, favorite,
share location, reset path, block and delete — but not hand the contact itself
to anyone. The plumbing was already there and only the companion protocol could
reach it: shareContactZeroHop() has backed CMD_SHARE_CONTACT all along.

So this is a button and a wrapper. uiShareContact() sits next to
uiResetContactPath() and looks the contact up in the live table the same way;
actionSheetShareContactCb mirrors actionSheetResetPathCb exactly — fetch by
index, close the sheet before acting, toast the result. No refreshContactsList():
unlike Reset path and Delete this does not touch the contact table, it only puts
a packet on the air.

Zero-hop, deliberately. Flooding a third party's advert spends the whole mesh's
airtime on a packet nobody asked for, and it re-advertises someone else's node
well beyond the room you are standing in. Zero-hop is what the companion command
already does and keeps the action to "hand this to whoever is next to me". A
flood variant is a different airtime trade-off and does not ride along with this.

Gated on !from_map like Share my loc and Block, so the map-marker sheet stays
compact, and placed after Reset path so Block and the full-width Delete keep the
bottom of the sheet.

grid_items goes 6 -> 7 for the non-from_map case. That is housekeeping, not the
fix — as the #306 note at the bottom of the function says, scrollability is
decided from the height the buttons actually reached, and that is untouched. The
#266 Share my loc row is still missing from the same tally; fixing it here would
widen the diff into behaviour this issue put out of scope.

Three new strings across the thirteen language files (Contact gone already
existed), inserted in sorted position and translated in each file's own style for
its neighbours — Contact added for the success toast, Send failed / Save failed
for the failure one, Share QR / Share channel for the button. i18n_builtin.h is
regenerated, not hand-edited; it costs ~2 KB of .rodata, which the V4 feels most.

Audit clean on all thirteen (1034 -> 1037 keys). T-Deck and Heltec V4 TFT build.
Not yet checked on hardware: a second node in direct range actually adding the
contact, and the smallest panel at the largest UI scale.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 15:24:32 -04:00
Kaj SchittecatandClaude Opus 5 799756cd94 i18n: the signal card's own strings were never wrapped
pisti87 pointed at UITask.cpp:22021 twice, and he was right both times. sigCell()
does not translate its argument, so "Signal", "Signal (stale)" and "nothing heard
yet" were raw at the call site and the card read English in every language.

Easy to miss because the home-graph signal popup has its own TR()'d copies of the
same three strings, so the text is translated in one place and not the other. It
also explains why "nothing heard yet" matched no key when his translation for it
did not appear: there was no key, only a literal.

Hungarian supplied by him.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 17:41:31 +02:00
Kaj SchittecatandClaude Opus 5 f49a950e30 lua: wada.ui.clear(), so an app can have more than one screen
The SDK documented building widgets in on_open() and gave no way to take them
down, so an app with a second screen drew it on top of the first. pisti87 hit
this building a multi-screen app and had been working around it with buttons;
he also noted that ui.list() has clear() while the page it sits on does not.

The reason this is not just lv_obj_clean() is the handles. WidgetUd holds a raw
lv_obj_t*, guarded only by `if (u->obj)`, so a Lua variable still referring to a
label from the screen just cleared would sail past that check into freed memory.
Every handle now records the generation it was created in, clear() bumps the
generation, and the accessors null the pointer of any handle from an older one.
The null guard that every method already has then turns a stale call into a
no-op. Given the day this codebase has had with use-after-frees, a clear() that
invited one would not have been worth shipping.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 13:02:15 +02:00
Kaj SchittecatandClaude Opus 5 80b9fd1693 lua: let an app reach all its contacts, and write a bigger app
Both reported by Jade, and both were arbitrary caps rather than real limits.

wada.mesh.contacts() scanned the first 200 contacts, returned at most 100, and
said nothing about having truncated. On a device holding 2000 contacts most of
them were simply unreachable. It takes an offset and a limit now, defaulting to
the old 100 and capped at 250 per call, because every entry is an eight-field
Lua table and building thousands in one go would exhaust the per-app heap. The
window moves, so everything is reachable by asking more than once, and the new
wada.mesh.contact_count() means an app can page without probing for the end.

The app source limit was 64 KB, which an ordinary program can reach. It is
192 KB now. The source sits in PSRAM only until luaL_loadbuffer has compiled it,
so this is a transient allocation, not a per-app cost, and the compiled chunk
still has to fit the 256 KB app heap. The Store's download buffer was the other
half of that ceiling and moves with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 12:23:16 +02:00
Kaj SchittecatandClaude Opus 5 2475cba5e7 store: do not offer an app the board cannot run
beta_69 published Wardrive and Nearby. Both need the extended SDK, and the
Heltec V4 does not have it, so the most common board in the mesh showed a
prominent Get button for two apps that download, install, open, and then refuse
to do anything. matthewjk reported exactly that on Discord and had to be told it
was a hardware limit.

An app can now declare `"requires"` in the catalog. The Store greys the button,
labels it N/A and says so on the row, rather than letting someone spend a slow
download to find out. Wardrive and Nearby declare sdk_ext.

An unknown requirement allows the install: a newer catalog must not disable apps
on firmware that has never heard of the capability name, which would be exactly
the wrong way round.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 10:54:24 +02:00
Kaj Schittecat 65ea09ea89 Merge PR #316: WAV/MP3 playback for Lua apps
oumike. Closes #315 (pisti87's request).

Two conflicts, both resolved by keeping BOTH sides rather than choosing:

  - sdRuntimeLifecycleBusy() gained an audio-playback source here and a web
    reader source in #317. They are independent consumers of the same card and
    both have to gate the mount lifecycle. The reader's self-exclusion is kept:
    it calls this from its own task while holding the card and would otherwise
    deadlock against itself.
  - The Lua harness caps table needed sd_list from #312 as well as the audio
    flags, and the test order needed the wardrive suite from #324 as well as
    audio_api.

Built on all eight S3 envs and both ESP32-P4 targets.
2026-08-27 10:49:59 +02:00
Kaj Schittecat 159d5bb709 Merge PR #324 2026-08-27 10:23:29 +02:00
Kaj SchittecatandClaude Opus 5 cecede23be i18n: undo the keys my extractor invented, and stop it inventing more
pisti87 read v18 and called it chaos. He was right, and it was mine.

Two defects in the extractor I added yesterday:

  - It joined every string literal in a call argument, so a ternary became one
    key. `cut ? "Paste (move)" : "Paste (copy)"` shipped as the single key
    "Paste (move)Paste (copy)", and with it "Unblock  Block",
    "Unfav  Favorite", "Other networksNetworks", "Batteryactivityon" and
    "Stop sharing loc  Share my loc". None of those strings exist anywhere in
    the firmware. Literals are now grouped only when genuinely adjacent, which
    is what the compiler concatenates, so both branches become their own key.

  - It scanned raw source, comments included. That is how "Geblokkeerde
    gebruikers" -- Dutch, appearing only inside a comment about how a long
    translation degrades -- became a KEY in the Hungarian file. Comments are
    stripped now, string literals preserved.

202 invented rows removed across the thirteen files. Only rows that were both
unknown to the extractor AND still untranslated were touched, so no
translator's work could be lost either way.

Also his: the curly quotes in the Hungarian credits render as boxes because no
bundled font carries U+201E/U+201D. Five values de-curled. And five strings he
found raw are wrapped: the Wi-Fi rescan and hidden-network rows, the update
check, and the downgrade prompt. The two Wi-Fi rows build their label at
runtime, since TR() returns a pointer and cannot join a glyph literal at
compile time.

Languages go to v19. v18 is deleted rather than left behind: it was only ever
correct for about a day and everything in it is superseded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 10:23:09 +02:00
Michael A. Cojocari 417a52caa1 Fix Wardrive UTF-8 text handling
Signed-off-by: Michael A. Cojocari <michael.cojocari@gmail.com>
2026-08-25 16:12:24 -04:00
Michael A. Cojocari acdcc6e7fc Work 2026-08-23 20:32:26 -04:00
Kaj SchittecatandClaude Opus 5 c044f75d11 i18n: pisti87's Hungarian for the strings the audit could not see (#294, #277)
33 of the pairs he posted apply directly to keys that only became visible when
the extractor learned about helper-wrapped and table-held literals. The rest
did not, and the reasons are worth recording rather than dropping:

  - Four were escaped in his markdown, so the key read `Node\\nRegion` against a
    real key of `Node\nRegion`. Matched after unescaping.
  - `Export crash report` is `Export crash report (%uK)` in source; the size is
    part of the label. Applied with the conversion appended.
  - `Distance` and `Heard` are column headers he read off the screen; the keys
    are `Distance: km` and `Recently heard`. Not guessed.
  - `Sent only when that contact asks...` is a translation of wording the
    English has since changed. Applying it would ship a Hungarian sentence that
    no longer describes what the setting does.
  - `nothing heard yet` and `Other (hidden) network...` match no key at all,
    which usually means a raw string somewhere the audit still cannot see.

88 Hungarian rows are still English: the remainder of the newly visible keys,
plus console mode, which is new.

Languages go to v18. The v17 snapshot is deleted rather than kept: it was cut
before the audit fix added 116 keys, so it was already wrong, and it never
reached a device.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 23:06:11 +02:00
Kaj SchittecatandClaude Opus 5 e3c9f9b2cb Merge PR #292: ThinkNode M9 compass (QMC6309), GPS motion in the SDK, GPS Compass app
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>
2026-08-22 22:54:26 +02:00
Kaj SchittecatandClaude Opus 5 eb439c8baa i18n: find the strings the audit could not see, and the apps it never checked
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>
2026-08-22 22:29:05 +02:00
Kaj SchittecatandClaude Opus 5 a9cff6f113 i18n: translate the map credits sheet, and make the baked table follow the files
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>
2026-08-22 22:06:39 +02:00
Kaj Schittecat 8cd8a5366c Merge PR #311: pick a region from a known-regions list
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.
2026-08-22 21:48:52 +02:00
Kaj Schittecat 919544de2a Merge PR #312: wada.sd.list() read-only SD directory listing
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.
2026-08-22 21:44:59 +02:00
Kaj Schittecat a359de0c18 Merge PR #291: Pager feature gaps + finish the CAP_SD gating
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.
2026-08-22 21:42:48 +02:00
Michael A. Cojocari b04ae358b2 feat(lua): add read-only SD directory listing (#309) 2026-08-22 15:17:54 -04:00
Michael A. Cojocari 98803f1f14 Merge remote-tracking branch 'upstream/main' into 271 2026-08-22 14:52:20 -04:00
Christopher Van Hoose 6cc0c1e56e Make the WMM block generated, and the tests reviewable
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.
2026-08-22 13:30:36 -04:00
Christopher Van Hoose d758781564 GPS Compass: put the dial and the bearings on the same north
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.
2026-08-22 13:00:33 -04:00
Christopher Van HooseandClaude Fable 5 d2c3322a41 GPS Compass: retire flip/rotate, calibrate the accelerometer, unwrap the diagnostics
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>
2026-08-22 12:15:44 -04:00
Christopher Van HooseandClaude Fable 5 144c4123ad Merge upstream (SDK text measurement, tall-bar and Discord fixes)
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>
2026-08-22 10:26:10 -04:00
Christopher Van HooseandClaude Fable 5 a650d7874e M9: quieten the bring-up logging, and record the measured IMU frame
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>
2026-08-22 10:22:10 -04:00
Kaj SchittecatandClaude Opus 5 92fb071ef3 touch: the dead second back chevron on the tall bar (#308), + SDK Test 1.5
#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>
2026-08-22 16:02:56 +02:00
Christopher Van HooseandClaude Fable 5 fcc146f57a M9 IMU (QMI8658) + tilt-compensated heading
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>
2026-08-22 10:00:44 -04:00
Christopher Van HooseandClaude Fable 5 4e6b050cf8 Fix the calibration I broke, and calibrate flat instead of tumbling
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>
2026-08-22 09:28:49 -04:00
Kaj SchittecatandClaude Opus 5 0259264a74 touch: fix the batch reported over Discord (#304 #305 #306 #307, part of #257)
* #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>
2026-08-22 15:15:56 +02:00
Christopher Van HooseandClaude Fable 5 8910c62d3c Lua: repeating named timers pause with the display too
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>
2026-08-22 09:03:20 -04:00
Christopher Van HooseandClaude Fable 5 08c9f2b822 GPS Compass: fit the calibration sphere instead of taking min/max midpoints
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>
2026-08-22 08:56:45 -04:00
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