Compare commits
No files matched your search
@@ -44,9 +44,13 @@ if os.path.exists(nf):
|
||||
|
||||
# Both touch boards are ESP32-S3, single touch-UI ("gui") role. ota_0 sits at
|
||||
# 0x10000 on both (partitions_t*_touch.csv), so flash-update@0x10000 is correct.
|
||||
# `name` MUST match the official MeshCore device name character-for-character, or Mesh America lists it
|
||||
# as a NEW device instead of folding our firmware into the existing tile (TJ Downes, 2026-06). Canonical
|
||||
# names verified from apps.meshamerica.com/proxy/flasher/config.json. The V4 entry is the Expansion Kit
|
||||
# (Touch) variant only, per Kaj. `slug` still maps to our bin filenames on firmware.wadamesh.com.
|
||||
BOARDS = [
|
||||
{"name": "wadamesh — Heltec V4 TFT", "slug": "heltec-v4-tft"},
|
||||
{"name": "wadamesh — LilyGo T-Deck", "slug": "tdeck"},
|
||||
{"name": "Heltec v4 + Expansion Kit (Touch)", "slug": "heltec-v4-tft"},
|
||||
{"name": "LilyGo T-Deck", "slug": "tdeck"},
|
||||
]
|
||||
|
||||
def device(b):
|
||||
@@ -74,7 +78,16 @@ def device(b):
|
||||
}],
|
||||
}
|
||||
|
||||
# Optional "what's this?" blurb Mesh America shows on a hover tooltip next to our provider badge
|
||||
# (spec section 3, added by TJ 2026-06-30). Plain text only — rendered escaped, so no markup/links.
|
||||
DESCRIPTION = (
|
||||
"WADAMESH is a full touch-screen interface for MeshCore: on-device chat, contacts, a live map "
|
||||
"with offline tiles, and complete radio settings. Same MeshCore protocol and phone-app companion "
|
||||
"as stock MeshCore, just a richer on-device front-end."
|
||||
)
|
||||
|
||||
catalog = {
|
||||
"description": DESCRIPTION,
|
||||
"maker": {"wadamesh": {"name": "wadamesh"}},
|
||||
"device": [device(b) for b in BOARDS],
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"description": "WADAMESH is a full touch-screen interface for MeshCore: on-device chat, contacts, a live map with offline tiles, and complete radio settings. Same MeshCore protocol and phone-app companion as stock MeshCore, just a richer on-device front-end.",
|
||||
"maker": {
|
||||
"wadamesh": {
|
||||
"name": "wadamesh"
|
||||
@@ -8,7 +9,7 @@
|
||||
{
|
||||
"maker": "wadamesh",
|
||||
"class": "wadamesh",
|
||||
"name": "wadamesh — Heltec V4 TFT",
|
||||
"name": "Heltec v4 + Expansion Kit (Touch)",
|
||||
"type": "esp32",
|
||||
"tooltip": "<img class='device' src='https://wadamesh.com/wadamesh-icon-512.png'>",
|
||||
"firmware": [
|
||||
@@ -16,19 +17,19 @@
|
||||
"role": "gui",
|
||||
"title": "Touch UI",
|
||||
"version": {
|
||||
"beta_21": {
|
||||
"notes": "Stable and test update channels. beta_21 is the first stable build. On devices that update over Wi-Fi there is now a \"Get test builds (beta)\" switch on the About screen: leave it off to stay on stable releases, or turn it on to receive test builds earlier — it switches both the \"update available\" check and the firmware you install to the test channel. You can go back to stable at any time.\nMQTT bridge (highly experimental, off by default). An opt-in bridge that forwards the messages your node receives to an MQTT broker of your choice, so you can log your mesh or wire it into other tools. It is gated behind a privacy consent you have to tick, direct messages are excluded unless you deliberately enable them, and the payload can be sealed with an encryption key so only you can read it. Use a broker you control, never a public one. This is experimental and may change.\nPortugal (Narrow) region preset, so Portuguese users can pick the local defaults in one tap. (Issue #74, requested by Salgueiron.)\nSaved-contacts counter. The Contacts overflow menu now shows how many contacts you have saved out of the maximum it can hold (for example 42 / 2000). (Issue #72, asked by Buko84.)\nThe signal probe never travels past the first node now. The \"auto-discover\" signal pulse, and the probe behind the Signal popup, are sent strictly zero-hop, so a repeater can no longer re-flood them across the mesh. The probe only measures the link to whatever hears it first, which is what it was always meant to do.\nYour most relevant contacts now show when you have a lot of them. The on-screen contact list is capped for performance, but it was trimming the list before sorting it, so some contacts could drop out of view. It now sorts first, shows the closest matches, and adds a \"+N more — search to narrow the list\" line at the bottom. (Issue #73, reported by 1nerdherder.)\nThe blocked-users list opened from Contacts now shows the proper two-line title bar with a back button, instead of an unlabelled screen.\nThe Contacts overflow popup fits the screen again after the saved-contacts counter was added.\nEverything from beta_20 is included.",
|
||||
"beta_31": {
|
||||
"notes": "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.\nChannels 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.\nLong chat names no longer wrap onto the message preview underneath.",
|
||||
"files": [
|
||||
{
|
||||
"type": "flash-wipe",
|
||||
"name": "wadamesh-heltec-v4-tft-merged.bin",
|
||||
"url": "https://firmware.wadamesh.com/releases/TOUCH/beta_21/wadamesh-heltec-v4-tft-merged.bin",
|
||||
"url": "https://firmware.wadamesh.com/releases/TOUCH/beta_31/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_21/wadamesh-heltec-v4-tft.bin",
|
||||
"url": "https://firmware.wadamesh.com/releases/TOUCH/beta_31/wadamesh-heltec-v4-tft.bin",
|
||||
"title": "Update (app only)"
|
||||
}
|
||||
]
|
||||
@@ -40,7 +41,7 @@
|
||||
{
|
||||
"maker": "wadamesh",
|
||||
"class": "wadamesh",
|
||||
"name": "wadamesh — LilyGo T-Deck",
|
||||
"name": "LilyGo T-Deck",
|
||||
"type": "esp32",
|
||||
"tooltip": "<img class='device' src='https://wadamesh.com/wadamesh-icon-512.png'>",
|
||||
"firmware": [
|
||||
@@ -48,19 +49,19 @@
|
||||
"role": "gui",
|
||||
"title": "Touch UI",
|
||||
"version": {
|
||||
"beta_21": {
|
||||
"notes": "Stable and test update channels. beta_21 is the first stable build. On devices that update over Wi-Fi there is now a \"Get test builds (beta)\" switch on the About screen: leave it off to stay on stable releases, or turn it on to receive test builds earlier — it switches both the \"update available\" check and the firmware you install to the test channel. You can go back to stable at any time.\nMQTT bridge (highly experimental, off by default). An opt-in bridge that forwards the messages your node receives to an MQTT broker of your choice, so you can log your mesh or wire it into other tools. It is gated behind a privacy consent you have to tick, direct messages are excluded unless you deliberately enable them, and the payload can be sealed with an encryption key so only you can read it. Use a broker you control, never a public one. This is experimental and may change.\nPortugal (Narrow) region preset, so Portuguese users can pick the local defaults in one tap. (Issue #74, requested by Salgueiron.)\nSaved-contacts counter. The Contacts overflow menu now shows how many contacts you have saved out of the maximum it can hold (for example 42 / 2000). (Issue #72, asked by Buko84.)\nThe signal probe never travels past the first node now. The \"auto-discover\" signal pulse, and the probe behind the Signal popup, are sent strictly zero-hop, so a repeater can no longer re-flood them across the mesh. The probe only measures the link to whatever hears it first, which is what it was always meant to do.\nYour most relevant contacts now show when you have a lot of them. The on-screen contact list is capped for performance, but it was trimming the list before sorting it, so some contacts could drop out of view. It now sorts first, shows the closest matches, and adds a \"+N more — search to narrow the list\" line at the bottom. (Issue #73, reported by 1nerdherder.)\nThe blocked-users list opened from Contacts now shows the proper two-line title bar with a back button, instead of an unlabelled screen.\nThe Contacts overflow popup fits the screen again after the saved-contacts counter was added.\nEverything from beta_20 is included.",
|
||||
"beta_31": {
|
||||
"notes": "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.\nChannels 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.\nLong chat names no longer wrap onto the message preview underneath.",
|
||||
"files": [
|
||||
{
|
||||
"type": "flash-wipe",
|
||||
"name": "wadamesh-tdeck-merged.bin",
|
||||
"url": "https://firmware.wadamesh.com/releases/TOUCH/beta_21/wadamesh-tdeck-merged.bin",
|
||||
"url": "https://firmware.wadamesh.com/releases/TOUCH/beta_31/wadamesh-tdeck-merged.bin",
|
||||
"title": "Full install (bootloader + firmware)"
|
||||
},
|
||||
{
|
||||
"type": "flash-update",
|
||||
"name": "wadamesh-tdeck.bin",
|
||||
"url": "https://firmware.wadamesh.com/releases/TOUCH/beta_21/wadamesh-tdeck.bin",
|
||||
"url": "https://firmware.wadamesh.com/releases/TOUCH/beta_31/wadamesh-tdeck.bin",
|
||||
"title": "Update (app only)"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -127,6 +127,7 @@
|
||||
<div class="grp">With your phone</div>
|
||||
<a href="#companion">Companion mode</a>
|
||||
<div class="grp">Reference</div>
|
||||
<a href="#updates">Updates & test builds</a>
|
||||
<a href="#settings-pages">Settings pages</a>
|
||||
<a href="#apps">Apps</a>
|
||||
</nav>
|
||||
@@ -140,6 +141,13 @@
|
||||
<span class="chip"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12.55a11 11 0 0 1 14 0"/><path d="M8.5 16.4a5 5 0 0 1 7 0"/><line x1="12" y1="20" x2="12" y2="20"/></svg> LoRa + Wi-Fi + Bluetooth</span>
|
||||
<span class="chip"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/></svg> Keyboard + touch</span>
|
||||
</div>
|
||||
<div class="callout">
|
||||
<span class="cico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h11l5 5v11a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V4z"/><path d="M9 4v4M13 4v4"/></svg></span>
|
||||
<div>
|
||||
<b>Got a microSD slot? Keep a card inserted.</b>
|
||||
<p>On devices with a microSD slot (the LilyGo T-Deck and Tanmatsu), wadamesh is best used with a card in place. It keeps map tiles, your contacts and your chat history on the card, which is roomier and far more reliable than the small internal flash, so a card prevents memory issues and stops that data being lost on reboot. The Heltec V4 has no card slot and manages fine on its internal storage.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="doc" id="home">
|
||||
@@ -197,6 +205,25 @@
|
||||
</div>
|
||||
<div class="shot"><div class="device"><img class="screen" src="docs-img/map.png" alt="Map"></div><div class="cap">Map</div></div>
|
||||
</div>
|
||||
<div class="prose">
|
||||
<h3>Bring your own tiles (offline maps)</h3>
|
||||
<p>You don't need Wi-Fi to see the map. Copy a standard OpenStreetMap tile pack onto the T-Deck's microSD card and the map reads straight off the card, fully offline.</p>
|
||||
<p><b>Where they go.</b> Use the usual slippy-map <code>z/x/y</code> folder layout, in either of these locations on the card:</p>
|
||||
<ul>
|
||||
<li><code>/maps/osm/<z>/<x>/<y>.png</code> — the Meshtastic / MeshCore standard layout (recommended, so packs are interchangeable between apps).</li>
|
||||
<li><code>/tiles/<z>/<x>/<y>.png</code> — also read. This is the same folder the Wi-Fi download cache uses, so your own tiles and downloaded ones live side by side.</li>
|
||||
</ul>
|
||||
<p><b>Format.</b> 256×256 <code>.png</code> tiles (<code>.jpg</code> also works). <code>z</code> is the zoom level and <code>x</code>/<code>y</code> the tile column/row at that zoom — the same scheme OSM's own tile server and every XYZ tile downloader use, so most ready-made packs drop in unchanged.</p>
|
||||
<p><b>Turn it on.</b> Open the map's options (the gear button on the map) and switch on <b>Tiles from SD card</b>; the status line confirms <code>Map tiles: microSD</code>. Turn it back off to fetch live tiles over Wi-Fi again.</p>
|
||||
<p><b>Making a pack.</b> Any XYZ / slippy-map tile downloader works — point it at OpenStreetMap for your area and a sensible zoom range, then copy the resulting <code>z/x/y</code> folders onto the card. Each extra zoom level roughly quadruples the tile count, so grab only the levels you'll actually use.</p>
|
||||
<div class="callout">
|
||||
<span class="cico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg></span>
|
||||
<div>
|
||||
<b>Board differences</b>
|
||||
<p>The "Tiles from SD card" toggle is a T-Deck feature. The Heltec V4 has no microSD slot, so it caches downloaded tiles to a small internal partition (no offline packs there). On the Tanmatsu, tiles — your own or downloaded — live at <code>/tiles/<z>/<x>/<y>.jpg</code> on the microSD card; drop JPEG tiles there and they show alongside the downloaded ones.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="doc" id="settings">
|
||||
@@ -239,6 +266,24 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="doc" id="updates">
|
||||
<h2><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M23 4v6h-6"/><path d="M1 20v-6h6"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg></span> Updates & test builds</h2>
|
||||
<div class="row">
|
||||
<div class="prose">
|
||||
<p>On the boards that update over Wi-Fi (the Heltec V4 and the standalone T-Deck), WADAMESH checks for new firmware on its own. When one is available, <b>Settings → About</b> shows it and you can flash it straight from the device with <b>Install update</b>, no computer needed. (Under Launcher and on the Tanmatsu you update through Launcher or the app store instead.)</p>
|
||||
<h3>Two channels: Stable and Test</h3>
|
||||
<ul>
|
||||
<li><b>Stable</b> is the default. It is the build that has been tested and is recommended for everyday use. Leave your device on this unless you want to help test.</li>
|
||||
<li><b>Test (beta)</b> gets new features and fixes earlier, before they are proven. It can be rough, so it is meant for trying things early and reporting bugs, not for a device you rely on.</li>
|
||||
</ul>
|
||||
<h3>Turn on test builds</h3>
|
||||
<p>Flip <b>Settings → About → "Get test builds (beta)"</b> on. From then on, both the update check and <b>Install update</b> follow the test channel, so the device offers the newest test build over Wi-Fi. Turn the switch back off to return to stable. You can switch either way at any time.</p>
|
||||
<p>Prefer to start on a test build from scratch? Pick <b>Beta · testing</b> when you flash from <a href="https://flasher.wadamesh.com" target="_blank" rel="noopener">flasher.wadamesh.com</a>.</p>
|
||||
</div>
|
||||
<div class="shot"><div class="device"><img class="screen" src="docs-img/set_about.png" alt="About page"></div><div class="cap">The About page</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="doc" id="settings-pages">
|
||||
<h2><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="20" y2="18"/></svg></span> Settings pages</h2>
|
||||
<div class="prose" style="max-width:68ch"><p>WADAMESH splits its settings into eighteen pages. Here is what each one does and the controls you will find on it. Open any page from the Settings tab; a back chevron in the title bar returns you to the grid.</p></div>
|
||||
@@ -438,6 +483,7 @@
|
||||
<div class="lede">Version, updates and diagnostics.</div>
|
||||
<ul>
|
||||
<li><b>Firmware version</b> plus update check / install</li>
|
||||
<li><b>Get test builds (beta)</b> switch: opt into the <a href="#updates">test channel</a></li>
|
||||
<li>Full <b>system diagnostics</b> — uptime, chip, memory, flash, storage</li>
|
||||
<li><b>Device ID</b> and crash-report export</li>
|
||||
</ul>
|
||||
|
||||
+25
-1
@@ -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">×</button>
|
||||
</div>
|
||||
<div id="vanta-bg"></div>
|
||||
|
||||
<!-- floating quick links -->
|
||||
@@ -321,6 +334,7 @@
|
||||
<button type="button" data-wm-ch="stable" style="font:inherit;font-size:.85rem;font-weight:600;padding:7px 16px;border:0;background:#fff;color:var(--mut);cursor:pointer">Stable</button>
|
||||
<button type="button" data-wm-ch="beta" style="font:inherit;font-size:.85rem;font-weight:600;padding:7px 16px;border:0;border-left:1px solid var(--line);background:#fff;color:var(--mut);cursor:pointer">Beta · testing</button>
|
||||
</div>
|
||||
<p style="margin:12px auto 0;max-width:600px;text-align:left;font-size:.88rem;line-height:1.6"><b>Stable</b> is the tested default. Pick it unless you want to help test. <b>Beta · testing</b> gets new features and fixes earlier, before they are proven, so it can be rough. It is for trying things early and reporting bugs.<br>Already running WADAMESH? You do not have to reflash to switch channels: turn on <b>Settings → About → "Get test builds (beta)"</b> on the device and it updates to test builds over Wi-Fi. Switch it off to go back to stable.</p>
|
||||
<p data-wm-betahint hidden style="margin:8px auto 0;font-size:.84rem;color:#b4631a;max-width:560px">Beta builds are newer and less tested — for trying things early and reporting bugs. On a T-Deck under Launcher you download the .bin and side-load it.</p>
|
||||
<div data-whatsnew hidden style="margin:18px auto 0;max-width:560px;background:rgba(21,182,166,.07);border:1px solid rgba(21,182,166,.28);border-radius:12px;padding:13px 16px;text-align:left">
|
||||
<div style="font-weight:600">What's new in <span data-wn-tag style="color:var(--teal)">the latest build</span></div>
|
||||
@@ -479,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).
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# beta_23 (TEST build — one user-facing note per non-blank, non-# line; # lines are section comments)
|
||||
|
||||
# --- Fixed ---
|
||||
Bluetooth companion reliability, especially messages and repeater admin. When a phone or Mac was connected over Bluetooth, the device was sending it a log entry for every LoRa packet it heard — on a busy mesh that swamped the Bluetooth link and starved the frames that actually matter. The device no longer sends that packet log over Bluetooth (USB and Wi-Fi companions still get it), so incoming messages reach the app and repeater / room-server admin (Manage Regions, saving parameters) stops timing out and dropping the connection. (Issues #46 and #54.)
|
||||
|
||||
# --- Also in this test line (carried from beta_22) ---
|
||||
The signal probe reads a real signal again: it sends a directed trace "ping" to your nearest repeater, which replies, instead of a zero-hop advert that nothing answers. Thanks to Tarmo for spotting it.
|
||||
Contacts discovered while your phone is connected now also show on the device's own screen, not just in the phone app. (Issue #73.)
|
||||
|
||||
# --- Also ---
|
||||
This is a test-channel build (opt in with "Get test builds" on the About screen). Everything from the stable beta_21 is included.
|
||||
@@ -0,0 +1,7 @@
|
||||
# beta_24 (TEST build — one user-facing note per non-blank, non-# line; # lines are section comments)
|
||||
|
||||
# --- Fixed ---
|
||||
The signal probe now reads a real signal to your nearest repeater. It sends the standard mesh node-discovery request that repeaters actually answer, instead of the trace packet from the last test build that they ignored, so the signal reading updates again. This is the same request the Ultra and KiekR apps use. Thanks to Tarmo for decoding the exact packet.
|
||||
|
||||
# --- Also ---
|
||||
This is a test-channel build (opt in with "Get test builds" on the About screen). Everything from the stable beta_21 and the earlier test builds is included.
|
||||
@@ -0,0 +1,21 @@
|
||||
# beta_25 (STABLE — rolls up the beta_22..25 test builds on top of the previous stable, beta_21.
|
||||
# One user-facing note per non-blank, non-# line; # lines are section comments.)
|
||||
|
||||
# --- Fixed ---
|
||||
The signal probe reads a live signal to your nearest repeater again. It sends the standard mesh node-discovery request that repeaters actually answer (the same one the Ultra and KiekR apps use), so the signal reading updates. Thanks to Tarmo for decoding the exact packet.
|
||||
Bluetooth companion reliability, especially messages and repeater / room-server admin. The device no longer floods the Bluetooth link with a per-packet log (USB and Wi-Fi companions still get it), so incoming messages reach the app and admin actions like Manage Regions stop timing out. (Issues #46 and #54.)
|
||||
Contacts show up right away. A node you discover, even while you're on another screen or one the phone app adds over Bluetooth, now appears in your contact list immediately instead of only after you change the sort. Thanks to 1nerdherder for the report. (Issue #73.)
|
||||
Manual telemetry and status requests now work on the first tap instead of needing a second. Thanks to Dan Vybiral (@Vybo) for the fix. (Issue #81.)
|
||||
On the emoji picker and quick-chat replies, pressing Esc or the red X now closes just the popup instead of the whole chat screen.
|
||||
Lock screen: you can now unlock the device after the screen wakes from an incoming message.
|
||||
|
||||
# --- New ---
|
||||
New "Advertise on boot" option in the Advertise app (off by default): turn it on to send one advert when the device powers on, so nearby nodes refresh your details after a reflash. (Issue #76.)
|
||||
|
||||
# --- Tanmatsu ---
|
||||
Tanmatsu: Wi-Fi map tiles now download and display, cached to the microSD card.
|
||||
Tanmatsu: contacts and message history now survive a reboot (moved to the microSD card, so keep a card inserted).
|
||||
Tanmatsu: fixed the crash that stopped the app from booting; popups now fit the screen and scroll, and keyboard navigation stays inside an open popup.
|
||||
|
||||
# --- Also ---
|
||||
This is the new stable build, promoted from the beta_25 test channel. It includes everything from the beta_22, beta_23, beta_24 and beta_25 test builds.
|
||||
@@ -0,0 +1,8 @@
|
||||
# beta_26 (TEST) — storage-reliability fix on top of the current stable (beta_25).
|
||||
# One user-facing note per non-blank, non-# line; # lines are section comments.
|
||||
|
||||
# --- Fixed ---
|
||||
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.
|
||||
|
||||
# --- Improved ---
|
||||
On devices with a microSD card, contacts and channels are now kept on the card (like chat history and map tiles already are). That frees the small internal flash and puts the frequently-rewritten files on the more reliable card. Your existing contacts move to the card automatically the first time; your identity and settings stay in internal flash, so the node is unchanged.
|
||||
@@ -0,0 +1,15 @@
|
||||
# beta_27 (STABLE — rolls up the beta_26 + beta_27 test builds on top of the previous stable, beta_25.
|
||||
# One user-facing note per non-blank, non-# line; # lines are section comments.)
|
||||
|
||||
# --- Fixed ---
|
||||
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.
|
||||
Devices 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.
|
||||
|
||||
# --- New ---
|
||||
Keyboard 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).
|
||||
|
||||
# --- Improved ---
|
||||
On 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.
|
||||
|
||||
# --- Also ---
|
||||
This is the new stable build, promoted from the beta_27 test channel. It includes everything from the beta_26 and beta_27 test builds.
|
||||
@@ -0,0 +1,13 @@
|
||||
# beta_28 (TEST) — room-server session fix + the wyvern.red feedback batch, on top of stable beta_27.
|
||||
# One user-facing note per non-blank, non-# line; # lines are section comments.
|
||||
|
||||
# --- Fixed ---
|
||||
Room servers no longer go one-way "frozen". The device now keeps its room login session alive in the background and automatically logs back in when the session drops, so posts keep being delivered and new room messages keep arriving. There is also a new "Log in again" option in the room chat's menu, and a clear warning when the room only granted you read-only access (posts would previously just vanish). Thanks to EricMW61 for the report (issue #89).
|
||||
The screen now truly switches off in standby. Before, only the backlight went dark while the panel kept displaying the last image - the actual cause of the LCD ghosting / burn-in some of you noticed. The panel itself now sleeps, and on wake your screen is back instantly. Also: a lit lock screen now always dims on every board, a screen woken by a message re-darkens after 10 seconds, and the lock-screen clock drifts a few pixels to avoid ghosting. Thanks to wyvern.red for pinning this down.
|
||||
The on-device "Install update" now also hides on mixed or older partition layouts where an update could crash the device halfway through (found via a user crash report - thanks!).
|
||||
|
||||
# --- New ---
|
||||
Compact messages (IRC style): a new toggle in chat settings shows one dense line per message instead of bubbles, fitting two to three times more history on screen. Off by default. (wyvern.red's suggestion.)
|
||||
Clearer delivery ticks: an up-arrow means sent but not yet confirmed, a double check means the other side confirmed it, and a failed message now says "tap to resend" - one tap resends it. (wyvern.red)
|
||||
Keyboard navigation: the scroll keys (F/C) now zoom the map, and backspace in an empty message box exits typing mode so the navigation keys work again. (wyvern.red)
|
||||
"Reset path" is now also in the chat's own menu next to Blocked users, so you can force a fresh route without going through Contacts.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
+101
-17
@@ -3,6 +3,7 @@
|
||||
#include "DataStore.h"
|
||||
#if defined(ESP32)
|
||||
#include <SD.h>
|
||||
#include "helpers/esp32/WdtHeavyGuard.h" // suspend core-0 idle WDT during the (SPIFFS-GC-prone) contact write
|
||||
#endif
|
||||
|
||||
#if defined(EXTRAFS) || defined(QSPIFLASH)
|
||||
@@ -70,6 +71,38 @@ void DataStore::begin() {
|
||||
#else
|
||||
// init 'blob store' support
|
||||
_fs->mkdir("/bl");
|
||||
#if defined(ESP32)
|
||||
// One-time migration: when a secondary FS is active (an SD card is present)
|
||||
// but contacts/channels still live on the primary FS from an earlier build,
|
||||
// copy them onto the card ONCE and delete the primary copies. This preserves
|
||||
// the user's contact list while getting the frequently-rewritten files off a
|
||||
// small/near-full SPIFFS — whose garbage collection can otherwise stall the
|
||||
// loop task for seconds during saveContacts and trip the task watchdog
|
||||
// (the beta_25 reboot loop). Identity + prefs stay on _fs and never move.
|
||||
// No-op once the card already holds the files, and a safe no-op on boards
|
||||
// whose primary-FS metadata can't confirm the source exists (e.g. P4 FFat).
|
||||
if (_fsExtra) {
|
||||
static const char* const kMoveFiles[] = { "/contacts3", "/channels2" };
|
||||
for (const char* nm : kMoveFiles) {
|
||||
if (_fsExtra->exists(nm)) continue; // already on the card
|
||||
if (!_fs->exists(nm)) continue; // nothing to move
|
||||
File in = openRead(_fs, nm);
|
||||
File out = openWrite(_fsExtra, nm);
|
||||
bool ok = in && out;
|
||||
if (ok) {
|
||||
uint8_t buf[256];
|
||||
int n;
|
||||
while ((n = in.read(buf, sizeof(buf))) > 0) {
|
||||
if (out.write(buf, (size_t)n) != (size_t)n) { ok = false; break; }
|
||||
}
|
||||
}
|
||||
if (in) in.close();
|
||||
if (out) out.close();
|
||||
if (ok) _fs->remove(nm); // reclaim SPIFFS space
|
||||
else if (_fsExtra->exists(nm)) _fsExtra->remove(nm); // don't leave a partial copy
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -382,33 +415,84 @@ File file = openRead(_getContactsChannelsFS(), "/contacts3");
|
||||
}
|
||||
|
||||
void DataStore::saveContacts(DataStoreHost* host, bool (*filter)(const ContactInfo& c)) {
|
||||
#if defined(ESP32)
|
||||
// A full/fragmenting contacts write on SPIFFS can trigger a multi-second GC
|
||||
// pass that disables the flash cache and starves core 0's idle task, tripping
|
||||
// the task watchdog (the beta_25 reboot loop, confirmed by coredump: loopTask
|
||||
// stuck in spiffs_gc_clean under this exact call). Suspend that watchdog for
|
||||
// the write so a bounded-but-slow pass stalls the UI instead of rebooting. The
|
||||
// ref-count is shared with the UI history/backup guards, so a backup import
|
||||
// that calls saveContacts stays balanced. On SD-routed devices this is cheap
|
||||
// (FAT has no such GC); it matters most for card-less SPIFFS devices.
|
||||
WdtHeavyGuard _wdt;
|
||||
#endif
|
||||
File file = openWrite(_getContactsChannelsFS(), "/contacts3");
|
||||
if (file) {
|
||||
uint32_t idx = 0;
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -45,6 +45,10 @@ public:
|
||||
#endif
|
||||
FILESYSTEM* getPrimaryFS() const { return _fs; }
|
||||
FILESYSTEM* getSecondaryFS() const { return _fsExtra; }
|
||||
// Route contacts + channels (see _getContactsChannelsFS) to a different FS than identity/prefs.
|
||||
// Used on the Tanmatsu to keep identity/prefs on the (working) internal FFat while putting the
|
||||
// frequently-rewritten contacts/channels on the reliable SD card. Call before begin().
|
||||
void setSecondaryFS(FILESYSTEM* fs) { _fsExtra = fs; }
|
||||
bool loadMainIdentity(mesh::LocalIdentity &identity);
|
||||
bool saveMainIdentity(const mesh::LocalIdentity &identity);
|
||||
void loadPrefs(NodePrefs& prefs, double& node_lat, double& node_lon);
|
||||
|
||||
+85
-16
@@ -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
|
||||
@@ -135,6 +137,15 @@ static int s_companion_ota_pinned_reply_target = -1;
|
||||
#define DIRECT_SEND_PERHOP_FACTOR 6.0f
|
||||
#define DIRECT_SEND_PERHOP_EXTRA_MILLIS 250
|
||||
#define LAZY_CONTACTS_WRITE_DELAY 5000
|
||||
// Our self-chosen room-session keep-alive interval (secs). Room servers zero the
|
||||
// legacy suggested-interval field in LOGIN_OK, so the client picks. 128 is the
|
||||
// value upstream itself recommended while the field was live (CLIENT_KEEP_ALIVE_SECS
|
||||
// 128, disabled 2025-06 rather than tuned) — matching it stays friendly to the
|
||||
// server's TODO'd "throttle keep-alives, evict fast pingers" heuristic. The core
|
||||
// expires the connection at 2.5x (320 s) without an ACK; every received room push
|
||||
// also counts as activity (markConnectionActive), so a busy room barely pings at
|
||||
// all. One 9-byte direct REQ + 5-byte ACK per interval — negligible airtime.
|
||||
#define ROOM_KEEPALIVE_SECS 128
|
||||
|
||||
#define PUBLIC_GROUP_PSK "izOH6cXN6mrJ5e26oRXNcg=="
|
||||
|
||||
@@ -1475,7 +1486,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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2200,6 +2218,26 @@ void MyMesh::onContactResponse(const ContactInfo &contact, const uint8_t *data,
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Deferred guest-login-then-request (uiSendRequestAfterGuestLogin): the touch
|
||||
// UI sent only a blank-password LOGIN and is waiting on the LOGIN-OK before
|
||||
// issuing the STATUS/TELEMETRY REQ. While armed we have sent no REQ to this
|
||||
// contact, so any RESPONSE from it here is the login reply. On OK, fire the
|
||||
// deferred REQ now — we're in the repeater's ACL and a direct path has been
|
||||
// learned, so it lands first try. On a non-OK (fail) reply, just disarm and
|
||||
// let the UI's reply deadline flip the window to "failed".
|
||||
if (_ui_login_then && len > 4 && memcmp(&_ui_login_then, contact.id.pub_key, 4) == 0) {
|
||||
const bool login_ok = (data[4] == RESP_SERVER_LOGIN_OK)
|
||||
|| (len > 5 && memcmp(&data[4], "OK", 2) == 0);
|
||||
const UiReqKind k = _ui_login_then_kind;
|
||||
cancelUIDeferredLogin();
|
||||
if (login_ok) {
|
||||
ContactInfo& rc = const_cast<ContactInfo&>(contact);
|
||||
if (k == UiReqKind::Telemetry) sendTelemetryRequestForUI(rc);
|
||||
else sendStatusPingForUI(rc);
|
||||
}
|
||||
return; // login frame consumed (OK fired the REQ; fail disarmed)
|
||||
}
|
||||
#endif
|
||||
|
||||
if (pending_login && memcmp(&pending_login, contact.id.pub_key, 4) == 0) { // check for login response
|
||||
@@ -2219,6 +2257,16 @@ void MyMesh::onContactResponse(const ContactInfo &contact, const uint8_t *data,
|
||||
uint16_t keep_alive_secs = ((uint16_t)data[5]) * 16;
|
||||
if (keep_alive_secs > 0) {
|
||||
startConnection(contact, keep_alive_secs);
|
||||
} else if (contact.type == ADV_TYPE_ROOM) {
|
||||
// Modern room servers zero the legacy keep-alive field (simple_room_server:
|
||||
// `reply_data[5] = 0; // Legacy`), so no connection was ever armed and we
|
||||
// never pinged. The server still NEEDS to hear from us: its push loop
|
||||
// abandons a client after 3 unACKed pushes (`push_failures < 3`) and only a
|
||||
// received transmission — a post, or exactly this REQ_TYPE_KEEP_ALIVE —
|
||||
// resets the counter (issue #89: rooms went one-way-dead). Arm our own
|
||||
// interval; checkConnections() in loop() does the rest (9-byte direct REQ,
|
||||
// its ACK also refreshes the server's last_activity for us).
|
||||
startConnection(contact, ROOM_KEEPALIVE_SECS);
|
||||
}
|
||||
out_frame[i++] = PUSH_CODE_LOGIN_SUCCESS;
|
||||
out_frame[i++] = data[6]; // permissions (eg. is_admin)
|
||||
@@ -2246,6 +2294,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
|
||||
@@ -2322,6 +2374,21 @@ bool MyMesh::onContactPathRecv(ContactInfo& contact, uint8_t* in_path, uint8_t i
|
||||
}
|
||||
|
||||
void MyMesh::onControlDataRecv(mesh::Packet *packet) {
|
||||
// Signal probe: a neighbouring repeater answered our zero-hop NODE_DISCOVER_REQ with a
|
||||
// NODE_DISCOVER_RESP. Capture OUR reception of that reply (SNR + RSSI) as the live
|
||||
// signal, matched by tag. This is the standard MeshCore node-discovery, the same packet
|
||||
// the Ultra / KiekR GUIs use (a TRACE gets no reply). Additive: the control data is still
|
||||
// relayed to a connected companion app below. The tag is kept, not cleared, so a slower
|
||||
// repeater's reply still registers (a random 32-bit tag makes a stale match negligible).
|
||||
if (packet->payload_len >= 6 && (packet->payload[0] & 0xF0) == CTL_TYPE_NODE_DISCOVER_RESP
|
||||
&& _ui_sig_probe_tag != 0) {
|
||||
uint32_t rtag; memcpy(&rtag, &packet->payload[2], 4);
|
||||
if (rtag == _ui_sig_probe_tag) {
|
||||
_ui_sig_snr_q4 = (int8_t)(packet->getSNR() * 4.0f);
|
||||
_ui_sig_rssi = (int8_t)_radio->getLastRSSI();
|
||||
_ui_sig_ms = millis();
|
||||
}
|
||||
}
|
||||
if (packet->payload_len + 4 > sizeof(out_frame)) {
|
||||
MESH_DEBUG_PRINTLN("onControlDataRecv(), payload_len too long: %d", packet->payload_len);
|
||||
return;
|
||||
@@ -2364,18 +2431,8 @@ void MyMesh::onRawDataRecv(mesh::Packet *packet) {
|
||||
void MyMesh::onTraceRecv(mesh::Packet *packet, uint32_t tag, uint32_t auth_code, uint8_t flags,
|
||||
const uint8_t *path_snrs, const uint8_t *path_hashes, uint8_t path_len) {
|
||||
uint8_t path_sz = flags & 0x03; // NEW v1.11+
|
||||
// Silent SIGNAL PROBE result: a directed trace we sent to a neighbouring repeater
|
||||
// has come back (it retransmitted; we heard it). Capture OUR reception of that
|
||||
// reply — SNR + RSSI — as the live signal, and swallow it (no popup). This is the
|
||||
// non-flooding replacement for the old flood-advert probe, and must run before the
|
||||
// visible-ping path below.
|
||||
if (tag != 0 && tag == _ui_sig_probe_tag) {
|
||||
_ui_sig_probe_tag = 0;
|
||||
_ui_sig_snr_q4 = (int8_t)(packet->getSNR() * 4.0f);
|
||||
_ui_sig_rssi = (int8_t)_radio->getLastRSSI();
|
||||
_ui_sig_ms = millis();
|
||||
return;
|
||||
}
|
||||
// (The signal probe used to be captured here as a directed trace; it is now the
|
||||
// standard node-discovery control packet, captured in onControlDataRecv instead.)
|
||||
#if defined(DISPLAY_CLASS)
|
||||
// If this trace's tag matches the most recent UI-initiated ping, surface
|
||||
// the bidirectional SNRs directly to the touch UI instead of (or in
|
||||
@@ -2931,12 +2988,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.
|
||||
@@ -4343,6 +4401,17 @@ void MyMesh::checkSerialInterface() {
|
||||
void MyMesh::loop() {
|
||||
BaseChatMesh::loop();
|
||||
|
||||
// Session keep-alives for logged-in servers (rooms). The core pinger sends the
|
||||
// 9-byte REQ_TYPE_KEEP_ALIVE (+ our sync_since) a room server expects; the ACK
|
||||
// back refreshes the server's last_activity for us AND resets its push_failures
|
||||
// counter — without it the server abandons a client after any 3 unacknowledged
|
||||
// post pushes and never pushes again (simple_room_server: `push_failures < 3`
|
||||
// gate in its round-robin push loop), which reads as a one-way-frozen room
|
||||
// (issue #89). Upstream left this call commented out ("deprecate the
|
||||
// Connections stuff"); the on-device room UI needs it. Cheap: scans 16 slots,
|
||||
// transmits only when a connection is armed and due (see the room login branch).
|
||||
checkConnections();
|
||||
|
||||
if (_cli_rescue) {
|
||||
checkCLIRescueCmd();
|
||||
} else {
|
||||
|
||||
+93
-41
@@ -6,6 +6,12 @@
|
||||
#include <helpers/MeshTouchTxTrace.h>
|
||||
#include "AbstractUITask.h"
|
||||
|
||||
// Node-discovery control packet (standard MeshCore — matches examples/simple_repeater
|
||||
// + simple_sensor): a zero-hop CTL_TYPE_NODE_DISCOVER_REQ that neighbouring repeaters
|
||||
// answer DIRECTLY with a NODE_DISCOVER_RESP. Used by the touch signal probe.
|
||||
#define CTL_TYPE_NODE_DISCOVER_REQ 0x80 // upper nibble = type; low bit = prefix_only
|
||||
#define CTL_TYPE_NODE_DISCOVER_RESP 0x90
|
||||
|
||||
// Operational wire-debug. The companion link is a BINARY protocol over Serial (USB-CDC); writing raw
|
||||
// text to Serial during a session interleaves into that stream and corrupts it — the MeshCore app
|
||||
// then throws "Bad state: Streamsink is bound to a stream" and the connect / room login / repeater
|
||||
@@ -327,6 +333,31 @@ public:
|
||||
return sendTelemetryRequestForUI(recipient);
|
||||
}
|
||||
|
||||
/** Touch-UI manual STATUS/TELEMETRY request that DEFERS the REQ until the
|
||||
* guest LOGIN is acknowledged. The chained helpers above fire LOGIN and REQ
|
||||
* back-to-back, but a repeater drops a PAYLOAD_TYPE_REQ from a sender it
|
||||
* hasn't ACL'd yet — and on first contact the ACL entry isn't committed by
|
||||
* the time the REQ is processed, so the first request usually gets no reply
|
||||
* (the user had to tap twice). This sends ONLY the blank-password LOGIN now
|
||||
* and arms _ui_login_then; onContactResponse issues the REQ once the
|
||||
* LOGIN-OK lands, by which point we're in the ACL and a direct out_path has
|
||||
* been learned, so the REQ decrypts on the first try.
|
||||
* Returns the LOGIN's MSG_SEND_* result — the UI shows "requesting…" on a
|
||||
* successful send and arms its own reply deadline. Manual paths only;
|
||||
* auto-poll keeps the immediate chained send above (a single arm slot can't
|
||||
* serve its multi-node loop, and a missed poll just retries next interval). */
|
||||
int uiSendRequestAfterGuestLogin(ContactInfo& recipient, UiReqKind kind) {
|
||||
uint32_t login_est = 0;
|
||||
int r = sendLogin(recipient, "", login_est);
|
||||
if (r == MSG_SEND_SENT_FLOOD || r == MSG_SEND_SENT_DIRECT) {
|
||||
memcpy(&_ui_login_then, recipient.id.pub_key, 4);
|
||||
_ui_login_then_kind = kind;
|
||||
} else {
|
||||
cancelUIDeferredLogin();
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
/** Admin login for the touch UI repeater admin console.
|
||||
* Sends a sendLogin with the given password (empty = guest), records
|
||||
* pending_login so onContactResponse's existing login branch can route
|
||||
@@ -349,6 +380,29 @@ public:
|
||||
return r;
|
||||
}
|
||||
|
||||
/** Is a keep-alive session to this server currently armed and alive?
|
||||
* (Room chat UI: the core frees the slot 2.5x the interval after the last
|
||||
* ACK/activity, so false = the session needs a re-login.) */
|
||||
bool uiHasConnectionTo(const uint8_t pub_key[32]) { return hasConnectionTo(pub_key); }
|
||||
|
||||
/** Re-login to a room server WITHOUT a password. For a client already in the
|
||||
* room's ACL a blank login replies LOGIN_OK with the stored permissions and
|
||||
* skips the login replay guard (simple_room_server onAnonDataRecv, blank-
|
||||
* password branch) — and when it arrives flooded the server also resets its
|
||||
* stale out_path to us. The blank login itself resets NOTHING else server-side
|
||||
* (not push_failures, not last_activity); the actual heal is the chain it
|
||||
* starts: LOGIN_OK -> onContactResponse arms the room keep-alive -> the first
|
||||
* REQ_TYPE_KEEP_ALIVE refreshes the server's last_activity and clears its
|
||||
* push-abandon counter, so pushes resume (issue #89: self-heal + the chat
|
||||
* sheet's "Log in again"). NOTE it cannot recover a server that REBOOTED
|
||||
* (non-admin ACL entries aren't persisted there) — that needs a passworded
|
||||
* Join from the Contacts sheet. */
|
||||
int uiRoomRelogin(const uint8_t pub_key[32]) {
|
||||
ContactInfo* c = lookupContactByPubKey(pub_key, PUB_KEY_SIZE);
|
||||
if (!c || c->type != ADV_TYPE_ROOM) return MSG_SEND_FAILED;
|
||||
return uiSendAdminLogin(*c, "");
|
||||
}
|
||||
|
||||
/** Send a CLI command line to a previously-logged-in repeater / room
|
||||
* server. The reply lands in AbstractUITask::onAdminCommandReply via
|
||||
* queueMessage when the server returns a TXT_TYPE_CLI_DATA frame.
|
||||
@@ -365,6 +419,13 @@ public:
|
||||
_ui_pending_kind = UiReqKind::None;
|
||||
_ui_pending_tag = 0;
|
||||
}
|
||||
/** Disarm a deferred guest-login-then-request (see uiSendRequestAfterGuestLogin)
|
||||
* so a late LOGIN-OK can't fire a REQ after the UI gave up. Kept separate from
|
||||
* cancelUIPingPending() so a ping timeout never disarms a telemetry request. */
|
||||
void cancelUIDeferredLogin() {
|
||||
_ui_login_then = 0;
|
||||
_ui_login_then_kind = UiReqKind::None;
|
||||
}
|
||||
/** True if a UI ping is still waiting on a reply. */
|
||||
bool hasUIPingPending() const { return _ui_pending_status != 0; }
|
||||
|
||||
@@ -526,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]++;
|
||||
@@ -541,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. */
|
||||
@@ -623,46 +688,27 @@ public:
|
||||
return tag;
|
||||
}
|
||||
|
||||
/** SIGNAL PROBE (the non-flooding way): trace-ping the best-reachable repeater so
|
||||
* it retransmits — a real reply we can measure — WITHOUT flooding the mesh. A
|
||||
* plain zero-hop advert gets no reply (nothing repeats it), so the signal never
|
||||
* updates; a directed trace to a neighbour repeater does (it appends its RX-SNR
|
||||
* and retransmits, which we hear). Picks the repeater with the shortest known
|
||||
* path (0 = direct neighbour), breaking ties by most-recently-heard. The reply
|
||||
* is captured silently in onTraceRecv via _ui_sig_probe_tag → _ui_sig_*. Returns
|
||||
* the tag, or 0 if no repeater with a known path is available (the caller can
|
||||
* fall back to a zero-hop advert). */
|
||||
/** SIGNAL PROBE (the standard MeshCore node-discovery): broadcast a zero-hop
|
||||
* NODE_DISCOVER_REQ control packet asking repeaters to answer. Each neighbouring
|
||||
* repeater replies DIRECTLY with a NODE_DISCOVER_RESP — this is the exact packet the
|
||||
* Ultra / KiekR GUIs use, and what repeater firmware actually answers. (A TRACE or a
|
||||
* bare zero-hop advert gets no reply: repeaters don't retransmit an unpathed trace,
|
||||
* so the old trace-probe read nothing — thanks to Tarmo for decoding the real packet.)
|
||||
* onControlDataRecv matches _ui_sig_probe_tag on the reply and captures its SNR/RSSI
|
||||
* into _ui_sig_*. Never floods (zero-hop, not forwarded). Returns the tag. */
|
||||
uint32_t uiSendSignalProbe() {
|
||||
ContactInfo best; bool have = false; ContactInfo c;
|
||||
const int n = getNumContacts();
|
||||
for (int i = 0; i < n; i++) {
|
||||
if (!getContactByIdx((uint32_t)i, c)) continue;
|
||||
if (c.type != ADV_TYPE_REPEATER) continue; // only repeaters retransmit a trace
|
||||
if (c.out_path_len == OUT_PATH_UNKNOWN) continue; // no known path → would need a flood
|
||||
if (c.out_path_len > MAX_PATH_SIZE) continue; // guard a corrupt length
|
||||
if (!have) { best = c; have = true; continue; }
|
||||
if (c.out_path_len < best.out_path_len ||
|
||||
(c.out_path_len == best.out_path_len &&
|
||||
c.last_advert_timestamp > best.last_advert_timestamp)) {
|
||||
best = c;
|
||||
}
|
||||
}
|
||||
if (!have) return 0;
|
||||
uint32_t tag = 0;
|
||||
getRNG()->random((uint8_t*)&tag, sizeof(tag));
|
||||
if (tag == 0) tag = 1;
|
||||
uint8_t flags = (uint8_t)(_prefs.path_hash_mode & 0x03);
|
||||
mesh::Packet* pkt = createTrace(tag, 0, flags);
|
||||
if (!pkt) return 0;
|
||||
_ui_sig_probe_tag = tag;
|
||||
if (best.out_path_len > 0 && best.out_path_len <= MAX_PATH_SIZE) {
|
||||
sendDirect(pkt, best.out_path, best.out_path_len); // directed along the known path
|
||||
} else {
|
||||
uint8_t hash_sz = (uint8_t)(_prefs.path_hash_mode + 1);
|
||||
if (hash_sz == 0 || hash_sz > 4) hash_sz = 1;
|
||||
sendDirect(pkt, best.id.pub_key, hash_sz); // direct neighbour → single hop
|
||||
}
|
||||
return tag;
|
||||
uint8_t data[10];
|
||||
data[0] = CTL_TYPE_NODE_DISCOVER_REQ; // 0x80; low bit (prefix_only) stays 0
|
||||
data[1] = (uint8_t)(1 << ADV_TYPE_REPEATER); // filter: ask repeaters to answer
|
||||
getRNG()->random(&data[2], 4); // random tag, to match the responses to
|
||||
memcpy(&_ui_sig_probe_tag, &data[2], 4);
|
||||
if (_ui_sig_probe_tag == 0) { _ui_sig_probe_tag = 1; memcpy(&data[2], &_ui_sig_probe_tag, 4); }
|
||||
uint32_t since = 0; // 0 = answer regardless of freshness
|
||||
memcpy(&data[6], &since, 4);
|
||||
mesh::Packet* pkt = createControlData(data, sizeof(data));
|
||||
if (!pkt) { _ui_sig_probe_tag = 0; return 0; }
|
||||
sendZeroHop(pkt); // repeaters reply directly; never floods
|
||||
return _ui_sig_probe_tag;
|
||||
}
|
||||
|
||||
/** Request CayenneLPP telemetry from a remote contact. Reply is delivered
|
||||
@@ -909,6 +955,12 @@ private:
|
||||
* matching by pubkey alone misroutes the login OK as the REQ reply.
|
||||
* Compare tag in onContactResponse to keep the two streams separate. */
|
||||
uint32_t _ui_pending_tag = 0;
|
||||
/** Deferred guest-login-then-request arm: holds the first 4 bytes of the
|
||||
* recipient pub_key (0 = none) plus which REQ to send, set by
|
||||
* uiSendRequestAfterGuestLogin(). onContactResponse fires the REQ when the
|
||||
* matching LOGIN-OK arrives, removing the ACL-commit race on the first try. */
|
||||
uint32_t _ui_login_then = 0;
|
||||
UiReqKind _ui_login_then_kind = UiReqKind::None;
|
||||
BaseSerialInterface *_serial;
|
||||
AbstractUITask* _ui;
|
||||
|
||||
|
||||
@@ -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; }
|
||||
};
|
||||
@@ -1,5 +1,11 @@
|
||||
#if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION)
|
||||
#include "MqttBridge.h"
|
||||
|
||||
// The single global bridge instance (extern in the header). Defined for every board: the header
|
||||
// makes MqttBridge a no-op stub on the Tanmatsu, and the real bridge below on all other boards.
|
||||
MqttBridge mqtt_bridge;
|
||||
|
||||
#if !defined(HAS_TANMATSU) // ---- real PubSubClient/WiFiClient implementation; NOT built on the P4 ----
|
||||
#include <Preferences.h>
|
||||
#include <WiFi.h>
|
||||
#include <stdio.h>
|
||||
@@ -9,8 +15,6 @@
|
||||
#include "mbedtls/md.h"
|
||||
#include "mbedtls/base64.h"
|
||||
|
||||
MqttBridge mqtt_bridge;
|
||||
|
||||
// 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).
|
||||
@@ -212,4 +216,5 @@ void MqttBridge::reloadConfig() {
|
||||
_host, _port, (int)_enabled, (int)_pubDm, (int)_pubChannel, (int)_encOn);
|
||||
}
|
||||
|
||||
#endif // !HAS_TANMATSU (real implementation)
|
||||
#endif // ESP32 && MULTI_TRANSPORT_COMPANION
|
||||
@@ -2,6 +2,25 @@
|
||||
#if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION)
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
#if defined(HAS_TANMATSU)
|
||||
// ---- MQTT DISABLED on the Tanmatsu (ESP32-P4) ----
|
||||
// The P4's Wi-Fi runs through the esp-hosted C6 co-processor; the Arduino WiFiClient / PubSubClient
|
||||
// path this bridge uses is not wired for that and crashed at boot. Provide a no-op bridge with the
|
||||
// SAME public API so the shared companion + settings code links without pulling PubSubClient into
|
||||
// the P4 build. Re-enable once esp-hosted TCP is proven on the Tanmatsu.
|
||||
class MqttBridge {
|
||||
public:
|
||||
void begin(const char* /*nodeHex*/) {}
|
||||
void loop() {}
|
||||
void publishDM(const char*, const uint8_t*, float, uint8_t, uint32_t, const char*) {}
|
||||
void publishChannel(int, const char*, float, uint8_t, uint32_t, const char*) {}
|
||||
bool enabled() const { return false; }
|
||||
static void saveConfig(const char*, uint16_t, const char*, const char*,
|
||||
bool, bool, const char*, bool) {}
|
||||
void reloadConfig() {}
|
||||
};
|
||||
#else
|
||||
#include <PubSubClient.h>
|
||||
#include <WiFiClient.h>
|
||||
|
||||
@@ -78,6 +97,7 @@ private:
|
||||
bool sealToB64(const char* plain, char* out, size_t outCap);
|
||||
static void escapeJson(const char* src, char* dst, size_t dstLen);
|
||||
};
|
||||
#endif // HAS_TANMATSU (no-op stub) vs real MqttBridge
|
||||
|
||||
extern MqttBridge mqtt_bridge;
|
||||
|
||||
|
||||
@@ -3,6 +3,13 @@
|
||||
#include "WifiRuntimeStore.h" // persist BLE on/off (ble_en) across reboots
|
||||
#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) — 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
|
||||
@@ -329,7 +336,18 @@ size_t MultiTransportCompanionInterface::writeFrameToAll(const uint8_t src[], si
|
||||
if (_usb.isConnected() && _usb.writeFrame(src, len) != len)
|
||||
all_ok = false;
|
||||
#ifdef BLE_PIN_CODE
|
||||
if (_ble_begun && _ble_enabled && _ble.isConnected() && _ble.writeFrame(src, len) != len)
|
||||
// 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). 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
|
||||
if (_tcp_started && _tcp.connectedCount() > 0 && _tcp.writeToAllClients(src, len) != len)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 = 28; // 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)
|
||||
static const uint8_t TOUCH_CFG_VER = 32; // 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)
|
||||
|
||||
// Defaults (kept identical to the historical per-key defaults).
|
||||
static const uint16_t DEFAULT_SCREEN_TIMEOUT_S = 20;
|
||||
@@ -99,6 +99,9 @@ struct __attribute__((packed)) TouchCfg {
|
||||
uint8_t flood_adv_hrs; // periodic flood self-advert interval in hours (0 = off) — v27 (trailing)
|
||||
uint16_t local_adv_min; // periodic zero-hop self-advert interval in minutes (0 = off) — v27 (trailing)
|
||||
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)
|
||||
};
|
||||
|
||||
static TouchCfg s_cfg;
|
||||
@@ -155,7 +158,7 @@ static void cfgSetDefaults(TouchCfg& c) {
|
||||
c.map_show_tilexyz = 1;
|
||||
c.map_show_contacts = 1;
|
||||
c.app_grid_large = 0; // default: compact app grid (T-Deck 4 cols / V4 3 cols)
|
||||
c.ui_scale = 0; // default: 100% UI scale (native resolution)
|
||||
c.ui_scale = 1; // default: 150% "Large" UI scale (Tanmatsu; S3 boards ignore this)
|
||||
#if defined(HAS_TANMATSU)
|
||||
c.kbd_nav = 1; // Tanmatsu: no touchscreen — keyboard nav is the only input, always on
|
||||
#else
|
||||
@@ -168,6 +171,9 @@ static void cfgSetDefaults(TouchCfg& c) {
|
||||
c.flood_adv_hrs = 0; // OFF: no periodic flood self-advert (advertise manually)
|
||||
c.local_adv_min = 0; // OFF: no periodic zero-hop self-advert
|
||||
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.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
|
||||
@@ -246,6 +252,10 @@ static void cfgLoadOrMigrate() {
|
||||
if (s_cfg.ver < 26) s_cfg.msg_flash = 0;
|
||||
if (s_cfg.ver < 27) { s_cfg.flood_adv_hrs = 0; s_cfg.local_adv_min = 0; }
|
||||
if (s_cfg.ver < 28) s_cfg.beta_updates = 0;
|
||||
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)
|
||||
s_cfg.ver = TOUCH_CFG_VER;
|
||||
s_cfg.magic = TOUCH_CFG_MAGIC;
|
||||
cfgFlush(); // rewrite with new fields defaulted-in
|
||||
@@ -977,6 +987,39 @@ bool touchPrefsSetMsgFlash(bool on) {
|
||||
return cfgFlush();
|
||||
}
|
||||
|
||||
bool touchPrefsGetBootAdvert() {
|
||||
if (!s_begun) touchPrefsBegin();
|
||||
return s_cfg.boot_advert != 0;
|
||||
}
|
||||
bool touchPrefsSetBootAdvert(bool on) {
|
||||
if (!s_begun) touchPrefsBegin();
|
||||
s_cfg.boot_advert = on ? 1 : 0;
|
||||
return cfgFlush();
|
||||
}
|
||||
|
||||
bool touchPrefsGetCompactChat() {
|
||||
if (!s_begun) touchPrefsBegin();
|
||||
return s_cfg.compact_chat != 0;
|
||||
}
|
||||
bool touchPrefsSetCompactChat(bool on) {
|
||||
if (!s_begun) touchPrefsBegin();
|
||||
s_cfg.compact_chat = on ? 1 : 0;
|
||||
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() {
|
||||
@@ -1638,6 +1681,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[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, sizeof(s_che));
|
||||
if (n == 0 || n > sizeof(s_che)) { 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,
|
||||
|
||||
@@ -148,6 +148,18 @@ bool touchPrefsSetFemLna(bool on);
|
||||
bool touchPrefsGetMsgFlash();
|
||||
bool touchPrefsSetMsgFlash(bool on);
|
||||
|
||||
/* Advertise on boot (#76): fire one flood self-advert ~6s after boot so peers with auto-add on
|
||||
* refresh our pubkey (useful after a reflash wiped storage). Opt-in, default off. All boards. */
|
||||
bool touchPrefsGetBootAdvert();
|
||||
bool touchPrefsSetBootAdvert(bool on);
|
||||
|
||||
/* Compact chat (IRC-style): one dense "HH:MM name: text" row per message instead of
|
||||
* bubbles, so far more history fits on screen. Opt-in, default off (bubbles). */
|
||||
bool touchPrefsGetCompactChat();
|
||||
bool touchPrefsSetCompactChat(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();
|
||||
@@ -371,6 +383,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). */
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
#pragma once
|
||||
// -----------------------------------------------------------------------------
|
||||
// WdtHeavyGuard — suspend core 0's IDLE-task watchdog around a bounded-but-slow
|
||||
// flash burst.
|
||||
//
|
||||
// On ESP32 a fragmenting SPIFFS write can trigger garbage collection: a
|
||||
// multi-second flash operation that disables the CPU cache and starves the idle
|
||||
// task long enough to trip the TASK watchdog. Seen in a coredump as an abort
|
||||
// from task_wdt_isr while the loop task was inside spiffs_gc_clean during
|
||||
// DataStore::saveContacts. Bracket such writes with a WdtHeavyGuard so a
|
||||
// bounded-but-slow GC pass stalls the UI briefly instead of rebooting.
|
||||
//
|
||||
// Ref-counted and SHARED across translation units — the touch-UI history/backup
|
||||
// saves (UITask.cpp) AND the core contact save (DataStore::saveContacts) use the
|
||||
// same count. That matters because a backup import runs under a guard and itself
|
||||
// calls saveContacts: one balanced count means the inner guard never re-arms the
|
||||
// watchdog while the outer import is still writing. The counter lives in an
|
||||
// inline function's static, so there is a single instance program-wide without a
|
||||
// dedicated .cpp (and no ordering/link surprises).
|
||||
//
|
||||
// IMPORTANT: touch ONLY core 0's IDLE-task WDT. In the Arduino S3 build only core
|
||||
// 0's idle task is subscribed to the task watchdog (CHECK_IDLE_TASK_CPU1 is off),
|
||||
// so disableCore1WDT() merely fails while enableCore1WDT() *newly subscribes*
|
||||
// IDLE1 — arming a core-1 watchdog that did not exist before, which a later slow
|
||||
// flash burst then trips. Leaving core 1 alone keeps the Arduino default
|
||||
// (loopTask not WDT-watched), so a slow write just stalls the UI briefly.
|
||||
// -----------------------------------------------------------------------------
|
||||
#if defined(ESP32)
|
||||
#include <Arduino.h> // disableCore0WDT / enableCore0WDT
|
||||
|
||||
inline int& _wdtHeavyDepth() { static int d = 0; return d; }
|
||||
inline void wdtHeavyBegin() { int& d = _wdtHeavyDepth(); if (d++ == 0) disableCore0WDT(); }
|
||||
inline void wdtHeavyEnd() { int& d = _wdtHeavyDepth(); if (d > 0 && --d == 0) enableCore0WDT(); }
|
||||
#else
|
||||
inline void wdtHeavyBegin() {}
|
||||
inline void wdtHeavyEnd() {}
|
||||
#endif
|
||||
|
||||
struct WdtHeavyGuard { WdtHeavyGuard() { wdtHeavyBegin(); } ~WdtHeavyGuard() { wdtHeavyEnd(); } };
|
||||
@@ -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).
|
||||
|
||||
@@ -34,9 +34,9 @@ void tdeckKeyboardSetBacklight(uint8_t level) {
|
||||
if (level != s_bl_desired || !forced) { s_bl_desired = level; s_bl_dirty = true; forced = true; }
|
||||
}
|
||||
|
||||
void tdeckKeyboardPoll() {
|
||||
if (!s_inited) return;
|
||||
if (s_bl_dirty) {
|
||||
void tdeckKeyboardFlushBacklight() {
|
||||
if (!s_inited || !s_bl_dirty) return;
|
||||
{
|
||||
s_bl_dirty = false;
|
||||
// LilyGo T-Keyboard backlight: 2-byte command [0x01, brightness] (0 = off).
|
||||
Wire.beginTransmission(PIN_KB_ADDR);
|
||||
@@ -44,6 +44,11 @@ void tdeckKeyboardPoll() {
|
||||
Wire.write(s_bl_desired); // 0 = off, 1-255 = brightness
|
||||
Wire.endTransmission();
|
||||
}
|
||||
}
|
||||
|
||||
void tdeckKeyboardPoll() {
|
||||
if (!s_inited) return;
|
||||
tdeckKeyboardFlushBacklight();
|
||||
if (Wire.requestFrom((int)PIN_KB_ADDR, 1) != 1) return;
|
||||
uint8_t key = Wire.read();
|
||||
if (key == 0) return; // no key this scan
|
||||
|
||||
@@ -28,4 +28,10 @@ int tdeckKeyboardReadKey();
|
||||
* owns the bus. Only re-sent when the value changes. */
|
||||
void tdeckKeyboardSetBacklight(uint8_t level);
|
||||
|
||||
/** Flush a pending backlight request over I2C NOW. CORE-0 ONLY (touch task).
|
||||
* Cheap when nothing is pending (one flag check); called every poll tick so a
|
||||
* brightness change lands within ~one touch-poll period (~8 ms) instead of
|
||||
* waiting for the next full keyboard scan (~32 ms). */
|
||||
void tdeckKeyboardFlushBacklight();
|
||||
|
||||
#endif
|
||||
@@ -356,6 +356,7 @@ static void touchPollTask(void* arg) {
|
||||
#if defined(HAS_TDECK_KEYBOARD)
|
||||
// Poll the keyboard from THIS task too (same I2C bus) so the two devices
|
||||
// never get hit from two cores at once. ~every 4th tick (~32 ms) is plenty.
|
||||
tdeckKeyboardFlushBacklight(); // backlight requests land within ~one tick (~8 ms); key scan stays /4
|
||||
if (++kb_div >= 4) { kb_div = 0; tdeckKeyboardPoll(); }
|
||||
#endif
|
||||
vTaskDelay(pdMS_TO_TICKS(s_period_ms));
|
||||
|
||||
+72
-14
@@ -362,23 +362,45 @@ void setup() {
|
||||
SPIFFS.exists("/identity/_main.id"));
|
||||
bool fresh_install = !use_sd_pref && !setup_done && !spiffs_has_data;
|
||||
|
||||
bool want_sd = !spiffs_ok // no usable SPIFFS partition -> must use SD
|
||||
|| use_sd_pref // user opted in
|
||||
|| fresh_install; // brand-new device: try SD first
|
||||
// Move the WHOLE store (identity/prefs/contacts) to SD:/meshcomod when the
|
||||
// device has no usable SPIFFS, the user opted in, or it's a brand-new device.
|
||||
bool want_full_sd = !spiffs_ok || use_sd_pref || fresh_install;
|
||||
|
||||
SPIClass* _spi = tdeckSharedSPI();
|
||||
if (want_sd && _spi) {
|
||||
for (int a = 0; a < 4 && !sd_storage; ++a) { // short mount ladder (cold cards)
|
||||
bool sd_mounted = false;
|
||||
if (_spi) {
|
||||
// Try to mount the card on EVERY boot now (not just the full-adoption case):
|
||||
// even a device that keeps identity on SPIFFS wants its churn-heavy
|
||||
// contacts/channels on the card. Full 4-try ladder for the cold-card
|
||||
// first-run adoption; a shorter 2-try for the contacts-overflow case so a
|
||||
// card-less device only pays ~260 ms.
|
||||
int tries = want_full_sd ? 4 : 2;
|
||||
for (int a = 0; a < tries && !sd_mounted; ++a) { // short mount ladder (cold cards)
|
||||
SD.end();
|
||||
delay(a == 0 ? 40 : 220);
|
||||
if (SD.begin(PIN_SD_CS, *_spi, 4000000, "/sd", 3) && SD.cardType() != CARD_NONE) {
|
||||
sd_storage = store.useSdStorage();
|
||||
}
|
||||
if (SD.begin(PIN_SD_CS, *_spi, 4000000, "/sd", 3) && SD.cardType() != CARD_NONE)
|
||||
sd_mounted = true;
|
||||
}
|
||||
// On a genuine first run, persist the auto-pick so the "Store data on SD"
|
||||
// toggle reflects it and the choice sticks on every later boot.
|
||||
if (fresh_install && sd_storage && !use_sd_pref) {
|
||||
Preferences _p; if (_p.begin("touch", false)) { _p.putBool("use_sd", true); _p.end(); }
|
||||
Serial.println("[BOOT] first run + SD card present -> data defaults to SD");
|
||||
}
|
||||
if (sd_mounted) {
|
||||
if (want_full_sd) {
|
||||
sd_storage = store.useSdStorage();
|
||||
// On a genuine first run, persist the auto-pick so the "Store data on SD"
|
||||
// toggle reflects it and the choice sticks on every later boot.
|
||||
if (fresh_install && sd_storage && !use_sd_pref) {
|
||||
Preferences _p; if (_p.begin("touch", false)) { _p.putBool("use_sd", true); _p.end(); }
|
||||
Serial.println("[BOOT] first run + SD card present -> data defaults to SD");
|
||||
}
|
||||
} else {
|
||||
// Upgraded device: identity + prefs stay on SPIFFS (no node-identity
|
||||
// change, safe if the card is later pulled), but route the frequently
|
||||
// rewritten contacts + channels to the card. On a near-full 3.375 MB
|
||||
// SPIFFS every 5-second saveContacts triggers a multi-second GC pass
|
||||
// that starves the loop task and trips the task watchdog (the beta_25
|
||||
// reboot loop). DataStore::begin() migrates the existing SPIFFS copies
|
||||
// to the card once, so the contact list is preserved.
|
||||
store.setSecondaryFS(&SD);
|
||||
Serial.println("[BOOT] contacts/channels -> SD card (identity/prefs stay on SPIFFS)");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -551,9 +573,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;
|
||||
@@ -678,14 +712,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)
|
||||
@@ -696,10 +738,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
|
||||
@@ -716,7 +766,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
|
||||
@@ -773,6 +827,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
|
||||
}
|
||||
+1771
-486
File diff suppressed because it is too large.
Load diff
@@ -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,47 @@ 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 {
|
||||
if (idx < 0 || idx >= MAX_UI_THREADS) return false;
|
||||
if (!_ui_threads[idx].used || _ui_threads[idx].channel) return false;
|
||||
const uint8_t* p = _ui_threads[idx].mesh_contact_pub;
|
||||
uint8_t any = 0;
|
||||
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; }
|
||||
@@ -456,6 +505,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;
|
||||
|
||||
@@ -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 "$@"
|
||||
@@ -51,6 +51,9 @@ vend "MicroNMEA" # GPS NMEA parsing
|
||||
vend "AsyncTCP" # the core's ESP32Board HTTP-OTA + companion-over-IP transport
|
||||
vend "ESPAsyncWebServer" # WebSocket/HTTP companion server (needs the task WDT on — sdkconfigs/wadamesh)
|
||||
vend "AsyncElegantOTA" # OTA web UI (the core's ESP32Board HTTP-OTA)
|
||||
# NOTE: PubSubClient is deliberately NOT vended — the MQTT bridge is compiled out on the Tanmatsu
|
||||
# (HAS_TANMATSU no-op stub in MqttBridge.h); the P4's esp-hosted Wi-Fi isn't wired for it and it
|
||||
# crashed the app at boot. Re-add this vend if MQTT is ever brought up on the P4.
|
||||
# Bump the component CMakeLists so the next build re-globs (IDF component scripts can't use
|
||||
# file(GLOB CONFIGURE_DEPENDS) — they run in script mode during requirement expansion).
|
||||
touch components/meshcore/CMakeLists.txt components/ardlibs/CMakeLists.txt 2>/dev/null || true
|
||||
|
||||
@@ -41,7 +41,7 @@ set(WADA_DEFS
|
||||
MULTI_TRANSPORT_COMPANION TCP_PORT=5000 WS_PORT=8765
|
||||
BLE_PIN_CODE=123456 # enables the app's NimBLE companion (BLE over the C6)
|
||||
LORA_FREQ=869.618 LORA_BW=62.5 LORA_SF=8
|
||||
MAX_CONTACTS=500 MAX_GROUP_CHANNELS=40 # 2000 was a ~400KB internal-DRAM strand; BT's SDIO RX buffers (MALLOC_CAP_INTERNAL|DMA) need that headroom
|
||||
MAX_CONTACTS=2000 MAX_GROUP_CHANNELS=40 # P4 has ample RAM; contacts array is PSRAM-resident (the_mesh placement-new)
|
||||
ENABLE_PRIVATE_KEY_IMPORT=1 ENABLE_PRIVATE_KEY_EXPORT=1
|
||||
DISPLAY_CLASS=TanmatsuDisplay
|
||||
# microSD lives on the P4's SDMMC *slot 0* (IOMUX pins CLK43/CMD44/D0-3 39-42); slot 1 is the
|
||||
@@ -53,5 +53,20 @@ set(WADA_DEFS
|
||||
foreach(_d ${WADA_DEFS})
|
||||
idf_build_set_property(COMPILE_OPTIONS "-D${_d}" APPEND)
|
||||
endforeach()
|
||||
|
||||
# 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.)
|
||||
+14
-2
@@ -14,6 +14,7 @@
|
||||
#include "target.h" // board, radio_driver, rtc_clock, display, sensors, radio_init()
|
||||
#include <SPIFFS.h>
|
||||
#include <FFat.h> // DataStore persists on the internal 'locfd' FAT partition
|
||||
#include <SD_MMC.h> // microSD (slot 0): reliable store for contacts/channels/chat (internal FFat loses them on this P4)
|
||||
#include "esp_partition.h" // enumerate partitions (AppFS apps boot fresh — verify locfd is visible)
|
||||
#include <WiFi.h>
|
||||
#include <helpers/esp32/MultiTransportCompanionInterface.h>
|
||||
@@ -54,6 +55,7 @@ void halt() { while (1) { delay(1000); } }
|
||||
// DataStore takes — FILESYSTEM == fs::FS on ESP32). See the storage block in setup().
|
||||
DataStore store(FFat, rtc_clock);
|
||||
bool g_fs_ok = false; // true once FFat(locfd) is mounted; UITask's file browser checks this (extern)
|
||||
bool g_sd_ok = false; // true once the microSD (SD_MMC) is mounted; contacts/channels/chat persist there (extern)
|
||||
MultiTransportCompanionInterface serial_interface;
|
||||
StdRNG fast_rng;
|
||||
SimpleMeshTables tables;
|
||||
@@ -298,9 +300,12 @@ static void hostedConnectC6() {
|
||||
}
|
||||
|
||||
static void wadameshSetup() {
|
||||
Serial.begin(115200);
|
||||
// Do NOT call Serial.begin() on the Tanmatsu (P4). The launcher already owns the USB-Serial-JTAG
|
||||
// console; re-initialising it here crashed the app during boot (before the splash could even draw
|
||||
// — the beta_21+ symptom of "blue screen, no boot log"). Serial output is swallowed on this board's
|
||||
// IDF console anyway, so printf (not Serial) carries the [BOOT] logs. See tanmatsu-serial-begin memory.
|
||||
delay(150);
|
||||
Serial.println("[BOOT] wadamesh / tanmatsu");
|
||||
printf("[BOOT] wadamesh / tanmatsu\n");
|
||||
|
||||
// badge-bsp: panel + input + power. The panel must be up before LVGL flushes to it; ask for
|
||||
// RGB565 to match LVGL's 16-bit color depth (lvglFlush -> writePixelsRGB565 -> bsp_display_blit).
|
||||
@@ -369,6 +374,13 @@ static void wadameshSetup() {
|
||||
} else {
|
||||
printf("[storage] persistence disabled this boot (DataStore opens fail gracefully)\n");
|
||||
}
|
||||
// The internal FFat 'locfd' on this P4 has a broken FAT metadata layer (see the tile-cache notes):
|
||||
// it keeps rarely-written data (identity/prefs) but LOSES the frequently-rewritten contacts + chat on
|
||||
// reboot. So route contacts/channels (and chat history, see uiDataFsReady) to the microSD card, which
|
||||
// is reliable FAT. Identity/NodePrefs stay on FFat — they persist there and survive a card-less boot.
|
||||
g_sd_ok = SD_MMC.begin("/sdcard", false /*1-bit*/) && SD_MMC.cardType() != CARD_NONE;
|
||||
printf("[storage] SD_MMC.begin = %s\n", g_sd_ok ? "OK" : "no card");
|
||||
if (g_sd_ok) store.setSecondaryFS(&SD_MMC); // contacts + channels -> SD; identity/prefs stay on FFat
|
||||
store.begin();
|
||||
|
||||
bootLog("Mesh stack");
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user