Compare commits

...
3 Commits
Author SHA1 Message Date
Kaj SchittecatandClaude Fable 5 8e17b1b071 touch: beta_33 — async MQTT connect (freeze fix) + About sketch-size cache + favorites-full toast
- MqttBridge: reconnect() (blocking DNS + TCP + CONNACK, 2-10 s vs an
  unreachable broker, every 15 s) now runs on a one-shot core-0 task;
  _connecting hands the client between threads. Broker socket writes get
  the same zero-timeout writability probe as the TCP/WS companion
  servers, so a wedged broker fails a publish instead of stalling loop().
- About: ESP.getSketchSize()/getFreeSketchSpace() esp_image_verify-walk
  the whole ~3 MB image (~190 ms each); the 1 Hz refresh cost ~390 ms/s
  and flooded the stall ring (the ui:sbar entries field testers
  photographed). Computed once and cached.
- Favorites: adding past the 16-cap now toasts 'Favorites full (max 16)'
  instead of the wrong 'Removed from favorites'.

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 22:55:42 +02:00
Kaj Schittecat 816045bb6b chore: refresh Mesh America catalog for beta_31 2026-07-05 21:13:27 +02:00
11 changed files with 346 additions and 56 deletions

No files matched your search

+8 -8
View File
@@ -17,19 +17,19 @@
"role": "gui",
"title": "Touch UI",
"version": {
"beta_27": {
"notes": "Fixed a reboot loop that could hit while the device saved its contact list. Internal-flash housekeeping during a save could freeze the device long enough to trip the watchdog and restart it; that save no longer trips it. Thanks to the crash report that pinned it down.\nDevices installed under the Launcher no longer show the on-device \"Install update\" button. That update path only works on a standalone flash; under the Launcher it failed with \"won't fit: Bad Size Given\". The \"update available\" note stays, but you update through the Launcher instead. Thanks to Keith (KM1USR) for the report.\nKeyboard backlight brightness control on the T-Deck: a live slider plus Off / 25 / 50 / 75 / Max presets in Settings > Keyboard. It dims smoothly (fine-grained at the low end), applies to the on and auto modes and the new-message flash, and survives a reboot. Thanks to Yazutsu (PR #85).\nOn devices with a microSD card, contacts and channels are now kept on the card (like chat history and map tiles already are). Your existing contacts move over automatically on first boot; your identity and settings stay in internal flash, so the node is unchanged. Keep the card inserted afterwards - the contact list lives on it, and an older firmware won't look there if you ever downgrade.\nThis is the new stable build, promoted from the beta_27 test channel. It includes everything from the beta_26 and beta_27 test builds.",
"beta_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_27/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_27/wadamesh-heltec-v4-tft.bin",
"url": "https://firmware.wadamesh.com/releases/TOUCH/beta_31/wadamesh-heltec-v4-tft.bin",
"title": "Update (app only)"
}
]
@@ -49,19 +49,19 @@
"role": "gui",
"title": "Touch UI",
"version": {
"beta_27": {
"notes": "Fixed a reboot loop that could hit while the device saved its contact list. Internal-flash housekeeping during a save could freeze the device long enough to trip the watchdog and restart it; that save no longer trips it. Thanks to the crash report that pinned it down.\nDevices installed under the Launcher no longer show the on-device \"Install update\" button. That update path only works on a standalone flash; under the Launcher it failed with \"won't fit: Bad Size Given\". The \"update available\" note stays, but you update through the Launcher instead. Thanks to Keith (KM1USR) for the report.\nKeyboard backlight brightness control on the T-Deck: a live slider plus Off / 25 / 50 / 75 / Max presets in Settings > Keyboard. It dims smoothly (fine-grained at the low end), applies to the on and auto modes and the new-message flash, and survives a reboot. Thanks to Yazutsu (PR #85).\nOn devices with a microSD card, contacts and channels are now kept on the card (like chat history and map tiles already are). Your existing contacts move over automatically on first boot; your identity and settings stay in internal flash, so the node is unchanged. Keep the card inserted afterwards - the contact list lives on it, and an older firmware won't look there if you ever downgrade.\nThis is the new stable build, promoted from the beta_27 test channel. It includes everything from the beta_26 and beta_27 test builds.",
"beta_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_27/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_27/wadamesh-tdeck.bin",
"url": "https://firmware.wadamesh.com/releases/TOUCH/beta_31/wadamesh-tdeck.bin",
"title": "Update (app only)"
}
]
+9
View File
@@ -0,0 +1,9 @@
# beta_32 (TEST) — the Wi-Fi freeze fix: a stale phone/web connection can no longer stall the device. On top of beta_31.
# One user-facing note per non-blank, non-# line; # lines are section comments.
# --- Fixed ---
The 1-2 second freezes every ~10 seconds some users saw with Wi-Fi on are gone. When a phone app or web client was connected over Wi-Fi and that connection went stale (phone asleep, walked out of range), every received mesh packet made the device wait on the dead connection before the UI could continue. The device now only writes to a connection when it can accept data immediately, and a connection that stays unresponsive for 10 seconds straight is dropped so the client can reconnect cleanly.
Saving chat history could briefly hitch the UI on busy meshes: the history file now writes in a few large chunks instead of thousands of tiny ones (the same treatment the contact list got in beta_30), and background saves are spaced further apart while messages stream in.
# --- New ---
The About page now shows recent loop stalls (anything over 0.2 seconds, newest first), so freeze reports can be confirmed or ruled out at a glance.
+7
View File
@@ -0,0 +1,7 @@
# beta_33 (TEST) — the MQTT freeze fix + the About page no longer stalls itself. On top of beta_32.
# One user-facing note per non-blank, non-# line; # lines are section comments.
# --- Fixed ---
The freezes with MQTT enabled are gone. Connecting to the broker (DNS lookup, TCP connect, login) could block the device for 2-10 seconds and retried every 15 seconds while the broker was unreachable, all on the UI thread. Connection attempts now run fully in the background, and writes to a broker that stops responding are skipped instead of waited on (the same treatment beta_32 gave phone/web connections). Tip: if your bridge keeps reconnecting, check the broker address and credentials - a healthy connection does not retry.
The About page caused a ~0.4 second hitch every second while it was open: computing the firmware image size checksums the whole ~3 MB app in flash and ran twice per refresh. It is now computed once per boot, so About (and its stall list) no longer disturbs the very measurements it shows.
Favoriting a contact when the favorites list is full (16) now says "Favorites full (max 16)" instead of the incorrect "Removed from favorites".
+42 -5
View File
@@ -10,11 +10,30 @@ MqttBridge mqtt_bridge;
#include <WiFi.h>
#include <stdio.h>
#include <string.h>
#include <lwip/sockets.h>
#include "esp_random.h"
#include "mbedtls/gcm.h"
#include "mbedtls/md.h"
#include "mbedtls/base64.h"
// Only hand bytes to lwIP when the socket can accept them right now (zero-timeout
// select). Otherwise WiFiClient::write() blocks in 1 s select() retries against a
// broker that stopped ACKing — on the loop thread that is a visible UI freeze per
// publish. A refused write fails the publish; PubSubClient then flags the
// connection down and the (async) reconnect path takes over.
size_t MqttNbClient::write(const uint8_t* buf, size_t size) {
int fd_ = fd();
if (fd_ < 0) return 0;
fd_set wset;
FD_ZERO(&wset);
FD_SET(fd_, &wset);
struct timeval tv;
tv.tv_sec = 0;
tv.tv_usec = 0;
if (select(fd_ + 1, NULL, &wset, NULL, &tv) <= 0 || !FD_ISSET(fd_, &wset)) return 0;
return WiFiClient::write(buf, size);
}
// Read every field from the "mqtt" NVS namespace into the members. isKey() guards
// avoid the [E] NOT_FOUND log spam that getString emits for absent keys on the
// USB-CDC companion stream (see TouchPrefsStore prefsGetStr note).
@@ -86,13 +105,27 @@ bool MqttBridge::reconnect() {
return ok;
}
// reconnect() blocks on DNS + TCP connect + CONNACK (2-10 s against an unreachable
// broker) — never run it on the loop thread, it drives LVGL. One-shot task instead;
// _connecting hands _mqtt/_wc to the task and back.
void MqttBridge::reconnectTask(void* arg) {
MqttBridge* self = (MqttBridge*)arg;
self->reconnect();
self->_connecting = false;
vTaskDelete(nullptr);
}
void MqttBridge::loop() {
if (!_enabled) return;
if (!_enabled || _connecting) return;
if (!_mqtt.connected()) {
uint32_t now = millis();
if ((uint32_t)(now - _lastReconnectMs) >= RECONNECT_INTERVAL_MS) {
_lastReconnectMs = now;
reconnect();
_connecting = true;
if (xTaskCreatePinnedToCore(reconnectTask, "mqtt_conn", 6144, this,
1, nullptr, 0) != pdPASS) {
_connecting = false; // task OOM — try again next interval
}
}
return;
}
@@ -128,7 +161,7 @@ bool MqttBridge::sealToB64(const char* plain, char* out, size_t outCap) {
}
void MqttBridge::pub(const char* subtopic, const char* json) {
if (!_enabled || !_mqtt.connected()) return;
if (!_enabled || _connecting || !_mqtt.connected()) return;
char topic[80];
snprintf(topic, sizeof(topic), "wadamesh/%s/%s", _nodeHex, subtopic);
if (_encOn) {
@@ -158,7 +191,7 @@ void MqttBridge::escapeJson(const char* src, char* dst, size_t dstLen) {
void MqttBridge::publishDM(const char* senderName, const uint8_t* senderKey32,
float snr, uint8_t hops, uint32_t ts, const char* text) {
if (!_enabled || !_pubDm || !_mqtt.connected()) return; // DMs are opt-in
if (!_enabled || !_pubDm || _connecting || !_mqtt.connected()) return; // DMs are opt-in
char keyHex[13] = {};
for (int i = 0; i < 6; ++i) snprintf(keyHex + i * 2, 3, "%02x", senderKey32[i]);
@@ -175,7 +208,7 @@ void MqttBridge::publishDM(const char* senderName, const uint8_t* senderKey32,
void MqttBridge::publishChannel(int channelIdx, const char* channelName,
float snr, uint8_t hops, uint32_t ts, const char* text) {
if (!_enabled || !_pubChannel || !_mqtt.connected()) return; // channel publish toggle
if (!_enabled || !_pubChannel || _connecting || !_mqtt.connected()) return; // channel publish toggle
char safeName[48], safeText[300];
escapeJson(channelName, safeName, sizeof(safeName));
escapeJson(text, safeText, sizeof(safeText));
@@ -204,6 +237,10 @@ void MqttBridge::saveConfig(const char* host, uint16_t port,
}
void MqttBridge::reloadConfig() {
// A connect attempt may be in flight on the one-shot task; PubSubClient is not
// thread-safe, so wait it out (bounded: DNS + TCP + CONNACK <= ~10 s, and it
// only overlaps when Save lands inside an attempt window on a dead broker).
while (_connecting) delay(10);
if (_mqtt.connected()) _mqtt.disconnect();
loadConfig();
if (!_enabled) { Serial.println("[MQTT] disabled"); return; }
+16 -1
View File
@@ -24,6 +24,17 @@ public:
#include <PubSubClient.h>
#include <WiFiClient.h>
// WiFiClient whose write() refuses to enter the framework's blocking-select path:
// bytes are only handed to lwIP when the socket can take them right now, so a
// wedged broker connection (half-open socket, peer stopped ACKing) fails the
// publish instead of stalling the loop thread ~1 s per write attempt. Same probe
// as the TCP/WS companion servers.
class MqttNbClient : public WiFiClient {
public:
size_t write(const uint8_t* buf, size_t size) override;
size_t write(uint8_t b) override { return write(&b, 1); }
};
// Publishes received mesh messages to an MQTT broker over the existing WiFi link.
//
// PRIVACY MODEL (mirrors how Meshtastic's MQTT module guards content):
@@ -73,7 +84,7 @@ public:
void reloadConfig();
private:
WiFiClient _wc;
MqttNbClient _wc;
PubSubClient _mqtt{_wc};
char _nodeHex[13] = {}; // 6-byte key → 12 hex chars + '\0'
bool _enabled = false;
@@ -87,12 +98,16 @@ private:
bool _encOn = false;
uint16_t _port = 1883;
uint32_t _lastReconnectMs = 0;
// True while the one-shot connect task owns _mqtt/_wc. The loop thread must
// not touch either until it clears (PubSubClient is not thread-safe).
volatile bool _connecting = false;
static const uint32_t RECONNECT_INTERVAL_MS = 15000;
void loadConfig(); // shared by begin() / reloadConfig()
void deriveKey(); // _key = SHA-256(_psk); sets _encOn
bool reconnect();
static void reconnectTask(void* arg); // one-shot task body wrapping reconnect()
void pub(const char* subtopic, const char* json); // seals if _encOn
bool sealToB64(const char* plain, char* out, size_t outCap);
static void escapeJson(const char* src, char* dst, size_t dstLen);
+44 -6
View File
@@ -1,20 +1,44 @@
#include "TCPCompanionServer.h"
#include <lwip/sockets.h>
#define RECV_STATE_IDLE 0
#define RECV_STATE_HDR_FOUND 1
#define RECV_STATE_LEN1_FOUND 2
#define RECV_STATE_LEN2_FOUND 3
#define TCP_WRITE_TIMEOUT_MS 120
#define TCP_WEDGED_DROP_MS 10000
// True when lwIP can accept bytes for this socket right now. select() only reports
// writable once at least TCP_SNDLOWAT (~2.8 KB) of send buffer is free, which holds a
// whole companion frame (MAX_FRAME_SIZE + header), so a write issued after this probe
// completes without blocking. Without the probe, WiFiClient::write() sits in 1 s
// select() waits (up to 10) against a peer that stopped ACKing — a phone asleep in a
// pocket keeps its socket "connected" but unwritable, and every mesh-RX push then
// stalls the loop thread (and the UI) for seconds.
static bool socketWritableNow(WiFiClient& client) {
int fd = client.fd();
if (fd < 0) return false;
fd_set wset;
FD_ZERO(&wset);
FD_SET(fd, &wset);
struct timeval tv;
tv.tv_sec = 0;
tv.tv_usec = 0;
return select(fd + 1, NULL, &wset, NULL, &tv) > 0 && FD_ISSET(fd, &wset);
}
static bool writeAllBytes(WiFiClient& client, const uint8_t* buf, size_t len, uint32_t timeout_ms) {
size_t sent = 0;
uint32_t start = millis();
while (sent < len) {
if (!client.connected()) return false;
size_t n = client.write(buf + sent, len - sent);
if (n > 0) {
sent += n;
continue;
if (socketWritableNow(client)) {
size_t n = client.write(buf + sent, len - sent);
if (n > 0) {
sent += n;
continue;
}
}
if (millis() - start >= timeout_ms) return false;
delay(1);
@@ -26,6 +50,7 @@ TCPCompanionServer::TCPCompanionServer() : _server(WiFiServer()), _port(0), _pol
for (int i = 0; i < TCP_COMPANION_MAX_CLIENTS; i++) {
_clients[i].state = RECV_STATE_IDLE;
_clients[i].in_use = false;
_clients[i].stall_ms = 0;
}
}
@@ -60,6 +85,7 @@ void TCPCompanionServer::acceptNewClients() {
_clients[slot].in_use = true;
_clients[slot].state = RECV_STATE_IDLE;
_clients[slot].rx_len = 0;
_clients[slot].stall_ms = 0;
} else {
incoming.stop();
}
@@ -81,6 +107,7 @@ void TCPCompanionServer::disconnectClient(int client_index) {
_clients[client_index].client.stop();
_clients[client_index].in_use = false;
_clients[client_index].state = RECV_STATE_IDLE;
_clients[client_index].stall_ms = 0;
}
}
@@ -160,10 +187,21 @@ size_t TCPCompanionServer::writeToClient(int client_index, const uint8_t src[],
WiFiClient* cl = &_clients[client_index].client;
if (!writeAllBytes(*cl, hdr, 3, TCP_WRITE_TIMEOUT_MS) ||
!writeAllBytes(*cl, src, len, TCP_WRITE_TIMEOUT_MS)) {
// Return 0 so caller can retry (e.g. contact list). Do not disconnect on transient
// buffer full; companion layer retries and will complete the sequence.
// Return 0 so caller can retry (e.g. contact list). A transient full buffer must
// not disconnect — the companion layer retries and completes the sequence. But a
// peer that stopped ACKing (phone asleep, walked out of range) keeps a half-open
// socket "connected" indefinitely while staying unwritable; drop it once the
// failure has been continuous for TCP_WEDGED_DROP_MS so it stops eating the
// write-timeout budget on every pushed frame.
TCPClientState* c = &_clients[client_index];
if (c->stall_ms == 0) {
c->stall_ms = millis() | 1;
} else if (millis() - c->stall_ms >= TCP_WEDGED_DROP_MS) {
disconnectClient(client_index);
}
return 0;
}
_clients[client_index].stall_ms = 0;
return len;
}
+1
View File
@@ -15,6 +15,7 @@ struct TCPClientState {
uint16_t rx_len;
uint8_t rx_buf[MAX_FRAME_SIZE];
bool in_use;
uint32_t stall_ms; // start of a continuous write-failure run; 0 = healthy
};
// Multi-client TCP server for companion protocol. Same frame format as serial.
+36 -4
View File
@@ -2,12 +2,14 @@
#include <Arduino.h>
#include <mbedtls/sha1.h>
#include <string.h>
#include <lwip/sockets.h>
#ifndef WS_FRAME_DEBUG
#define WS_FRAME_DEBUG 0
#endif
#define TCP_WRITE_TIMEOUT_MS 120
#define WS_WEDGED_DROP_MS 10000
#define WS_MAGIC "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
// Browser opens http://device:8765 — short info page (plain WebSocket endpoint).
@@ -47,15 +49,32 @@ static void base64Encode20(const uint8_t* in, char* out) {
*out = '\0';
}
// True when lwIP can accept bytes for this socket right now — see the identical probe
// in TCPCompanionServer.cpp. Without it, WiFiClient::write() against a peer that
// stopped ACKing (half-open socket) blocks in 1 s select() waits on the loop thread.
static bool socketWritableNow(WiFiClient& client) {
int fd = client.fd();
if (fd < 0) return false;
fd_set wset;
FD_ZERO(&wset);
FD_SET(fd, &wset);
struct timeval tv;
tv.tv_sec = 0;
tv.tv_usec = 0;
return select(fd + 1, NULL, &wset, NULL, &tv) > 0 && FD_ISSET(fd, &wset);
}
static bool writeAllBytes(WiFiClient& client, const uint8_t* buf, size_t len, uint32_t timeout_ms) {
size_t sent = 0;
uint32_t start = millis();
while (sent < len) {
if (!client.connected()) return false;
size_t n = client.write(buf + sent, len - sent);
if (n > 0) {
sent += n;
continue;
if (socketWritableNow(client)) {
size_t n = client.write(buf + sent, len - sent);
if (n > 0) {
sent += n;
continue;
}
}
if (millis() - start >= timeout_ms) return false;
delay(1);
@@ -72,6 +91,7 @@ WebSocketCompanionServer::WebSocketCompanionServer()
_clients[i].handshake_len = 0;
_clients[i].ws_state = WS_STATE_HEADER_0;
_clients[i].comp_state = COMP_STATE_IDLE;
_clients[i].stall_ms = 0;
}
}
@@ -142,6 +162,7 @@ void WebSocketCompanionServer::acceptNewClients() {
_clients[slot].handshake_len = 0;
_clients[slot].ws_state = WS_STATE_HEADER_0;
_clients[slot].comp_state = COMP_STATE_IDLE;
_clients[slot].stall_ms = 0;
}
}
@@ -355,8 +376,18 @@ size_t WebSocketCompanionServer::writeToClient(int client_index, const uint8_t s
#if WS_FRAME_DEBUG
Serial.printf("WS frame client=%d code=%u len=%u written=0\n", client_index, (unsigned)(len ? src[0] : 0), (unsigned)len);
#endif
// Same wedged-peer reaper as the TCP server: a client that stays unwritable for
// WS_WEDGED_DROP_MS straight is half-open (browser tab gone, phone asleep) and
// would otherwise eat the write-timeout budget on every pushed frame forever.
WSClientState* c = &_clients[client_index];
if (c->stall_ms == 0) {
c->stall_ms = millis() | 1;
} else if (millis() - c->stall_ms >= WS_WEDGED_DROP_MS) {
disconnectClient(client_index);
}
return 0;
}
_clients[client_index].stall_ms = 0;
#if WS_FRAME_DEBUG
Serial.printf("WS frame client=%d code=%u len=%u written=%u\n", client_index, (unsigned)(len ? src[0] : 0), (unsigned)len, (unsigned)len);
#endif
@@ -396,5 +427,6 @@ void WebSocketCompanionServer::disconnectClient(int client_index) {
if (client_index >= 0 && client_index < WS_COMPANION_MAX_CLIENTS && _clients[client_index].in_use) {
_clients[client_index].client.stop();
_clients[client_index].in_use = false;
_clients[client_index].stall_ms = 0;
}
}
@@ -35,6 +35,7 @@ struct WSClientState {
uint16_t comp_frame_len;
uint16_t comp_rx_len;
uint8_t comp_rx_buf[MAX_FRAME_SIZE];
uint32_t stall_ms; // start of a continuous write-failure run; 0 = healthy
};
// WebSocket server for companion protocol. Same logical protocol as TCP; transport is RFC 6455 (plain WS).
+37 -1
View File
@@ -573,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;
@@ -700,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)
@@ -718,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
@@ -738,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
@@ -795,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
}
+145 -31
View File
@@ -8553,6 +8553,11 @@ static void openMemoryDetailCb(lv_event_t* e) {
// Render the System-info text into buf. Re-callable so live values (uptime,
// free heap) update; the inline About page calls it once at build then
// refreshSysInfo() re-runs it ~1 Hz while that tab is visible.
// beta_31 field-freeze tracer record (ring + logger defined with UITask::loop below).
struct StallRec { uint32_t at_s; uint16_t dur_ms; const char* tag; };
extern StallRec g_stall_ring[16];
extern uint8_t g_stall_cnt, g_stall_w;
static void sysInfoText(char* buf, size_t cap) {
int p = 0;
#if defined(ESP32)
@@ -8598,8 +8603,20 @@ static void sysInfoText(char* buf, size_t cap) {
const uint32_t sketch_size = 0; // TODO(device): surface the real AppFS app size
const uint32_t sketch_free = 0;
#else
const uint32_t sketch_size = ESP.getSketchSize();
const uint32_t sketch_free = ESP.getFreeSketchSpace();
// Each of these runs esp_image_verify — a SHA walk over the whole ~3 MB app
// image (~190 ms per call). At the 1 Hz About refresh the pair was a constant
// ~390 ms/s UI stall (the "ui:sbar" entries field testers photographed) and it
// flooded the stall ring, hiding the entries that mattered. The running image
// cannot change while running, so pay the walk once and cache.
static uint32_t s_sketch_size_c = 0, s_sketch_free_c = 0;
static bool s_sketch_known = false;
if (!s_sketch_known) {
s_sketch_size_c = ESP.getSketchSize();
s_sketch_free_c = ESP.getFreeSketchSpace();
s_sketch_known = true;
}
const uint32_t sketch_size = s_sketch_size_c;
const uint32_t sketch_free = s_sketch_free_c;
#endif
p += snprintf(buf + p, cap - p,
"Flash\n chip: %u MB\n sketch: %u KB used\n app slot free: %u KB\n\n",
@@ -8644,6 +8661,21 @@ static void sysInfoText(char* buf, size_t cap) {
p += snprintf(buf + p, cap - p,
"Last reset\n %s\n\n", resetReasonString(esp_reset_reason()));
#endif
// beta_31 field-freeze tracer: recent loop stalls (>0.2 s), newest first — a
// field tester photographs this instead of needing a serial console.
p += snprintf(buf + p, cap - p, "Loop stalls (>0.2s)\n");
if (g_stall_cnt == 0) {
p += snprintf(buf + p, cap - p, " none recorded\n\n");
} else {
const uint32_t nows = millis() / 1000u;
for (int i = 0; i < (int)g_stall_cnt && i < 6; ++i) {
const int idx = ((int)g_stall_w - 1 - i + 32) % 16;
const StallRec& r = g_stall_ring[idx];
p += snprintf(buf + p, cap - p, " -%lus %s %ums\n",
(unsigned long)(nows - r.at_s), r.tag, (unsigned)r.dur_ms);
}
p += snprintf(buf + p, cap - p, "\n");
}
p += snprintf(buf + p, cap - p,
"Build\n %s\n %s\n WADAMESH TOUCH\n",
FIRMWARE_VERSION, FIRMWARE_BUILD_DATE);
@@ -12770,6 +12802,14 @@ static void actionSheetFavoriteCb(lv_event_t* e) {
#if defined(ESP32)
bool was_fav = touchPrefsIsFavorite(c.id.pub_key);
bool now_fav = touchPrefsSetFavorite(c.id.pub_key, !was_fav);
if (!was_fav && !now_fav) {
// Add refused: the favorites table is at capacity, nothing changed — saying
// "Removed from favorites" here (the plain now_fav mapping) is a lie.
char full_msg[48];
snprintf(full_msg, sizeof full_msg, TR("Favorites full (max %d)"), TOUCH_FAVORITES_MAX);
g_lv.task->showAlert(full_msg, 1500);
return;
}
g_lv.task->showAlert(now_fav ? TR("Added to favorites") : TR("Removed from favorites"), 1100);
// Force a list rebuild so the star (or its removal) shows immediately — a plain
// refresh hits the no-change cache (the contact count is unchanged by a fav toggle).
@@ -33818,16 +33858,20 @@ void UITask::markThreadsDirty(unsigned long delay_ms) {
}
void UITask::markMsgsDirty(unsigned long delay_ms) {
// Deep (SD) ring: a flush rewrites the whole ring file (~1.1 MB at 5000 slots),
// so space the writes out — at least 30 s apart instead of the 0.5-2 s cadence
// of the small internal-flash ring. persistHistoryNow() still forces a write on
// reboot/shutdown, so at most the last <30 s of messages ride on a hard crash.
if (_ui_msg_cap > MAX_UI_MESSAGES && delay_ms < 30000) delay_ms = 30000;
// Every flush rewrites the WHOLE ring file on the loop thread, so space the
// writes out: at least 30 s for the deep SD ring (~1.1 MB at 5000 slots) and at
// least 10 s for the small internal-flash ring (~115 KB) — a busy channel used
// to trigger a full rewrite (+ possible SPIFFS GC) ~2 s after EVERY message,
// which read as "the device freezes 1-2 s every ~10 s" on stable beta_31.
// persistHistoryNow() still forces a write on reboot/shutdown, so a hard crash
// costs at most the last few seconds of messages.
if (_ui_msg_cap > MAX_UI_MESSAGES) { if (delay_ms < 30000) delay_ms = 30000; }
else { if (delay_ms < 10000) delay_ms = 10000; }
const unsigned long deadline = millis() + delay_ms;
if (!_msgs_dirty || deadline < _next_msgs_flush_ms)
_next_msgs_flush_ms = deadline;
_msgs_dirty = true;
markThreadsDirty(200); // thread state (last_ts, unread) also changed
markThreadsDirty(1500); // thread state (last_ts, unread) changed too — coalesce a message burst into one write
}
void UITask::flushHistoryIfDue(unsigned long now) {
@@ -34203,10 +34247,14 @@ bool UITask::saveThreadsToStorage() {
bool UITask::saveMsgsToStorage() {
#if defined(ESP32)
// Write from internal-RAM stack structs (fast). NOTE: do NOT "optimize"
// this into a single big write from a PSRAM buffer — the flash driver has
// to bounce a PSRAM source through internal RAM chunk-by-chunk, which is
// SLOWER than these per-struct writes and was a real regression.
// Chunked writer (same pattern that fixed the contacts stall, #82): records are
// packed into an INTERNAL-RAM chunk and flushed in ~6 KB writes, so the ring
// costs ~20 FS calls (500 slots) / ~210 (5000 slots) instead of one small write
// per record — a full-ring rewrite froze the UI 1-2 s per incoming message on
// busy meshes. This is NOT the "one big write from PSRAM" that regressed before:
// the chunk lives in internal RAM (the flash driver never bounces a PSRAM
// source), and the per-record field copies are unchanged. Alloc failure falls
// back to the original per-record writes.
WdtHeavyGuard _wg; // a fragmenting write can trigger a multi-second SPIFFS GC
File f = uiDataOpen(k_ui_msgs_path, "w");
if (!f) return false;
@@ -34222,28 +34270,59 @@ bool UITask::saveMsgsToStorage() {
f.close(); return false;
}
UiHistoryMsg m{};
for (int i = 0; i < _ui_msg_cap; ++i) {
memset(&m, 0, sizeof(m));
m.ts = _ui_msgs[i].ts;
m.channel = _ui_msgs[i].channel ? 1u : 0u;
m.outgoing = _ui_msgs[i].outgoing ? 1u : 0u;
m.meta_flags = _ui_msgs[i].meta_flags;
m.path_len = _ui_msgs[i].path_len;
m.snr_q4 = _ui_msgs[i].snr_q4;
m.rssi = _ui_msgs[i].rssi;
strncpy(m.thread, _ui_msgs[i].thread, MAX_THREAD_NAME);
m.thread[MAX_THREAD_NAME] = '\0';
strncpy(m.sender, _ui_msgs[i].sender, MAX_SENDER_NAME);
m.sender[MAX_SENDER_NAME] = '\0';
strncpy(m.text, _ui_msgs[i].text, MAX_MSG_TEXT);
m.text[MAX_MSG_TEXT] = '\0';
if (f.write(reinterpret_cast<const uint8_t*>(&m), sizeof(m)) != sizeof(m)) {
f.close(); return false;
const size_t REC = sizeof(UiHistoryMsg);
size_t chunk_recs = 6144 / REC;
if (chunk_recs < 1) chunk_recs = 1;
uint8_t* buf = (uint8_t*)malloc(REC * chunk_recs); // internal RAM by default
bool ok = true;
if (buf) {
size_t fill = 0;
for (int i = 0; ok && i < _ui_msg_cap; ++i) {
UiHistoryMsg* m = reinterpret_cast<UiHistoryMsg*>(buf + fill);
memset(m, 0, REC);
m->ts = _ui_msgs[i].ts;
m->channel = _ui_msgs[i].channel ? 1u : 0u;
m->outgoing = _ui_msgs[i].outgoing ? 1u : 0u;
m->meta_flags = _ui_msgs[i].meta_flags;
m->path_len = _ui_msgs[i].path_len;
m->snr_q4 = _ui_msgs[i].snr_q4;
m->rssi = _ui_msgs[i].rssi;
strncpy(m->thread, _ui_msgs[i].thread, MAX_THREAD_NAME);
m->thread[MAX_THREAD_NAME] = '\0';
strncpy(m->sender, _ui_msgs[i].sender, MAX_SENDER_NAME);
m->sender[MAX_SENDER_NAME] = '\0';
strncpy(m->text, _ui_msgs[i].text, MAX_MSG_TEXT);
m->text[MAX_MSG_TEXT] = '\0';
fill += REC;
if (fill == REC * chunk_recs) {
ok = (f.write(buf, fill) == fill);
fill = 0;
}
}
if (ok && fill > 0) ok = (f.write(buf, fill) == fill);
free(buf);
} else {
UiHistoryMsg m{};
for (int i = 0; ok && i < _ui_msg_cap; ++i) {
memset(&m, 0, sizeof(m));
m.ts = _ui_msgs[i].ts;
m.channel = _ui_msgs[i].channel ? 1u : 0u;
m.outgoing = _ui_msgs[i].outgoing ? 1u : 0u;
m.meta_flags = _ui_msgs[i].meta_flags;
m.path_len = _ui_msgs[i].path_len;
m.snr_q4 = _ui_msgs[i].snr_q4;
m.rssi = _ui_msgs[i].rssi;
strncpy(m.thread, _ui_msgs[i].thread, MAX_THREAD_NAME);
m.thread[MAX_THREAD_NAME] = '\0';
strncpy(m.sender, _ui_msgs[i].sender, MAX_SENDER_NAME);
m.sender[MAX_SENDER_NAME] = '\0';
strncpy(m.text, _ui_msgs[i].text, MAX_MSG_TEXT);
m.text[MAX_MSG_TEXT] = '\0';
ok = (f.write(reinterpret_cast<const uint8_t*>(&m), sizeof(m)) == sizeof(m));
}
}
f.close();
return true;
return ok;
#else
return false;
#endif
@@ -36573,8 +36652,35 @@ void UITask::notify(UIEventType t) {
// ============================================================
// UITask::loop
// ============================================================
// ---- beta_31 field-freeze tracer (diagnostics only, no behaviour change) ----
// Any instrumented main-loop section that blocks >200 ms lands in this ring,
// printed to Serial as [STALL] and listed at the bottom of Settings -> About so
// a field tester can simply photograph the screen. uiCp() checkpoints attribute
// a slow UITask::loop pass to the section that actually ate the time.
StallRec g_stall_ring[16]; // struct defined above sysInfoText (About shows the ring)
uint8_t g_stall_cnt = 0, g_stall_w = 0;
void stallLog(const char* tag, uint32_t dur_ms) {
if (dur_ms < 200) return;
g_stall_ring[g_stall_w] = { (uint32_t)(millis() / 1000u), (uint16_t)(dur_ms > 65535 ? 65535 : dur_ms), tag };
g_stall_w = (uint8_t)((g_stall_w + 1) % 16);
if (g_stall_cnt < 16) g_stall_cnt++;
Serial.printf("[STALL] %s %lums\n", tag, (unsigned long)dur_ms);
}
const char* g_ui_stall_tag = ""; // slowest section of the current UITask::loop pass
uint16_t g_ui_stall_max = 0;
static const char* s_ui_cp_tag = "";
static uint32_t s_ui_cp_t0 = 0;
static inline void uiCp(const char* next) {
const uint32_t nowms = millis();
const uint32_t dt = nowms - s_ui_cp_t0;
if (dt > g_ui_stall_max) { g_ui_stall_max = (uint16_t)(dt > 65535 ? 65535 : dt); g_ui_stall_tag = s_ui_cp_tag; }
s_ui_cp_tag = next;
s_ui_cp_t0 = nowms;
}
void UITask::loop() {
unsigned long now = millis();
g_ui_stall_max = 0; g_ui_stall_tag = ""; s_ui_cp_tag = "ui:head"; s_ui_cp_t0 = now;
#if defined(ESP32)
// Persist the clock floor every 15 min (small prefs-blob write; SdNvsPrefs
// rewrites a file on the Launcher-SD backend, so keep the cadence low). Power
@@ -36600,7 +36706,9 @@ void UITask::loop() {
// but if the page ever vanished without that, the mesh would stay off the
// radio forever — so re-apply the mesh params and release ownership here.
if (s_spectrum_active && !s_spec_root) spectrumRestoreRadio();
uiCp("ui:hist");
flushHistoryIfDue(now);
uiCp("ui:ctdirty");
// A contact was discovered/updated via an advert — possibly while a companion app was connected
// over BLE (issue #73). FORCE the rebuild: an advert can fill in a name, or refresh an existing
// (or app-added) contact, WITHOUT changing getNumContacts() — which the count-cached
@@ -36634,7 +36742,9 @@ void UITask::loop() {
if (s_msgled_flash_until) msgLedRefresh(getUnreadTotal() > 0); // end the one-shot envelope-LED flash on time
#endif
{ static bool s_disc_loaded = false; if (!s_disc_loaded) { s_disc_loaded = true; loadDiscovered(); } }
uiCp("ui:disc");
discoveredFlushIfDue(now); // persist the discovered ring (rate-capped) so it survives reboot
uiCp("ui:gps");
updateGpsLocation(now); // sync + persist node location from GPS once fixed
++s_live_diag_loops;
if (_alert_expiry != 0 && now >= _alert_expiry) {
@@ -37100,7 +37210,9 @@ void UITask::loop() {
#if CAP_SD
telemetryPollTick((uint32_t)now); // auto-poll due nodes -> log (no window)
#endif
uiCp("ui:verchk");
versionCheckService(now); // firmware update check (gear badge + About line)
uiCp("ui:sbar");
refreshSysInfo(now); // live uptime / heap on the About sub-tab
#if defined(HAS_TDECK_GT911)
refreshSleepDiag(now); // live sleep counters on the Lock settings panel
@@ -37177,7 +37289,9 @@ void UITask::loop() {
// (fed by handleHwKey) or the trackball D-pad nav (fed by updateTrackball -> navMoveDir).
if (s_kbd_nav || s_tb_nav) navMaybeRebuild();
#endif
uiCp("ui:lvgl");
lv_timer_handler();
uiCp("ui:tail");
#if defined(HAS_TANMATSU)
if (s_nav_entered_obj) {
lv_obj_t* ent = s_nav_entered_obj; s_nav_entered_obj = nullptr;