From 16e883187ce6a7cfc7de1e84a1a881315c2e176c Mon Sep 17 00:00:00 2001 From: Christopher Van Hoose Date: Thu, 20 Aug 2026 13:50:48 -0400 Subject: [PATCH] M9 + all boards: persist the companion app-sync ring across power cycles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User report: the M9 "does not remember chats or sync them to the app" while the V4-R8 does. The companion sync ring (MyMesh::history_ring + per-client cursors) was RAM-only on every board — invisible on a USB-powered V4 that rarely loses power, fatal on the M9 whose only off is the hard-cut slider. Shared fix: the ring is now an append log (/synchist) plus client cursors (/synccur) on DataStore::getHotDataFS(), restored in MyMesh::begin() and flushed by persistSyncHistoryNow() on the paths that lose RAM (deep sleep, power-off, explicit save). Record + bench-test recipe: M9_PORT.md Deferred #13. Co-Authored-By: Claude Fable 5 --- src/DataStore.cpp | 23 +++ src/DataStore.h | 12 ++ src/MyMesh.cpp | 247 ++++++++++++++++++++++++++++++- src/MyMesh.h | 34 +++++ src/ui-touch/UITask.cpp | 4 + variants/thinknode_m9/M9_PORT.md | 28 ++++ 6 files changed, 346 insertions(+), 2 deletions(-) diff --git a/src/DataStore.cpp b/src/DataStore.cpp index 9f5bcc9a..1c51ed3c 100644 --- a/src/DataStore.cpp +++ b/src/DataStore.cpp @@ -216,6 +216,29 @@ bool DataStore::removeFile(FILESYSTEM* fs, const char* filename) { return fs->remove(filename); } +#if defined(ESP32) +File DataStore::openAppend(FILESYSTEM* fs, const char* filename) { + return fs->open(_rp(filename), "a", true); +} + +bool DataStore::fileExists(FILESYSTEM* fs, const char* filename) { + return fs->exists(_rp(filename)); +} + +bool DataStore::removeRooted(FILESYSTEM* fs, const char* filename) { + return fs->remove(_rp(filename)); +} + +bool DataStore::renameFile(FILESYSTEM* fs, const char* from, const char* to) { + // _rp() hands back ONE shared scratch buffer, so the two paths must be built + // into independent buffers (same trap savePrefs documents). + char from_path[80], to_path[80]; + snprintf(from_path, sizeof(from_path), "%s%s", _root, from); + snprintf(to_path, sizeof(to_path), "%s%s", _root, to); + return fs->rename(from_path, to_path); +} +#endif + bool DataStore::formatFileSystem() { #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) if (_fsExtra == nullptr) { diff --git a/src/DataStore.h b/src/DataStore.h index d52022ae..b999bb84 100644 --- a/src/DataStore.h +++ b/src/DataStore.h @@ -98,6 +98,18 @@ public: // _root; setSecondaryFS sets _fsExtra) — FAT has no such GC. Lets callers coalesce // the advert-driven contacts save on card-less devices without changing SD boards. bool contactsOnInternalFlash() const { return _root[0] == '\0' && _fsExtra == nullptr; } + + // Root-aware file helpers for the app-level stores that live beside the core + // data files (MyMesh's companion sync-history log, see MyMesh::loadSyncHistory). + // getHotDataFS() is whichever filesystem already takes the frequently-rewritten + // contacts/channels — the SD card when one is routed, else the primary FS — so + // an often-appended log never lands on GC-prone internal flash while a card + // exists. NB removeFile(fs, name) above is NOT root-aware (legacy); these are. + FILESYSTEM* getHotDataFS() const { return _getContactsChannelsFS(); } + File openAppend(FILESYSTEM* fs, const char* filename); // create if missing + bool fileExists(FILESYSTEM* fs, const char* filename); + bool removeRooted(FILESYSTEM* fs, const char* filename); + bool renameFile(FILESYSTEM* fs, const char* from, const char* to); #endif private: diff --git a/src/MyMesh.cpp b/src/MyMesh.cpp index de142d0a..25391828 100644 --- a/src/MyMesh.cpp +++ b/src/MyMesh.cpp @@ -140,6 +140,20 @@ static int s_companion_ota_pinned_reply_target = -1; #define DIRECT_SEND_PERHOP_FACTOR 6.0f #define DIRECT_SEND_PERHOP_EXTRA_MILLIS 250 #define LAZY_CONTACTS_WRITE_DELAY 5000 +// Companion sync-history persistence pacing (see serviceSyncHistory). The log append +// is a ~180 B write per message, so it lands quickly — the coalesce is the hard-cut +// loss window. Cursor rewrites (tmp + swap of a ~300 B file) happen on every synced +// or live-pushed message while an app is connected, so they coalesce longer; a stale +// cursor only costs the app a few re-delivered (already-seen) messages, never loss. +#define SYNC_HIST_LOG_COALESCE_MS 3000 +#define SYNC_HIST_LOG_MAX_LATENCY_MS 10000 +#define SYNC_HIST_CUR_COALESCE_MS 5000 +#define SYNC_HIST_CUR_MAX_LATENCY_MS 30000 +#define SYNC_HIST_RETRY_MS 30000 // storage unavailable / write failed +#define SYNC_HIST_LOG_FILE "/synchist" +#define SYNC_HIST_LOG_TMP "/synchist.tmp" +#define SYNC_HIST_CUR_FILE "/synccur" +#define SYNC_HIST_CUR_TMP "/synccur.tmp" // On card-less (internal-flash) devices, coalesce advert-refresh contacts saves to // at most once per this window — a full rewrite can trigger a multi-second SPIFFS GC // that freezes the loop, and re-adverts (last-heard/path refreshes) otherwise churn @@ -1162,9 +1176,230 @@ uint32_t MyMesh::addToHistoryRing(const uint8_t frame[], int len) { if (history_count < HISTORY_RING_SIZE) { history_count++; } + // Every add counts as unflushed (appendSyncLog walks the newest N ring slots and + // skips non-message codes itself); only message frames arm a write. + if (_synchist_unflushed < HISTORY_RING_SIZE) _synchist_unflushed++; + if (isSyncMessageCode(frame[0])) markSyncLogDirty(); return assigned; } +// --------------------------------------------------------------------------- +// Companion sync-history persistence (ring + per-client cursors). File formats: +// /synchist : "WSH" 0x01, then records { u32 seq, u8 len, u8 buf[len] } oldest first. +// /synccur : "WSC" 0x01, u32 next_seq, u8 n, then n x { char id[MAX_CLIENT_ID_LEN+1], u32 last_seq }. +// Little-endian, packed by hand (no struct dumps) so the layout is explicit. +// --------------------------------------------------------------------------- +static const uint8_t kSyncHistMagic[4] = { 'W', 'S', 'H', 1 }; +static const uint8_t kSyncCurMagic[4] = { 'W', 'S', 'C', 1 }; + +bool MyMesh::isSyncMessageCode(uint8_t code) { + return code == RESP_CODE_CONTACT_MSG_RECV || code == RESP_CODE_CONTACT_MSG_RECV_V3 || + code == RESP_CODE_CHANNEL_MSG_RECV || code == RESP_CODE_CHANNEL_MSG_RECV_V3; +} + +void MyMesh::markSyncLogDirty() { + unsigned long now = millis(); + if (!_synchist_log_dirty) _synchist_log_first = now; + _synchist_log_dirty = true; + _synchist_log_due = now + SYNC_HIST_LOG_COALESCE_MS; +} + +void MyMesh::markSyncCursorsDirty() { + unsigned long now = millis(); + if (!_synchist_cur_dirty) _synchist_cur_first = now; + _synchist_cur_dirty = true; + _synchist_cur_due = now + SYNC_HIST_CUR_COALESCE_MS; +} + +static size_t syncHistWriteRecord(File& f, const uint8_t* frame, uint8_t len, uint32_t seq) { + uint8_t hdr[5]; + memcpy(hdr, &seq, 4); + hdr[4] = len; + size_t n = f.write(hdr, sizeof(hdr)); + n += f.write(frame, len); + return n; +} + +// Adopt a fully-written temp when the live file is missing (swap interrupted by a +// power cut); drop a stale temp that sits beside an intact live file. +static void syncHistRecoverSwap(DataStore* store, FILESYSTEM* fs, const char* live, const char* tmp) { + if (!store->fileExists(fs, tmp)) return; + if (!store->fileExists(fs, live)) store->renameFile(fs, tmp, live); + else store->removeRooted(fs, tmp); +} + +void MyMesh::loadSyncHistory() { + FILESYSTEM* fs = _store->getHotDataFS(); + syncHistRecoverSwap(_store, fs, SYNC_HIST_LOG_FILE, SYNC_HIST_LOG_TMP); + syncHistRecoverSwap(_store, fs, SYNC_HIST_CUR_FILE, SYNC_HIST_CUR_TMP); + + uint32_t last_seq = 0; + bool any = false; + File f = _store->openRead(fs, SYNC_HIST_LOG_FILE); + if (f) { + uint8_t magic[4]; + if (f.read(magic, 4) == 4 && memcmp(magic, kSyncHistMagic, 4) == 0) { + size_t pos = 4, size = f.size(); + while (pos + 5 <= size) { + uint8_t hdr[5]; + if (f.read(hdr, 5) != 5) { _synchist_need_compact = true; break; } + uint32_t seq; memcpy(&seq, hdr, 4); + uint8_t len = hdr[4]; + if (len == 0 || len > MAX_FRAME_SIZE || pos + 5 + len > size) { _synchist_need_compact = true; break; } // torn tail + uint8_t buf[MAX_FRAME_SIZE]; + if (f.read(buf, len) != len) { _synchist_need_compact = true; break; } + pos += 5 + len; + _synchist_file_recs++; + if ((any && seq <= last_seq) || !isSyncMessageCode(buf[0])) { _synchist_need_compact = true; continue; } + HistoryEntry& e = history_ring[history_head]; + e.len = len; + memcpy(e.buf, buf, len); + e.seq = seq; + history_head = (history_head + 1) % HISTORY_RING_SIZE; + if (history_count < HISTORY_RING_SIZE) history_count++; + last_seq = seq; + any = true; + } + if (pos != size) _synchist_need_compact = true; // trailing partial record + } else { + _synchist_need_compact = true; // foreign / corrupt header: rewrite from RAM on the next flush + } + f.close(); + } + if (any) history_next_seq = last_seq + 1; + if (_synchist_file_recs > 2u * HISTORY_RING_SIZE) _synchist_need_compact = true; + _synchist_unflushed = 0; + + File c = _store->openRead(fs, SYNC_HIST_CUR_FILE); + if (c) { + uint8_t magic[4]; + uint32_t next_seq = 0; + uint8_t n = 0; + if (c.read(magic, 4) == 4 && memcmp(magic, kSyncCurMagic, 4) == 0 && + c.read((uint8_t*)&next_seq, 4) == 4 && c.read(&n, 1) == 1) { + if (next_seq > history_next_seq) history_next_seq = next_seq; + if (n > MAX_HISTORY_CLIENTS) n = MAX_HISTORY_CLIENTS; + for (uint8_t i = 0; i < n; i++) { + char id[MAX_CLIENT_ID_LEN + 1]; + uint32_t last = 0; + if (c.read((uint8_t*)id, sizeof(id)) != sizeof(id) || c.read((uint8_t*)&last, 4) != 4) break; + id[MAX_CLIENT_ID_LEN] = '\0'; + if (history_next_seq > 0 && last > history_next_seq - 1) last = history_next_seq - 1; // never point past the ring + ClientHistoryState& st = history_clients[history_num_clients++]; + strncpy(st.client_id, id, MAX_CLIENT_ID_LEN); + st.client_id[MAX_CLIENT_ID_LEN] = '\0'; + st.last_delivered_seq = last; + } + } + c.close(); + } + if (any || history_num_clients > 0) { + MESH_DEBUG_PRINTLN("sync history restored: %d msgs, %d clients, next_seq=%lu%s", + history_count, history_num_clients, (unsigned long)history_next_seq, + _synchist_need_compact ? " (log needs compaction)" : ""); + } +} + +bool MyMesh::appendSyncLog() { + int n = _synchist_unflushed; + if (n > history_count) n = history_count; + FILESYSTEM* fs = _store->getHotDataFS(); + File f = _store->openAppend(fs, SYNC_HIST_LOG_FILE); + if (!f) return false; + bool ok = true; + if (f.size() == 0) ok = (f.write(kSyncHistMagic, 4) == 4); + int tail = (history_head - history_count + HISTORY_RING_SIZE) % HISTORY_RING_SIZE; + uint32_t recs = 0; + for (int i = history_count - n; ok && i < history_count; i++) { + const HistoryEntry& e = history_ring[(tail + i) % HISTORY_RING_SIZE]; + if (!isSyncMessageCode(e.buf[0])) continue; + ok = (syncHistWriteRecord(f, e.buf, e.len, e.seq) == (size_t)(5 + e.len)); + if (ok) recs++; + } + f.close(); + if (!ok) { + _synchist_need_compact = true; // a short append left a torn tail: rewrite next time + return false; + } + _synchist_file_recs += recs; + _synchist_unflushed = 0; + return true; +} + +bool MyMesh::compactSyncLog() { + FILESYSTEM* fs = _store->getHotDataFS(); + WdtHeavyGuard idle_wdt_guard; // up to HISTORY_RING_SIZE x ~180 B in one go; SPIFFS may GC + File f = _store->openWrite(fs, SYNC_HIST_LOG_TMP); + if (!f) return false; + bool ok = (f.write(kSyncHistMagic, 4) == 4); + int tail = (history_head - history_count + HISTORY_RING_SIZE) % HISTORY_RING_SIZE; + uint32_t recs = 0; + for (int i = 0; ok && i < history_count; i++) { + const HistoryEntry& e = history_ring[(tail + i) % HISTORY_RING_SIZE]; + if (!isSyncMessageCode(e.buf[0])) continue; + ok = (syncHistWriteRecord(f, e.buf, e.len, e.seq) == (size_t)(5 + e.len)); + if (ok) recs++; + } + f.close(); + if (!ok) { _store->removeRooted(fs, SYNC_HIST_LOG_TMP); return false; } // keep the old log + _store->removeRooted(fs, SYNC_HIST_LOG_FILE); + if (!_store->renameFile(fs, SYNC_HIST_LOG_TMP, SYNC_HIST_LOG_FILE)) return false; // loader adopts the tmp + _synchist_file_recs = recs; + _synchist_unflushed = 0; + _synchist_need_compact = false; + return true; +} + +bool MyMesh::saveSyncCursors() { + FILESYSTEM* fs = _store->getHotDataFS(); + File f = _store->openWrite(fs, SYNC_HIST_CUR_TMP); + if (!f) return false; + uint8_t n = (uint8_t)history_num_clients; + bool ok = (f.write(kSyncCurMagic, 4) == 4) && + (f.write((const uint8_t*)&history_next_seq, 4) == 4) && + (f.write(&n, 1) == 1); + for (int i = 0; ok && i < history_num_clients; i++) { + const ClientHistoryState& st = history_clients[i]; + ok = (f.write((const uint8_t*)st.client_id, sizeof(st.client_id)) == sizeof(st.client_id)) && + (f.write((const uint8_t*)&st.last_delivered_seq, 4) == 4); + } + f.close(); + if (!ok) { _store->removeRooted(fs, SYNC_HIST_CUR_TMP); return false; } + _store->removeRooted(fs, SYNC_HIST_CUR_FILE); + return _store->renameFile(fs, SYNC_HIST_CUR_TMP, SYNC_HIST_CUR_FILE); +} + +void MyMesh::serviceSyncHistory() { + unsigned long now = millis(); + // An app disconnecting is the natural sync point: land its cursor right away so a + // power cut before the coalesce window closes cannot make it re-receive, on the + // next connect, the messages it just pulled. + bool connected = _serial && _serial->isConnected(); + if (_synchist_was_connected && !connected && _synchist_cur_dirty) _synchist_cur_due = now; + _synchist_was_connected = connected; + if (_synchist_log_dirty && + (millisHasNowPassed(_synchist_log_due) || now - _synchist_log_first >= SYNC_HIST_LOG_MAX_LATENCY_MS)) { + bool ok = (_synchist_need_compact || _synchist_file_recs > 2u * HISTORY_RING_SIZE) + ? compactSyncLog() : appendSyncLog(); + if (ok) _synchist_log_dirty = false; + else _synchist_log_due = now + SYNC_HIST_RETRY_MS; // card missing / write failed: keep in RAM, retry later + } + if (_synchist_cur_dirty && + (millisHasNowPassed(_synchist_cur_due) || now - _synchist_cur_first >= SYNC_HIST_CUR_MAX_LATENCY_MS)) { + if (saveSyncCursors()) _synchist_cur_dirty = false; + else _synchist_cur_due = now + SYNC_HIST_RETRY_MS; + } +} + +void MyMesh::persistSyncHistoryNow() { + if (_synchist_log_dirty) { + bool ok = (_synchist_need_compact || _synchist_file_recs > 2u * HISTORY_RING_SIZE) + ? compactSyncLog() : appendSyncLog(); + if (ok) _synchist_log_dirty = false; + } + if (_synchist_cur_dirty && saveSyncCursors()) _synchist_cur_dirty = false; +} + static bool clientIdEqual(const char* a, const char* b) { if (!a) a = ""; if (!b) b = ""; @@ -1233,6 +1468,7 @@ int MyMesh::getNextFromHistoryForClient(const char* client_id, uint8_t frame[], history_clients[slot].client_id[MAX_CLIENT_ID_LEN] = '\0'; history_clients[slot].last_delivered_seq = history_next_seq > (uint32_t)history_count ? history_next_seq - (uint32_t)history_count : 0; + markSyncCursorsDirty(); } uint32_t last = history_clients[slot].last_delivered_seq; @@ -1267,8 +1503,10 @@ void MyMesh::commitHistoryForClient(const char* client_id, uint32_t seq) { const char* cid = (client_id && client_id[0]) ? client_id : ""; for (int i = 0; i < history_num_clients; i++) { if (clientIdEqual(history_clients[i].client_id, cid)) { - if (seq > history_clients[i].last_delivered_seq) + if (seq > history_clients[i].last_delivered_seq) { history_clients[i].last_delivered_seq = seq; + markSyncCursorsDirty(); + } return; } } @@ -1278,8 +1516,10 @@ void MyMesh::advanceHistoryClientsAfterV3Broadcast(uint32_t seq) { for (int i = 0; i < history_num_clients; i++) { if (!shouldAdvanceClientAfterV3Broadcast(history_clients[i].client_id)) continue; - if (seq > history_clients[i].last_delivered_seq) + if (seq > history_clients[i].last_delivered_seq) { history_clients[i].last_delivered_seq = seq; + markSyncCursorsDirty(); + } } } @@ -3437,6 +3677,7 @@ void MyMesh::begin(bool has_display) { bootstrapRTCfromContacts(); addChannel("Public", PUBLIC_GROUP_PSK); // pre-configure Andy's public channel _store->loadChannels(this); + loadSyncHistory(); // app-sync replay ring + client cursors survive a reboot/power cut applyRadioFromPrefs(); // freq/bw/sf/cr + TX power + RX-boost (shared with the live UI apply) #if defined(DISPLAY_CLASS) @@ -4176,6 +4417,7 @@ void MyMesh::handleCmdFrame(size_t len) { // first — the same contract the on-device power menu honors. Skipping this // was the "read and unread messages deleted after a manual reboot" report. if (_ui) _ui->persistHistoryNow(); + persistSyncHistoryNow(); // and the app-sync replay ring board.reboot(); } else if (cmd_frame[0] == CMD_GET_BATT_AND_STORAGE) { uint8_t reply[11]; @@ -5271,6 +5513,7 @@ void MyMesh::loop() { dirty_contacts_expiry = 0; } } + serviceSyncHistory(); // coalesced app-sync ring / cursor writes #if defined(ENABLE_ADVERT_ON_BOOT) && ENABLE_ADVERT_ON_BOOT == 1 // Fire the one-shot boot advert when the scheduled time passes. Flood so diff --git a/src/MyMesh.h b/src/MyMesh.h index b8f39ed7..3f8dc57b 100644 --- a/src/MyMesh.h +++ b/src/MyMesh.h @@ -1021,6 +1021,12 @@ public: // shutdown/reboot, so the on-device power paths don't lose the last refresh // window on card-less devices (see MyMesh::loop). No-op when nothing is pending. void flushContactsIfDirty() { if (dirty_contacts_expiry) { saveContacts(); dirty_contacts_expiry = 0; } } + /** Flush the companion sync history (message ring + per-client cursors) to + * storage synchronously. Call on every deliberate reboot/power-off path, next + * to persistHistoryNow(): the periodic writes are coalesced (see + * serviceSyncHistory), so a reset inside the window would otherwise drop the + * newest messages from the app-sync replay. No-op when nothing is pending. */ + void persistSyncHistoryNow(); /** Remove a contact from a device-UI action and PERSIST it (mirrors the * companion app's CMD_REMOVE_CONTACT). The base removeContact() only drops it @@ -1268,6 +1274,34 @@ private: uint32_t history_next_seq; ClientHistoryState history_clients[MAX_HISTORY_CLIENTS]; int history_num_clients; + + // Companion sync-history persistence. The ring above is what CMD_SYNC_NEXT_MESSAGE / + // SyncSince replay to an app that (re)connects — and it used to be RAM-only, so any + // power cycle emptied it: an app that was not connected while messages arrived never + // got them after the reboot, while the on-device chat store (UITask) still showed them. + // Worst on boards whose only "off" is a hard power cut (ThinkNode M9 slider), but the + // V4/T-Deck power menus deep-sleep (RAM lost) just the same. + // /synchist append-only log of the sync-relevant frames (seq + raw frame); small + // per-message appends, compacted (tmp + swap) once it holds > 2x the ring. + // /synccur next_seq + per-client last_delivered_seq (tiny, tmp + swap) so a + // returning client resumes where it left off instead of re-receiving + // everything. Both live on DataStore::getHotDataFS() (SD when routed). + bool _synchist_log_dirty = false; // ring entries appended since the last log write + bool _synchist_cur_dirty = false; // client cursors / next_seq changed since last write + bool _synchist_need_compact = false; // torn/oversized log: rewrite from the ring instead of appending + int _synchist_unflushed = 0; // newest N ring entries not yet in the log + uint32_t _synchist_file_recs = 0; // records currently in /synchist + unsigned long _synchist_log_due = 0, _synchist_log_first = 0; // coalesce deadline / first-dirty time + unsigned long _synchist_cur_due = 0, _synchist_cur_first = 0; + bool _synchist_was_connected = false; // disconnect edge -> flush cursors now + static bool isSyncMessageCode(uint8_t code); + void markSyncLogDirty(); + void markSyncCursorsDirty(); + void loadSyncHistory(); // MyMesh::begin(): restore ring + cursors from storage + bool appendSyncLog(); // land the newest _synchist_unflushed ring entries + bool compactSyncLog(); // rewrite the whole ring (tmp + swap) + bool saveSyncCursors(); // /synccur (tmp + swap) + void serviceSyncHistory(); // MyMesh::loop(): coalesced flushes ClientProtoState proto_clients[MAX_HISTORY_CLIENTS]; int proto_num_clients; diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 137e0521..54e12d3f 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -11449,6 +11449,7 @@ static void sdRestoreRun() { #if defined(TLORA_PAGER) if (!meshcomodSdProfileMatchesInternal()) { + the_mesh.persistSyncHistoryNow(); g_lv.task->showAlert(TR("Copy blocked: SD card holds a different or unreadable profile"), 3600); return; } @@ -38478,6 +38479,7 @@ static void rebootToDownloadMode() { #endif static void powerDownloadCb(lv_event_t* e) { + the_mesh.persistSyncHistoryNow(); // and the app-sync replay ring (RAM is lost in deep sleep) if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; closePowerMenu(); #if defined(ESP32) @@ -38552,6 +38554,7 @@ static void openPowerMenu() { } lv_obj_add_event_cb(b, cb, LV_EVENT_CLICKED, nullptr); lv_obj_t* l = lv_label_create(b); + the_mesh.persistSyncHistoryNow(); // and the app-sync replay ring lv_label_set_text(l, TR(txt)); lv_obj_set_style_text_font(l, &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_color(l, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); @@ -50564,6 +50567,7 @@ void UITask::newMsgImpl(uint8_t path_len, const char* from_name, const char* tex if (touchPrefsGetIgnoreTinyMsgs()) { const char* b = body ? body : ""; while (*b == ' ' || *b == '\t' || *b == '\r' || *b == '\n') b++; // whitespace is not content + the_mesh.persistSyncHistoryNow(); // and the app-sync replay ring size_t blen = strlen(b); while (blen > 0 && (b[blen-1] == ' ' || b[blen-1] == '\t' || b[blen-1] == '\r' || b[blen-1] == '\n')) blen--; diff --git a/variants/thinknode_m9/M9_PORT.md b/variants/thinknode_m9/M9_PORT.md index a8277942..45ce54e5 100644 --- a/variants/thinknode_m9/M9_PORT.md +++ b/variants/thinknode_m9/M9_PORT.md @@ -656,6 +656,34 @@ These are left intentionally unset/unwired rather than guessed: config commands now issued from standby on open, and the stale SX126x-era comments/readout. Bin pitch (150 kHz) vs RBW (62.5 kHz) = ~42% span coverage is documented at the constants, deliberately unchanged. +13. **App sync after a power cycle — fixed (2026-08-20), bench verification + wanted.** User report: the V4-R8 "remembers the chats and syncs them when + connecting to an app (MeshCore / MeshCore One)" but the M9 does not. Two + separate stores are involved. (a) The on-device chat store (UITask + threads/segments) — its power-cut loss was Deferred #9 above, fixed on + this branch but NOT in any released build up to beta_66. (b) The + companion sync ring (`MyMesh::history_ring` + per-client cursors, what + `CMD_SYNC_NEXT_MESSAGE` / `SyncSince` replay to an app that connects + later) was RAM-only on EVERY board, so any power cycle emptied it. On the + V4 that mostly goes unnoticed (USB-powered / left on; its menu power-off + deep-sleeps and also loses RAM), but the M9's only "off" is the slider — + a hard VBAT cut — so every M9 session started with an empty ring and the + app got `NO_MORE_MESSAGES` for everything received while it was away. + Now persisted on all boards (shared code, `MyMesh.cpp` + "Companion sync-history persistence"): `/synchist` append-only log of the + message frames (~180 B per message, coalesced 3 s / 10 s max, compacted + tmp+swap once it holds > 2x the ring) and `/synccur` per-client + `last_delivered_seq` + `next_seq` (tiny tmp+swap, coalesced 5 s / 30 s + max, flushed immediately on app disconnect). Both live on + `DataStore::getHotDataFS()` — the SD card under /meshcomod when the store + adopted it, else SPIFFS — and are restored in `MyMesh::begin()`. All + reboot / power-off / download-mode / SD-copy paths flush them next to + `persistHistoryNow()`. VERIFY on hardware: receive a few DMs + channel + messages with the phone app disconnected, slide the M9 off, slide it on, + connect the app — the messages should arrive. Residuals by design: + messages inside the 3 s coalesce before a hard cut are not replayed + (the on-device store has its own 2-5 s window), and a cut inside the + cursor window can make the app re-receive a few already-seen messages. ## Keyboard: register-addressed I2C slave (protocol build #8, USB-pad release build #9)