Compare commits

...
55 Commits
Author SHA1 Message Date
Kaj SchittecatandClaude Opus 5 43c0a9ed70 touch: beta_75 release notes
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 12:43:07 +02:00
Kaj Schittecat 29fd206448 Merge PR #371 2026-09-01 10:49:15 +02:00
Kaj Schittecat 8f9e9993c8 Merge PR #370 2026-09-01 10:49:15 +02:00
Kaj Schittecat a7bc830b9a Merge PR #377 2026-09-01 10:40:29 +02:00
Kaj Schittecat 9db00b397b Merge PR #376 2026-09-01 10:40:29 +02:00
Kaj Schittecat dd4c319151 Merge PR #362 2026-09-01 10:35:23 +02:00
Kaj Schittecat dbc1f0c431 Merge PR #361 2026-09-01 10:35:23 +02:00
Kaj Schittecat 592e0c8bd3 Merge PR #356 2026-09-01 10:35:23 +02:00
Kaj Schittecat 2e57f0503f Merge PR #355 2026-09-01 10:35:23 +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
Kaj Schittecat c700c279f8 Merge PR #359 2026-09-01 10:04:53 +02:00
Kaj Schittecat bda866e740 Merge PR #365 2026-09-01 10:04:53 +02:00
Kaj Schittecat a857003b75 Merge PR #360 2026-09-01 10:04:53 +02:00
Kaj Schittecat c9097f8108 Merge PR #358 2026-09-01 10:04:53 +02:00
Kaj Schittecat e15966297a Merge PR #357 2026-09-01 10:04:53 +02:00
Kaj Schittecat d2fcef7588 Merge PR #354 2026-09-01 10:04:52 +02:00
Ruben Laban b8481fdec7 touch: latch the block-name migration only once it has landed
s_ign_names_migrated was set before the work, so a failed re-open for write
(the same NVS failure ignWriteAll returns false for) latched a fold that never
happened. From then on ign_nm2 does not exist and ign_nm is never consulted
again, so ignNamesReadAll returns 0: every name block silently stops firing for
the rest of the boot. Worse, the next Block tap writes a fresh ign_nm2 holding
only that one entry, and the next boot's migration sees ign_nm2 present, skips,
and the old list is orphaned for good.

Latch on success instead, and remove ign_nm only after the new key actually
holds the list -- keeping it costs nothing, since nothing but this function
reads it and this function reads it at the correct old stride.

The "nothing to do" case still latches on the first call, so the steady-state
cost the latch exists for is unchanged.
2026-09-01 08:19:00 +02:00
Ruben Laban 3bf89ce855 touch: never append a wider record to a segment written at the old width
Widening UiSegMsg from 233 to 240 bytes is safe to READ in both directions --
the header is self-describing and readHistoryRec prefix-reads or skips the
surplus. Appending is not. uiSegAppendRecords writes sizeof(UiSegMsg) into
whatever file the table points at, and after an upgrade that file is the
still-non-full active segment whose header says 233:

  record 100: reader reads at 23316, record starts at 23316  (off by 0)
  record 101: reader reads at 23549, record starts at 23556  (off by 7)
  record 102: reader reads at 23782, record starts at 23796  (off by 14)

Every record after the first appended one is read at the wrong stride. The
damage is silent and it escalates: garbage bubbles, and a `seq` read out of a
long name's tail bytes lands far in the future, which makes the monotonic
filter drop every later segment and leaves _ui_seq_next poisoned. The next
compaction writes the result back for good.

Only a non-full segment can ever receive an append, so flag exactly those for
the existing rewrite_open repair when their on-disk width is not ours. Step 0
of segBuildJob runs that before any append and lays down a fresh header at the
current width; a full old-width segment is left alone, since it reads correctly
from its own header. The synchronous drain was never exposed -- it turns every
append into a whole-segment rewrite already.

The mirror case is documented on UiSegMsg rather than fixed: an older build
reading this record is fine, but an older build APPENDING to it writes 233-byte
records into a 240-byte-header file. That write belongs to the old firmware and
no change here can prevent it -- a version bump would only turn it into an
outright rejection.
2026-09-01 08:14:34 +02:00
Ruben Laban 7eada9b8c3 touch: store the whole sender name, not the first 24 characters
MeshCore names run to 31 characters (ContactInfo::name[32]), but
UIMessage::sender held 24, so every longer name was cut on the way in.
d304d3f made the cut visible instead of letting it push the author into
the message body; this makes it stop happening. MAX_SENDER_NAME goes to
31 — exactly the wire width, so nothing is lost.

Truncation could never fix three of these. "Ack" and "Mention" insert
@[<sender>] into the composer, and textMentionsMe needs the whole name
followed by ']', so a mention of a long-named node never reached them.
Block-by-name compares the stored name against the sender on the RX
path; for ROOM posts those two came from different widths already — the
check sees the untruncated 31-char author while the stored entry came
from the 24-char field — so a room author with a long name has simply
been unblockable. Info and the chat-list preview showed a cut name.

The obvious change — widening UiHistoryMsg::sender — would have been
quietly destructive, so the on-disk width is frozen at 25 instead and
the overflow is APPENDED to UiSegMsg, after seq. sender sits between
thread and text, so widening it in place shifts text for every record
already on disk, and nothing would have caught that: the segment store
is what actually holds messages now, and uiSegOpenValidated checks only
magic/version/rec_size — it never consults k_ui_history_version or
k_ui_history_min_version, which guard the legacy files alone. An old
segment would have passed validation, prefix-read into the new offsets,
lost the first 8 characters of every message body and read seq as 0 (so
loadMsgsFromSegments renumbers the lot), and the next compaction would
have written that back permanently.

Appending at the tail is the evolution path UiSegHeader already
documents, and it is correct in both directions: an old 233-byte record
zero-fills the new field and yields its original short name, and older
firmware reading a new 240-byte record copies the first 233 bytes and
ignores the tail. That second half is why k_ui_seg_version STAYS 1 —
bumping it would make older firmware reject the file outright and
quarantine a history it could have read. So there is no migration, no
version bump, and no wiped chat history. static_asserts now pin the
offsets an already-written record depends on.

TOUCH_IGNORED_NAME_LEN has to move in lockstep (28 -> 32) or blocking
breaks: a slot narrower than a sender stores a cut name the full-width
check can never match, and the block sits in Settings doing nothing.
Its NVS blob has no header — the entry count is its length divided by
the slot width — so re-slotting in place would mangle every entry after
the first and the next write would make that stick. The key is renamed
ign_nm -> ign_nm2 and the old blob folded in once at first read, which
is unambiguous in a way that sniffing the blob length is not (28 and 32
share multiples at 224 and 448, both inside the 16-entry cap). The
invariant the header only stated in prose is a static_assert now.

Split/rejoin is a host-tested header next to ChannelSenderSplit, with
the field-width bounds spelled out: a corrupt record can leave the name
field unterminated, and the message body is what follows it on disk.

Signed-off-by: Ruben Laban <ruben@tun0.nl>
2026-09-01 08:10:57 +02:00
Ruben Laban 9dd3d14152 touch: a colon in an unprefixed post is not an author
The split accepted any "X: " prefix once the length test came out, so a post
that carries no author but does contain ": " — "the repeater at Ouderkerk is
back up: full quieting again" — got read as an author of 36 characters. That
invents a sender AND hides the start of the body.

The old `slen <= MAX_SENDER_NAME` test used to catch this as a side effect;
dropping it for long names dropped the plausibility check with it. Restore the
check at the WIRE width (31 — MeshCore keeps every name in a char[32]) rather
than the destination width, so a real 31-char name still splits and truncates
while a 32-plus "prefix" is left in the body where it belongs.

A static_assert pins kMaxWireName >= MAX_SENDER_NAME, so widening the field can
never leave the split rejecting names the field holds fine.
2026-09-01 08:09:41 +02:00
Ruben Laban 78ca05f5bd touch: a long name no longer pushes the author into the message body
A channel post arrives on the wire as "SenderName: body" — the author is
in the text, not a separate field. Both split sites gave up when the name
was longer than MAX_SENDER_NAME (24), and giving up meant the sender fell
back to from_name, which for a channel is the CHANNEL name. The bubble
then read

  #channel      date
  nick: message

instead of the author over their own message. MeshCore names run to 31
chars (ContactInfo::name[32]), so this hit every post from a node with a
25+ char name — the "occasionally" in the report.

Truncate the label instead of rejecting the split, backing off to a UTF-8
character boundary so a multi-byte name is never cut mid-sequence (the
missing-glyph sanitiser would render the tail as '*'), and mark the cut
with "..." so a shortened name does not read as a different node.

Widening MAX_SENDER_NAME is the real cure but not an option here: sender[]
sits in the MIDDLE of the persisted UiHistoryMsg record, and this file
format only tolerates fields APPENDED to the end — moving `text` would
force k_ui_history_min_version up and discard everyone's chat history.

The two copies of the split are now one host-tested header, matching how
Utf8Text/ReaderContent are factored.

Signed-off-by: Ruben Laban <ruben@tun0.nl>
2026-09-01 07:53:14 +02:00
Michael A. Cojocari 015a9cec4a mqtt: add connection status command 2026-08-30 15:05:09 -04:00
Michael A. Cojocari f70a491480 Work 2026-08-30 14:42:46 -04:00
Christopher Van HooseandClaude Fable 5 cf5ddad358 Measure GPS acquisition from when it started, not when you looked
s_gps_acq_since is stamped in exactly one place: lazily inside gpsStatusStr(),
on its first call. And gpsStatusStr() only runs from the GPS settings page
(build + live refresh) and the Control Center GPS chip (build + refresh).

So the "GPS: searching Nm" figure measured time since the user first LOOKED at
the status, not since the receiver was switched on. Boot with GPS enabled, walk
around for twenty minutes, then open the GPS page: it reports "searching 0m02s"
as if the module had just come up. That reads exactly like "GPS only starts when
I navigate into the GPS view" — which is how it was reported.

The receiver itself was fine. UITask::begin already re-applies the saved
gps_enabled pref at boot, and the M9 sets ENV_SKIP_GPS_DETECT so the detect race
cannot suppress it.

Stamp the clock where the state actually changes — in toggleGPS() and at the
boot resume — and leave the lazy stamp in gpsStatusStr() as a fallback. millis()
can be 0 at boot and gpsStatusStr treats 0 as "not started", so both sites use a
non-zero sentinel.

This matters beyond cosmetics: the comment above that string explains it
deliberately reports elapsed acquisition time because the driver exposes no
satellites-in-view, no SNR and no HDOP — the elapsed figure IS the diagnostic.
It was reporting the wrong quantity.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Christopher Van Hoose <cvhvisuals@icloud.com>
2026-08-29 07:50:42 -04:00
Christopher Van Hoose 8fbbac3b66 M9_PORT.md: record audit pass 3
Cold map open, Back navigation, the SD map-pack deletion, the label overlaps
and the measurements behind them - including the correction to the earlier
"the remaining cold-open cost is the SJPG decode" note, which was reasoning
about the two smaller halves.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Christopher Van Hoose <cvhvisuals@icloud.com>
(cherry picked from commit 68fe4dbc09f450cc1e1545573cf9c53658bcf840)
2026-08-28 20:37:17 -04:00
Christopher Van Hoose a19bb42c4b Record the SPIFFS repair catch-22 that strands the identity on SD
An invalid SPIFFS forces want_full_sd above ("move the WHOLE store to SD when
the device has no usable SPIFFS"), and being on SD then SKIPS the repair -
so the partition stays unformatted forever, every boot logs
"E SPIFFS: mount failed, -10025", and internal storage reads 0 bytes.

It is not cosmetic. The migration path documents that it "never deletes its
SPIFFS sources, so a device that already adopted the card still has a
byte-identical copy here" - the design assumes internal flash holds a SECOND
copy of the identity. A device that went SD-native BECAUSE SPIFFS was broken
never had that copy, so /meshcomod/identity/_main.id on the card is the only
thing making it its node. On the ThinkNode M9 that card is soldered.

Reproduced on M9 hardware, and the obvious one-line fix is NOT applied:
changing the condition does repair the partition, but that boot then panicked
and looped in the Arduino Wi-Fi event task. Once the partition was formatted,
the same build with the line back as-is boots cleanly and repeatedly - so the
fault is the 3.375 MB format itself running at this point in setup(), racing
the event task, not a mounted SPIFFS. The real fix has to repair the
partition somewhere that can afford a multi-second blocking format.

Comment only; no behaviour change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Christopher Van Hoose <cvhvisuals@icloud.com>
(cherry picked from commit ab86caa9385b6d63acce977d63e2b1a1731b7236)
2026-08-28 20:36:00 -04:00
Christopher Van Hoose 11f6dc4ddd M9: attribute keypress work in the stall ring
uiCp("ui:input") lives inside #if CAP_TRACKBALL, which device_caps.h sets to
0 for the M9 - while the M9's keyboard drain sits between the ui:threads and
ui:diag checkpoints. So on the one board whose ONLY input is the d-pad, every
keypress-driven action was billed to whichever bucket happened to be open,
which is ui:threads. A cold map open was reported to the user as a slow
chat-threads refresh, and the About page's stall ring - which exists so a
field reporter can photograph it - was actively misleading.

Give the M9 its own checkpoints, and split what was one coarse bucket into
the three things it actually contains:

  ui:navreb  nav focus-group rebuild (walks the widget tree)
  ui:kbpoll  I2C keyboard drain + compass/IMU idle ticks
  ui:keys    handleHwKey: the real work (page builds, list rebuilds, ...)

uiCp() is two loads and a compare, so this costs nothing. Work that reaches
LVGL via navPushTap still lands in ui:lvgl, since the handler runs inside
lv_timer_handler; that is correct and worth knowing when reading the ring.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Christopher Van Hoose <cvhvisuals@icloud.com>
(cherry picked from commit 9ba3eac6d72bcba85bee58cc39d740fd24c9ba41)
2026-08-28 20:35:02 -04:00
Christopher Van Hoose 56f12d39aa Remount through the full ladder after an in-device SD format
Upstream #343: formatting the microSD from the device reports that there is
no card present.

The format worker's remount was a single bare SD.begin at 4 MHz with no
settle - but sdcard_uninit() immediately above it issues GO_IDLE_STATE, so
the card is back in SPI idle and has to re-run its whole power-up handshake.
That is exactly what fmSdTryMount's ladder exists for: seven rungs from
4 MHz down to 400 kHz with 40-900 ms settles, plus an upward renegotiation
after a slow success. Its own comment flags the timing as UNVERIFIED on M9,
which is why the format path must not hand-roll a weaker version of it.

Also stop claiming the card is absent when it is not. f_mkfs succeeding and
the remount missing are different outcomes; reporting "no card" for the
second sends people hunting a hardware fault that is not there. Track the
mkfs result separately and word the two cases differently, and clear the
10 s mount backoff on the failure path so fmShowRoots() below does not hide
the SD row for ten seconds while the toast says to reinsert it.

Not reproduced on hardware: doing so means formatting the card, and on the
M9 that card is soldered and holds the only copy of the node identity.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Christopher Van Hoose <cvhvisuals@icloud.com>
(cherry picked from commit b85eca606d417f212614d634571f3f3d55802e23)
2026-08-28 20:33:23 -04:00
Christopher Van Hoose 12071d3a28 Keep hidden and inert widgets out of the d-pad focus group
Two places where the keyboard-nav collector picked the wrong container, so
Back's result was a screen the d-pad could not drive.

- The background-notification chip was a non-NAV_SKIP child of lv_layer_top
  with nothing focusable inside (it is not CLICKABLE). navTopHasVisibleChild
  therefore re-rooted the group onto it and navCollect found nothing, so the
  focus group came up EMPTY for the chip's ~1.1 s life - d-pad and OK inert,
  status-bar actions skipped - on every incoming message.

- The Spectrum page's NAV_SKIP_FLAG root hid it from the same two functions,
  so the collector decided the top layer showed nothing and re-rooted onto the
  app drawer still open BENEATH the full-screen page: the d-pad moved an
  unseen focus ring across hidden tiles and OK launched them.
  NAV_PASSTHRU_FLAG keeps the page as the focus container - but note that
  passthru only excludes the CONTAINER, and navCollect still recurses. In
  LVGL 8 lv_chart does not clear LV_OBJ_FLAG_CLICKABLE the way lv_label and
  lv_img do, and lv_obj_remove_style_all does not touch flags, so the chart
  and the five colour-ramp swatches became focus stops and were painted
  reverse-video - a white block straight over the live trace. They now carry
  NAV_SKIP_FLAG so the group ends up genuinely empty, which is what the
  original comment always claimed. Anything added to that page later must do
  the same or it becomes a focus stop by default.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Christopher Van Hoose <cvhvisuals@icloud.com>
(cherry picked from commit 2c1dfe6ea10c7f2eaa002d1a6e07aa96b9118ecb)
2026-08-28 20:32:25 -04:00
Christopher Van Hoose ffea25a85e Size the remaining build-time-measured labels for their real text
Follow-up to the label-overlap sweep: the seven lower-severity hits, same
root pattern as before - a label measured once at build time, or given no
width at all, then handed text of a different length.

Note how many only bite in a NON-ENGLISH build. i18n_builtin.h carries
German, Hungarian and Dutch, all markedly longer than English, and these
layouts were sized against English alone. The Bluetooth one already
overflows in English.

Overlaps (text grows over what is beneath):
- First-boot wizard header: the title has LONG_WRAP at width sw-84, but y
  advanced a hardcoded 24 px, so a wrapped title ran through the blurb and
  every step's content under it.
- Radio & Mesh: the duty-cycle/time-on-air readout has a width (so it wraps)
  but a fixed SC(22) advance; a 4-digit time-on-air - any SF11/SF12 preset -
  puts its second line through the MESH divider.
- Add contact / the three channel modals: the error label is EMPTY at build,
  so 24 px was reserved for text that did not exist; a validation message
  then wrapped over the submit button. Pin the height to the reservation.
- Contacts > Discovered: the auto-add hint names every enabled type TWICE,
  so with several enabled it wraps past its fixed SC(38) onto the first card.
- App drawer > cog: "App drawer as home" had no width and ran under its own
  right-aligned switch.

Clips (no width set, so the label cannot wrap and runs off the edge):
- Settings > Bluetooth pairing hint - give it a width so it wraps.
- Channel "Region & scope" hint - one line, ellipsized: the textarea below is
  pinned at SC(72) and this sits at SC(54), so there is no room for a second
  line and wrapping would print over the input.

Two findings from the sweep are deliberately NOT changed:
- The Discover feed's LV_LABEL_LONG_CLIP is load-bearing, not an oversight:
  the comment records that one line per row is what makes the tap-to-add
  Y->row mapping work. Its clipped trailing columns are a content-priority
  question, not a layout bug.
- Compact chat rows bake their offsets into the virtualised list
  (s_chat_virt.offsets) while the delivery glyph is in one state; a later
  glyph change grows the row into the next. Fixing that means re-measuring
  rows on delivery-state change, which is the chat virtualisation and
  deserves its own change with its own testing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Christopher Van Hoose <cvhvisuals@icloud.com>
(cherry picked from commit fe35be577c705f9d0774877aae5879afe24c241d)
2026-08-28 20:30:55 -04:00
Christopher Van Hoose 060c09c5c9 Stop live labels overlapping the rows beneath them
Most pages here lay out children with a manual cursor - lv_obj_set_pos then
y += height - so nothing reflows. Four labels were measured ONCE at build
time and then re-texted with content of a different line count, printing
over whatever sat below.

- Settings > GPS: gps_status is refreshed every tick. Built with GPS off it
  is a one-line "GPS: off", so y advanced ~22 px; toggling GPS on - the
  obvious thing to do on that page - swaps in "GPS: searching Nm" plus a
  ~100-character cold-start sentence that wraps to several lines and lands
  on the "GPS serial baud" row. Reserve the tallest possible string instead,
  probing BOTH long variants since which one wins depends on the language.

- Settings > About: three children each pinned to the measured height of the
  one above. The live tier gains a hard newline the moment a chat-store write
  fails, and the slow tier grows as the microSD scan publishes and the stall
  ring fills from 0 to 6 rows. Neither has a fixed upper bound, so a
  worst-case reserve is not possible - use a flex column and let it reflow.

- Settings > Wi-Fi and Files: both use LV_LABEL_LONG_DOT, which only
  ellipsizes against the object's HEIGHT - and a label defaults to
  LV_SIZE_CONTENT. With no height set, a long SSID or a deep path wrapped and
  grew instead of getting its dots, running into the network list and under
  the file list respectively. Both had a comment promising one line; pin it.

Found by sweeping the other views after the GPS report; the remaining hits
were lower-severity clips and are left for a follow-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Christopher Van Hoose <cvhvisuals@icloud.com>
(cherry picked from commit 2c8805c0f7bd43ee1432ff3cdf2f500bbf27c589)
2026-08-28 20:30:55 -04:00
Christopher Van Hoose 86efe48faf M9: make Back return to the previous screen
The M9's Back ladder was a copy of pagerNavGoBack MINUS its last rung, so
once every layer was peeled it fell through to navPushTap(LV_KEY_ESC) - and
nothing in this build consumes LV_KEY_ESC. The only consumer is lv_dropdown,
peeled several rungs earlier, and navMaybeRebuild deliberately keeps the tab
bar out of the focus group. The press was swallowed by the return true and
NOTHING happened, on every bare tab.

Restoring the Pager's rung alone only gets you Home, so this also adds the
history that never existed anywhere in this UI (s_lv_tab_prev is a single
transition latch; s_apppage_close is depth 1; the popup registry walks in
static table order):

- s_m9_nav[], 12 entries, 24 B. Records main-tab switches only. Pushed from
  goToTab - the single choke point, and the only place that fires
  LV_EVENT_VALUE_CHANGED explicitly - and popped by a new rung placed BELOW
  every peel rung, so modals still close innermost-first and a null-close
  blocker row still stops Back.
- Never stores an lv_obj_t*: settings sheets, app pages and the drawer are
  destroyed, often via del_async. A stale entry is skipped, never a dead press.
- s_m9_nav_replaying suppresses the re-push from a pop's own goToTab. The
  Home-fallback rung needs it too, or that jump records the tab it is leaving
  and the next Back pops straight back to it - Back ping-ponging forever.
- The app drawer is deliberately NOT stacked: it is a registry row the ladder
  already closes, and its mode survives a tab switch.

Fixed alongside, all reachable on this board:

- openMeshContactDm switched tabs with a bare lv_tabview_set_act and no event
  send, so tabChangedCb never ran, s_lv_tab_prev went stale, and the NEXT tab
  change skipped the only code that hides the DM overlay - the chat stayed
  painted over whatever tab you moved to.
- Back was a dead key for the whole first-boot wizard, even on the three steps
  that draw a working on-screen Back wired to setupBackCb.
- Contacts select mode outlived its tab, so Back closed it INVISIBLY from
  another tab. It now clears on leaving Contacts, the discipline map pan mode
  already had. Deliberately not via ctSetSelectMode(false), which ends in a
  forced ~1.3 s list rebuild (#82) - that would freeze the tab being entered.
- navGoToMainTab ignored the registry's key-blocker rows and switched tabs out
  from under a running SD format or bulk delete. It now returns bool and stops;
  M9_KEY_LEFT_MESSAGE and M9_KEY_MAP check before destroying an app page.

Verified on M9 hardware.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Christopher Van Hoose <cvhvisuals@icloud.com>
(cherry picked from commit 4f84ec6268737413de6684aebdc5bcc97da77819)
2026-08-28 20:29:30 -04:00
Christopher Van Hoose 3cbceb301e M9: cut the map's cold open from 2.6 s to 1.5 s
Measured on hardware, a 9-tile cold open was 2598 ms. Four changes, in
descending order of what they actually bought:

- renderMapTiles repainted the whole screen after EVERY tile. On the
  immersive map each lv_refr_now also alpha-blends the transparent status
  and tab bars over the full panel: ~115 ms a call, nine times. That cost
  more than the SD reads and the JPEG decodes COMBINED (measured
  read=497ms decode=539ms other=1042ms). Throttle it to one paint per
  k_map_progressive_ms, first tile still immediate. Saves ~684 ms.
  This corrects the standing assumption that the remaining cold-open cost
  was the decode - it was not, and nobody had counted the compositing.

- onMapTabActivated called bestAvailableZoom() unconditionally and then
  discarded the result whenever a zoom was persisted. Worse, with no GPS
  fix the centre stays 0,0, s_map_view_inited never latches, and the whole
  17-level probe re-ran on EVERY open rather than just the first. Skip it
  when the answer cannot be used. Saves ~410 ms.

- The "Loading map..." hint was set AFTER that probe, so the scan ran with
  the outgoing tab still frozen on the panel. Move it to the top, and skip
  the tab-slide for the map so the flush is not racing the animation.

- tileExistsAt probed .png while the generic loader only ever opens .jpg,
  so the zoom guard advertised - and the auto-snap selected - levels that
  render blank. Mirror the loader.

Also gate s_tile_fetch_dirty on the visible zoom, matching the rule the
already-cached path above it already used: the 29-tile background prefetch
pyramid was driving full grid re-renders for tiles that are never on screen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Christopher Van Hoose <cvhvisuals@icloud.com>
(cherry picked from commit a14ab185c9e2f1a3c54ee285d486e1c2b7a05901)
2026-08-28 20:28:07 -04:00
Christopher Van Hoose 1ecd684b08 Stop the tile fetcher deleting the user's SD map pack
tileCacheRemove(path_png) in the fetch worker was written to drop a stale
.png from an older build on the firmware's own LittleFS cache partition.
But s_tile_fs is re-pointed at the SD card ROOT on every board that caches
tiles to the card (the M9 always; the R8, T-Deck and Pager under various
conditions), with s_tile_root "" — so unguarded it resolved to
SD.remove("/tiles/<z>/<x>/<y>.png"), deleting the user's own offline map
pack. The skip-check above it only ever tests the .jpg, so every queued
tile whose .jpg was absent erased the co-located .png.

Confirmed on M9 hardware: the boot log reports
"[TILE] caching Wi-Fi tiles on SD /tiles", i.e. exactly this aliasing.

Gate it on s_tile_fs == &s_tiles_fs, the same discriminator
tileCacheMarkSdIo() and tilesFsLowSpace() already use.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Christopher Van Hoose <cvhvisuals@icloud.com>
(cherry picked from commit bccd7f33f27d4a5cee9bd1f72ffc23ca989e7a55)
2026-08-28 20:22:56 -04:00
Kaj SchittecatandClaude Opus 5 b877e43d5e touch: beta_74 release notes
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 18:49:27 +02: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 5c66a5b9cd keyboard: latch shift like the other modifiers (#344)
jrote1: symbol and alt latch, shift does not, so a capital letter still means
holding shift down while reaching for the key.

Shift was a bare "is it held" test while the other two ran through
LatchedModifier. It has its own now, so it behaves identically: tap once to
capitalise the next letter, tap twice to lock until you tap again.

The board has two shift keys and they share one latch. Whichever you press arms
it and either releases it, which is what someone alternating hands would expect.

The one-shot is consumed by the key it capitalises, in the same place symbol and
alt are consumed, so it cannot leak onto the following letter.

Still to do from that issue: an on-screen indication of which modifier is
currently latched. That is a UI change rather than a driver one and wants its
own look at where it belongs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 17:49:20 +02:00
Kaj Schittecat c59a951449 Merge PR #346: Attaky front D-pad + SELECT for UI navigation
oumike. The board has nine onboard buttons and only POWER was wired; the front
D-pad and SELECT did nothing. They hang off an AW9523 expander rather than
direct GPIO, so the driver drains an expander queue in attakyNavPump().

Adds ATTAKY_MESH_SERIES to CAP_KEYPAD_NAV, which makes it the first board
carrying that flag with no physical keyboard. He flagged that himself; the
matrix build is what proves nothing behind the flag assumes a keyboard.
2026-08-28 17:37:35 +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 dbf2c43f7d web: stop the remote UI rounding the bandwidth to a whole number (#345)
bradhawkins85: a T-Deck on BW 62.5 shows 62 in the remote web settings, and
applying anything from that form sends 62 back to the radio. So opening the page
and pressing Apply silently reconfigures the radio onto a bandwidth the mesh is
not using.

Two independent halves, both needed:

  - The settings JSON emitted "bw":%.0f, so 62.5 was already 62 before the
    browser saw it. Now %.1f.
  - The form's bandwidth input had no step attribute, so it defaults to step=1
    and a decimal is invalid. The frequency field beside it has step=0.001; this
    one was simply missed. Now step=0.1.

The apply path was never wrong: it parses with %f and passes a float through.
Only the outbound direction lost the fraction, which is why it looked like a
display quirk while actually changing the radio.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 16:31:11 +02:00
Kaj SchittecatandClaude Opus 5 708f4434f5 touch: beta_73 release notes
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 10:32:19 +02:00
Kaj SchittecatandClaude Opus 5 875ac92a48 keyboard: let raw mode admit it guessed wrong (#341)
Vybo's older T-Deck types garbage on beta_71 and beta_72: "I get some keyboard
codes when typing". His controller has the pre-backlight firmware and does not
speak the raw column protocol, but the probe concluded it did.

A legacy controller answers a five-byte read with the pressed character in byte
0 and padding after it, and every lowercase letter has its high bit clear, so
"five bytes came back and no high bit is set" passes. Read as a column bitmap,
0x61 ('a') sets bits 0, 5 and 6 of column 0 and emits "q "; 0x6d ('m') emits
"qa ". That is exactly what he sees. Worse, the retry window I added in beta_72
gave a legacy controller more chances to look plausible, which is why it did not
help him.

The probe cannot fix this alone. Idle, both controllers answer with zeros, and
requiring proof of a real matrix before committing means an idle boot never
finds any, falls back to legacy, and everyone silently loses modifier latching.
Tried that first; it cost latching on a device where raw mode was working.

So the decision stays optimistic and becomes reversible. Raw mode now tests each
frame: three or more bits in byte 0, a printable value, and every other column
empty is not something a key matrix can report, but it is precisely an ASCII
character. On that, the driver returns the controller to key mode, switches to
legacy, and delivers the character.

Checked against the real tables rather than assumed: all 26 lowercase letters
set three or more bits, so any letter demotes on the FIRST press. Only '0', ' '
and '!' do not, and space renders correctly anyway since column 0 bit 5 IS
space. Going the other way needs three or more of {q, w, a, space} held at once
and nothing else; the printable-range test drops q+w+a (0x0B), and what remains
is a fumble that costs a reboot rather than a wrong character.

Worst case for a misdetected board is now one odd keystroke instead of a
keyboard that types nonsense until it is reflashed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 10:20:26 +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. Cojocari 107367bf82 Merge remote-tracking branch 'upstream/main' into attaky 2026-08-27 20:41:39 -04:00
Michael A. Cojocari fe6741b26d Work 2026-08-27 20:37:59 -04:00
Kaj SchittecatandClaude Opus 5 2d94be4511 touch: beta_72 release notes
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 22:06:03 +02:00
Kaj Schittecat 41424ed5d2 Merge PR #337 2026-08-27 21:54:21 +02:00
Kaj Schittecat 523776eb0e Merge PR #335 2026-08-27 21:54:21 +02:00
Kaj SchittecatandClaude Opus 5 f880b5461f touch: restore accent popups for physical keyboards (#338)
Buko84 reports accent popups gone on beta_71, with the settings toggle doing
nothing. Introduced by the mention-autocomplete merge (#313).

composerSuggestRefresh() used to do both jobs in order: show the @mention picker
if the token warrants it, otherwise show the accent box. It was renamed to
composerMentionRefresh() and lost its accentBoxMaybeShow() tail. One of its two
callers got a replacement call added next to it; the other did not.

The one that did not is the composer textarea's own VALUE_CHANGED, which is
exactly the path a PHYSICAL keyboard takes. So accents kept working from the
on-screen keyboard and died on the T-Deck and the Pager, which is also why the
settings toggle looked broken: it gates a function nothing was calling any more.

Restored the fall-through. Calling it from both paths is harmless, since
accentBoxMaybeShow() hides the previous box before deciding whether to show one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 21:53:32 +02:00
Michael A. CojocariandClaude Opus 5 031b4bb1ba build: one script that compiles every target, both toolchains
"Does it still build?" spans two toolchains here. The eight S3 boards come from
platformio.ini, but the Tanmatsu and the T-Display P4 are standalone ESP-IDF apps
with their own build.sh wrappers — release.sh notes that in a comment and then
builds only the PlatformIO half. A change that broke an IDF-only board stayed
invisible until someone cut a release by hand.

build-all-targets.sh builds all ten, uploads nothing, and prints one table.

The env list is read from platformio.ini rather than hard-coded, so adding a board
to that file is enough and this cannot drift out of step with it (release.sh has
to hard-code its copy because it also needs the per-board binary names). A missing
project-local ESP-IDF is a SKIP rather than a failure, so a laptop with only
PlatformIO still gets the eight boards checked and a note pointing at
tanmatsu/fetch-deps.sh. Failures do not stop the run — which boards are broken is
the question being asked — and the exit status is non-zero if any target failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 15:24:32 -04: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 6aad8bf1ad console: never offer a mode the board cannot be typed into (#334)
gadgeteerza's ThinkNode M9 entered console mode on beta_70 and could not leave.
The prompt painted, "type 'ui' to go back" was on screen, and no key did
anything. A power cycle came straight back, the pinhole reset came straight
back, and reflashing failed. From the user's side the device was gone.

CAP_CONSOLE was gated on DISPLAY_CLASS: it reasoned entirely about whether
there was something to draw on, and never about whether the user could type.
The console has exactly two input drains, the T-Deck keyboard and the on-screen
keypad behind CAP_TOUCH. The M9 has an I2C keyboard and no touchscreen, so it
had neither, and nothing catches this at runtime either: the panic self-heal
only fires on ESP_RST_PANIC, and a console with no input has not crashed.

Two changes. The M9's keyboard is drained in the console loop, so that board
keeps the feature. And CAP_CONSOLE now requires CAP_CONSOLE_INPUT as well as a
display, so a board with no drain does not offer the mode at all rather than
offering a one-way door.

That guard immediately caught a second one: the T-Lora Pager has a keyboard, no
console drain for it, and was one Settings toggle away from the same state. It
no longer shows the option.

Built on all eight S3 envs and both ESP32-P4 targets. Verified by string
presence per image: T-Deck, M9 and V4 keep console mode; the Pager no longer
carries it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 21:21:40 +02:00
alphalord336 30bbefc066 Merge branch 'ALLFATHER-BV:main' into main 2026-08-27 18:17:41 +02: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
alphalord336 3dee9d62e7 Update battery voltage calculation formula 2026-08-24 19:49:47 +02:00
60 changed files with 2743 additions and 237 deletions

No files matched your search

+1 -1
View File
@@ -366,7 +366,7 @@ which is why the console needed far fewer new commands than the UI has screens.
| Quick replies | none | gap |
| Backups | none | gap |
| Language | none | gap |
| MQTT bridge | none | gap |
| MQTT bridge | `mqtt status` | **done** (diagnostics only) |
| App permissions | none | with Phase 4 |
| General | `console`/`ui` toggle | partial |
+1 -1
View File
@@ -16,7 +16,7 @@ USB) and the [GitHub releases](https://github.com/ALLFATHER-BV/wadamesh/releases
| LilyGo T-Lora Pager | ESP32-S3, LR1121 or SX1262 | 2.33" IPS LCD, 480x222 (no touch), QWERTY + rotary encoder | Web flasher (pick the build matching your radio) | Beta (new in beta_45) | Fully supported; keyboard-first navigation, microSD, map tile packs |
| Heltec V4-R8 + Expansion Kit V2 | ESP32-S3 (8 MB octal PSRAM), SX1262 | 2.4" touch (CHSC6x) | Web flasher | Beta (new in beta_45) | As the V4 plus microSD and the Expansion Kit sensors; buzzer supported |
| LilyGo T-Display P4 | ESP32-P4 + ESP32-C6, SX1262 | AMOLED or TFT-LCD, touch | Web flasher (AMOLED) or the .bin for the LCD SKU | Beta (new in beta_45) | Two screen SKUs; fuel-gauge battery reporting, full TX power |
| Attaky Mesh Series | ESP32-S3, SX1262 | Touch display | Web flasher | Beta (new in beta_47) | Community port by attakygit (#158/#169); detachable keyboard supported |
| Attaky Mesh Series | ESP32-S3, SX1262 | Touch display + front D-pad | Web flasher | Beta (new in beta_47) | Community port by attakygit (#158/#169); detachable keyboard supported; the front D-pad + SELECT navigate the UI |
## Feature notes per board
+33 -5
View File
@@ -20,20 +20,33 @@ this page is the five-minute tour that makes the board feel native.
the second layer instead. Start typing in a chat and the composer focuses
itself; any key wakes the screen from idle.
## The one rule: Back closes what you see
## The one rule: Back closes what you see, then goes back
**Back** always closes the *top* layer, one per press: map pan mode → an open
dropdown → the power menu → the Control Center → a full-screen app → a dialog
→ any popup → an open chat → back out of the page. Nothing ever closes
invisibly beneath something else. The only exception is deliberate: progress
overlays (SD format, bulk delete) block all keys until the operation finishes.
→ any popup → an open chat. Nothing ever closes invisibly beneath something
else.
Once there is nothing left covering the screen, **Back returns you to the
previous screen** — the tab you came from, however you got there: a function
key, an app-drawer tile, "Show on map", a contact's Send-msg button. Keep
pressing and you keep walking back through where you have been. When the trail
runs out Back lands on Home, and on Home with nothing open it does nothing,
because that is the root.
**HOME** is the shortcut past all of that: it goes straight to the root and
clears the trail, so Back from there won't jump you back out again.
The only exception is deliberate: progress overlays (SD format, bulk delete)
block all keys until the operation finishes — Back will not navigate out from
under a running operation.
## Function keys
| Key | Press | Hold |
|---|---|---|
| **MSG** | Jump to the Chats tab (closes an open app first) | — |
| **HOME** | Peel one layer off an open app; on the Home tab, toggle the app drawer; otherwise jump Home | — |
| **HOME** | Peel one layer off an open app; on the Home tab, toggle the app drawer; otherwise jump Home (and clear the Back trail) | — |
| **@ (Mentions)** | Open the Mentions screen | — |
| **ADV** | Open the Send Advert page | **Toggle GPS on/off** |
| **MAP** | Jump to the Map tab — press again *on* the map to toggle pan mode | — |
@@ -77,9 +90,24 @@ app can trap the keys, even while the screen is locked.
If you last used the M9 on an earlier beta, the notable navigation changes:
- **Back now actually goes back** — once everything covering the screen was
closed, Back had nothing left to do and the press simply died on any bare
tab. It now walks back through the screens you visited, then Home. Jumps that
used to strand you ("Show on map" from Discover or Contacts, a route replay
from a chat, the Home unread line, the ✉ badge, app-drawer tiles) all return
properly. **Back also works in the first-boot wizard** now, stepping back
through its screens like the on-screen Back button.
- **Back restored and made consistent** — it had regressed to doing nothing
outside text fields, and could close hidden popups beneath the Control
Center. Both fixed; the close-what-you-see rule above now holds everywhere.
- **The map opens faster the first time** — the cold open was doing a
whole-card zoom scan whose result was thrown away, and (with no GPS fix) was
repeating that scan on *every* open. The "Loading map…" hint now appears
immediately instead of after the scan, so the board no longer looks frozen on
the previous screen while the first tiles decode.
- **The d-pad no longer goes dead for a second** when a message arrives, and
the Spectrum page no longer steers an invisible cursor around the app drawer
underneath it.
- **Apps can't strand you** — opening Advert/Mentions over a running app used
to orphan it with no key path back; app permission dialogs are now
answerable with the d-pad.
@@ -1,7 +1,7 @@
# wadamesh language file
# canonical source - translators: edit this file and PR it
# code: bg
# ver: 19
# ver: 21
# name: Български
# base: bg
(device behind) (устройството изостава)
@@ -184,6 +184,7 @@ Contact added Контактът е добавен
Contact deleted Контактът е изтрит
Contact gone Контактът изчезна
Contact missing Контактът липсва
Contact shared Контактът е споделен
Contacts Контакти
Contacts are on internal flash - the SD card did not mount at boot. Re-seat the card and reboot. Контактите са във вътрешната памет - SD картата не се монтира при старт. Постави картата отново и рестартирай.
Contacts are on internal flash. Контактите са във вътрешната памет.
@@ -778,6 +779,8 @@ Settings Настройки
Setup complete Настройката е готова
Share QR Сподели QR
Share channel Сподели канала
Share contact Сподели контакт
Share failed Споделянето неуспешно
Share location in advert Споделяй местоположението в advert
Share secret Сподели ключа
Share the 32-char secret so others can join. Leave the secret empty to generate a random one. Споделете 32-символната тайна, за да се присъединят други. Празно = случайна.
@@ -1103,3 +1106,11 @@ Glance shows message previews\nwhile the screen is locked Glance shows message p
Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction
N/A N/A
This board cannot run this app. This board cannot run this app.
Signal Signal
Signal (stale) Signal (stale)
nothing heard yet nothing heard yet
Detecting the keyboard protocol Detecting the keyboard protocol
Older keyboard protocol Older keyboard protocol
Using the older keyboard protocol Using the older keyboard protocol
turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching
Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it
@@ -1,7 +1,7 @@
# wadamesh language file
# canonical source - translators: edit this file and PR it
# code: de
# ver: 19
# ver: 21
# name: Deutsch
# base: de
(device behind) (Gerät geht nach)
@@ -184,6 +184,7 @@ Contact added Kontakt hinzugefügt
Contact deleted Kontakt gelöscht
Contact gone Kontakt weg
Contact missing Kontakt fehlt
Contact shared Kontakt geteilt
Contacts Kontakte
Contacts are on internal flash - the SD card did not mount at boot. Re-seat the card and reboot. Kontakte liegen im internen Flash - die SD-Karte wurde beim Start nicht eingebunden. Karte neu einsetzen und neu starten.
Contacts are on internal flash. Kontakte liegen im internen Flash.
@@ -777,6 +778,8 @@ Settings Einstellungen
Setup complete Einrichtung abgeschlossen
Share QR QR teilen
Share channel Kanal teilen
Share contact Kontakt teilen
Share failed Teilen fehlgeschlagen
Share location in advert Standort im Advert teilen
Share secret Geheimnis teilen
Share the 32-char secret so others can join. Leave the secret empty to generate a random one. Teilen Sie das 32-Zeichen-Geheimnis, damit andere beitreten. Leer lassen für ein zufälliges.
@@ -1099,3 +1102,11 @@ Glance shows message previews\nwhile the screen is locked Glance shows message p
Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction
N/A N/A
This board cannot run this app. This board cannot run this app.
Signal Signal
Signal (stale) Signal (stale)
nothing heard yet nothing heard yet
Detecting the keyboard protocol Detecting the keyboard protocol
Older keyboard protocol Older keyboard protocol
Using the older keyboard protocol Using the older keyboard protocol
turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching
Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it
@@ -1,7 +1,7 @@
# wadamesh language file
# canonical source - translators: edit this file and PR it
# code: el
# ver: 19
# ver: 21
# name: Ελληνικά
# base: el
(device behind) (συσκευή πίσω)
@@ -184,6 +184,7 @@ Contact added Η επαφή προστέθηκε
Contact deleted Η επαφή διαγράφηκε
Contact gone Η επαφή χάθηκε
Contact missing Η επαφή λείπει
Contact shared Η επαφή κοινοποιήθηκε
Contacts Επαφές
Contacts are on internal flash - the SD card did not mount at boot. Re-seat the card and reboot. Οι επαφές είναι στην εσωτερική μνήμη - η κάρτα SD δεν προσαρτήθηκε στην εκκίνηση. Επανατοποθετήστε την και επανεκκινήστε.
Contacts are on internal flash. Οι επαφές είναι στην εσωτερική μνήμη.
@@ -778,6 +779,8 @@ Settings Ρυθμίσεις
Setup complete Η ρύθμιση ολοκληρώθηκε
Share QR Κοινοποίηση QR
Share channel Κοινή χρήση καναλιού
Share contact Κοινοποίηση επαφής
Share failed Αποτυχία κοινοποίησης
Share location in advert Κοινή χρήση θέσης στο advert
Share secret Κοινοποίηση μυστικού
Share the 32-char secret so others can join. Leave the secret empty to generate a random one. Μοιραστείτε το μυστικό 32 χαρακτήρων για να συμμετάσχουν άλλοι. Κενό = τυχαίο.
@@ -1103,3 +1106,11 @@ Glance shows message previews\nwhile the screen is locked Glance shows message p
Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction
N/A N/A
This board cannot run this app. This board cannot run this app.
Signal Signal
Signal (stale) Signal (stale)
nothing heard yet nothing heard yet
Detecting the keyboard protocol Detecting the keyboard protocol
Older keyboard protocol Older keyboard protocol
Using the older keyboard protocol Using the older keyboard protocol
turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching
Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it
@@ -1,7 +1,7 @@
# wadamesh language file
# canonical source - translators: edit this file and PR it
# code: es
# ver: 19
# ver: 21
# name: Español
# base: es
(device behind) (dispositivo atrasado)
@@ -184,6 +184,7 @@ Contact added Contacto añadido
Contact deleted Contacto eliminado
Contact gone Contacto perdido
Contact missing Falta el contacto
Contact shared Contacto compartido
Contacts Contactos
Contacts are on internal flash - the SD card did not mount at boot. Re-seat the card and reboot. Los contactos están en memoria interna - la tarjeta SD no se montó al arrancar. Reinserta la tarjeta y reinicia.
Contacts are on internal flash. Los contactos están en la memoria interna.
@@ -778,6 +779,8 @@ Settings Ajustes
Setup complete Configuración completa
Share QR Compartir QR
Share channel Compartir canal
Share contact Compartir contacto
Share failed Error al compartir
Share location in advert Compartir ubicación en anuncio
Share secret Compartir secreto
Share the 32-char secret so others can join. Leave the secret empty to generate a random one. Comparte el secreto de 32 caracteres para que otros se unan. Déjalo vacío para uno aleatorio.
@@ -1103,3 +1106,11 @@ Glance shows message previews\nwhile the screen is locked Glance shows message p
Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction
N/A N/A
This board cannot run this app. This board cannot run this app.
Signal Signal
Signal (stale) Signal (stale)
nothing heard yet nothing heard yet
Detecting the keyboard protocol Detecting the keyboard protocol
Older keyboard protocol Older keyboard protocol
Using the older keyboard protocol Using the older keyboard protocol
turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching
Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it
@@ -1,7 +1,7 @@
# wadamesh language file
# canonical source - translators: edit this file and PR it
# code: fr
# ver: 19
# ver: 21
# name: Français
# base: fr
(device behind) (appareil en retard)
@@ -184,6 +184,7 @@ Contact added Contact ajouté
Contact deleted Contact supprimé
Contact gone Contact perdu
Contact missing Contact manquant
Contact shared Contact partagé
Contacts Contacts
Contacts are on internal flash - the SD card did not mount at boot. Re-seat the card and reboot. Les contacts sont en mémoire interne - la carte SD ne s'est pas montée au démarrage. Réinsérez la carte et redémarrez.
Contacts are on internal flash. Les contacts sont sur la mémoire interne.
@@ -777,6 +778,8 @@ Settings Paramètres
Setup complete Configuration terminée
Share QR Partager le QR
Share channel Partager le canal
Share contact Partager le contact
Share failed Échec du partage
Share location in advert Partager la position dans l'annonce
Share secret Partager le secret
Share the 32-char secret so others can join. Leave the secret empty to generate a random one. Partagez le secret de 32 caractères pour que d'autres rejoignent. Laissez vide pour un aléatoire.
@@ -1102,3 +1105,11 @@ Glance shows message previews\nwhile the screen is locked Glance shows message p
Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction
N/A N/A
This board cannot run this app. This board cannot run this app.
Signal Signal
Signal (stale) Signal (stale)
nothing heard yet nothing heard yet
Detecting the keyboard protocol Detecting the keyboard protocol
Older keyboard protocol Older keyboard protocol
Using the older keyboard protocol Using the older keyboard protocol
turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching
Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it
@@ -1,7 +1,7 @@
# wadamesh language file
# canonical source - translators: edit this file and PR it
# code: hu
# ver: 19
# ver: 21
# name: Magyar
# base: hu
(device behind) (készülék hátulján)
@@ -186,6 +186,7 @@ Contact added Névjegy hozzáadva
Contact deleted Névjegy törölve
Contact gone Eltűnt névjegy
Contact missing Hiányzó névjegy
Contact shared Névjegy megosztva
Contacts Névjegyek
Contacts are on internal flash - the SD card did not mount at boot. Re-seat the card and reboot. A névjegyek a belső tárolón vannak - az SD-kártya nem csatolódott induláskor. Helyezd be újra a kártyát, és indítsd újra.
Contacts are on internal flash. A névjegyek a belső tárolón vannak.
@@ -400,7 +401,7 @@ Join a private channel Belépés priváthoz
Join hashtag channel Csatlakozz # csatornához
Join network Csatlakozás a hálózathoz
Join private channel Csatlakozz privát csatornához
Join the public channel Csatlakozás a nyilvános csatornához
Join the public channel Belépés a nyilvánoshoz
Joined %.40s Csatlakozott %.40s alatt
Keep per chat (messages) Megőrzés csevegésenként (üzenet)
Key already in use Kulcs már hozzárendelve
@@ -413,7 +414,7 @@ Keyboard nav: WASDZ on Billentyűzet navigáció: WASDZ bekapcsolva
Keyboard nav: off Billentyűzetnavigáció: kikapcsolva
Keyboard navigation Billentyűzetnavigáció
Keys Kulcsok
Known regions Known regions
Known regions Ismert régiók
LOS → %s Rálátás → %s
Landscape (desktop) Fekvő (asztali)
Landscape set - applies when remote mode starts. Fekvő beállítva - a távoli mód indulásakor lép életbe.
@@ -471,7 +472,7 @@ Map — no tile pack on SPIFFS yet.\n\nUpload a tile pack to /tiles/<z>/<x>/<y>.
Map — set your location\nin Settings → Profile to\nshow the map here. Térkép – a Beállítások → Profil menüpontban állítsd be a tartózkodási helyedet, hogy itt jelenjen meg a térkép.
Mark all chats and channels as read? Az összes csevegést és csatornát megjelölöd olvasottként?
Mark as read Olvasottnak jelöl
Mark read Mark read
Mark read Olvasott
Marked read Olvasottként megjelölve
Max hops (0..64) Max ugrások (0-64)
Max zoom for this pack Maximális zoom ehhez a csomaghoz
@@ -791,6 +792,8 @@ Settings Beállítások
Setup complete Beállítás kész
Share QR QR megosztása
Share channel Csatorna megosztása
Share contact Névjegy megosztása
Share failed Megosztás sikertelen
Share location in advert Helyszín megosztása a hirdetésben
Share secret Titok megosztása
Share the 32-char secret so others can join. Leave the secret empty to generate a random one. Oszd meg a 32 karakteres titkot, hogy mások is csatlakozhassanak. Hagyd üresen a titkot, hogy véletlenszerű titkos kódot generálj.
@@ -896,7 +899,7 @@ Turn this on, then open the address below in any web browser on the same Wi-Fi -
Type Tipus
Type a URL, then Go Írj be egy URL-t, majd Mehet
Type a message... Írj be egy üzenetet...
Type a new 6-digit code · applies after a reboot Írjon be egy új 6 jegyű kódot · újraindítás után érvényes
Type a new 6-digit code · applies after a reboot Írjon be egy 6 jegyű kódot · újraindítás után él
Type your network name — or switch to Wi-Fi in Settings to scan Írd be a hálózatod nevét – vagy válts Wi-Fi-re a beállításokban a kereséshez
UI size (restart to apply) UI mérete (újraindítás az alkalmazáshoz)
UI size saved — restart to apply UI mérete mentve – indítsa újra a programot az alkalmazáshoz
@@ -1121,3 +1124,10 @@ Glance shows message previews\nwhile the screen is locked Glance shows message p
Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction
N/A N/A
This board cannot run this app. This board cannot run this app.
Signal (stale) Jel (elavult)
nothing heard yet Még semmi hír.
Detecting the keyboard protocol Detecting the keyboard protocol
Older keyboard protocol Older keyboard protocol
Using the older keyboard protocol Using the older keyboard protocol
turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching
Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it
@@ -1,7 +1,7 @@
# wadamesh language file
# canonical source - translators: edit this file and PR it
# code: it
# ver: 19
# ver: 21
# name: Italiano
# base: it
(device behind) (dispositivo indietro)
@@ -184,6 +184,7 @@ Contact added Contatto aggiunto
Contact deleted Contatto eliminato
Contact gone Contatto perso
Contact missing Contatto mancante
Contact shared Contatto condiviso
Contacts Contatti
Contacts are on internal flash - the SD card did not mount at boot. Re-seat the card and reboot. I contatti sono sulla memoria interna - la scheda SD non si è montata all'avvio. Reinserisci la scheda e riavvia.
Contacts are on internal flash. I contatti sono sulla memoria interna.
@@ -777,6 +778,8 @@ Settings Impostazioni
Setup complete Configurazione completata
Share QR Condividi QR
Share channel Condividi canale
Share contact Condividi contatto
Share failed Condivisione non riuscita
Share location in advert Condividi posizione in advert
Share secret Condividi segreto
Share the 32-char secret so others can join. Leave the secret empty to generate a random one. Condividi il segreto di 32 caratteri per far unire altri. Lascia vuoto per uno casuale.
@@ -1102,3 +1105,11 @@ Glance shows message previews\nwhile the screen is locked Glance shows message p
Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction
N/A N/A
This board cannot run this app. This board cannot run this app.
Signal Signal
Signal (stale) Signal (stale)
nothing heard yet nothing heard yet
Detecting the keyboard protocol Detecting the keyboard protocol
Older keyboard protocol Older keyboard protocol
Using the older keyboard protocol Using the older keyboard protocol
turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching
Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it
@@ -1,7 +1,7 @@
# wadamesh language file
# canonical source - translators: edit this file and PR it
# code: nl
# ver: 19
# ver: 21
# name: Nederlands
# base: nl
(device behind) (apparaat loopt achter)
@@ -184,6 +184,7 @@ Contact added Contact toegevoegd
Contact deleted Contact verwijderd
Contact gone Contact weg
Contact missing Contact ontbreekt
Contact shared Contact gedeeld
Contacts Contacten
Contacts are on internal flash - the SD card did not mount at boot. Re-seat the card and reboot. Contacten staan op intern flash - de SD-kaart is bij het opstarten niet gekoppeld. Plaats de kaart opnieuw en herstart.
Contacts are on internal flash. Contacten staan op intern flashgeheugen.
@@ -777,6 +778,8 @@ Settings Instellingen
Setup complete Installatie voltooid
Share QR QR delen
Share channel Kanaal delen
Share contact Contact delen
Share failed Delen mislukt
Share location in advert Locatie delen in advert
Share secret Geheim delen
Share the 32-char secret so others can join. Leave the secret empty to generate a random one. Deel het 32-teken geheim zodat anderen kunnen deelnemen. Leeg laten voor een willekeurig geheim.
@@ -1100,3 +1103,11 @@ Glance shows message previews\nwhile the screen is locked Glance shows message p
Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction
N/A N/A
This board cannot run this app. This board cannot run this app.
Signal Signal
Signal (stale) Signal (stale)
nothing heard yet nothing heard yet
Detecting the keyboard protocol Detecting the keyboard protocol
Older keyboard protocol Older keyboard protocol
Using the older keyboard protocol Using the older keyboard protocol
turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching
Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it
@@ -1,7 +1,7 @@
# wadamesh language file
# canonical source - translators: edit this file and PR it
# code: pt-br
# ver: 19
# ver: 21
# name: Português (BR)
# base: pt-br
(device behind) (dispositivo atrasado)
@@ -184,6 +184,7 @@ Contact added Contato adicionado
Contact deleted Contato excluído
Contact gone Contato sumiu
Contact missing Contato ausente
Contact shared Contato compartilhado
Contacts Contatos
Contacts are on internal flash - the SD card did not mount at boot. Re-seat the card and reboot. Os contatos estão na memória interna - o cartão SD não montou na inicialização. Recoloque o cartão e reinicie.
Contacts are on internal flash. Os contatos estão na memória interna.
@@ -778,6 +779,8 @@ Settings Ajustes
Setup complete Configuração concluída
Share QR Compartilhar QR
Share channel Compartilhar canal
Share contact Compartilhar contato
Share failed Falha ao compartilhar
Share location in advert Compartilhar local no advert
Share secret Compartilhar segredo
Share the 32-char secret so others can join. Leave the secret empty to generate a random one. Compartilhe o segredo de 32 caracteres para outros entrarem. Deixe vazio para gerar um aleatório.
@@ -1102,3 +1105,11 @@ Glance shows message previews\nwhile the screen is locked Glance shows message p
Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction
N/A N/A
This board cannot run this app. This board cannot run this app.
Signal Signal
Signal (stale) Signal (stale)
nothing heard yet nothing heard yet
Detecting the keyboard protocol Detecting the keyboard protocol
Older keyboard protocol Older keyboard protocol
Using the older keyboard protocol Using the older keyboard protocol
turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching
Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it
@@ -1,7 +1,7 @@
# wadamesh language file
# canonical source - translators: edit this file and PR it
# code: ro
# ver: 19
# ver: 21
# name: Română
# base: ro
(device behind) (dispozitiv în urmă)
@@ -184,6 +184,7 @@ Contact added Contact adăugat
Contact deleted Contact şters
Contact gone Contact dispărut
Contact missing Contact lipsă
Contact shared Contact partajat
Contacts Contacte
Contacts are on internal flash - the SD card did not mount at boot. Re-seat the card and reboot. Contactele sunt pe memoria internă - cardul SD nu s-a montat la pornire. Reaşază cardul şi reporneşte.
Contacts are on internal flash. Contactele sunt pe memoria internă.
@@ -778,6 +779,8 @@ Settings Setări
Setup complete Configurare finalizată
Share QR Partajează QR
Share channel Partajează canalul
Share contact Partajează contactul
Share failed Partajare eşuată
Share location in advert Partajează locaţia în advert
Share secret Partajează secretul
Share the 32-char secret so others can join. Leave the secret empty to generate a random one. Partajează secretul de 32 caractere ca să poată intra şi alţii. Lasă secretul gol pentru a genera unul aleatoriu.
@@ -1104,3 +1107,11 @@ Glance shows message previews\nwhile the screen is locked Glance shows message p
Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction
N/A N/A
This board cannot run this app. This board cannot run this app.
Signal Signal
Signal (stale) Signal (stale)
nothing heard yet nothing heard yet
Detecting the keyboard protocol Detecting the keyboard protocol
Older keyboard protocol Older keyboard protocol
Using the older keyboard protocol Using the older keyboard protocol
turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching
Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it
@@ -1,7 +1,7 @@
# wadamesh language file
# canonical source - translators: edit this file and PR it
# code: ru
# ver: 19
# ver: 21
# name: Русский
# base: ru
(device behind) (устройство отстаёт)
@@ -184,6 +184,7 @@ Contact added Контакт добавлен
Contact deleted Контакт удалён
Contact gone Контакт пропал
Contact missing Контакт отсутствует
Contact shared Контакт отправлен
Contacts Контакты
Contacts are on internal flash - the SD card did not mount at boot. Re-seat the card and reboot. Контакты во внутренней памяти - SD-карта не смонтировалась при загрузке. Переустановите карту и перезагрузитесь.
Contacts are on internal flash. Контакты во внутренней памяти.
@@ -778,6 +779,8 @@ Settings Настройки
Setup complete Настройка завершена
Share QR Поделиться QR
Share channel Поделиться каналом
Share contact Поделиться контактом
Share failed Не удалось отправить контакт
Share location in advert Делиться местоположением в advert
Share secret Поделиться ключом
Share the 32-char secret so others can join. Leave the secret empty to generate a random one. Поделитесь секретом из 32 символов, чтобы другие присоединились. Пусто = случайный.
@@ -1103,3 +1106,11 @@ Glance shows message previews\nwhile the screen is locked Glance shows message p
Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction
N/A N/A
This board cannot run this app. This board cannot run this app.
Signal Signal
Signal (stale) Signal (stale)
nothing heard yet nothing heard yet
Detecting the keyboard protocol Detecting the keyboard protocol
Older keyboard protocol Older keyboard protocol
Using the older keyboard protocol Using the older keyboard protocol
turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching
Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it
@@ -1,7 +1,7 @@
# wadamesh language file
# canonical source - translators: edit this file and PR it
# code: sr
# ver: 19
# ver: 21
# name: Српски
# base: sr
(device behind) (уређај касни)
@@ -184,6 +184,7 @@ Contact added Контакт додат
Contact deleted Контакт обрисан
Contact gone Контакт нестао
Contact missing Контакт недостаје
Contact shared Контакт подељен
Contacts Контакти
Contacts are on internal flash - the SD card did not mount at boot. Re-seat the card and reboot. Контакти су у унутрашњој меморији - SD картица се није монтирала при покретању. Поново убаци картицу и рестартуј.
Contacts are on internal flash. Контакти су у унутрашњој меморији.
@@ -778,6 +779,8 @@ Settings Подешавања
Setup complete Подешавање завршено
Share QR Подели QR
Share channel Подели канал
Share contact Подели контакт
Share failed Дељење није успело
Share location in advert Дели локацију у advert-у
Share secret Подели тајну
Share the 32-char secret so others can join. Leave the secret empty to generate a random one. Поделите тајну од 32 знака да се други придруже. Празно = насумична.
@@ -1103,3 +1106,11 @@ Glance shows message previews\nwhile the screen is locked Glance shows message p
Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction
N/A N/A
This board cannot run this app. This board cannot run this app.
Signal Signal
Signal (stale) Signal (stale)
nothing heard yet nothing heard yet
Detecting the keyboard protocol Detecting the keyboard protocol
Older keyboard protocol Older keyboard protocol
Using the older keyboard protocol Using the older keyboard protocol
turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching
Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it
@@ -1,7 +1,7 @@
# wadamesh language file
# canonical source - translators: edit this file and PR it
# code: uk
# ver: 19
# ver: 21
# name: Українська
# base: uk
(device behind) (пристрій відстає)
@@ -184,6 +184,7 @@ Contact added Контакт додано
Contact deleted Контакт видалено
Contact gone Контакт зник
Contact missing Контакт відсутній
Contact shared Контакт надіслано
Contacts Контакти
Contacts are on internal flash - the SD card did not mount at boot. Re-seat the card and reboot. Контакти у внутрішній пам'яті - SD-карта не змонтувалася під час завантаження. Переставте картку і перезавантажте.
Contacts are on internal flash. Контакти у внутрішній пам'яті.
@@ -778,6 +779,8 @@ Settings Налаштування
Setup complete Налаштування завершено
Share QR Поділитися QR
Share channel Поділитися каналом
Share contact Поділитися контактом
Share failed Не вдалося надіслати контакт
Share location in advert Ділитися місцезнаходженням в advert
Share secret Поділитися ключем
Share the 32-char secret so others can join. Leave the secret empty to generate a random one. Поділіться секретом із 32 символів, щоб інші приєдналися. Порожньо = випадковий.
@@ -1103,3 +1106,11 @@ Glance shows message previews\nwhile the screen is locked Glance shows message p
Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction
N/A N/A
This board cannot run this app. This board cannot run this app.
Signal Signal
Signal (stale) Signal (stale)
nothing heard yet nothing heard yet
Detecting the keyboard protocol Detecting the keyboard protocol
Older keyboard protocol Older keyboard protocol
Using the older keyboard protocol Using the older keyboard protocol
turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching
Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it
+12 -1
View File
@@ -1,7 +1,7 @@
# wadamesh language file
# canonical source - translators: edit this file and PR it
# code: bg
# ver: 19
# ver: 21
# name: Български
# base: bg
(device behind) (устройството изостава)
@@ -184,6 +184,7 @@ Contact added Контактът е добавен
Contact deleted Контактът е изтрит
Contact gone Контактът изчезна
Contact missing Контактът липсва
Contact shared Контактът е споделен
Contacts Контакти
Contacts are on internal flash - the SD card did not mount at boot. Re-seat the card and reboot. Контактите са във вътрешната памет - SD картата не се монтира при старт. Постави картата отново и рестартирай.
Contacts are on internal flash. Контактите са във вътрешната памет.
@@ -778,6 +779,8 @@ Settings Настройки
Setup complete Настройката е готова
Share QR Сподели QR
Share channel Сподели канала
Share contact Сподели контакт
Share failed Споделянето неуспешно
Share location in advert Споделяй местоположението в advert
Share secret Сподели ключа
Share the 32-char secret so others can join. Leave the secret empty to generate a random one. Споделете 32-символната тайна, за да се присъединят други. Празно = случайна.
@@ -1103,3 +1106,11 @@ Glance shows message previews\nwhile the screen is locked Glance shows message p
Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction
N/A N/A
This board cannot run this app. This board cannot run this app.
Signal Signal
Signal (stale) Signal (stale)
nothing heard yet nothing heard yet
Detecting the keyboard protocol Detecting the keyboard protocol
Older keyboard protocol Older keyboard protocol
Using the older keyboard protocol Using the older keyboard protocol
turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching
Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it
+12 -1
View File
@@ -1,7 +1,7 @@
# wadamesh language file
# canonical source - translators: edit this file and PR it
# code: de
# ver: 19
# ver: 21
# name: Deutsch
# base: de
(device behind) (Gerät geht nach)
@@ -184,6 +184,7 @@ Contact added Kontakt hinzugefügt
Contact deleted Kontakt gelöscht
Contact gone Kontakt weg
Contact missing Kontakt fehlt
Contact shared Kontakt geteilt
Contacts Kontakte
Contacts are on internal flash - the SD card did not mount at boot. Re-seat the card and reboot. Kontakte liegen im internen Flash - die SD-Karte wurde beim Start nicht eingebunden. Karte neu einsetzen und neu starten.
Contacts are on internal flash. Kontakte liegen im internen Flash.
@@ -777,6 +778,8 @@ Settings Einstellungen
Setup complete Einrichtung abgeschlossen
Share QR QR teilen
Share channel Kanal teilen
Share contact Kontakt teilen
Share failed Teilen fehlgeschlagen
Share location in advert Standort im Advert teilen
Share secret Geheimnis teilen
Share the 32-char secret so others can join. Leave the secret empty to generate a random one. Teilen Sie das 32-Zeichen-Geheimnis, damit andere beitreten. Leer lassen für ein zufälliges.
@@ -1099,3 +1102,11 @@ Glance shows message previews\nwhile the screen is locked Glance shows message p
Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction
N/A N/A
This board cannot run this app. This board cannot run this app.
Signal Signal
Signal (stale) Signal (stale)
nothing heard yet nothing heard yet
Detecting the keyboard protocol Detecting the keyboard protocol
Older keyboard protocol Older keyboard protocol
Using the older keyboard protocol Using the older keyboard protocol
turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching
Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it
+12 -1
View File
@@ -1,7 +1,7 @@
# wadamesh language file
# canonical source - translators: edit this file and PR it
# code: el
# ver: 19
# ver: 21
# name: Ελληνικά
# base: el
(device behind) (συσκευή πίσω)
@@ -184,6 +184,7 @@ Contact added Η επαφή προστέθηκε
Contact deleted Η επαφή διαγράφηκε
Contact gone Η επαφή χάθηκε
Contact missing Η επαφή λείπει
Contact shared Η επαφή κοινοποιήθηκε
Contacts Επαφές
Contacts are on internal flash - the SD card did not mount at boot. Re-seat the card and reboot. Οι επαφές είναι στην εσωτερική μνήμη - η κάρτα SD δεν προσαρτήθηκε στην εκκίνηση. Επανατοποθετήστε την και επανεκκινήστε.
Contacts are on internal flash. Οι επαφές είναι στην εσωτερική μνήμη.
@@ -778,6 +779,8 @@ Settings Ρυθμίσεις
Setup complete Η ρύθμιση ολοκληρώθηκε
Share QR Κοινοποίηση QR
Share channel Κοινή χρήση καναλιού
Share contact Κοινοποίηση επαφής
Share failed Αποτυχία κοινοποίησης
Share location in advert Κοινή χρήση θέσης στο advert
Share secret Κοινοποίηση μυστικού
Share the 32-char secret so others can join. Leave the secret empty to generate a random one. Μοιραστείτε το μυστικό 32 χαρακτήρων για να συμμετάσχουν άλλοι. Κενό = τυχαίο.
@@ -1103,3 +1106,11 @@ Glance shows message previews\nwhile the screen is locked Glance shows message p
Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction
N/A N/A
This board cannot run this app. This board cannot run this app.
Signal Signal
Signal (stale) Signal (stale)
nothing heard yet nothing heard yet
Detecting the keyboard protocol Detecting the keyboard protocol
Older keyboard protocol Older keyboard protocol
Using the older keyboard protocol Using the older keyboard protocol
turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching
Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it
+12 -1
View File
@@ -1,7 +1,7 @@
# wadamesh language file
# canonical source - translators: edit this file and PR it
# code: es
# ver: 19
# ver: 21
# name: Español
# base: es
(device behind) (dispositivo atrasado)
@@ -184,6 +184,7 @@ Contact added Contacto añadido
Contact deleted Contacto eliminado
Contact gone Contacto perdido
Contact missing Falta el contacto
Contact shared Contacto compartido
Contacts Contactos
Contacts are on internal flash - the SD card did not mount at boot. Re-seat the card and reboot. Los contactos están en memoria interna - la tarjeta SD no se montó al arrancar. Reinserta la tarjeta y reinicia.
Contacts are on internal flash. Los contactos están en la memoria interna.
@@ -778,6 +779,8 @@ Settings Ajustes
Setup complete Configuración completa
Share QR Compartir QR
Share channel Compartir canal
Share contact Compartir contacto
Share failed Error al compartir
Share location in advert Compartir ubicación en anuncio
Share secret Compartir secreto
Share the 32-char secret so others can join. Leave the secret empty to generate a random one. Comparte el secreto de 32 caracteres para que otros se unan. Déjalo vacío para uno aleatorio.
@@ -1103,3 +1106,11 @@ Glance shows message previews\nwhile the screen is locked Glance shows message p
Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction
N/A N/A
This board cannot run this app. This board cannot run this app.
Signal Signal
Signal (stale) Signal (stale)
nothing heard yet nothing heard yet
Detecting the keyboard protocol Detecting the keyboard protocol
Older keyboard protocol Older keyboard protocol
Using the older keyboard protocol Using the older keyboard protocol
turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching
Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it
+12 -1
View File
@@ -1,7 +1,7 @@
# wadamesh language file
# canonical source - translators: edit this file and PR it
# code: fr
# ver: 19
# ver: 21
# name: Français
# base: fr
(device behind) (appareil en retard)
@@ -184,6 +184,7 @@ Contact added Contact ajouté
Contact deleted Contact supprimé
Contact gone Contact perdu
Contact missing Contact manquant
Contact shared Contact partagé
Contacts Contacts
Contacts are on internal flash - the SD card did not mount at boot. Re-seat the card and reboot. Les contacts sont en mémoire interne - la carte SD ne s'est pas montée au démarrage. Réinsérez la carte et redémarrez.
Contacts are on internal flash. Les contacts sont sur la mémoire interne.
@@ -777,6 +778,8 @@ Settings Paramètres
Setup complete Configuration terminée
Share QR Partager le QR
Share channel Partager le canal
Share contact Partager le contact
Share failed Échec du partage
Share location in advert Partager la position dans l'annonce
Share secret Partager le secret
Share the 32-char secret so others can join. Leave the secret empty to generate a random one. Partagez le secret de 32 caractères pour que d'autres rejoignent. Laissez vide pour un aléatoire.
@@ -1102,3 +1105,11 @@ Glance shows message previews\nwhile the screen is locked Glance shows message p
Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction
N/A N/A
This board cannot run this app. This board cannot run this app.
Signal Signal
Signal (stale) Signal (stale)
nothing heard yet nothing heard yet
Detecting the keyboard protocol Detecting the keyboard protocol
Older keyboard protocol Older keyboard protocol
Using the older keyboard protocol Using the older keyboard protocol
turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching
Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it
+15 -5
View File
@@ -1,7 +1,7 @@
# wadamesh language file
# canonical source - translators: edit this file and PR it
# code: hu
# ver: 19
# ver: 21
# name: Magyar
# base: hu
(device behind) (készülék hátulján)
@@ -186,6 +186,7 @@ Contact added Névjegy hozzáadva
Contact deleted Névjegy törölve
Contact gone Eltűnt névjegy
Contact missing Hiányzó névjegy
Contact shared Névjegy megosztva
Contacts Névjegyek
Contacts are on internal flash - the SD card did not mount at boot. Re-seat the card and reboot. A névjegyek a belső tárolón vannak - az SD-kártya nem csatolódott induláskor. Helyezd be újra a kártyát, és indítsd újra.
Contacts are on internal flash. A névjegyek a belső tárolón vannak.
@@ -400,7 +401,7 @@ Join a private channel Belépés priváthoz
Join hashtag channel Csatlakozz # csatornához
Join network Csatlakozás a hálózathoz
Join private channel Csatlakozz privát csatornához
Join the public channel Csatlakozás a nyilvános csatornához
Join the public channel Belépés a nyilvánoshoz
Joined %.40s Csatlakozott %.40s alatt
Keep per chat (messages) Megőrzés csevegésenként (üzenet)
Key already in use Kulcs már hozzárendelve
@@ -413,7 +414,7 @@ Keyboard nav: WASDZ on Billentyűzet navigáció: WASDZ bekapcsolva
Keyboard nav: off Billentyűzetnavigáció: kikapcsolva
Keyboard navigation Billentyűzetnavigáció
Keys Kulcsok
Known regions Known regions
Known regions Ismert régiók
LOS → %s Rálátás → %s
Landscape (desktop) Fekvő (asztali)
Landscape set - applies when remote mode starts. Fekvő beállítva - a távoli mód indulásakor lép életbe.
@@ -471,7 +472,7 @@ Map — no tile pack on SPIFFS yet.\n\nUpload a tile pack to /tiles/<z>/<x>/<y>.
Map — set your location\nin Settings → Profile to\nshow the map here. Térkép – a Beállítások → Profil menüpontban állítsd be a tartózkodási helyedet, hogy itt jelenjen meg a térkép.
Mark all chats and channels as read? Az összes csevegést és csatornát megjelölöd olvasottként?
Mark as read Olvasottnak jelöl
Mark read Mark read
Mark read Olvasott
Marked read Olvasottként megjelölve
Max hops (0..64) Max ugrások (0-64)
Max zoom for this pack Maximális zoom ehhez a csomaghoz
@@ -791,6 +792,8 @@ Settings Beállítások
Setup complete Beállítás kész
Share QR QR megosztása
Share channel Csatorna megosztása
Share contact Névjegy megosztása
Share failed Megosztás sikertelen
Share location in advert Helyszín megosztása a hirdetésben
Share secret Titok megosztása
Share the 32-char secret so others can join. Leave the secret empty to generate a random one. Oszd meg a 32 karakteres titkot, hogy mások is csatlakozhassanak. Hagyd üresen a titkot, hogy véletlenszerű titkos kódot generálj.
@@ -896,7 +899,7 @@ Turn this on, then open the address below in any web browser on the same Wi-Fi -
Type Tipus
Type a URL, then Go Írj be egy URL-t, majd Mehet
Type a message... Írj be egy üzenetet...
Type a new 6-digit code · applies after a reboot Írjon be egy új 6 jegyű kódot · újraindítás után érvényes
Type a new 6-digit code · applies after a reboot Írjon be egy 6 jegyű kódot · újraindítás után él
Type your network name — or switch to Wi-Fi in Settings to scan Írd be a hálózatod nevét – vagy válts Wi-Fi-re a beállításokban a kereséshez
UI size (restart to apply) UI mérete (újraindítás az alkalmazáshoz)
UI size saved — restart to apply UI mérete mentve – indítsa újra a programot az alkalmazáshoz
@@ -1121,3 +1124,10 @@ Glance shows message previews\nwhile the screen is locked Glance shows message p
Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction
N/A N/A
This board cannot run this app. This board cannot run this app.
Signal (stale) Jel (elavult)
nothing heard yet Még semmi hír.
Detecting the keyboard protocol Detecting the keyboard protocol
Older keyboard protocol Older keyboard protocol
Using the older keyboard protocol Using the older keyboard protocol
turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching
Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it
+12 -1
View File
@@ -1,7 +1,7 @@
# wadamesh language file
# canonical source - translators: edit this file and PR it
# code: it
# ver: 19
# ver: 21
# name: Italiano
# base: it
(device behind) (dispositivo indietro)
@@ -184,6 +184,7 @@ Contact added Contatto aggiunto
Contact deleted Contatto eliminato
Contact gone Contatto perso
Contact missing Contatto mancante
Contact shared Contatto condiviso
Contacts Contatti
Contacts are on internal flash - the SD card did not mount at boot. Re-seat the card and reboot. I contatti sono sulla memoria interna - la scheda SD non si è montata all'avvio. Reinserisci la scheda e riavvia.
Contacts are on internal flash. I contatti sono sulla memoria interna.
@@ -777,6 +778,8 @@ Settings Impostazioni
Setup complete Configurazione completata
Share QR Condividi QR
Share channel Condividi canale
Share contact Condividi contatto
Share failed Condivisione non riuscita
Share location in advert Condividi posizione in advert
Share secret Condividi segreto
Share the 32-char secret so others can join. Leave the secret empty to generate a random one. Condividi il segreto di 32 caratteri per far unire altri. Lascia vuoto per uno casuale.
@@ -1102,3 +1105,11 @@ Glance shows message previews\nwhile the screen is locked Glance shows message p
Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction
N/A N/A
This board cannot run this app. This board cannot run this app.
Signal Signal
Signal (stale) Signal (stale)
nothing heard yet nothing heard yet
Detecting the keyboard protocol Detecting the keyboard protocol
Older keyboard protocol Older keyboard protocol
Using the older keyboard protocol Using the older keyboard protocol
turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching
Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it
+12 -1
View File
@@ -1,7 +1,7 @@
# wadamesh language file
# canonical source - translators: edit this file and PR it
# code: nl
# ver: 19
# ver: 21
# name: Nederlands
# base: nl
(device behind) (apparaat loopt achter)
@@ -184,6 +184,7 @@ Contact added Contact toegevoegd
Contact deleted Contact verwijderd
Contact gone Contact weg
Contact missing Contact ontbreekt
Contact shared Contact gedeeld
Contacts Contacten
Contacts are on internal flash - the SD card did not mount at boot. Re-seat the card and reboot. Contacten staan op intern flash - de SD-kaart is bij het opstarten niet gekoppeld. Plaats de kaart opnieuw en herstart.
Contacts are on internal flash. Contacten staan op intern flashgeheugen.
@@ -777,6 +778,8 @@ Settings Instellingen
Setup complete Installatie voltooid
Share QR QR delen
Share channel Kanaal delen
Share contact Contact delen
Share failed Delen mislukt
Share location in advert Locatie delen in advert
Share secret Geheim delen
Share the 32-char secret so others can join. Leave the secret empty to generate a random one. Deel het 32-teken geheim zodat anderen kunnen deelnemen. Leeg laten voor een willekeurig geheim.
@@ -1100,3 +1103,11 @@ Glance shows message previews\nwhile the screen is locked Glance shows message p
Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction
N/A N/A
This board cannot run this app. This board cannot run this app.
Signal Signal
Signal (stale) Signal (stale)
nothing heard yet nothing heard yet
Detecting the keyboard protocol Detecting the keyboard protocol
Older keyboard protocol Older keyboard protocol
Using the older keyboard protocol Using the older keyboard protocol
turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching
Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it
+12 -1
View File
@@ -1,7 +1,7 @@
# wadamesh language file
# canonical source - translators: edit this file and PR it
# code: pt-br
# ver: 19
# ver: 21
# name: Português (BR)
# base: pt-br
(device behind) (dispositivo atrasado)
@@ -184,6 +184,7 @@ Contact added Contato adicionado
Contact deleted Contato excluído
Contact gone Contato sumiu
Contact missing Contato ausente
Contact shared Contato compartilhado
Contacts Contatos
Contacts are on internal flash - the SD card did not mount at boot. Re-seat the card and reboot. Os contatos estão na memória interna - o cartão SD não montou na inicialização. Recoloque o cartão e reinicie.
Contacts are on internal flash. Os contatos estão na memória interna.
@@ -778,6 +779,8 @@ Settings Ajustes
Setup complete Configuração concluída
Share QR Compartilhar QR
Share channel Compartilhar canal
Share contact Compartilhar contato
Share failed Falha ao compartilhar
Share location in advert Compartilhar local no advert
Share secret Compartilhar segredo
Share the 32-char secret so others can join. Leave the secret empty to generate a random one. Compartilhe o segredo de 32 caracteres para outros entrarem. Deixe vazio para gerar um aleatório.
@@ -1102,3 +1105,11 @@ Glance shows message previews\nwhile the screen is locked Glance shows message p
Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction
N/A N/A
This board cannot run this app. This board cannot run this app.
Signal Signal
Signal (stale) Signal (stale)
nothing heard yet nothing heard yet
Detecting the keyboard protocol Detecting the keyboard protocol
Older keyboard protocol Older keyboard protocol
Using the older keyboard protocol Using the older keyboard protocol
turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching
Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it
+12 -1
View File
@@ -1,7 +1,7 @@
# wadamesh language file
# canonical source - translators: edit this file and PR it
# code: ro
# ver: 19
# ver: 21
# name: Română
# base: ro
(device behind) (dispozitiv în urmă)
@@ -184,6 +184,7 @@ Contact added Contact adăugat
Contact deleted Contact şters
Contact gone Contact dispărut
Contact missing Contact lipsă
Contact shared Contact partajat
Contacts Contacte
Contacts are on internal flash - the SD card did not mount at boot. Re-seat the card and reboot. Contactele sunt pe memoria internă - cardul SD nu s-a montat la pornire. Reaşază cardul şi reporneşte.
Contacts are on internal flash. Contactele sunt pe memoria internă.
@@ -778,6 +779,8 @@ Settings Setări
Setup complete Configurare finalizată
Share QR Partajează QR
Share channel Partajează canalul
Share contact Partajează contactul
Share failed Partajare eşuată
Share location in advert Partajează locaţia în advert
Share secret Partajează secretul
Share the 32-char secret so others can join. Leave the secret empty to generate a random one. Partajează secretul de 32 caractere ca să poată intra şi alţii. Lasă secretul gol pentru a genera unul aleatoriu.
@@ -1104,3 +1107,11 @@ Glance shows message previews\nwhile the screen is locked Glance shows message p
Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction
N/A N/A
This board cannot run this app. This board cannot run this app.
Signal Signal
Signal (stale) Signal (stale)
nothing heard yet nothing heard yet
Detecting the keyboard protocol Detecting the keyboard protocol
Older keyboard protocol Older keyboard protocol
Using the older keyboard protocol Using the older keyboard protocol
turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching
Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it
+12 -1
View File
@@ -1,7 +1,7 @@
# wadamesh language file
# canonical source - translators: edit this file and PR it
# code: ru
# ver: 19
# ver: 21
# name: Русский
# base: ru
(device behind) (устройство отстаёт)
@@ -184,6 +184,7 @@ Contact added Контакт добавлен
Contact deleted Контакт удалён
Contact gone Контакт пропал
Contact missing Контакт отсутствует
Contact shared Контакт отправлен
Contacts Контакты
Contacts are on internal flash - the SD card did not mount at boot. Re-seat the card and reboot. Контакты во внутренней памяти - SD-карта не смонтировалась при загрузке. Переустановите карту и перезагрузитесь.
Contacts are on internal flash. Контакты во внутренней памяти.
@@ -778,6 +779,8 @@ Settings Настройки
Setup complete Настройка завершена
Share QR Поделиться QR
Share channel Поделиться каналом
Share contact Поделиться контактом
Share failed Не удалось отправить контакт
Share location in advert Делиться местоположением в advert
Share secret Поделиться ключом
Share the 32-char secret so others can join. Leave the secret empty to generate a random one. Поделитесь секретом из 32 символов, чтобы другие присоединились. Пусто = случайный.
@@ -1103,3 +1106,11 @@ Glance shows message previews\nwhile the screen is locked Glance shows message p
Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction
N/A N/A
This board cannot run this app. This board cannot run this app.
Signal Signal
Signal (stale) Signal (stale)
nothing heard yet nothing heard yet
Detecting the keyboard protocol Detecting the keyboard protocol
Older keyboard protocol Older keyboard protocol
Using the older keyboard protocol Using the older keyboard protocol
turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching
Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it
+12 -1
View File
@@ -1,7 +1,7 @@
# wadamesh language file
# canonical source - translators: edit this file and PR it
# code: sr
# ver: 19
# ver: 21
# name: Српски
# base: sr
(device behind) (уређај касни)
@@ -184,6 +184,7 @@ Contact added Контакт додат
Contact deleted Контакт обрисан
Contact gone Контакт нестао
Contact missing Контакт недостаје
Contact shared Контакт подељен
Contacts Контакти
Contacts are on internal flash - the SD card did not mount at boot. Re-seat the card and reboot. Контакти су у унутрашњој меморији - SD картица се није монтирала при покретању. Поново убаци картицу и рестартуј.
Contacts are on internal flash. Контакти су у унутрашњој меморији.
@@ -778,6 +779,8 @@ Settings Подешавања
Setup complete Подешавање завршено
Share QR Подели QR
Share channel Подели канал
Share contact Подели контакт
Share failed Дељење није успело
Share location in advert Дели локацију у advert-у
Share secret Подели тајну
Share the 32-char secret so others can join. Leave the secret empty to generate a random one. Поделите тајну од 32 знака да се други придруже. Празно = насумична.
@@ -1103,3 +1106,11 @@ Glance shows message previews\nwhile the screen is locked Glance shows message p
Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction
N/A N/A
This board cannot run this app. This board cannot run this app.
Signal Signal
Signal (stale) Signal (stale)
nothing heard yet nothing heard yet
Detecting the keyboard protocol Detecting the keyboard protocol
Older keyboard protocol Older keyboard protocol
Using the older keyboard protocol Using the older keyboard protocol
turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching
Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it
+12 -1
View File
@@ -1,7 +1,7 @@
# wadamesh language file
# canonical source - translators: edit this file and PR it
# code: uk
# ver: 19
# ver: 21
# name: Українська
# base: uk
(device behind) (пристрій відстає)
@@ -184,6 +184,7 @@ Contact added Контакт додано
Contact deleted Контакт видалено
Contact gone Контакт зник
Contact missing Контакт відсутній
Contact shared Контакт надіслано
Contacts Контакти
Contacts are on internal flash - the SD card did not mount at boot. Re-seat the card and reboot. Контакти у внутрішній пам'яті - SD-карта не змонтувалася під час завантаження. Переставте картку і перезавантажте.
Contacts are on internal flash. Контакти у внутрішній пам'яті.
@@ -778,6 +779,8 @@ Settings Налаштування
Setup complete Налаштування завершено
Share QR Поділитися QR
Share channel Поділитися каналом
Share contact Поділитися контактом
Share failed Не вдалося надіслати контакт
Share location in advert Ділитися місцезнаходженням в advert
Share secret Поділитися ключем
Share the 32-char secret so others can join. Leave the secret empty to generate a random one. Поділіться секретом із 32 символів, щоб інші приєдналися. Порожньо = випадковий.
@@ -1103,3 +1106,11 @@ Glance shows message previews\nwhile the screen is locked Glance shows message p
Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction
N/A N/A
This board cannot run this app. This board cannot run this app.
Signal Signal
Signal (stale) Signal (stale)
nothing heard yet nothing heard yet
Detecting the keyboard protocol Detecting the keyboard protocol
Older keyboard protocol Older keyboard protocol
Using the older keyboard protocol Using the older keyboard protocol
turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching
Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it
+13 -13
View File
@@ -3,67 +3,67 @@
{
"code": "bg",
"name": "Български",
"ver": "19"
"ver": "21"
},
{
"code": "de",
"name": "Deutsch",
"ver": "19"
"ver": "21"
},
{
"code": "el",
"name": "Ελληνικά",
"ver": "19"
"ver": "21"
},
{
"code": "es",
"name": "Español",
"ver": "19"
"ver": "21"
},
{
"code": "fr",
"name": "Français",
"ver": "19"
"ver": "21"
},
{
"code": "hu",
"name": "Magyar",
"ver": "19"
"ver": "21"
},
{
"code": "it",
"name": "Italiano",
"ver": "19"
"ver": "21"
},
{
"code": "nl",
"name": "Nederlands",
"ver": "19"
"ver": "21"
},
{
"code": "pt-br",
"name": "Português (BR)",
"ver": "19"
"ver": "21"
},
{
"code": "ro",
"name": "Română",
"ver": "19"
"ver": "21"
},
{
"code": "ru",
"name": "Русский",
"ver": "19"
"ver": "21"
},
{
"code": "sr",
"name": "Српски",
"ver": "19"
"ver": "21"
},
{
"code": "uk",
"name": "Українська",
"ver": "19"
"ver": "21"
}
]
}
+18
View File
@@ -0,0 +1,18 @@
# beta_72 - test build. Stable stays beta_65.
# One user-facing note per non-blank, non-# line; # lines are section comments.
# --- Two things broken in the last two betas ------------------------------------------------
Console mode could be entered on a board that had no way to type into it. On the ThinkNode M9 it painted the prompt, showed the line telling you to type 'ui' to get back, and accepted nothing: a power cycle and the reset button both returned straight to it. The M9's keyboard now works in the console, and any board without a console keyboard no longer offers the mode at all. The T-Lora Pager was one setting away from the same state and no longer shows the option either.
Accent popups stopped appearing for anyone typing on a physical keyboard, and the setting to turn them on and off did nothing. The accent box and the new @-mention picker share one piece of code, and when the mention work went in the accent half was dropped from the path a physical keyboard uses. Touchscreen typing was unaffected, which is why it looked like a settings problem.
# --- New ----------------------------------------------------------------------------------------
Share a contact with whoever is next to you. The contact menu has a Share entry that re-broadcasts that contact so nodes in direct range can add them. It is deliberately direct-range only: putting someone else's node on the whole mesh is a lot of airtime for a packet nobody asked for.
The signal card is translatable. Signal, Signal (stale) and "nothing heard yet" were never wired for translation, so that card stayed English in every language while the same words were translated everywhere else.
# --- Fixed --------------------------------------------------------------------------------------
The T-Deck reported a full battery as 4.09 V. The conversion from the board's voltage divider was slightly off, so every T-Deck read low.
# --- For testers ------------------------------------------------------------------------------
If you are on a ThinkNode M9 or a T-Lora Pager, update before touching console mode. On those boards the previous two releases could leave you unable to get out.
If your M9 is already stuck: hold BOOT, tap RESET, release BOOT, then flash normally. The device is fine, it just has to be flashed from the bootloader rather than from the running app, which is why a normal reflash reported a failure.
Still open and not fixed here: a screen that will not stay dark after a manual lock (issue 333). We are narrowing it down.
+17
View File
@@ -0,0 +1,17 @@
# beta_73 - test build. Stable stays beta_65.
# One user-facing note per non-blank, non-# line; # lines are section comments.
# --- Older T-Decks could not be typed on ------------------------------------------------------
If your T-Deck types the wrong letters, this release fixes it. Pressing "a" produced "q " and "m" produced "qa ", which made the keyboard unusable for writing, navigating or locking the screen.
It affects the older T-Deck keyboards, the ones without the controllable backlight, and it started in beta_70. The firmware asks the keyboard at startup whether it speaks the newer protocol that modifier latching needs, and an older keyboard's answer looked enough like a yes to be taken as one. Everything typed after that was read the wrong way round.
Nothing can tell the two keyboards apart while nobody is typing, so the firmware now simply corrects itself: the first letter you press is unmistakably a character rather than a key-position report, and it switches to the older protocol on the spot and delivers that letter. You lose modifier latching, which your keyboard cannot do anyway, and you get a working keyboard.
Newer keyboards are unaffected and keep latching.
The beta_72 change that was meant to make this detection more reliable made it worse, because it gave an older keyboard more chances to look like a newer one.
# --- Translations ---------------------------------------------------------------------------------
Two Hungarian strings: "Mark read", which had never been translated, and a shorter wording for joining the public channel so it fits its button. These are already on devices, since language files publish separately from the firmware.
# --- For testers -------------------------------------------------------------------------------
If your keyboard was typing nonsense, you should be able to install this normally rather than needing a cable.
A reminder that two settings control whether an arriving message lights a dark or locked screen, and they are separate: Message flash wakes the screen, and At a glance draws the preview text on it. Both are in Options, Display. Turning off At a glance alone will not stop the screen lighting up.
Still open and not fixed here: a map that renders nothing after beta_71, a screen that will not stay dark after a manual lock, and a web flasher that hangs at "wrapping up" on the T-Display P4.
+25
View File
@@ -0,0 +1,25 @@
# beta_74 - test build. Stable stays beta_65.
# One user-facing note per non-blank, non-# line; # lines are section comments.
# --- If your T-Deck types the wrong letters -----------------------------------------------
There is now a switch that fixes it, and you can reach it without typing: Settings, Keyboard, "Older keyboard protocol". It skips the automatic detection and uses the protocol every T-Deck keyboard understands.
This exists because the detection cannot always be certain, and when it gets it wrong the keyboard types the wrong letters, so any fix that needs you to type is not a fix. The switch is operated by touch and applies immediately, with no reboot.
Turning it on costs modifier latching, which a keyboard that needs the switch cannot do anyway.
The automatic detection is unchanged and still the default. If beta_73 fixed your keyboard, nothing here changes for you.
# --- Web settings could quietly change your radio ------------------------------------------
The remote web page showed a bandwidth of 62.5 as 62, and pressing Apply sent 62 back to the device. Opening the page and applying anything reconfigured the radio onto a bandwidth the mesh was not using. Both halves of that are fixed: the device now sends the fraction, and the form accepts it.
# --- Keyboard ---------------------------------------------------------------------------------
Shift latches like the symbol and alt keys do: tap once for the next letter, tap twice to lock until you tap again. No more holding shift down while reaching for a key.
The Attaky Mesh Deck's front D-pad and SELECT button work now. The board has nine buttons and only the power button did anything.
# --- Reading a route ------------------------------------------------------------------------
Each hop in a message route shows its path prefix next to the repeater name. When a repeater is named misleadingly, or two share a name, the prefix is the part that still identifies it, which is exactly when you are looking at a route.
# --- Translations ---------------------------------------------------------------------------------
"Known regions" and the Bluetooth pairing hint are translatable and translated into Hungarian.
# --- For testers ------------------------------------------------------------------------------
Still open and not fixed here: a map that renders nothing since beta_71, a screen that will not stay dark after a manual lock, the web flasher hanging at "wrapping up" on the T-Display P4, and an M9 that reports no SD card when formatting.
A reminder that two separate settings control whether an arriving message lights a dark screen. "Flash on new message" is in Settings, Keyboard, and it is the one that wakes the screen. "At a glance" is in Settings, Display, and it draws the preview text. Turning off the second one alone will not stop the screen lighting up.
+26
View File
@@ -0,0 +1,26 @@
# beta_75 - test build. Stable stays beta_65.
# One user-facing note per non-blank, non-# line; # lines are section comments.
# --- Your offline map pack was being deleted -----------------------------------------------
If you keep map tiles on an SD card, the firmware was permanently deleting files from your own map pack. Every tile it wanted but could not find as a .jpg erased the .png sitting next to it, and that deletion is not recoverable.
The cleanup responsible was written for the firmware's own small tile cache, but on every board that keeps tiles on the card it was pointed at the card instead. It is now limited to the cache it was meant for. Found and fixed by Christopher Van Hoose, confirmed on a ThinkNode M9.
# --- Names -----------------------------------------------------------------------------------
Long sender names are stored in full. Names can be 31 characters and only the first 24 were kept, so anything longer was cut permanently on arrival. Nothing is lost now, and existing chat history is untouched.
A long name no longer pushes the message text out of shape, and a colon inside an ordinary message is no longer mistaken for an author name.
Both by Ruben Laban, a first contribution.
# --- ThinkNode M9 ----------------------------------------------------------------------------
The map opens in about 1.5 seconds instead of 2.6.
Back returns to the previous screen rather than jumping out of where you were.
The SD card is remounted properly after formatting it on the device.
Labels that update while you watch no longer overlap the rows beneath them, and controls that are hidden or inactive are skipped when moving with the d-pad instead of trapping the cursor.
GPS acquisition time is measured from when the search started rather than from when you happened to open the page.
All from Christopher Van Hoose.
# --- Also ------------------------------------------------------------------------------------
Notification indicators and modal focus behave correctly, and there is a command to check MQTT connection status. Both by Michael A. Cojocari.
# --- For testers -------------------------------------------------------------------------------
If you keep an offline map pack on SD, this release stops the damage but cannot undo it. Check whether tiles are missing and re-copy that area if so.
Still open and not fixed here: chat history resetting to an older point on reboot, a Tanmatsu whose chat store will not finish migrating, a map that renders nothing since beta_71, a screen that will not stay dark after a manual lock, and the web flasher hanging at "wrapping up" on the T-Display P4. Several of those are waiting on information from the people who reported them.
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env bash
# wadamesh: compile every board this repo can produce firmware for. Builds only —
# nothing is flashed, uploaded or published.
#
# WHY THIS SCRIPT EXISTS: "does it still build?" spans two toolchains. The eight
# S3/PIO boards come from platformio.ini, but the Tanmatsu and the T-Display P4
# are standalone ESP-IDF apps with their own build.sh wrappers, and release.sh
# says so in a comment while building only the PlatformIO half. So a change that
# broke an IDF-only board was invisible until someone cut a release by hand. This
# builds all ten, keeps going after a failure, and prints one table at the end.
#
# Usage:
# scripts/build-all-targets.sh # everything it can build here
# scripts/build-all-targets.sh --list # show the targets, build nothing
# scripts/build-all-targets.sh --pio # PlatformIO boards only
# scripts/build-all-targets.sh --idf # Tanmatsu + T-Display P4 only
# scripts/build-all-targets.sh --fail-fast # stop at the first failure
# scripts/build-all-targets.sh -- -v # pass the rest through to pio run
#
# Optional:
# PIO=/path/to/pio # override the PlatformIO executable (default: on PATH)
#
# The PlatformIO env list is read from platformio.ini rather than hard-coded, so
# adding a board to that file is enough — this cannot drift out of step with it.
#
# Exit status is 0 only when every target that ran succeeded. Skipped IDF targets
# (toolchain not installed on this machine) are reported as SKIP and do NOT fail
# the run — see the note printed at the end for how to enable them.
set -uo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
PIO="${PIO:-$(command -v pio || true)}"
LOGDIR="$ROOT/out/build-logs"
DO_PIO=1; DO_IDF=1; LIST_ONLY=0; FAIL_FAST=0; PASSTHRU=()
while [ $# -gt 0 ]; do
case "$1" in
--list) LIST_ONLY=1 ;;
--pio) DO_IDF=0 ;;
--idf) DO_PIO=0 ;;
--fail-fast) FAIL_FAST=1 ;;
--) shift; PASSTHRU=("$@"); break ;;
-h|--help) sed -n '2,28p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "unknown option: $1 (try --help)" >&2; exit 2 ;;
esac
shift
done
# --- Target inventory ----------------------------------------------------------
# PlatformIO envs, in platformio.ini order. Every [env:NAME] is a board we ship.
PIO_ENVS=()
while IFS= read -r e; do PIO_ENVS+=("$e"); done < <(sed -n 's/^\[env:\(.*\)\]$/\1/p' platformio.ini)
# The two ESP-IDF apps. Each is <dir>/build.sh, which needs a project-local
# ESP-IDF 5.5.1; the P4 reuses the Tanmatsu's via symlinks, so one check covers
# both. Absent toolchain = SKIP, not failure: a laptop with only PlatformIO
# installed should still be able to run this and get the eight boards checked.
IDF_DIRS=(tanmatsu tdisplay_p4)
idf_available() { [ -f "$ROOT/tanmatsu/esp-idf/export.sh" ]; }
if [ "$LIST_ONLY" = 1 ]; then
echo "PlatformIO targets (${#PIO_ENVS[@]}):"
for e in "${PIO_ENVS[@]}"; do echo " $e"; done
echo "ESP-IDF targets (${#IDF_DIRS[@]}):"
for d in "${IDF_DIRS[@]}"; do
idf_available && echo " $d" || echo " $d (toolchain not installed here - would SKIP)"
done
exit 0
fi
[ "$DO_PIO" = 1 ] && [ -z "$PIO" ] && { echo "ERROR: no 'pio' on PATH - set PIO=/path/to/pio" >&2; exit 1; }
mkdir -p "$LOGDIR"
NAMES=(); RESULTS=(); TIMES=(); FAILED=0
# run_target <label> <logfile> <command...>
run_target() {
local label="$1" log="$2"; shift 2
local start; start=$(date +%s)
printf '\n==> %s\n' "$label"
if "$@" >"$log" 2>&1; then
local rc=OK
else
local rc=FAIL; FAILED=1
tail -25 "$log" | sed 's/^/ /'
fi
local secs=$(( $(date +%s) - start ))
NAMES+=("$label"); RESULTS+=("$rc"); TIMES+=("$secs")
printf ' %s (%dm%02ds) log: %s\n' "$rc" $((secs/60)) $((secs%60)) "${log#$ROOT/}"
[ "$rc" = FAIL ] && [ "$FAIL_FAST" = 1 ] && { summary; exit 1; }
return 0
}
skip_target() {
NAMES+=("$1"); RESULTS+=("SKIP"); TIMES+=(0)
printf '\n==> %s\n SKIP (%s)\n' "$1" "$2"
}
summary() {
printf '\n%s\n' "-------------------------------------------------------------------"
printf '%-46s %-6s %s\n' "TARGET" "STATUS" "TIME"
local i total=0
for i in "${!NAMES[@]}"; do
printf '%-46s %-6s %dm%02ds\n' "${NAMES[$i]}" "${RESULTS[$i]}" $((TIMES[i]/60)) $((TIMES[i]%60))
total=$((total + TIMES[i]))
done
printf '%s\n' "-------------------------------------------------------------------"
printf 'total %dm%02ds\n' $((total/60)) $((total%60))
if [ "$DO_IDF" = 1 ] && ! idf_available; then
printf '\nnote: the ESP-IDF boards were skipped - install the project-local IDF with\n'
printf ' tanmatsu/fetch-deps.sh (needs an S3 pio build first), then re-run.\n'
fi
}
# --- Build ---------------------------------------------------------------------
if [ "$DO_PIO" = 1 ]; then
for e in "${PIO_ENVS[@]}"; do
# `pio run` with no -t builds; upload/mergebin are never requested here.
run_target "$e" "$LOGDIR/$e.log" "$PIO" run -e "$e" ${PASSTHRU+"${PASSTHRU[@]}"}
done
fi
if [ "$DO_IDF" = 1 ]; then
for d in "${IDF_DIRS[@]}"; do
if idf_available; then
run_target "$d (esp-idf)" "$LOGDIR/$d.log" "$ROOT/$d/build.sh" build
else
skip_target "$d (esp-idf)" "no tanmatsu/esp-idf - see tanmatsu/fetch-deps.sh"
fi
done
fi
summary
exit $FAILED
+116
View File
@@ -280,12 +280,101 @@ static const char* kMeshcomodHelpMsg =
"set tx <dBm> set TX power\n"
"wifi status|on|off|scan\n"
"wifi use <n> | set ssid <v> | set pwd <v> | apply | clear\n"
"mqtt status\n"
"tcp status|on|off\n"
"ble status|on|off\n"
"ota status|start|netdiag | ota url <https://...bin>\n"
"reboot restart the device\n"
"bootloader / dfu reboot to download mode";
#if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION)
static const char* mqttResultText(int result) {
switch (result) {
case -4: return "connection timeout";
case -3: return "connection lost";
case -2: return "TCP connect failed";
case -1: return "disconnected";
case 0: return "connected";
case 1: return "bad protocol";
case 2: return "rejected client ID";
case 3: return "broker unavailable";
case 4: return "bad credentials";
case 5: return "unauthorized";
default: return "unknown";
}
}
static void mqttFormatAge(char* out, size_t cap, uint32_t timestamp, uint32_t now) {
if (!timestamp) { snprintf(out, cap, "never"); return; }
const uint32_t seconds = (uint32_t)(now - timestamp) / 1000u;
if (seconds < 60u) snprintf(out, cap, "%lus ago", (unsigned long)seconds);
else if (seconds < 3600u) snprintf(out, cap, "%lum %lus ago",
(unsigned long)(seconds / 60u),
(unsigned long)(seconds % 60u));
else snprintf(out, cap, "%luh %lum ago", (unsigned long)(seconds / 3600u),
(unsigned long)((seconds / 60u) % 60u));
}
static void mqttFormatStatus(char* out, size_t cap) {
MqttBridgeStatus status;
mqtt_bridge.getStatus(status);
if (!status.available) {
snprintf(out, cap, "mqtt: unavailable on this build");
return;
}
if (status.phase == MqttBridgePhase::Disabled) {
snprintf(out, cap, "mqtt: disabled");
return;
}
if (status.phase == MqttBridgePhase::Misconfigured) {
snprintf(out, cap, "mqtt: misconfigured (broker host is empty)");
return;
}
const uint32_t now = millis();
char state[64];
char timing[96] = {};
switch (status.phase) {
case MqttBridgePhase::WaitingForWifi:
snprintf(state, sizeof(state), "mqtt: waiting for Wi-Fi");
break;
case MqttBridgePhase::Connecting: {
char age[32];
mqttFormatAge(age, sizeof(age), status.lastAttemptMs, now);
snprintf(state, sizeof(state), "mqtt: connecting");
snprintf(timing, sizeof(timing), "attempt: %s", age);
break;
}
case MqttBridgePhase::Connected: {
char age[32];
mqttFormatAge(age, sizeof(age), status.lastConnectedMs, now);
snprintf(state, sizeof(state), "mqtt: connected");
snprintf(timing, sizeof(timing), "connected: %s", age);
break;
}
case MqttBridgePhase::RetryWait: {
const int32_t remaining = (int32_t)(status.retryAtMs - now);
const uint32_t seconds = remaining > 0 ? ((uint32_t)remaining + 999u) / 1000u : 0u;
snprintf(state, sizeof(state), "mqtt: retrying (%lus)", (unsigned long)seconds);
if (status.lastResultMs) {
char age[32];
mqttFormatAge(age, sizeof(age), status.lastResultMs, now);
snprintf(timing, sizeof(timing), "last error: %d (%s), %s",
(int)status.lastResult, mqttResultText(status.lastResult), age);
}
break;
}
default:
snprintf(state, sizeof(state), "mqtt: unavailable on this build");
break;
}
snprintf(out, cap, "%s\nbroker: %s:%u\npublish: channels=%s dm=%s encrypted=%s%s%s",
state, status.host, (unsigned)status.port,
status.publishChannel ? "on" : "off", status.publishDm ? "on" : "off",
status.encrypted ? "yes" : "no", timing[0] ? "\n" : "", timing);
}
#endif
#define MESHCOMOD_CMD_CACHE_SIZE 6
struct MeshcomodCmdCacheEntry {
uint32_t ts;
@@ -698,6 +787,23 @@ bool MyMesh::handleMeshcomodCommand(const char* text, int text_len) {
return true;
}
if (isCmd(p, "mqtt")) {
const char* q = p + 4;
while (*q == ' ' || *q == '\t') q++;
if (isCmd(q, "status")) {
#if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION)
char reply[320];
mqttFormatStatus(reply, sizeof(reply));
pushMeshcomodReply(reply);
#else
pushMeshcomodReply("mqtt: unavailable on this build");
#endif
return true;
}
pushMeshcomodReply("usage:\n- mqtt status");
return true;
}
if (strncasecmp(p, "tcp", 3) == 0 && (p[3] == '\0' || p[3] == ' ' || p[3] == '\t')) {
p += 3;
while (*p == ' ' || *p == '\t') p++;
@@ -5219,6 +5325,16 @@ void MyMesh::checkCLIRescueCmd() {
} else {
Serial.printf(" Error: unknown config: %s\n", config);
}
} else if (strcmp(cli_command, "mqtt.status") == 0 ||
strcmp(cli_command, "get mqtt.status") == 0 ||
strcmp(cli_command, "mqtt status") == 0) {
#if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION)
char reply[320];
mqttFormatStatus(reply, sizeof(reply));
Serial.printf(" > %s\n", reply);
#else
Serial.println(" > mqtt: unavailable on this build");
#endif
} else if (strcmp(cli_command, "get wifi.ssid") == 0) {
#ifdef ESP32
#if defined(WIFI_SSID) || defined(MULTI_TRANSPORT_COMPANION)
+11
View File
@@ -1003,6 +1003,17 @@ public:
return true;
}
/** Re-broadcast a contact's advert as a zero-hop packet so nodes in direct
* range can add it. Mirrors the CMD_SHARE_CONTACT serial handler. Zero-hop
* deliberately: flooding a third party's advert spends the whole mesh's
* airtime on a packet nobody asked for. Returns false when the contact isn't
* in the table any more, or the send fails. */
bool uiShareContact(const uint8_t pub_key[32]) {
ContactInfo* slot = lookupContactByPubKey(pub_key, PUB_KEY_SIZE);
if (!slot) return false;
return shareContactZeroHop(*slot);
}
/** Update a contact's stored GPS position (microdegrees, *1e6) — e.g. from a
* telemetry reply that carried a CayenneLPP GPS field. Lets contacts that
* don't flood position adverts (but do answer telemetry) appear on the map
+85 -7
View File
@@ -36,17 +36,52 @@ size_t MqttNbClient::write(const uint8_t* buf, size_t size) {
return WiFiClient::write(buf, size);
}
void MqttBridge::getStatus(MqttBridgeStatus& out) const {
portENTER_CRITICAL(&_statusMux);
out = _status;
portEXIT_CRITICAL(&_statusMux);
}
void MqttBridge::syncStatusConfig(MqttBridgePhase phase) {
MqttBridgeStatus next;
next.phase = phase;
next.available = true;
next.requestedEnabled = _requestedEnabled;
next.publishDm = _pubDm;
next.publishChannel = _pubChannel;
next.encrypted = _encOn;
strncpy(next.host, _host, sizeof(next.host) - 1);
next.port = _port;
portENTER_CRITICAL(&_statusMux);
_status = next;
portEXIT_CRITICAL(&_statusMux);
}
void MqttBridge::setStatusPhase(MqttBridgePhase phase, int result, uint32_t now,
bool recordResult, uint32_t retryAt) {
portENTER_CRITICAL(&_statusMux);
_status.phase = phase;
_status.retryAtMs = retryAt;
if (phase == MqttBridgePhase::Connecting) _status.lastAttemptMs = now;
if (phase == MqttBridgePhase::Connected) _status.lastConnectedMs = now;
if (recordResult) {
_status.lastResult = (int8_t)result;
_status.lastResultMs = now;
}
portEXIT_CRITICAL(&_statusMux);
}
// Read every field from the "mqtt" NVS namespace into the members. isKey() guards
// avoid the [E] NOT_FOUND log spam that getString emits for absent keys on the
// USB-CDC companion stream (see TouchPrefsStore prefsGetStr note).
void MqttBridge::loadConfig() {
_enabled = false; _pubDm = false; _pubChannel = true;
_requestedEnabled = false; _enabled = false; _pubDm = false; _pubChannel = true;
_host[0] = _user[0] = _pwd[0] = _psk[0] = '\0';
_port = 1883;
SdNvsPrefs p;
if (p.begin("mqtt", true)) {
_enabled = p.getBool("en", false);
_requestedEnabled = p.getBool("en", false);
_port = (uint16_t)p.getUInt("port", 1883);
_pubDm = p.getBool("dm", false);
_pubChannel = p.getBool("ch", true);
@@ -57,7 +92,13 @@ void MqttBridge::loadConfig() {
p.end();
}
deriveKey();
if (!_enabled || _host[0] == '\0') _enabled = false;
_enabled = _requestedEnabled && _host[0] != '\0';
MqttBridgePhase phase = MqttBridgePhase::Disabled;
if (_requestedEnabled && _host[0] == '\0') phase = MqttBridgePhase::Misconfigured;
else if (_enabled) phase = WiFi.status() == WL_CONNECTED
? MqttBridgePhase::RetryWait
: MqttBridgePhase::WaitingForWifi;
syncStatusConfig(phase);
}
// Derive the AES key from the passphrase. mbedtls_md() (generic digest) is used
@@ -88,7 +129,12 @@ void MqttBridge::begin(const char* nodeHex) {
}
bool MqttBridge::reconnect() {
if (!_enabled || WiFi.status() != WL_CONNECTED) return false;
if (!_enabled) return false;
if (WiFi.status() != WL_CONNECTED) {
setStatusPhase(MqttBridgePhase::WaitingForWifi, MQTT_DISCONNECTED,
millis(), false);
return false;
}
char clientId[32], lwtTopic[80];
snprintf(clientId, sizeof(clientId), "wadamesh-%s", _nodeHex);
@@ -98,11 +144,21 @@ bool MqttBridge::reconnect() {
? _mqtt.connect(clientId, _user, _pwd, lwtTopic, 0, true, "offline")
: _mqtt.connect(clientId, nullptr, nullptr, lwtTopic, 0, true, "offline");
const uint32_t now = millis();
const int result = _mqtt.state();
if (ok) {
setStatusPhase(MqttBridgePhase::Connected, result, now, true);
_mqtt.publish(lwtTopic, "online", true);
Serial.printf("[MQTT] connected as %s\n", clientId);
} else {
Serial.printf("[MQTT] connect failed, rc=%d\n", _mqtt.state());
MqttBridgeStatus current;
getStatus(current);
const uint32_t retryAt = current.lastAttemptMs
? current.lastAttemptMs + RECONNECT_INTERVAL_MS
: now + RECONNECT_INTERVAL_MS;
setStatusPhase(MqttBridgePhase::RetryWait, result, now, true,
retryAt);
Serial.printf("[MQTT] connect failed, rc=%d\n", result);
}
return ok;
}
@@ -119,19 +175,41 @@ void MqttBridge::reconnectTask(void* arg) {
void MqttBridge::loop() {
if (!_enabled || _connecting) return;
uint32_t now = millis();
if (WiFi.status() != WL_CONNECTED) {
if (_mqtt.connected()) _mqtt.loop();
setStatusPhase(MqttBridgePhase::WaitingForWifi, MQTT_DISCONNECTED,
now, false);
return;
}
if (!_mqtt.connected()) {
uint32_t now = millis();
MqttBridgeStatus current;
getStatus(current);
if (current.phase == MqttBridgePhase::Connected) {
setStatusPhase(MqttBridgePhase::RetryWait, _mqtt.state(), now, true,
now);
}
if ((uint32_t)(now - _lastReconnectMs) >= RECONNECT_INTERVAL_MS) {
_lastReconnectMs = now;
_connecting = true;
setStatusPhase(MqttBridgePhase::Connecting, MQTT_DISCONNECTED,
now, false);
if (xTaskCreatePinnedToCore(reconnectTask, "mqtt_conn", 6144, this,
1, nullptr, 0) != pdPASS) {
_connecting = false; // task OOM — try again next interval
setStatusPhase(MqttBridgePhase::RetryWait, MQTT_DISCONNECTED,
now, false, now + RECONNECT_INTERVAL_MS);
}
} else {
setStatusPhase(MqttBridgePhase::RetryWait, MQTT_DISCONNECTED,
now, false, _lastReconnectMs + RECONNECT_INTERVAL_MS);
}
return;
}
_mqtt.loop();
if (!_mqtt.loop()) {
setStatusPhase(MqttBridgePhase::RetryWait, _mqtt.state(), now, true,
now);
}
}
// AES-256-GCM seal: out = base64( nonce[12] || ciphertext || tag[16] ). Single-task
+34
View File
@@ -3,6 +3,32 @@
#include <Arduino.h>
enum class MqttBridgePhase : uint8_t {
Unavailable,
Disabled,
Misconfigured,
WaitingForWifi,
Connecting,
Connected,
RetryWait,
};
struct MqttBridgeStatus {
MqttBridgePhase phase = MqttBridgePhase::Unavailable;
bool available = false;
bool requestedEnabled = false;
bool publishDm = false;
bool publishChannel = false;
bool encrypted = false;
char host[64] = {};
uint16_t port = 1883;
int8_t lastResult = -1;
uint32_t lastAttemptMs = 0;
uint32_t lastConnectedMs = 0;
uint32_t lastResultMs = 0;
uint32_t retryAtMs = 0;
};
#if defined(HAS_TANMATSU) || defined(HAS_TDISPLAY_P4)
// ---- MQTT DISABLED on the ESP32-P4 boards (Tanmatsu + T-Display P4) ----
// The P4's Wi-Fi runs through the esp-hosted C6 co-processor; the Arduino WiFiClient / PubSubClient
@@ -16,6 +42,7 @@ public:
void publishDM(const char*, const uint8_t*, float, uint8_t, uint32_t, const char*) {}
void publishChannel(int, const char*, float, uint8_t, uint32_t, const char*) {}
bool enabled() const { return false; }
void getStatus(MqttBridgeStatus& out) const { out = MqttBridgeStatus{}; }
static void saveConfig(const char*, uint16_t, const char*, const char*,
bool, bool, const char*, bool) {}
void reloadConfig() {}
@@ -75,6 +102,7 @@ public:
float snr, uint8_t hops, uint32_t ts, const char* text);
bool enabled() const { return _enabled; }
void getStatus(MqttBridgeStatus& out) const;
// Persist config (called from Settings UI save).
static void saveConfig(const char* host, uint16_t port,
@@ -87,6 +115,7 @@ private:
MqttNbClient _wc;
PubSubClient _mqtt{_wc};
char _nodeHex[13] = {}; // 6-byte key → 12 hex chars + '\0'
bool _requestedEnabled = false;
bool _enabled = false;
bool _pubDm = false; // DMs off by default (private 1:1 traffic)
bool _pubChannel = true; // channel messages on by default
@@ -101,11 +130,16 @@ private:
// True while the one-shot connect task owns _mqtt/_wc. The loop thread must
// not touch either until it clears (PubSubClient is not thread-safe).
volatile bool _connecting = false;
mutable portMUX_TYPE _statusMux = portMUX_INITIALIZER_UNLOCKED;
MqttBridgeStatus _status;
static const uint32_t RECONNECT_INTERVAL_MS = 15000;
void loadConfig(); // shared by begin() / reloadConfig()
void deriveKey(); // _key = SHA-256(_psk); sets _encOn
void setStatusPhase(MqttBridgePhase phase, int result, uint32_t now,
bool recordResult, uint32_t retryAt = 0);
void syncStatusConfig(MqttBridgePhase phase);
bool reconnect();
static void reconnectTask(void* arg); // one-shot task body wrapping reconnect()
void pub(const char* subtopic, const char* json); // seals if _encOn
+8 -2
View File
@@ -9,7 +9,7 @@
namespace TouchPrefsSchema {
static constexpr uint16_t MAGIC = 0x5743; // 'WC' (WadaCfg)
static constexpr uint8_t CURRENT_VERSION = 52; // v49: fem_lna default flip on the V4-R8; v50: map tile z/x/y line off by default (no new fields); v51: console_mode (boot into the text console; new trailing field, default OFF); v52: console_monitor (show incoming messages in the console, default ON)
static constexpr uint8_t CURRENT_VERSION = 53; // v49: fem_lna default flip on the V4-R8; v50: map tile z/x/y line off by default (no new fields); v51: console_mode (boot into the text console; new trailing field, default OFF); v52: console_monitor (show incoming messages in the console, default ON)
static constexpr uint8_t BROKEN_MID_INSERT_VERSION = 44;
// Persisted byte layout. New fields must be appended at the end: older blobs
@@ -83,6 +83,12 @@ struct __attribute__((packed)) Config {
// every field after it, which is what v45 had to undo. Append, never insert.
uint8_t console_mode; // v51: boot into the LVGL-free text console (CONSOLE_MODE.md)
uint8_t console_monitor; // v52: console mode shows incoming messages as they arrive
// v53: never probe for the raw keyboard protocol, always use the older one.
// The detection cannot be certain while nobody is typing, and when it guesses
// wrong the keyboard types nonsense -- which also makes it very hard to reach
// a setting to fix it. This is reachable by touch, so it is a way out that
// does not need the keyboard that is broken (#341, #351).
uint8_t kb_force_legacy;
};
static constexpr size_t HEADER_SIZE = offsetof(Config, bright);
@@ -90,7 +96,7 @@ static constexpr size_t STABLE_V44_PREFIX_SIZE = offsetof(Config, web_mirror);
static_assert(offsetof(Config, web_mirror) == offsetof(Config, rx_queue) + sizeof(Config::rx_queue),
"the pre-v44 suffix moved");
static_assert(offsetof(Config, console_monitor) + sizeof(Config::console_monitor) == sizeof(Config),
static_assert(offsetof(Config, kb_force_legacy) + sizeof(Config::kb_force_legacy) == sizeof(Config),
"new preference fields must remain trailing");
// lang_file must stay immediately before the tail, or a v48-era blob overlays
// onto the wrong bytes. Checking both ends means the next person to append is
+67 -2
View File
@@ -135,6 +135,7 @@ static void cfgSetDefaults(TouchCfg& c) {
c.boot_advert = 0; // OFF: no automatic advert on boot — opt-in (#76)
c.console_mode = 0; // OFF: boot into the graphical UI (CONSOLE_MODE.md)
c.console_monitor = 1; // ON: the console shows messages as they arrive
c.kb_force_legacy = 0; // OFF: detect the keyboard protocol automatically
c.compact_chat = 0; // OFF: bubble chat layout (opt-in IRC-style dense rows)
c.clock_floor = 0; // no persisted send-timestamp floor yet
c.rx_queue = 1; // ON: buffered receive (test-channel default; opt-out toggle in Radio & Mesh)
@@ -242,6 +243,7 @@ static void cfgLoadOrMigrate() {
// a garbage 1 would boot a user into a console they did not ask for.
if (stored_version < 51) s_cfg.console_mode = 0;
if (stored_version < 52) s_cfg.console_monitor = 1; // new trailing field: on by default
if (stored_version < 53) s_cfg.kb_force_legacy = 0; // new trailing field: auto-detect
if (stored_version < 31) s_cfg.compact_chat = 0; // new trailing field: compact chat rows off by default
if (stored_version < 32) s_cfg.clock_floor = 0; // new trailing field: no send-timestamp floor persisted yet (#89)
if (stored_version < 33) s_cfg.rx_queue = 1; // buffered LoRa receive ON for the test channel (opt-out toggle in Radio & Mesh)
@@ -1114,6 +1116,16 @@ bool touchPrefsSetConsoleMode(bool on) {
return cfgFlush();
}
bool touchPrefsGetKbForceLegacy() {
if (!s_begun) touchPrefsBegin();
return s_cfg.kb_force_legacy != 0;
}
bool touchPrefsSetKbForceLegacy(bool on) {
if (!s_begun) touchPrefsBegin();
s_cfg.kb_force_legacy = on ? 1 : 0;
return cfgFlush();
}
bool touchPrefsGetConsoleMonitor() {
if (!s_begun) touchPrefsBegin();
return s_cfg.console_monitor != 0;
@@ -1716,13 +1728,66 @@ bool touchPrefsSetIgnored(const uint8_t* pub_key6, bool ignored) {
}
// Ignored / blocked sender NAMES (channel/room senders that aren't contacts) ---
// One NVS blob "ign_nm" of up to TOUCH_IGNORED_NAMES_MAX fixed-width,
// One NVS blob "ign_nm2" of up to TOUCH_IGNORED_NAMES_MAX fixed-width,
// NUL-padded TOUCH_IGNORED_NAME_LEN slots. Same read/replace/write scheme as
// the 6-byte prefix list above.
static const char* KEY_IGN_NAMES = "ign_nm";
// The blob carries no header — the entry count is derived by dividing its length by the
// slot width — so widening the slot re-slots every stored entry: slot 1 would start inside
// the old name 0, every row after the first would render as a mangled suffix and would stop
// matching, and the first write back would make that permanent. A new key sidesteps it: the
// old blob is read once with the OLD stride, re-slotted, written here, and removed. A key
// rename is unambiguous by construction, unlike sniffing the blob length (28 and 32 share
// multiples at 224 and 448 bytes, both inside the 16-entry cap).
static const char* KEY_IGN_NAMES = "ign_nm2";
static const char* KEY_IGN_NAMES_V1 = "ign_nm";
constexpr int TOUCH_IGN_NAME_LEN_V1 = 28;
// One-shot: fold a pre-widening "ign_nm" blob into the current key. No-op once migrated
// (and on a fresh device, where neither key exists). Latched for the boot because the
// caller runs on the RX path for EVERY incoming message — two NVS key lookups per message
// is not a price worth paying for a check that can only change once.
static bool s_ign_names_migrated = false;
static void ignNamesMigrateV1() {
if (s_ign_names_migrated) return;
if (!s_begun) touchPrefsBegin();
if (s_prefs.isKey(KEY_IGN_NAMES) || !s_prefs.isKey(KEY_IGN_NAMES_V1)) {
s_ign_names_migrated = true; // nothing to fold in, now or on any later call
return;
}
char old_buf[TOUCH_IGNORED_NAMES_MAX * TOUCH_IGN_NAME_LEN_V1];
size_t n = s_prefs.getBytes(KEY_IGN_NAMES_V1, old_buf, sizeof(old_buf));
const int count = (n == 0 || n > sizeof(old_buf)) ? 0 : (int)(n / TOUCH_IGN_NAME_LEN_V1);
char buf[TOUCH_IGNORED_NAMES_MAX * TOUCH_IGNORED_NAME_LEN];
memset(buf, 0, sizeof(buf));
for (int i = 0; i < count; ++i) {
// Old slots hold at most 27 chars, so they always fit the wider one.
strncpy(&buf[i * TOUCH_IGNORED_NAME_LEN], &old_buf[i * TOUCH_IGN_NAME_LEN_V1],
TOUCH_IGN_NAME_LEN_V1 - 1);
}
s_prefs.end();
bool ok = false;
if (s_prefs.begin(TOUCH_NS, false)) {
ok = (count == 0) ||
s_prefs.putBytes(KEY_IGN_NAMES, buf, (size_t)(count * TOUCH_IGNORED_NAME_LEN)) > 0;
if (ok) s_prefs.remove(KEY_IGN_NAMES_V1); // only once the new key actually holds the list
s_prefs.end();
}
s_begun = s_prefs.begin(TOUCH_NS, true);
// Latch on SUCCESS only. Latching a failed fold would leave the list reading empty for
// the rest of the boot — every block silently stops firing — and the next Block tap
// would then write a fresh ign_nm2 that orphans every old entry for good. Retrying
// costs the two key lookups per message the latch exists to avoid, but only while the
// store is already refusing writes.
s_ign_names_migrated = ok;
}
static int ignNamesReadAll(char out[TOUCH_IGNORED_NAMES_MAX * TOUCH_IGNORED_NAME_LEN]) {
if (!s_begun) touchPrefsBegin();
ignNamesMigrateV1();
if (!s_prefs.isKey(KEY_IGN_NAMES)) return 0; // absent on a fresh device — skip [E] NOT_FOUND
size_t n = s_prefs.getBytes(KEY_IGN_NAMES, out, TOUCH_IGNORED_NAMES_MAX * TOUCH_IGNORED_NAME_LEN);
if (n == 0 || n > (size_t)(TOUCH_IGNORED_NAMES_MAX * TOUCH_IGNORED_NAME_LEN)) return 0;
+14 -1
View File
@@ -193,6 +193,13 @@ bool touchPrefsSetMsgFlash(bool on);
* refresh our pubkey (useful after a reflash wiped storage). Opt-in, default off. All boards. */
bool touchPrefsGetConsoleMode(); // boot into the LVGL-free console (CONSOLE_MODE.md)
bool touchPrefsSetConsoleMode(bool on);
// Skip raw-protocol detection on the T-Deck keyboard and always use the older
// one. Reachable by touch, so it is a way out when a misdetection has left the
// keyboard typing nonsense (#341, #351).
bool touchPrefsGetKbForceLegacy();
bool touchPrefsSetKbForceLegacy(bool on);
bool touchPrefsGetKbForceLegacy();
bool touchPrefsSetKbForceLegacy(bool on);
bool touchPrefsGetConsoleMonitor(); // console: show incoming messages live
bool touchPrefsSetConsoleMonitor(bool on);
bool touchPrefsGetBootAdvert();
@@ -385,7 +392,13 @@ int touchPrefsCopyIgnored(uint8_t* out_buf);
* • touchPrefsCopyIgnoredNames: copy every stored name into `out_buf`
* (>= TOUCH_IGNORED_NAMES_MAX * TOUCH_IGNORED_NAME_LEN bytes); returns count. */
constexpr int TOUCH_IGNORED_NAMES_MAX = 16;
constexpr int TOUCH_IGNORED_NAME_LEN = 28; // fixed NUL-padded slot (>= MAX_SENDER_NAME+1)
// Fixed NUL-padded slot, and it MUST stay >= UITask::MAX_SENDER_NAME + 1 — a slot narrower
// than a sender stores a cut name that the full-width RX check can never match again, so
// the block shows in the list and silently never fires. That invariant was prose only; it
// is now a static_assert in UITask.cpp. Widened 28 -> 32 with MAX_SENDER_NAME 24 -> 31; the
// slot width is baked into the stored blob (the entry count is its length / this), so the
// key was renamed in the same change to migrate rather than mis-slot the old one.
constexpr int TOUCH_IGNORED_NAME_LEN = 32;
bool touchPrefsIsNameIgnored(const char* name);
bool touchPrefsSetNameIgnored(const char* name, bool ignored);
int touchPrefsCopyIgnoredNames(char* out_buf);
@@ -326,12 +326,12 @@ static const char WS_HTML_TERMINAL_PAGE[] =
"var QCMDS=[{h:'CHAT'},{l:'list - contacts',c:'list'},{l:'channels',c:'channels'},{l:'to <name>',c:'to '},{l:'send <text>',c:'send '},{l:'public <text>',c:'public '},{l:'exit',c:'exit'},\n"
" {h:'INFO'},{l:'help',c:'help'},{l:'ver - firmware version',c:'ver'},{l:'clock - RTC time',c:'clock'},{l:'status',c:'status'},{l:'get - radio params',c:'get'},\n"
" {h:'RADIO / MESH'},{l:'advert',c:'advert'},{l:'advert.zerohop',c:'advert.zerohop'},{l:'set name <v>',c:'set name '},{l:'set freq <MHz>',c:'set freq '},{l:'set bw <kHz>',c:'set bw '},{l:'set sf <7-12>',c:'set sf '},{l:'set cr <5-8>',c:'set cr '},{l:'set tx <dBm>',c:'set tx '},\n"
" {h:'CONNECTIVITY'},{l:'wifi status',c:'wifi status'},{l:'wifi on',c:'wifi on'},{l:'wifi off',c:'wifi off'},{l:'wifi scan',c:'wifi scan'},{l:'wifi apply',c:'wifi apply'},{l:'wifi clear',c:'wifi clear'},{l:'tcp status',c:'tcp status'},{l:'tcp on',c:'tcp on'},{l:'tcp off',c:'tcp off'},{l:'ble status',c:'ble status'},{l:'ble on',c:'ble on'},{l:'ble off',c:'ble off'},{l:'ota status',c:'ota status'},{l:'ota start',c:'ota start'},\n"
" {h:'CONNECTIVITY'},{l:'wifi status',c:'wifi status'},{l:'wifi on',c:'wifi on'},{l:'wifi off',c:'wifi off'},{l:'wifi scan',c:'wifi scan'},{l:'wifi apply',c:'wifi apply'},{l:'wifi clear',c:'wifi clear'},{l:'mqtt status',c:'mqtt status'},{l:'tcp status',c:'tcp status'},{l:'tcp on',c:'tcp on'},{l:'tcp off',c:'tcp off'},{l:'ble status',c:'ble status'},{l:'ble on',c:'ble on'},{l:'ble off',c:'ble off'},{l:'ota status',c:'ota status'},{l:'ota start',c:'ota start'},\n"
" {h:'SYSTEM'},{l:'reboot',c:'reboot'},{l:'bootloader - download mode',c:'bootloader'}];\n"
"function cmdPicker(){var h='<h4>Commands</h4>';QCMDS.forEach(function(x,i){if(x.h)h+='<div class=sec>'+esc(x.h)+'</div>';else h+='<button class=si data-i='+i+'>'+esc(x.l)+'</button>'});\n"
" E('sheet').innerHTML=h;each(E('sheet').querySelectorAll('.si'),function(b){b.onclick=function(){var x=QCMDS[+b.getAttribute('data-i')];closeOv();showTab('term');E('i').value=x.c;E('i').focus()}});\n"
" E('sheet').classList.add('on');E('scrim').classList.add('on')}\n"
"var hist=[],hi=0,CMDS=['help','?','status','ver','clock','get','advert','advert.zerohop','reboot','bootloader','set ','tcp ','ble ','ota ','wifi ','list','contacts','channels','to ','send ','public ','exit','leave','ok','cancel','clear'];\n"
"var hist=[],hi=0,CMDS=['help','?','status','ver','clock','get','advert','advert.zerohop','reboot','bootloader','set ','mqtt ','tcp ','ble ','ota ','wifi ','list','contacts','channels','to ','send ','public ','exit','leave','ok','cancel','clear'];\n"
"function tadd(t,cls){var s=document.createElement('span');if(cls)s.className=cls;s.textContent=t;E('o').appendChild(s);E('o').scrollTop=E('o').scrollHeight}\n"
"function tsend(){var v=E('i').value;E('i').value='';if(!v.trim())return;if(v.trim()=='clear'){E('o').innerHTML='';return}\n"
" tadd('\\n> '+v+'\\n','c');hist.push(v);hi=hist.length;if(!wsSend(v))tadd('(not connected)\\n','err')}\n"
@@ -374,7 +374,7 @@ static const char WS_HTML_TERMINAL_PAGE[] =
"function showSettings(d){var g=E;\n"
" var h='<div class=sset><label>NODE NAME</label><div class=srow><input type=text id=sfname><button class=sbtn id=sbname>Save</button></div>';\n"
" h+='<label>RADIO (applies live)</label><div class=sgrid>';\n"
" h+='<div class=f><label>Freq MHz</label><input type=number step=0.001 id=sffreq></div><div class=f><label>BW kHz</label><input type=number id=sfbw></div>';\n"
" h+='<div class=f><label>Freq MHz</label><input type=number step=0.001 id=sffreq></div><div class=f><label>BW kHz</label><input type=number step=0.1 id=sfbw></div>';\n"
" h+='<div class=f><label>SF</label><input type=number id=sfsf></div><div class=f><label>CR</label><input type=number id=sfcr></div>';\n"
" h+='<div class=f><label>TX dBm</label><input type=number id=sftx></div><div class=f><label>&nbsp;</label><button class=sbtn id=sbradio style=width:100%>Apply</button></div></div>';\n"
" h+='<label>CONNECTIVITY</label><div class=stog><b>Wi-Fi</b><div class=sw id=swifi><i></i></div></div>';\n"
+104 -27
View File
@@ -75,6 +75,27 @@ static void processRawFrame(const uint8_t frame[TDeckKeyboardState::COLS], uint3
portEXIT_CRITICAL(&s_keyboard_mux);
}
// True when a five-byte answer can only be a legacy ASCII reply: the character
// in byte 0 with padding after it. A real matrix frame puts one bit per pressed
// key in that key's own column, so three or more bits in byte 0 with every other
// column empty is not something the hardware can report.
static bool looksLikeAscii(const uint8_t frame[TDeckKeyboardState::COLS]) {
for (size_t col = 1; col < TDeckKeyboardState::COLS; ++col) if (frame[col]) return false;
if (frame[0] < 0x20 || frame[0] > 0x7E) return false; // must be a printable character
int bits = 0;
for (int b = 0; b < 8; ++b) if (frame[0] & (1u << b)) ++bits;
return bits > 2;
}
// Verified against the real tables rather than assumed:
// - all 26 lowercase letters set 3 or more bits, so ANY letter demotes on the
// first press. Only '0', ' ' and '!' do not, and space happens to render
// correctly anyway (column 0 bit 5 IS space).
// - going the other way, a genuine frame reaching this needs three or more of
// {q, w, a, space} held at once and nothing else. The printable-range test
// above removes q+w+a (0x0B); what remains are combinations including space,
// which is a fumble rather than typing, and costs a reboot to undo.
static void processLegacyKey(uint8_t key) {
portENTER_CRITICAL(&s_keyboard_mux);
const uint32_t generation = s_modifier_mode_generation;
@@ -90,6 +111,12 @@ static void processLegacyKey(uint8_t key) {
portEXIT_CRITICAL(&s_keyboard_mux);
}
// Set from the UI at boot (Settings > Keyboard). When true the probe is skipped
// entirely and the older protocol is used, which is the escape hatch for a
// controller that detection gets wrong (#351).
static bool s_force_legacy = false;
void tdeckKeyboardForceLegacy(bool on) { s_force_legacy = on; }
void tdeckKeyboardBegin() {
s_mode = KeyboardMode::Probe;
s_raw_state = TDeckKeyboardState{};
@@ -127,17 +154,34 @@ void tdeckKeyboardFlushBacklight() {
void tdeckKeyboardPoll() {
if (!s_inited) return;
tdeckKeyboardFlushBacklight();
if (s_force_legacy && s_mode != KeyboardMode::Legacy) {
keyboardCommand(0x04);
s_mode = KeyboardMode::Legacy;
Serial.println("[keyboard] T-Deck legacy mode: forced in settings");
}
if (s_mode == KeyboardMode::Probe) {
// LilyGO keyboard firmware from June 2025 onward accepts 0x03 and returns
// five column-state bytes. Older controllers ignore it and keep returning
// one resolved ASCII byte; restore 0x04 key mode when that is what we have.
// Deciding whether this controller speaks the raw column protocol.
//
// KEEP RETRYING before concluding "old firmware". This used to decide on a
// SINGLE read, and the first I2C read after boot is the least trustworthy
// one there is: the C3 is still coming up, so a short or garbled frame is
// ordinary. One such frame committed the device to legacy mode for the whole
// session, with no recovery short of a reboot -- reported as latching simply
// not working on a T-Deck carrying the June 2025 firmware (#332).
// The old test was "five bytes came back and no high bit is set", which a
// LEGACY controller passes trivially: it answers a five-byte read with the
// pressed character followed by padding, and every lowercase letter has its
// high bit clear. Read as a column bitmap, 'a' (0x61) emits "q " and 'm'
// (0x6d) emits "qa ". That is the garbage Vybo reported on an older T-Deck
// (#341), and the retry window added in beta_72 made it MORE likely to
// latch, not less.
//
// The positive test: a real matrix frame from a key press has ONE bit set,
// in whichever column that key lives. A legacy reply only ever has byte 0
// non-zero, and an ASCII letter sets three or four bits at once. So:
//
// any non-zero byte in columns 1..4 -> genuinely raw (padding is never
// non-zero there)
// byte 0 with >2 bits set, rest zero -> an ASCII character, so legacy
//
// An all-zero frame is idle and proves nothing either way, so keep probing.
// If the window expires having seen only column-0 activity, fall back to
// LEGACY. That is the safe default: the cost of being wrong is losing
// modifier latching, never losing the keyboard.
static uint32_t s_probe_start_ms = 0;
const uint32_t now = millis();
if (!s_probe_start_ms) s_probe_start_ms = now;
@@ -145,37 +189,70 @@ void tdeckKeyboardPoll() {
keyboardCommand(0x03);
uint8_t frame[TDeckKeyboardState::COLS] = {};
const int count = keyboardRead(frame, sizeof frame);
bool valid_raw = count == (int)sizeof frame;
size_t bad_col = 0;
for (size_t col = 0; col < sizeof frame && valid_raw; ++col) {
if (frame[col] & 0x80) { valid_raw = false; bad_col = col; }
}
if (valid_raw) {
bool shape_ok = (count == (int)sizeof frame);
for (size_t col = 0; col < sizeof frame && shape_ok; ++col)
if (frame[col] & 0x80) shape_ok = false;
if (shape_ok) {
bool cols1plus = false;
for (size_t col = 1; col < sizeof frame; ++col) if (frame[col]) cols1plus = true;
int b0bits = 0;
for (int b = 0; b < 8; ++b) if (frame[0] & (1u << b)) ++b0bits;
if (cols1plus) { // only a real matrix reaches here
s_mode = KeyboardMode::Raw;
Serial.println("[keyboard] T-Deck raw mode: modifier latching enabled");
processRawFrame(frame, now);
return;
}
if (frame[0] && b0bits > 2) { // an ASCII byte, not a column
keyboardCommand(0x04);
s_mode = KeyboardMode::Legacy;
Serial.printf("[keyboard] T-Deck legacy mode: controller answered with a character "
"(0x%02X), not a column frame\n", frame[0]);
processLegacyKey(frame[0]);
return;
}
if ((uint32_t)(now - s_probe_start_ms) < kProbeWindowMs) return; // idle: keep looking
// Window expired with a well-formed, idle frame every time. That is what a
// raw controller looks like when nobody is typing, so take it -- a legacy
// one would have shown us a character by now if it had one, and if it has
// not, looksLikeAscii() below still demotes the moment it does.
s_mode = KeyboardMode::Raw;
Serial.println("[keyboard] T-Deck raw mode: modifier latching enabled");
processRawFrame(frame, millis());
return;
} else if ((uint32_t)(now - s_probe_start_ms) < kProbeWindowMs) {
if (count == 1) { keyboardCommand(0x04); processLegacyKey(frame[0]); }
return; // short/garbled read: the C3 may still be waking
}
// A legacy controller answers with one resolved byte. Deliver it either way
// so nothing typed during the probe window is dropped.
if (count == 1) { keyboardCommand(0x04); processLegacyKey(frame[0]); }
if ((uint32_t)(now - s_probe_start_ms) < kProbeWindowMs) return; // try again next poll
keyboardCommand(0x04);
s_mode = KeyboardMode::Legacy;
// Say WHY, so the next report of "latching does not work" is one line to
// diagnose instead of a guess about which firmware someone has.
Serial.printf("[keyboard] T-Deck legacy mode: update keyboard C3 firmware for modifier "
"latching (probe read %d bytes, wanted %d%s)\n",
count, (int)sizeof frame,
(count == (int)sizeof frame) ? ", high bit set in a column" : "");
(void)bad_col;
Serial.printf("[keyboard] T-Deck legacy mode: no raw column frame seen in %lu ms "
"(probe read %d bytes)\n", (unsigned long)kProbeWindowMs, count);
if (count == 1) processLegacyKey(frame[0]);
return;
}
if (s_mode == KeyboardMode::Raw) {
uint8_t frame[TDeckKeyboardState::COLS] = {};
if (keyboardRead(frame, sizeof frame) != (int)sizeof frame) return;
for (size_t col = 0; col < sizeof frame; ++col) if (frame[col] & 0x80) return;
// Raw mode has to be able to admit it guessed wrong. An idle controller of
// either kind answers with zeros, so the probe cannot tell them apart until
// someone types -- and on a legacy controller that first character arrives
// in byte 0 with three or four bits set, which no single key press can
// produce. Demote on the spot and deliver the character, so the worst case
// is one odd keystroke rather than a keyboard that types nonsense forever.
if (looksLikeAscii(frame)) {
keyboardCommand(0x04);
s_mode = KeyboardMode::Legacy;
Serial.printf("[keyboard] T-Deck legacy mode: raw guess withdrawn, controller sent a "
"character (0x%02X)\n", frame[0]);
processLegacyKey(frame[0]);
return;
}
processRawFrame(frame, millis());
return;
}
+4
View File
@@ -19,6 +19,10 @@
* driver, so this just enables polling. */
void tdeckKeyboardBegin();
/** Force the older keyboard protocol, skipping raw detection. Escape hatch for a
* controller the probe gets wrong; call before/at begin and on the setting. */
void tdeckKeyboardForceLegacy(bool on);
/** Read one key over I2C and push it into the ring. CORE-0 ONLY (touch task). */
void tdeckKeyboardPoll();
+11 -1
View File
@@ -39,13 +39,17 @@ class TDeckKeyboardState {
if (!modifiers_enabled) {
symbol_.baseline(down(0, 2));
alt_.baseline(down(0, 4));
shift_.baseline(down(1, 6) || down(2, 3));
}
if (modifiers_enabled && pressed(0, 2)) symbol_.press();
if (modifiers_enabled && pressed(0, 4)) alt_.press();
if (modifiers_enabled && released(0, 2)) symbol_.release(now_ms);
if (modifiers_enabled && released(0, 4)) alt_.release(now_ms);
if (modifiers_enabled && (pressed(1, 6) || pressed(2, 3))) shift_.press();
if (modifiers_enabled && (released(1, 6) || released(2, 3))) shift_.release(now_ms);
const bool shift = modifiers_enabled && (down(1, 6) || down(2, 3));
// Held OR latched: a tap arms it for the next key, a double tap locks it.
const bool shift = modifiers_enabled && (down(1, 6) || down(2, 3) || shift_.latched());
size_t written = 0;
auto emit = [&](uint8_t key) {
if (written < out_capacity) out[written++] = key;
@@ -81,6 +85,7 @@ class TDeckKeyboardState {
if (modifiers_enabled) {
symbol_.consumeForKey();
if (!physical_alt) alt_.consumeForKey();
shift_.consumeForKey(); // one-shot shift expires on the key it capitalised
}
if (key == '\0') continue;
char resolved = key;
@@ -108,4 +113,9 @@ class TDeckKeyboardState {
uint8_t previous_[COLS] = {};
LatchedModifier symbol_;
LatchedModifier alt_;
// Shift latches like the other two (#344). It used to be a bare "is it held"
// test, so a capital letter meant holding shift down while reaching for the
// key. The board has two shift keys and they share one latch: whichever you
// press arms it, and either releases it.
LatchedModifier shift_;
};
+52
View File
@@ -1124,6 +1124,58 @@ void setup() {
}
}
#endif
// Repair an unusable SPIFFS whether or not the profile lives on SD.
//
// This used to be `if (!sd_storage && !spiffs_ok)`, which is a catch-22: an
// invalid SPIFFS forces want_full_sd above ("Move the WHOLE store to SD when
// the device has no usable SPIFFS"), and being on SD then skipped the repair —
// so the partition stayed unformatted forever. Every boot logged
// `E SPIFFS: mount failed, -10025` and internal storage read as 0 bytes.
//
// The consequence is not cosmetic. The migration path documents that it
// "never deletes its SPIFFS sources, so a device that already adopted the card
// still has a byte-identical copy here" — i.e. the design assumes internal
// flash holds a second copy of the identity. A device that went SD-native
// because SPIFFS was broken never had that copy, so /meshcomod/identity/_main.id
// on the card is the ONLY thing making it its node. On the ThinkNode M9 the
// card is soldered, so it cannot even be moved to another device to recover.
//
// Formatting here is safe for the profile: sd_storage was already decided
// above and is not revisited, so this only makes the internal filesystem
// usable again — it does not move anyone's data. A brand-new device is
// unaffected (a virgin partition is formatted on the same call it always was).
// KNOWN BUG, deliberately NOT fixed here yet — read before touching this.
//
// The condition is a catch-22. An invalid SPIFFS forces want_full_sd above
// ("Move the WHOLE store to SD when the device has no usable SPIFFS"), and
// being on SD then SKIPS this repair — so the partition stays unformatted
// forever. Every boot logs `E SPIFFS: mount failed, -10025` and internal
// storage reads 0 bytes. It is not cosmetic: the migration path documents that
// it "never deletes its SPIFFS sources, so a device that already adopted the
// card still has a byte-identical copy here", i.e. the design assumes internal
// flash holds a SECOND copy of the identity. A device that went SD-native
// *because* SPIFFS was broken never had that copy, so
// /meshcomod/identity/_main.id on the card is the only thing making it its
// node — and on the ThinkNode M9 that card is soldered.
//
// Reproduced on M9 hardware 2026-08-28. Changing this to
// `if (!spiffs_ok) spiffs_ok = SPIFFS.begin(true);` DOES repair the partition
// (the boot then reports the internal fs as ok, and the profile correctly
// stays on SD), but that boot then panicked and looped:
// Guru Meditation Error: Core 1 panic'ed (InstrFetchProhibited)
// Backtrace: WiFiEventCbList::WiFiEventCbList(const&)
// -> WiFiGenericClass::_eventCallback -> _arduino_event_task
// The follow-up matters: once the partition was formatted, the SAME build with
// this line back as-is — SPIFFS now mounting AND the profile on SD, i.e. the
// exact state that crashed — boots cleanly and repeatedly. So the fault is not
// "SPIFFS is mounted"; it is almost certainly the 3.375 MB format ITSELF
// running at this point in setup(), blocking long enough to race the Arduino
// Wi-Fi event task that is starting up alongside it.
//
// So the fix is not this one-liner: it is to repair the partition somewhere
// that can afford a multi-second blocking format (before the Wi-Fi/event tasks
// exist, or deferred to a UI-driven action with a progress notice), and to
// confirm on hardware that a cold first-format boot survives.
if (!sd_storage && !spiffs_ok) SPIFFS.begin(true); // last resort: format SPIFFS
Serial.printf("[BOOT] storage: %s\n", sd_storage ? "SD /meshcomod" : "SPIFFS");
#if defined(ESP32_PLATFORM) && defined(HAS_TOUCH_UI)
+75
View File
@@ -0,0 +1,75 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <stddef.h>
#include <stdint.h>
#include <string.h>
// A channel/group post arrives on the wire as "SenderName: body" — the author is
// part of the text, not a separate field (unlike a DM, where the sender rides
// along as from_name). Split it so the UI can label the bubble with the author
// and show only the body underneath.
namespace ChannelSenderSplit {
// The widest author a channel post can legitimately name. MeshCore keeps every
// name in a char[32] (ContactInfo::name, ChannelDetails::name,
// NodePrefs::node_name), so 31 characters is the whole wire width.
constexpr size_t kMaxWireName = 31;
// Copies the embedded author into `sender_out` (always NUL-terminated, empty when
// there is nothing to split) and returns the start of the body — `text` itself
// when the string carries no author prefix.
//
// A name longer than the destination is TRUNCATED, not rejected. MeshCore names
// run to 31 chars while UIMessage::sender holds 24, and refusing the split for
// the overflowing ones is what put the channel name in the bubble header and left
// "nick: message" in the body.
//
// A prefix wider than kMaxWireName is a different thing entirely and IS rejected:
// no name can be that long, so the ": " belongs to an unprefixed message ("the
// repeater at Ouderkerk: it works"). Splitting there would invent an author and
// hide the start of the body. That plausibility check is what the old
// `slen <= MAX_SENDER_NAME` test provided; it has to be the WIRE width and not
// the destination width, or a long real name gets rejected all over again.
//
// A truncated name ends in "..." (ASCII, like chatFitLeadingEllipsis — the baked
// fonts carry no U+2026), so a cut name reads as cut rather than as a different
// node with a shorter name. The marker is part of the stored sender, so the
// bubble label, the chat-list preview and block-by-name all agree on one string.
inline const char* split(const char* text, char* sender_out, size_t sender_cap) {
if (sender_out && sender_cap > 0) sender_out[0] = '\0';
if (!text) return "";
if (!sender_out || sender_cap < 2) return text;
const char* colon = strstr(text, ": ");
if (!colon || colon == text) return text;
if (static_cast<size_t>(colon - text) > kMaxWireName) return text; // not a name — see above
static const char kEllipsis[] = "...";
const size_t kEllipsisLen = sizeof(kEllipsis) - 1;
const size_t cap = sender_cap - 1; // bytes available, NUL excluded
size_t slen = static_cast<size_t>(colon - text);
const bool truncated = slen > cap;
// Only mark when the name still gets at least as many bytes as the marker —
// on a destination too small for that, "nick" says more than "n...".
const bool mark = truncated && cap >= 2 * kEllipsisLen;
if (truncated) {
slen = mark ? cap - kEllipsisLen : cap;
// Never cut a multi-byte character in half: step back to the start of the
// sequence the cap lands inside, so the label gets valid UTF-8 rather than a
// stray '*' from the missing-glyph sanitiser.
while (slen > 0 && (static_cast<uint8_t>(text[slen]) & 0xC0U) == 0x80U) --slen;
if (slen == 0) return text; // one long sequence and nothing fits — leave it whole
}
memcpy(sender_out, text, slen);
if (mark) {
memcpy(sender_out + slen, kEllipsis, kEllipsisLen);
slen += kEllipsisLen;
}
sender_out[slen] = '\0';
return colon + 2;
}
} // namespace ChannelSenderSplit
+2 -1
View File
@@ -548,11 +548,12 @@ void submit() {
consoleWriteLine("mesh contacts, chans, unread");
consoleWriteLine(" chat <name> show a thread");
consoleWriteLine(" discover / discovered");
consoleWriteLine("radio stat, batt, wifi");
consoleWriteLine(" read <name> clear that thread's unread");
consoleWriteLine(" monitor on|off show arriving messages");
consoleWriteLine(" to <name> pick a contact or channel");
consoleWriteLine(" msg <text> send to it");
consoleWriteLine("network mqtt status, tcp status, ble status");
consoleWriteLine("radio stat, batt, wifi");
consoleWriteLine("node anything the CLI answers:");
consoleWriteLine(" advert, get name, set name <x>, time, ver");
return;
+70
View File
@@ -0,0 +1,70 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <stddef.h>
#include <string.h>
// A stored sender name is split across two on-disk fields: the head lives in the frozen
// UiHistoryMsg::sender, and anything past it in UiSegMsg::sender_ext, which is appended at
// the very end of the segment record. The split exists so the record can hold a full-width
// name without moving any pre-existing field — see the comment on UiSegMsg::sender_ext.
//
// Nothing outside the persistence layer sees the split: UIMessage::sender is one string,
// and these two calls are the only places the halves come apart and go back together.
//
// A multi-byte character may straddle the boundary. That is harmless — the halves are
// always rejoined before the name is measured, drawn or compared — so this deliberately
// does NOT try to split on a character boundary, which would waste bytes of a field sized
// to hold the longest name exactly.
namespace SenderExtField {
// Writes `sender` into the two destination fields. `base` is always NUL-terminated; `ext`
// carries the overflow and is NOT NUL-terminated when it is full (it is a fixed-width tail,
// and its length is implied by base being full). Both are zero-filled first, so a short
// name leaves a clean record.
inline void store(const char* sender, char* base, size_t base_cap, char* ext, size_t ext_cap) {
if (base && base_cap > 0) memset(base, 0, base_cap);
if (ext && ext_cap > 0) memset(ext, 0, ext_cap);
if (!sender || !base || base_cap == 0) return;
// Walked with a pointer rather than indexed + memcpy'd: the copy length and the bounds
// check are then the same expression, which keeps -Warray-bounds quiet on inlined
// short literals. These are 31 bytes at most, so byte-at-a-time costs nothing.
const char* p = sender;
size_t head = 0;
while (head + 1 < base_cap && *p) base[head++] = *p++;
base[head] = '\0';
if (head + 1 < base_cap || !ext || ext_cap == 0) return; // fitted, or nowhere to spill
size_t tail = 0;
while (tail < ext_cap && *p) ext[tail++] = *p++;
}
// Rejoins the two fields into `out`. Reads are bounded by the FIELD sizes, not by strlen:
// a corrupt record may leave `base` unterminated, and it is followed on disk by the message
// body, so an unbounded read would splice the text onto the name.
inline void load(const char* base, size_t base_cap, const char* ext, size_t ext_cap,
char* out, size_t out_cap) {
if (!out || out_cap == 0) return;
out[0] = '\0';
if (!base || base_cap == 0) return;
const size_t head_cap = base_cap - 1;
const char* p = base;
size_t head = 0;
while (head < head_cap && head + 1 < out_cap && *p) out[head++] = *p++;
out[head] = '\0';
// The tail only means anything when the head field is exactly full — that is the only
// way a name could have spilled. Anything else is an old record's zero-fill or garbage
// behind a short name, and must not be appended.
if (head != head_cap || !ext || ext_cap == 0) return;
const char* q = ext;
size_t n = head;
size_t tail = 0;
while (tail < ext_cap && n + 1 < out_cap && *q) { out[n++] = *q++; ++tail; }
out[n] = '\0';
}
} // namespace SenderExtField
+874 -110
View File
File diff suppressed because it is too large. Load diff
+5 -1
View File
@@ -36,7 +36,11 @@ public:
static const int MAX_UI_MESSAGES_SD = 5000;
static const int MAX_UI_THREADS = 48;
static const int MAX_THREAD_NAME = 32;
static const int MAX_SENDER_NAME = 24;
// Full MeshCore name width: ContactInfo::name / ChannelDetails::name / NodePrefs::
// node_name are all char[32], so 31 chars + NUL is exactly lossless. Was 24, which
// silently dropped every longer name. The PERSISTED width is frozen separately
// (k_ui_disk_sender_len in UITask.cpp) — raising this does not move any on-disk field.
static const int MAX_SENDER_NAME = 31;
static const int MAX_MSG_TEXT = 160; // full LoRa text length (was 96 -> cut long msgs ~3 lines)
static const int MAX_UI_PATH = 32; // inbound-route bytes/message for the Info popup (covers deep + multi-byte-hash routes)
+29 -11
View File
@@ -163,13 +163,16 @@
// Focus-group D-pad navigation (no pointer): Tanmatsu keypad, T-Deck trackball,
// the pager (no touch at all — the rotary encoder is its only nav input, so
// like Tanmatsu this is always-on, not an optional toggle like the T-Deck's),
// or the ThinkNode M9's d-pad. The underlying machinery (navFifo, navMoveDir,
// the focus group, the secondary KEYPAD indev) is generic — only the *pump*
// that feeds it differs per board: Tanmatsu's navPump() reads bsp-input events;
// T-Deck's WASDZ-letter nav and the M9's raw d-pad bytes are both fed straight
// from handleHwKey() instead (see UITask.cpp's `#elif defined(HAS_M9_KEYBOARD)`
// block, parallel to the T-Deck's `#if CAP_TRACKBALL` block).
#if defined(HAS_TANMATSU) || defined(HAS_TDECK_TRACKBALL) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9)
// the ThinkNode M9's d-pad, or the Attaky's front D-pad + SELECT. The underlying
// machinery (navFifo, navMoveDir, the focus group, the secondary KEYPAD indev)
// is generic — only the *pump* that feeds it differs per board: Tanmatsu's
// navPump() reads bsp-input events; T-Deck's WASDZ-letter nav and the M9's raw
// d-pad bytes are both fed straight from handleHwKey() instead (see UITask.cpp's
// `#elif defined(HAS_M9_KEYBOARD)` block, parallel to the T-Deck's `#if
// CAP_TRACKBALL` block); the Attaky drains its expander queue in attakyNavPump().
// NOTE: the Attaky is the first board here with CAP_KEYBOARD == 0, so anything
// this flag pulls in must not assume a physical keyboard is also compiled.
#if defined(HAS_TANMATSU) || defined(HAS_TDECK_TRACKBALL) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9) || defined(ATTAKY_MESH_SERIES)
#define CAP_KEYPAD_NAV 1
#else
#define CAP_KEYPAD_NAV 0
@@ -195,11 +198,26 @@
#endif
// Console mode (CONSOLE_MODE.md): a text front end drawn straight to the panel
// with no LVGL. Needs a DisplayDriver, which every board that has a screen has,
// so this is on wherever there is something to draw on. It does NOT imply the
// device boots into it; that is a user pref read at startup.
// with no LVGL. It does NOT imply the device boots into it; that is a user pref
// read at startup.
//
// A DISPLAY IS NOT ENOUGH. This used to be gated on DISPLAY_CLASS alone, which
// reasons about output and says nothing about whether the user can type. The
// console's only input paths are the T-Deck keyboard, the M9 keyboard and the
// on-screen keypad (CAP_TOUCH), so on any other board it booted, painted
// "type 'ui' to go back", and accepted nothing: an unbootable-out state that
// survived a power cycle and a reset, because the pref persists and the panic
// self-heal never fires when nothing has actually crashed. A ThinkNode M9 was
// stranded exactly this way on beta_70 (gadgeteerza).
//
// Any board added here must have a drain in the console branch of UITask::loop.
#if defined(HAS_TDECK_KEYBOARD) || defined(HAS_M9_KEYBOARD) || CAP_TOUCH
#define CAP_CONSOLE_INPUT 1
#else
#define CAP_CONSOLE_INPUT 0
#endif
#ifndef CAP_CONSOLE
#if defined(DISPLAY_CLASS)
#if defined(DISPLAY_CLASS) && CAP_CONSOLE_INPUT
#define CAP_CONSOLE 1
#else
#define CAP_CONSOLE 0
+45 -2
View File
@@ -188,6 +188,7 @@ static const I18nPair kBuiltin_hu[] = {
{ "Contact deleted", "Névjegy törölve" },
{ "Contact gone", "Eltűnt névjegy" },
{ "Contact missing", "Hiányzó névjegy" },
{ "Contact shared", "Névjegy megosztva" },
{ "Contacts", "Névjegyek" },
{ "Contacts are on internal flash - the SD card did not mount at boot. Re-seat the card and reboot.", "A névjegyek a belső tárolón vannak - az SD-kártya nem csatolódott induláskor. Helyezd be újra a kártyát, és indítsd újra." },
{ "Contacts are on internal flash.", "A névjegyek a belső tárolón vannak." },
@@ -403,7 +404,7 @@ static const I18nPair kBuiltin_hu[] = {
{ "Join hashtag channel", "Csatlakozz # csatornához" },
{ "Join network", "Csatlakozás a hálózathoz" },
{ "Join private channel", "Csatlakozz privát csatornához" },
{ "Join the public channel", "Csatlakozás a nyilvános csatornához" },
{ "Join the public channel", "Belépés a nyilvánoshoz" },
{ "Join w/ password", "Csatlakozás jelszóval" },
{ "Joined %.40s", "Csatlakozott %.40s alatt" },
{ "Keep per chat (messages)", "Megőrzés csevegésenként (üzenet)" },
@@ -417,6 +418,7 @@ static const I18nPair kBuiltin_hu[] = {
{ "Keyboard nav: off", "Billentyűzetnavigáció: kikapcsolva" },
{ "Keyboard navigation", "Billentyűzetnavigáció" },
{ "Keys", "Kulcsok" },
{ "Known regions", "Ismert régiók" },
{ "LOS → %s", "Rálátás → %s" },
{ "Landscape (desktop)", "Fekvő (asztali)" },
{ "Landscape set - applies when remote mode starts.", "Fekvő beállítva - a távoli mód indulásakor lép életbe." },
@@ -481,6 +483,7 @@ static const I18nPair kBuiltin_hu[] = {
{ "Map — set your location\nin Settings → Profile to\nshow the map here.", "Térkép – a Beállítások → Profil menüpontban állítsd be a tartózkodási helyedet, hogy itt jelenjen meg a térkép." },
{ "Mark all chats and channels as read?", "Az összes csevegést és csatornát megjelölöd olvasottként?" },
{ "Mark as read", "Olvasottnak jelöl" },
{ "Mark read", "Olvasott" },
{ "Marked read", "Olvasottként megjelölve" },
{ "Max dots", "Max. pontok" },
{ "Max hops (0..64)", "Max ugrások (0-64)" },
@@ -817,6 +820,8 @@ static const I18nPair kBuiltin_hu[] = {
{ "Setup complete", "Beállítás kész" },
{ "Share QR", "QR megosztása" },
{ "Share channel", "Csatorna megosztása" },
{ "Share contact", "Névjegy megosztása" },
{ "Share failed", "Megosztás sikertelen" },
{ "Share location in advert", "Helyszín megosztása a hirdetésben" },
{ "Share my loc", "Helyzetem megosztása" },
{ "Share my location when asked", "Helyzetem megosztása kérésre" },
@@ -836,6 +841,7 @@ static const I18nPair kBuiltin_hu[] = {
{ "Sightline", "Rálátás" },
{ "Signal", "Jelerősség" },
{ "Signal & traffic", "Jelzés és forgalom" },
{ "Signal (stale)", "Jel (elavult)" },
{ "Signal probe", "Jelmérés" },
{ "Size", "Méret" },
{ "Skip", "Kihagyás" },
@@ -930,7 +936,7 @@ static const I18nPair kBuiltin_hu[] = {
{ "Type", "Tipus" },
{ "Type a URL, then Go", "Írj be egy URL-t, majd Mehet" },
{ "Type a message...", "Írj be egy üzenetet..." },
{ "Type a new 6-digit code · applies after a reboot", "Írjon be egy új 6 jegyű kódot · újraindítás után érvényes" },
{ "Type a new 6-digit code · applies after a reboot", "Írjon be egy 6 jegyű kódot · újraindítás után él" },
{ "Type your network name — or switch to Wi-Fi in Settings to scan", "Írd be a hálózatod nevét – vagy válts Wi-Fi-re a beállításokban a kereséshez" },
{ "UI size (restart to apply)", "UI mérete (újraindítás az alkalmazáshoz)" },
{ "UI size saved — restart to apply", "UI mérete mentve – indítsa újra a programot az alkalmazáshoz" },
@@ -1011,6 +1017,7 @@ static const I18nPair kBuiltin_hu[] = {
{ "my region", "saját régió" },
{ "n/a in this build", "n/a ebben a buildben" },
{ "not asked yet", "Még nem kérték" },
{ "nothing heard yet", "Még semmi hír." },
{ "now on channel %s", "most a %s csatornán" },
{ "now talking to %s", "most %s-szel beszélgetek" },
{ "off", "ki" },
@@ -1194,6 +1201,7 @@ static const I18nPair kBuiltin_nl[] = {
{ "Contact deleted", "Contact verwijderd" },
{ "Contact gone", "Contact weg" },
{ "Contact missing", "Contact ontbreekt" },
{ "Contact shared", "Contact gedeeld" },
{ "Contacts", "Contacten" },
{ "Contacts are on internal flash - the SD card did not mount at boot. Re-seat the card and reboot.", "Contacten staan op intern flash - de SD-kaart is bij het opstarten niet gekoppeld. Plaats de kaart opnieuw en herstart." },
{ "Contacts are on internal flash.", "Contacten staan op intern flashgeheugen." },
@@ -1764,6 +1772,8 @@ static const I18nPair kBuiltin_nl[] = {
{ "Setup complete", "Installatie voltooid" },
{ "Share QR", "QR delen" },
{ "Share channel", "Kanaal delen" },
{ "Share contact", "Contact delen" },
{ "Share failed", "Delen mislukt" },
{ "Share location in advert", "Locatie delen in advert" },
{ "Share secret", "Geheim delen" },
{ "Share the 32-char secret so others can join. Leave the secret empty to generate a random one.", "Deel het 32-teken geheim zodat anderen kunnen deelnemen. Leeg laten voor een willekeurig geheim." },
@@ -2114,6 +2124,7 @@ static const I18nPair kBuiltin_de[] = {
{ "Contact deleted", "Kontakt gelöscht" },
{ "Contact gone", "Kontakt weg" },
{ "Contact missing", "Kontakt fehlt" },
{ "Contact shared", "Kontakt geteilt" },
{ "Contacts", "Kontakte" },
{ "Contacts are on internal flash - the SD card did not mount at boot. Re-seat the card and reboot.", "Kontakte liegen im internen Flash - die SD-Karte wurde beim Start nicht eingebunden. Karte neu einsetzen und neu starten." },
{ "Contacts are on internal flash.", "Kontakte liegen im internen Flash." },
@@ -2685,6 +2696,8 @@ static const I18nPair kBuiltin_de[] = {
{ "Setup complete", "Einrichtung abgeschlossen" },
{ "Share QR", "QR teilen" },
{ "Share channel", "Kanal teilen" },
{ "Share contact", "Kontakt teilen" },
{ "Share failed", "Teilen fehlgeschlagen" },
{ "Share location in advert", "Standort im Advert teilen" },
{ "Share secret", "Geheimnis teilen" },
{ "Share the 32-char secret so others can join. Leave the secret empty to generate a random one.", "Teilen Sie das 32-Zeichen-Geheimnis, damit andere beitreten. Leer lassen für ein zufälliges." },
@@ -3037,6 +3050,7 @@ static const I18nPair kBuiltin_fr[] = {
{ "Contact deleted", "Contact supprimé" },
{ "Contact gone", "Contact perdu" },
{ "Contact missing", "Contact manquant" },
{ "Contact shared", "Contact partagé" },
{ "Contacts are on internal flash - the SD card did not mount at boot. Re-seat the card and reboot.", "Les contacts sont en mémoire interne - la carte SD ne s'est pas montée au démarrage. Réinsérez la carte et redémarrez." },
{ "Contacts are on internal flash.", "Les contacts sont sur la mémoire interne." },
{ "Contacts are saved to the SD card.", "Les contacts sont enregistrés sur la carte SD." },
@@ -3572,6 +3586,8 @@ static const I18nPair kBuiltin_fr[] = {
{ "Setup complete", "Configuration terminée" },
{ "Share QR", "Partager le QR" },
{ "Share channel", "Partager le canal" },
{ "Share contact", "Partager le contact" },
{ "Share failed", "Échec du partage" },
{ "Share location in advert", "Partager la position dans l'annonce" },
{ "Share secret", "Partager le secret" },
{ "Share the 32-char secret so others can join. Leave the secret empty to generate a random one.", "Partagez le secret de 32 caractères pour que d'autres rejoignent. Laissez vide pour un aléatoire." },
@@ -3916,6 +3932,7 @@ static const I18nPair kBuiltin_es[] = {
{ "Contact deleted", "Contacto eliminado" },
{ "Contact gone", "Contacto perdido" },
{ "Contact missing", "Falta el contacto" },
{ "Contact shared", "Contacto compartido" },
{ "Contacts", "Contactos" },
{ "Contacts are on internal flash - the SD card did not mount at boot. Re-seat the card and reboot.", "Los contactos están en memoria interna - la tarjeta SD no se montó al arrancar. Reinserta la tarjeta y reinicia." },
{ "Contacts are on internal flash.", "Los contactos están en la memoria interna." },
@@ -4457,6 +4474,8 @@ static const I18nPair kBuiltin_es[] = {
{ "Setup complete", "Configuración completa" },
{ "Share QR", "Compartir QR" },
{ "Share channel", "Compartir canal" },
{ "Share contact", "Compartir contacto" },
{ "Share failed", "Error al compartir" },
{ "Share location in advert", "Compartir ubicación en anuncio" },
{ "Share secret", "Compartir secreto" },
{ "Share the 32-char secret so others can join. Leave the secret empty to generate a random one.", "Comparte el secreto de 32 caracteres para que otros se unan. Déjalo vacío para uno aleatorio." },
@@ -4802,6 +4821,7 @@ static const I18nPair kBuiltin_it[] = {
{ "Contact deleted", "Contatto eliminato" },
{ "Contact gone", "Contatto perso" },
{ "Contact missing", "Contatto mancante" },
{ "Contact shared", "Contatto condiviso" },
{ "Contacts", "Contatti" },
{ "Contacts are on internal flash - the SD card did not mount at boot. Re-seat the card and reboot.", "I contatti sono sulla memoria interna - la scheda SD non si è montata all'avvio. Reinserisci la scheda e riavvia." },
{ "Contacts are on internal flash.", "I contatti sono sulla memoria interna." },
@@ -5341,6 +5361,8 @@ static const I18nPair kBuiltin_it[] = {
{ "Setup complete", "Configurazione completata" },
{ "Share QR", "Condividi QR" },
{ "Share channel", "Condividi canale" },
{ "Share contact", "Condividi contatto" },
{ "Share failed", "Condivisione non riuscita" },
{ "Share location in advert", "Condividi posizione in advert" },
{ "Share secret", "Condividi segreto" },
{ "Share the 32-char secret so others can join. Leave the secret empty to generate a random one.", "Condividi il segreto di 32 caratteri per far unire altri. Lascia vuoto per uno casuale." },
@@ -5688,6 +5710,7 @@ static const I18nPair kBuiltin_ru[] = {
{ "Contact deleted", "Контакт удалён" },
{ "Contact gone", "Контакт пропал" },
{ "Contact missing", "Контакт отсутствует" },
{ "Contact shared", "Контакт отправлен" },
{ "Contacts", "Контакты" },
{ "Contacts are on internal flash - the SD card did not mount at boot. Re-seat the card and reboot.", "Контакты во внутренней памяти - SD-карта не смонтировалась при загрузке. Переустановите карту и перезагрузитесь." },
{ "Contacts are on internal flash.", "Контакты во внутренней памяти." },
@@ -6234,6 +6257,8 @@ static const I18nPair kBuiltin_ru[] = {
{ "Setup complete", "Настройка завершена" },
{ "Share QR", "Поделиться QR" },
{ "Share channel", "Поделиться каналом" },
{ "Share contact", "Поделиться контактом" },
{ "Share failed", "Не удалось отправить контакт" },
{ "Share location in advert", "Делиться местоположением в advert" },
{ "Share secret", "Поделиться ключом" },
{ "Share the 32-char secret so others can join. Leave the secret empty to generate a random one.", "Поделитесь секретом из 32 символов, чтобы другие присоединились. Пусто = случайный." },
@@ -6585,6 +6610,7 @@ static const I18nPair kBuiltin_uk[] = {
{ "Contact deleted", "Контакт видалено" },
{ "Contact gone", "Контакт зник" },
{ "Contact missing", "Контакт відсутній" },
{ "Contact shared", "Контакт надіслано" },
{ "Contacts", "Контакти" },
{ "Contacts are on internal flash - the SD card did not mount at boot. Re-seat the card and reboot.", "Контакти у внутрішній пам'яті - SD-карта не змонтувалася під час завантаження. Переставте картку і перезавантажте." },
{ "Contacts are on internal flash.", "Контакти у внутрішній пам'яті." },
@@ -7131,6 +7157,8 @@ static const I18nPair kBuiltin_uk[] = {
{ "Setup complete", "Налаштування завершено" },
{ "Share QR", "Поділитися QR" },
{ "Share channel", "Поділитися каналом" },
{ "Share contact", "Поділитися контактом" },
{ "Share failed", "Не вдалося надіслати контакт" },
{ "Share location in advert", "Ділитися місцезнаходженням в advert" },
{ "Share secret", "Поділитися ключем" },
{ "Share the 32-char secret so others can join. Leave the secret empty to generate a random one.", "Поділіться секретом із 32 символів, щоб інші приєдналися. Порожньо = випадковий." },
@@ -7480,6 +7508,7 @@ static const I18nPair kBuiltin_bg[] = {
{ "Contact deleted", "Контактът е изтрит" },
{ "Contact gone", "Контактът изчезна" },
{ "Contact missing", "Контактът липсва" },
{ "Contact shared", "Контактът е споделен" },
{ "Contacts", "Контакти" },
{ "Contacts are on internal flash - the SD card did not mount at boot. Re-seat the card and reboot.", "Контактите са във вътрешната памет - SD картата не се монтира при старт. Постави картата отново и рестартирай." },
{ "Contacts are on internal flash.", "Контактите са във вътрешната памет." },
@@ -8026,6 +8055,8 @@ static const I18nPair kBuiltin_bg[] = {
{ "Setup complete", "Настройката е готова" },
{ "Share QR", "Сподели QR" },
{ "Share channel", "Сподели канала" },
{ "Share contact", "Сподели контакт" },
{ "Share failed", "Споделянето неуспешно" },
{ "Share location in advert", "Споделяй местоположението в advert" },
{ "Share secret", "Сподели ключа" },
{ "Share the 32-char secret so others can join. Leave the secret empty to generate a random one.", "Споделете 32-символната тайна, за да се присъединят други. Празно = случайна." },
@@ -8375,6 +8406,7 @@ static const I18nPair kBuiltin_sr[] = {
{ "Contact deleted", "Контакт обрисан" },
{ "Contact gone", "Контакт нестао" },
{ "Contact missing", "Контакт недостаје" },
{ "Contact shared", "Контакт подељен" },
{ "Contacts", "Контакти" },
{ "Contacts are on internal flash - the SD card did not mount at boot. Re-seat the card and reboot.", "Контакти су у унутрашњој меморији - SD картица се није монтирала при покретању. Поново убаци картицу и рестартуј." },
{ "Contacts are on internal flash.", "Контакти су у унутрашњој меморији." },
@@ -8921,6 +8953,8 @@ static const I18nPair kBuiltin_sr[] = {
{ "Setup complete", "Подешавање завршено" },
{ "Share QR", "Подели QR" },
{ "Share channel", "Подели канал" },
{ "Share contact", "Подели контакт" },
{ "Share failed", "Дељење није успело" },
{ "Share location in advert", "Дели локацију у advert-у" },
{ "Share secret", "Подели тајну" },
{ "Share the 32-char secret so others can join. Leave the secret empty to generate a random one.", "Поделите тајну од 32 знака да се други придруже. Празно = насумична." },
@@ -9270,6 +9304,7 @@ static const I18nPair kBuiltin_el[] = {
{ "Contact deleted", "Η επαφή διαγράφηκε" },
{ "Contact gone", "Η επαφή χάθηκε" },
{ "Contact missing", "Η επαφή λείπει" },
{ "Contact shared", "Η επαφή κοινοποιήθηκε" },
{ "Contacts", "Επαφές" },
{ "Contacts are on internal flash - the SD card did not mount at boot. Re-seat the card and reboot.", "Οι επαφές είναι στην εσωτερική μνήμη - η κάρτα SD δεν προσαρτήθηκε στην εκκίνηση. Επανατοποθετήστε την και επανεκκινήστε." },
{ "Contacts are on internal flash.", "Οι επαφές είναι στην εσωτερική μνήμη." },
@@ -9813,6 +9848,8 @@ static const I18nPair kBuiltin_el[] = {
{ "Setup complete", "Η ρύθμιση ολοκληρώθηκε" },
{ "Share QR", "Κοινοποίηση QR" },
{ "Share channel", "Κοινή χρήση καναλιού" },
{ "Share contact", "Κοινοποίηση επαφής" },
{ "Share failed", "Αποτυχία κοινοποίησης" },
{ "Share location in advert", "Κοινή χρήση θέσης στο advert" },
{ "Share secret", "Κοινοποίηση μυστικού" },
{ "Share the 32-char secret so others can join. Leave the secret empty to generate a random one.", "Μοιραστείτε το μυστικό 32 χαρακτήρων για να συμμετάσχουν άλλοι. Κενό = τυχαίο." },
@@ -10156,6 +10193,7 @@ static const I18nPair kBuiltin_pt_br[] = {
{ "Contact deleted", "Contato excluído" },
{ "Contact gone", "Contato sumiu" },
{ "Contact missing", "Contato ausente" },
{ "Contact shared", "Contato compartilhado" },
{ "Contacts", "Contatos" },
{ "Contacts are on internal flash - the SD card did not mount at boot. Re-seat the card and reboot.", "Os contatos estão na memória interna - o cartão SD não montou na inicialização. Recoloque o cartão e reinicie." },
{ "Contacts are on internal flash.", "Os contatos estão na memória interna." },
@@ -10693,6 +10731,8 @@ static const I18nPair kBuiltin_pt_br[] = {
{ "Setup complete", "Configuração concluída" },
{ "Share QR", "Compartilhar QR" },
{ "Share channel", "Compartilhar canal" },
{ "Share contact", "Compartilhar contato" },
{ "Share failed", "Falha ao compartilhar" },
{ "Share location in advert", "Compartilhar local no advert" },
{ "Share secret", "Compartilhar segredo" },
{ "Share the 32-char secret so others can join. Leave the secret empty to generate a random one.", "Compartilhe o segredo de 32 caracteres para outros entrarem. Deixe vazio para gerar um aleatório." },
@@ -11036,6 +11076,7 @@ static const I18nPair kBuiltin_ro[] = {
{ "Contact deleted", "Contact şters" },
{ "Contact gone", "Contact dispărut" },
{ "Contact missing", "Contact lipsă" },
{ "Contact shared", "Contact partajat" },
{ "Contacts", "Contacte" },
{ "Contacts are on internal flash - the SD card did not mount at boot. Re-seat the card and reboot.", "Contactele sunt pe memoria internă - cardul SD nu s-a montat la pornire. Reaşază cardul şi reporneşte." },
{ "Contacts are on internal flash.", "Contactele sunt pe memoria internă." },
@@ -11570,6 +11611,8 @@ static const I18nPair kBuiltin_ro[] = {
{ "Setup complete", "Configurare finalizată" },
{ "Share QR", "Partajează QR" },
{ "Share channel", "Partajează canalul" },
{ "Share contact", "Partajează contactul" },
{ "Share failed", "Partajare eşuată" },
{ "Share location in advert", "Partajează locaţia în advert" },
{ "Share secret", "Partajează secretul" },
{ "Share the 32-char secret so others can join. Leave the secret empty to generate a random one.", "Partajează secretul de 32 caractere ca să poată intra şi alţii. Lasă secretul gol pentru a genera unul aleatoriu." },
+143
View File
@@ -0,0 +1,143 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include <assert.h>
#include <string.h>
#include "ui-touch/ChannelSenderSplit.h"
// UIMessage::sender is char[MAX_SENDER_NAME + 1] with MAX_SENDER_NAME == 31 — the full
// MeshCore name width (ContactInfo::name[32]), so every real name now fits and the
// truncation path is unreachable from the firmware's own callers. It is still exercised
// below against narrower destinations: the helper is documented to truncate to whatever
// it is handed, and the beta that shipped a 24-char field relies on exactly that.
static const size_t kSenderCap = 32;
// The frozen on-disk width (k_ui_disk_sender_len) — and what the previous beta's
// UIMessage::sender was. The truncation + UTF-8 back-off cases run against this.
static const size_t kNarrowCap = 25;
int main() {
char sender[kSenderCap];
// Ordinary channel post: author out, body in.
const char* body = ChannelSenderSplit::split("nick: hello there", sender, sizeof sender);
assert(strcmp(sender, "nick") == 0);
assert(strcmp(body, "hello there") == 0);
// The regression this all started from: a 27-char name. It used to fail the split
// outright, so the bubble header showed the channel and the body kept reading
// "nick: message". At the full width it is stored whole, with no marker.
const char* long_name = "Ouderkerk Repeater Node Two: on air";
body = ChannelSenderSplit::split(long_name, sender, sizeof sender);
assert(strcmp(sender, "Ouderkerk Repeater Node Two") == 0);
assert(strlen(sender) == 27);
assert(strcmp(body, "on air") == 0);
// The longest name the wire can carry: 31 chars, stored exactly, no marker.
const char* longest = "1234567890123456789012345678901: body";
body = ChannelSenderSplit::split(longest, sender, sizeof sender);
assert(strlen(sender) == 31);
assert(strcmp(sender, "1234567890123456789012345678901") == 0);
assert(strcmp(body, "body") == 0);
// One char PAST the wire width is not a name at all — no node can be called this — so
// the ": " belongs to the message. Leave the text whole rather than inventing an author
// and hiding the start of the body.
const char* not_a_name = "12345678901234567890123456789012: body";
assert(strlen(not_a_name) == 38);
body = ChannelSenderSplit::split(not_a_name, sender, sizeof sender);
assert(sender[0] == '\0');
assert(body == not_a_name);
// The realistic shape of that: an unprefixed post whose body just happens to contain
// ": ". It must survive intact, whole, with the caller's own sender label.
const char* prose = "the repeater at Ouderkerk is back up: full quieting again";
body = ChannelSenderSplit::split(prose, sender, sizeof sender);
assert(sender[0] == '\0');
assert(body == prose);
// ---- Narrower destination: truncate the label, never reject the split -------------
char narrow[kNarrowCap];
// The 27-char name again. Too long for this field, so it is cut and marked with "..."
// — a shortened name must not read as a different node.
body = ChannelSenderSplit::split(long_name, narrow, sizeof narrow);
assert(strlen(narrow) == 24);
assert(strcmp(narrow, "Ouderkerk Repeater No...") == 0);
assert(strcmp(body, "on air") == 0);
// The widest name the wire can carry still splits here — cut, but the author is out
// of the body, which is the whole point.
body = ChannelSenderSplit::split(longest, narrow, sizeof narrow);
assert(strcmp(narrow, "123456789012345678901...") == 0);
assert(strcmp(body, "body") == 0);
// One char over the cap still gets the marker (name budget 21, not 24).
const char* one_over = "1234567890123456789012345: body";
body = ChannelSenderSplit::split(one_over, narrow, sizeof narrow);
assert(strcmp(narrow, "123456789012345678901...") == 0);
assert(strcmp(body, "body") == 0);
// Exactly at the cap: 24 chars, no truncation, no marker.
const char* exact = "123456789012345678901234: body";
body = ChannelSenderSplit::split(exact, narrow, sizeof narrow);
assert(strcmp(narrow, "123456789012345678901234") == 0);
assert(strcmp(body, "body") == 0);
// Truncation lands on a character boundary, never inside a multi-byte sequence.
// "Ouderkerkk" (10) + U+2600 (3 bytes) x 5 = 25 bytes; the 21-byte name budget
// (24 minus the marker) falls inside the fourth sun, so it backs off to 19.
const char* utf8_name = "Ouderkerkk\xE2\x98\x80\xE2\x98\x80\xE2\x98\x80\xE2\x98\x80\xE2\x98\x80: hi";
body = ChannelSenderSplit::split(utf8_name, narrow, sizeof narrow);
assert(strcmp(narrow, "Ouderkerkk\xE2\x98\x80\xE2\x98\x80\xE2\x98\x80...") == 0);
assert(strlen(narrow) == 22);
assert(strcmp(body, "hi") == 0);
// Same shape one char shorter, so the budget already sits on a boundary: the back-off
// must not eat a whole character that fits.
const char* utf8_fits = "Ouderkerk\xE2\x98\x80\xE2\x98\x80\xE2\x98\x80\xE2\x98\x80\xE2\x98\x80\xE2\x98\x80: hi";
body = ChannelSenderSplit::split(utf8_fits, narrow, sizeof narrow);
assert(strcmp(narrow, "Ouderkerk\xE2\x98\x80\xE2\x98\x80\xE2\x98\x80\xE2\x98\x80...") == 0);
assert(strlen(narrow) == 24);
assert(strcmp(body, "hi") == 0);
// Destination too small to hold a marker: truncate plainly rather than spending the
// whole label on dots.
char tiny[5];
body = ChannelSenderSplit::split("nickname: body", tiny, sizeof tiny);
assert(strcmp(tiny, "nick") == 0);
assert(strcmp(body, "body") == 0);
// ---- Nothing to split --------------------------------------------------------------
// No author prefix — text passes through untouched, sender stays empty so the
// caller falls back to from_name.
const char* plain = "just a message";
body = ChannelSenderSplit::split(plain, sender, sizeof sender);
assert(sender[0] == '\0');
assert(body == plain);
// A bare colon without the space is not a separator.
const char* no_space = "nick:hello";
body = ChannelSenderSplit::split(no_space, sender, sizeof sender);
assert(sender[0] == '\0');
assert(body == no_space);
// Leading separator: empty author is not a name.
const char* leading = ": body";
body = ChannelSenderSplit::split(leading, sender, sizeof sender);
assert(sender[0] == '\0');
assert(body == leading);
// First separator wins — a colon in the body does not re-split.
body = ChannelSenderSplit::split("nick: ratio 3: 1", sender, sizeof sender);
assert(strcmp(sender, "nick") == 0);
assert(strcmp(body, "ratio 3: 1") == 0);
// Degenerate inputs.
assert(strcmp(ChannelSenderSplit::split(nullptr, sender, sizeof sender), "") == 0);
assert(sender[0] == '\0');
const char* keep = "nick: hi";
assert(ChannelSenderSplit::split(keep, nullptr, 0) == keep);
return 0;
}
+89
View File
@@ -0,0 +1,89 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include <assert.h>
#include <string.h>
#include "ui-touch/SenderExtField.h"
// The on-disk shape: base is the frozen v6 field (24 chars + NUL), ext is the appended
// tail, and together they hold MAX_SENDER_NAME == 31 chars.
static const size_t kBaseCap = 25;
static const size_t kExtCap = 7;
static const size_t kOutCap = 32;
int main() {
char base[kBaseCap], ext[kExtCap], out[kOutCap];
// Short name: fits the base field, ext stays empty.
SenderExtField::store("nick", base, sizeof base, ext, sizeof ext);
assert(strcmp(base, "nick") == 0);
for (size_t i = 0; i < kExtCap; ++i) assert(ext[i] == '\0');
SenderExtField::load(base, sizeof base, ext, sizeof ext, out, sizeof out);
assert(strcmp(out, "nick") == 0);
// Exactly 24: fills the base field with no spill. The tail must stay empty, or the
// "base is full" test below would resurrect garbage.
const char* exactly24 = "123456789012345678901234";
assert(strlen(exactly24) == 24);
SenderExtField::store(exactly24, base, sizeof base, ext, sizeof ext);
assert(strcmp(base, exactly24) == 0);
for (size_t i = 0; i < kExtCap; ++i) assert(ext[i] == '\0');
SenderExtField::load(base, sizeof base, ext, sizeof ext, out, sizeof out);
assert(strcmp(out, exactly24) == 0);
// A full 31-char name — the case the whole split exists for. Round-trips intact.
const char* full = "Ouderkerk Repeater Node Twelve";
assert(strlen(full) == 30);
SenderExtField::store(full, base, sizeof base, ext, sizeof ext);
assert(strncmp(base, full, 24) == 0 && base[24] == '\0');
assert(memcmp(ext, full + 24, 6) == 0);
SenderExtField::load(base, sizeof base, ext, sizeof ext, out, sizeof out);
assert(strcmp(out, full) == 0);
// The longest possible name fills ext completely, leaving it unterminated. load() is
// bounded by the field size, so it must not read past.
const char* longest = "1234567890123456789012345678901";
assert(strlen(longest) == 31);
SenderExtField::store(longest, base, sizeof base, ext, sizeof ext);
assert(memcmp(ext, longest + 24, kExtCap) == 0); // no NUL anywhere in ext
SenderExtField::load(base, sizeof base, ext, sizeof ext, out, sizeof out);
assert(strcmp(out, longest) == 0);
// An OLD record: base written by a pre-widening build, ext zero-filled by the loader's
// prefix read. Must yield exactly the stored short name.
memset(ext, 0, sizeof ext);
memset(base, 0, sizeof base);
strcpy(base, "olde name");
SenderExtField::load(base, sizeof base, ext, sizeof ext, out, sizeof out);
assert(strcmp(out, "olde name") == 0);
// Garbage in ext behind a NON-full base must be ignored — that is the corrupt-record
// case, and appending it would splice junk onto a valid short name.
memset(ext, 'X', sizeof ext);
SenderExtField::load(base, sizeof base, ext, sizeof ext, out, sizeof out);
assert(strcmp(out, "olde name") == 0);
// An unterminated base field (corrupt record) must not read into the message body that
// follows it on disk: the copy stops at the field width.
char raw_base[kBaseCap];
memset(raw_base, 'A', sizeof raw_base); // no NUL at all
memset(ext, 0, sizeof ext);
SenderExtField::load(raw_base, sizeof raw_base, ext, sizeof ext, out, sizeof out);
assert(strlen(out) == 24);
// A multi-byte character straddling the boundary: split anywhere, rejoin exact. The
// halves are never used apart, so the byte-level split is deliberately not aligned.
const char* utf8 = "Ouderkerkkkkkkkkkkkkkkk\xE2\x98\x80"; // 23 ASCII + 3-byte U+2600
assert(strlen(utf8) == 26);
SenderExtField::store(utf8, base, sizeof base, ext, sizeof ext);
assert((unsigned char)base[23] == 0xE2); // sequence is cut mid-character on disk
SenderExtField::load(base, sizeof base, ext, sizeof ext, out, sizeof out);
assert(strcmp(out, utf8) == 0); // and comes back whole
// Degenerate inputs.
SenderExtField::store(nullptr, base, sizeof base, ext, sizeof ext);
assert(base[0] == '\0');
SenderExtField::load(base, sizeof base, ext, sizeof ext, out, sizeof out);
assert(out[0] == '\0');
return 0;
}
@@ -20,15 +20,50 @@ static const uint8_t POWER_BIT = (1u << 7); // P07 = POWER_BTN
static const uint8_t DEBOUNCE_POLLS = 2;
static const uint32_t POLL_INTERVAL_MS = 30;
// Auto-repeat for the four directions (never SELECT — one press must stay one
// activation). Without it, walking a long contact list means one press per row.
static const uint32_t REPEAT_DELAY_MS = 450;
static const uint32_t REPEAT_RATE_MS = 130;
// P0 bit -> nav event. 0 = not a nav button (L1/R2 are read but unbound, POWER
// has its own edge flag below).
static const uint8_t BIT_NAV[8] = {
ATTAKY_NAV_DOWN, // P00
ATTAKY_NAV_LEFT, // P01
ATTAKY_NAV_SELECT, // P02
ATTAKY_NAV_RIGHT, // P03
ATTAKY_NAV_UP, // P04
ATTAKY_NAV_NONE, // P05 BTN_L1 — unbound
ATTAKY_NAV_NONE, // P06 BTN_R2 — unbound
ATTAKY_NAV_NONE, // P07 POWER_BTN — attakyPowerKeyPressed()
};
static bool s_inited = false;
static bool s_present = false;
static uint32_t s_next_ms = 0;
static bool s_power_down = false; // debounced state
static uint8_t s_pending_n = 0; // consecutive polls agreeing with s_pending_down
static bool s_pending_down = false;
// Debounced whole-port state, active-low (a 0 bit = that button is down).
static uint8_t s_stable = 0xFF;
static uint8_t s_pending = 0xFF; // candidate awaiting DEBOUNCE_POLLS agreement
static uint8_t s_pending_n = 0;
static volatile bool s_power_event = false;
// Nav press queue. Small ring: the poll runs at ~30 ms and the UI drains every
// tick, so it only has to absorb a burst, never buffer.
static const uint8_t NAV_Q_LEN = 8;
static uint8_t s_navq[NAV_Q_LEN];
static uint8_t s_navq_head = 0, s_navq_tail = 0;
static int8_t s_repeat_bit = -1; // direction currently held, or -1
static uint32_t s_repeat_ms = 0; // when it next repeats
static void navPush(uint8_t ev) {
const uint8_t next = (uint8_t)((s_navq_head + 1) % NAV_Q_LEN);
if (next == s_navq_tail) return; // full — drop the newest rather than stall
s_navq[s_navq_head] = ev;
s_navq_head = next;
}
static bool awWrite(uint8_t reg, uint8_t val) {
Wire.beginTransmission(KEYS_ADDR);
Wire.write(reg);
@@ -75,14 +110,37 @@ void attakyKeysPoll() {
}
if (!ok) return; // bus busy or read failed — hold the previous state
const bool down = (p0 & POWER_BIT) == 0; // active low
// Every button reading pressed at once is a bad read, not a hand: acting on
// it would inject a burst of nav keys AND a panel toggle from one glitch.
// Resync the debouncer instead of treating the recovery as presses.
if (p0 == 0x00) { s_pending = s_stable; s_pending_n = 0; return; }
if (down != s_pending_down) { s_pending_down = down; s_pending_n = 1; return; }
if (p0 != s_pending) { s_pending = p0; s_pending_n = 1; return; }
if (s_pending_n < DEBOUNCE_POLLS) { s_pending_n++; return; }
if (down != s_power_down) {
s_power_down = down;
if (down) s_power_event = true; // fire on the press edge
const uint8_t changed = (uint8_t)(s_stable ^ p0);
if (changed) {
const uint8_t went_down = (uint8_t)(changed & ~p0); // active low: 1 -> 0
s_stable = p0;
if (went_down & POWER_BIT) s_power_event = true; // fire on the press edge
for (uint8_t b = 0; b < 8; b++) {
if (!(went_down & (1u << b)) || BIT_NAV[b] == ATTAKY_NAV_NONE) continue;
navPush(BIT_NAV[b]);
// Only the directions repeat, and only the most recent one: rolling a
// thumb across the pad should follow the finger, not fire two axes.
if (BIT_NAV[b] != ATTAKY_NAV_SELECT) {
s_repeat_bit = (int8_t)b;
s_repeat_ms = now + REPEAT_DELAY_MS;
}
}
// The repeating direction was released (or another button's edge arrived
// while it was already up) — stop repeating.
if (s_repeat_bit >= 0 && (s_stable & (1u << (uint8_t)s_repeat_bit))) s_repeat_bit = -1;
}
if (s_repeat_bit >= 0 && (int32_t)(now - s_repeat_ms) >= 0) {
navPush(BIT_NAV[(uint8_t)s_repeat_bit]);
s_repeat_ms = now + REPEAT_RATE_MS;
}
}
@@ -92,12 +150,26 @@ bool attakyPowerKeyPressed() {
return true;
}
int attakyNavKeyRead() {
if (s_navq_tail == s_navq_head) return ATTAKY_NAV_NONE;
const uint8_t ev = s_navq[s_navq_tail];
s_navq_tail = (uint8_t)((s_navq_tail + 1) % NAV_Q_LEN);
return (int)ev;
}
void attakyNavKeysDiscard() {
s_navq_tail = s_navq_head;
s_repeat_bit = -1;
}
bool attakyKeysPresent() { return s_present; }
#else
void attakyKeysPoll() {}
bool attakyPowerKeyPressed() { return false; }
int attakyNavKeyRead() { return ATTAKY_NAV_NONE; }
void attakyNavKeysDiscard() {}
bool attakyKeysPresent() { return false; }
#endif
@@ -3,9 +3,24 @@
#include <stdbool.h>
// Front buttons on the board's AW9523 @0x59 (P00-P07, active-low). Only
// POWER_BTN (P07) is used: short-press toggles the panel. A long press is the
// board's hardware power-cut, outside firmware control.
// Front buttons on the board's AW9523 @0x59 (P00-P07, active-low), per Attaky's
// hardware catalog:
// P00 DOWN P01 LEFT P02 SELECT P03 RIGHT P04 UP
// P05 BTN_L1 (top-left shoulder) P06 BTN_R2 (top-right shoulder)
// P07 POWER_BTN
// POWER_BTN short-press toggles the panel (a long press is the board's hardware
// power-cut, outside firmware control). The D-pad + SELECT feed the UI's focus
// navigation. L1/R2 are read but deliberately unbound — nothing claims them yet.
/** D-pad events drained by attakyNavKeyRead(). */
enum AttakyNavKey {
ATTAKY_NAV_NONE = 0,
ATTAKY_NAV_UP,
ATTAKY_NAV_DOWN,
ATTAKY_NAV_LEFT,
ATTAKY_NAV_RIGHT,
ATTAKY_NAV_SELECT,
};
/** Poll the expander. Call once per UI tick; rate-limits its own I2C traffic. */
void attakyKeysPoll();
@@ -13,5 +28,11 @@ void attakyKeysPoll();
/** True once per POWER_BTN press edge (consumes the event). */
bool attakyPowerKeyPressed();
/** Next queued D-pad event, or ATTAKY_NAV_NONE when the queue is empty. */
int attakyNavKeyRead();
/** Drop every queued D-pad event (screen-off wake, remote mode). */
void attakyNavKeysDiscard();
/** Whether the @0x59 expander answered at init (false = keys unavailable). */
bool attakyKeysPresent();
+2 -2
View File
@@ -59,7 +59,7 @@ public:
for (int i = 0; i < BATTERY_SAMPLES; i++) {
sum += analogReadMilliVolts(PIN_VBAT_READ);
}
return (uint16_t)(2 * (sum / BATTERY_SAMPLES));
return (uint16_t)(2.037f * (sum / BATTERY_SAMPLES));
#else
return 0;
#endif
@@ -68,4 +68,4 @@ public:
const char* getManufacturerName() const{
return "LilyGo T-Deck";
}
};
};
+208 -2
View File
@@ -363,8 +363,9 @@ ruled out the tile-slot pool, the tab-bar removal, and the key-drain timing:
touchless board it was uncontrollable dead space. `TABBAR_H` is now 0 for
M9 and the btnmatrix is hidden; every tab remains reachable via the dedicated
HOME/MESSAGE/MAP keys and the app drawer's Chats/Contacts/Map/Settings tiles.
Content gains the 30 px row; the update / chat-unread badges anchor to the
bottom corners instead of the (gone) bar.
Content gains the 30 px row. The M9 draws no notification badges there;
unread messages and newly discovered contacts instead blink small accent-colour
envelope/person glyphs in the former Chats and Contacts nav slots.
- **Spectrum sweep crawl fixed (LR1110-specific)**: `LR11x0::getRSSI(false)` is
not the SX126x's cheap register read — it internally re-arms RX and drops to
standby around EVERY call, so the 6-read peak-hold per bin paid ~960 extra
@@ -667,6 +668,211 @@ Gotchas for anyone revisiting this:
onMapTabActivated). The open levers are decoding on core 0, or the M9's SD
clock (still 4 MHz — see the deferred list below).
**SUPERSEDED by measurement, 2026-08-28.** The decode-vs-read framing above is
directionally right but was reasoning about the two SMALLER halves. Instrumented
on hardware (`[MAPPROF]`, audit pass 3 below), a 9-tile cold open split:
```
total=2189ms probe=0ms render=2080ms
[read=497ms decode=539ms other=1042ms tiles=9] markers=10ms
```
`other` — the per-tile `lv_refr_now()` progressive paint — cost **more than the
SD reads and the JPEG decodes combined**. On the immersive full-screen map each
call composites the whole 240x320 panel *and* alpha-blends the transparent
status + tab bars over it: ~115 ms a call, nine times. Throttling that to one
paint per 350 ms (`k_map_progressive_ms`) is a bigger win than either lever
named above, and it is board-independent. Measure before optimising here: the
intuition that "it must be the decode" was wrong by a factor of two.
## Audit pass 3 (2026-08-28) — cold map open + a real Back history
Third full sweep, prompted by two field reports: "the map doesn't load fast the
first time" and "Back should go back to the previous navigation". Both were
real; both had one identifiable cause. All eight PlatformIO envs compile-verified
(the shared `UITask.cpp` is the blast radius); on-device validation still wanted.
**Map, cold open.** The 2026-08-22 pass above fixed the *re-open* cost and left
cold open at 2598 ms, correctly attributing the bulk to the SJPG decode. What it
did not catch is what runs *in front* of that decode:
- **`bestAvailableZoom()` was called unconditionally** (`onMapTabActivated`) and
its result discarded one line later whenever a zoom was persisted — which it
is as soon as the user touches +/- once. It walks z19→z3 probing the card at
each level, returning at the first hit. Now skipped when the answer cannot be
used. It has no other caller, so this is pure dead-work removal.
- **The no-fix case was never first-open-only.** `s_map_view_inited` only latches
once a non-zero centre exists, so with no GPS fix and no Profile location the
recenter guard never closed: the full 17-level walk ran against lat/lon 0,0 —
tiles that cannot exist — on *every* map open, forever, and `renderMapTiles`
then bailed to the placeholder and freed the slots anyway. The centre is not
persisted across reboots either (only the zoom is), so every boot starts here.
- **The "Loading map…" hint could not paint.** It was set *after* the probe, so
the whole scan happened with the outgoing tab still frozen on the panel. Moved
to the top of the function, and `goToTab` now uses `LV_ANIM_OFF` for the map
tab so the flush is not racing a 200-400 ms slide. Zero ms saved, but it turns
a frozen screen into visible progress — which is most of the reported bug.
- **`tileExistsAt` probed `.png`, the generic loader only opens `.jpg`.** The
zoom guard therefore advertised, and the auto-snap selected, levels that render
blank. The `.png` leg is gone: the guard now mirrors the loader, and every
probe costs half as much.
**Map, data loss (ship this one regardless).** `tileCacheRemove(path_png)` in the
fetch worker — "drop any stale .png from an older build" — was written for the
firmware's private LittleFS partition. `UITask::begin` re-points `s_tile_fs` at
the SD **root** on this board with `s_tile_root = ""`, so it resolved to
`SD.remove("/tiles/<z>/<x>/<y>.png")`: the user's own offline map pack. The
skip-check above it only ever tests the `.jpg`, so every queued tile whose `.jpg`
was absent erased the co-located `.png`. Now gated on `s_tile_fs == &s_tiles_fs`,
the same discriminator `tileCacheMarkSdIo`/`tilesFsLowSpace` use. Wi-Fi-only
trigger, permanent loss.
**Back.** The ladder was a faithful copy of `pagerNavGoBack` **minus one rung** —
`else if (getActiveTab() != HOME_TAB_INDEX) navGoToMainTab(HOME_TAB_INDEX);`.
Without it Back fell through to `navPushTap(LV_KEY_ESC)`, and nothing in this
build consumes `LV_KEY_ESC`: the sole consumer is `lv_dropdown`, peeled several
rungs above, and `navMaybeRebuild` deliberately keeps the tab bar out of the
focus group. The press was consumed by the `return true` and nothing happened, on
every bare tab.
Restoring that rung alone gives *Home*, not *back*, so this pass also adds the
history that never existed anywhere in this UI (`s_lv_tab_prev` is a single
transition latch; `s_apppage_close` is depth 1; the popup registry walks in
static table order):
- **`s_m9_nav[]` — 12 entries, 24 B.** Records main-tab switches only. Pushed
from `goToTab` (the single choke point, and the only place that fires
`LV_EVENT_VALUE_CHANGED` explicitly), popped by a new rung placed **below**
every peel rung, so modals still close innermost-first and a null-close blocker
row still stops Back at the registry rung.
- **Deliberately not stacked:** the app drawer (a registry row rung 6 already
closes, whose mode survives a tab switch and is restored on return to Home) and
app pages / chats / settings sheets (all peeled above). Duplicating those here
would double-close.
- **Never store an `lv_obj_t*`** — settings sheets, app pages and the drawer are
destroyed, often via `del_async`. Entries are tab indices; one that no longer
applies is skipped, never a dead press.
- **`s_m9_nav_replaying`** suppresses the re-push from a pop's own `goToTab`.
The Home-fallback rung needs it too: without it that jump recorded the tab it
was leaving and the next Back popped straight back — Back ping-ponging forever.
- **HOME clears the trail** (it means "go to root"); MSG/MAP are lateral jumps
and keep pushing.
Also closed in this pass, all confirmed and all reachable on this board:
- **`openMeshContactDm` bypassed the choke point** — a bare `lv_tabview_set_act`
with no event send, so `tabChangedCb` never ran, `s_lv_tab_prev` went stale,
and the *next* real tab change computed `leaving_inbox` false and skipped the
only code that hides the DM overlay. The chat stayed painted over whatever tab
you moved to. The switch now fires the event, and moved above the overlay
setup so `tabChangedCb`'s own `hideKb()` cannot undo it.
- **Setup wizard Back was a dead key** for the whole wizard, even on the three
steps that draw a working on-screen Back wired to `setupBackCb`. The comment
claimed "nothing to back out of"; the wizard is a four-step stack.
- **Contacts select mode outlived its tab.** Its registry row has `flags=0`, so
`anyPopupOpen()` never reported it and nothing cleared it on tab change —
leaving Back to close it *invisibly* from another tab. `ctExitSelectMode()` now
runs on leaving Contacts, the same discipline `s_m9_map_pan` already gets.
- **`navGoToMainTab` ignored key-blocker rows** — the unconditional 8-iteration
drain spun closing nothing and switched tabs out from under a running SD format
or bulk delete. Now returns bool and stops. (The board's SUB_MESSAGE/SUB_MAP
cases already did this correctly; HOME and `navGoToMainTab` did not.)
- **Two focus-group blackouts** that made Back's *result* undrivable: the
notification chip was a non-`NAV_SKIP` `lv_layer_top` child with nothing
focusable inside, emptying the group for its ~1.1 s life on every incoming
message; and the Spectrum page's `NAV_SKIP_FLAG` root hid it from
`navTopHasVisibleChild`, re-rooting the group onto the app drawer still open
beneath it (d-pad moved an unseen ring over hidden tiles, OK launched them).
**The Spectrum fix needs BOTH halves — `NAV_PASSTHRU_FLAG` alone is a trap.**
Passthru excludes only the container; `navCollect` still recurses into the
subtree. `lv_chart` does **not** clear `LV_OBJ_FLAG_CLICKABLE` (LVGL 8.4 sets
it in the base `lv_obj` constructor; only `lv_label` and `lv_img`/`lv_canvas`
clear it), and `lv_obj_remove_style_all()` on the five colour-ramp swatches
removes styles, not flags. So passthru-on-root alone handed the page six focus
stops where it previously had zero, and `navFocusCb`'s reverse-video fill
painted a solid white block straight over the live trace — caught on hardware
within a minute of flashing. The chart and each swatch now carry
`NAV_SKIP_FLAG` so the group ends up genuinely empty, which is what the
original comment always claimed. Anything added to this page later must do the
same, or it becomes a focus stop by default.
Known consequence: with the page as the focus container, `useTop` is true and
`navAddStatusBarActions()` is skipped, so the "‹ Spectrum" chevron is no
longer a d-pad target. Back (hardware key / registry row) still closes the
page on every board, which is the documented exit.
- **`ctExitSelectMode` must not go through `ctSetSelectMode(false)`.** That
setter ends in `s_ct_list_force = true; refreshContactsList();` — the full
teardown+rebuild measured at ~1.3 s with a large list (#82). Called from
`tabChangedCb` as the user LEAVES Contacts, inside the tabview's
`VALUE_CHANGED` chain, that freezes the tab they are moving *to*, on every
board. It now restores the toolbars and only ARMS `s_ct_list_force`; the
rebuild lands on the way back in, where `tabChangedCb` already calls
`refreshContactsList()` and the user can see it.
- **Blocker check must precede the app-page close.** With `navGoToMainTab` now
refusing to switch while a null-close progress row owns the screen,
`M9_KEY_LEFT_MESSAGE` and `M9_KEY_MAP` — which called `s_apppage_close()`
*first* — would have destroyed the page and then not jumped, leaving the user
with neither. Both now drain-and-bail before closing anything, the order
`SUB_MESSAGE` / `SUB_MAP` already used.
**Measured on hardware after the above** (`[MAPPROF]`, a `HAS_M9_KEYBOARD`-only
`Serial.printf` in `onMapTabActivated` with `micros()` accumulators around
`loadTileJpeg` and the decode — **diagnostic scaffolding, strip or gate it before
release**):
| | cold open (first after boot) | re-open |
|---|---|---|
| before this pass | 2598 ms | 245 ms |
| after dead-work removal | 2189 ms | 141-165 ms |
| **after repaint throttle** | **1507 ms** | **~150-190 ms** |
| split, before throttle | read 497 / decode 539 / **other 1042** / markers 10 | render 5-6, tiles=0 |
| split, after throttle | read 499 / decode 539 / other **358** / markers 10 | render 7, tiles=0 |
The throttle moved `other` and nothing else (read 497->499, decode 539->539 are
run-to-run noise), which is the confirmation that it cut compositing rather than
starving the load path. Cold open is down **42%** from the 2598 ms baseline; the
`[STALL] ui:lvgl` bucket for the same action fell 2497 ms -> 1822 ms.
With compositing no longer dominant, the two levers named in the superseded note
above finally ARE the top of the list, and they are now roughly equal in size:
decode 539 ms (core-0 decoding) and read 499 ms (the 4 MHz SD clock — one line,
`SD_SPI_FAST_HZ`, which only the V4-R8 env sets today). Treat the SD clock with
care: this board shares one SPI peripheral across radio + display + card, and
deferred item 4 records that the M9's mount-ladder timing margins were never
characterised.
Three things this settled that static reading could not:
1. **`probe=0ms` confirms the `bestAvailableZoom` guard fires.** The 2598→2189
delta (~410 ms) is the scan that used to run and be discarded — the top of the
100-500 ms band the audit estimated.
2. **`other` dominates.** See the correction under "Map re-open cost" above. The
progressive per-tile repaint, not decode and not SD, was the biggest single
cost. Now throttled via `k_map_progressive_ms`.
3. **The retained-slot path works exactly as designed** — a re-open reports
`tiles=0`, `render=5-6ms`: a true pure blit. Note that ~135 ms of the ~150 ms
re-open is now the "Loading map…" hint's `lv_refr_now`, which fires even when
the render will not block. Suppressing it when a pure blit is predictable
(same zoom + centre as the last render, and `s_map_last_missing == 0`) is the
obvious next trim, and is NOT done.
Stall attribution had to be fixed first, or none of these numbers meant what they
said: `uiCp("ui:input")` lives inside `#if CAP_TRACKBALL`, which is **0** on the
M9, while the keyboard drain sits between the `ui:threads` and `ui:diag`
checkpoints. So on the one board whose *only* input is the d-pad, every
keypress-driven action — map opens included — was billed to `ui:threads`. A cold
map open was being reported as a slow chat-threads refresh. The M9 now has its own
`uiCp("ui:input")`. (Work that reaches LVGL via `navPushTap` still lands in
`ui:lvgl`, since the handler runs inside `lv_timer_handler`; that is correct.)
Still open on the map, in win/risk order: a priority lane for visible tiles
(`xQueueSendToFront` — today the nine tiles you are looking at queue *behind* the
29-tile background prefetch pyramid on every pan), a retry pump so a dropped
fetch heals without a manual pan, and the SD clock below.
## Deferred — hardware-verify list
These are left intentionally unset/unwired rather than guessed: