Compare commits

...
10 Commits
Author SHA1 Message Date
Kaj SchittecatandClaude Fable 5 01900296f3 touch: beta_35 — worker-thread history flush, radio memory guards, 30.5 KB DRAM freed, crash-safe prefs
- History flush runs on the core-0 worker (snapshot + storage-busy gate +
  bounded shutdown wait): kills the V4's multi-second ui:hist stalls and
  the refuses-to-wake-after-message symptom (SPIFFS GC off the UI thread).
- BLE + Wi-Fi runtime enables share the boot co-init heap guard (50 KB
  free + 20 KB block): refuse with a toast + revert the switch instead of
  panicking (BLE) or silently not starting while claiming on (Wi-Fi).
- Internal DRAM: static footprint 95,365 -> 64,908 B (30.5 KB freed):
  serial_interface -> PSRAM; 23 keyboard layout maps const'd -> flash;
  nine rings/tables -> psAlloc PSRAM (UITask/TouchPrefsStore/MyMesh);
  unused TinyUSB MSC + DFU-mode class drivers stubbed out of the link
  (usb_unused_class_stubs.c) — CDC + runtime-DFU untouched, double-flash
  verified over the stubbed stack.
- DataStore prefs writes are crash-safe (write .tmp, swap) with a
  self-healing loader (.tmp recovery) and a truthful save result up to
  the profile-name toast — fixes the boots-with-default-name-once report.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 13:34:24 +02:00
Kaj SchittecatandClaude Fable 5 4e1ac2ca6b touch: beta_34 — buffered LoRa receive ON by default (test channel) + core pin core-v1.16.5
Consumes the new core (buffered receive drain task + RX counters, #13
stock payload layout, GPS-date guard). Fork side:
- TouchPrefs v33 rx_queue, DEFAULT ON for the test channel (fresh
  installs + a one-time migrate for upgraders); opt-out toggle
  'Buffered receive (experimental)' in Radio & Mesh, applies live.
- About live tier shows LoRa RX heard/read/err/late-lost/qdrop, the
  field evidence for the missed-messages fix.
- Spectrum parks/resumes the drain task around raw radio use; MyMesh
  setParams sequences hold the radio mutex.
- TanmatsuLoraRadio: no-op API stubs (P4 keeps its own RX path).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 11:15:52 +02:00
Kaj Schittecat 5d9a184314 chore: refresh Mesh America catalog for beta_33 2026-07-05 23:50:32 +02:00
Kaj SchittecatandClaude Fable 5 8e17b1b071 touch: beta_33 — async MQTT connect (freeze fix) + About sketch-size cache + favorites-full toast
- MqttBridge: reconnect() (blocking DNS + TCP + CONNACK, 2-10 s vs an
  unreachable broker, every 15 s) now runs on a one-shot core-0 task;
  _connecting hands the client between threads. Broker socket writes get
  the same zero-timeout writability probe as the TCP/WS companion
  servers, so a wedged broker fails a publish instead of stalling loop().
- About: ESP.getSketchSize()/getFreeSketchSpace() esp_image_verify-walk
  the whole ~3 MB image (~190 ms each); the 1 Hz refresh cost ~390 ms/s
  and flooded the stall ring (the ui:sbar entries field testers
  photographed). Computed once and cached.
- Favorites: adding past the 16-cap now toasts 'Favorites full (max 16)'
  instead of the wrong 'Removed from favorites'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 23:34:22 +02:00
Kaj SchittecatandClaude Fable 5 b58cecab5c touch: beta_32 — non-blocking TCP/WS companion writes (Wi-Fi freeze fix) + chunked history flush + stall diagnostics
Root cause of the beta_31 periodic 1-2s freezes with Wi-Fi on: every
received mesh packet pushes an RX-log frame to all connected TCP/WS
companion clients from the loop thread, and writeAllBytes called
WiFiClient::write() unguarded — against a stale peer (phone asleep,
half-open socket) that blocks in the framework's 1s select() retries.
The 120ms app timeout only bounded attempts, not the first call.

- TCP+WS servers: probe writability (zero-timeout select on the client
  fd) before every write; lwIP reports writable only with ~2.8KB free,
  a whole frame is ~180B, so probed writes complete instantly.
- Reap clients whose writes fail continuously for 10s (half-open
  sockets previously stayed "connected" forever, eating the write
  budget on every pushed frame).
- Chat-history ring: chunked writer + wider flush spacing (same
  treatment saveContacts got in beta_30).
- Stall tracer: 16-entry ring of >=0.2s loop stalls, shown in About.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 22:55:42 +02:00
Kaj Schittecat 816045bb6b chore: refresh Mesh America catalog for beta_31 2026-07-05 21:13:27 +02:00
Kaj SchittecatandClaude Fable 5 11939a744e touch: beta_31 — WhatsApp-style chats tab (avatars + previews) with per-channel custom icons
- Default chat list: 56px rows with a round initials avatar in the thread's
  FNV-hue signature colour, last-message preview (getThreadLastMessage ring
  accessor), time top-right, unread pill + @ below; compact chat mode keeps
  the dense 34px contact-style rows (layout mode mixed into the rebuild sig)
- Per-channel avatar emoji: 'Chat icon' in the thread-settings sheet (tap =
  emoji sheet in new pick mode, long-press = reset to letters); persisted in
  a chmute-style keyed blob (touchPrefsGet/SetChannelEmoji, 24 x 48B)
- Fix: name/preview overlap (LONG_DOT labels need fixed one-line heights)
- Thread-sheet grid gap 6->4 so the 7th channel cell keeps one screen on T-Deck

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 21:07:42 +02:00
Kaj SchittecatandClaude Fable 5 f47d71a05b touch: beta_30 — big-mesh contacts perf (buffered save, in-place age labels, coalesced rebuilds) + live Spectrum
- DataStore::saveContacts packs byte-identical 152B records into ~5KB chunk
  writes (was ~12 tiny FS writes per contact: 2.6-3.0s at ~570 contacts on the
  loop thread — the #82 sluggishness); falls back to per-field writes on alloc failure
- Contacts 60s age tick updates the Heard labels in place (was a ~1.3s full
  teardown+rebuild); advert-driven rebuilds coalesced to one per 2.5s
- Spectrum: rolling waterfall row + live trace every quarter sweep (~250ms),
  8-bin/8ms sweep chunks (~20% faster full sweep); trace Y range now tracks the
  live min/max — instant expand, 3dB/sweep contract, 30dB minimum span

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 13:08:40 +02:00
Kaj SchittecatandClaude Fable 5 8eb63a7edd touch: beta_29 — clock floor for room-server replay guards, BLE repeats-heard, deep SD history, one-screen popups
- ClockFloorRTC on all boards: persisted monotonic send-timestamp floor (touchPrefs v32),
  garbage-set rejection, trusted-backstep cap; Join no-reply watchdog + server clock
  skew warning from the LOGIN_OK timestamp prefix (issue #89 follow-up)
- BLE repeats-heard: own-echo RX-log frames pass to BLE one-shot; the #46/#54
  congestion skip stays for everything else (issue #94)
- Mirror app-sent channel messages on-device (was Tanmatsu-only from birth)
- Chat delete: persist the ring purge; mesh-channel drop prefers the pinned slot
- Deep SD history (5000-msg ring) + date separators; chat-list rows restyled compact
- Popup unification: popupClose() + k_popup_registry drive dismiss/count/swipe-block
- Thread/channel sheet as one-screen 2-col grid (contact-sheet layout)
- Keypad nav: strip LVGL SCROLL_ON_FOCUS from collected widgets (About-page jump);
  update dot no longer focusable; verchk waits for an IP + retries with backoff
- Tanmatsu: FIRMWARE_RELEASE_TAG from git describe (drop the hand-bumped hardcode)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 10:07:16 +02:00
Kaj SchittecatandClaude Opus 4.8 5623237951 site: dismissible donate banner -> support the MeshCore founders
Sticky top banner asking would-be donors to help the MeshCore founders instead — links to the blog post (Learn more) and the givealittle donation page (Donate). Dismissible (x + localStorage), and it shifts the corner FAB rails down while shown so they don't collide with the close button.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 15:22:37 +02:00
40 changed files with 2142 additions and 482 deletions

No files matched your search

+8 -8
View File
@@ -17,19 +17,19 @@
"role": "gui",
"title": "Touch UI",
"version": {
"beta_27": {
"notes": "Fixed a reboot loop that could hit while the device saved its contact list. Internal-flash housekeeping during a save could freeze the device long enough to trip the watchdog and restart it; that save no longer trips it. Thanks to the crash report that pinned it down.\nDevices installed under the Launcher no longer show the on-device \"Install update\" button. That update path only works on a standalone flash; under the Launcher it failed with \"won't fit: Bad Size Given\". The \"update available\" note stays, but you update through the Launcher instead. Thanks to Keith (KM1USR) for the report.\nKeyboard backlight brightness control on the T-Deck: a live slider plus Off / 25 / 50 / 75 / Max presets in Settings > Keyboard. It dims smoothly (fine-grained at the low end), applies to the on and auto modes and the new-message flash, and survives a reboot. Thanks to Yazutsu (PR #85).\nOn devices with a microSD card, contacts and channels are now kept on the card (like chat history and map tiles already are). Your existing contacts move over automatically on first boot; your identity and settings stay in internal flash, so the node is unchanged. Keep the card inserted afterwards - the contact list lives on it, and an older firmware won't look there if you ever downgrade.\nThis is the new stable build, promoted from the beta_27 test channel. It includes everything from the beta_26 and beta_27 test builds.",
"beta_33": {
"notes": "The freezes with MQTT enabled are gone. Connecting to the broker (DNS lookup, TCP connect, login) could block the device for 2-10 seconds and retried every 15 seconds while the broker was unreachable, all on the UI thread. Connection attempts now run fully in the background, and writes to a broker that stops responding are skipped instead of waited on (the same treatment beta_32 gave phone/web connections). Tip: if your bridge keeps reconnecting, check the broker address and credentials - a healthy connection does not retry.\nThe About page caused a ~0.4 second hitch every second while it was open: computing the firmware image size checksums the whole ~3 MB app in flash and ran twice per refresh. It is now computed once per boot, so About (and its stall list) no longer disturbs the very measurements it shows.\nFavoriting a contact when the favorites list is full (16) now says \"Favorites full (max 16)\" instead of the incorrect \"Removed from favorites\".",
"files": [
{
"type": "flash-wipe",
"name": "wadamesh-heltec-v4-tft-merged.bin",
"url": "https://firmware.wadamesh.com/releases/TOUCH/beta_27/wadamesh-heltec-v4-tft-merged.bin",
"url": "https://firmware.wadamesh.com/releases/TOUCH/beta_33/wadamesh-heltec-v4-tft-merged.bin",
"title": "Full install (bootloader + firmware)"
},
{
"type": "flash-update",
"name": "wadamesh-heltec-v4-tft.bin",
"url": "https://firmware.wadamesh.com/releases/TOUCH/beta_27/wadamesh-heltec-v4-tft.bin",
"url": "https://firmware.wadamesh.com/releases/TOUCH/beta_33/wadamesh-heltec-v4-tft.bin",
"title": "Update (app only)"
}
]
@@ -49,19 +49,19 @@
"role": "gui",
"title": "Touch UI",
"version": {
"beta_27": {
"notes": "Fixed a reboot loop that could hit while the device saved its contact list. Internal-flash housekeeping during a save could freeze the device long enough to trip the watchdog and restart it; that save no longer trips it. Thanks to the crash report that pinned it down.\nDevices installed under the Launcher no longer show the on-device \"Install update\" button. That update path only works on a standalone flash; under the Launcher it failed with \"won't fit: Bad Size Given\". The \"update available\" note stays, but you update through the Launcher instead. Thanks to Keith (KM1USR) for the report.\nKeyboard backlight brightness control on the T-Deck: a live slider plus Off / 25 / 50 / 75 / Max presets in Settings > Keyboard. It dims smoothly (fine-grained at the low end), applies to the on and auto modes and the new-message flash, and survives a reboot. Thanks to Yazutsu (PR #85).\nOn devices with a microSD card, contacts and channels are now kept on the card (like chat history and map tiles already are). Your existing contacts move over automatically on first boot; your identity and settings stay in internal flash, so the node is unchanged. Keep the card inserted afterwards - the contact list lives on it, and an older firmware won't look there if you ever downgrade.\nThis is the new stable build, promoted from the beta_27 test channel. It includes everything from the beta_26 and beta_27 test builds.",
"beta_33": {
"notes": "The freezes with MQTT enabled are gone. Connecting to the broker (DNS lookup, TCP connect, login) could block the device for 2-10 seconds and retried every 15 seconds while the broker was unreachable, all on the UI thread. Connection attempts now run fully in the background, and writes to a broker that stops responding are skipped instead of waited on (the same treatment beta_32 gave phone/web connections). Tip: if your bridge keeps reconnecting, check the broker address and credentials - a healthy connection does not retry.\nThe About page caused a ~0.4 second hitch every second while it was open: computing the firmware image size checksums the whole ~3 MB app in flash and ran twice per refresh. It is now computed once per boot, so About (and its stall list) no longer disturbs the very measurements it shows.\nFavoriting a contact when the favorites list is full (16) now says \"Favorites full (max 16)\" instead of the incorrect \"Removed from favorites\".",
"files": [
{
"type": "flash-wipe",
"name": "wadamesh-tdeck-merged.bin",
"url": "https://firmware.wadamesh.com/releases/TOUCH/beta_27/wadamesh-tdeck-merged.bin",
"url": "https://firmware.wadamesh.com/releases/TOUCH/beta_33/wadamesh-tdeck-merged.bin",
"title": "Full install (bootloader + firmware)"
},
{
"type": "flash-update",
"name": "wadamesh-tdeck.bin",
"url": "https://firmware.wadamesh.com/releases/TOUCH/beta_27/wadamesh-tdeck.bin",
"url": "https://firmware.wadamesh.com/releases/TOUCH/beta_33/wadamesh-tdeck.bin",
"title": "Update (app only)"
}
]
+24 -1
View File
@@ -42,7 +42,15 @@
font-family:'JetBrains Mono',monospace; background:#fff; transition:border-color .15s, color .15s}
.kicker:hover{border-color:var(--teal); color:var(--ink)}
.freeline{margin:0 auto 24px; color:var(--mut); font-size:.92rem}
.fab-rail{position:fixed; top:16px; z-index:45; display:flex; flex-direction:column; gap:12px}
.fab-rail{position:fixed; top:var(--fab-top,16px); z-index:45; display:flex; flex-direction:column; gap:12px}
/* dismissible donate banner */
.donate-bar{position:sticky; top:0; z-index:50; display:flex; align-items:center; justify-content:center; gap:7px 16px; flex-wrap:wrap;
padding:9px 44px; background:var(--ink); color:#fff; font-size:.9rem; line-height:1.4; text-align:center}
.donate-bar a{color:#fff; text-decoration:underline; text-underline-offset:2px}
.donate-bar .donate-cta{background:var(--teal); color:#fff; text-decoration:none; padding:5px 14px; border-radius:100px; font-weight:650; white-space:nowrap}
.donate-bar .donate-cta:hover{background:var(--cyan)}
.donate-bar .x{position:absolute; right:12px; top:50%; transform:translateY(-50%); background:none; border:0; color:#fff; font-size:1.5rem; line-height:1; cursor:pointer; opacity:.75; padding:0 4px}
.donate-bar .x:hover{opacity:1}
.fab-rail.left{left:16px} .fab-rail.right{right:16px}
.fab{width:44px; height:44px; border-radius:50%; display:grid; place-items:center; cursor:pointer;
background:#fff; border:1px solid var(--line); color:#46525c; box-shadow:0 10px 26px -14px rgba(21,24,30,.45);
@@ -210,6 +218,11 @@
</style>
</head>
<body>
<div class="donate-bar" id="donateBar">
<span>Like the project and want to donate? Help out the <b>MeshCore</b> founders instead. <a href="https://blog.meshcore.io/2026/07/04/help-us-save-meshcore" target="_blank" rel="noopener">Learn more</a></span>
<a class="donate-cta" href="https://givealittle.co.nz/cause/help-us-save-meshcore" target="_blank" rel="noopener">Donate</a>
<button class="x" id="donateX" type="button" aria-label="Dismiss">&times;</button>
</div>
<div id="vanta-bg"></div>
<!-- floating quick links -->
@@ -480,6 +493,16 @@ We use no tracking, analytics, or advertising cookies. Our CDN and security prov
try { if (!localStorage.getItem('wm-cookie-ack')) cbar.classList.add('show'); } catch(_) {}
document.getElementById('cookieok').addEventListener('click', () => { cbar.classList.remove('show'); try { localStorage.setItem('wm-cookie-ack','1'); } catch(_) {} });
// ---- donate banner: dismissible; shifts the corner FABs down while shown ----
(function(){
const bar = document.getElementById('donateBar'); if (!bar) return;
function setFabTop(){ document.documentElement.style.setProperty('--fab-top', (getComputedStyle(bar).display === 'none' ? 16 : bar.offsetHeight + 12) + 'px'); }
try { if (localStorage.getItem('wm-donate-dismiss') === '1') bar.style.display = 'none'; } catch(_) {}
setFabTop();
addEventListener('resize', setFabTop);
document.getElementById('donateX').addEventListener('click', () => { bar.style.display = 'none'; setFabTop(); try { localStorage.setItem('wm-donate-dismiss','1'); } catch(_) {} });
})();
// Live release version + "what's new" — read from the firmware host so the
// flasher always reflects the current build (the release pipeline updates
// version.json; no site redeploy needed per release).
+4 -2
View File
@@ -167,6 +167,7 @@ build_flags =
build_src_filter =
+<*.cpp>
+<*.c>
+<helpers/*.cpp>
+<helpers/esp32/*.cpp>
+<helpers/input/*.cpp>
@@ -178,7 +179,7 @@ lib_deps =
; MeshCore core = the meshcomod monorepo built as a lib, pinned by tag.
; Touch-app files (TouchPrefsStore, WifiRuntimeStore, transports, ...) are
; vendored in this app's src/ and dropped from the lib via -DMC_VENDORED_TOUCH_APP.
https://github.com/ALLFATHER-BV/meshcomod.git#core-v1.16.4
https://github.com/ALLFATHER-BV/meshcomod.git#core-v1.16.5
SPI
Wire
jgromes/RadioLib @ ^7.6.0
@@ -341,6 +342,7 @@ build_flags =
build_src_filter =
+<*.cpp>
+<*.c>
+<helpers/*.cpp>
+<helpers/esp32/*.cpp>
+<helpers/input/*.cpp>
@@ -349,7 +351,7 @@ build_src_filter =
+<../variants/lilygo_tdeck/*.cpp>
lib_deps =
https://github.com/ALLFATHER-BV/meshcomod.git#core-v1.16.4
https://github.com/ALLFATHER-BV/meshcomod.git#core-v1.16.5
SPI
Wire
jgromes/RadioLib @ ^7.6.0
+17
View File
@@ -0,0 +1,17 @@
# beta_29 (TEST) — clock hardening for room servers, BLE repeats-heard fix, deeper SD chat history, one-screen popups. On top of beta_28.
# One user-facing note per non-blank, non-# line; # lines are section comments.
# --- Fixed ---
Room logins can no longer be silently locked out by the device clock. Room servers quietly reject anything older than the newest timestamp they have seen from you, so a clock that stepped backwards (a reboot without GPS or phone around, or a sync correcting a fast clock) black-holed every Join with zero feedback. The device now remembers the newest timestamp it has used across reboots so it can never fall behind itself, a Join that gets no reply shows a clear error after 20 seconds (including what the device thinks the time is) instead of spinning forever, and the device warns you when its clock and the room server's clock disagree. Thanks to EricMW61 for the follow-up testing on issue #89.
"Repeats heard" works again over Bluetooth. The companion app counts repeats from the per-packet RX log, which beta_23 stopped sending over BLE to fix congestion. The firmware now recognises packets that are repeats of your own messages and lets exactly those through to BLE, so the counter works and the congestion fix stays. Thanks to daryldakine for the clean bisect (issue #94).
Messages sent from the phone app through the device now also appear on the device itself, as your own outgoing message in that channel. This worked for direct messages but never for channels.
Deleting a chat or channel now sticks. Deleted messages could survive in storage and resurrect the chat later, and removing a channel could miss the underlying channel entry so the thread came right back.
Settings pages no longer jump down when opened on keyboard-navigation devices, and the red update dot can no longer steal the selection from the About button.
The update check no longer shows "couldn't reach the update server" for six hours after one failed attempt. It now waits for Wi-Fi to actually have an address and retries within a minute.
The Tanmatsu About page now shows the real firmware version instead of a stale hardcoded one.
# --- New ---
Much deeper chat history on devices with an SD card: up to 5000 messages instead of 500, with date separators (Today, Yesterday, day names) between messages from different days. Note for testers: if you later downgrade below beta_29, the older build will not read the larger history file and will start with an empty history.
Chat and channel rows in the messages tab now use the same compact style as the Contacts tab, fitting more conversations on screen.
The chat or channel settings sheet (long-press or the gear) now fits on one screen, laid out like the contact menu, instead of scrolling.
Compact chat mode is easier to read: messages alternate between two background tones.
+11
View File
@@ -0,0 +1,11 @@
# beta_30 (TEST) — the big-mesh sluggishness fix + a much faster Spectrum analyzer. On top of beta_29.
# One user-facing note per non-blank, non-# line; # lines are section comments.
# --- Fixed ---
The periodic multi-second freezes on busy meshes are gone. Saving the contact list to storage took up to 3 seconds at ~570 contacts and ran on the UI thread every couple of minutes, which is exactly the trackball and touch hesitation people felt since beta_25. The save now writes the same data in a few large chunks instead of thousands of tiny ones, cutting it to a fraction. Thanks to guinnessbear for the report and Yazutsu for the precise measurements that pinned this down (issue #82).
The Contacts tab no longer rebuilds itself every 60 seconds just to refresh the "heard 2h ago" labels - they update in place now. That rebuild cost over a second on large contact lists and could yank the list while you were scrolling.
A burst of adverts can no longer queue several back-to-back Contacts rebuilds; they are now coalesced.
# --- Improved ---
The Spectrum analyzer feels live now: the waterfall gets a new line about four times per second instead of once per sweep, the trace advances across the band as it sweeps (like a bench analyzer), and a full sweep is about 20 percent faster.
The Spectrum trace also scales itself to the signal now: the chart tracks the live minimum and maximum, jumping out instantly for a strong signal (no more clipping flat at the top) and easing back once it is gone.
+9
View File
@@ -0,0 +1,9 @@
# beta_31 (TEST) — the new chats tab: avatars, message previews, custom channel icons. On top of beta_30.
# One user-facing note per non-blank, non-# line; # lines are section comments.
# --- New ---
The chats tab has a fresh look: each conversation now shows a round avatar in that chat's signature colour (the same colour its bubbles use) with the first two letters of the name, the last message previewed under the name, the time top right and the unread count below it. Compact chat mode keeps the dense single-line rows instead, so both styles are available.
Channels can have a custom icon: open the channel's settings (long-press or the gear) and tap "Chat icon" to pick any emoji as its avatar. Long-press the same entry to go back to the automatic letters. The choice is remembered per channel.
# --- Fixed ---
Long chat names no longer wrap onto the message preview underneath.
+9
View File
@@ -0,0 +1,9 @@
# beta_32 (TEST) — the Wi-Fi freeze fix: a stale phone/web connection can no longer stall the device. On top of beta_31.
# One user-facing note per non-blank, non-# line; # lines are section comments.
# --- Fixed ---
The 1-2 second freezes every ~10 seconds some users saw with Wi-Fi on are gone. When a phone app or web client was connected over Wi-Fi and that connection went stale (phone asleep, walked out of range), every received mesh packet made the device wait on the dead connection before the UI could continue. The device now only writes to a connection when it can accept data immediately, and a connection that stays unresponsive for 10 seconds straight is dropped so the client can reconnect cleanly.
Saving chat history could briefly hitch the UI on busy meshes: the history file now writes in a few large chunks instead of thousands of tiny ones (the same treatment the contact list got in beta_30), and background saves are spaced further apart while messages stream in.
# --- New ---
The About page now shows recent loop stalls (anything over 0.2 seconds, newest first), so freeze reports can be confirmed or ruled out at a glance.
+7
View File
@@ -0,0 +1,7 @@
# beta_33 (TEST) — the MQTT freeze fix + the About page no longer stalls itself. On top of beta_32.
# One user-facing note per non-blank, non-# line; # lines are section comments.
# --- Fixed ---
The freezes with MQTT enabled are gone. Connecting to the broker (DNS lookup, TCP connect, login) could block the device for 2-10 seconds and retried every 15 seconds while the broker was unreachable, all on the UI thread. Connection attempts now run fully in the background, and writes to a broker that stops responding are skipped instead of waited on (the same treatment beta_32 gave phone/web connections). Tip: if your bridge keeps reconnecting, check the broker address and credentials - a healthy connection does not retry.
The About page caused a ~0.4 second hitch every second while it was open: computing the firmware image size checksums the whole ~3 MB app in flash and ran twice per refresh. It is now computed once per boot, so About (and its stall list) no longer disturbs the very measurements it shows.
Favoriting a contact when the favorites list is full (16) now says "Favorites full (max 16)" instead of the incorrect "Removed from favorites".
+11
View File
@@ -0,0 +1,11 @@
# beta_34 (TEST) - buffered receive: the missed-messages fix, ON by default on this test build. On top of beta_33.
# One user-facing note per non-blank, non-# line; # lines are section comments.
# --- New ---
This build changes how the device collects packets from the LoRa radio, and the new way is ON by default so the test channel can prove it. Background: the radio chip holds only one received packet at a time, and until now the firmware only collected it when the main loop got around to it, the same loop that draws the screen. If the interface kept the loop busy while two packets arrived back to back, the first one was silently overwritten. That is the "my device missed a message another device right next to it heard" problem some of you reported, and why it affected this firmware but not stock MeshCore, which has no screen to draw. A small dedicated background task now collects every packet within about a millisecond of it arriving, no matter what the interface is doing.
Because the receive path is the heart of the device, this ships with a safety hatch: if you see anything odd with receiving, sending, or the Spectrum analyzer, turn off "Buffered receive (experimental)" in Settings > Radio & Mesh (applies instantly, no reboot) and you are back on the old path. Please report it either way, that is exactly what the test channel is for.
The About page now shows LoRa receive counters (heard / read / late-lost). "late-lost" counts packets the radio heard but the firmware failed to collect in time. With buffered receive on it should stay at zero. If you turn the toggle off on a busy mesh you may watch it creep up, which is the old bug happening live.
# --- Fixed ---
Messages sent by wadamesh no longer carry a few stray bytes at the end that stock MeshCore devices and some companion apps displayed as garbage characters after the text (issue #13).
A GPS module reporting a bogus date can no longer drag the device clock back to the year 1902 (core clock guard).
+11
View File
@@ -0,0 +1,11 @@
# beta_35 (TEST) - V4 breathing room: freezes fixed, 30 KB of internal RAM back, crash-safe settings. On top of beta_34.
# One user-facing note per non-blank, non-# line; # lines are section comments.
# --- Fixed ---
Heltec V4: multi-second freezes right after receiving messages are gone, including the screen refusing to wake from dark mode. Saving chat history to the internal flash could stall the device for up to 6 seconds (the V4 has no SD card, and internal flash pauses for housekeeping); that save now runs on a background task, so the screen and touch stay responsive no matter what storage is doing.
Turning Bluetooth on while Wi-Fi is running no longer reboots the device when memory is tight: the switch now checks free memory first and tells you "Not enough free memory for Bluetooth. Turn Wi-Fi off first." instead of crashing.
The same protection applies the other way around: turning Wi-Fi on with Bluetooth active used to claim "Wi-Fi on" while the radio silently never started. It now either really starts or tells you why it cannot.
Settings could vanish for a single boot after an unlucky reboot (the device came up with a default name once, then recovered). Settings writes are now crash-safe: a reboot or power cut at any moment leaves at least one intact copy on storage, the loader self-heals from it, and a failed save now says "Couldn't save the name to storage" instead of pretending it worked.
# --- Improved ---
About 30 KB of the ESP32's scarce internal RAM has been freed by moving buffers to the external PSRAM and dropping unused USB components from the build. This is the headroom that makes Wi-Fi and Bluetooth run together comfortably again, especially on the Heltec V4.
+8
View File
@@ -136,6 +136,14 @@ public:
virtual void onAdminLoginResult(const ContactInfo& contact, bool success, uint8_t perms) {
(void)contact; (void)success; (void)perms;
}
/** The server's own clock, from the timestamp prefix every server RESPONSE
* carries (first 4 bytes). Fired on LOGIN_OK so the UI can warn when the
* device clock and the server clock disagree — a skewed device clock makes
* the server's replay guard silently drop our logins/posts/keep-alives
* (issue #89). Default no-op. */
virtual void onServerClock(const ContactInfo& contact, uint32_t server_epoch) {
(void)contact; (void)server_epoch;
}
/** A CLI command reply from a repeater / room server / sensor (the
* TXT_TYPE_CLI_DATA path). `text` is the decrypted command output text
* the server returned. Default no-op; the touch UI overrides to append
+80 -20
View File
@@ -242,10 +242,19 @@ bool DataStore::saveMainIdentity(const mesh::LocalIdentity &identity) {
void DataStore::loadPrefs(NodePrefs& prefs, double& node_lat, double& node_lon) {
if (_fs->exists(_rp("/new_prefs"))) {
loadPrefsInt("/new_prefs", prefs, node_lat, node_lon); // new filename
} else if (_fs->exists(_rp("/new_prefs.tmp"))) {
// Main file gone but a staged copy exists: a reboot landed between the temp
// write and the swap (or the swap was torn). Recover from it — this is the
// "device booted with the default name once" failure mode.
MESH_DEBUG_PRINTLN("DataStore: /new_prefs missing, recovering from .tmp");
loadPrefsInt("/new_prefs.tmp", prefs, node_lat, node_lon);
savePrefs(prefs, node_lat, node_lon); // re-establish the main file
} else if (_fs->exists(_rp("/node_prefs"))) {
loadPrefsInt("/node_prefs", prefs, node_lat, node_lon);
savePrefs(prefs, node_lat, node_lon); // save to new filename
_fs->remove(_rp("/node_prefs")); // remove old
} else {
MESH_DEBUG_PRINTLN("DataStore: no prefs file found — using defaults");
}
}
@@ -339,8 +348,12 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no
}
}
void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_lon) {
File file = openWrite(_fs, "/new_prefs");
bool DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_lon) {
// Write to a temp file first and swap it in afterwards: a reboot / power cut
// mid-write can then never destroy the only copy (the loader recovers from
// whichever file survived). SPIFFS has no atomic rename-over, so the swap is
// remove+rename — the loader handles the tiny between-steps window too.
File file = openWrite(_fs, "/new_prefs.tmp");
if (file) {
uint8_t pad[8];
memset(pad, 0, sizeof(pad));
@@ -377,10 +390,17 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_
file.write((uint8_t *)&_prefs.autoadd_max_hops, sizeof(_prefs.autoadd_max_hops)); // 91
file.write((uint8_t *)&_prefs.rx_boosted_gain, sizeof(_prefs.rx_boosted_gain)); // 92
file.write((uint8_t *)_prefs.default_scope_name, sizeof(_prefs.default_scope_name)); // 93
file.write((uint8_t *)_prefs.default_scope_key, sizeof(_prefs.default_scope_key)); // 125
size_t last = file.write((uint8_t *)_prefs.default_scope_key, sizeof(_prefs.default_scope_key)); // 125
file.close();
if (last != sizeof(_prefs.default_scope_key)) {
_fs->remove(_rp("/new_prefs.tmp")); // short write (storage full?) — keep the old main file
return false;
}
_fs->remove(_rp("/new_prefs"));
return _fs->rename(_rp("/new_prefs.tmp"), _rp("/new_prefs"));
}
return false;
}
void DataStore::loadContacts(DataStoreHost* host) {
@@ -432,27 +452,67 @@ void DataStore::saveContacts(DataStoreHost* host, bool (*filter)(const ContactIn
ContactInfo c;
uint8_t unused = 0;
while (host->getContactForSave(idx, c)) {
if (filter && !filter(c)) {
// Field-by-field writes cost ~12 FS calls per contact — ~7000 tiny writes at
// ~570 contacts measured 2.6-3.0 s on SD, freezing the loop/UI for the whole
// save (issue #82). Pack the SAME 152-byte records into a heap chunk and
// flush in ~5 KB slabs instead: identical file bytes, ~20 large writes, tens
// of ms. Falls back to the original per-field path if the alloc fails.
static const size_t REC = 152; // 32+32+1+1+1+4+1+4+64+4+4+4
static const size_t CHUNK_RECS = 32;
uint8_t* buf = (uint8_t*)malloc(REC * CHUNK_RECS);
if (buf) {
size_t fill = 0;
bool ok = true;
while (ok && host->getContactForSave(idx, c)) {
if (filter && !filter(c)) {
idx++; // advance to next contact
continue;
}
uint8_t* p = buf + fill;
memcpy(p, c.id.pub_key, 32); p += 32;
memcpy(p, (uint8_t *)&c.name, 32); p += 32;
*p++ = c.type;
*p++ = c.flags;
*p++ = unused;
memcpy(p, (uint8_t *)&c.sync_since, 4); p += 4;
memcpy(p, (uint8_t *)&c.out_path_len, 1); p += 1;
memcpy(p, (uint8_t *)&c.last_advert_timestamp, 4); p += 4;
memcpy(p, c.out_path, 64); p += 64;
memcpy(p, (uint8_t *)&c.lastmod, 4); p += 4;
memcpy(p, (uint8_t *)&c.gps_lat, 4); p += 4;
memcpy(p, (uint8_t *)&c.gps_lon, 4); p += 4;
fill += REC;
if (fill == REC * CHUNK_RECS) {
ok = (file.write(buf, fill) == fill);
fill = 0;
}
idx++; // advance to next contact
continue;
}
bool success = (file.write(c.id.pub_key, 32) == 32);
success = success && (file.write((uint8_t *)&c.name, 32) == 32);
success = success && (file.write(&c.type, 1) == 1);
success = success && (file.write(&c.flags, 1) == 1);
success = success && (file.write(&unused, 1) == 1);
success = success && (file.write((uint8_t *)&c.sync_since, 4) == 4);
success = success && (file.write((uint8_t *)&c.out_path_len, 1) == 1);
success = success && (file.write((uint8_t *)&c.last_advert_timestamp, 4) == 4);
success = success && (file.write(c.out_path, 64) == 64);
success = success && (file.write((uint8_t *)&c.lastmod, 4) == 4);
success = success && (file.write((uint8_t *)&c.gps_lat, 4) == 4);
success = success && (file.write((uint8_t *)&c.gps_lon, 4) == 4);
if (ok && fill > 0) file.write(buf, fill);
free(buf);
} else {
while (host->getContactForSave(idx, c)) {
if (filter && !filter(c)) {
idx++; // advance to next contact
continue;
}
bool success = (file.write(c.id.pub_key, 32) == 32);
success = success && (file.write((uint8_t *)&c.name, 32) == 32);
success = success && (file.write(&c.type, 1) == 1);
success = success && (file.write(&c.flags, 1) == 1);
success = success && (file.write(&unused, 1) == 1);
success = success && (file.write((uint8_t *)&c.sync_since, 4) == 4);
success = success && (file.write((uint8_t *)&c.out_path_len, 1) == 1);
success = success && (file.write((uint8_t *)&c.last_advert_timestamp, 4) == 4);
success = success && (file.write(c.out_path, 64) == 64);
success = success && (file.write((uint8_t *)&c.lastmod, 4) == 4);
success = success && (file.write((uint8_t *)&c.gps_lat, 4) == 4);
success = success && (file.write((uint8_t *)&c.gps_lon, 4) == 4);
if (!success) break; // write failed
if (!success) break; // write failed
idx++; // advance to next contact
idx++; // advance to next contact
}
}
file.close();
}
+4 -1
View File
@@ -52,7 +52,10 @@ public:
bool loadMainIdentity(mesh::LocalIdentity &identity);
bool saveMainIdentity(const mesh::LocalIdentity &identity);
void loadPrefs(NodePrefs& prefs, double& node_lat, double& node_lon);
void savePrefs(const NodePrefs& prefs, double node_lat, double node_lon);
// Crash-safe: writes /new_prefs.tmp, then swaps it in. Returns false when the
// write or the swap failed (storage full / torn) so callers can surface it —
// the save used to fail silently and the UI toasted "saved" regardless.
bool savePrefs(const NodePrefs& prefs, double node_lat, double node_lon);
void loadContacts(DataStoreHost* host);
void saveContacts(DataStoreHost* host, bool (*filter)(const ContactInfo& c) = NULL);
void loadChannels(DataStoreHost* host);
+33 -5
View File
@@ -21,7 +21,9 @@
#include <WiFi.h>
#endif
#ifdef MULTI_TRANSPORT_COMPANION
#include <helpers/esp32/MultiTransportCompanionInterface.h>
// QUOTED on purpose: the vendored core lib ships a STALE copy of this header in its
// include path (no bleAllowNextRxLog); quotes force the local src/ copy we compile.
#include "helpers/esp32/MultiTransportCompanionInterface.h"
#include "helpers/esp32/MqttBridge.h"
#endif
#endif
@@ -263,7 +265,15 @@ struct MeshcomodCmdCacheEntry {
uint32_t seen_ms;
char text[128];
};
static MeshcomodCmdCacheEntry s_meshcomod_cmd_cache[MESHCOMOD_CMD_CACHE_SIZE] = {};
// PSRAM-first (internal fallback), zero-initialized (0.8 KB off internal .bss).
static void* msPsAlloc(size_t n) {
void* p = heap_caps_malloc(n, MALLOC_CAP_SPIRAM);
if (!p) p = heap_caps_malloc(n, MALLOC_CAP_8BIT);
if (p) memset(p, 0, n);
return p;
}
static MeshcomodCmdCacheEntry* s_meshcomod_cmd_cache =
(MeshcomodCmdCacheEntry*)msPsAlloc(sizeof(MeshcomodCmdCacheEntry) * MESHCOMOD_CMD_CACHE_SIZE);
static int s_meshcomod_cmd_cache_next = 0;
static uint32_t s_meshcomod_last_reply_ts = 0;
static uint32_t s_last_cmd_txt_ts = 0;
@@ -1484,7 +1494,14 @@ void MyMesh::logRxRaw(float snr, float rssi, const uint8_t raw[], int len) {
if (poff < len) {
// last path hop = the repeater whose re-flood our radio just heard
const uint8_t* lasthop = (cnt_p >= 1) ? (raw + poff - hsz_p) : nullptr;
uiCountEcho(fnv1a32(raw + poff, len - poff), lasthop, lasthop ? hsz_p : 0);
if (uiCountEcho(fnv1a32(raw + poff, len - poff), lasthop, lasthop ? hsz_p : 0)) {
#if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION)
// Echo of OUR OWN send: let this one RX-log frame through to BLE too.
// The app's "Repeats heard" is computed exactly from these (issue #94)
// and a few frames per send can't re-create the #46/#54 BLE flood.
MultiTransportCompanionInterface::bleAllowNextRxLog();
#endif
}
}
}
}
@@ -2285,6 +2302,10 @@ void MyMesh::onContactResponse(const ContactInfo &contact, const uint8_t *data,
// Notify the touch UI so the admin console can flip from "logging in…"
// to the prompt (success) or show "wrong password" (fail). Same data
// the companion app sees via PUSH_CODE_LOGIN_SUCCESS/_FAIL.
// On LOGIN_OK also hand it the server's clock (`tag` = the timestamp prefix
// of every server response) so it can warn about device-vs-server skew —
// skew is what makes the server's replay guard silently eat us (#89).
if (_ui && ok) _ui->onServerClock(contact, tag);
if (_ui) _ui->onAdminLoginResult(contact, ok, perms);
#endif
} else if (len > 4 && // check for status response
@@ -2526,7 +2547,11 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe
}
void MyMesh::applyRadioFromPrefs() {
// setParams is a multi-step SPI sequence; hold the radio against the buffered-
// receive drain task so it can't re-arm RX between the steps (no-op when off).
radio_driver.radioAcquire();
radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr);
radio_driver.radioRelease();
radio_driver.setTxPower(_prefs.tx_power_dbm);
#if defined(USE_SX1262) || defined(USE_SX1268)
_prefs.rx_boosted_gain = _prefs.rx_boosted_gain ? 1 : 0;
@@ -2975,12 +3000,13 @@ void MyMesh::handleCmdFrame(size_t len) {
bool success = getChannel(channel_idx, channel);
if (success && sendGroupMessage(msg_timestamp, channel.channel, _prefs.node_name, text, len - i)) {
writeOKFrame();
#if defined(HAS_TANMATSU)
// Mirror the app-sent channel message into the on-device touch UI — the channel-send path
// otherwise never shows companion-originated channel sends on screen (the DM path does, via
// appSentMsgToContact). NUL-terminate the text in-place first, exactly as the DM path does.
// ALL boards — this sat behind HAS_TANMATSU from its beta_17 birth, so T-Deck/V4 users
// never saw their own app-sent channel posts on the device. Repeater echoes of our own
// flood can't double the bubble: the dispatcher's seen-packet table drops them pre-ingest.
if (_ui) { cmd_frame[len] = 0; _ui->appSentMsgToChannel(channel.name, text); }
#endif
// Sent channel messages are not added to shared history / broadcast: channel_idx and
// frame format are device-specific and clients (e.g. HA) without channel support can
// misparse or show "text as sender"; also avoids failed-to-sync when versions differ.
@@ -3324,10 +3350,12 @@ void MyMesh::handleCmdFrame(size_t len) {
_prefs.client_repeat = repeat;
savePrefs();
radio_driver.radioAcquire(); // hold off the RX drain task mid-sequence (no-op when off)
radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr);
#if defined(USE_SX1262) || defined(USE_SX1268) || defined(SX126X_RX_BOOSTED_GAIN)
radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain != 0);
#endif
radio_driver.radioRelease();
MESH_DEBUG_PRINTLN("OK: CMD_SET_RADIO_PARAMS: f=%d, bw=%d, sf=%d, cr=%d", freq, bw, (uint32_t)sf,
(uint32_t)cr);
+7 -3
View File
@@ -587,7 +587,10 @@ public:
/** Count one echo of fingerprint `fp` (called from logRxRaw on a match). `hop`
* (optional) is the re-flooding repeater's hash — the echo's last path hop —
* recorded deduped + bounded so the sent-message Info can name the repeaters. */
void uiCountEcho(uint32_t fp, const uint8_t* hop = nullptr, uint8_t hop_sz = 0) {
/** Returns true when `fp` matched one of OUR recent sends (i.e. this RX is an
* echo of our own flood) — logRxRaw uses that to let the frame through to BLE
* for the app's "Repeats heard" (issue #94). */
bool uiCountEcho(uint32_t fp, const uint8_t* hop = nullptr, uint8_t hop_sz = 0) {
for (int i = 0; i < UI_ECHO_SLOTS; i++)
if (_echo_fp[i] == fp) {
if (_echo_rep[i] < 255) _echo_rep[i]++;
@@ -602,8 +605,9 @@ public:
_echo_hop_n[i]++;
}
}
return;
return true;
}
return false;
}
/** True once if a repeat was counted since the last call — the UI uses this
* to refresh the chat so the bubble's repeat tag updates live. */
@@ -727,7 +731,7 @@ public:
private:
public:
void savePrefs() { _store->savePrefs(_prefs, sensors.node_lat, sensors.node_lon); }
bool savePrefs() { return _store->savePrefs(_prefs, sensors.node_lat, sensors.node_lon); }
// Set the default flood scope (region) used to tag outgoing flood packets, so
// repeaters on region-scoped networks ("flood only for their region") will
+55
View File
@@ -0,0 +1,55 @@
#pragma once
#include <helpers/AutoDiscoverRTCClock.h>
// Monotonic clock floor for outgoing protocol timestamps (issue #89 follow-up).
//
// MeshCore servers keep a per-client "newest timestamp seen" high-water mark and
// silently drop anything at-or-below it (replay guard) — and since beta_28 our
// room keep-alives refresh that mark every ~2 minutes. A device clock that steps
// BACKWARD (reboot without GPS/phone around, or a sync correcting a fast clock)
// therefore black-holes every login/post/keep-alive with zero feedback. None of
// our boards has a battery-backed RTC chip, so a power cycle without a time
// source is exactly such a step.
//
// getCurrentTime() ratchets: it never returns less than the highest value it has
// ever returned — and getCurrentTimeUnique() (which stamps every login, post and
// keep-alive in the core) builds directly on it. The ratchet is seeded from a
// persisted copy at boot and written back rate-capped (see the UITask wiring +
// touchPrefsGet/SetClockFloor), so it survives power loss.
//
// Two escape hatches keep a WRONG-future clock from sticking forever:
// • setCurrentTime() rejects anything below MIN_VALID_EPOCH, whatever the
// source — this centrally kills the 1902/1970-class garbage sets (GPS date
// bugs, unset system clocks) for every path: GPS NMEA, phone CMD, NTP.
// • a set that lands more than TRUSTED_BACK_CAP below the floor pulls the
// floor down to it: a >10 min backward correction means the floor itself was
// built on a bad clock, and freezing time for hours to bridge it would be
// worse than the one-time server re-lock it avoids (which the login skew
// warning surfaces to the user anyway).
class ClockFloorRTC : public AutoDiscoverRTCClock {
uint32_t _floor = 0;
public:
ClockFloorRTC(mesh::RTCClock& fallback) : AutoDiscoverRTCClock(fallback) {}
static constexpr uint32_t MIN_VALID_EPOCH = 1715770351UL; // 15 May 2024 — the core's own unset-clock seed
static constexpr uint32_t TRUSTED_BACK_CAP = 10UL * 60UL;
uint32_t getCurrentTime() override {
uint32_t t = AutoDiscoverRTCClock::getCurrentTime();
if (t < _floor) return _floor;
_floor = t;
return t;
}
void setCurrentTime(uint32_t time) override {
if (time < MIN_VALID_EPOCH) return; // garbage set — ignore, whatever the source
AutoDiscoverRTCClock::setCurrentTime(time);
if (_floor > time + TRUSTED_BACK_CAP) _floor = time;
}
// Boot-time seed from the persisted floor (only ever raises), and the getter
// the persister reads back. Benign u32 races: both cores may touch _floor.
void seedFloor(uint32_t persisted) { if (persisted > _floor) _floor = persisted; }
uint32_t getFloor() const { return _floor; }
};
+42 -5
View File
@@ -10,11 +10,30 @@ MqttBridge mqtt_bridge;
#include <WiFi.h>
#include <stdio.h>
#include <string.h>
#include <lwip/sockets.h>
#include "esp_random.h"
#include "mbedtls/gcm.h"
#include "mbedtls/md.h"
#include "mbedtls/base64.h"
// Only hand bytes to lwIP when the socket can accept them right now (zero-timeout
// select). Otherwise WiFiClient::write() blocks in 1 s select() retries against a
// broker that stopped ACKing — on the loop thread that is a visible UI freeze per
// publish. A refused write fails the publish; PubSubClient then flags the
// connection down and the (async) reconnect path takes over.
size_t MqttNbClient::write(const uint8_t* buf, size_t size) {
int fd_ = fd();
if (fd_ < 0) return 0;
fd_set wset;
FD_ZERO(&wset);
FD_SET(fd_, &wset);
struct timeval tv;
tv.tv_sec = 0;
tv.tv_usec = 0;
if (select(fd_ + 1, NULL, &wset, NULL, &tv) <= 0 || !FD_ISSET(fd_, &wset)) return 0;
return WiFiClient::write(buf, size);
}
// 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).
@@ -86,13 +105,27 @@ bool MqttBridge::reconnect() {
return ok;
}
// reconnect() blocks on DNS + TCP connect + CONNACK (2-10 s against an unreachable
// broker) — never run it on the loop thread, it drives LVGL. One-shot task instead;
// _connecting hands _mqtt/_wc to the task and back.
void MqttBridge::reconnectTask(void* arg) {
MqttBridge* self = (MqttBridge*)arg;
self->reconnect();
self->_connecting = false;
vTaskDelete(nullptr);
}
void MqttBridge::loop() {
if (!_enabled) return;
if (!_enabled || _connecting) return;
if (!_mqtt.connected()) {
uint32_t now = millis();
if ((uint32_t)(now - _lastReconnectMs) >= RECONNECT_INTERVAL_MS) {
_lastReconnectMs = now;
reconnect();
_connecting = true;
if (xTaskCreatePinnedToCore(reconnectTask, "mqtt_conn", 6144, this,
1, nullptr, 0) != pdPASS) {
_connecting = false; // task OOM — try again next interval
}
}
return;
}
@@ -128,7 +161,7 @@ bool MqttBridge::sealToB64(const char* plain, char* out, size_t outCap) {
}
void MqttBridge::pub(const char* subtopic, const char* json) {
if (!_enabled || !_mqtt.connected()) return;
if (!_enabled || _connecting || !_mqtt.connected()) return;
char topic[80];
snprintf(topic, sizeof(topic), "wadamesh/%s/%s", _nodeHex, subtopic);
if (_encOn) {
@@ -158,7 +191,7 @@ void MqttBridge::escapeJson(const char* src, char* dst, size_t dstLen) {
void MqttBridge::publishDM(const char* senderName, const uint8_t* senderKey32,
float snr, uint8_t hops, uint32_t ts, const char* text) {
if (!_enabled || !_pubDm || !_mqtt.connected()) return; // DMs are opt-in
if (!_enabled || !_pubDm || _connecting || !_mqtt.connected()) return; // DMs are opt-in
char keyHex[13] = {};
for (int i = 0; i < 6; ++i) snprintf(keyHex + i * 2, 3, "%02x", senderKey32[i]);
@@ -175,7 +208,7 @@ void MqttBridge::publishDM(const char* senderName, const uint8_t* senderKey32,
void MqttBridge::publishChannel(int channelIdx, const char* channelName,
float snr, uint8_t hops, uint32_t ts, const char* text) {
if (!_enabled || !_pubChannel || !_mqtt.connected()) return; // channel publish toggle
if (!_enabled || !_pubChannel || _connecting || !_mqtt.connected()) return; // channel publish toggle
char safeName[48], safeText[300];
escapeJson(channelName, safeName, sizeof(safeName));
escapeJson(text, safeText, sizeof(safeText));
@@ -204,6 +237,10 @@ void MqttBridge::saveConfig(const char* host, uint16_t port,
}
void MqttBridge::reloadConfig() {
// A connect attempt may be in flight on the one-shot task; PubSubClient is not
// thread-safe, so wait it out (bounded: DNS + TCP + CONNACK <= ~10 s, and it
// only overlaps when Save lands inside an attempt window on a dead broker).
while (_connecting) delay(10);
if (_mqtt.connected()) _mqtt.disconnect();
loadConfig();
if (!_enabled) { Serial.println("[MQTT] disabled"); return; }
+16 -1
View File
@@ -24,6 +24,17 @@ public:
#include <PubSubClient.h>
#include <WiFiClient.h>
// WiFiClient whose write() refuses to enter the framework's blocking-select path:
// bytes are only handed to lwIP when the socket can take them right now, so a
// wedged broker connection (half-open socket, peer stopped ACKing) fails the
// publish instead of stalling the loop thread ~1 s per write attempt. Same probe
// as the TCP/WS companion servers.
class MqttNbClient : public WiFiClient {
public:
size_t write(const uint8_t* buf, size_t size) override;
size_t write(uint8_t b) override { return write(&b, 1); }
};
// Publishes received mesh messages to an MQTT broker over the existing WiFi link.
//
// PRIVACY MODEL (mirrors how Meshtastic's MQTT module guards content):
@@ -73,7 +84,7 @@ public:
void reloadConfig();
private:
WiFiClient _wc;
MqttNbClient _wc;
PubSubClient _mqtt{_wc};
char _nodeHex[13] = {}; // 6-byte key → 12 hex chars + '\0'
bool _enabled = false;
@@ -87,12 +98,16 @@ private:
bool _encOn = false;
uint16_t _port = 1883;
uint32_t _lastReconnectMs = 0;
// 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;
static const uint32_t RECONNECT_INTERVAL_MS = 15000;
void loadConfig(); // shared by begin() / reloadConfig()
void deriveKey(); // _key = SHA-256(_psk); sets _encOn
bool reconnect();
static void reconnectTask(void* arg); // one-shot task body wrapping reconnect()
void pub(const char* subtopic, const char* json); // seals if _encOn
bool sealToB64(const char* plain, char* out, size_t outCap);
static void escapeJson(const char* src, char* dst, size_t dstLen);
@@ -4,9 +4,12 @@
#include <string.h>
// Companion push code for the per-packet RX log (matches MyMesh.cpp). It is kept OFF
// the BLE transport in writeFrameToAll — see the note there (issues #46, #54).
// the BLE transport in writeFrameToAll — see the note there (issues #46, #54) — EXCEPT
// for one-shot passes armed via bleAllowNextRxLog() (echoes of our own sends, #94).
#define PUSH_CODE_LOG_RX_DATA 0x88
bool MultiTransportCompanionInterface::s_ble_rxlog_once = false;
MultiTransportCompanionInterface::MultiTransportCompanionInterface()
: _tcp_port(0), _ws_port(0), _tcp_started(false), _ws_started(false), _tcp_enabled(true), _isEnabled(false), _broadcast(false), _last_reply_target(REPLY_TARGET_USB), _ota_tcp_suspended(false), _ota_ws_suspended(false), _ota_ws_listen_paused(false)
#ifdef BLE_PIN_CODE
@@ -334,10 +337,16 @@ size_t MultiTransportCompanionInterface::writeFrameToAll(const uint8_t src[], si
all_ok = false;
#ifdef BLE_PIN_CODE
// The per-packet RX log floods BLE's ~16 frames/sec budget on a busy mesh, starving
// the frames that matter — chat messages + admin responses (issues #46, #54). The
// MeshCore app doesn't consume the RX log, so keep it OFF BLE; USB/TCP/WS (far more
// bandwidth) still receive it for the on-device Monitor app / diagnostics.
const bool skip_ble = (len > 0 && src[0] == PUSH_CODE_LOG_RX_DATA);
// the frames that matter — chat messages + admin responses (issues #46, #54). So it
// is kept OFF BLE (USB/TCP/WS have the bandwidth) — EXCEPT when MyMesh::logRxRaw
// armed a one-shot pass because this frame is an echo of OUR OWN flood send: the
// app's "Repeats heard" is computed exactly from those (it broke on BLE when the
// blanket skip landed in beta_23 — issue #94), and a few echoes per send are
// nowhere near the flood that caused #46/#54.
const bool rxlog = (len > 0 && src[0] == PUSH_CODE_LOG_RX_DATA);
const bool ble_pass = rxlog && s_ble_rxlog_once;
if (rxlog) s_ble_rxlog_once = false; // consume the one-shot either way
const bool skip_ble = rxlog && !ble_pass;
if (!skip_ble && _ble_begun && _ble_enabled && _ble.isConnected() && _ble.writeFrame(src, len) != len)
all_ok = false;
#endif
@@ -64,6 +64,13 @@ public:
bool isWriteBusy() const override;
size_t writeFrame(const uint8_t src[], size_t len) override;
size_t writeFrameToAll(const uint8_t src[], size_t len) override;
/** One-shot: let the NEXT RX-log frame (PUSH_CODE_LOG_RX_DATA) through to BLE.
* RX logs are normally kept OFF BLE (#46/#54 congestion), but the app's
* "Repeats heard" is computed from them — MyMesh::logRxRaw calls this right
* before writeFrameToAll when the frame is an echo of OUR OWN send, so BLE
* gets exactly the few frames the app needs and none of the flood (#94).
* Same-thread set-then-consume (both in the mesh loop), so no atomics. */
static void bleAllowNextRxLog() { s_ble_rxlog_once = true; }
bool companionUnsolicitedPushesBroadcastToAll() const override { return _broadcast; }
size_t checkRecvFrame(uint8_t dest[]) override;
@@ -73,6 +80,10 @@ public:
int getReplyTarget() const override { return _last_reply_target; }
void setReplyTarget(int target) override { _last_reply_target = target; }
private:
static bool s_ble_rxlog_once; // one-shot BLE pass for the next RX-log frame (#94)
public:
private:
int _clientIdSlot() const;
static const size_t _max_client_id_len = 32;
+44 -6
View File
@@ -1,20 +1,44 @@
#include "TCPCompanionServer.h"
#include <lwip/sockets.h>
#define RECV_STATE_IDLE 0
#define RECV_STATE_HDR_FOUND 1
#define RECV_STATE_LEN1_FOUND 2
#define RECV_STATE_LEN2_FOUND 3
#define TCP_WRITE_TIMEOUT_MS 120
#define TCP_WEDGED_DROP_MS 10000
// True when lwIP can accept bytes for this socket right now. select() only reports
// writable once at least TCP_SNDLOWAT (~2.8 KB) of send buffer is free, which holds a
// whole companion frame (MAX_FRAME_SIZE + header), so a write issued after this probe
// completes without blocking. Without the probe, WiFiClient::write() sits in 1 s
// select() waits (up to 10) against a peer that stopped ACKing — a phone asleep in a
// pocket keeps its socket "connected" but unwritable, and every mesh-RX push then
// stalls the loop thread (and the UI) for seconds.
static bool socketWritableNow(WiFiClient& client) {
int fd = client.fd();
if (fd < 0) return false;
fd_set wset;
FD_ZERO(&wset);
FD_SET(fd, &wset);
struct timeval tv;
tv.tv_sec = 0;
tv.tv_usec = 0;
return select(fd + 1, NULL, &wset, NULL, &tv) > 0 && FD_ISSET(fd, &wset);
}
static bool writeAllBytes(WiFiClient& client, const uint8_t* buf, size_t len, uint32_t timeout_ms) {
size_t sent = 0;
uint32_t start = millis();
while (sent < len) {
if (!client.connected()) return false;
size_t n = client.write(buf + sent, len - sent);
if (n > 0) {
sent += n;
continue;
if (socketWritableNow(client)) {
size_t n = client.write(buf + sent, len - sent);
if (n > 0) {
sent += n;
continue;
}
}
if (millis() - start >= timeout_ms) return false;
delay(1);
@@ -26,6 +50,7 @@ TCPCompanionServer::TCPCompanionServer() : _server(WiFiServer()), _port(0), _pol
for (int i = 0; i < TCP_COMPANION_MAX_CLIENTS; i++) {
_clients[i].state = RECV_STATE_IDLE;
_clients[i].in_use = false;
_clients[i].stall_ms = 0;
}
}
@@ -60,6 +85,7 @@ void TCPCompanionServer::acceptNewClients() {
_clients[slot].in_use = true;
_clients[slot].state = RECV_STATE_IDLE;
_clients[slot].rx_len = 0;
_clients[slot].stall_ms = 0;
} else {
incoming.stop();
}
@@ -81,6 +107,7 @@ void TCPCompanionServer::disconnectClient(int client_index) {
_clients[client_index].client.stop();
_clients[client_index].in_use = false;
_clients[client_index].state = RECV_STATE_IDLE;
_clients[client_index].stall_ms = 0;
}
}
@@ -160,10 +187,21 @@ size_t TCPCompanionServer::writeToClient(int client_index, const uint8_t src[],
WiFiClient* cl = &_clients[client_index].client;
if (!writeAllBytes(*cl, hdr, 3, TCP_WRITE_TIMEOUT_MS) ||
!writeAllBytes(*cl, src, len, TCP_WRITE_TIMEOUT_MS)) {
// Return 0 so caller can retry (e.g. contact list). Do not disconnect on transient
// buffer full; companion layer retries and will complete the sequence.
// Return 0 so caller can retry (e.g. contact list). A transient full buffer must
// not disconnect — the companion layer retries and completes the sequence. But a
// peer that stopped ACKing (phone asleep, walked out of range) keeps a half-open
// socket "connected" indefinitely while staying unwritable; drop it once the
// failure has been continuous for TCP_WEDGED_DROP_MS so it stops eating the
// write-timeout budget on every pushed frame.
TCPClientState* c = &_clients[client_index];
if (c->stall_ms == 0) {
c->stall_ms = millis() | 1;
} else if (millis() - c->stall_ms >= TCP_WEDGED_DROP_MS) {
disconnectClient(client_index);
}
return 0;
}
_clients[client_index].stall_ms = 0;
return len;
}
+1
View File
@@ -15,6 +15,7 @@ struct TCPClientState {
uint16_t rx_len;
uint8_t rx_buf[MAX_FRAME_SIZE];
bool in_use;
uint32_t stall_ms; // start of a continuous write-failure run; 0 = healthy
};
// Multi-client TCP server for companion protocol. Same frame format as serial.
+106 -4
View File
@@ -35,7 +35,7 @@ static bool s_begun = false;
// short read (→ treat as absent → defaults); `ver` lets later builds add fields.
static const char* KEY_CFG = "cfg";
static const uint16_t TOUCH_CFG_MAGIC = 0x5743; // 'WC' (WadaCfg)
static const uint8_t TOUCH_CFG_VER = 31; // v2 sig_probe/poll; v3 tz_zone; v4 hide_node_name; v5 map_night/map_zoom; v6 map text/marker visibility; v7 app_grid_large; v8 ui_scale; v9 tb_keypad; v10 sleep_idle; v11 nav_keys; v12 map_zoom_buttons; v13 nav_dir_keys; v14 home_is_drawer; v15 kbd_nav default ON (one-time migrate); v16 nav_scroll_keys; v17 notify_new_contact; v18 kbd_nav OFF by default (reverses v15; T-Deck/V4 only, Tanmatsu stays on); v19 show_sensors_tab; v20 map_show_links; v21 map_style (0=OSM default, 1=OpenTopoMap); v22 tb_nav; v23 scope_direct (opt-in: scope direct/login floods to the region); v24 tb_nav default OFF (experimental); v25 fem_lna (Heltec V4.3 high-gain FEM LNA, opt-in); v26 msg_flash (flash keyboard backlight + wake screen on a new message, opt-in); v27 flood_adv_hrs + local_adv_min (periodic self-advert intervals, the standard MeshCore flood/local advert on a timer); v28 beta_updates (opt-in to test/beta firmware on the OTA update check + install); v29 ui_scale default -> Large/150% (Tanmatsu; bumps the old 100% default, leaves an explicit Large/Huge choice); v30 boot_advert (opt-in one-shot flood self-advert ~6s after boot, all boards, #76); v31 compact_chat (opt-in IRC-style dense chat rows instead of bubbles)
static const uint8_t TOUCH_CFG_VER = 33; // v2 sig_probe/poll; v3 tz_zone; v4 hide_node_name; v5 map_night/map_zoom; v6 map text/marker visibility; v7 app_grid_large; v8 ui_scale; v9 tb_keypad; v10 sleep_idle; v11 nav_keys; v12 map_zoom_buttons; v13 nav_dir_keys; v14 home_is_drawer; v15 kbd_nav default ON (one-time migrate); v16 nav_scroll_keys; v17 notify_new_contact; v18 kbd_nav OFF by default (reverses v15; T-Deck/V4 only, Tanmatsu stays on); v19 show_sensors_tab; v20 map_show_links; v21 map_style (0=OSM default, 1=OpenTopoMap); v22 tb_nav; v23 scope_direct (opt-in: scope direct/login floods to the region); v24 tb_nav default OFF (experimental); v25 fem_lna (Heltec V4.3 high-gain FEM LNA, opt-in); v26 msg_flash (flash keyboard backlight + wake screen on a new message, opt-in); v27 flood_adv_hrs + local_adv_min (periodic self-advert intervals, the standard MeshCore flood/local advert on a timer); v28 beta_updates (opt-in to test/beta firmware on the OTA update check + install); v29 ui_scale default -> Large/150% (Tanmatsu; bumps the old 100% default, leaves an explicit Large/Huge choice); v30 boot_advert (opt-in one-shot flood self-advert ~6s after boot, all boards, #76); v31 compact_chat (opt-in IRC-style dense chat rows instead of bubbles); v32 clock_floor (highest epoch handed out — monotonic send-timestamp floor across reboots, #89); v33 rx_queue (buffered LoRa receive: drain task + packet ring, experimental, default OFF)
// Defaults (kept identical to the historical per-key defaults).
static const uint16_t DEFAULT_SCREEN_TIMEOUT_S = 20;
@@ -101,6 +101,8 @@ struct __attribute__((packed)) TouchCfg {
uint8_t beta_updates; // opt-in to test/beta firmware on the OTA check + install (bool) — v28 (trailing)
uint8_t boot_advert; // one-shot flood self-advert ~6s after boot (bool, 0=off) — v30 (trailing) — #76
uint8_t compact_chat; // IRC-style dense chat rows instead of bubbles (bool, 0=off) — v31 (trailing)
uint32_t clock_floor; // highest epoch this device handed out (ClockFloorRTC persistence) — v32 (trailing)
uint8_t rx_queue; // buffered LoRa receive: drain task + packet ring (bool, 0=off, experimental) — v33 (trailing)
};
static TouchCfg s_cfg;
@@ -172,6 +174,8 @@ static void cfgSetDefaults(TouchCfg& c) {
c.beta_updates = 0; // OFF: stable update channel (opt-in to beta/test firmware)
c.boot_advert = 0; // OFF: no automatic advert on boot — opt-in (#76)
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)
c.sleep_idle = 0; // default: idle light-sleep OFF
{ const char* d = "ertui"; for (int i = 0; i < 5; i++) c.nav_keys[i] = (uint8_t)d[i]; } // default tab hotkeys E/R/T/U/I
c.map_zoom_buttons = 0; // default: map zoom = slider
@@ -253,6 +257,8 @@ static void cfgLoadOrMigrate() {
if (s_cfg.ver < 29 && s_cfg.ui_scale == 0) s_cfg.ui_scale = 1; // bump old 100% default -> Large (150%)
if (s_cfg.ver < 30) s_cfg.boot_advert = 0; // #76 new trailing field: advert-on-boot off by default
if (s_cfg.ver < 31) s_cfg.compact_chat = 0; // new trailing field: compact chat rows off by default
if (s_cfg.ver < 32) s_cfg.clock_floor = 0; // new trailing field: no send-timestamp floor persisted yet (#89)
if (s_cfg.ver < 33) s_cfg.rx_queue = 1; // buffered LoRa receive ON for the test channel (opt-out toggle in Radio & Mesh)
s_cfg.ver = TOUCH_CFG_VER;
s_cfg.magic = TOUCH_CFG_MAGIC;
cfgFlush(); // rewrite with new fields defaulted-in
@@ -994,6 +1000,16 @@ bool touchPrefsSetBootAdvert(bool on) {
return cfgFlush();
}
bool touchPrefsGetRxQueue() {
if (!s_begun) touchPrefsBegin();
return s_cfg.rx_queue != 0;
}
bool touchPrefsSetRxQueue(bool on) {
if (!s_begun) touchPrefsBegin();
s_cfg.rx_queue = on ? 1 : 0;
return cfgFlush();
}
bool touchPrefsGetCompactChat() {
if (!s_begun) touchPrefsBegin();
return s_cfg.compact_chat != 0;
@@ -1004,6 +1020,19 @@ bool touchPrefsSetCompactChat(bool on) {
return cfgFlush();
}
// Monotonic send-timestamp floor (ClockFloorRTC, issue #89). Written rate-capped
// from UITask::loop + on shutdown; only ever grows between resets.
uint32_t touchPrefsGetClockFloor() {
if (!s_begun) touchPrefsBegin();
return s_cfg.clock_floor;
}
bool touchPrefsSetClockFloor(uint32_t epoch) {
if (!s_begun) touchPrefsBegin();
if (epoch <= s_cfg.clock_floor) return true; // never regress the persisted floor
s_cfg.clock_floor = epoch;
return cfgFlush();
}
// Periodic self-advert intervals (0 = off). Validation mirrors MeshCore: flood in hours (cap 168);
// local zero-hop in minutes, 0 or 60-240 (MeshCore's MIN_LOCAL_ADVERT_INTERVAL is 60).
uint16_t touchPrefsGetFloodAdvHrs() {
@@ -1617,15 +1646,23 @@ void touchPrefsSetKbdBacklight(uint8_t pct) { if (pct > 100) pct = 100; if (!s_b
// Per-channel mute (name-keyed NVS blob "chm" + tiny RAM cache) --------------
static const char* KEY_CHM = "chm";
static const int CHM_ENTRY = TOUCH_CHMUTE_NAME + 1; // 32-byte name + 1 flag byte
static uint8_t s_chm[TOUCH_CHMUTE_MAX * CHM_ENTRY];
// PSRAM-first (internal fallback), zero-initialized — keeps these keyed tables
// off the scarce internal SRAM (same pattern as UITask's psAlloc).
static void* tpPsAlloc(size_t n) {
void* p = heap_caps_malloc(n, MALLOC_CAP_SPIRAM);
if (!p) p = heap_caps_malloc(n, MALLOC_CAP_8BIT);
if (p) memset(p, 0, n);
return p;
}
static uint8_t* s_chm = (uint8_t*)tpPsAlloc(TOUCH_CHMUTE_MAX * CHM_ENTRY);
static int s_chm_n = -1; // -1 = not loaded yet
static void chmLoad() {
if (s_chm_n >= 0) return;
s_chm_n = 0;
if (!s_begun) touchPrefsBegin();
if (!s_prefs.isKey(KEY_CHM)) return;
size_t n = s_prefs.getBytes(KEY_CHM, s_chm, sizeof(s_chm));
if (n == 0 || n > sizeof(s_chm)) { s_chm_n = 0; return; }
size_t n = s_prefs.getBytes(KEY_CHM, s_chm, (size_t)(TOUCH_CHMUTE_MAX * CHM_ENTRY));
if (n == 0 || n > (size_t)(TOUCH_CHMUTE_MAX * CHM_ENTRY)) { s_chm_n = 0; return; }
s_chm_n = (int)(n / CHM_ENTRY);
}
static int chmFind(const char* name) {
@@ -1665,6 +1702,71 @@ void touchPrefsSetChannelMute(const char* name, uint8_t flags) {
s_begun = s_prefs.begin(TOUCH_NS, true);
}
// ---- Per-channel avatar emoji (chat-list avatar override) ----
// Same keyed-blob pattern as the mute table above: 32-byte name + 16-byte UTF-8
// glyph (16 covers ZWJ sequences). No entry = auto (the two-letter avatar).
// 24 entries x 48 B = 1152 B, safely under the SdNvsPrefs 2048-byte blob cap.
static const int CHE_NAME = TOUCH_CHMUTE_NAME;
static const int CHE_GLYPH = 16;
static const int CHE_ENTRY = CHE_NAME + CHE_GLYPH;
static const int CHE_MAX = 24;
static const char* KEY_CHE = "chemoji";
static uint8_t* s_che = (uint8_t*)tpPsAlloc(CHE_MAX * CHE_ENTRY);
static int s_che_n = -1; // -1 = not loaded yet
static void cheLoad() {
if (s_che_n >= 0) return;
s_che_n = 0;
if (!s_begun) touchPrefsBegin();
if (!s_prefs.isKey(KEY_CHE)) return;
size_t n = s_prefs.getBytes(KEY_CHE, s_che, (size_t)(CHE_MAX * CHE_ENTRY));
if (n == 0 || n > (size_t)(CHE_MAX * CHE_ENTRY)) { s_che_n = 0; return; }
s_che_n = (int)(n / CHE_ENTRY);
}
static int cheFind(const char* name) {
cheLoad();
for (int i = 0; i < s_che_n; ++i)
if (strncmp((const char*)&s_che[i * CHE_ENTRY], name, CHE_NAME) == 0) return i;
return -1;
}
bool touchPrefsGetChannelEmoji(const char* name, char* out, size_t cap) {
if (out && cap) out[0] = '\0';
if (!name || !name[0] || !out || cap == 0) return false;
int i = cheFind(name);
if (i < 0) return false;
const char* g = (const char*)&s_che[i * CHE_ENTRY + CHE_NAME];
size_t n = strnlen(g, CHE_GLYPH);
if (n >= cap) n = cap - 1;
memcpy(out, g, n);
out[n] = '\0';
return out[0] != '\0';
}
void touchPrefsSetChannelEmoji(const char* name, const char* utf8) {
if (!name || !name[0]) return;
cheLoad();
int i = cheFind(name);
if (!utf8 || !utf8[0]) { // clear -> back to auto letters
if (i < 0) return;
for (int j = i; j + 1 < s_che_n; ++j) memcpy(&s_che[j * CHE_ENTRY], &s_che[(j + 1) * CHE_ENTRY], CHE_ENTRY);
--s_che_n;
} else {
if (i < 0) {
if (s_che_n >= CHE_MAX) return; // cap reached
i = s_che_n++;
memset(&s_che[i * CHE_ENTRY], 0, CHE_ENTRY);
strncpy((char*)&s_che[i * CHE_ENTRY], name, CHE_NAME - 1);
}
memset(&s_che[i * CHE_ENTRY + CHE_NAME], 0, CHE_GLYPH);
strncpy((char*)&s_che[i * CHE_ENTRY + CHE_NAME], utf8, CHE_GLYPH - 1);
}
s_prefs.end();
if (s_prefs.begin(TOUCH_NS, false)) {
if (s_che_n == 0) s_prefs.remove(KEY_CHE);
else s_prefs.putBytes(KEY_CHE, s_che, (size_t)(s_che_n * CHE_ENTRY));
s_prefs.end();
}
s_begun = s_prefs.begin(TOUCH_NS, true);
}
// Remembered repeater admin passwords --------------------------------------
//
// Layout: single NVS blob "rpw" of up to TOUCH_REPEATER_PW_MAX records,
+10
View File
@@ -158,6 +158,14 @@ bool touchPrefsSetBootAdvert(bool on);
bool touchPrefsGetCompactChat();
bool touchPrefsSetCompactChat(bool on);
/* Buffered LoRa receive (experimental): a high-priority drain task lifts each packet
* out of the radio within ~1 ms so UI-thread stalls can't overwrite unread packets
* (the missed-messages class). Opt-in, default off = stock receive path. */
bool touchPrefsGetRxQueue();
bool touchPrefsSetRxQueue(bool on);
uint32_t touchPrefsGetClockFloor(); // monotonic send-timestamp floor (#89)
bool touchPrefsSetClockFloor(uint32_t epoch); // only ever grows; no-op below current
/* Periodic self-advert intervals (the standard MeshCore flood/local advert, on a timer). 0 = off.
* Flood in hours; local zero-hop in minutes (0 or 60-240). Scheduled in UITask::loop via sendAdvert. */
uint16_t touchPrefsGetFloodAdvHrs();
@@ -381,6 +389,8 @@ constexpr uint8_t TOUCH_CHMUTE_MSG = 0x1;
constexpr uint8_t TOUCH_CHMUTE_MEN = 0x2;
uint8_t touchPrefsGetChannelMute(const char* name);
void touchPrefsSetChannelMute(const char* name, uint8_t flags);
bool touchPrefsGetChannelEmoji(const char* name, char* out, size_t cap); // chat-list avatar override; false/empty = auto letters
void touchPrefsSetChannelEmoji(const char* name, const char* utf8); // empty/null clears back to auto
/** Generic NVS blob (key/value). Used to persist the discovered-nodes list so
* it survives a reboot. getBlob returns the byte count copied (0 if absent). */
+36 -4
View File
@@ -2,12 +2,14 @@
#include <Arduino.h>
#include <mbedtls/sha1.h>
#include <string.h>
#include <lwip/sockets.h>
#ifndef WS_FRAME_DEBUG
#define WS_FRAME_DEBUG 0
#endif
#define TCP_WRITE_TIMEOUT_MS 120
#define WS_WEDGED_DROP_MS 10000
#define WS_MAGIC "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
// Browser opens http://device:8765 — short info page (plain WebSocket endpoint).
@@ -47,15 +49,32 @@ static void base64Encode20(const uint8_t* in, char* out) {
*out = '\0';
}
// True when lwIP can accept bytes for this socket right now — see the identical probe
// in TCPCompanionServer.cpp. Without it, WiFiClient::write() against a peer that
// stopped ACKing (half-open socket) blocks in 1 s select() waits on the loop thread.
static bool socketWritableNow(WiFiClient& client) {
int fd = client.fd();
if (fd < 0) return false;
fd_set wset;
FD_ZERO(&wset);
FD_SET(fd, &wset);
struct timeval tv;
tv.tv_sec = 0;
tv.tv_usec = 0;
return select(fd + 1, NULL, &wset, NULL, &tv) > 0 && FD_ISSET(fd, &wset);
}
static bool writeAllBytes(WiFiClient& client, const uint8_t* buf, size_t len, uint32_t timeout_ms) {
size_t sent = 0;
uint32_t start = millis();
while (sent < len) {
if (!client.connected()) return false;
size_t n = client.write(buf + sent, len - sent);
if (n > 0) {
sent += n;
continue;
if (socketWritableNow(client)) {
size_t n = client.write(buf + sent, len - sent);
if (n > 0) {
sent += n;
continue;
}
}
if (millis() - start >= timeout_ms) return false;
delay(1);
@@ -72,6 +91,7 @@ WebSocketCompanionServer::WebSocketCompanionServer()
_clients[i].handshake_len = 0;
_clients[i].ws_state = WS_STATE_HEADER_0;
_clients[i].comp_state = COMP_STATE_IDLE;
_clients[i].stall_ms = 0;
}
}
@@ -142,6 +162,7 @@ void WebSocketCompanionServer::acceptNewClients() {
_clients[slot].handshake_len = 0;
_clients[slot].ws_state = WS_STATE_HEADER_0;
_clients[slot].comp_state = COMP_STATE_IDLE;
_clients[slot].stall_ms = 0;
}
}
@@ -355,8 +376,18 @@ size_t WebSocketCompanionServer::writeToClient(int client_index, const uint8_t s
#if WS_FRAME_DEBUG
Serial.printf("WS frame client=%d code=%u len=%u written=0\n", client_index, (unsigned)(len ? src[0] : 0), (unsigned)len);
#endif
// Same wedged-peer reaper as the TCP server: a client that stays unwritable for
// WS_WEDGED_DROP_MS straight is half-open (browser tab gone, phone asleep) and
// would otherwise eat the write-timeout budget on every pushed frame forever.
WSClientState* c = &_clients[client_index];
if (c->stall_ms == 0) {
c->stall_ms = millis() | 1;
} else if (millis() - c->stall_ms >= WS_WEDGED_DROP_MS) {
disconnectClient(client_index);
}
return 0;
}
_clients[client_index].stall_ms = 0;
#if WS_FRAME_DEBUG
Serial.printf("WS frame client=%d code=%u len=%u written=%u\n", client_index, (unsigned)(len ? src[0] : 0), (unsigned)len, (unsigned)len);
#endif
@@ -396,5 +427,6 @@ void WebSocketCompanionServer::disconnectClient(int client_index) {
if (client_index >= 0 && client_index < WS_COMPANION_MAX_CLIENTS && _clients[client_index].in_use) {
_clients[client_index].client.stop();
_clients[client_index].in_use = false;
_clients[client_index].stall_ms = 0;
}
}
@@ -35,6 +35,7 @@ struct WSClientState {
uint16_t comp_frame_len;
uint16_t comp_rx_len;
uint8_t comp_rx_buf[MAX_FRAME_SIZE];
uint32_t stall_ms; // start of a continuous write-failure run; 0 = healthy
};
// WebSocket server for companion protocol. Same logical protocol as TCP; transport is RFC 6455 (plain WS).
+52 -2
View File
@@ -67,7 +67,21 @@ static uint32_t _atoi(const char* sp) {
#ifdef MULTI_TRANSPORT_COMPANION
#include <helpers/esp32/MultiTransportCompanionInterface.h>
#include "helpers/esp32/MqttBridge.h"
MultiTransportCompanionInterface serial_interface;
#include <esp_heap_caps.h>
#include <new>
// ~9.2 KB of TCP/WS/USB framing buffers. Internal DRAM is the scarce pool on
// the touch boards (Wi-Fi + BLE coexistence needs ~50 KB free), and none of
// these buffers are touched from ISR context, so build the whole object in
// PSRAM (heap is up before C++ static init on ESP32; falls back to internal
// RAM if PSRAM is absent). In-TU init order runs this before
// ui_task(&serial_interface) further down.
static void* s_si_mem = [] {
void* p = heap_caps_malloc(sizeof(MultiTransportCompanionInterface),
MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
return p ? p : malloc(sizeof(MultiTransportCompanionInterface));
}();
MultiTransportCompanionInterface& serial_interface =
*new (s_si_mem) MultiTransportCompanionInterface();
#ifndef TCP_PORT
#define TCP_PORT 5000
#endif
@@ -573,9 +587,21 @@ void setup() {
void loop() {
// Run UI first every iteration so splash can dismiss at 3s even if mesh/serial blocks later (was stuck on version screen when the_mesh.loop() ran before ui_task.loop()).
#ifdef DISPLAY_CLASS
ui_task.loop();
// ---- beta_31 field-freeze tracer (see UITask.cpp): time each loop section ----
extern void stallLog(const char* tag, uint32_t dur_ms);
extern const char* g_ui_stall_tag;
extern uint16_t g_ui_stall_max;
#define STALL_SCOPE(tag, call) { uint32_t _st0 = millis(); call; uint32_t _sdt = millis() - _st0; if (_sdt >= 200) stallLog(tag, _sdt); }
{ uint32_t _ui0 = millis();
ui_task.loop();
uint32_t _uidt = millis() - _ui0;
if (_uidt >= 200) stallLog((g_ui_stall_max >= 150 && g_ui_stall_tag[0]) ? g_ui_stall_tag : "ui:other", _uidt);
}
#endif
#ifdef MULTI_TRANSPORT_COMPANION
#ifdef DISPLAY_CLASS
uint32_t _wf0 = millis(); // beta_31 tracer: time the whole Wi-Fi state machine + SNTP span
#endif
static bool wifi_started = false;
static uint32_t last_wifi_retry_ms = 0;
static const uint32_t WIFI_RETRY_INTERVAL_MS = 10000;
@@ -700,14 +726,22 @@ void loop() {
if (sntp_kicked && !sntp_pushed) sntp_kicked = false;
}
}
#ifdef DISPLAY_CLASS
{ uint32_t _wfdt = millis() - _wf0; if (_wfdt >= 200) stallLog("wifi-sm", _wfdt); }
#endif
// Defer TCP and WebSocket until after splash dismisses so the_mesh.loop() never blocks on accept() before ui_task.loop() runs.
static const uint32_t TCP_DEFER_MS = 5000; // 5 s: don't start TCP/WS until version screen has dismissed
/* Only start TCP / WS when WiFi was actually brought up. In BLE-only mode
* (no saved creds) the lwIP stack is never initialized — calling
* WiFiServer::begin() crashes with a tcpip_adapter assert. */
if (millis() > TCP_DEFER_MS && wifi_started) {
#ifdef DISPLAY_CLASS
STALL_SCOPE("tcp-server", serial_interface.startTcpServer(WiFi.status() == WL_CONNECTED));
STALL_SCOPE("ws-tick", serial_interface.tickWebSocketHandshake());
#else
serial_interface.startTcpServer(WiFi.status() == WL_CONNECTED);
serial_interface.tickWebSocketHandshake();
#endif
}
#endif
#if defined(HAS_TOUCH_UI)
@@ -718,10 +752,18 @@ void loop() {
// and clears the flag, so the next the_mesh.loop() re-arms RX correctly.
if (!spectrumOwnsRadio())
#endif
#ifdef DISPLAY_CLASS
STALL_SCOPE("mesh", the_mesh.loop());
#else
the_mesh.loop();
#endif
#if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION)
#ifdef DISPLAY_CLASS
STALL_SCOPE("mqtt", mqtt_bridge.loop());
#else
mqtt_bridge.loop();
#endif
#endif
#if defined(GPS_BUF_DEBUG)
// Bench diagnostic (build with -DGPS_BUF_DEBUG only; absent in releases): peak GPS UART
// backlog accumulated between sensors.loop() drains. A peak above the old 256-byte default
@@ -738,7 +780,11 @@ void loop() {
}
}
#endif
#ifdef DISPLAY_CLASS
STALL_SCOPE("sensors", sensors.loop());
#else
sensors.loop();
#endif
#if defined(ESP32)
// GPS time guard (Ricky Leong's "stuck at 1902"): MicroNMEALocationProvider
// sets the mesh RTC from a GPS *position* fix even before the date fields are
@@ -795,6 +841,10 @@ void loop() {
// Idle light-sleep gate: evaluated every loop tick. g_enabled is false by
// default (Task 1 is inert); Task 2 sets it from the NVS pref and wires
// the real predicates so the gate can actually pass and arm light sleep.
#ifdef DISPLAY_CLASS
STALL_SCOPE("sleep-gate", touchSleep::loopEnd(millis()));
#else
touchSleep::loopEnd(millis());
#endif
#endif
}
+27 -27
View File
@@ -27,7 +27,7 @@ static constexpr lv_btnmatrix_ctrl_t KC(uint8_t width_mul) {
/* ---------- Bulgarian on-screen keyboard ---------- */
/* Lower-case Cyrillic, 3 letter rows + 1 control row. */
static const char* kb_bg_lower[] = {
static const char* const kb_bg_lower[] = {
"я","в","е","р","т","ъ","у","и","о","п","\n",
"а","с","д","ф","г","х","й","к","л","ь","\n",
"з","ц","ч","ж","б","н","м","ш","щ","ю","\n",
@@ -42,7 +42,7 @@ static const lv_btnmatrix_ctrl_t kb_bg_lower_ctrl[] = {
};
/* Upper-case Cyrillic. */
static const char* kb_bg_upper[] = {
static const char* const kb_bg_upper[] = {
"Я","В","Е","Р","Т","Ъ","У","И","О","П","\n",
"А","С","Д","Ф","Г","Х","Й","К","Л","Ь","\n",
"З","Ц","Ч","Ж","Б","Н","М","Ш","Щ","Ю","\n",
@@ -58,7 +58,7 @@ static const lv_btnmatrix_ctrl_t kb_bg_upper_ctrl[] = {
/* ---------- Russian (ЙЦУКЕН) on-screen keyboard ---------- */
/* Standard Russian layout, 3 rows of 11 = all 33 letters. */
static const char* kb_ru_lower[] = {
static const char* const kb_ru_lower[] = {
"й","ц","у","к","е","н","г","ш","щ","з","х","\n",
"ф","ы","в","а","п","р","о","л","д","ж","э","\n",
"я","ч","с","м","и","т","ь","б","ю","ъ","ё","\n",
@@ -72,7 +72,7 @@ static const lv_btnmatrix_ctrl_t kb_ru_lower_ctrl[] = {
KC(2), KC(1), KC(4), KC(2), KC(1)
};
static const char* kb_ru_upper[] = {
static const char* const kb_ru_upper[] = {
"Й","Ц","У","К","Е","Н","Г","Ш","Щ","З","Х","\n",
"Ф","Ы","В","А","П","Р","О","Л","Д","Ж","Э","\n",
"Я","Ч","С","М","И","Т","Ь","Б","Ю","Ъ","Ё","\n",
@@ -88,7 +88,7 @@ static const lv_btnmatrix_ctrl_t kb_ru_upper_ctrl[] = {
/* ---------- Ukrainian on-screen keyboard ---------- */
/* Standard Ukrainian layout, 3 rows of 11 = all 33 letters. */
static const char* kb_uk_lower[] = {
static const char* const kb_uk_lower[] = {
"й","ц","у","к","е","н","г","ш","щ","з","х","\n",
"ф","і","в","а","п","р","о","л","д","ж","є","\n",
"я","ч","с","м","и","т","ь","б","ю","ї","ґ","\n",
@@ -100,7 +100,7 @@ static const lv_btnmatrix_ctrl_t kb_uk_lower_ctrl[] = {
0,0,0,0,0,0,0,0,0,0,0,
KC(2), KC(1), KC(4), KC(2), KC(1)
};
static const char* kb_uk_upper[] = {
static const char* const kb_uk_upper[] = {
"Й","Ц","У","К","Е","Н","Г","Ш","Щ","З","Х","\n",
"Ф","І","В","А","П","Р","О","Л","Д","Ж","Є","\n",
"Я","Ч","С","М","И","Т","Ь","Б","Ю","Ї","Ґ","\n",
@@ -115,7 +115,7 @@ static const lv_btnmatrix_ctrl_t kb_uk_upper_ctrl[] = {
/* ---------- Serbian (Cyrillic) on-screen keyboard ---------- */
/* Serbian azbuka, 3 rows of 10 = all 30 letters. */
static const char* kb_sr_lower[] = {
static const char* const kb_sr_lower[] = {
"љ","њ","е","р","т","з","у","и","о","п","\n",
"а","с","д","ф","г","х","ј","к","л","ч","\n",
"ж","џ","ц","в","б","н","м","ђ","ћ","ш","\n",
@@ -127,7 +127,7 @@ static const lv_btnmatrix_ctrl_t kb_sr_lower_ctrl[] = {
0,0,0,0,0,0,0,0,0,0,
KC(2), KC(1), KC(4), KC(2), KC(1)
};
static const char* kb_sr_upper[] = {
static const char* const kb_sr_upper[] = {
"Љ","Њ","Е","Р","Т","З","У","И","О","П","\n",
"А","С","Д","Ф","Г","Х","Ј","К","Л","Ч","\n",
"Ж","Џ","Ц","В","Б","Н","М","Ђ","Ћ","Ш","\n",
@@ -142,7 +142,7 @@ static const lv_btnmatrix_ctrl_t kb_sr_upper_ctrl[] = {
/* ---------- Greek on-screen keyboard ---------- */
/* ΕΛΟΤ-style positions; 9+9+7 = all 24 letters (+ final sigma ς). */
static const char* kb_el_lower[] = {
static const char* const kb_el_lower[] = {
"ς","ε","ρ","τ","υ","θ","ι","ο","π","\n",
"α","σ","δ","φ","γ","η","ξ","κ","λ","\n",
"ζ","χ","ψ","ω","β","ν","μ","\n",
@@ -154,7 +154,7 @@ static const lv_btnmatrix_ctrl_t kb_el_lower_ctrl[] = {
0,0,0,0,0,0,0,
KC(2), KC(1), KC(4), KC(2), KC(1)
};
static const char* kb_el_upper[] = {
static const char* const kb_el_upper[] = {
"Σ","Ε","Ρ","Τ","Υ","Θ","Ι","Ο","Π","\n",
"Α","Σ","Δ","Φ","Γ","Η","Ξ","Κ","Λ","\n",
"Ζ","Χ","Ψ","Ω","Β","Ν","Μ","\n",
@@ -171,7 +171,7 @@ static const lv_btnmatrix_ctrl_t kb_el_upper_ctrl[] = {
/* Arabic-101 letter positions, 3 rows of 11 = all 28 letters + ة ى ء ئ ؤ و.
* Arabic is unicameral, so the same map serves LOWER and UPPER. The text
* field renders RTL + shaped via LV_USE_BIDI / LV_USE_ARABIC_PERSIAN_CHARS. */
static const char* kb_ar_lower[] = {
static const char* const kb_ar_lower[] = {
"ض","ص","ث","ق","ف","غ","ع","ه","خ","ح","ج","\n",
"ش","س","ي","ب","ل","ا","ت","ن","م","ك","ط","\n",
"ئ","ء","ؤ","ر","ى","ة","و","ز","ظ","ذ","د","\n",
@@ -193,7 +193,7 @@ static const lv_btnmatrix_ctrl_t kb_ar_lower_ctrl[] = {
* copy to restore it when the user cycles back to English. EN_KB_BTN mirrors the
* library's private LV_KB_BTN (popover preview + width). */
#define EN_KB_BTN(w) (LV_BTNMATRIX_CTRL_POPOVER | (w))
static const char* kb_en_lower[] = {
static const char* const kb_en_lower[] = {
"1#", "q", "w", "e", "r", "t", "y", "u", "i", "o", "p", LV_SYMBOL_BACKSPACE, "\n",
"ABC", "a", "s", "d", "f", "g", "h", "j", "k", "l", LV_SYMBOL_NEW_LINE, "\n",
"_", "-", "z", "x", "c", "v", "b", "n", "m", ".", ",", ":", "\n",
@@ -207,7 +207,7 @@ static const lv_btnmatrix_ctrl_t kb_en_lower_ctrl[] = {
LV_KEYBOARD_CTRL_BTN_FLAGS | 2, LV_BTNMATRIX_CTRL_CHECKED | 2, 6, LV_BTNMATRIX_CTRL_CHECKED | 2, LV_KEYBOARD_CTRL_BTN_FLAGS | 2
};
static const char* kb_en_upper[] = {
static const char* const kb_en_upper[] = {
"1#", "Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P", LV_SYMBOL_BACKSPACE, "\n",
"abc", "A", "S", "D", "F", "G", "H", "J", "K", "L", LV_SYMBOL_NEW_LINE, "\n",
"_", "-", "Z", "X", "C", "V", "B", "N", "M", ".", ",", ":", "\n",
@@ -224,7 +224,7 @@ static const lv_btnmatrix_ctrl_t kb_en_upper_ctrl[] = {
/* ---------- French (AZERTY) on-screen keyboard ---------- */
/* Keeps the proven LVGL control rows/punctuation, but swaps the alpha rows to
* a familiar French AZERTY order. Accents still come from the existing popup. */
static const char* kb_fr_lower[] = {
static const char* const kb_fr_lower[] = {
"1#", "a", "z", "e", "r", "t", "y", "u", "i", "o", "p", LV_SYMBOL_BACKSPACE, "\n",
"ABC", "q", "s", "d", "f", "g", "h", "j", "k", "l", "m", LV_SYMBOL_NEW_LINE, "\n",
"_", "-", "w", "x", "c", "v", "b", "n", ".", ",", ":", "", "\n",
@@ -238,7 +238,7 @@ static const lv_btnmatrix_ctrl_t kb_fr_lower_ctrl[] = {
LV_KEYBOARD_CTRL_BTN_FLAGS | 2, LV_BTNMATRIX_CTRL_CHECKED | 2, 6, LV_BTNMATRIX_CTRL_CHECKED | 2, LV_KEYBOARD_CTRL_BTN_FLAGS | 2
};
static const char* kb_fr_upper[] = {
static const char* const kb_fr_upper[] = {
"1#", "A", "Z", "E", "R", "T", "Y", "U", "I", "O", "P", LV_SYMBOL_BACKSPACE, "\n",
"abc", "Q", "S", "D", "F", "G", "H", "J", "K", "L", "M", LV_SYMBOL_NEW_LINE, "\n",
"_", "-", "W", "X", "C", "V", "B", "N", ".", ",", ":", "", "\n",
@@ -254,7 +254,7 @@ static const lv_btnmatrix_ctrl_t kb_fr_upper_ctrl[] = {
/* Dutch largely stays QWERTY, but a dedicated IJ key makes the most common
* digraph directly reachable without forcing users through the accent popup. */
static const char* kb_nl_lower[] = {
static const char* const kb_nl_lower[] = {
"1#", "q", "w", "e", "r", "t", "y", "u", "i", "o", "p", LV_SYMBOL_BACKSPACE, "\n",
"ABC", "a", "s", "d", "f", "g", "h", "j", "k", "l", LV_SYMBOL_NEW_LINE, "\n",
"_", "-", "z", "x", "c", "v", "b", "n", "m", ".", ",", "ij", "\n",
@@ -268,7 +268,7 @@ static const lv_btnmatrix_ctrl_t kb_nl_lower_ctrl[] = {
LV_KEYBOARD_CTRL_BTN_FLAGS | 2, LV_BTNMATRIX_CTRL_CHECKED | 2, 6, LV_BTNMATRIX_CTRL_CHECKED | 2, LV_KEYBOARD_CTRL_BTN_FLAGS | 2
};
static const char* kb_nl_upper[] = {
static const char* const kb_nl_upper[] = {
"1#", "Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P", LV_SYMBOL_BACKSPACE, "\n",
"abc", "A", "S", "D", "F", "G", "H", "J", "K", "L", LV_SYMBOL_NEW_LINE, "\n",
"_", "-", "Z", "X", "C", "V", "B", "N", "M", ".", ",", "IJ", "\n",
@@ -284,7 +284,7 @@ static const lv_btnmatrix_ctrl_t kb_nl_upper_ctrl[] = {
/* German QWERTZ keeps the proven LVGL control rows and swaps the Y/Z alpha
* positions. Umlauts / eszett still come from the accent popup. */
static const char* kb_de_lower[] = {
static const char* const kb_de_lower[] = {
"1#", "q", "w", "e", "r", "t", "z", "u", "i", "o", "p", LV_SYMBOL_BACKSPACE, "\n",
"ABC", "a", "s", "d", "f", "g", "h", "j", "k", "l", LV_SYMBOL_NEW_LINE, "\n",
"_", "-", "y", "x", "c", "v", "b", "n", "m", ".", ",", ":", "\n",
@@ -298,7 +298,7 @@ static const lv_btnmatrix_ctrl_t kb_de_lower_ctrl[] = {
LV_KEYBOARD_CTRL_BTN_FLAGS | 2, LV_BTNMATRIX_CTRL_CHECKED | 2, 6, LV_BTNMATRIX_CTRL_CHECKED | 2, LV_KEYBOARD_CTRL_BTN_FLAGS | 2
};
static const char* kb_de_upper[] = {
static const char* const kb_de_upper[] = {
"1#", "Q", "W", "E", "R", "T", "Z", "U", "I", "O", "P", LV_SYMBOL_BACKSPACE, "\n",
"abc", "A", "S", "D", "F", "G", "H", "J", "K", "L", LV_SYMBOL_NEW_LINE, "\n",
"_", "-", "Y", "X", "C", "V", "B", "N", "M", ".", ",", ":", "\n",
@@ -313,7 +313,7 @@ static const lv_btnmatrix_ctrl_t kb_de_upper_ctrl[] = {
};
/* Spanish keeps QWERTY but promotes n-tilde directly onto the alpha deck. */
static const char* kb_es_lower[] = {
static const char* const kb_es_lower[] = {
"1#", "q", "w", "e", "r", "t", "y", "u", "i", "o", "p", LV_SYMBOL_BACKSPACE, "\n",
"ABC", "a", "s", "d", "f", "g", "h", "j", "k", "l", "ñ", LV_SYMBOL_NEW_LINE, "\n",
"_", "-", "z", "x", "c", "v", "b", "n", "m", ".", ",", ":", "\n",
@@ -327,7 +327,7 @@ static const lv_btnmatrix_ctrl_t kb_es_lower_ctrl[] = {
LV_KEYBOARD_CTRL_BTN_FLAGS | 2, LV_BTNMATRIX_CTRL_CHECKED | 2, 6, LV_BTNMATRIX_CTRL_CHECKED | 2, LV_KEYBOARD_CTRL_BTN_FLAGS | 2
};
static const char* kb_es_upper[] = {
static const char* const kb_es_upper[] = {
"1#", "Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P", LV_SYMBOL_BACKSPACE, "\n",
"abc", "A", "S", "D", "F", "G", "H", "J", "K", "L", "Ñ", LV_SYMBOL_NEW_LINE, "\n",
"_", "-", "Z", "X", "C", "V", "B", "N", "M", ".", ",", ":", "\n",
@@ -342,7 +342,7 @@ static const lv_btnmatrix_ctrl_t kb_es_upper_ctrl[] = {
};
/* Italian gets the common accented vowels directly on the base layer. */
static const char* kb_it_lower[] = {
static const char* const kb_it_lower[] = {
"1#", "q", "w", "e", "r", "t", "y", "u", "i", "o", "p", LV_SYMBOL_BACKSPACE, "\n",
"ABC", "a", "s", "d", "f", "g", "h", "j", "k", "l", "à", LV_SYMBOL_NEW_LINE, "\n",
"è", "ì", "z", "x", "c", "v", "b", "n", "m", ".", ",", ":", "\n",
@@ -356,7 +356,7 @@ static const lv_btnmatrix_ctrl_t kb_it_lower_ctrl[] = {
LV_KEYBOARD_CTRL_BTN_FLAGS | 2, LV_BTNMATRIX_CTRL_CHECKED | 2, 6, LV_BTNMATRIX_CTRL_CHECKED | 2, LV_KEYBOARD_CTRL_BTN_FLAGS | 2
};
static const char* kb_it_upper[] = {
static const char* const kb_it_upper[] = {
"1#", "Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P", LV_SYMBOL_BACKSPACE, "\n",
"abc", "A", "S", "D", "F", "G", "H", "J", "K", "L", "À", LV_SYMBOL_NEW_LINE, "\n",
"È", "Ì", "Z", "X", "C", "V", "B", "N", "M", ".", ",", ":", "\n",
@@ -373,9 +373,9 @@ static const lv_btnmatrix_ctrl_t kb_it_upper_ctrl[] = {
struct OsKeyboardLayout {
KeyboardLayoutId id;
const char* name;
const char** lower_map;
const char* const* lower_map;
const lv_btnmatrix_ctrl_t* lower_ctrl;
const char** upper_map;
const char* const* upper_map;
const lv_btnmatrix_ctrl_t* upper_ctrl;
};
@@ -691,8 +691,8 @@ void keyboardLayoutsApply(lv_obj_t* keyboard, KeyboardLayoutId id) {
s_current_layout = id;
const OsKeyboardLayout& lo = k_os_layouts[static_cast<int>(id)];
lv_keyboard_set_map(keyboard, LV_KEYBOARD_MODE_TEXT_LOWER, lo.lower_map, lo.lower_ctrl);
lv_keyboard_set_map(keyboard, LV_KEYBOARD_MODE_TEXT_UPPER, lo.upper_map, lo.upper_ctrl);
lv_keyboard_set_map(keyboard, LV_KEYBOARD_MODE_TEXT_LOWER, const_cast<const char**>(lo.lower_map), lo.lower_ctrl); // maps now in flash; LVGL never writes them
lv_keyboard_set_map(keyboard, LV_KEYBOARD_MODE_TEXT_UPPER, const_cast<const char**>(lo.upper_map), lo.upper_ctrl);
}
KeyboardLayoutId keyboardLayoutsGetCurrent() {
+1335 -375
View File
File diff suppressed because it is too large. Load diff
+46 -1
View File
@@ -30,6 +30,10 @@ enum class TouchUiScreen : uint8_t { Home = 0, ChatInbox = 1, Contacts = 2, Sett
class UITask : public AbstractUITask {
public:
static const int MAX_UI_MESSAGES = 500;
/** Deep ring for devices whose chat history lives on an SD card (T-Deck with a
* card, Tanmatsu SD_MMC): 10x the internal-flash ring. Chosen at begin() into
* _ui_msg_cap; PSRAM cost ~5000 * sizeof(UIMessage) ≈ 1.3 MB (of 8 MB). */
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;
@@ -130,6 +134,10 @@ private:
int _msgcount;
int _ui_msg_count;
int _ui_msg_head;
/** Runtime ring capacity: MAX_UI_MESSAGES_SD when history lives on an SD card,
* else MAX_UI_MESSAGES. Fixed for the whole boot (chosen before the PSRAM
* alloc in begin()); the loader linearizes files written under another cap. */
int _ui_msg_cap = MAX_UI_MESSAGES;
unsigned long _next_thread_seed;
UIMessage* _ui_msgs = nullptr; // ring of recent messages — PSRAM-allocated in begin()
UIThread* _ui_threads = nullptr; // thread table — PSRAM-allocated in begin()
@@ -315,6 +323,16 @@ public:
return _ui_threads[_active_thread_idx].mesh_channel_slot;
}
int activeThreadIdx() const { return _active_thread_idx; }
/** Any thread's pinned mesh-channel slot (-1 when not a channel / out of range).
* chatDeleteApply prefers this (name-validated) over a pure name scan so
* deleting a channel actually drops its mesh-table entry — otherwise
* refreshThreadsFromMesh() recreates the thread from the surviving channel. */
int16_t threadMeshChannelSlot(int idx) const {
if (idx < 0 || idx >= MAX_UI_THREADS || !_ui_threads[idx].used || !_ui_threads[idx].channel) return -1;
return _ui_threads[idx].mesh_channel_slot;
}
/** Message-ring capacity this boot (500, or 5000 with SD-backed history). */
int msgCap() const { return _ui_msg_cap; }
/** DM thread's full contact pubkey (32 B) — for the chat sheet's "Reset path".
* False for channels, unused slots, or a thread with no pubkey mapping yet. */
bool getThreadContactPub(int idx, uint8_t out[32]) const {
@@ -325,6 +343,27 @@ public:
for (int i = 0; i < 32; i++) { out[i] = p[i]; any |= p[i]; }
return any != 0;
}
/** Newest message of a thread (chat-list preview): scans the ring by the
* thread's name + kind and returns the latest by timestamp. False when the
* thread has no messages in the ring. */
bool getThreadLastMessage(int idx, char* sender, size_t sender_cap,
char* text, size_t text_cap, bool* outgoing) const {
if (idx < 0 || idx >= MAX_UI_THREADS || !_ui_threads[idx].used || !_ui_msgs) return false;
const bool ch = _ui_threads[idx].channel;
const char* nm = _ui_threads[idx].name;
int best = -1; uint32_t best_ts = 0;
for (int i = 0; i < _ui_msg_cap; ++i) {
const UIMessage& m = _ui_msgs[i];
if (!m.text[0] || m.channel != ch) continue;
if (strncmp(m.thread, nm, MAX_THREAD_NAME) != 0) continue;
if (best < 0 || m.ts >= best_ts) { best = i; best_ts = m.ts; } // >= : a later slot wins ts ties (newer in the ring)
}
if (best < 0) return false;
if (sender && sender_cap) { strncpy(sender, _ui_msgs[best].sender, sender_cap - 1); sender[sender_cap - 1] = '\0'; }
if (text && text_cap) { strncpy(text, _ui_msgs[best].text, text_cap - 1); text[text_cap - 1] = '\0'; }
if (outgoing) *outgoing = _ui_msgs[best].outgoing;
return true;
}
int threadScroll() const { return _thread_scroll; }
void setThreadScroll(int v) { _thread_scroll = v; }
bool hasDisplay() const { return _display != NULL; }
@@ -404,7 +443,12 @@ public:
void disableTcp() { if (_serial) _serial->disableTcp(); }
bool hasBleCapability() const { return _serial && _serial->hasBleCapability(); }
bool isBleEnabled() const { return _serial && _serial->isBleEnabled(); }
void enableBle() { if (_serial) _serial->enableBle(); }
// Live BLE enable, guarded like the boot co-init in main.cpp: NimBLE needs
// ~50 KB free internal heap + a 20 KB contiguous block, and starting it below
// that does not fail cleanly — it panics mid-init (the "reboots when I turn
// BLE on with Wi-Fi running" report). Returns false when refused; the caller
// shows the reason and reverts its switch. Defined in UITask.cpp.
bool enableBle();
void disableBle() { if (_serial) _serial->disableBle(); }
int getWsConnectedCount() const { return _serial ? _serial->getWsConnectedCount() : 0; }
void setDeviceTimeFromSystemClock();
@@ -466,6 +510,7 @@ public:
void onPingReply(const ContactInfo& contact, const uint8_t* data, size_t len) override;
void onTelemetryReply(const ContactInfo& contact, const uint8_t* data, size_t len) override;
void onAdminLoginResult(const ContactInfo& contact, bool success, uint8_t perms) override;
void onServerClock(const ContactInfo& contact, uint32_t server_epoch) override;
void onAdminCommandReply(const ContactInfo& contact, const char* text) override;
void onThreadsChanged() override;
void loop() override;
+60
View File
@@ -0,0 +1,60 @@
/*******************************************************************************
* usb_unused_class_stubs.c — keep TinyUSB's MSC + DFU-mode class drivers OUT of
* the link (~8.2 KB of internal-DRAM .bss: _mscd_buf 4096 + _dfu_ctx 4116).
*
* Why this works: the precompiled libarduino_tinyusb.a was built with every
* device class enabled, so usbd.c's class-driver table references mscd_* and
* dfu_moded_* unconditionally, which drags msc_device.c.obj / dfu_device.c.obj
* (and their big static buffers) into every USB-OTG build. Providing these
* symbols ourselves satisfies usbd's references first, so the archive members
* are never pulled in.
*
* Why it is safe: an interface class only becomes live if something calls
* tinyusb_enable_interface() for it — this firmware (and the Arduino core init
* for a CDC-on-boot build) only ever registers CDC. Verified in the linker map:
* the ONLY referencer of these objects is usbd.c.obj's driver table, and the
* wadamesh tree contains no USBMSC / FirmwareMSC / DFU usage. usbd still calls
* every table entry's init()/reset() on startup and bus reset, which is why
* these are no-ops rather than absent; open() returning 0 means "interface not
* claimed" and is never reached anyway (no such interface descriptor exists).
*
* Deliberately NOT stubbed: cdcd_* (the companion/console link) and dfu_rtd_*
* (runtime-DFU is tiny and part of the reset-to-bootloader plumbing).
*
* V4 only in practice: the T-Deck ships ARDUINO_USB_MODE=1 (HW-CDC, no TinyUSB
* device stack linked), where these definitions are simply dead code.
******************************************************************************/
#if defined(ESP32) || defined(ARDUINO_ARCH_ESP32)
#include <stdint.h>
#include <stdbool.h>
/* --- MSC class driver (tinyusb usbd_pvt.h driver-table signatures) --- */
void mscd_init(void) {}
void mscd_reset(uint8_t rhport) { (void)rhport; }
uint16_t mscd_open(uint8_t rhport, const void* itf_desc, uint16_t max_len) {
(void)rhport; (void)itf_desc; (void)max_len;
return 0; /* interface not claimed (never offered in the descriptor) */
}
bool mscd_control_xfer_cb(uint8_t rhport, uint8_t stage, const void* request) {
(void)rhport; (void)stage; (void)request;
return false;
}
bool mscd_xfer_cb(uint8_t rhport, uint8_t ep_addr, uint32_t result, uint32_t xferred_bytes) {
(void)rhport; (void)ep_addr; (void)result; (void)xferred_bytes;
return false;
}
/* --- DFU-mode class driver (dfu_rtd_* runtime-DFU intentionally untouched) --- */
void dfu_moded_init(void) {}
void dfu_moded_reset(uint8_t rhport) { (void)rhport; }
uint16_t dfu_moded_open(uint8_t rhport, const void* itf_desc, uint16_t max_len) {
(void)rhport; (void)itf_desc; (void)max_len;
return 0;
}
bool dfu_moded_control_xfer_cb(uint8_t rhport, uint8_t stage, const void* request) {
(void)rhport; (void)stage; (void)request;
return false;
}
#endif /* ESP32 */
+9
View File
@@ -36,7 +36,16 @@ if [ -f "$BLEDEV_CPP" ] && grep -q 'int rc = ble_gap_read_local_irk(irk);' "$BLE
echo "[build.sh] patched arduino BLEDevice.cpp (stub renamed ble_gap_read_local_irk)"
fi
# Firmware tag shown on About + used by the update check. Derived from git so dev
# builds are honest ("beta_28-14-gabc123" = 14 commits past beta_28) and a release
# build on a fresh beta_N tag bakes exactly "beta_N" — no more hand-bumping the
# old hardcoded CMake define (which left every dev flash claiming the last beta).
# Passed as a CMake cache var: idf.py reconfigures automatically when it changes.
WADA_FW_TAG="$(git describe --tags --match 'beta_*' --always 2>/dev/null || echo dev)"
WADA_FW_DATE="$(date '+%-d %b %Y')"
exec idf.py -B build/tanmatsu \
-DDEVICE=tanmatsu \
-DSDKCONFIG_DEFAULTS="sdkconfigs/general;sdkconfigs/tanmatsu;sdkconfigs/wadamesh" \
-DWADA_FW_TAG="$WADA_FW_TAG" -DWADA_FW_DATE="$WADA_FW_DATE" \
-DIDF_TARGET=esp32p4 "$@"
+14 -6
View File
@@ -54,11 +54,19 @@ foreach(_d ${WADA_DEFS})
idf_build_set_property(COMPILE_OPTIONS "-D${_d}" APPEND)
endforeach()
# Firmware version shown on the About page. The Tanmatsu builds outside the S3 release.sh flow (which
# injects these), so set them here or About shows only a generic "development build" with no version.
# Quotes are escaped so the compiler sees string literals. TODO: have build.sh pass the real tag/date
# per release (e.g. from git describe) instead of hardcoding; bump this each beta for now.
idf_build_set_property(COMPILE_OPTIONS "-DFIRMWARE_RELEASE_TAG=\"beta_25\"" APPEND)
idf_build_set_property(COMPILE_OPTIONS "-DFIRMWARE_BUILD_DATE=\"1 Jul 2026\"" APPEND)
# Firmware version shown on the About page + parsed by the update check. build.sh passes
# WADA_FW_TAG/-DATE from git describe + date, so dev builds show the real state
# ("beta_28-14-gabc123") and a release build on the beta_N tag bakes exactly "beta_N" —
# the old hardcoded define left every dev flash claiming the last hand-bumped beta.
# firmwareReleaseN() parses the leading "beta_%d", so the -dev suffix keeps the update
# check on the right baseline. Quotes escaped so the compiler sees string literals.
if(NOT WADA_FW_TAG)
set(WADA_FW_TAG "dev")
endif()
if(NOT WADA_FW_DATE)
set(WADA_FW_DATE "")
endif()
idf_build_set_property(COMPILE_OPTIONS "-DFIRMWARE_RELEASE_TAG=\"${WADA_FW_TAG}\"" APPEND)
idf_build_set_property(COMPILE_OPTIONS "-DFIRMWARE_BUILD_DATE=\"${WADA_FW_DATE}\"" APPEND)
# (the adcAttachPin compat shim is force-included per-component in meshcore + wadamesh_app —
# NOT globally, which would feed the C++ header to FreeRTOS's .S assembly files and break them.)
+1 -1
View File
@@ -20,7 +20,7 @@ HeltecV4Board board;
WRAPPER_CLASS radio_driver(radio, board);
ESP32RTCClock fallback_clock;
AutoDiscoverRTCClock rtc_clock(fallback_clock);
ClockFloorRTC rtc_clock(fallback_clock);
#if ENV_INCLUDE_GPS
#include <helpers/sensors/MicroNMEALocationProvider.h>
+2 -1
View File
@@ -10,6 +10,7 @@ extern "C" void set_boot_phase(int phase);
#include <HeltecV4Board.h>
#include <helpers/radiolib/CustomSX1262Wrapper.h>
#include <helpers/AutoDiscoverRTCClock.h>
#include "../../src/helpers/ClockFloorRTC.h" // monotonic send-timestamp floor (issue #89)
#include <helpers/SensorManager.h>
#include <helpers/sensors/EnvironmentSensorManager.h>
#ifdef DISPLAY_CLASS
@@ -24,7 +25,7 @@ extern "C" void set_boot_phase(int phase);
extern HeltecV4Board board;
extern WRAPPER_CLASS radio_driver;
extern RADIO_CLASS radio; // raw SX1262 — driven directly by the Spectrum analyzer sweep
extern AutoDiscoverRTCClock rtc_clock;
extern ClockFloorRTC rtc_clock;
extern EnvironmentSensorManager sensors;
#ifdef DISPLAY_CLASS
+1 -1
View File
@@ -21,7 +21,7 @@ SPIClass* tdeckSharedSPI() {
}
ESP32RTCClock fallback_clock;
AutoDiscoverRTCClock rtc_clock(fallback_clock);
ClockFloorRTC rtc_clock(fallback_clock);
MicroNMEALocationProvider gps(Serial1, &rtc_clock);
EnvironmentSensorManager sensors(gps);
+2 -1
View File
@@ -6,6 +6,7 @@
#include <helpers/radiolib/CustomSX1262Wrapper.h>
#include <TDeckBoard.h>
#include <helpers/AutoDiscoverRTCClock.h>
#include "../../src/helpers/ClockFloorRTC.h" // monotonic send-timestamp floor (issue #89)
#include <helpers/SensorManager.h>
#ifdef DISPLAY_CLASS
#include <helpers/ui/ST7789LCDDisplay.h>
@@ -17,7 +18,7 @@
extern TDeckBoard board;
extern WRAPPER_CLASS radio_driver;
extern RADIO_CLASS radio; // raw SX1262 — driven directly by the Spectrum analyzer sweep
extern AutoDiscoverRTCClock rtc_clock;
extern ClockFloorRTC rtc_clock;
extern EnvironmentSensorManager sensors;
#ifdef DISPLAY_CLASS
+11
View File
@@ -36,6 +36,17 @@ public:
void idle(); // → standby (RadioLibWrapper::idle)
uint32_t getPacketsRecvErrors() const { return _n_recv_errors; }
// Buffered-receive API parity with RadioLibWrapper — no-op stubs. This bridge
// drains the remote radio its own way; the S3 drain-task feature does not map
// onto it, but shared UI/mesh code calls these unguarded.
void rxQueueEnable(bool) {}
bool rxQueueEnabled() const { return false; }
void rxQueueSuspend(bool) {}
void radioAcquire() {}
void radioRelease() {}
uint32_t getRxEvents() const { return _n_recv + _n_recv_errors; }
uint32_t getRxQueueDrops() const { return 0; }
// --- mesh::Radio contract (Dispatcher.h) ---
void begin() override {} // lifecycle hook; remote radio is RX-ready after init()
int recvRaw(uint8_t* bytes, int sz) override; // drain one RX packet, 0 if none
+1 -1
View File
@@ -10,7 +10,7 @@ DISPLAY_CLASS display; // global LVGL flush target + boot/shutdown
SensorManager sensors; // base (no telemetry yet); EnvironmentSensorManager later
ESP32RTCClock fallback_clock;
AutoDiscoverRTCClock rtc_clock(fallback_clock);
ClockFloorRTC rtc_clock(fallback_clock);
bool radio_init() {
fallback_clock.begin();
+2 -1
View File
@@ -8,10 +8,11 @@
#include <TanmatsuBoard.h>
#include "TanmatsuLoraRadio.h"
#include <helpers/AutoDiscoverRTCClock.h>
#include "../../src/helpers/ClockFloorRTC.h" // monotonic send-timestamp floor (issue #89)
extern TanmatsuBoard board;
extern TanmatsuLoraRadio radio_driver;
extern AutoDiscoverRTCClock rtc_clock;
extern ClockFloorRTC rtc_clock;
#include <TanmatsuDisplay.h>
#include <helpers/SensorManager.h>