645 Commits
Author SHA1 Message Date
Kaj SchittecatandClaude Opus 5 6ed73b290a Merge PR #313: @-mention autocomplete in the device composer and the web chat
oumike. Closes #301.

Suggestions come from identities whose named adverts the device actually heard
this session, not the stored contact list, so the list stays short and is
evidence the node is reachable rather than a name someone once saved.

The advert-path cache it reads was 16 entries with no validity flag and no
ordering beyond insertion, so it grew an explicit used flag, the advertised node
type and a monotonic receive sequence. Reads take a snapshot under a short
critical section and sort it outside the lock, which is the right shape: the
cache is written from packet receive and read from the UI thread.

Token parsing works from the real LVGL caret through a UTF-8 codepoint-to-byte
conversion rather than assuming one byte per character, does not fire on
email-like text, and replaces only the active token.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 13:06:59 +02:00
Kaj SchittecatandClaude Opus 5 0c158d0bce touch: the app-permission switches packed the app index over the probe bit
Reported on Discord: granting an app full permissions and then watching
wada.mesh.send come back "permission denied", with no dialog ever shown.

The Settings switches pack (app index, permission bit) into one pointer as
(i << 4) | bit. LUA_PERM_PROBE is 16, which does not fit in four bits, so the
probe switch overflowed straight into the index field: toggling it for app i
decoded as app i+1 with bit 0. That changes no permission, but it still writes
app i+1's record, and the mere presence of a record means "asked".

An app that has been asked and not granted is refused outright rather than
prompting, which is the correct rule and exactly what made this invisible: an
app the user had never touched became permanently unable to send, silently,
because they flipped a probe switch on the app above it in the list.

The shift is 8 now, with a static_assert tying it to the widest LUA_PERM_* bit
so the next permission added cannot reintroduce this. Anyone already affected
can flip the switch in Settings > App permissions, which now writes what it
says it does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 10:35:39 +02:00
Michael A. Cojocari 50473b16d2 Merge remote-tracking branch 'upstream/main' into 301 2026-08-22 18:33:35 -04:00
Kaj SchittecatandClaude Opus 5 6dd1e2514b console: the exit bridge must not sit behind CAP_TOUCH
The extern for consoleHostRebootToUi went inside the CAP_TOUCH block, so the
M9 (console, no touchscreen) stopped compiling. Every board with a console can
leave it; the declaration follows CAP_CONSOLE's reach, not the panel's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 23:51:32 +02:00
Kaj SchittecatandClaude Opus 5 e9bf6d9e17 console: make ui actually leave console mode
Typing `ui` or `exit` rebooted straight back into the console.

touchPrefsSetConsoleMode() does not write the pref, it QUEUES an A/B snapshot:
SdNvsPrefs deliberately keeps filesystem I/O off the calling thread, and
touchPrefsFlush() is what forces the queue out. The console called
ESP.restart() directly, so the queued write was discarded and the next boot
still read "console". The Settings toggle never showed this because it goes
through rebootDevice(), whose last act is that flush.

The console now exits through the same path, which also means it leaves on the
same terms as everything else: chat history, the Discovered ring and the sync
replay ring are persisted first. rebootDevice()'s save-failure branch called
lv_refr_now() unconditionally, which is not survivable with no LVGL, so that
one call is now conditional; showAlert was already console-aware.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 23:34:50 +02:00
Kaj SchittecatandClaude Opus 5 c2357f032e mesh: stop prefixing a guest LOGIN on requests to plain chat contacts (#293)
christianprim reported that on a T-Deck, telemetry for a contact fails and that
contact's position never updates on the map, while the phone app gets both from
the same contact.

Every device-originated STATUS and TELEMETRY request chains a blank-password
LOGIN in front of the request. That exists for a good reason: a repeater will
not decrypt a PAYLOAD_TYPE_REQ from a sender that is not in its ACL, and the
ACL is only populated by handleLoginReq. But it was applied to every contact
regardless of type, and a chat contact is another companion, which has no LOGIN
handler at all.

The two fire-and-forget helpers merely wasted a packet. The manual Request
button goes through uiSendRequestAfterGuestLogin, which deliberately does NOT
send the request until the LOGIN is acknowledged -- so against a companion it
armed a wait for a LOGIN-OK that can never arrive, the request was never sent,
and the deadline expired as a failure. The phone app sends the request directly,
which is why it always worked.

The stale map position is the same bug: a contact's position is refreshed from
the CayenneLPP GPS field in a telemetry reply, and there was no reply.

contactNeedsGuestLogin() now decides, and it decides on the only thing that
matters: whether the far end keeps an ACL. Repeaters, room servers and sensors
still get the LOGIN; chat contacts get the request itself, immediately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 23:12:59 +02:00
Michael A. Cojocari ab5be7764e Work 2026-08-22 17:09:51 -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 a63e35bdaa touch: only offer Power off where something can wake the device (#310)
wb6zsu found the T-Display P4 freezes on Home > Cmdr > Control > Power > Power
off, recoverable only with the hardware switch, under a toast telling him to
click a trackball the board does not have.

Both halves are the same bug. The wake source is armed inside `#if
defined(PIN_USER_BTN)`, which platformio.ini sets per env and the two ESP32-P4
targets never see, so the P4 ran esp_deep_sleep_start() with no wake source
configured: the device is off and nothing short of the switch brings it back.
That is not a freeze, but it is indistinguishable from one.

The Power-off row was gated on `!HAS_THINKNODE_M9` instead -- a board name
standing in for "has a wakeable button", correct for the one board it was
written against and silently wrong for every board added since. It now follows
PIN_USER_BTN, the same symbol that arms the wake, so the row exists exactly when
it can be undone: the M9, the P4 and the Tanmatsu drop it, and any future board
gets the right answer without anyone remembering this.

The toast names the control the board actually has rather than assuming a
trackball.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 23:02:21 +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
Kaj SchittecatandClaude Opus 5 b1beb4e4ff discover: back off the sweep instead of probing every 4 s forever
Reported from a regional packet monitor: a burst of unattributable Control
packets whenever someone had WadaMesh Discover open. Correct on both counts.
They are ours, and they cannot be attributed: a NODE_DISCOVER_REQ carries a type
filter, a random tag and a since-timestamp, and no sender identity at all.

The page swept every 4 seconds for as long as it stayed open, and scanning
defaults to ON when it opens. A probe is not one packet: it is our zero-hop
broadcast PLUS a reply from every node in earshot. So an open Discover page put
a burst on the whole neighbourhood every four seconds, indefinitely.

Worth noting the inconsistency that made this obvious: wada.mesh.discover()
enforces a 15 second floor on third-party apps, with a comment explaining that a
probe spends everyone else's airtime. The built-in was doing it nearly 4x
faster.

Now 4s, 4s, 8s, 15s, then 30s. The first seconds are when someone is actually
looking, so those stay fast and the steady state goes quiet. Opening the page or
pressing Scan resets to the fast cadence, because both mean somebody is looking
again. The existing TX-budget gate is unchanged.

Reported by kevin77.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 21:40:20 +02:00
Kaj SchittecatandClaude Opus 5 0a7a25324b console mode: label it experimental where it is switched on
Both toggles (Settings > General and the VNC page) now say EXPERIMENTAL on the
row itself, not only in the paragraph under it, and the description mentions the
self-heal so the risk reads as bounded rather than vague.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 21:31:35 +02:00
Kaj SchittecatandClaude Opus 5 93f1934889 console mode: the console was painting on a WHITE background
That is why the greens were unreadable, and it was my mistake at the root: I
built a light-on-dark palette and then painted it onto whatever UIColor
resolved to.

src/helpers/ui/UIColorPalette.cpp defines wadamesh's dark palette and its own
comment warns that the core's driver .cpps 'carry upstream definitions that stay
unlinked only while nothing references them'. The core's ST7789LCDDisplay.cpp
sets window_bkg = WHITE. The console referenced UIColor and got that one.

Fixed by not depending on which definition wins: the console now owns both ends,
painting its own near-black background and its own RGB565 palette, with the
values taken from the firmware's actual theme colours rather than invented neon.
Only the e-ink fallback still consults UIColor, where a single ink colour is the
correct answer.

Banner: the slash-and-underscore figlet art was a smear at a 6 px cell on a
240 px panel. Replaced with a ruled header, which reads at any size on any
board. A login banner exists to say what the machine is, legibly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 21:27:28 +02:00
Kaj SchittecatandClaude Opus 5 4b5b51ef01 console mode: three colours per line, trackball scroll, shell prompt, visible banner
Colour, properly this time. A line can carry THREE coloured segments, because
the three parts of a message answer different questions: WHERE it arrived
(channel, cyan), WHO said it (sender, yellow) and WHAT they said (grey). A DM
has no channel, so its sender takes the magenta slot and stands out in a busy
feed. Same treatment for stored history, contacts and unread.

The palette is explicit RGB565, deliberately NOT the UIColor theme names:
several of those resolve to the same value on a given board, so 'channel' and
'sender' would have come out identical. e-ink falls back to one ink colour,
where anything else would be invisible.

Banner: it WAS printing, just scrolling off. Boot output was seventeen lines on
a panel that fits fewer. The art is now three lines, identity is one, and the
menu is three-per-row instead of two, so the whole thing fits.

Prompt: 'node:recipient$' instead of a bare caret. Where you are and who you are
talking to is what a shell prompt is for, and 'to <name>' now visibly changes it.

Trackball scrolls the scrollback, accumulated so one flick does not race past
everything, and it wakes a dark screen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 21:19:31 +02:00
Michael A. Cojocari b04ae358b2 feat(lua): add read-only SD directory listing (#309) 2026-08-22 15:17:54 -04:00
Kaj SchittecatandClaude Opus 5 a1916cfcc7 console mode: screen timeout and keyboard backlight
Neither ran. Both live in UITask::loop() below the console early return, so the
panel stayed lit indefinitely (battery, and burn-in on the panels that suffer
from it) and the keyboard never lit at all.

Console mode now does both itself, using the same prefs and helpers as the UI:
* Idle past the screen-timeout setting -> backlight off, CPU to 80 MHz, and the
  render is skipped entirely while dark. There is nothing to draw on a dark
  panel, so an idle console now costs almost nothing.
* Keyboard backlight off / on / auto, and dark whenever the screen is.
* A key pressed on a dark screen WAKES rather than types, the same as a touch
  does in the UI. Otherwise the character you used to wake it ended up in the
  command you were typing.

And the part that would have made the above silently useless:
_screen_timeout_ms is assigned at line 49804, while the console returns at
49431, so it was still at its constructor value and the timeout could never
fire. The console branch now reads the pref itself and prints it at boot, so a
wrong value is visible rather than mysterious.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 21:03:06 +02:00
Michael A. Cojocari 98803f1f14 Merge remote-tracking branch 'upstream/main' into 271 2026-08-22 14:52:20 -04:00
Kaj SchittecatandClaude Opus 5 98e8e63af9 console mode: colour, a boot banner, a quick menu, and the audit's missing commands
Colour: each scrollback line carries a ConsoleColor, so meaning picks the colour
rather than the caller's taste. Incoming channel posts, DMs, your own echoed
command, successes, warnings and failures are now distinguishable at a glance.
The palette maps onto the core's UIColor names so the console tracks the rest of
the firmware instead of inventing values.

Banner: an ASCII mark, the node name and build, then the quick menu, in the
login-banner tradition. 19 columns wide so it does not break on the V4's 240 px
panel.

Quick menu: numbered, because typing 1 beats typing 'contacts' and because a
numbered list is how you find out what exists. A bare digit runs the item; it is
checked before everything else so the node CLI cannot shadow it.

The audit (full table in CONSOLE_MODE.md) compared every UI surface against what
the console offered. The node CLI already covers more than expected: advert,
clock, gps, region, get/set, log, neighbors, ota, power, sensor, time, ver. What
it does NOT cover, now added:
  chat <name>   read a thread (does NOT mark it read)
  stat          radio + traffic, the Signal page's numbers
  batt          battery
  wifi          radio state
  discover / discovered   the active probe, with BOTH link directions

luaHostBattery and luaHostMeshDiscover were CAP_LUA_SDK_EXT-gated, so the V4 --
the board this exists for -- failed to link. Widened to || CAP_CONSOLE, same as
the send path earlier.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 20:50:59 +02:00
Michael A. Cojocari b643396228 Work 2026-08-22 14:47:56 -04:00
Michael A. Cojocari 83bf43df53 Work 2026-08-22 14:40:13 -04:00
Kaj SchittecatandClaude Opus 5 5c93c05a71 console mode: a live message monitor that does not mark anything read
Incoming messages now appear in the console as they arrive, for every known
channel and DM, without opening anything. On by default, persisted (v52
console_monitor), toggled with 'monitor on|off'.

The unread requirement drove the design. The previous version returned early
from newMsgImpl and printed, which skipped ALL the bookkeeping: no store, no
unread, no filters. Now console mode allocates the same chat store the UI does
(allocMessageStore(), extracted from begin() so both paths share it) and the
message takes the NORMAL path — block list, 1-character spam filter, sender
parsing, appendMessage with mark_unread = true. The console print happens AFTER
that append.

So watching a message scroll past is not reading it. It is stored, it is
counted, and it stays unread until the thread is actually opened:
  unread        what is waiting, per thread. Listing does not clear it.
  read <name>   clear one thread, deliberately.

That also means the monitor inherits the spam and block filtering for free, and
history survives a switch back to the graphical UI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 20:24:09 +02:00
Kaj SchittecatandClaude Opus 5 a9464fa131 console mode: an incoming message crashed it (null thread table)
Root cause, from the coredump against a matching elf:

  #0 UITask::findOrCreateThread (name="#test", channel=true) UITask.cpp:48598

_ui_threads is 'PSRAM-allocated in begin()' at line 49732 — AFTER the console
early-return at the top of begin(). So in console mode it is null, and the first
message to arrive dereferenced it. Because the panic rebooted straight back into
console mode, it then did that on every boot: a loop, with no way to the UI from
the device. The self-heal added earlier is what broke the loop.

This is the 'everything that assumes the UI exists' hard part from CONSOLE_MODE.md
showing up for real. showAlert and loop() were handled; the message-arrival path
was not.

* findOrCreateThread, appendMessage and the two inline accessors in UITask.h now
  tolerate a null table instead of assuming begin() ran to completion.
* newMsgImpl prints the message to the console in console mode. That also
  supplies the receiving half, which was missing: it could send but nothing
  arriving was ever shown.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 20:03:06 +02:00
Kaj SchittecatandClaude Opus 5 5b5e86c197 console mode: split CLI output on newlines, and self-heal after a panic
The overlap: the node CLI hands its whole reply to the terminal sink as ONE
buffer containing '\n' (its help text is a dozen lines in a single string).
consoleWriteLine stored that as one ring entry, so the text renderer drew the
breaks itself while our y-cursor still advanced by a single row, and every later
line landed on top of the one before it. Worsening down the screen, which is
what the photo shows. Now split on newlines first, then wrap each piece to the
panel width, so no stored line can be wider than the panel and the renderer
never wraps one on its own.

The bootloop: the device panicked, rebooted into console mode, and panicked
again, with no way back to the UI from the device. Console mode now checks
esp_reset_reason() and, if the previous boot panicked, comes up graphical and
clears the pref. That is the third way out the plan called for, and the only one
that needs the user to know nothing.

Honest about attribution: it boots clean now, but I cannot prove the newline bug
was the panic. The serial cut off right after 'serial_interface ok' because
those prints were not flushed, so the fault may have been later than it looked.
Boot breadcrumbs added (flushed) so the next one localises itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 19:53:14 +02:00
Kaj SchittecatandClaude Opus 5 51ea08743b console mode: stop the whole screen flashing on every blink and keystroke
startFrame() is display.fillScreen() and endFrame() is a no-op, so drawing is
direct to the panel with no buffer. render() did a full clear-and-repaint, and
it was called for the cursor blink twice a second AND for every keypress, so the
entire screen flashed continuously.

Split into three levels, cheapest that covers what changed:
* scrollback changed  -> full render (the only case that clears)
* typing              -> repaint the input row only, which never touches the
                         scrollback above it or the keypad below
* cursor blink        -> one fillRect on the cursor cell

An idle console now paints one character cell every half second instead of the
whole panel. Blink also moved to 530 ms so it does not beat against the loop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 19:37:44 +02:00
Kaj SchittecatandClaude Opus 5 bc87645df7 console mode Phase 3: mem, contacts, chans, to, msg
* 'mem' prints free DRAM and PSRAM plus the largest PSRAM block. This is the
  number the whole feature is justified by, so it is a command rather than
  something you have to instrument a build to see: read it in the console,
  reboot into the UI, read it there. Phase 0, self-service.

* contacts / chans / to <name> / msg <text>.

The send path is REUSED, not reimplemented. luaHostMeshSendChannel matches the
channel by name at transmit time, and a cached slot index is how messages once
went out encrypted to the wrong channel; duplicating that logic here would have
been an invitation to reintroduce it. It was gated on CAP_LUA_SDK_EXT, which is
off on the V4 -- exactly the board console mode exists for -- so the guard is
now CAP_LUA_SDK_EXT || CAP_CONSOLE.

'to' stores a name rather than an index for the same reason: an index goes
stale the moment the contact list changes underneath it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 19:32:38 +02:00
Kaj SchittecatandClaude Opus 5 7d98d359ee console mode: bring up the input hardware ourselves
Keys did nothing on the T-Deck because nothing was scanning them. The touch and
keyboard poll task is started inside UITask::loop(), below the console early
return AND behind 'if (!g_lv.ready) return;' — and g_lv.ready is false in console
mode, since that is the LVGL flag and LVGL is never initialised. So
tdeckKeyboardBegin() was never called and the ring the drain reads was always
empty.

Console mode now starts it once itself. One call covers both: the background
poll task owns the shared I2C bus and scans the touch panel and, on the T-Deck,
the keyboard. Which is exactly why it must not be polled from two places.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 18:58:45 +02:00
Kaj SchittecatandClaude Opus 5 b73965c18f console mode: put the toggle where people look for it
I added it to openVncPage(), which is the Remote app's page and is not reachable
from Settings at all. Reported as 'I don't see the settings option', which is
exactly right.

It is now in Settings > General, above 'Run setup again': a boot mode belongs
with the other device-level switches, not behind an app. The VNC page keeps its
copy since remote mode lives there too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 18:50:58 +02:00
Kaj SchittecatandClaude Opus 5 7a32e58b3d console mode: a way in, and keyboard input
Two things that made it untestable, both found by asking what a user would
actually do rather than by the compiler.

* No way to enter it. Added a Settings > Remote & console toggle mirroring the
  Remote UI one, since that is also a boot mode: set the pref, then reboot via
  rebootDevice() so chat history is flushed rather than lost to the reset.

* Typing did nothing. The physical-keyboard drain lives inside UITask::loop()
  BELOW the console early-return, so it never ran. Console mode now drains the
  same buffer itself. T-Deck codes are ASCII with backspace 8 / enter 13, which
  is what consoleKey already expects.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 18:43:19 +02:00
Kaj SchittecatandClaude Opus 5 48dae32f8c console mode Phase 2: boot into it, and the way back out
* Prefs v51 adds console_mode. It goes at the ACTUAL tail of the struct, after
  lang_file. I first appended it after retry_echo, which is mid-struct and
  exactly the v44 mistake the schema comments warn about; the existing
  static_assert caught it at compile time. Added a second assert pinning
  console_mode immediately after lang_file, so the next person to append is told
  by the compiler rather than by a field of corrupted installs.

* The pref fails safe by construction: only the value 1 means console, so a
  corrupt or unreadable byte boots the graphical UI. The <51 migration forces it
  to 0 rather than inheriting whatever was in that position.

* UITask gains a headless personality rather than #if through main.cpp:
  begin() decides once from the pref and returns before lv_init() and before the
  draw buffer is allocated, loop() runs consoleLoop() instead of the graphical
  path, and showAlert() becomes a console line so main.cpp's ~20 Wi-Fi and
  Bluetooth reports keep working untouched.

* 'ui' or 'exit' in the console clears the pref and reboots. That is one of the
  three ways out in CONSOLE_MODE.md; the boot-key escape is next.

Also Phase 1's on-screen key grid, since the V4 cannot use console mode without
one: three layers (lower/upper/symbols) rather than a shift key that changes
every glyph, so a key's label is always what it types. Compiled out where
CAP_KEYBOARD is set.

All 9 S3 envs, the Tanmatsu and the T-Display P4 build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 18:29:26 +02:00
Kaj SchittecatandClaude Opus 5 e179653350 console mode Phase 1: the console front end (no LVGL)
src/ui-touch/ConsoleUI.{h,cpp}, new and self-contained. Draws straight to the
panel through DisplayDriver and reads input from the board drivers, so nothing
in it needs LVGL initialised.

What is here:
* Scrollback as a flat ring in PSRAM. No per-line allocation, so a chatty
  command cannot fragment the heap the way a strdup-per-line log would. Long
  replies wrap to the panel width rather than being truncated, because a reply
  that runs off the edge is the same as no reply on a screen this size.
* Render only when something changed. That is the point of the mode: an idle
  console costs a millis() comparison, not a render pass.
* Line height derived from the measured character cell (DisplayDriver has
  getTextWidth but no text height, and the drivers scale a fixed 6x8 cell), so
  it stays right at another scale instead of being a hardcoded guess.
* Commands go to the_mesh.runLocalCli(); replies arrive through the existing
  terminal sink. Only clear/help/ui are handled locally. Anything CommonCLI
  already answers belongs there, not reimplemented here.
* Touch: scroll strips wired against heltecV4CapTouchGetLive, guarded on
  CAP_TOUCH so the Tanmatsu (which excludes that driver entirely) still builds.
  The key grid is next.

CAP_CONSOLE is on wherever there is a DISPLAY_CLASS to draw on. It does NOT
imply booting into it; that is Phase 2.

Builds on all 5 S3 envs checked plus the Tanmatsu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 16:54:26 +02: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
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 5a4a5a8ab6 Park the magnetometer when idle, and stop Lua apps ticking into a dark screen
Two answers to "does the compass app matter for battery life", both yes-ish
and both now fixed.

The driver put the QMC6309 into normal mode at boot and left it there, so it
converted continuously from power-on whether or not anything read it: ~1 mA at
100 Hz / OSR 8, forever, on a 2300 mAh battery. It is now parked in suspend
after configuration and woken on demand, with m9CompassIdleTick() (called from
the M9's existing per-loop branch) suspending it again two seconds after the
last read. Waking is a single register write since suspend preserves the
configuration; the waking call reports "nothing fresh" and the caller's next
poll gets data, which callers already handle because the chip may be absent.

Separately, lv_timer_handler() runs unconditionally, so a Lua app's timer kept
firing while the display slept -- GPS Compass polled the magnetometer at 10 Hz
into a dark screen, which would have held the sensor awake even after the fix
above. The host now skips the tick while the screen is off and resumes on wake;
dt comes from millis(), so an app sees one long frame rather than a broken
clock. Documented on the SDK page.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 09:01:19 -04:00
Christopher Van HooseandClaude Fable 5 15fa9a46e2 fix(lua): the net worker freed its buffers out from under itself
luaNetWorkerService() cleared s_net_pending as its FIRST statement and only
then made the HTTP call, so for the length of a round trip nothing told the UI
thread a request was in flight. An app closing in that window ran hostTeardown,
saw !s_net_pending, and freed s_net_buf / s_net_body while the worker task on
the other core was still reading and writing them.

The flag now stays set for the whole request and is cleared at the end, just
before s_net_done is announced -- that ordering also means a teardown observing
done=true can never also observe pending=true and hold the buffers forever. The
caller runs one service() per loop pass and re-checks afterwards, so holding it
across the call cannot re-enter, and http_get/http_post already reject a second
fetch while it is set.

Found while merging beta_68: upstream's comment said the buffers were
deliberately not freed at teardown because the worker might still be using
them, which was the right hazard behind a guard that could not actually see it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 02:50:43 -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 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 2a06695e25 Map: keep decoded tiles across tab switches; Lua host teardown fixes
Map re-open cost ~2.5 s of blocked UI on the M9, every visit. Measured with
the existing stall instrumentation: [STALL] ui:lvgl 2597ms and 2479ms on two
consecutive opens -- identical, so it was paying full price each time rather
than only on the first paint. Cause: leaving the Map tab called freeMapTiles(),
throwing away all nine decoded tiles, so the next open re-read and re-decoded
nine JPEGs. The grid costs 9 x 128 KB = 1.15 MB of PSRAM, which only matters
on the 2 MB V4 -- the board renderMapTiles already caps to a 4-tile pool -- so
boards with room now keep the slots exactly as panning within the tab leaves
them and the re-open is renderMapTiles' match-and-reposition path, no decode.
Note releaseMapTileSlot() would NOT have worked here: it clears in_use, so the
next render treats the tile as absent and decodes it again.

Lua host teardown, from auditing "do apps close properly":
- the http_get buffer (up to 64 KB of PSRAM) was a static that no one freed, so
  an app that fetched left it allocated after closing until some later app
  happened to fetch again. Released on teardown unless a fetch is in flight.
- cvGc's comment claimed it freed canvas pixels only when the app was closing;
  it freed unconditionally, so a canvas whose Lua handle went out of scope
  could be collected while LVGL was still drawing from that buffer. Canvas
  handles are now pinned in the registry for the app's lifetime (there is no
  API to destroy one early), so the pixels die with the state. All three
  shipped canvas apps keep references, so this was latent, not live.
- both the open and close log lines now report free PSRAM: the Lua heap
  counter excludes canvas buffers, so "leaked=0" alone never proved an app
  had given everything back.

M9, V4, V4-R8, T-Deck and Pager all compile. The map timing still needs the
after-measurement on hardware.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 02:07:58 -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