Compare commits

...
2 Commits
Author SHA1 Message Date
Kaj SchittecatandClaude Opus 4.8 2aa8f59410 touch: beta_8 — Ack reply, map visibility toggles + auto-refresh, RAM trim
- Chat: quick replies insert at the cursor (no longer replace typed text);
  long-press 'Ack' drops a signal-strength + route reply into the composer
  (@mentions the sender on channels); fixed the action-menu overflowing the screen.
- Map: +/- zoom glyph; per-line on-map visibility toggles (coords / tile xyz /
  contacts); microSD-tiles regrouped under a heading; auto-refresh follow button
  with on/off toast; close-X overlap fix when scrolled.
- Perf: ~18.9 KB internal DRAM reclaimed (6 scratch buffers -> on-demand PSRAM).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 14:36:46 +02:00
Kaj SchittecatandClaude Opus 4.8 c8b451f14f release: beta_7 notes — credit contributors prominently (Vybo #12, Ruben Laban #15, marcelverdult #13)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 12:32:03 +02:00
5 changed files with 383 additions and 78 deletions

No files matched your search

+18 -15
View File
@@ -1,31 +1,34 @@
# beta_7 — what's new (one user-facing note per non-blank, non-# line)
# --- Community-built ---
Most of beta_7 was contributed by the community — Dan Vybiral (Vybo) and Ruben Laban. Thank you both!
# --- Chat reliability ---
Fixed garbled and duplicated messages — chat text is no longer corrupted in transit, so messages arrive intact and duplicates are filtered out correctly (thanks to marcelverdult, issue #13)
Unread badges are accurate again: the phantom and stuck unread counts on chats and channels are fixed
More of your chat history is kept and reloaded across reboots, and it is saved more reliably than before
Fixed garbled and duplicated messages — chat text is no longer corrupted in transit, so messages arrive intact and duplicates are filtered out correctly (thanks to marcelverdult for the report, issue #13)
Unread badges are accurate again: the phantom and stuck unread counts on chats and channels are fixed (Ruben Laban, PR #15)
More of your chat history is kept and reloaded across reboots, and it is saved more reliably than before (Ruben Laban, PR #15)
# --- New: Snake game ---
A Snake game is now in the app drawer — steer by swiping the screen or with the T-Deck trackball, with pause/resume and restart after a game over
A Snake game is now in the app drawer — steer by swiping the screen or with the T-Deck trackball, with pause/resume and restart after a game over (Vybo, PR #12)
While you play, touch stays inside the game and no longer leaks through to the screen behind it
# --- Battery & telemetry ---
Tap the battery in the status bar for a 24-hour history chart with an estimated time-left readout; battery level is logged every 5 minutes
New per-node telemetry window: poll a node's battery, temperature and humidity, watch them on a live chart, and keep a per-node log
Tap the battery in the status bar for a 24-hour history chart with an estimated time-left readout; battery level is logged every 5 minutes (Vybo, PR #12)
New per-node telemetry window: poll a node's battery, temperature and humidity, watch them on a live chart, and keep a per-node log (Vybo, PR #12)
# --- Map ---
New map night mode — the status bar and tab bar invert to match the dark map instead of glaring
A live zoom-level readout sits above the zoom slider, and the zoom button now shows a proper magnifier icon
Contacts have a new "Show on map" action that centres the map on that node
New map night mode — the status bar and tab bar invert to match the dark map instead of glaring (Vybo, PR #12)
A live zoom-level readout sits above the zoom slider, and the zoom button now shows a proper magnifier icon (Vybo, PR #12)
Contacts have a new "Show on map" action that centres the map on that node (Vybo, PR #12)
# --- Status bar & tools ---
A microSD activity LED lights whenever the card is accessed, including file-manager reads and copies
An activity spinner shows while a telemetry, admin, ping, trace, range or sightline request is in flight, kept clear of the clock
New option to hide the device name and move the clock to the left
Press and hold the status bar to save a full-screen screenshot to the SD card
A microSD activity LED lights whenever the card is accessed, including file-manager reads and copies (Vybo, PR #12)
An activity spinner shows while a telemetry, admin, ping, trace, range or sightline request is in flight, kept clear of the clock (Vybo, PR #12)
New option to hide the device name and move the clock to the left (Vybo, PR #12)
Press and hold the status bar to save a full-screen screenshot to the SD card (Vybo, PR #12)
# --- Files & images ---
The file browser can preview PNG and BMP images now, not just JPEG, and no longer shows a stale image when you open several in a row
The file browser can preview PNG and BMP images now, not just JPEG, and no longer shows a stale image when you open several in a row (Vybo, PR #12)
Lock-screen wallpaper: open Files, view any JPEG on your SD card, and tap "Set as wallpaper" — no more slow scan or "no image found"
# --- App drawer ---
@@ -41,5 +44,5 @@ Replying to a message with @ no longer overwrites text you were already typing
Fixed an out-of-memory crash and made notification audio more reliable
# --- Community ---
Huge thanks to Dan Vybíral (Vybo) for the Snake game, battery history, per-node telemetry, map night mode, PNG/BMP image viewer and the status-bar tools (PR #12)
Huge thanks to Dan Vybiral (Vybo) — most of beta_7 is his work: the Snake game, battery history, per-node telemetry, map night mode, PNG/BMP image viewer and the whole status-bar toolkit (PR #12)
Thanks to Ruben Laban for the accurate unread-badge and chat-history-persistence fixes (PR #15)
+16
View File
@@ -0,0 +1,16 @@
# beta_8 — what's new (one user-facing note per non-blank, non-# line)
# --- Chat ---
Quick replies now insert at the cursor instead of wiping whatever you'd already typed — tap one mid-message and it's added in place
Long-press any message for a new one-tap "Ack": it drops a ready-to-send reply into the composer with that message's signal strength (SNR/RSSI) and the route it took, so you can instantly confirm someone's test came through — on a channel it @mentions the sender
Fixed the message menu (Ack / Copy / Info / Mention / Block) running off the bottom of the screen — it now fits, and scrolls if it ever needs to
# --- Map ---
The zoom button now shows a clear "+/-" icon
Each on-map readout can be shown or hidden independently in Map settings — the coordinates line, the tile x/y/z line, and the contact markers
Map settings are reorganised: the microSD-tiles switch sits at the top under its own heading, separated from the new show/hide toggles
New auto-refresh button: turn it on and the map re-centres on you automatically whenever your position changes, with an on-screen "Auto-refresh on/off" confirmation
Fixed the close (X) in Map settings overlapping the switches when the panel was scrolled
# --- Performance ---
Freed about 19 KB of internal memory, leaving more headroom for Wi-Fi and trimming the idle-memory growth seen in recent betas
+35 -1
View File
@@ -35,7 +35,7 @@ static bool s_begun = false;
// short read (→ treat as absent → defaults); `ver` lets later builds add fields.
static const char* KEY_CFG = "cfg";
static const uint16_t TOUCH_CFG_MAGIC = 0x5743; // 'WC' (WadaCfg)
static const uint8_t TOUCH_CFG_VER = 5; // v2 sig_probe/poll; v3 tz_zone; v4 hide_node_name; v5 map_night/map_zoom
static const uint8_t TOUCH_CFG_VER = 6; // v2 sig_probe/poll; v3 tz_zone; v4 hide_node_name; v5 map_night/map_zoom; v6 map text/marker visibility
// Defaults (kept identical to the historical per-key defaults).
static const uint16_t DEFAULT_SCREEN_TIMEOUT_S = 20;
@@ -76,6 +76,9 @@ struct __attribute__((packed)) TouchCfg {
uint8_t hide_node_name; // hide the device name in the status bar + park clock left (bool) — v4
uint8_t map_night; // invert map tile colours at render time (bool) — v5
uint8_t map_zoom; // last map zoom level (0 = unset, auto-snap) — v5
uint8_t map_show_coords; // show the coords read-out on the map (bool) — v6
uint8_t map_show_tilexyz; // show the zoom + tile z/x/y line on the map (bool) — v6
uint8_t map_show_contacts;// show contact markers on the map (bool) — v6
};
static TouchCfg s_cfg;
@@ -128,6 +131,9 @@ static void cfgSetDefaults(TouchCfg& c) {
c.hide_node_name = 0; // default: show the device name
c.map_night = 0; // default: normal (light) tiles
c.map_zoom = 0; // 0 = unset -> auto-snap on first map open
c.map_show_coords = 1; // default: show coords / tile line / contacts
c.map_show_tilexyz = 1;
c.map_show_contacts = 1;
}
// Persist the whole blob using the same end()/begin(RW)/put/end()/begin(RO)
@@ -718,6 +724,34 @@ bool touchPrefsSetMapZoom(uint8_t z) {
return cfgFlush();
}
bool touchPrefsGetMapShowCoords() {
if (!s_begun) touchPrefsBegin();
return s_cfg.map_show_coords != 0;
}
bool touchPrefsSetMapShowCoords(bool on) {
if (!s_begun) touchPrefsBegin();
s_cfg.map_show_coords = on ? 1 : 0;
return cfgFlush();
}
bool touchPrefsGetMapShowTileXYZ() {
if (!s_begun) touchPrefsBegin();
return s_cfg.map_show_tilexyz != 0;
}
bool touchPrefsSetMapShowTileXYZ(bool on) {
if (!s_begun) touchPrefsBegin();
s_cfg.map_show_tilexyz = on ? 1 : 0;
return cfgFlush();
}
bool touchPrefsGetMapShowContacts() {
if (!s_begun) touchPrefsBegin();
return s_cfg.map_show_contacts != 0;
}
bool touchPrefsSetMapShowContacts(bool on) {
if (!s_begun) touchPrefsBegin();
s_cfg.map_show_contacts = on ? 1 : 0;
return cfgFlush();
}
// Store ALL device data (identity, prefs, contacts, channels) on the SD card
// under /meshcomod instead of internal SPIFFS. Read at boot (main.cpp) BEFORE
// the data loads, so changing it needs a reboot. Key "use_sd" in the "touch"
+9
View File
@@ -90,6 +90,15 @@ bool touchPrefsSetMapNight(bool on);
uint8_t touchPrefsGetMapZoom();
bool touchPrefsSetMapZoom(uint8_t z);
/** Per-element visibility of the map's on-screen text/markers. All default true
* (shown), so existing installs are unchanged. */
bool touchPrefsGetMapShowCoords();
bool touchPrefsSetMapShowCoords(bool on);
bool touchPrefsGetMapShowTileXYZ();
bool touchPrefsSetMapShowTileXYZ(bool on);
bool touchPrefsGetMapShowContacts();
bool touchPrefsSetMapShowContacts(bool on);
/** Hide the device/profile name in the status bar and move the clock to the
* left where the name used to be. Default false (name shown). */
bool touchPrefsGetHideNodeName();
+305 -62
View File
@@ -757,9 +757,11 @@ static int s_diag_line = 0;
static char s_diag_id_pinned[DIAG_COLS] = { 0 };
constexpr int RXLOG_LINES = 28;
constexpr int RXLOG_COLS = 80;
static char s_rxlog_ring[RXLOG_LINES][RXLOG_COLS];
// Monitor RX/RAW rings — lazy PSRAM (allocated in begin(), internal fallback):
// keeps 2×28×80 = 4.4 KB off internal DRAM. Null-guarded in the helpers below.
static char (*s_rxlog_ring)[RXLOG_COLS] = nullptr;
static int s_rxlog_line = 0;
static char s_rawlog_ring[RXLOG_LINES][RXLOG_COLS];
static char (*s_rawlog_ring)[RXLOG_COLS] = nullptr;
static int s_rawlog_line = 0;
static lv_obj_t* s_live_diag_label = nullptr;
static uint32_t s_live_diag_reads = 0;
@@ -921,7 +923,13 @@ static const uint8_t DISC_BLOB_VER = 1;
static constexpr size_t DISC_REC_SZ = PUB_KEY_SIZE + 32 + 4 + 16; // pubkey + name + 4×u8 + 4×u32
static constexpr int DISC_PER_CHUNK = 24; // 2 + 24*84 = 2018 B < 2048 cap
static constexpr int DISC_CHUNKS = (DISCOVERED_MAX + DISC_PER_CHUNK - 1) / DISC_PER_CHUNK;
static uint8_t s_disc_blob[2 + DISC_PER_CHUNK * DISC_REC_SZ];
static constexpr size_t DISC_BLOB_SZ = 2 + DISC_PER_CHUNK * DISC_REC_SZ;
static uint8_t* s_disc_blob = nullptr; // Discovered-ring persistence scratch — lazy PSRAM (internal fallback): ~2 KB off internal DRAM
static uint8_t* discBlob() { // alloc on first save/load; returns null only on (unlikely) OOM
if (!s_disc_blob) { s_disc_blob = (uint8_t*)heap_caps_malloc(DISC_BLOB_SZ, MALLOC_CAP_SPIRAM);
if (!s_disc_blob) s_disc_blob = (uint8_t*)heap_caps_malloc(DISC_BLOB_SZ, MALLOC_CAP_8BIT); }
return s_disc_blob;
}
static inline void discPutU32(uint8_t*& p, uint32_t v) { p[0]=(uint8_t)v; p[1]=(uint8_t)(v>>8); p[2]=(uint8_t)(v>>16); p[3]=(uint8_t)(v>>24); p+=4; }
static inline uint32_t discGetU32(const uint8_t*& p) { uint32_t v=(uint32_t)p[0]|((uint32_t)p[1]<<8)|((uint32_t)p[2]<<16)|((uint32_t)p[3]<<24); p+=4; return v; }
static void discChunkKey(int chunk, char* out, size_t cap) {
@@ -929,7 +937,7 @@ static void discChunkKey(int chunk, char* out, size_t cap) {
else { snprintf(out, cap, "disc%d", chunk); }
}
static void saveDiscovered() {
if (!s_discovered) return;
if (!s_discovered || !discBlob()) return;
int src = 0; // walk the ring once, emitting used entries into successive chunk blobs
for (int chunk = 0; chunk < DISC_CHUNKS; ++chunk) {
uint8_t* p = s_disc_blob;
@@ -959,12 +967,12 @@ static void saveDiscovered() {
s_disc_dirty = false;
}
static void loadDiscovered() {
if (!s_discovered) return;
if (!s_discovered || !discBlob()) return;
memset(s_discovered, 0, sizeof(LvDiscoveredEntry) * DISCOVERED_MAX);
int dst = 0;
for (int chunk = 0; chunk < DISC_CHUNKS && dst < DISCOVERED_MAX; ++chunk) {
char key[8]; discChunkKey(chunk, key, sizeof key);
size_t got = touchPrefsGetBlob(key, s_disc_blob, sizeof(s_disc_blob));
size_t got = touchPrefsGetBlob(key, s_disc_blob, DISC_BLOB_SZ);
if (got < 2 || s_disc_blob[0] != DISC_BLOB_VER) continue; // missing / garbage chunk -> skip
uint8_t count = s_disc_blob[1];
const uint8_t* p = s_disc_blob + 2;
@@ -1405,7 +1413,7 @@ static void styleButton(lv_obj_t* obj) {
// IMPORTANT: each caller is responsible for keeping the top-right ~32×32
// of the card content-free (or for placing only short titles there) so
// the X doesn't sit on top of a real button.
static void addCloseXBadge(lv_obj_t* card, lv_event_cb_t cb, void* user_data = nullptr) {
static lv_obj_t* addCloseXBadge(lv_obj_t* card, lv_event_cb_t cb, void* user_data = nullptr) {
lv_obj_t* x = lv_obj_create(card);
lv_obj_remove_style_all(x);
lv_obj_set_size(x, 32, 32);
@@ -1435,6 +1443,7 @@ static void addCloseXBadge(lv_obj_t* card, lv_event_cb_t cb, void* user_data = n
// nothing. The 32×32 hit zone still extends down/left for an easy tap,
// but visually the glyph occupies the corner of the popup card.
lv_obj_align(lbl, LV_ALIGN_TOP_RIGHT, -6, 4);
return x;
}
// ============================================================
@@ -1615,6 +1624,7 @@ static void logModeRawCb(lv_event_t* e);
static void showConfirm(const char* msg, const char* ok_label, void (*on_confirm)());
static void clipboardSet(const char* text, const char* tag);
static void copyLabelLongPressCb(lv_event_t* e);
static void taClearSelection(lv_obj_t* ta); // clear composer text-selection before an insert
static lv_obj_t* createSettingsModal(const char* title, SettingsModalKind kind);
static void chatDeleteApply();
static void shareMyContactBtnCb(lv_event_t* e);
@@ -3372,10 +3382,11 @@ static void qrPickCb(lv_event_t* e) {
char buf[TOUCH_QUICK_REPLY_MAXLEN];
int n = touchPrefsGetQuickReply((int)idx, buf, sizeof(buf));
if (n <= 0) return;
// Append at the cursor (NOT set_text) so a quick-reply tapped after an
// "@[name] " mention adds to it instead of wiping the address — matches the
// @-mention insert path. No auto-send: lets the operator tweak before the
// send button (a stray tap blasting a macro is worse UX than an extra tap).
// Insert at the cursor (NOT set_text) so a quick-reply tapped after some typed
// text adds to it instead of wiping it. Clear any active text-selection first —
// otherwise lv_textarea_add_text REPLACES the selection (that was the "quick
// reply wipes what I typed" report). No auto-send: lets the operator tweak first.
taClearSelection(p->composer_ta);
lv_textarea_add_text(p->composer_ta, buf);
// Move keyboard focus to the textarea so a quick send tap works next.
lv_obj_add_state(p->composer_ta, LV_STATE_FOCUSED);
@@ -11672,10 +11683,13 @@ static bool fmCopyFile(fs::FS* sf, const char* sp, fs::FS* df, const char* dp) {
if (!in) return false;
File out = df->open(dp, "w");
if (!out) { in.close(); return false; }
static uint8_t buf[2048];
static uint8_t* buf = nullptr; // copy scratch — lazy PSRAM (internal fallback): ~2 KB off internal DRAM
if (!buf) { buf = (uint8_t*)heap_caps_malloc(2048, MALLOC_CAP_SPIRAM);
if (!buf) buf = (uint8_t*)heap_caps_malloc(2048, MALLOC_CAP_8BIT); }
if (!buf) { out.close(); in.close(); return false; }
bool ok = true;
int n;
while ((n = in.read(buf, sizeof buf)) > 0) {
while ((n = in.read(buf, 2048)) > 0) {
if (out.write(buf, (size_t)n) != (size_t)n) { ok = false; break; }
feedLoopWDT();
}
@@ -14540,10 +14554,16 @@ static int tjpgOutCb(JDEC* jd, void* bitmap, JRECT* r) {
static uint8_t* decodeJpegScaledToRgb565(const uint8_t* jpeg, size_t jpeg_len,
int* out_w, int* out_h, int max_dim) {
s_jpgScaleErr[0] = '\0';
static uint8_t pool[4096]; // tjpgd work area (JD_FASTDECODE=0 ~3.1 KB); matches LVGL's TJPGD_WORKBUFF_SIZE
// tjpgd work area (~3.1 KB used). Lazy PSRAM (internal fallback), cached — keeps
// this ~4 KB out of scarce internal DRAM. JD_FASTDECODE=0; matches LVGL's TJPGD_WORKBUFF_SIZE.
static const size_t kJpgWork = 4096;
static uint8_t* pool = nullptr;
if (!pool) { pool = (uint8_t*)heap_caps_malloc(kJpgWork, MALLOC_CAP_SPIRAM);
if (!pool) pool = (uint8_t*)heap_caps_malloc(kJpgWork, MALLOC_CAP_8BIT); }
if (!pool) { snprintf(s_jpgScaleErr, sizeof s_jpgScaleErr, "Out of memory."); return nullptr; }
TjpgIo io; io.data = jpeg; io.len = jpeg_len; io.pos = 0; io.out = nullptr; io.ow = 0; io.oh = 0;
JDEC jd;
JRESULT rp = jd_prepare(&jd, tjpgInCb, pool, sizeof pool, &io);
JRESULT rp = jd_prepare(&jd, tjpgInCb, pool, kJpgWork, &io);
if (rp != JDR_OK) {
if (rp == JDR_FMT3 || rp == JDR_FMT2) // tjpgd can't decode progressive / unusual-sampling JPEGs
snprintf(s_jpgScaleErr, sizeof s_jpgScaleErr, "Progressive JPEG.\nRe-save as a standard JPEG.");
@@ -14580,6 +14600,8 @@ static double s_map_center_lat = 0.0;
static double s_map_center_lon = 0.0;
static uint8_t s_map_zoom = k_map_zoom_default;
static bool s_map_view_inited = false; // first map open did the recenter+zoom-snap; after that, remember the user's view (issue #5)
static bool s_map_follow = false; // auto-follow: recenter on self whenever the GPS coords change
static lv_obj_t* s_map_follow_btn = nullptr;
static bool s_map_has_pack = false; // toggles placeholder visibility
// lat/lon → world pixel at given zoom (Web Mercator).
@@ -15597,6 +15619,25 @@ static bool s_map_show_links = true;
static lv_obj_t* s_map_link_objs[k_map_markers_max] = {};
static lv_point_t s_map_link_pts[k_map_markers_max][2];
// Per-element visibility of the map's on-screen text/markers (persisted; all
// default shown). Coords = bottom-left read-out, TileXYZ = the zoom + tile path
// line, Contacts = the contact markers.
static bool s_map_show_coords = true;
static bool s_map_show_tilexyz = true;
static bool s_map_show_contacts = true;
// Apply the coords/tile-line visibility flags to the two corner labels. Safe to
// call before the labels exist (null-guarded); invoked at map build + on toggle.
static void applyMapTextVis() {
auto vis = [](lv_obj_t* o, bool show) {
if (!o) return;
if (show) lv_obj_clear_flag(o, LV_OBJ_FLAG_HIDDEN);
else lv_obj_add_flag(o, LV_OBJ_FLAG_HIDDEN);
};
vis(s_map_info_lbl, s_map_show_coords);
vis(s_map_zoom_lbl, s_map_show_tilexyz);
}
// ----- Route replay overlay -----
// "Replay" in a message's Info popup resolves that message's repeater hops to
// their advertised positions and animates them onto the map as a path, one node
@@ -15678,7 +15719,7 @@ static void renderMapMarkers() {
// ---- Dotted links from self to each on-screen contact (drawn first so the
// markers sit on top). Toggled by the on-map links button.
if (s_map_show_links && self_has) {
if (s_map_show_links && s_map_show_contacts && self_has) { // links only make sense when contacts are shown
auto clampc = [](int v) -> lv_coord_t {
if (v < -2000) v = -2000;
if (v > 2000) v = 2000;
@@ -15731,7 +15772,8 @@ static void renderMapMarkers() {
// ---- Contact markers — colored circles sized for taps (14×14 with a
// 2-px black border so they read on any tile background).
int slot = 1; // slot 0 reserved for self
for (uint32_t i = 0; i < the_mesh.getNumContacts() && slot < k_map_markers_max; ++i) {
// Contact markers — skipped entirely when "Contacts on map" is off (self stays).
for (uint32_t i = 0; s_map_show_contacts && i < the_mesh.getNumContacts() && slot < k_map_markers_max; ++i) {
ContactInfo c;
if (!the_mesh.getContactByIdx(i, c)) continue;
if (c.gps_lat == 0 && c.gps_lon == 0) continue;
@@ -16131,6 +16173,33 @@ static void mapOptNightCb(lv_event_t* e) {
applyMapChrome(true); // re-tint the status bar + tab bar for the new tile brightness
}
// Per-element map text/marker visibility toggles. Coords + tile line just hide
// their corner label; contacts re-renders the markers.
static void mapOptCoordsCb(lv_event_t* e) {
if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return;
s_map_show_coords = lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED);
#if defined(ESP32)
touchPrefsSetMapShowCoords(s_map_show_coords);
#endif
applyMapTextVis();
}
static void mapOptTileXYZCb(lv_event_t* e) {
if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return;
s_map_show_tilexyz = lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED);
#if defined(ESP32)
touchPrefsSetMapShowTileXYZ(s_map_show_tilexyz);
#endif
applyMapTextVis();
}
static void mapOptContactsCb(lv_event_t* e) {
if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return;
s_map_show_contacts = lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED);
#if defined(ESP32)
touchPrefsSetMapShowContacts(s_map_show_contacts);
#endif
renderMapMarkers(); // add/remove the contact markers (+ their links)
}
#if defined(HAS_TDECK_GT911)
// Map tile source toggle (in the map options popup): ON = read tiles off the microSD
// (fully offline, no server fetch); OFF = tile server + on-device cache.
@@ -16291,34 +16360,9 @@ static void openMapOptions() {
lv_obj_set_pos(title, 0, 0);
int y = 26;
// Row: Lines toggle.
lv_obj_t* ll = lv_label_create(card);
lv_label_set_text(ll, TR("Show link lines"));
lv_obj_set_style_text_color(ll, lv_color_hex(COLOR_TEXT), LV_PART_MAIN);
lv_obj_set_style_text_font(ll, &g_font_14, LV_PART_MAIN);
lv_obj_set_pos(ll, 2, y + 4);
lv_obj_t* sw_lines = lv_switch_create(card);
lv_obj_align(sw_lines, LV_ALIGN_TOP_RIGHT, 0, y);
if (s_map_show_links) lv_obj_add_state(sw_lines, LV_STATE_CHECKED);
lv_obj_add_event_cb(sw_lines, mapOptLinesCb, LV_EVENT_VALUE_CHANGED, nullptr);
y += 40;
// Row: Night mode (invert tile colours).
{
lv_obj_t* nl = lv_label_create(card);
lv_label_set_text(nl, TR("Night mode (invert)"));
lv_obj_set_style_text_color(nl, lv_color_hex(COLOR_TEXT), LV_PART_MAIN);
lv_obj_set_style_text_font(nl, &g_font_14, LV_PART_MAIN);
lv_obj_set_pos(nl, 2, y + 4);
lv_obj_t* sw_night = lv_switch_create(card);
lv_obj_align(sw_night, LV_ALIGN_TOP_RIGHT, 0, y);
if (s_map_night) lv_obj_add_state(sw_night, LV_STATE_CHECKED);
lv_obj_add_event_cb(sw_night, mapOptNightCb, LV_EVENT_VALUE_CHANGED, nullptr);
y += 40;
}
#if defined(HAS_TDECK_GT911)
// Row: tile source — microSD (offline) vs the tile server.
// Row: tile source — microSD (offline) vs the tile server. The important one,
// so it sits at the very top.
{
lv_obj_t* tl = lv_label_create(card);
lv_label_set_text(tl, TR("Tiles from SD card"));
@@ -16333,6 +16377,59 @@ static void openMapOptions() {
}
#endif
// Row: Night mode (invert tile colours) — a tile/display setting, kept up top.
{
lv_obj_t* nl = lv_label_create(card);
lv_label_set_text(nl, TR("Night mode (invert)"));
lv_obj_set_style_text_color(nl, lv_color_hex(COLOR_TEXT), LV_PART_MAIN);
lv_obj_set_style_text_font(nl, &g_font_14, LV_PART_MAIN);
lv_obj_set_pos(nl, 2, y + 4);
lv_obj_t* sw_night = lv_switch_create(card);
lv_obj_align(sw_night, LV_ALIGN_TOP_RIGHT, 0, y);
if (s_map_night) lv_obj_add_state(sw_night, LV_STATE_CHECKED);
lv_obj_add_event_cb(sw_night, mapOptNightCb, LV_EVENT_VALUE_CHANGED, nullptr);
y += 40;
}
// ---- Section separator + label: the on-map visibility toggles below are a
// distinct, lower-stakes group from the tile settings above.
{
lv_obj_t* sep = lv_obj_create(card);
lv_obj_remove_style_all(sep);
lv_obj_set_size(sep, cardw - 24, 1);
lv_obj_set_pos(sep, 0, y + 4);
lv_obj_set_style_bg_color(sep, lv_color_hex(0x303438), LV_PART_MAIN);
lv_obj_set_style_bg_opa(sep, LV_OPA_COVER, LV_PART_MAIN);
lv_obj_t* sl = lv_label_create(card);
lv_label_set_text(sl, TR("Show on map"));
lv_obj_set_style_text_color(sl, lv_color_hex(0x8A929B), LV_PART_MAIN);
lv_obj_set_style_text_font(sl, &g_font_12, LV_PART_MAIN);
lv_obj_set_pos(sl, 2, y + 10);
y += 34;
}
// Rows: per-element on-map visibility (link lines / coords / tile z-x-y / contacts).
{
struct { const char* label; bool state; lv_event_cb_t cb; } rows[] = {
{ "Show link lines", s_map_show_links, mapOptLinesCb },
{ "Show coordinates", s_map_show_coords, mapOptCoordsCb },
{ "Show tile z/x/y", s_map_show_tilexyz, mapOptTileXYZCb },
{ "Show contacts", s_map_show_contacts, mapOptContactsCb },
};
for (auto& r : rows) {
lv_obj_t* l = lv_label_create(card);
lv_label_set_text(l, TR(r.label));
lv_obj_set_style_text_color(l, lv_color_hex(COLOR_TEXT), LV_PART_MAIN);
lv_obj_set_style_text_font(l, &g_font_14, LV_PART_MAIN);
lv_obj_set_pos(l, 2, y + 4);
lv_obj_t* sw = lv_switch_create(card);
lv_obj_align(sw, LV_ALIGN_TOP_RIGHT, 0, y);
if (r.state) lv_obj_add_state(sw, LV_STATE_CHECKED);
lv_obj_add_event_cb(sw, r.cb, LV_EVENT_VALUE_CHANGED, nullptr);
y += 40;
}
}
auto mk_row_btn = [&](const char* txt, lv_event_cb_t cb) {
lv_obj_t* b = lv_btn_create(card);
lv_obj_set_size(b, cardw - 24, 38);
@@ -16353,7 +16450,14 @@ static void openMapOptions() {
// above the title/switch/rows and reliably tappable. Same dismiss path as the
// backdrop tap. The 32×32 hit area only grazes the link-lines switch (y=30)
// by ~2px, which is imperceptible.
addCloseXBadge(card, mapOptionsDismissCb);
// This card scrolls (many rows), and the close badge floats fixed at the
// top-right. Give it an opaque chip + a faint ring so toggles scrolling under
// it are cleanly hidden instead of bleeding through the bare glyph.
lv_obj_t* xb = addCloseXBadge(card, mapOptionsDismissCb);
lv_obj_set_style_bg_color(xb, lv_color_hex(COLOR_PANEL), LV_PART_MAIN);
lv_obj_set_style_bg_opa(xb, LV_OPA_COVER, LV_PART_MAIN);
lv_obj_set_style_border_color(xb, lv_color_hex(0x303438), LV_PART_MAIN);
lv_obj_set_style_border_width(xb, 1, LV_PART_MAIN);
}
static void mapOpenOptionsCb(lv_event_t* e) {
@@ -16979,6 +17083,45 @@ static void mapRecenterCb(lv_event_t* e) {
refreshMapInfoLabel();
}
// Auto-follow: while enabled, recenter the map on self whenever the GPS position
// moves meaningfully (or the view was panned away). Called from the map tick.
static void mapAutoFollowTick() {
if (!s_map_follow || !g_lv.task) return;
const double lat = g_lv.task->getNodeLat();
const double lon = g_lv.task->getNodeLon();
if (lat == 0.0 && lon == 0.0) return; // no fix yet
if (fabs(lat - s_map_center_lat) < 5e-5 &&
fabs(lon - s_map_center_lon) < 5e-5) return; // ~5 m: ignore GPS jitter
s_map_center_lat = lat;
s_map_center_lon = lon;
renderMapTiles();
renderMapMarkers();
refreshMapInfoLabel();
}
// Auto-follow toggle button (bottom of the right-edge column). Highlights when on
// and recenters immediately so the map jumps to your position the moment you enable it.
static void mapFollowToggleCb(lv_event_t* e) {
if (lv_event_get_code(e) != LV_EVENT_CLICKED) return;
s_map_follow = !s_map_follow;
if (s_map_follow_btn) {
if (s_map_follow) lv_obj_add_state(s_map_follow_btn, LV_STATE_CHECKED);
else lv_obj_clear_state(s_map_follow_btn, LV_STATE_CHECKED);
}
if (g_lv.task) g_lv.task->showAlert(s_map_follow ? TR("Auto-refresh on") : TR("Auto-refresh off"), 900);
if (s_map_follow && g_lv.task) {
const double lat = g_lv.task->getNodeLat();
const double lon = g_lv.task->getNodeLon();
if (lat != 0.0 || lon != 0.0) {
s_map_center_lat = lat;
s_map_center_lon = lon;
renderMapTiles();
renderMapMarkers();
refreshMapInfoLabel();
}
}
}
// Recenters on self GPS and rebuilds the tile grid. Called from tabChangedCb
// every time the user switches TO the Map tab.
static void onMapTabActivated() {
@@ -17169,6 +17312,7 @@ static void makeMapTab(lv_obj_t* tab) {
style_corner(s_map_zoom_lbl);
lv_label_set_text(s_map_zoom_lbl, TR(""));
lv_obj_align(s_map_zoom_lbl, LV_ALIGN_TOP_LEFT, 2, STATUSBAR_H + 2);
applyMapTextVis(); // honour the coords / tile-line visibility toggles
(void)kMapInfoH;
// Touch catcher: the tiles live on the background canvas (behind the tabview)
@@ -17211,9 +17355,15 @@ static void makeMapTab(lv_obj_t* tab) {
// Options gear sits at the TOP of the right-edge column; zoom/recenter below,
// then a "contacts on map" picker (list of GPS-bearing contacts → recenter).
make_overlay_btn(LV_SYMBOL_SETTINGS, 4, mapOpenOptionsCb);
make_overlay_btn(TOUCH_SYM_ZOOM, 4 + 32, mapZoomToggleCb); // magnifier — toggles the zoom slider (was +/-)
make_overlay_btn("+/-", 4 + 32, mapZoomToggleCb); // +/- — toggles the zoom slider
make_overlay_btn(LV_SYMBOL_GPS, 4 + 32*2, mapRecenterCb);
make_overlay_btn(LV_SYMBOL_LIST, 4 + 32*3, mapOpenContactsCb);
// Auto-follow toggle: recenters on self whenever the GPS coords change. Lit
// (accent) while active via the CHECKED state.
s_map_follow_btn = make_overlay_btn(LV_SYMBOL_REFRESH, 4 + 32*4, mapFollowToggleCb);
lv_obj_set_style_bg_color(s_map_follow_btn, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN | LV_STATE_CHECKED);
lv_obj_set_style_bg_opa(s_map_follow_btn, LV_OPA_COVER, LV_PART_MAIN | LV_STATE_CHECKED);
if (s_map_follow) lv_obj_add_state(s_map_follow_btn, LV_STATE_CHECKED);
// Zoom slider — a full-width overlay along the bottom of the map, hidden until
// the zoom button is tapped. Reuses the control-centre brightness-slider look.
@@ -17937,6 +18087,11 @@ static char s_msg_menu_sender[UITask::MAX_SENDER_NAME + 1] = {0};
// Buffer the body text so the Copy button doesn't reach back into the ring
// (the bubble might get redrawn between menu-open and copy-tap).
static char s_msg_menu_text[UITask::MAX_MSG_TEXT + 1] = {0};
// Pre-composed one-tap "ack" line (mention + link quality + route of the message),
// built when the menu opens so the Ack button never reaches back into the (possibly
// rotated) ring. s_msg_menu_channel picks which composer to drop it into.
static char s_msg_menu_ack[UITask::MAX_MSG_TEXT + 1] = {0};
static bool s_msg_menu_channel = false;
static void closeMsgActionMenu() {
if (s_msg_menu_root) {
@@ -18051,6 +18206,7 @@ static void msgMenuMentionCb(lv_event_t* e) {
if (!p.composer_ta || !s_msg_menu_sender[0]) return;
char ins[UITask::MAX_SENDER_NAME + 8];
snprintf(ins, sizeof ins, "@[%s] ", s_msg_menu_sender);
taClearSelection(p.composer_ta);
lv_textarea_add_text(p.composer_ta, ins);
lv_textarea_set_cursor_pos(p.composer_ta, LV_TEXTAREA_CURSOR_LAST);
showKb(&p); // focus composer so typing continues there
@@ -18076,6 +18232,57 @@ static void msgMenuBlockCb(lv_event_t* e) {
g_lv.task->showAlert(ok ? TR("Sender blocked") : TR("Block list full"), 1500);
}
// Compose a one-tap "ack" for an incoming message: @mentions the sender (on a
// channel) and reports the link quality + route of THAT message, so you can
// confirm to them their (test) message arrived and how strong it came in. Built
// once when the menu opens; dropped into the composer for review, never auto-sent.
static void buildAckText(UITask::UIMessage& m, char* out, size_t cap) {
if (!out || cap == 0) return;
out[0] = '\0';
int n = 0;
if (m.channel && m.sender[0])
n += snprintf(out + n, cap - (size_t)n, "@[%s], ack", m.sender);
else
n += snprintf(out + n, cap - (size_t)n, "Ack");
if (m.meta_flags & UITask::MSG_META_HAS_RX) {
n += snprintf(out + n, cap - (size_t)n, ": SNR %.1f dB, RSSI %d dBm",
(double)m.snr_q4 / 4.0, (int)m.rssi);
if (m.meta_flags & UITask::MSG_META_IS_FLOOD) {
const uint8_t hops = (uint8_t)(m.path_len & 0x3F);
n += snprintf(out + n, cap - (size_t)n, ", %u hop%s", (unsigned)hops, hops == 1 ? "" : "s");
// Append the hop codes in hex ("via 3A, F1, …") — the raw path-hash bytes
// each repeater is keyed by, matching what the mesh shows on the wire.
const uint8_t hsz = (uint8_t)((m.path_len >> 6) + 1);
int off = 0, listed = 0;
for (uint8_t h = 0; h < hops && off + (int)hsz <= m.in_path_n && listed < 8; ++h, off += hsz) {
if (n >= (int)cap - 24) break; // leave room; never overflow out[]
n += snprintf(out + n, cap - (size_t)n, "%s", listed == 0 ? " via " : ", ");
for (uint8_t b = 0; b < hsz && b < 4 && n < (int)cap - 3; ++b)
n += snprintf(out + n, cap - (size_t)n, "%02X", m.in_path[off + b]);
++listed;
}
} else {
n += snprintf(out + n, cap - (size_t)n, ", direct");
}
}
if (n < 0 || n >= (int)cap) out[cap - 1] = '\0';
}
// "Ack" button: drop the pre-composed ack line into the active chat's composer
// (channel or DM) at the cursor, for the operator to review and send.
static void msgMenuAckCb(lv_event_t* e) {
if (lv_event_get_code(e) != LV_EVENT_CLICKED) return;
lv_indev_t* a = lv_indev_get_act();
if (a) lv_indev_wait_release(a);
closeMsgActionMenu();
LvChatPanel& p = s_msg_menu_channel ? g_lv.ch : g_lv.dm;
if (!p.composer_ta || !s_msg_menu_ack[0]) return;
taClearSelection(p.composer_ta); // never replace a stray selection
lv_textarea_add_text(p.composer_ta, s_msg_menu_ack);
lv_textarea_set_cursor_pos(p.composer_ta, LV_TEXTAREA_CURSOR_LAST);
showKb(&p); // focus composer so you can tweak/send
}
static void openMessageActionMenu(int msg_idx) {
if (!g_lv.task) return;
UITask::UIMessage m;
@@ -18088,7 +18295,12 @@ static void openMessageActionMenu(int msg_idx) {
s_msg_menu_text[sizeof(s_msg_menu_text) - 1] = '\0';
strncpy(s_msg_menu_sender, m.sender, sizeof(s_msg_menu_sender) - 1);
s_msg_menu_sender[sizeof(s_msg_menu_sender) - 1] = '\0';
// Offer "Mention" only for an incoming channel message (mention its sender).
// Pre-compose the ack line now (RX metadata is on `m` here; the ring may rotate
// before the button is tapped). s_msg_menu_channel picks the composer to fill.
s_msg_menu_channel = m.channel;
buildAckText(m, s_msg_menu_ack, sizeof s_msg_menu_ack);
// "Ack" (quick link-quality confirmation) + "Mention" are for incoming messages.
const bool can_ack = !m.outgoing && m.sender[0]; // ack its sender (channel or DM)
const bool can_mention = m.channel && !m.outgoing && m.sender[0];
const bool can_block = !m.outgoing; // block the sender — never for our own messages
@@ -18106,14 +18318,19 @@ static void openMessageActionMenu(int msg_idx) {
lv_obj_add_event_cb(s_msg_menu_root, msgMenuBackdropCb, LV_EVENT_CLICKED, nullptr);
const int card_w = 180;
const int btn_h = 38;
const int gap = 6;
const int btn_h = 30; // was 38 — the menu now carries up to 5 rows (Ack/Mention/Copy/Info/Block); shrink so they fit a 240-px screen
const int gap = 4;
const int pad = 10;
// 28-px header row at the top reserves space for the close-X badge so it
// doesn't sit on top of the Copy / Info buttons.
const int hdr_h = 28;
const int nbtn = (can_mention ? 1 : 0) + 2 /*Copy+Info*/ + (can_block ? 1 : 0);
const int card_h = hdr_h + nbtn * btn_h + (nbtn - 1) * gap + 2 * pad;
// Header row reserves space for the close-X badge so it doesn't sit on a button.
const int hdr_h = 24;
const int nbtn = (can_ack ? 1 : 0) + (can_mention ? 1 : 0) + 2 /*Copy+Info*/ + (can_block ? 1 : 0);
int card_h = hdr_h + nbtn * btn_h + (nbtn - 1) * gap + 2 * pad;
// Never exceed the visible area under the status bar; scroll if it ever would
// (e.g. an even taller menu, or a shorter display). The close-X floats, so it
// stays pinned while the buttons scroll.
const int avail_h = (int)(sh - STATUSBAR_H) - 8;
const bool scroll_menu = (card_h > avail_h);
if (scroll_menu) card_h = avail_h;
lv_obj_t* card = lv_obj_create(s_msg_menu_root);
lv_obj_remove_style_all(card);
@@ -18125,7 +18342,12 @@ static void openMessageActionMenu(int msg_idx) {
lv_obj_set_style_border_color(card, lv_color_hex(0x18191A), LV_PART_MAIN);
lv_obj_set_style_border_width(card, 1, LV_PART_MAIN);
lv_obj_set_style_pad_all(card, pad, LV_PART_MAIN);
lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE);
if (scroll_menu) {
lv_obj_set_scroll_dir(card, LV_DIR_VER);
lv_obj_set_scrollbar_mode(card, LV_SCROLLBAR_MODE_AUTO);
} else {
lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE);
}
addCloseXBadge(card, msgMenuBackdropCb);
auto mk_btn = [&](const char* text, lv_event_cb_t cb, int y_off) {
@@ -18141,6 +18363,10 @@ static void openMessageActionMenu(int msg_idx) {
lv_obj_center(lbl);
};
int by = hdr_h;
if (can_ack) {
mk_btn(LV_SYMBOL_OK " Ack", msgMenuAckCb, by);
by += btn_h + gap;
}
if (can_mention) {
char ml[UITask::MAX_SENDER_NAME + 16];
snprintf(ml, sizeof ml, "@%.16s", m.sender); // "Mention" is implied by the @
@@ -19361,7 +19587,7 @@ static void pushDiagLine(const char* message) {
}
static void pushLogLine(char ring[][RXLOG_COLS], int& line_counter, const char* message) {
if (!message || !message[0]) return;
if (!ring || !message || !message[0]) return;
strncpy(ring[line_counter % RXLOG_LINES], message, RXLOG_COLS - 1);
ring[line_counter % RXLOG_LINES][RXLOG_COLS - 1] = '\0';
++line_counter;
@@ -19370,7 +19596,7 @@ static void pushLogLine(char ring[][RXLOG_COLS], int& line_counter, const char*
static void collectLogText(const char ring[][RXLOG_COLS], int line_counter, char* out, size_t out_cap) {
if (!out || out_cap == 0) return;
out[0] = '\0';
if (line_counter <= 0) return;
if (!ring || line_counter <= 0) return;
int p = 0;
int first = (line_counter > RXLOG_LINES) ? (line_counter - RXLOG_LINES) : 0;
for (int i = first; i < line_counter && p < static_cast<int>(out_cap) - 2; ++i) {
@@ -19402,11 +19628,15 @@ static void setLogModeButtonStyles() {
static void refreshLogModalView() {
if (!g_set_modal.log_view_ta || g_set_modal.kind != SettingsModalKind::Log) return;
static char text[(RXLOG_LINES * RXLOG_COLS) + 64];
static const size_t kLogTextSz = (RXLOG_LINES * RXLOG_COLS) + 64;
static char* text = nullptr; // log-view scratch — lazy PSRAM (internal fallback): ~2.3 KB off internal DRAM
if (!text) { text = (char*)heap_caps_malloc(kLogTextSz, MALLOC_CAP_SPIRAM);
if (!text) text = (char*)heap_caps_malloc(kLogTextSz, MALLOC_CAP_8BIT); }
if (!text) return;
if (g_set_modal.log_mode == 0) {
collectLogText(s_rxlog_ring, s_rxlog_line, text, sizeof(text));
collectLogText(s_rxlog_ring, s_rxlog_line, text, kLogTextSz);
} else {
collectLogText(s_rawlog_ring, s_rawlog_line, text, sizeof(text));
collectLogText(s_rawlog_ring, s_rawlog_line, text, kLogTextSz);
}
lv_textarea_set_text(g_set_modal.log_view_ta, text[0] ? text : "No entries yet.");
lv_textarea_set_cursor_pos(g_set_modal.log_view_ta, LV_TEXTAREA_CURSOR_LAST);
@@ -21709,7 +21939,10 @@ static void openMentionsScreen() {
// Scan all channel messages that @mention me, newest first.
struct M { uint32_t ts; int idx; };
static M found[UITask::MAX_UI_MESSAGES];
static M* found = nullptr; // mentions scratch — lazy PSRAM (internal fallback): ~4 KB off internal DRAM
if (!found) { found = (M*)heap_caps_malloc(sizeof(M) * UITask::MAX_UI_MESSAGES, MALLOC_CAP_SPIRAM);
if (!found) found = (M*)heap_caps_malloc(sizeof(M) * UITask::MAX_UI_MESSAGES, MALLOC_CAP_8BIT); }
if (!found) return;
int nf = 0;
for (int i = 0; i < UITask::MAX_UI_MESSAGES; i++) {
UITask::UIMessage m;
@@ -22593,7 +22826,7 @@ static void refreshStatusLabels() {
// Map tab: refresh the bottom info strip (self GPS + on-map count) only
// when the tab is actually visible. Cheap, but no point doing it on every
// tick if the user is somewhere else.
if (active_tab == MAP_TAB_INDEX) refreshMapInfoLabel();
if (active_tab == MAP_TAB_INDEX) { mapAutoFollowTick(); refreshMapInfoLabel(); }
#if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION)
// Wi-Fi fetcher arrived a new tile — re-render the map so the freshly
// downloaded tile appears. Only re-render if we're actually on the
@@ -25930,8 +26163,18 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
}
s_tiles_from_sd = touchPrefsGetTilesFromSd(); // map tile source: server (default) vs microSD
s_map_night = touchPrefsGetMapNight(); // map night mode (render-time tile invert)
s_map_show_coords = touchPrefsGetMapShowCoords(); // per-element map text/marker visibility
s_map_show_tilexyz = touchPrefsGetMapShowTileXYZ();
s_map_show_contacts = touchPrefsGetMapShowContacts();
{ const uint8_t pz = touchPrefsGetMapZoom(); // restore the last user-set map zoom
if (pz >= k_map_zoom_min && pz <= k_map_zoom_max) s_map_zoom = pz; }
// Monitor RX/RAW log rings live in PSRAM (2×28×80 = 4.4 KB) so they don't sit
// in scarce internal DRAM that Wi-Fi DMA needs. Zero-init; the push/collect
// helpers null-guard, so a (vanishingly unlikely) OOM just drops Monitor logs.
if (!s_rxlog_ring) s_rxlog_ring = (char(*)[RXLOG_COLS])heap_caps_calloc(RXLOG_LINES, RXLOG_COLS, MALLOC_CAP_SPIRAM);
if (!s_rxlog_ring) s_rxlog_ring = (char(*)[RXLOG_COLS])heap_caps_calloc(RXLOG_LINES, RXLOG_COLS, MALLOC_CAP_8BIT);
if (!s_rawlog_ring) s_rawlog_ring = (char(*)[RXLOG_COLS])heap_caps_calloc(RXLOG_LINES, RXLOG_COLS, MALLOC_CAP_SPIRAM);
if (!s_rawlog_ring) s_rawlog_ring = (char(*)[RXLOG_COLS])heap_caps_calloc(RXLOG_LINES, RXLOG_COLS, MALLOC_CAP_8BIT);
#if defined(ESP32)
// Bump the CPU above the 80 MHz base-config default. The base was
// chosen for power on a headless companion build; on a touch device