mirror of
https://github.com/ALLFATHER-BV/wadamesh.git
synced 2026-10-23 10:42:26 +00:00
Merge remote-tracking branch 'upstream/main' into square
This commit is contained in:
commit
7cf9621fec
44 files changed
+1680
-118
No files matched your search
+1
-1
@@ -366,7 +366,7 @@ which is why the console needed far fewer new commands than the UI has screens.
|
||||
| Quick replies | none | gap |
|
||||
| Backups | none | gap |
|
||||
| Language | none | gap |
|
||||
| MQTT bridge | none | gap |
|
||||
| MQTT bridge | `mqtt status` | **done** (diagnostics only) |
|
||||
| App permissions | none | with Phase 4 |
|
||||
| General | `console`/`ui` toggle | partial |
|
||||
|
||||
|
||||
@@ -20,20 +20,33 @@ this page is the five-minute tour that makes the board feel native.
|
||||
the second layer instead. Start typing in a chat and the composer focuses
|
||||
itself; any key wakes the screen from idle.
|
||||
|
||||
## The one rule: Back closes what you see
|
||||
## The one rule: Back closes what you see, then goes back
|
||||
|
||||
**Back** always closes the *top* layer, one per press: map pan mode → an open
|
||||
dropdown → the power menu → the Control Center → a full-screen app → a dialog
|
||||
→ any popup → an open chat → back out of the page. Nothing ever closes
|
||||
invisibly beneath something else. The only exception is deliberate: progress
|
||||
overlays (SD format, bulk delete) block all keys until the operation finishes.
|
||||
→ any popup → an open chat. Nothing ever closes invisibly beneath something
|
||||
else.
|
||||
|
||||
Once there is nothing left covering the screen, **Back returns you to the
|
||||
previous screen** — the tab you came from, however you got there: a function
|
||||
key, an app-drawer tile, "Show on map", a contact's Send-msg button. Keep
|
||||
pressing and you keep walking back through where you have been. When the trail
|
||||
runs out Back lands on Home, and on Home with nothing open it does nothing,
|
||||
because that is the root.
|
||||
|
||||
**HOME** is the shortcut past all of that: it goes straight to the root and
|
||||
clears the trail, so Back from there won't jump you back out again.
|
||||
|
||||
The only exception is deliberate: progress overlays (SD format, bulk delete)
|
||||
block all keys until the operation finishes — Back will not navigate out from
|
||||
under a running operation.
|
||||
|
||||
## Function keys
|
||||
|
||||
| Key | Press | Hold |
|
||||
|---|---|---|
|
||||
| **MSG** | Jump to the Chats tab (closes an open app first) | — |
|
||||
| **HOME** | Peel one layer off an open app; on the Home tab, toggle the app drawer; otherwise jump Home | — |
|
||||
| **HOME** | Peel one layer off an open app; on the Home tab, toggle the app drawer; otherwise jump Home (and clear the Back trail) | — |
|
||||
| **@ (Mentions)** | Open the Mentions screen | — |
|
||||
| **ADV** | Open the Send Advert page | **Toggle GPS on/off** |
|
||||
| **MAP** | Jump to the Map tab — press again *on* the map to toggle pan mode | — |
|
||||
@@ -77,9 +90,24 @@ app can trap the keys, even while the screen is locked.
|
||||
|
||||
If you last used the M9 on an earlier beta, the notable navigation changes:
|
||||
|
||||
- **Back now actually goes back** — once everything covering the screen was
|
||||
closed, Back had nothing left to do and the press simply died on any bare
|
||||
tab. It now walks back through the screens you visited, then Home. Jumps that
|
||||
used to strand you ("Show on map" from Discover or Contacts, a route replay
|
||||
from a chat, the Home unread line, the ✉ badge, app-drawer tiles) all return
|
||||
properly. **Back also works in the first-boot wizard** now, stepping back
|
||||
through its screens like the on-screen Back button.
|
||||
- **Back restored and made consistent** — it had regressed to doing nothing
|
||||
outside text fields, and could close hidden popups beneath the Control
|
||||
Center. Both fixed; the close-what-you-see rule above now holds everywhere.
|
||||
- **The map opens faster the first time** — the cold open was doing a
|
||||
whole-card zoom scan whose result was thrown away, and (with no GPS fix) was
|
||||
repeating that scan on *every* open. The "Loading map…" hint now appears
|
||||
immediately instead of after the scan, so the board no longer looks frozen on
|
||||
the previous screen while the first tiles decode.
|
||||
- **The d-pad no longer goes dead for a second** when a message arrives, and
|
||||
the Spectrum page no longer steers an invisible cursor around the app drawer
|
||||
underneath it.
|
||||
- **Apps can't strand you** — opening Advert/Mentions over a running app used
|
||||
to orphan it with no key path back; app permission dialogs are now
|
||||
answerable with the d-pad.
|
||||
|
||||
@@ -1113,3 +1113,4 @@ Detecting the keyboard protocol Detecting the keyboard protocol
|
||||
Older keyboard protocol Older keyboard protocol
|
||||
Using the older keyboard protocol Using the older keyboard protocol
|
||||
turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching
|
||||
Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it
|
||||
@@ -1109,3 +1109,4 @@ Detecting the keyboard protocol Detecting the keyboard protocol
|
||||
Older keyboard protocol Older keyboard protocol
|
||||
Using the older keyboard protocol Using the older keyboard protocol
|
||||
turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching
|
||||
Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it
|
||||
@@ -1113,3 +1113,4 @@ Detecting the keyboard protocol Detecting the keyboard protocol
|
||||
Older keyboard protocol Older keyboard protocol
|
||||
Using the older keyboard protocol Using the older keyboard protocol
|
||||
turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching
|
||||
Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it
|
||||
@@ -1113,3 +1113,4 @@ Detecting the keyboard protocol Detecting the keyboard protocol
|
||||
Older keyboard protocol Older keyboard protocol
|
||||
Using the older keyboard protocol Using the older keyboard protocol
|
||||
turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching
|
||||
Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it
|
||||
@@ -1112,3 +1112,4 @@ Detecting the keyboard protocol Detecting the keyboard protocol
|
||||
Older keyboard protocol Older keyboard protocol
|
||||
Using the older keyboard protocol Using the older keyboard protocol
|
||||
turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching
|
||||
Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it
|
||||
@@ -1130,3 +1130,4 @@ Detecting the keyboard protocol Detecting the keyboard protocol
|
||||
Older keyboard protocol Older keyboard protocol
|
||||
Using the older keyboard protocol Using the older keyboard protocol
|
||||
turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching
|
||||
Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it
|
||||
@@ -1112,3 +1112,4 @@ Detecting the keyboard protocol Detecting the keyboard protocol
|
||||
Older keyboard protocol Older keyboard protocol
|
||||
Using the older keyboard protocol Using the older keyboard protocol
|
||||
turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching
|
||||
Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it
|
||||
@@ -1110,3 +1110,4 @@ Detecting the keyboard protocol Detecting the keyboard protocol
|
||||
Older keyboard protocol Older keyboard protocol
|
||||
Using the older keyboard protocol Using the older keyboard protocol
|
||||
turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching
|
||||
Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it
|
||||
@@ -1112,3 +1112,4 @@ Detecting the keyboard protocol Detecting the keyboard protocol
|
||||
Older keyboard protocol Older keyboard protocol
|
||||
Using the older keyboard protocol Using the older keyboard protocol
|
||||
turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching
|
||||
Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it
|
||||
@@ -1114,3 +1114,4 @@ Detecting the keyboard protocol Detecting the keyboard protocol
|
||||
Older keyboard protocol Older keyboard protocol
|
||||
Using the older keyboard protocol Using the older keyboard protocol
|
||||
turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching
|
||||
Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it
|
||||
@@ -1113,3 +1113,4 @@ Detecting the keyboard protocol Detecting the keyboard protocol
|
||||
Older keyboard protocol Older keyboard protocol
|
||||
Using the older keyboard protocol Using the older keyboard protocol
|
||||
turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching
|
||||
Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it
|
||||
@@ -1113,3 +1113,4 @@ Detecting the keyboard protocol Detecting the keyboard protocol
|
||||
Older keyboard protocol Older keyboard protocol
|
||||
Using the older keyboard protocol Using the older keyboard protocol
|
||||
turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching
|
||||
Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it
|
||||
@@ -1113,3 +1113,4 @@ Detecting the keyboard protocol Detecting the keyboard protocol
|
||||
Older keyboard protocol Older keyboard protocol
|
||||
Using the older keyboard protocol Using the older keyboard protocol
|
||||
turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching
|
||||
Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it
|
||||
@@ -1113,3 +1113,4 @@ Detecting the keyboard protocol Detecting the keyboard protocol
|
||||
Older keyboard protocol Older keyboard protocol
|
||||
Using the older keyboard protocol Using the older keyboard protocol
|
||||
turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching
|
||||
Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it
|
||||
@@ -1109,3 +1109,4 @@ Detecting the keyboard protocol Detecting the keyboard protocol
|
||||
Older keyboard protocol Older keyboard protocol
|
||||
Using the older keyboard protocol Using the older keyboard protocol
|
||||
turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching
|
||||
Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it
|
||||
@@ -1113,3 +1113,4 @@ Detecting the keyboard protocol Detecting the keyboard protocol
|
||||
Older keyboard protocol Older keyboard protocol
|
||||
Using the older keyboard protocol Using the older keyboard protocol
|
||||
turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching
|
||||
Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it
|
||||
@@ -1113,3 +1113,4 @@ Detecting the keyboard protocol Detecting the keyboard protocol
|
||||
Older keyboard protocol Older keyboard protocol
|
||||
Using the older keyboard protocol Using the older keyboard protocol
|
||||
turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching
|
||||
Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it
|
||||
@@ -1112,3 +1112,4 @@ Detecting the keyboard protocol Detecting the keyboard protocol
|
||||
Older keyboard protocol Older keyboard protocol
|
||||
Using the older keyboard protocol Using the older keyboard protocol
|
||||
turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching
|
||||
Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it
|
||||
@@ -1130,3 +1130,4 @@ Detecting the keyboard protocol Detecting the keyboard protocol
|
||||
Older keyboard protocol Older keyboard protocol
|
||||
Using the older keyboard protocol Using the older keyboard protocol
|
||||
turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching
|
||||
Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it
|
||||
@@ -1112,3 +1112,4 @@ Detecting the keyboard protocol Detecting the keyboard protocol
|
||||
Older keyboard protocol Older keyboard protocol
|
||||
Using the older keyboard protocol Using the older keyboard protocol
|
||||
turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching
|
||||
Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it
|
||||
@@ -1110,3 +1110,4 @@ Detecting the keyboard protocol Detecting the keyboard protocol
|
||||
Older keyboard protocol Older keyboard protocol
|
||||
Using the older keyboard protocol Using the older keyboard protocol
|
||||
turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching
|
||||
Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it
|
||||
@@ -1112,3 +1112,4 @@ Detecting the keyboard protocol Detecting the keyboard protocol
|
||||
Older keyboard protocol Older keyboard protocol
|
||||
Using the older keyboard protocol Using the older keyboard protocol
|
||||
turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching
|
||||
Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it
|
||||
@@ -1114,3 +1114,4 @@ Detecting the keyboard protocol Detecting the keyboard protocol
|
||||
Older keyboard protocol Older keyboard protocol
|
||||
Using the older keyboard protocol Using the older keyboard protocol
|
||||
turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching
|
||||
Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it
|
||||
@@ -1113,3 +1113,4 @@ Detecting the keyboard protocol Detecting the keyboard protocol
|
||||
Older keyboard protocol Older keyboard protocol
|
||||
Using the older keyboard protocol Using the older keyboard protocol
|
||||
turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching
|
||||
Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it
|
||||
@@ -1113,3 +1113,4 @@ Detecting the keyboard protocol Detecting the keyboard protocol
|
||||
Older keyboard protocol Older keyboard protocol
|
||||
Using the older keyboard protocol Using the older keyboard protocol
|
||||
turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching
|
||||
Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it
|
||||
@@ -1113,3 +1113,4 @@ Detecting the keyboard protocol Detecting the keyboard protocol
|
||||
Older keyboard protocol Older keyboard protocol
|
||||
Using the older keyboard protocol Using the older keyboard protocol
|
||||
turn on if your keyboard types the wrong letters; disables modifier latching turn on if your keyboard types the wrong letters; disables modifier latching
|
||||
Formatted OK - card did not remount, reinsert it Formatted OK - card did not remount, reinsert it
|
||||
@@ -0,0 +1,26 @@
|
||||
# beta_75 - test build. Stable stays beta_65.
|
||||
# One user-facing note per non-blank, non-# line; # lines are section comments.
|
||||
|
||||
# --- Your offline map pack was being deleted -----------------------------------------------
|
||||
If you keep map tiles on an SD card, the firmware was permanently deleting files from your own map pack. Every tile it wanted but could not find as a .jpg erased the .png sitting next to it, and that deletion is not recoverable.
|
||||
The cleanup responsible was written for the firmware's own small tile cache, but on every board that keeps tiles on the card it was pointed at the card instead. It is now limited to the cache it was meant for. Found and fixed by Christopher Van Hoose, confirmed on a ThinkNode M9.
|
||||
|
||||
# --- Names -----------------------------------------------------------------------------------
|
||||
Long sender names are stored in full. Names can be 31 characters and only the first 24 were kept, so anything longer was cut permanently on arrival. Nothing is lost now, and existing chat history is untouched.
|
||||
A long name no longer pushes the message text out of shape, and a colon inside an ordinary message is no longer mistaken for an author name.
|
||||
Both by Ruben Laban, a first contribution.
|
||||
|
||||
# --- ThinkNode M9 ----------------------------------------------------------------------------
|
||||
The map opens in about 1.5 seconds instead of 2.6.
|
||||
Back returns to the previous screen rather than jumping out of where you were.
|
||||
The SD card is remounted properly after formatting it on the device.
|
||||
Labels that update while you watch no longer overlap the rows beneath them, and controls that are hidden or inactive are skipped when moving with the d-pad instead of trapping the cursor.
|
||||
GPS acquisition time is measured from when the search started rather than from when you happened to open the page.
|
||||
All from Christopher Van Hoose.
|
||||
|
||||
# --- Also ------------------------------------------------------------------------------------
|
||||
Notification indicators and modal focus behave correctly, and there is a command to check MQTT connection status. Both by Michael A. Cojocari.
|
||||
|
||||
# --- For testers -------------------------------------------------------------------------------
|
||||
If you keep an offline map pack on SD, this release stops the damage but cannot undo it. Check whether tiles are missing and re-copy that area if so.
|
||||
Still open and not fixed here: chat history resetting to an older point on reboot, a Tanmatsu whose chat store will not finish migrating, a map that renders nothing since beta_71, a screen that will not stay dark after a manual lock, and the web flasher hanging at "wrapping up" on the T-Display P4. Several of those are waiting on information from the people who reported them.
|
||||
+116
@@ -280,12 +280,101 @@ static const char* kMeshcomodHelpMsg =
|
||||
"set tx <dBm> set TX power\n"
|
||||
"wifi status|on|off|scan\n"
|
||||
"wifi use <n> | set ssid <v> | set pwd <v> | apply | clear\n"
|
||||
"mqtt status\n"
|
||||
"tcp status|on|off\n"
|
||||
"ble status|on|off\n"
|
||||
"ota status|start|netdiag | ota url <https://...bin>\n"
|
||||
"reboot restart the device\n"
|
||||
"bootloader / dfu reboot to download mode";
|
||||
|
||||
#if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION)
|
||||
static const char* mqttResultText(int result) {
|
||||
switch (result) {
|
||||
case -4: return "connection timeout";
|
||||
case -3: return "connection lost";
|
||||
case -2: return "TCP connect failed";
|
||||
case -1: return "disconnected";
|
||||
case 0: return "connected";
|
||||
case 1: return "bad protocol";
|
||||
case 2: return "rejected client ID";
|
||||
case 3: return "broker unavailable";
|
||||
case 4: return "bad credentials";
|
||||
case 5: return "unauthorized";
|
||||
default: return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
static void mqttFormatAge(char* out, size_t cap, uint32_t timestamp, uint32_t now) {
|
||||
if (!timestamp) { snprintf(out, cap, "never"); return; }
|
||||
const uint32_t seconds = (uint32_t)(now - timestamp) / 1000u;
|
||||
if (seconds < 60u) snprintf(out, cap, "%lus ago", (unsigned long)seconds);
|
||||
else if (seconds < 3600u) snprintf(out, cap, "%lum %lus ago",
|
||||
(unsigned long)(seconds / 60u),
|
||||
(unsigned long)(seconds % 60u));
|
||||
else snprintf(out, cap, "%luh %lum ago", (unsigned long)(seconds / 3600u),
|
||||
(unsigned long)((seconds / 60u) % 60u));
|
||||
}
|
||||
|
||||
static void mqttFormatStatus(char* out, size_t cap) {
|
||||
MqttBridgeStatus status;
|
||||
mqtt_bridge.getStatus(status);
|
||||
if (!status.available) {
|
||||
snprintf(out, cap, "mqtt: unavailable on this build");
|
||||
return;
|
||||
}
|
||||
if (status.phase == MqttBridgePhase::Disabled) {
|
||||
snprintf(out, cap, "mqtt: disabled");
|
||||
return;
|
||||
}
|
||||
if (status.phase == MqttBridgePhase::Misconfigured) {
|
||||
snprintf(out, cap, "mqtt: misconfigured (broker host is empty)");
|
||||
return;
|
||||
}
|
||||
|
||||
const uint32_t now = millis();
|
||||
char state[64];
|
||||
char timing[96] = {};
|
||||
switch (status.phase) {
|
||||
case MqttBridgePhase::WaitingForWifi:
|
||||
snprintf(state, sizeof(state), "mqtt: waiting for Wi-Fi");
|
||||
break;
|
||||
case MqttBridgePhase::Connecting: {
|
||||
char age[32];
|
||||
mqttFormatAge(age, sizeof(age), status.lastAttemptMs, now);
|
||||
snprintf(state, sizeof(state), "mqtt: connecting");
|
||||
snprintf(timing, sizeof(timing), "attempt: %s", age);
|
||||
break;
|
||||
}
|
||||
case MqttBridgePhase::Connected: {
|
||||
char age[32];
|
||||
mqttFormatAge(age, sizeof(age), status.lastConnectedMs, now);
|
||||
snprintf(state, sizeof(state), "mqtt: connected");
|
||||
snprintf(timing, sizeof(timing), "connected: %s", age);
|
||||
break;
|
||||
}
|
||||
case MqttBridgePhase::RetryWait: {
|
||||
const int32_t remaining = (int32_t)(status.retryAtMs - now);
|
||||
const uint32_t seconds = remaining > 0 ? ((uint32_t)remaining + 999u) / 1000u : 0u;
|
||||
snprintf(state, sizeof(state), "mqtt: retrying (%lus)", (unsigned long)seconds);
|
||||
if (status.lastResultMs) {
|
||||
char age[32];
|
||||
mqttFormatAge(age, sizeof(age), status.lastResultMs, now);
|
||||
snprintf(timing, sizeof(timing), "last error: %d (%s), %s",
|
||||
(int)status.lastResult, mqttResultText(status.lastResult), age);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
snprintf(state, sizeof(state), "mqtt: unavailable on this build");
|
||||
break;
|
||||
}
|
||||
snprintf(out, cap, "%s\nbroker: %s:%u\npublish: channels=%s dm=%s encrypted=%s%s%s",
|
||||
state, status.host, (unsigned)status.port,
|
||||
status.publishChannel ? "on" : "off", status.publishDm ? "on" : "off",
|
||||
status.encrypted ? "yes" : "no", timing[0] ? "\n" : "", timing);
|
||||
}
|
||||
#endif
|
||||
|
||||
#define MESHCOMOD_CMD_CACHE_SIZE 6
|
||||
struct MeshcomodCmdCacheEntry {
|
||||
uint32_t ts;
|
||||
@@ -698,6 +787,23 @@ bool MyMesh::handleMeshcomodCommand(const char* text, int text_len) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isCmd(p, "mqtt")) {
|
||||
const char* q = p + 4;
|
||||
while (*q == ' ' || *q == '\t') q++;
|
||||
if (isCmd(q, "status")) {
|
||||
#if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION)
|
||||
char reply[320];
|
||||
mqttFormatStatus(reply, sizeof(reply));
|
||||
pushMeshcomodReply(reply);
|
||||
#else
|
||||
pushMeshcomodReply("mqtt: unavailable on this build");
|
||||
#endif
|
||||
return true;
|
||||
}
|
||||
pushMeshcomodReply("usage:\n- mqtt status");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (strncasecmp(p, "tcp", 3) == 0 && (p[3] == '\0' || p[3] == ' ' || p[3] == '\t')) {
|
||||
p += 3;
|
||||
while (*p == ' ' || *p == '\t') p++;
|
||||
@@ -5219,6 +5325,16 @@ void MyMesh::checkCLIRescueCmd() {
|
||||
} else {
|
||||
Serial.printf(" Error: unknown config: %s\n", config);
|
||||
}
|
||||
} else if (strcmp(cli_command, "mqtt.status") == 0 ||
|
||||
strcmp(cli_command, "get mqtt.status") == 0 ||
|
||||
strcmp(cli_command, "mqtt status") == 0) {
|
||||
#if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION)
|
||||
char reply[320];
|
||||
mqttFormatStatus(reply, sizeof(reply));
|
||||
Serial.printf(" > %s\n", reply);
|
||||
#else
|
||||
Serial.println(" > mqtt: unavailable on this build");
|
||||
#endif
|
||||
} else if (strcmp(cli_command, "get wifi.ssid") == 0) {
|
||||
#ifdef ESP32
|
||||
#if defined(WIFI_SSID) || defined(MULTI_TRANSPORT_COMPANION)
|
||||
|
||||
@@ -36,17 +36,52 @@ size_t MqttNbClient::write(const uint8_t* buf, size_t size) {
|
||||
return WiFiClient::write(buf, size);
|
||||
}
|
||||
|
||||
void MqttBridge::getStatus(MqttBridgeStatus& out) const {
|
||||
portENTER_CRITICAL(&_statusMux);
|
||||
out = _status;
|
||||
portEXIT_CRITICAL(&_statusMux);
|
||||
}
|
||||
|
||||
void MqttBridge::syncStatusConfig(MqttBridgePhase phase) {
|
||||
MqttBridgeStatus next;
|
||||
next.phase = phase;
|
||||
next.available = true;
|
||||
next.requestedEnabled = _requestedEnabled;
|
||||
next.publishDm = _pubDm;
|
||||
next.publishChannel = _pubChannel;
|
||||
next.encrypted = _encOn;
|
||||
strncpy(next.host, _host, sizeof(next.host) - 1);
|
||||
next.port = _port;
|
||||
portENTER_CRITICAL(&_statusMux);
|
||||
_status = next;
|
||||
portEXIT_CRITICAL(&_statusMux);
|
||||
}
|
||||
|
||||
void MqttBridge::setStatusPhase(MqttBridgePhase phase, int result, uint32_t now,
|
||||
bool recordResult, uint32_t retryAt) {
|
||||
portENTER_CRITICAL(&_statusMux);
|
||||
_status.phase = phase;
|
||||
_status.retryAtMs = retryAt;
|
||||
if (phase == MqttBridgePhase::Connecting) _status.lastAttemptMs = now;
|
||||
if (phase == MqttBridgePhase::Connected) _status.lastConnectedMs = now;
|
||||
if (recordResult) {
|
||||
_status.lastResult = (int8_t)result;
|
||||
_status.lastResultMs = now;
|
||||
}
|
||||
portEXIT_CRITICAL(&_statusMux);
|
||||
}
|
||||
|
||||
// Read every field from the "mqtt" NVS namespace into the members. isKey() guards
|
||||
// avoid the [E] NOT_FOUND log spam that getString emits for absent keys on the
|
||||
// USB-CDC companion stream (see TouchPrefsStore prefsGetStr note).
|
||||
void MqttBridge::loadConfig() {
|
||||
_enabled = false; _pubDm = false; _pubChannel = true;
|
||||
_requestedEnabled = false; _enabled = false; _pubDm = false; _pubChannel = true;
|
||||
_host[0] = _user[0] = _pwd[0] = _psk[0] = '\0';
|
||||
_port = 1883;
|
||||
|
||||
SdNvsPrefs p;
|
||||
if (p.begin("mqtt", true)) {
|
||||
_enabled = p.getBool("en", false);
|
||||
_requestedEnabled = p.getBool("en", false);
|
||||
_port = (uint16_t)p.getUInt("port", 1883);
|
||||
_pubDm = p.getBool("dm", false);
|
||||
_pubChannel = p.getBool("ch", true);
|
||||
@@ -57,7 +92,13 @@ void MqttBridge::loadConfig() {
|
||||
p.end();
|
||||
}
|
||||
deriveKey();
|
||||
if (!_enabled || _host[0] == '\0') _enabled = false;
|
||||
_enabled = _requestedEnabled && _host[0] != '\0';
|
||||
MqttBridgePhase phase = MqttBridgePhase::Disabled;
|
||||
if (_requestedEnabled && _host[0] == '\0') phase = MqttBridgePhase::Misconfigured;
|
||||
else if (_enabled) phase = WiFi.status() == WL_CONNECTED
|
||||
? MqttBridgePhase::RetryWait
|
||||
: MqttBridgePhase::WaitingForWifi;
|
||||
syncStatusConfig(phase);
|
||||
}
|
||||
|
||||
// Derive the AES key from the passphrase. mbedtls_md() (generic digest) is used
|
||||
@@ -88,7 +129,12 @@ void MqttBridge::begin(const char* nodeHex) {
|
||||
}
|
||||
|
||||
bool MqttBridge::reconnect() {
|
||||
if (!_enabled || WiFi.status() != WL_CONNECTED) return false;
|
||||
if (!_enabled) return false;
|
||||
if (WiFi.status() != WL_CONNECTED) {
|
||||
setStatusPhase(MqttBridgePhase::WaitingForWifi, MQTT_DISCONNECTED,
|
||||
millis(), false);
|
||||
return false;
|
||||
}
|
||||
|
||||
char clientId[32], lwtTopic[80];
|
||||
snprintf(clientId, sizeof(clientId), "wadamesh-%s", _nodeHex);
|
||||
@@ -98,11 +144,21 @@ bool MqttBridge::reconnect() {
|
||||
? _mqtt.connect(clientId, _user, _pwd, lwtTopic, 0, true, "offline")
|
||||
: _mqtt.connect(clientId, nullptr, nullptr, lwtTopic, 0, true, "offline");
|
||||
|
||||
const uint32_t now = millis();
|
||||
const int result = _mqtt.state();
|
||||
if (ok) {
|
||||
setStatusPhase(MqttBridgePhase::Connected, result, now, true);
|
||||
_mqtt.publish(lwtTopic, "online", true);
|
||||
Serial.printf("[MQTT] connected as %s\n", clientId);
|
||||
} else {
|
||||
Serial.printf("[MQTT] connect failed, rc=%d\n", _mqtt.state());
|
||||
MqttBridgeStatus current;
|
||||
getStatus(current);
|
||||
const uint32_t retryAt = current.lastAttemptMs
|
||||
? current.lastAttemptMs + RECONNECT_INTERVAL_MS
|
||||
: now + RECONNECT_INTERVAL_MS;
|
||||
setStatusPhase(MqttBridgePhase::RetryWait, result, now, true,
|
||||
retryAt);
|
||||
Serial.printf("[MQTT] connect failed, rc=%d\n", result);
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
@@ -119,19 +175,41 @@ void MqttBridge::reconnectTask(void* arg) {
|
||||
|
||||
void MqttBridge::loop() {
|
||||
if (!_enabled || _connecting) return;
|
||||
uint32_t now = millis();
|
||||
if (WiFi.status() != WL_CONNECTED) {
|
||||
if (_mqtt.connected()) _mqtt.loop();
|
||||
setStatusPhase(MqttBridgePhase::WaitingForWifi, MQTT_DISCONNECTED,
|
||||
now, false);
|
||||
return;
|
||||
}
|
||||
if (!_mqtt.connected()) {
|
||||
uint32_t now = millis();
|
||||
MqttBridgeStatus current;
|
||||
getStatus(current);
|
||||
if (current.phase == MqttBridgePhase::Connected) {
|
||||
setStatusPhase(MqttBridgePhase::RetryWait, _mqtt.state(), now, true,
|
||||
now);
|
||||
}
|
||||
if ((uint32_t)(now - _lastReconnectMs) >= RECONNECT_INTERVAL_MS) {
|
||||
_lastReconnectMs = now;
|
||||
_connecting = true;
|
||||
setStatusPhase(MqttBridgePhase::Connecting, MQTT_DISCONNECTED,
|
||||
now, false);
|
||||
if (xTaskCreatePinnedToCore(reconnectTask, "mqtt_conn", 6144, this,
|
||||
1, nullptr, 0) != pdPASS) {
|
||||
_connecting = false; // task OOM — try again next interval
|
||||
setStatusPhase(MqttBridgePhase::RetryWait, MQTT_DISCONNECTED,
|
||||
now, false, now + RECONNECT_INTERVAL_MS);
|
||||
}
|
||||
} else {
|
||||
setStatusPhase(MqttBridgePhase::RetryWait, MQTT_DISCONNECTED,
|
||||
now, false, _lastReconnectMs + RECONNECT_INTERVAL_MS);
|
||||
}
|
||||
return;
|
||||
}
|
||||
_mqtt.loop();
|
||||
if (!_mqtt.loop()) {
|
||||
setStatusPhase(MqttBridgePhase::RetryWait, _mqtt.state(), now, true,
|
||||
now);
|
||||
}
|
||||
}
|
||||
|
||||
// AES-256-GCM seal: out = base64( nonce[12] || ciphertext || tag[16] ). Single-task
|
||||
|
||||
@@ -3,6 +3,32 @@
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
enum class MqttBridgePhase : uint8_t {
|
||||
Unavailable,
|
||||
Disabled,
|
||||
Misconfigured,
|
||||
WaitingForWifi,
|
||||
Connecting,
|
||||
Connected,
|
||||
RetryWait,
|
||||
};
|
||||
|
||||
struct MqttBridgeStatus {
|
||||
MqttBridgePhase phase = MqttBridgePhase::Unavailable;
|
||||
bool available = false;
|
||||
bool requestedEnabled = false;
|
||||
bool publishDm = false;
|
||||
bool publishChannel = false;
|
||||
bool encrypted = false;
|
||||
char host[64] = {};
|
||||
uint16_t port = 1883;
|
||||
int8_t lastResult = -1;
|
||||
uint32_t lastAttemptMs = 0;
|
||||
uint32_t lastConnectedMs = 0;
|
||||
uint32_t lastResultMs = 0;
|
||||
uint32_t retryAtMs = 0;
|
||||
};
|
||||
|
||||
#if defined(HAS_TANMATSU) || defined(HAS_TDISPLAY_P4)
|
||||
// ---- MQTT DISABLED on the ESP32-P4 boards (Tanmatsu + T-Display P4) ----
|
||||
// The P4's Wi-Fi runs through the esp-hosted C6 co-processor; the Arduino WiFiClient / PubSubClient
|
||||
@@ -16,6 +42,7 @@ public:
|
||||
void publishDM(const char*, const uint8_t*, float, uint8_t, uint32_t, const char*) {}
|
||||
void publishChannel(int, const char*, float, uint8_t, uint32_t, const char*) {}
|
||||
bool enabled() const { return false; }
|
||||
void getStatus(MqttBridgeStatus& out) const { out = MqttBridgeStatus{}; }
|
||||
static void saveConfig(const char*, uint16_t, const char*, const char*,
|
||||
bool, bool, const char*, bool) {}
|
||||
void reloadConfig() {}
|
||||
@@ -75,6 +102,7 @@ public:
|
||||
float snr, uint8_t hops, uint32_t ts, const char* text);
|
||||
|
||||
bool enabled() const { return _enabled; }
|
||||
void getStatus(MqttBridgeStatus& out) const;
|
||||
|
||||
// Persist config (called from Settings UI save).
|
||||
static void saveConfig(const char* host, uint16_t port,
|
||||
@@ -87,6 +115,7 @@ private:
|
||||
MqttNbClient _wc;
|
||||
PubSubClient _mqtt{_wc};
|
||||
char _nodeHex[13] = {}; // 6-byte key → 12 hex chars + '\0'
|
||||
bool _requestedEnabled = false;
|
||||
bool _enabled = false;
|
||||
bool _pubDm = false; // DMs off by default (private 1:1 traffic)
|
||||
bool _pubChannel = true; // channel messages on by default
|
||||
@@ -101,11 +130,16 @@ private:
|
||||
// True while the one-shot connect task owns _mqtt/_wc. The loop thread must
|
||||
// not touch either until it clears (PubSubClient is not thread-safe).
|
||||
volatile bool _connecting = false;
|
||||
mutable portMUX_TYPE _statusMux = portMUX_INITIALIZER_UNLOCKED;
|
||||
MqttBridgeStatus _status;
|
||||
|
||||
static const uint32_t RECONNECT_INTERVAL_MS = 15000;
|
||||
|
||||
void loadConfig(); // shared by begin() / reloadConfig()
|
||||
void deriveKey(); // _key = SHA-256(_psk); sets _encOn
|
||||
void setStatusPhase(MqttBridgePhase phase, int result, uint32_t now,
|
||||
bool recordResult, uint32_t retryAt = 0);
|
||||
void syncStatusConfig(MqttBridgePhase phase);
|
||||
bool reconnect();
|
||||
static void reconnectTask(void* arg); // one-shot task body wrapping reconnect()
|
||||
void pub(const char* subtopic, const char* json); // seals if _encOn
|
||||
|
||||
@@ -1728,13 +1728,66 @@ bool touchPrefsSetIgnored(const uint8_t* pub_key6, bool ignored) {
|
||||
}
|
||||
|
||||
// Ignored / blocked sender NAMES (channel/room senders that aren't contacts) ---
|
||||
// One NVS blob "ign_nm" of up to TOUCH_IGNORED_NAMES_MAX fixed-width,
|
||||
// One NVS blob "ign_nm2" of up to TOUCH_IGNORED_NAMES_MAX fixed-width,
|
||||
// NUL-padded TOUCH_IGNORED_NAME_LEN slots. Same read/replace/write scheme as
|
||||
// the 6-byte prefix list above.
|
||||
static const char* KEY_IGN_NAMES = "ign_nm";
|
||||
// The blob carries no header — the entry count is derived by dividing its length by the
|
||||
// slot width — so widening the slot re-slots every stored entry: slot 1 would start inside
|
||||
// the old name 0, every row after the first would render as a mangled suffix and would stop
|
||||
// matching, and the first write back would make that permanent. A new key sidesteps it: the
|
||||
// old blob is read once with the OLD stride, re-slotted, written here, and removed. A key
|
||||
// rename is unambiguous by construction, unlike sniffing the blob length (28 and 32 share
|
||||
// multiples at 224 and 448 bytes, both inside the 16-entry cap).
|
||||
static const char* KEY_IGN_NAMES = "ign_nm2";
|
||||
static const char* KEY_IGN_NAMES_V1 = "ign_nm";
|
||||
constexpr int TOUCH_IGN_NAME_LEN_V1 = 28;
|
||||
|
||||
// One-shot: fold a pre-widening "ign_nm" blob into the current key. No-op once migrated
|
||||
// (and on a fresh device, where neither key exists). Latched for the boot because the
|
||||
// caller runs on the RX path for EVERY incoming message — two NVS key lookups per message
|
||||
// is not a price worth paying for a check that can only change once.
|
||||
static bool s_ign_names_migrated = false;
|
||||
|
||||
static void ignNamesMigrateV1() {
|
||||
if (s_ign_names_migrated) return;
|
||||
if (!s_begun) touchPrefsBegin();
|
||||
if (s_prefs.isKey(KEY_IGN_NAMES) || !s_prefs.isKey(KEY_IGN_NAMES_V1)) {
|
||||
s_ign_names_migrated = true; // nothing to fold in, now or on any later call
|
||||
return;
|
||||
}
|
||||
|
||||
char old_buf[TOUCH_IGNORED_NAMES_MAX * TOUCH_IGN_NAME_LEN_V1];
|
||||
size_t n = s_prefs.getBytes(KEY_IGN_NAMES_V1, old_buf, sizeof(old_buf));
|
||||
const int count = (n == 0 || n > sizeof(old_buf)) ? 0 : (int)(n / TOUCH_IGN_NAME_LEN_V1);
|
||||
|
||||
char buf[TOUCH_IGNORED_NAMES_MAX * TOUCH_IGNORED_NAME_LEN];
|
||||
memset(buf, 0, sizeof(buf));
|
||||
for (int i = 0; i < count; ++i) {
|
||||
// Old slots hold at most 27 chars, so they always fit the wider one.
|
||||
strncpy(&buf[i * TOUCH_IGNORED_NAME_LEN], &old_buf[i * TOUCH_IGN_NAME_LEN_V1],
|
||||
TOUCH_IGN_NAME_LEN_V1 - 1);
|
||||
}
|
||||
|
||||
s_prefs.end();
|
||||
bool ok = false;
|
||||
if (s_prefs.begin(TOUCH_NS, false)) {
|
||||
ok = (count == 0) ||
|
||||
s_prefs.putBytes(KEY_IGN_NAMES, buf, (size_t)(count * TOUCH_IGNORED_NAME_LEN)) > 0;
|
||||
if (ok) s_prefs.remove(KEY_IGN_NAMES_V1); // only once the new key actually holds the list
|
||||
s_prefs.end();
|
||||
}
|
||||
s_begun = s_prefs.begin(TOUCH_NS, true);
|
||||
// Latch on SUCCESS only. Latching a failed fold would leave the list reading empty for
|
||||
// the rest of the boot — every block silently stops firing — and the next Block tap
|
||||
// would then write a fresh ign_nm2 that orphans every old entry for good. Retrying
|
||||
// costs the two key lookups per message the latch exists to avoid, but only while the
|
||||
// store is already refusing writes.
|
||||
s_ign_names_migrated = ok;
|
||||
}
|
||||
|
||||
static int ignNamesReadAll(char out[TOUCH_IGNORED_NAMES_MAX * TOUCH_IGNORED_NAME_LEN]) {
|
||||
if (!s_begun) touchPrefsBegin();
|
||||
ignNamesMigrateV1();
|
||||
if (!s_prefs.isKey(KEY_IGN_NAMES)) return 0; // absent on a fresh device — skip [E] NOT_FOUND
|
||||
size_t n = s_prefs.getBytes(KEY_IGN_NAMES, out, TOUCH_IGNORED_NAMES_MAX * TOUCH_IGNORED_NAME_LEN);
|
||||
if (n == 0 || n > (size_t)(TOUCH_IGNORED_NAMES_MAX * TOUCH_IGNORED_NAME_LEN)) return 0;
|
||||
|
||||
@@ -392,7 +392,13 @@ int touchPrefsCopyIgnored(uint8_t* out_buf);
|
||||
* • touchPrefsCopyIgnoredNames: copy every stored name into `out_buf`
|
||||
* (>= TOUCH_IGNORED_NAMES_MAX * TOUCH_IGNORED_NAME_LEN bytes); returns count. */
|
||||
constexpr int TOUCH_IGNORED_NAMES_MAX = 16;
|
||||
constexpr int TOUCH_IGNORED_NAME_LEN = 28; // fixed NUL-padded slot (>= MAX_SENDER_NAME+1)
|
||||
// Fixed NUL-padded slot, and it MUST stay >= UITask::MAX_SENDER_NAME + 1 — a slot narrower
|
||||
// than a sender stores a cut name that the full-width RX check can never match again, so
|
||||
// the block shows in the list and silently never fires. That invariant was prose only; it
|
||||
// is now a static_assert in UITask.cpp. Widened 28 -> 32 with MAX_SENDER_NAME 24 -> 31; the
|
||||
// slot width is baked into the stored blob (the entry count is its length / this), so the
|
||||
// key was renamed in the same change to migrate rather than mis-slot the old one.
|
||||
constexpr int TOUCH_IGNORED_NAME_LEN = 32;
|
||||
bool touchPrefsIsNameIgnored(const char* name);
|
||||
bool touchPrefsSetNameIgnored(const char* name, bool ignored);
|
||||
int touchPrefsCopyIgnoredNames(char* out_buf);
|
||||
|
||||
@@ -326,12 +326,12 @@ static const char WS_HTML_TERMINAL_PAGE[] =
|
||||
"var QCMDS=[{h:'CHAT'},{l:'list - contacts',c:'list'},{l:'channels',c:'channels'},{l:'to <name>',c:'to '},{l:'send <text>',c:'send '},{l:'public <text>',c:'public '},{l:'exit',c:'exit'},\n"
|
||||
" {h:'INFO'},{l:'help',c:'help'},{l:'ver - firmware version',c:'ver'},{l:'clock - RTC time',c:'clock'},{l:'status',c:'status'},{l:'get - radio params',c:'get'},\n"
|
||||
" {h:'RADIO / MESH'},{l:'advert',c:'advert'},{l:'advert.zerohop',c:'advert.zerohop'},{l:'set name <v>',c:'set name '},{l:'set freq <MHz>',c:'set freq '},{l:'set bw <kHz>',c:'set bw '},{l:'set sf <7-12>',c:'set sf '},{l:'set cr <5-8>',c:'set cr '},{l:'set tx <dBm>',c:'set tx '},\n"
|
||||
" {h:'CONNECTIVITY'},{l:'wifi status',c:'wifi status'},{l:'wifi on',c:'wifi on'},{l:'wifi off',c:'wifi off'},{l:'wifi scan',c:'wifi scan'},{l:'wifi apply',c:'wifi apply'},{l:'wifi clear',c:'wifi clear'},{l:'tcp status',c:'tcp status'},{l:'tcp on',c:'tcp on'},{l:'tcp off',c:'tcp off'},{l:'ble status',c:'ble status'},{l:'ble on',c:'ble on'},{l:'ble off',c:'ble off'},{l:'ota status',c:'ota status'},{l:'ota start',c:'ota start'},\n"
|
||||
" {h:'CONNECTIVITY'},{l:'wifi status',c:'wifi status'},{l:'wifi on',c:'wifi on'},{l:'wifi off',c:'wifi off'},{l:'wifi scan',c:'wifi scan'},{l:'wifi apply',c:'wifi apply'},{l:'wifi clear',c:'wifi clear'},{l:'mqtt status',c:'mqtt status'},{l:'tcp status',c:'tcp status'},{l:'tcp on',c:'tcp on'},{l:'tcp off',c:'tcp off'},{l:'ble status',c:'ble status'},{l:'ble on',c:'ble on'},{l:'ble off',c:'ble off'},{l:'ota status',c:'ota status'},{l:'ota start',c:'ota start'},\n"
|
||||
" {h:'SYSTEM'},{l:'reboot',c:'reboot'},{l:'bootloader - download mode',c:'bootloader'}];\n"
|
||||
"function cmdPicker(){var h='<h4>Commands</h4>';QCMDS.forEach(function(x,i){if(x.h)h+='<div class=sec>'+esc(x.h)+'</div>';else h+='<button class=si data-i='+i+'>'+esc(x.l)+'</button>'});\n"
|
||||
" E('sheet').innerHTML=h;each(E('sheet').querySelectorAll('.si'),function(b){b.onclick=function(){var x=QCMDS[+b.getAttribute('data-i')];closeOv();showTab('term');E('i').value=x.c;E('i').focus()}});\n"
|
||||
" E('sheet').classList.add('on');E('scrim').classList.add('on')}\n"
|
||||
"var hist=[],hi=0,CMDS=['help','?','status','ver','clock','get','advert','advert.zerohop','reboot','bootloader','set ','tcp ','ble ','ota ','wifi ','list','contacts','channels','to ','send ','public ','exit','leave','ok','cancel','clear'];\n"
|
||||
"var hist=[],hi=0,CMDS=['help','?','status','ver','clock','get','advert','advert.zerohop','reboot','bootloader','set ','mqtt ','tcp ','ble ','ota ','wifi ','list','contacts','channels','to ','send ','public ','exit','leave','ok','cancel','clear'];\n"
|
||||
"function tadd(t,cls){var s=document.createElement('span');if(cls)s.className=cls;s.textContent=t;E('o').appendChild(s);E('o').scrollTop=E('o').scrollHeight}\n"
|
||||
"function tsend(){var v=E('i').value;E('i').value='';if(!v.trim())return;if(v.trim()=='clear'){E('o').innerHTML='';return}\n"
|
||||
" tadd('\\n> '+v+'\\n','c');hist.push(v);hi=hist.length;if(!wsSend(v))tadd('(not connected)\\n','err')}\n"
|
||||
|
||||
@@ -1238,6 +1238,58 @@ void setup() {
|
||||
}
|
||||
}
|
||||
#endif
|
||||
// Repair an unusable SPIFFS whether or not the profile lives on SD.
|
||||
//
|
||||
// This used to be `if (!sd_storage && !spiffs_ok)`, which is a catch-22: an
|
||||
// invalid SPIFFS forces want_full_sd above ("Move the WHOLE store to SD when
|
||||
// the device has no usable SPIFFS"), and being on SD then skipped the repair —
|
||||
// so the partition stayed unformatted forever. Every boot logged
|
||||
// `E SPIFFS: mount failed, -10025` and internal storage read as 0 bytes.
|
||||
//
|
||||
// The consequence is not cosmetic. The migration path documents that it
|
||||
// "never deletes its SPIFFS sources, so a device that already adopted the card
|
||||
// still has a byte-identical copy here" — i.e. the design assumes internal
|
||||
// flash holds a second copy of the identity. A device that went SD-native
|
||||
// because SPIFFS was broken never had that copy, so /meshcomod/identity/_main.id
|
||||
// on the card is the ONLY thing making it its node. On the ThinkNode M9 the
|
||||
// card is soldered, so it cannot even be moved to another device to recover.
|
||||
//
|
||||
// Formatting here is safe for the profile: sd_storage was already decided
|
||||
// above and is not revisited, so this only makes the internal filesystem
|
||||
// usable again — it does not move anyone's data. A brand-new device is
|
||||
// unaffected (a virgin partition is formatted on the same call it always was).
|
||||
// KNOWN BUG, deliberately NOT fixed here yet — read before touching this.
|
||||
//
|
||||
// The condition is a catch-22. An invalid SPIFFS forces want_full_sd above
|
||||
// ("Move the WHOLE store to SD when the device has no usable SPIFFS"), and
|
||||
// being on SD then SKIPS this repair — so the partition stays unformatted
|
||||
// forever. Every boot logs `E SPIFFS: mount failed, -10025` and internal
|
||||
// storage reads 0 bytes. It is not cosmetic: the migration path documents that
|
||||
// it "never deletes its SPIFFS sources, so a device that already adopted the
|
||||
// card still has a byte-identical copy here", i.e. the design assumes internal
|
||||
// flash holds a SECOND copy of the identity. A device that went SD-native
|
||||
// *because* SPIFFS was broken never had that copy, so
|
||||
// /meshcomod/identity/_main.id on the card is the only thing making it its
|
||||
// node — and on the ThinkNode M9 that card is soldered.
|
||||
//
|
||||
// Reproduced on M9 hardware 2026-08-28. Changing this to
|
||||
// `if (!spiffs_ok) spiffs_ok = SPIFFS.begin(true);` DOES repair the partition
|
||||
// (the boot then reports the internal fs as ok, and the profile correctly
|
||||
// stays on SD), but that boot then panicked and looped:
|
||||
// Guru Meditation Error: Core 1 panic'ed (InstrFetchProhibited)
|
||||
// Backtrace: WiFiEventCbList::WiFiEventCbList(const&)
|
||||
// -> WiFiGenericClass::_eventCallback -> _arduino_event_task
|
||||
// The follow-up matters: once the partition was formatted, the SAME build with
|
||||
// this line back as-is — SPIFFS now mounting AND the profile on SD, i.e. the
|
||||
// exact state that crashed — boots cleanly and repeatedly. So the fault is not
|
||||
// "SPIFFS is mounted"; it is almost certainly the 3.375 MB format ITSELF
|
||||
// running at this point in setup(), blocking long enough to race the Arduino
|
||||
// Wi-Fi event task that is starting up alongside it.
|
||||
//
|
||||
// So the fix is not this one-liner: it is to repair the partition somewhere
|
||||
// that can afford a multi-second blocking format (before the Wi-Fi/event tasks
|
||||
// exist, or deferred to a UI-driven action with a progress notice), and to
|
||||
// confirm on hardware that a cold first-format boot survives.
|
||||
if (!sd_storage && !spiffs_ok) SPIFFS.begin(true); // last resort: format SPIFFS
|
||||
Serial.printf("[BOOT] storage: %s\n", sd_storage ? "SD /meshcomod" : "SPIFFS");
|
||||
#if defined(ESP32_PLATFORM) && defined(HAS_TOUCH_UI)
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
|
||||
// A channel/group post arrives on the wire as "SenderName: body" — the author is
|
||||
// part of the text, not a separate field (unlike a DM, where the sender rides
|
||||
// along as from_name). Split it so the UI can label the bubble with the author
|
||||
// and show only the body underneath.
|
||||
namespace ChannelSenderSplit {
|
||||
|
||||
// The widest author a channel post can legitimately name. MeshCore keeps every
|
||||
// name in a char[32] (ContactInfo::name, ChannelDetails::name,
|
||||
// NodePrefs::node_name), so 31 characters is the whole wire width.
|
||||
constexpr size_t kMaxWireName = 31;
|
||||
|
||||
// Copies the embedded author into `sender_out` (always NUL-terminated, empty when
|
||||
// there is nothing to split) and returns the start of the body — `text` itself
|
||||
// when the string carries no author prefix.
|
||||
//
|
||||
// A name longer than the destination is TRUNCATED, not rejected. MeshCore names
|
||||
// run to 31 chars while UIMessage::sender holds 24, and refusing the split for
|
||||
// the overflowing ones is what put the channel name in the bubble header and left
|
||||
// "nick: message" in the body.
|
||||
//
|
||||
// A prefix wider than kMaxWireName is a different thing entirely and IS rejected:
|
||||
// no name can be that long, so the ": " belongs to an unprefixed message ("the
|
||||
// repeater at Ouderkerk: it works"). Splitting there would invent an author and
|
||||
// hide the start of the body. That plausibility check is what the old
|
||||
// `slen <= MAX_SENDER_NAME` test provided; it has to be the WIRE width and not
|
||||
// the destination width, or a long real name gets rejected all over again.
|
||||
//
|
||||
// A truncated name ends in "..." (ASCII, like chatFitLeadingEllipsis — the baked
|
||||
// fonts carry no U+2026), so a cut name reads as cut rather than as a different
|
||||
// node with a shorter name. The marker is part of the stored sender, so the
|
||||
// bubble label, the chat-list preview and block-by-name all agree on one string.
|
||||
inline const char* split(const char* text, char* sender_out, size_t sender_cap) {
|
||||
if (sender_out && sender_cap > 0) sender_out[0] = '\0';
|
||||
if (!text) return "";
|
||||
if (!sender_out || sender_cap < 2) return text;
|
||||
|
||||
const char* colon = strstr(text, ": ");
|
||||
if (!colon || colon == text) return text;
|
||||
if (static_cast<size_t>(colon - text) > kMaxWireName) return text; // not a name — see above
|
||||
|
||||
static const char kEllipsis[] = "...";
|
||||
const size_t kEllipsisLen = sizeof(kEllipsis) - 1;
|
||||
|
||||
const size_t cap = sender_cap - 1; // bytes available, NUL excluded
|
||||
size_t slen = static_cast<size_t>(colon - text);
|
||||
const bool truncated = slen > cap;
|
||||
// Only mark when the name still gets at least as many bytes as the marker —
|
||||
// on a destination too small for that, "nick" says more than "n...".
|
||||
const bool mark = truncated && cap >= 2 * kEllipsisLen;
|
||||
if (truncated) {
|
||||
slen = mark ? cap - kEllipsisLen : cap;
|
||||
// Never cut a multi-byte character in half: step back to the start of the
|
||||
// sequence the cap lands inside, so the label gets valid UTF-8 rather than a
|
||||
// stray '*' from the missing-glyph sanitiser.
|
||||
while (slen > 0 && (static_cast<uint8_t>(text[slen]) & 0xC0U) == 0x80U) --slen;
|
||||
if (slen == 0) return text; // one long sequence and nothing fits — leave it whole
|
||||
}
|
||||
|
||||
memcpy(sender_out, text, slen);
|
||||
if (mark) {
|
||||
memcpy(sender_out + slen, kEllipsis, kEllipsisLen);
|
||||
slen += kEllipsisLen;
|
||||
}
|
||||
sender_out[slen] = '\0';
|
||||
return colon + 2;
|
||||
}
|
||||
|
||||
} // namespace ChannelSenderSplit
|
||||
@@ -548,11 +548,12 @@ void submit() {
|
||||
consoleWriteLine("mesh contacts, chans, unread");
|
||||
consoleWriteLine(" chat <name> show a thread");
|
||||
consoleWriteLine(" discover / discovered");
|
||||
consoleWriteLine("radio stat, batt, wifi");
|
||||
consoleWriteLine(" read <name> clear that thread's unread");
|
||||
consoleWriteLine(" monitor on|off show arriving messages");
|
||||
consoleWriteLine(" to <name> pick a contact or channel");
|
||||
consoleWriteLine(" msg <text> send to it");
|
||||
consoleWriteLine("network mqtt status, tcp status, ble status");
|
||||
consoleWriteLine("radio stat, batt, wifi");
|
||||
consoleWriteLine("node anything the CLI answers:");
|
||||
consoleWriteLine(" advert, get name, set name <x>, time, ver");
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
#include <string.h>
|
||||
|
||||
// A stored sender name is split across two on-disk fields: the head lives in the frozen
|
||||
// UiHistoryMsg::sender, and anything past it in UiSegMsg::sender_ext, which is appended at
|
||||
// the very end of the segment record. The split exists so the record can hold a full-width
|
||||
// name without moving any pre-existing field — see the comment on UiSegMsg::sender_ext.
|
||||
//
|
||||
// Nothing outside the persistence layer sees the split: UIMessage::sender is one string,
|
||||
// and these two calls are the only places the halves come apart and go back together.
|
||||
//
|
||||
// A multi-byte character may straddle the boundary. That is harmless — the halves are
|
||||
// always rejoined before the name is measured, drawn or compared — so this deliberately
|
||||
// does NOT try to split on a character boundary, which would waste bytes of a field sized
|
||||
// to hold the longest name exactly.
|
||||
namespace SenderExtField {
|
||||
|
||||
// Writes `sender` into the two destination fields. `base` is always NUL-terminated; `ext`
|
||||
// carries the overflow and is NOT NUL-terminated when it is full (it is a fixed-width tail,
|
||||
// and its length is implied by base being full). Both are zero-filled first, so a short
|
||||
// name leaves a clean record.
|
||||
inline void store(const char* sender, char* base, size_t base_cap, char* ext, size_t ext_cap) {
|
||||
if (base && base_cap > 0) memset(base, 0, base_cap);
|
||||
if (ext && ext_cap > 0) memset(ext, 0, ext_cap);
|
||||
if (!sender || !base || base_cap == 0) return;
|
||||
|
||||
// Walked with a pointer rather than indexed + memcpy'd: the copy length and the bounds
|
||||
// check are then the same expression, which keeps -Warray-bounds quiet on inlined
|
||||
// short literals. These are 31 bytes at most, so byte-at-a-time costs nothing.
|
||||
const char* p = sender;
|
||||
size_t head = 0;
|
||||
while (head + 1 < base_cap && *p) base[head++] = *p++;
|
||||
base[head] = '\0';
|
||||
if (head + 1 < base_cap || !ext || ext_cap == 0) return; // fitted, or nowhere to spill
|
||||
|
||||
size_t tail = 0;
|
||||
while (tail < ext_cap && *p) ext[tail++] = *p++;
|
||||
}
|
||||
|
||||
// Rejoins the two fields into `out`. Reads are bounded by the FIELD sizes, not by strlen:
|
||||
// a corrupt record may leave `base` unterminated, and it is followed on disk by the message
|
||||
// body, so an unbounded read would splice the text onto the name.
|
||||
inline void load(const char* base, size_t base_cap, const char* ext, size_t ext_cap,
|
||||
char* out, size_t out_cap) {
|
||||
if (!out || out_cap == 0) return;
|
||||
out[0] = '\0';
|
||||
if (!base || base_cap == 0) return;
|
||||
|
||||
const size_t head_cap = base_cap - 1;
|
||||
const char* p = base;
|
||||
size_t head = 0;
|
||||
while (head < head_cap && head + 1 < out_cap && *p) out[head++] = *p++;
|
||||
out[head] = '\0';
|
||||
|
||||
// The tail only means anything when the head field is exactly full — that is the only
|
||||
// way a name could have spilled. Anything else is an old record's zero-fill or garbage
|
||||
// behind a short name, and must not be appended.
|
||||
if (head != head_cap || !ext || ext_cap == 0) return;
|
||||
|
||||
const char* q = ext;
|
||||
size_t n = head;
|
||||
size_t tail = 0;
|
||||
while (tail < ext_cap && n + 1 < out_cap && *q) { out[n++] = *q++; ++tail; }
|
||||
out[n] = '\0';
|
||||
}
|
||||
|
||||
} // namespace SenderExtField
|
||||
+651
-96
File diff suppressed because it is too large.
Load diff
@@ -36,7 +36,11 @@ public:
|
||||
static const int MAX_UI_MESSAGES_SD = 5000;
|
||||
static const int MAX_UI_THREADS = 48;
|
||||
static const int MAX_THREAD_NAME = 32;
|
||||
static const int MAX_SENDER_NAME = 24;
|
||||
// Full MeshCore name width: ContactInfo::name / ChannelDetails::name / NodePrefs::
|
||||
// node_name are all char[32], so 31 chars + NUL is exactly lossless. Was 24, which
|
||||
// silently dropped every longer name. The PERSISTED width is frozen separately
|
||||
// (k_ui_disk_sender_len in UITask.cpp) — raising this does not move any on-disk field.
|
||||
static const int MAX_SENDER_NAME = 31;
|
||||
static const int MAX_MSG_TEXT = 160; // full LoRa text length (was 96 -> cut long msgs ~3 lines)
|
||||
static const int MAX_UI_PATH = 32; // inbound-route bytes/message for the Info popup (covers deep + multi-byte-hash routes)
|
||||
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include <assert.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "ui-touch/ChannelSenderSplit.h"
|
||||
|
||||
// UIMessage::sender is char[MAX_SENDER_NAME + 1] with MAX_SENDER_NAME == 31 — the full
|
||||
// MeshCore name width (ContactInfo::name[32]), so every real name now fits and the
|
||||
// truncation path is unreachable from the firmware's own callers. It is still exercised
|
||||
// below against narrower destinations: the helper is documented to truncate to whatever
|
||||
// it is handed, and the beta that shipped a 24-char field relies on exactly that.
|
||||
static const size_t kSenderCap = 32;
|
||||
|
||||
// The frozen on-disk width (k_ui_disk_sender_len) — and what the previous beta's
|
||||
// UIMessage::sender was. The truncation + UTF-8 back-off cases run against this.
|
||||
static const size_t kNarrowCap = 25;
|
||||
|
||||
int main() {
|
||||
char sender[kSenderCap];
|
||||
|
||||
// Ordinary channel post: author out, body in.
|
||||
const char* body = ChannelSenderSplit::split("nick: hello there", sender, sizeof sender);
|
||||
assert(strcmp(sender, "nick") == 0);
|
||||
assert(strcmp(body, "hello there") == 0);
|
||||
|
||||
// The regression this all started from: a 27-char name. It used to fail the split
|
||||
// outright, so the bubble header showed the channel and the body kept reading
|
||||
// "nick: message". At the full width it is stored whole, with no marker.
|
||||
const char* long_name = "Ouderkerk Repeater Node Two: on air";
|
||||
body = ChannelSenderSplit::split(long_name, sender, sizeof sender);
|
||||
assert(strcmp(sender, "Ouderkerk Repeater Node Two") == 0);
|
||||
assert(strlen(sender) == 27);
|
||||
assert(strcmp(body, "on air") == 0);
|
||||
|
||||
// The longest name the wire can carry: 31 chars, stored exactly, no marker.
|
||||
const char* longest = "1234567890123456789012345678901: body";
|
||||
body = ChannelSenderSplit::split(longest, sender, sizeof sender);
|
||||
assert(strlen(sender) == 31);
|
||||
assert(strcmp(sender, "1234567890123456789012345678901") == 0);
|
||||
assert(strcmp(body, "body") == 0);
|
||||
|
||||
// One char PAST the wire width is not a name at all — no node can be called this — so
|
||||
// the ": " belongs to the message. Leave the text whole rather than inventing an author
|
||||
// and hiding the start of the body.
|
||||
const char* not_a_name = "12345678901234567890123456789012: body";
|
||||
assert(strlen(not_a_name) == 38);
|
||||
body = ChannelSenderSplit::split(not_a_name, sender, sizeof sender);
|
||||
assert(sender[0] == '\0');
|
||||
assert(body == not_a_name);
|
||||
|
||||
// The realistic shape of that: an unprefixed post whose body just happens to contain
|
||||
// ": ". It must survive intact, whole, with the caller's own sender label.
|
||||
const char* prose = "the repeater at Ouderkerk is back up: full quieting again";
|
||||
body = ChannelSenderSplit::split(prose, sender, sizeof sender);
|
||||
assert(sender[0] == '\0');
|
||||
assert(body == prose);
|
||||
|
||||
// ---- Narrower destination: truncate the label, never reject the split -------------
|
||||
char narrow[kNarrowCap];
|
||||
|
||||
// The 27-char name again. Too long for this field, so it is cut and marked with "..."
|
||||
// — a shortened name must not read as a different node.
|
||||
body = ChannelSenderSplit::split(long_name, narrow, sizeof narrow);
|
||||
assert(strlen(narrow) == 24);
|
||||
assert(strcmp(narrow, "Ouderkerk Repeater No...") == 0);
|
||||
assert(strcmp(body, "on air") == 0);
|
||||
|
||||
// The widest name the wire can carry still splits here — cut, but the author is out
|
||||
// of the body, which is the whole point.
|
||||
body = ChannelSenderSplit::split(longest, narrow, sizeof narrow);
|
||||
assert(strcmp(narrow, "123456789012345678901...") == 0);
|
||||
assert(strcmp(body, "body") == 0);
|
||||
|
||||
// One char over the cap still gets the marker (name budget 21, not 24).
|
||||
const char* one_over = "1234567890123456789012345: body";
|
||||
body = ChannelSenderSplit::split(one_over, narrow, sizeof narrow);
|
||||
assert(strcmp(narrow, "123456789012345678901...") == 0);
|
||||
assert(strcmp(body, "body") == 0);
|
||||
|
||||
// Exactly at the cap: 24 chars, no truncation, no marker.
|
||||
const char* exact = "123456789012345678901234: body";
|
||||
body = ChannelSenderSplit::split(exact, narrow, sizeof narrow);
|
||||
assert(strcmp(narrow, "123456789012345678901234") == 0);
|
||||
assert(strcmp(body, "body") == 0);
|
||||
|
||||
// Truncation lands on a character boundary, never inside a multi-byte sequence.
|
||||
// "Ouderkerkk" (10) + U+2600 (3 bytes) x 5 = 25 bytes; the 21-byte name budget
|
||||
// (24 minus the marker) falls inside the fourth sun, so it backs off to 19.
|
||||
const char* utf8_name = "Ouderkerkk\xE2\x98\x80\xE2\x98\x80\xE2\x98\x80\xE2\x98\x80\xE2\x98\x80: hi";
|
||||
body = ChannelSenderSplit::split(utf8_name, narrow, sizeof narrow);
|
||||
assert(strcmp(narrow, "Ouderkerkk\xE2\x98\x80\xE2\x98\x80\xE2\x98\x80...") == 0);
|
||||
assert(strlen(narrow) == 22);
|
||||
assert(strcmp(body, "hi") == 0);
|
||||
|
||||
// Same shape one char shorter, so the budget already sits on a boundary: the back-off
|
||||
// must not eat a whole character that fits.
|
||||
const char* utf8_fits = "Ouderkerk\xE2\x98\x80\xE2\x98\x80\xE2\x98\x80\xE2\x98\x80\xE2\x98\x80\xE2\x98\x80: hi";
|
||||
body = ChannelSenderSplit::split(utf8_fits, narrow, sizeof narrow);
|
||||
assert(strcmp(narrow, "Ouderkerk\xE2\x98\x80\xE2\x98\x80\xE2\x98\x80\xE2\x98\x80...") == 0);
|
||||
assert(strlen(narrow) == 24);
|
||||
assert(strcmp(body, "hi") == 0);
|
||||
|
||||
// Destination too small to hold a marker: truncate plainly rather than spending the
|
||||
// whole label on dots.
|
||||
char tiny[5];
|
||||
body = ChannelSenderSplit::split("nickname: body", tiny, sizeof tiny);
|
||||
assert(strcmp(tiny, "nick") == 0);
|
||||
assert(strcmp(body, "body") == 0);
|
||||
|
||||
// ---- Nothing to split --------------------------------------------------------------
|
||||
|
||||
// No author prefix — text passes through untouched, sender stays empty so the
|
||||
// caller falls back to from_name.
|
||||
const char* plain = "just a message";
|
||||
body = ChannelSenderSplit::split(plain, sender, sizeof sender);
|
||||
assert(sender[0] == '\0');
|
||||
assert(body == plain);
|
||||
|
||||
// A bare colon without the space is not a separator.
|
||||
const char* no_space = "nick:hello";
|
||||
body = ChannelSenderSplit::split(no_space, sender, sizeof sender);
|
||||
assert(sender[0] == '\0');
|
||||
assert(body == no_space);
|
||||
|
||||
// Leading separator: empty author is not a name.
|
||||
const char* leading = ": body";
|
||||
body = ChannelSenderSplit::split(leading, sender, sizeof sender);
|
||||
assert(sender[0] == '\0');
|
||||
assert(body == leading);
|
||||
|
||||
// First separator wins — a colon in the body does not re-split.
|
||||
body = ChannelSenderSplit::split("nick: ratio 3: 1", sender, sizeof sender);
|
||||
assert(strcmp(sender, "nick") == 0);
|
||||
assert(strcmp(body, "ratio 3: 1") == 0);
|
||||
|
||||
// Degenerate inputs.
|
||||
assert(strcmp(ChannelSenderSplit::split(nullptr, sender, sizeof sender), "") == 0);
|
||||
assert(sender[0] == '\0');
|
||||
const char* keep = "nick: hi";
|
||||
assert(ChannelSenderSplit::split(keep, nullptr, 0) == keep);
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include <assert.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "ui-touch/SenderExtField.h"
|
||||
|
||||
// The on-disk shape: base is the frozen v6 field (24 chars + NUL), ext is the appended
|
||||
// tail, and together they hold MAX_SENDER_NAME == 31 chars.
|
||||
static const size_t kBaseCap = 25;
|
||||
static const size_t kExtCap = 7;
|
||||
static const size_t kOutCap = 32;
|
||||
|
||||
int main() {
|
||||
char base[kBaseCap], ext[kExtCap], out[kOutCap];
|
||||
|
||||
// Short name: fits the base field, ext stays empty.
|
||||
SenderExtField::store("nick", base, sizeof base, ext, sizeof ext);
|
||||
assert(strcmp(base, "nick") == 0);
|
||||
for (size_t i = 0; i < kExtCap; ++i) assert(ext[i] == '\0');
|
||||
SenderExtField::load(base, sizeof base, ext, sizeof ext, out, sizeof out);
|
||||
assert(strcmp(out, "nick") == 0);
|
||||
|
||||
// Exactly 24: fills the base field with no spill. The tail must stay empty, or the
|
||||
// "base is full" test below would resurrect garbage.
|
||||
const char* exactly24 = "123456789012345678901234";
|
||||
assert(strlen(exactly24) == 24);
|
||||
SenderExtField::store(exactly24, base, sizeof base, ext, sizeof ext);
|
||||
assert(strcmp(base, exactly24) == 0);
|
||||
for (size_t i = 0; i < kExtCap; ++i) assert(ext[i] == '\0');
|
||||
SenderExtField::load(base, sizeof base, ext, sizeof ext, out, sizeof out);
|
||||
assert(strcmp(out, exactly24) == 0);
|
||||
|
||||
// A full 31-char name — the case the whole split exists for. Round-trips intact.
|
||||
const char* full = "Ouderkerk Repeater Node Twelve";
|
||||
assert(strlen(full) == 30);
|
||||
SenderExtField::store(full, base, sizeof base, ext, sizeof ext);
|
||||
assert(strncmp(base, full, 24) == 0 && base[24] == '\0');
|
||||
assert(memcmp(ext, full + 24, 6) == 0);
|
||||
SenderExtField::load(base, sizeof base, ext, sizeof ext, out, sizeof out);
|
||||
assert(strcmp(out, full) == 0);
|
||||
|
||||
// The longest possible name fills ext completely, leaving it unterminated. load() is
|
||||
// bounded by the field size, so it must not read past.
|
||||
const char* longest = "1234567890123456789012345678901";
|
||||
assert(strlen(longest) == 31);
|
||||
SenderExtField::store(longest, base, sizeof base, ext, sizeof ext);
|
||||
assert(memcmp(ext, longest + 24, kExtCap) == 0); // no NUL anywhere in ext
|
||||
SenderExtField::load(base, sizeof base, ext, sizeof ext, out, sizeof out);
|
||||
assert(strcmp(out, longest) == 0);
|
||||
|
||||
// An OLD record: base written by a pre-widening build, ext zero-filled by the loader's
|
||||
// prefix read. Must yield exactly the stored short name.
|
||||
memset(ext, 0, sizeof ext);
|
||||
memset(base, 0, sizeof base);
|
||||
strcpy(base, "olde name");
|
||||
SenderExtField::load(base, sizeof base, ext, sizeof ext, out, sizeof out);
|
||||
assert(strcmp(out, "olde name") == 0);
|
||||
|
||||
// Garbage in ext behind a NON-full base must be ignored — that is the corrupt-record
|
||||
// case, and appending it would splice junk onto a valid short name.
|
||||
memset(ext, 'X', sizeof ext);
|
||||
SenderExtField::load(base, sizeof base, ext, sizeof ext, out, sizeof out);
|
||||
assert(strcmp(out, "olde name") == 0);
|
||||
|
||||
// An unterminated base field (corrupt record) must not read into the message body that
|
||||
// follows it on disk: the copy stops at the field width.
|
||||
char raw_base[kBaseCap];
|
||||
memset(raw_base, 'A', sizeof raw_base); // no NUL at all
|
||||
memset(ext, 0, sizeof ext);
|
||||
SenderExtField::load(raw_base, sizeof raw_base, ext, sizeof ext, out, sizeof out);
|
||||
assert(strlen(out) == 24);
|
||||
|
||||
// A multi-byte character straddling the boundary: split anywhere, rejoin exact. The
|
||||
// halves are never used apart, so the byte-level split is deliberately not aligned.
|
||||
const char* utf8 = "Ouderkerkkkkkkkkkkkkkkk\xE2\x98\x80"; // 23 ASCII + 3-byte U+2600
|
||||
assert(strlen(utf8) == 26);
|
||||
SenderExtField::store(utf8, base, sizeof base, ext, sizeof ext);
|
||||
assert((unsigned char)base[23] == 0xE2); // sequence is cut mid-character on disk
|
||||
SenderExtField::load(base, sizeof base, ext, sizeof ext, out, sizeof out);
|
||||
assert(strcmp(out, utf8) == 0); // and comes back whole
|
||||
|
||||
// Degenerate inputs.
|
||||
SenderExtField::store(nullptr, base, sizeof base, ext, sizeof ext);
|
||||
assert(base[0] == '\0');
|
||||
SenderExtField::load(base, sizeof base, ext, sizeof ext, out, sizeof out);
|
||||
assert(out[0] == '\0');
|
||||
return 0;
|
||||
}
|
||||
@@ -363,8 +363,9 @@ ruled out the tile-slot pool, the tab-bar removal, and the key-drain timing:
|
||||
touchless board it was uncontrollable dead space. `TABBAR_H` is now 0 for
|
||||
M9 and the btnmatrix is hidden; every tab remains reachable via the dedicated
|
||||
HOME/MESSAGE/MAP keys and the app drawer's Chats/Contacts/Map/Settings tiles.
|
||||
Content gains the 30 px row; the update / chat-unread badges anchor to the
|
||||
bottom corners instead of the (gone) bar.
|
||||
Content gains the 30 px row. The M9 draws no notification badges there;
|
||||
unread messages and newly discovered contacts instead blink small accent-colour
|
||||
envelope/person glyphs in the former Chats and Contacts nav slots.
|
||||
- **Spectrum sweep crawl fixed (LR1110-specific)**: `LR11x0::getRSSI(false)` is
|
||||
not the SX126x's cheap register read — it internally re-arms RX and drops to
|
||||
standby around EVERY call, so the 6-read peak-hold per bin paid ~960 extra
|
||||
@@ -667,6 +668,211 @@ Gotchas for anyone revisiting this:
|
||||
onMapTabActivated). The open levers are decoding on core 0, or the M9's SD
|
||||
clock (still 4 MHz — see the deferred list below).
|
||||
|
||||
**SUPERSEDED by measurement, 2026-08-28.** The decode-vs-read framing above is
|
||||
directionally right but was reasoning about the two SMALLER halves. Instrumented
|
||||
on hardware (`[MAPPROF]`, audit pass 3 below), a 9-tile cold open split:
|
||||
|
||||
```
|
||||
total=2189ms probe=0ms render=2080ms
|
||||
[read=497ms decode=539ms other=1042ms tiles=9] markers=10ms
|
||||
```
|
||||
|
||||
`other` — the per-tile `lv_refr_now()` progressive paint — cost **more than the
|
||||
SD reads and the JPEG decodes combined**. On the immersive full-screen map each
|
||||
call composites the whole 240x320 panel *and* alpha-blends the transparent
|
||||
status + tab bars over it: ~115 ms a call, nine times. Throttling that to one
|
||||
paint per 350 ms (`k_map_progressive_ms`) is a bigger win than either lever
|
||||
named above, and it is board-independent. Measure before optimising here: the
|
||||
intuition that "it must be the decode" was wrong by a factor of two.
|
||||
|
||||
## Audit pass 3 (2026-08-28) — cold map open + a real Back history
|
||||
|
||||
Third full sweep, prompted by two field reports: "the map doesn't load fast the
|
||||
first time" and "Back should go back to the previous navigation". Both were
|
||||
real; both had one identifiable cause. All eight PlatformIO envs compile-verified
|
||||
(the shared `UITask.cpp` is the blast radius); on-device validation still wanted.
|
||||
|
||||
**Map, cold open.** The 2026-08-22 pass above fixed the *re-open* cost and left
|
||||
cold open at 2598 ms, correctly attributing the bulk to the SJPG decode. What it
|
||||
did not catch is what runs *in front* of that decode:
|
||||
|
||||
- **`bestAvailableZoom()` was called unconditionally** (`onMapTabActivated`) and
|
||||
its result discarded one line later whenever a zoom was persisted — which it
|
||||
is as soon as the user touches +/- once. It walks z19→z3 probing the card at
|
||||
each level, returning at the first hit. Now skipped when the answer cannot be
|
||||
used. It has no other caller, so this is pure dead-work removal.
|
||||
- **The no-fix case was never first-open-only.** `s_map_view_inited` only latches
|
||||
once a non-zero centre exists, so with no GPS fix and no Profile location the
|
||||
recenter guard never closed: the full 17-level walk ran against lat/lon 0,0 —
|
||||
tiles that cannot exist — on *every* map open, forever, and `renderMapTiles`
|
||||
then bailed to the placeholder and freed the slots anyway. The centre is not
|
||||
persisted across reboots either (only the zoom is), so every boot starts here.
|
||||
- **The "Loading map…" hint could not paint.** It was set *after* the probe, so
|
||||
the whole scan happened with the outgoing tab still frozen on the panel. Moved
|
||||
to the top of the function, and `goToTab` now uses `LV_ANIM_OFF` for the map
|
||||
tab so the flush is not racing a 200-400 ms slide. Zero ms saved, but it turns
|
||||
a frozen screen into visible progress — which is most of the reported bug.
|
||||
- **`tileExistsAt` probed `.png`, the generic loader only opens `.jpg`.** The
|
||||
zoom guard therefore advertised, and the auto-snap selected, levels that render
|
||||
blank. The `.png` leg is gone: the guard now mirrors the loader, and every
|
||||
probe costs half as much.
|
||||
|
||||
**Map, data loss (ship this one regardless).** `tileCacheRemove(path_png)` in the
|
||||
fetch worker — "drop any stale .png from an older build" — was written for the
|
||||
firmware's private LittleFS partition. `UITask::begin` re-points `s_tile_fs` at
|
||||
the SD **root** on this board with `s_tile_root = ""`, so it resolved to
|
||||
`SD.remove("/tiles/<z>/<x>/<y>.png")`: the user's own offline map pack. The
|
||||
skip-check above it only ever tests the `.jpg`, so every queued tile whose `.jpg`
|
||||
was absent erased the co-located `.png`. Now gated on `s_tile_fs == &s_tiles_fs`,
|
||||
the same discriminator `tileCacheMarkSdIo`/`tilesFsLowSpace` use. Wi-Fi-only
|
||||
trigger, permanent loss.
|
||||
|
||||
**Back.** The ladder was a faithful copy of `pagerNavGoBack` **minus one rung** —
|
||||
`else if (getActiveTab() != HOME_TAB_INDEX) navGoToMainTab(HOME_TAB_INDEX);`.
|
||||
Without it Back fell through to `navPushTap(LV_KEY_ESC)`, and nothing in this
|
||||
build consumes `LV_KEY_ESC`: the sole consumer is `lv_dropdown`, peeled several
|
||||
rungs above, and `navMaybeRebuild` deliberately keeps the tab bar out of the
|
||||
focus group. The press was consumed by the `return true` and nothing happened, on
|
||||
every bare tab.
|
||||
|
||||
Restoring that rung alone gives *Home*, not *back*, so this pass also adds the
|
||||
history that never existed anywhere in this UI (`s_lv_tab_prev` is a single
|
||||
transition latch; `s_apppage_close` is depth 1; the popup registry walks in
|
||||
static table order):
|
||||
|
||||
- **`s_m9_nav[]` — 12 entries, 24 B.** Records main-tab switches only. Pushed
|
||||
from `goToTab` (the single choke point, and the only place that fires
|
||||
`LV_EVENT_VALUE_CHANGED` explicitly), popped by a new rung placed **below**
|
||||
every peel rung, so modals still close innermost-first and a null-close blocker
|
||||
row still stops Back at the registry rung.
|
||||
- **Deliberately not stacked:** the app drawer (a registry row rung 6 already
|
||||
closes, whose mode survives a tab switch and is restored on return to Home) and
|
||||
app pages / chats / settings sheets (all peeled above). Duplicating those here
|
||||
would double-close.
|
||||
- **Never store an `lv_obj_t*`** — settings sheets, app pages and the drawer are
|
||||
destroyed, often via `del_async`. Entries are tab indices; one that no longer
|
||||
applies is skipped, never a dead press.
|
||||
- **`s_m9_nav_replaying`** suppresses the re-push from a pop's own `goToTab`.
|
||||
The Home-fallback rung needs it too: without it that jump recorded the tab it
|
||||
was leaving and the next Back popped straight back — Back ping-ponging forever.
|
||||
- **HOME clears the trail** (it means "go to root"); MSG/MAP are lateral jumps
|
||||
and keep pushing.
|
||||
|
||||
Also closed in this pass, all confirmed and all reachable on this board:
|
||||
|
||||
- **`openMeshContactDm` bypassed the choke point** — a bare `lv_tabview_set_act`
|
||||
with no event send, so `tabChangedCb` never ran, `s_lv_tab_prev` went stale,
|
||||
and the *next* real tab change computed `leaving_inbox` false and skipped the
|
||||
only code that hides the DM overlay. The chat stayed painted over whatever tab
|
||||
you moved to. The switch now fires the event, and moved above the overlay
|
||||
setup so `tabChangedCb`'s own `hideKb()` cannot undo it.
|
||||
- **Setup wizard Back was a dead key** for the whole wizard, even on the three
|
||||
steps that draw a working on-screen Back wired to `setupBackCb`. The comment
|
||||
claimed "nothing to back out of"; the wizard is a four-step stack.
|
||||
- **Contacts select mode outlived its tab.** Its registry row has `flags=0`, so
|
||||
`anyPopupOpen()` never reported it and nothing cleared it on tab change —
|
||||
leaving Back to close it *invisibly* from another tab. `ctExitSelectMode()` now
|
||||
runs on leaving Contacts, the same discipline `s_m9_map_pan` already gets.
|
||||
- **`navGoToMainTab` ignored key-blocker rows** — the unconditional 8-iteration
|
||||
drain spun closing nothing and switched tabs out from under a running SD format
|
||||
or bulk delete. Now returns bool and stops. (The board's SUB_MESSAGE/SUB_MAP
|
||||
cases already did this correctly; HOME and `navGoToMainTab` did not.)
|
||||
- **Two focus-group blackouts** that made Back's *result* undrivable: the
|
||||
notification chip was a non-`NAV_SKIP` `lv_layer_top` child with nothing
|
||||
focusable inside, emptying the group for its ~1.1 s life on every incoming
|
||||
message; and the Spectrum page's `NAV_SKIP_FLAG` root hid it from
|
||||
`navTopHasVisibleChild`, re-rooting the group onto the app drawer still open
|
||||
beneath it (d-pad moved an unseen ring over hidden tiles, OK launched them).
|
||||
|
||||
**The Spectrum fix needs BOTH halves — `NAV_PASSTHRU_FLAG` alone is a trap.**
|
||||
Passthru excludes only the container; `navCollect` still recurses into the
|
||||
subtree. `lv_chart` does **not** clear `LV_OBJ_FLAG_CLICKABLE` (LVGL 8.4 sets
|
||||
it in the base `lv_obj` constructor; only `lv_label` and `lv_img`/`lv_canvas`
|
||||
clear it), and `lv_obj_remove_style_all()` on the five colour-ramp swatches
|
||||
removes styles, not flags. So passthru-on-root alone handed the page six focus
|
||||
stops where it previously had zero, and `navFocusCb`'s reverse-video fill
|
||||
painted a solid white block straight over the live trace — caught on hardware
|
||||
within a minute of flashing. The chart and each swatch now carry
|
||||
`NAV_SKIP_FLAG` so the group ends up genuinely empty, which is what the
|
||||
original comment always claimed. Anything added to this page later must do the
|
||||
same, or it becomes a focus stop by default.
|
||||
|
||||
Known consequence: with the page as the focus container, `useTop` is true and
|
||||
`navAddStatusBarActions()` is skipped, so the "‹ Spectrum" chevron is no
|
||||
longer a d-pad target. Back (hardware key / registry row) still closes the
|
||||
page on every board, which is the documented exit.
|
||||
|
||||
- **`ctExitSelectMode` must not go through `ctSetSelectMode(false)`.** That
|
||||
setter ends in `s_ct_list_force = true; refreshContactsList();` — the full
|
||||
teardown+rebuild measured at ~1.3 s with a large list (#82). Called from
|
||||
`tabChangedCb` as the user LEAVES Contacts, inside the tabview's
|
||||
`VALUE_CHANGED` chain, that freezes the tab they are moving *to*, on every
|
||||
board. It now restores the toolbars and only ARMS `s_ct_list_force`; the
|
||||
rebuild lands on the way back in, where `tabChangedCb` already calls
|
||||
`refreshContactsList()` and the user can see it.
|
||||
|
||||
- **Blocker check must precede the app-page close.** With `navGoToMainTab` now
|
||||
refusing to switch while a null-close progress row owns the screen,
|
||||
`M9_KEY_LEFT_MESSAGE` and `M9_KEY_MAP` — which called `s_apppage_close()`
|
||||
*first* — would have destroyed the page and then not jumped, leaving the user
|
||||
with neither. Both now drain-and-bail before closing anything, the order
|
||||
`SUB_MESSAGE` / `SUB_MAP` already used.
|
||||
|
||||
**Measured on hardware after the above** (`[MAPPROF]`, a `HAS_M9_KEYBOARD`-only
|
||||
`Serial.printf` in `onMapTabActivated` with `micros()` accumulators around
|
||||
`loadTileJpeg` and the decode — **diagnostic scaffolding, strip or gate it before
|
||||
release**):
|
||||
|
||||
| | cold open (first after boot) | re-open |
|
||||
|---|---|---|
|
||||
| before this pass | 2598 ms | 245 ms |
|
||||
| after dead-work removal | 2189 ms | 141-165 ms |
|
||||
| **after repaint throttle** | **1507 ms** | **~150-190 ms** |
|
||||
| split, before throttle | read 497 / decode 539 / **other 1042** / markers 10 | render 5-6, tiles=0 |
|
||||
| split, after throttle | read 499 / decode 539 / other **358** / markers 10 | render 7, tiles=0 |
|
||||
|
||||
The throttle moved `other` and nothing else (read 497->499, decode 539->539 are
|
||||
run-to-run noise), which is the confirmation that it cut compositing rather than
|
||||
starving the load path. Cold open is down **42%** from the 2598 ms baseline; the
|
||||
`[STALL] ui:lvgl` bucket for the same action fell 2497 ms -> 1822 ms.
|
||||
|
||||
With compositing no longer dominant, the two levers named in the superseded note
|
||||
above finally ARE the top of the list, and they are now roughly equal in size:
|
||||
decode 539 ms (core-0 decoding) and read 499 ms (the 4 MHz SD clock — one line,
|
||||
`SD_SPI_FAST_HZ`, which only the V4-R8 env sets today). Treat the SD clock with
|
||||
care: this board shares one SPI peripheral across radio + display + card, and
|
||||
deferred item 4 records that the M9's mount-ladder timing margins were never
|
||||
characterised.
|
||||
|
||||
Three things this settled that static reading could not:
|
||||
|
||||
1. **`probe=0ms` confirms the `bestAvailableZoom` guard fires.** The 2598→2189
|
||||
delta (~410 ms) is the scan that used to run and be discarded — the top of the
|
||||
100-500 ms band the audit estimated.
|
||||
2. **`other` dominates.** See the correction under "Map re-open cost" above. The
|
||||
progressive per-tile repaint, not decode and not SD, was the biggest single
|
||||
cost. Now throttled via `k_map_progressive_ms`.
|
||||
3. **The retained-slot path works exactly as designed** — a re-open reports
|
||||
`tiles=0`, `render=5-6ms`: a true pure blit. Note that ~135 ms of the ~150 ms
|
||||
re-open is now the "Loading map…" hint's `lv_refr_now`, which fires even when
|
||||
the render will not block. Suppressing it when a pure blit is predictable
|
||||
(same zoom + centre as the last render, and `s_map_last_missing == 0`) is the
|
||||
obvious next trim, and is NOT done.
|
||||
|
||||
Stall attribution had to be fixed first, or none of these numbers meant what they
|
||||
said: `uiCp("ui:input")` lives inside `#if CAP_TRACKBALL`, which is **0** on the
|
||||
M9, while the keyboard drain sits between the `ui:threads` and `ui:diag`
|
||||
checkpoints. So on the one board whose *only* input is the d-pad, every
|
||||
keypress-driven action — map opens included — was billed to `ui:threads`. A cold
|
||||
map open was being reported as a slow chat-threads refresh. The M9 now has its own
|
||||
`uiCp("ui:input")`. (Work that reaches LVGL via `navPushTap` still lands in
|
||||
`ui:lvgl`, since the handler runs inside `lv_timer_handler`; that is correct.)
|
||||
|
||||
Still open on the map, in win/risk order: a priority lane for visible tiles
|
||||
(`xQueueSendToFront` — today the nine tiles you are looking at queue *behind* the
|
||||
29-tile background prefetch pyramid on every pan), a retry pump so a dropped
|
||||
fetch heals without a manual pan, and the SD clock below.
|
||||
|
||||
## Deferred — hardware-verify list
|
||||
|
||||
These are left intentionally unset/unwired rather than guessed:
|
||||
|
||||
Reference in new issue
Block a user