Compare commits

...
3 Commits
Author SHA1 Message Date
Kaj SchittecatandClaude Fable 5 b58cecab5c touch: beta_32 — non-blocking TCP/WS companion writes (Wi-Fi freeze fix) + chunked history flush + stall diagnostics
Root cause of the beta_31 periodic 1-2s freezes with Wi-Fi on: every
received mesh packet pushes an RX-log frame to all connected TCP/WS
companion clients from the loop thread, and writeAllBytes called
WiFiClient::write() unguarded — against a stale peer (phone asleep,
half-open socket) that blocks in the framework's 1s select() retries.
The 120ms app timeout only bounded attempts, not the first call.

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 21:07:42 +02:00
12 changed files with 584 additions and 51 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_31 (TEST) — the new chats tab: avatars, message previews, custom channel icons. On top of beta_30.
# One user-facing note per non-blank, non-# line; # lines are section comments.
# --- New ---
The chats tab has a fresh look: each conversation now shows a round avatar in that chat's signature colour (the same colour its bubbles use) with the first two letters of the name, the last message previewed under the name, the time top right and the unread count below it. Compact chat mode keeps the dense single-line rows instead, so both styles are available.
Channels can have a custom icon: open the channel's settings (long-press or the gear) and tap "Chat icon" to pick any emoji as its avatar. Long-press the same entry to go back to the automatic letters. The choice is remembered per channel.
# --- Fixed ---
Long chat names no longer wrap onto the message preview underneath.
+9
View File
@@ -0,0 +1,9 @@
# beta_32 (TEST) — the Wi-Fi freeze fix: a stale phone/web connection can no longer stall the device. On top of beta_31.
# One user-facing note per non-blank, non-# line; # lines are section comments.
# --- Fixed ---
The 1-2 second freezes every ~10 seconds some users saw with Wi-Fi on are gone. When a phone app or web client was connected over Wi-Fi and that connection went stale (phone asleep, walked out of range), every received mesh packet made the device wait on the dead connection before the UI could continue. The device now only writes to a connection when it can accept data immediately, and a connection that stays unresponsive for 10 seconds straight is dropped so the client can reconnect cleanly.
Saving chat history could briefly hitch the UI on busy meshes: the history file now writes in a few large chunks instead of thousands of tiny ones (the same treatment the contact list got in beta_30), and background saves are spaced further apart while messages stream in.
# --- New ---
The About page now shows recent loop stalls (anything over 0.2 seconds, newest first), so freeze reports can be confirmed or ruled out at a glance.
+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.
+65
View File
@@ -1681,6 +1681,71 @@ void touchPrefsSetChannelMute(const char* name, uint8_t flags) {
s_begun = s_prefs.begin(TOUCH_NS, true);
}
// ---- Per-channel avatar emoji (chat-list avatar override) ----
// Same keyed-blob pattern as the mute table above: 32-byte name + 16-byte UTF-8
// glyph (16 covers ZWJ sequences). No entry = auto (the two-letter avatar).
// 24 entries x 48 B = 1152 B, safely under the SdNvsPrefs 2048-byte blob cap.
static const int CHE_NAME = TOUCH_CHMUTE_NAME;
static const int CHE_GLYPH = 16;
static const int CHE_ENTRY = CHE_NAME + CHE_GLYPH;
static const int CHE_MAX = 24;
static const char* KEY_CHE = "chemoji";
static uint8_t s_che[CHE_MAX * CHE_ENTRY];
static int s_che_n = -1; // -1 = not loaded yet
static void cheLoad() {
if (s_che_n >= 0) return;
s_che_n = 0;
if (!s_begun) touchPrefsBegin();
if (!s_prefs.isKey(KEY_CHE)) return;
size_t n = s_prefs.getBytes(KEY_CHE, s_che, sizeof(s_che));
if (n == 0 || n > sizeof(s_che)) { s_che_n = 0; return; }
s_che_n = (int)(n / CHE_ENTRY);
}
static int cheFind(const char* name) {
cheLoad();
for (int i = 0; i < s_che_n; ++i)
if (strncmp((const char*)&s_che[i * CHE_ENTRY], name, CHE_NAME) == 0) return i;
return -1;
}
bool touchPrefsGetChannelEmoji(const char* name, char* out, size_t cap) {
if (out && cap) out[0] = '\0';
if (!name || !name[0] || !out || cap == 0) return false;
int i = cheFind(name);
if (i < 0) return false;
const char* g = (const char*)&s_che[i * CHE_ENTRY + CHE_NAME];
size_t n = strnlen(g, CHE_GLYPH);
if (n >= cap) n = cap - 1;
memcpy(out, g, n);
out[n] = '\0';
return out[0] != '\0';
}
void touchPrefsSetChannelEmoji(const char* name, const char* utf8) {
if (!name || !name[0]) return;
cheLoad();
int i = cheFind(name);
if (!utf8 || !utf8[0]) { // clear -> back to auto letters
if (i < 0) return;
for (int j = i; j + 1 < s_che_n; ++j) memcpy(&s_che[j * CHE_ENTRY], &s_che[(j + 1) * CHE_ENTRY], CHE_ENTRY);
--s_che_n;
} else {
if (i < 0) {
if (s_che_n >= CHE_MAX) return; // cap reached
i = s_che_n++;
memset(&s_che[i * CHE_ENTRY], 0, CHE_ENTRY);
strncpy((char*)&s_che[i * CHE_ENTRY], name, CHE_NAME - 1);
}
memset(&s_che[i * CHE_ENTRY + CHE_NAME], 0, CHE_GLYPH);
strncpy((char*)&s_che[i * CHE_ENTRY + CHE_NAME], utf8, CHE_GLYPH - 1);
}
s_prefs.end();
if (s_prefs.begin(TOUCH_NS, false)) {
if (s_che_n == 0) s_prefs.remove(KEY_CHE);
else s_prefs.putBytes(KEY_CHE, s_che, (size_t)(s_che_n * CHE_ENTRY));
s_prefs.end();
}
s_begun = s_prefs.begin(TOUCH_NS, true);
}
// Remembered repeater admin passwords --------------------------------------
//
// Layout: single NVS blob "rpw" of up to TOUCH_REPEATER_PW_MAX records,
+2
View File
@@ -383,6 +383,8 @@ constexpr uint8_t TOUCH_CHMUTE_MSG = 0x1;
constexpr uint8_t TOUCH_CHMUTE_MEN = 0x2;
uint8_t touchPrefsGetChannelMute(const char* name);
void touchPrefsSetChannelMute(const char* name, uint8_t flags);
bool touchPrefsGetChannelEmoji(const char* name, char* out, size_t cap); // chat-list avatar override; false/empty = auto letters
void touchPrefsSetChannelEmoji(const char* name, const char* utf8); // empty/null clears back to auto
/** Generic NVS blob (key/value). Used to persist the discovered-nodes list so
* it survives a reboot. getBlob returns the byte count copied (0 if absent). */
+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
}
+351 -32
View File
@@ -5272,6 +5272,29 @@ static void threadSheetResetPathCb(lv_event_t* e) {
}
}
// Chat-icon chooser (channels): tap picks an emoji for the chat-list avatar via the
// emoji sheet in pick mode; long-press resets to the automatic two-letter avatar.
static void openEmojiPickerPick(void (*cb)(const char* utf8), const char* title); // defined with the emoji sheet below
static void threadSheetIconPicked(const char* g) {
char nm[UITask::MAX_THREAD_NAME + 1] = "";
if (!channelLongSheetName(nm, sizeof nm)) return;
touchPrefsSetChannelEmoji(nm, g);
g_lv.dirty_threads = true; // chat list re-renders the avatar
if (g_lv.task) g_lv.task->showAlert(TR("Chat icon set"), 1200);
}
static void threadSheetIconCb(lv_event_t* e) {
if (lv_event_get_code(e) != LV_EVENT_CLICKED) return;
openEmojiPickerPick(threadSheetIconPicked, "Chat icon");
}
static void threadSheetIconResetCb(lv_event_t* e) {
if (lv_event_get_code(e) != LV_EVENT_LONG_PRESSED) return;
char nm[UITask::MAX_THREAD_NAME + 1] = "";
if (!channelLongSheetName(nm, sizeof nm)) return;
touchPrefsSetChannelEmoji(nm, "");
g_lv.dirty_threads = true;
if (g_lv.task) g_lv.task->showAlert(TR("Chat icon reset to letters"), 1400);
}
// Per-thread settings sheet (DM or channel) — opened by long-press AND the per-row gear AND the
// in-channel cog, so all are the SAME screen. Mark-as-read, mute messages / @ mentions, region &
// scope, share secret, blocked users, remove/delete.
@@ -5309,7 +5332,7 @@ static void openThreadActionSheet(int thread_idx, const char* name, bool is_chan
#else
const int card_w = 232;
const int btn_h = 30;
const int btn_gap = 6;
const int btn_gap = 4; // 4 (was 6): the 7-item channel grid + danger row must fit the T-Deck's ~206 px
const int title_h = 28;
const int pad = 6;
const lv_font_t* row_font = &g_font_12;
@@ -5327,7 +5350,7 @@ static void openThreadActionSheet(int thread_idx, const char* name, bool is_chan
// spans the full width at the bottom. Channel: mark-read + region + the two
// mutes + share + blocked = 6 grid items (3 rows). Room: mark-read +
// login-again + reset-path + blocked = 4 (2 rows). DM: 3 (2 rows).
const int grid_items = is_channel ? 6 : (is_room_thread ? 4 : 3);
const int grid_items = is_channel ? 7 : (is_room_thread ? 4 : 3); // channels: +Chat icon
const int grid_rows = (grid_items + 1) / 2; // ceil
int card_h = title_h + (grid_rows + 1) * (btn_h + btn_gap) + pad;
const int max_h = lv_disp_get_ver_res(nullptr) - STATUSBAR_H - 12;
@@ -5423,6 +5446,9 @@ static void openThreadActionSheet(int thread_idx, const char* name, bool is_chan
chmuteRefreshLabels();
mk(LV_SYMBOL_SHUFFLE " Share secret", channelLongSheetShareCb, 0);
mk(LV_SYMBOL_CLOSE " Blocked users", channelLongSheetBlockedCb, 0);
// Chat-list avatar emoji: tap = pick, long-press = back to the two-letter auto avatar.
lv_obj_t* icb = mk(LV_SYMBOL_IMAGE " Chat icon", threadSheetIconCb, 0);
if (icb) lv_obj_add_event_cb(icb, threadSheetIconResetCb, LV_EVENT_LONG_PRESSED, nullptr);
mk_full(LV_SYMBOL_TRASH " Remove channel", channelLongSheetDeleteCb, 0xB23A48);
} else {
if (is_room_thread)
@@ -5654,11 +5680,17 @@ static constexpr int k_special_count = (int)(sizeof(k_special_items) / sizeof(k_
static const char* const* s_glyph_items = k_emoji_items;
static int s_glyph_count = k_emoji_count;
// Pick-mode: when set, the next chosen glyph is handed to this callback instead
// of being inserted into a textarea (used by the chat-icon chooser). Dismissing
// the sheet without choosing cancels (the closer clears it).
static void (*s_emoji_pick_cb)(const char* utf8) = nullptr;
static void closeEmojiSheet() {
popupClose(&s_emoji_sheet);
s_emoji_target_ta = nullptr;
s_emoji_grid = nullptr;
s_emoji_sel = -1;
s_emoji_pick_cb = nullptr;
}
static void emojiSheetCloseCb(lv_event_t* e) {
if (lv_event_get_code(e) != LV_EVENT_CLICKED) return;
@@ -5669,6 +5701,14 @@ static void emojiSheetCloseCb(lv_event_t* e) {
// Insert item `idx` into the active composer and close. Shared by finger-tap
// (emojiPickCb) and the trackball selector (emojiSelectorClick).
static void emojiInsertIndex(int idx) {
if (s_emoji_pick_cb) {
void (*cb)(const char*) = s_emoji_pick_cb;
s_emoji_pick_cb = nullptr; // one-shot; also survives the closer's clear
const char* g = (idx >= 0 && idx < s_glyph_count) ? s_glyph_items[idx] : nullptr;
closeEmojiSheet();
if (g) cb(g);
return;
}
lv_obj_t* ta = s_emoji_target_ta;
if (idx < 0 || idx >= s_glyph_count || !ta) { closeEmojiSheet(); return; }
const char* g = s_glyph_items[idx];
@@ -5685,6 +5725,14 @@ static void emojiPickCb(lv_event_t* e) {
emojiInsertIndex((int)(intptr_t)lv_event_get_user_data(e));
}
// Open the emoji sheet in PICK mode: the chosen glyph goes to `cb` (not a textarea).
// Defined after openEmojiPicker; forward-declared next to the thread-sheet code.
static void openEmojiPicker(lv_obj_t* ta, const char* const* items, int count, const char* picker_title);
static void openEmojiPickerPick(void (*cb)(const char* utf8), const char* title) {
openEmojiPicker(nullptr, k_emoji_items, k_emoji_count, title); // resets pick state via closeEmojiSheet()
s_emoji_pick_cb = cb; // arm AFTER the open (the closer clears it)
}
// Paint the trackball-selected cell highlighted and the rest normal, and keep
// the selection scrolled into view. No-op when nothing is selected (finger-only).
static void emojiPaintSelection() {
@@ -8505,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)
@@ -8596,6 +8649,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);
@@ -25491,9 +25559,11 @@ static void refreshChatList(LvChatPanel& p) {
// every periodic refresh, which resets the scroll to the top — so skip the rebuild
// entirely when nothing changed (the common case while you're just scrolling), and
// otherwise preserve the scroll position across it.
const bool compact_rows = touchPrefsGetCompactChat(); // compact = today's dense contact-style rows; off = WhatsApp-style
uint32_t sig = 2166136261u;
auto mix = [&sig](uint32_t v) { sig = (sig ^ v) * 16777619u; };
mix((uint32_t)count);
mix(compact_rows ? 0xC0FFEEu : 1u);
for (int i = 0; i < count; ++i) {
bool ch = false; uint16_t unread = 0; uint32_t ts = 0;
char nm[UITask::MAX_THREAD_NAME + 1];
@@ -25501,6 +25571,11 @@ static void refreshChatList(LvChatPanel& p) {
mix((uint32_t)idxs[i]); mix(unread); mix(ts);
mix((ch ? 2u : 0u) | (g_lv.task->threadHasMention(idxs[i]) ? 1u : 0u));
for (const char* s = nm; *s; ++s) mix((uint8_t)*s);
if (ch && !compact_rows) { // avatar-emoji change must re-render the row
char eb[20];
if (touchPrefsGetChannelEmoji(nm, eb, sizeof eb))
for (const char* s2 = eb; *s2; ++s2) mix((uint8_t)*s2);
}
}
if (sig == p.list_sig && lv_obj_get_child_cnt(p.list_cont) > 0) return; // nothing changed
p.list_sig = sig;
@@ -25525,11 +25600,14 @@ static void refreshChatList(LvChatPanel& p) {
char san_name[UITask::MAX_THREAD_NAME + 8];
copyUtf8ReplacingMissingGlyphs(&g_font_14, san_name, sizeof(san_name), name);
lv_obj_t* btn = nullptr;
if (compact_rows) {
// ---- Compact rows (the dense contact-style list) ----
// Name only — the unread count is shown as a right-aligned badge below.
// DM = single person; channel = group of people (renders via the person_font
// splice on g_font_14). Replaces the old envelope / loop-arrow glyphs.
const char* icon = ch ? TOUCH_SYM_GROUP : TOUCH_SYM_PERSON;
lv_obj_t* btn = lv_list_add_btn(p.list_cont, icon, san_name);
btn = lv_list_add_btn(p.list_cont, icon, san_name);
// Match the Contacts-tab row recipe exactly (Kaj: one list design across the
// tabs, and the same 34 px height so more threads fit per screen): panel fill,
@@ -25634,6 +25712,173 @@ static void refreshChatList(LvChatPanel& p) {
lv_obj_set_style_text_color(at, lv_color_hex(COLOR_MENTION), LV_PART_MAIN);
lv_obj_align(at, LV_ALIGN_RIGHT_MID, (lv_coord_t)(unread > 0 ? r_edge - 38 : r_edge), 0);
}
} else {
// ---- WhatsApp-style rows (default, compact chat OFF) ----
// Round avatar in the thread's signature colour with a stable per-name emoji,
// name + last-message preview stacked next to it, time top-right, unread
// pill + @ below the time, gear on the far edge.
btn = lv_list_add_btn(p.list_cont, nullptr, nullptr);
lv_obj_set_style_bg_color(btn, lv_color_hex(COLOR_PANEL), LV_PART_MAIN);
lv_obj_set_style_bg_opa(btn, LV_OPA_COVER, LV_PART_MAIN);
lv_obj_set_style_bg_color(btn, lv_color_hex(0x141516), LV_PART_MAIN | LV_STATE_PRESSED);
lv_obj_set_style_border_width(btn, 1, LV_PART_MAIN);
lv_obj_set_style_border_color(btn, lv_color_hex(0x141516), LV_PART_MAIN);
lv_obj_set_style_border_side(btn, LV_BORDER_SIDE_BOTTOM, LV_PART_MAIN);
lv_obj_set_style_radius(btn, 0, LV_PART_MAIN);
lv_obj_set_style_pad_all(btn, 0, LV_PART_MAIN);
lv_obj_set_style_min_height(btn, 56, LV_PART_MAIN);
lv_obj_set_height(btn, 56);
lv_obj_clear_flag(btn, LV_OBJ_FLAG_SCROLLABLE);
// Avatar: same FNV-1a hue family as the chat-bubble colours (see
// usernameBubbleColors), lifted in value so the disc reads on the dark panel.
uint32_t hh = 2166136261u;
for (const char* s2 = name; *s2; ++s2) { hh ^= (uint8_t)*s2; hh *= 16777619u; }
lv_obj_t* av = lv_obj_create(btn);
lv_obj_remove_style_all(av);
lv_obj_add_flag(av, LV_OBJ_FLAG_IGNORE_LAYOUT);
lv_obj_clear_flag(av, LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_SCROLLABLE); // taps fall through to the row
lv_obj_set_size(av, 40, 40);
lv_obj_set_style_radius(av, LV_RADIUS_CIRCLE, LV_PART_MAIN);
lv_obj_set_style_bg_color(av, lv_color_hsv_to_rgb((uint16_t)(hh % 360u), 55, 42), LV_PART_MAIN);
lv_obj_set_style_bg_opa(av, LV_OPA_COVER, LV_PART_MAIN);
lv_obj_align(av, LV_ALIGN_LEFT_MID, 8, 0);
// Avatar content: a user-chosen emoji for channels (thread sheet -> Chat icon),
// otherwise the first two letters of the name (a leading '#' skipped, ASCII
// uppercased) — the classic initials avatar.
const lv_img_dsc_t* eg = nullptr;
char av_glyph[20] = "";
if (ch && touchPrefsGetChannelEmoji(name, av_glyph, sizeof av_glyph)) {
uint32_t goff = 0;
eg = emojiGlyphLookup(_lv_txt_encoded_next(av_glyph, &goff)); // ZWJ glyphs are keyed on their lead codepoint
}
if (eg) {
lv_obj_t* im = lv_img_create(av);
lv_img_set_src(im, eg);
lv_img_set_zoom(im, 384); // 16 px baked glyph -> ~24 px in the 40 px disc
lv_img_set_antialias(im, true);
lv_obj_center(im);
} else {
char initials[12]; int o = 0, glyphs = 0;
const char* q = name;
while (*q == '#' || *q == ' ') ++q;
while (*q && glyphs < 2 && o < 8) {
const uint8_t c = (uint8_t)*q;
int len = 1;
if (c >= 0xF0) len = 4; else if (c >= 0xE0) len = 3; else if (c >= 0xC0) len = 2;
for (int b = 0; b < len && *q; ++b) initials[o++] = *q++;
++glyphs;
}
initials[o] = '\0';
for (char* u = initials; *u; ++u) if ((uint8_t)*u < 0x80) *u = (char)toupper((unsigned char)*u);
char av_txt[16];
copyUtf8ReplacingMissingGlyphs(&g_font_16, av_txt, sizeof av_txt, initials[0] ? initials : "?");
lv_obj_t* fl = lv_label_create(av);
lv_label_set_text(fl, av_txt);
lv_obj_set_style_text_font(fl, &g_font_16, LV_PART_MAIN);
lv_obj_set_style_text_color(fl, lv_color_hex(COLOR_TEXT), LV_PART_MAIN);
lv_obj_center(fl);
}
// Per-row settings gear on the far right (same behaviour as the compact rows).
const lv_coord_t gear_w = 28;
{
lv_obj_t* gear = lv_btn_create(btn);
lv_obj_remove_style_all(gear);
lv_obj_add_flag(gear, LV_OBJ_FLAG_IGNORE_LAYOUT);
lv_obj_add_flag(gear, NAV_HMOVE_FLAG);
lv_obj_set_size(gear, gear_w, 40);
lv_obj_align(gear, LV_ALIGN_RIGHT_MID, -2, 0);
lv_obj_add_event_cb(gear, threadGearCb, LV_EVENT_CLICKED, &p.ctx_store[i]);
lv_obj_t* gl = lv_label_create(gear);
lv_label_set_text(gl, LV_SYMBOL_SETTINGS);
lv_obj_set_style_text_font(gl, &g_font_14, LV_PART_MAIN);
lv_obj_set_style_text_color(gl, lv_color_hex(COLOR_SUB), LV_PART_MAIN);
lv_obj_center(gl);
}
// Time, top-right (left of the gear).
const lv_coord_t time_x = (lv_coord_t)(-(10 + gear_w));
char tbuf[16];
formatChatRowTime(tbuf, sizeof(tbuf), ts);
lv_coord_t time_w = 0;
if (tbuf[0]) {
lv_point_t tsz;
lv_txt_get_size(&tsz, tbuf, &g_font_12, 0, 0, LV_COORD_MAX, 0);
time_w = tsz.x;
lv_obj_t* tlbl = lv_label_create(btn);
lv_obj_add_flag(tlbl, LV_OBJ_FLAG_IGNORE_LAYOUT);
lv_label_set_text(tlbl, tbuf);
lv_obj_set_style_text_font(tlbl, &g_font_12, LV_PART_MAIN);
lv_obj_set_style_text_color(tlbl, lv_color_hex(unread > 0 ? COLOR_ACCENT : COLOR_SUB), LV_PART_MAIN);
lv_obj_align(tlbl, LV_ALIGN_TOP_RIGHT, time_x, 8);
}
// Name (top line) + last-message preview (bottom line), right of the avatar.
const lv_coord_t text_x = 8 + 40 + 8;
const lv_coord_t name_w = (lv_coord_t)(lv_disp_get_hor_res(nullptr) - text_x - gear_w - time_w - 24);
lv_obj_t* nm2 = lv_label_create(btn);
lv_obj_add_flag(nm2, LV_OBJ_FLAG_IGNORE_LAYOUT);
lv_label_set_text(nm2, san_name);
lv_obj_set_style_text_font(nm2, &g_font_14, LV_PART_MAIN);
lv_obj_set_style_text_color(nm2, lv_color_hex(unread > 0 ? COLOR_ACCENT : COLOR_TEXT), LV_PART_MAIN);
lv_label_set_long_mode(nm2, LV_LABEL_LONG_DOT);
// Fixed ONE-LINE height: with only a width, LONG_DOT lets a long name wrap to
// a second line (never truncating) and it overlapped the preview underneath.
lv_obj_set_size(nm2, name_w, 18);
lv_obj_align(nm2, LV_ALIGN_TOP_LEFT, text_x, 9);
// Preview: "sender: text" for channels, "You: text" for own DMs, plain text otherwise.
char psender[UITask::MAX_SENDER_NAME + 1] = "";
char ptext[80] = "";
bool pout = false;
char preview[120] = "";
if (g_lv.task->getThreadLastMessage(idxs[i], psender, sizeof psender, ptext, sizeof ptext, &pout)) {
char raw[112];
if (ch && psender[0] && !pout) snprintf(raw, sizeof raw, "%s: %s", psender, ptext);
else if (pout) snprintf(raw, sizeof raw, "%s: %s", TR("You"), ptext);
else snprintf(raw, sizeof raw, "%s", ptext);
// Previews render in a plain 12 px label: strip newlines, replace unbaked
// glyphs, and let LONG_DOT ellipsize the rest.
for (char* q = raw; *q; ++q) if (*q == '\n' || *q == '\r') *q = ' ';
copyUtf8ReplacingMissingGlyphs(&g_font_12, preview, sizeof preview, raw);
}
lv_obj_t* pv = lv_label_create(btn);
lv_obj_add_flag(pv, LV_OBJ_FLAG_IGNORE_LAYOUT);
lv_label_set_text(pv, preview[0] ? preview : "");
lv_obj_set_style_text_font(pv, &g_font_12, LV_PART_MAIN);
lv_obj_set_style_text_color(pv, lv_color_hex(COLOR_SUB), LV_PART_MAIN);
lv_label_set_long_mode(pv, LV_LABEL_LONG_DOT);
lv_obj_set_size(pv, (lv_coord_t)(lv_disp_get_hor_res(nullptr) - text_x - gear_w - 60), 16); // one line, ellipsized
lv_obj_align(pv, LV_ALIGN_BOTTOM_LEFT, text_x, -8);
// Unread pill bottom-right (under the time), @ to its left on a mention.
if (unread > 0) {
lv_obj_t* badge = lv_label_create(btn);
lv_obj_add_flag(badge, LV_OBJ_FLAG_IGNORE_LAYOUT);
char cnt[8];
if (unread > 99) snprintf(cnt, sizeof cnt, "99+");
else snprintf(cnt, sizeof cnt, "%u", (unsigned)unread);
lv_label_set_text(badge, cnt);
lv_obj_set_style_text_font(badge, &g_font_12, LV_PART_MAIN);
lv_obj_set_style_text_color(badge, lv_color_hex(0x0A0B0C), LV_PART_MAIN);
lv_obj_set_style_bg_color(badge, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN);
lv_obj_set_style_bg_opa(badge, LV_OPA_COVER, LV_PART_MAIN);
lv_obj_set_style_radius(badge, 9, LV_PART_MAIN);
lv_obj_set_style_pad_hor(badge, 6, LV_PART_MAIN);
lv_obj_set_style_pad_ver(badge, 1, LV_PART_MAIN);
lv_obj_align(badge, LV_ALIGN_BOTTOM_RIGHT, time_x, -6);
}
if (g_lv.task->threadHasMention(idxs[i])) {
lv_obj_t* at = lv_label_create(btn);
lv_obj_add_flag(at, LV_OBJ_FLAG_IGNORE_LAYOUT);
lv_label_set_text(at, TR("@"));
lv_obj_set_style_text_font(at, &g_font_14, LV_PART_MAIN);
lv_obj_set_style_text_color(at, lv_color_hex(COLOR_MENTION), LV_PART_MAIN);
lv_obj_align(at, LV_ALIGN_BOTTOM_RIGHT, (lv_coord_t)(time_x - (unread > 0 ? 34 : 0)), -6);
}
}
if (!btn) continue;
p.ctx_store[i].idx = idxs[i];
p.ctx_store[i].channel = ch;
@@ -33593,16 +33838,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) {
@@ -33978,10 +34227,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;
@@ -33997,28 +34250,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
@@ -36348,8 +36632,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
@@ -36375,7 +36686,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
@@ -36409,7 +36722,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) {
@@ -36875,7 +37190,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
@@ -36952,7 +37269,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;
+21
View File
@@ -343,6 +343,27 @@ public:
for (int i = 0; i < 32; i++) { out[i] = p[i]; any |= p[i]; }
return any != 0;
}
/** Newest message of a thread (chat-list preview): scans the ring by the
* thread's name + kind and returns the latest by timestamp. False when the
* thread has no messages in the ring. */
bool getThreadLastMessage(int idx, char* sender, size_t sender_cap,
char* text, size_t text_cap, bool* outgoing) const {
if (idx < 0 || idx >= MAX_UI_THREADS || !_ui_threads[idx].used || !_ui_msgs) return false;
const bool ch = _ui_threads[idx].channel;
const char* nm = _ui_threads[idx].name;
int best = -1; uint32_t best_ts = 0;
for (int i = 0; i < _ui_msg_cap; ++i) {
const UIMessage& m = _ui_msgs[i];
if (!m.text[0] || m.channel != ch) continue;
if (strncmp(m.thread, nm, MAX_THREAD_NAME) != 0) continue;
if (best < 0 || m.ts >= best_ts) { best = i; best_ts = m.ts; } // >= : a later slot wins ts ties (newer in the ring)
}
if (best < 0) return false;
if (sender && sender_cap) { strncpy(sender, _ui_msgs[best].sender, sender_cap - 1); sender[sender_cap - 1] = '\0'; }
if (text && text_cap) { strncpy(text, _ui_msgs[best].text, text_cap - 1); text[text_cap - 1] = '\0'; }
if (outgoing) *outgoing = _ui_msgs[best].outgoing;
return true;
}
int threadScroll() const { return _thread_scroll; }
void setThreadScroll(int v) { _thread_scroll = v; }
bool hasDisplay() const { return _display != NULL; }