diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 19d0dbe7..b0165e2b 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -708,6 +708,9 @@ void MyMesh::onCommandDataRecv(const ContactInfo &from, mesh::Packet *pkt, uint3 } } +// Oldest room post the bot still answers (see onSignedMessageRecv()). +#define BOT_ROOM_MAX_AGE_SECS 300 + void MyMesh::onSignedMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp, const uint8_t *sender_prefix, const char *text) { markConnectionActive(from); @@ -729,7 +732,12 @@ void MyMesh::onSignedMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uin // Room-server auto-reply bot — only ever fires for the room server contact // itself (signed posts are how a room relays its members' messages back to // us); a defensive type check lives in tryBotReplyRoom/tryBotRoomCommand too. - if (from.type == ADV_TYPE_ROOM) { + // After login the room replays posts we missed: answer only fresh ones, or + // the bot would reply to (and run commands from) the whole backlog. A clock + // behind the room's makes every post look new, which keeps today's behaviour. + uint32_t now = getRTCClock()->getCurrentTime(); + bool replayed = now > sender_timestamp && now - sender_timestamp > BOT_ROOM_MAX_AGE_SECS; + if (from.type == ADV_TYPE_ROOM && !replayed) { uint8_t hops = pkt ? pkt->getPathHashCount() : 0; if (!tryBotRoomCommand(from, sender_prefix, text, hops)) // commands take priority tryBotReplyRoom(from, sender_prefix, text, hops); @@ -1796,8 +1804,7 @@ void MyMesh::handleCmdFrame(size_t len) { if (pkt) { if (len >= 2 && cmd_frame[1] == 1) { // optional param (1 = flood, 0 = zero hop) unsigned long delay_millis = 0; - TransportKey default_scope; - memcpy(&default_scope.key, _prefs.default_scope_key, sizeof(default_scope.key)); + TransportKey default_scope = _scope_list.key(_scope_list.default_idx); // what messages use too sendFloodScoped(default_scope, pkt, delay_millis); } else { sendZeroHop(pkt); @@ -2512,17 +2519,18 @@ void MyMesh::handleCmdFrame(size_t len) { // fields: since the scope list landed, every send and the relay filter // resolve through _scope_list, so writing default_scope_name/key alone // would leave the app's request with nothing reading it. - setPrimaryScope((char *) &cmd_frame[1]); + if (!setPrimaryScope((char *) &cmd_frame[1])) { + writeErrFrame(ERR_CODE_TABLE_FULL); // no room for a new entry: default unchanged + return; + } // Honour the key the app derived rather than the one setPrimaryScope() // re-derived from the name. They agree today (same "#name" -> SHA256), // but the app is the authority on its own regions, and a silent // mismatch here would be an on-air difference nothing surfaces. - memcpy(_prefs.default_scope_key, &cmd_frame[1+31], 16); - if (_scope_list.default_idx >= 1) { - memcpy(_scope_list.entries[_scope_list.default_idx - 1].key, &cmd_frame[1+31], 16); - if (_store) _store->saveScopeList(_scope_list); - rebuildRepeatScopes(); // slot 0 of the relay filter tracks this key - } + memcpy(_scope_list.entries[_scope_list.default_idx - 1].key, &cmd_frame[1+31], 16); + syncLegacyDefaultScope(); + if (_store) _store->saveScopeList(_scope_list); + rebuildRepeatScopes(); // slot 0 of the relay filter tracks this key savePrefs(); writeOKFrame(); } else { @@ -2924,8 +2932,7 @@ bool MyMesh::advertFlood() { mesh::Packet* pkt; pkt = createOwnAdvert(); if (pkt) { - TransportKey default_scope; - memcpy(&default_scope.key, _prefs.default_scope_key, sizeof(default_scope.key)); + TransportKey default_scope = _scope_list.key(_scope_list.default_idx); sendFloodScoped(default_scope, pkt, 0); return true; } else { diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 950971d6..7d1f6d52 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -485,7 +485,7 @@ public: // device's own list too, not just in the two legacy fields. Empty name // clears the default back to list index 0 ("*"). Calls // rebuildRepeatScopes() and persists the list. - void setPrimaryScope(const char* name); + bool setPrimaryScope(const char* name); // false: list full, default unchanged // Mirrors the list's current default entry back into the legacy // NodePrefs::default_scope_name/key pair. Those two fields are the only diff --git a/examples/companion_radio/MyMeshScope.h b/examples/companion_radio/MyMeshScope.h index 5d690978..c7fcf334 100644 --- a/examples/companion_radio/MyMeshScope.h +++ b/examples/companion_radio/MyMeshScope.h @@ -4,41 +4,33 @@ // Not part of upstream MyMesh.cpp: included at its bottom, after all class // definitions (as MyMeshBot.h and MyMeshCli.h). -void MyMesh::setPrimaryScope(const char* name) { - // Keep the legacy fields in sync too -- inert for rebuildRepeatScopes() - // once /scopes1 exists, but still what a pre-list save file round-trips, - // and cheap to maintain. - strncpy(_prefs.default_scope_name, name, sizeof(_prefs.default_scope_name) - 1); - _prefs.default_scope_name[sizeof(_prefs.default_scope_name) - 1] = '\0'; - if (_prefs.default_scope_name[0] == '\0') { - memset(_prefs.default_scope_key, 0, sizeof(_prefs.default_scope_key)); - } else { - char hashtag[1 + sizeof(_prefs.default_scope_name)]; - snprintf(hashtag, sizeof(hashtag), "#%s", _prefs.default_scope_name); - TransportKeyStore temp; - TransportKey key; - temp.getAutoKeyFor(0, hashtag, key); - memcpy(_prefs.default_scope_key, key.key, sizeof(key.key)); - } - - if (_prefs.default_scope_name[0] == '\0') { +bool MyMesh::setPrimaryScope(const char* name) { + bool ok = true; + if (!name || name[0] == '\0') { _scope_list.default_idx = 0; // "*" } else { // Find-or-create a matching named entry (e.g. the app driving this // remotely via CMD_SET_DEFAULT_FLOOD_SCOPE), then mark it default. + char nm[sizeof(_prefs.default_scope_name)]; + StrHelper::strncpy(nm, name, sizeof(nm)); uint8_t idx = 0; for (uint8_t i = 0; i < _scope_list.count; i++) { - if (strcmp(_scope_list.entries[i].name, _prefs.default_scope_name) == 0) { idx = i + 1; break; } + if (strcmp(_scope_list.entries[i].name, nm) == 0) { idx = i + 1; break; } } - if (idx == 0) idx = _scope_list.add(_prefs.default_scope_name); + if (idx == 0) idx = _scope_list.add(nm); // add() returns 0 only when the list is full. Keep the previous default // rather than taking that as "*": silently dropping to unscoped would put // traffic on the air outside any region, which is a louder failure than // ignoring a request we had no room to honour. if (idx != 0) _scope_list.default_idx = idx; + else ok = false; } + // The legacy fields always mirror the list's default, so a refused request + // can't leave them naming a scope nothing else sends with. + syncLegacyDefaultScope(); if (_store) _store->saveScopeList(_scope_list); rebuildRepeatScopes(); + return ok; } void MyMesh::syncLegacyDefaultScope() { @@ -91,7 +83,7 @@ void MyMesh::removeScope(uint8_t idx) { for (uint8_t i = 0; i < NodePrefs::MAX_SCOPED_CHANNELS; i++) { uint8_t ci = _prefs.ch_scope_idx[i]; if (ci == NodePrefs::CH_SCOPE_DEFAULT) continue; // follows the default, fixed up above - if (ci == idx) _prefs.ch_scope_idx[i] = 0; + if (ci == idx) _prefs.ch_scope_idx[i] = NodePrefs::CH_SCOPE_DEFAULT; // back to the default, not to unscoped "*" else if (ci > idx) _prefs.ch_scope_idx[i] = ci - 1; } // Live Share's own scope (stored as list index + 1; 0 = follow the target). diff --git a/examples/companion_radio/ui-core/AdminSession.h b/examples/companion_radio/ui-core/AdminSession.h index 1959c645..36287ccf 100644 --- a/examples/companion_radio/ui-core/AdminSession.h +++ b/examples/companion_radio/ui-core/AdminSession.h @@ -336,7 +336,8 @@ public: if ((_state != LOGGING_IN && _state != WAITING) || (int32_t)(millis() - _deadline_ms) < 0) return; if (_state == LOGGING_IN) { the_mesh.cancelUiPendingLogin(_target.id.pub_key); // a late answer mustn't land elsewhere - the_mesh.forgetRoomPassword(_target.id.pub_key); + // No answer says nothing about the password (out of range, busy mesh): + // keep a saved one, as RoomSessions does. Only a rejection forgets it. _state = IDLE; _outcome = LOGIN_TIMEOUT; return; diff --git a/examples/companion_radio/ui-core/MessageHistory.h b/examples/companion_radio/ui-core/MessageHistory.h index b49cc93b..93ca47c5 100644 --- a/examples/companion_radio/ui-core/MessageHistory.h +++ b/examples/companion_radio/ui-core/MessageHistory.h @@ -467,7 +467,8 @@ public: // Periodic resend driver for outgoing DMs whose ACK deadline lapsed with no // ACK: resend with the next attempt# (reusing the original timestamp so the - // recipient dedups) while resends remain, else mark it failed (✗). + // recipient dedups) while resends remain, else mark it failed (✗). The last + // resend floods. void tickDmResends() { uint32_t now = millis(); for (int i = 0; i < _dm_hist_count; i++) { @@ -477,6 +478,13 @@ public: if (e.resends_left == 0) { failDm(e); continue; } ContactInfo c; if (!contactByPrefix(e.prefix, c)) { failDm(e); continue; } + // Last attempt over a direct path that went unanswered every time: drop + // the path and flood it, as the phone app does -- the route may be gone, + // and the flood finds a new one (the ACK's return path re-learns it). + if (e.resends_left == 1 && c.out_path_len != OUT_PATH_UNKNOWN && + the_mesh.resetContactPath(c.id.pub_key)) { + c.out_path_len = OUT_PATH_UNKNOWN; + } uint32_t expected_ack = 0, est_timeout = 0; uint8_t next_attempt = e.attempt + 1; if (the_mesh.sendMessage(c, e.msg_ts, next_attempt, e.text, diff --git a/examples/companion_radio/ui-core/TrailEngine.h b/examples/companion_radio/ui-core/TrailEngine.h index b0e628d6..364b5ad0 100644 --- a/examples/companion_radio/ui-core/TrailEngine.h +++ b/examples/companion_radio/ui-core/TrailEngine.h @@ -63,10 +63,7 @@ public: // trail can't wipe a previously saved one. void onShutdown() { if (!_prefs || !_prefs->trail_autosave_lowbatt || _trail.count() == 0) return; - DataStore* ds = the_mesh.getDataStore(); - if (!ds) return; - File f = ds->openWrite("/trail"); - if (f) { _trail.writeTo(f); f.close(); } + save(); } TrailStore& store() { return _trail; } @@ -74,6 +71,7 @@ public: // ── Actions ──────────────────────────────────────────────────────────────── enum FileResult : uint8_t { FILE_OK, FILE_NO_FS, FILE_MISSING, FILE_FAILED }; static constexpr const char* TRAIL_FILE = "/trail"; + static constexpr const char* TRAIL_TMP = "/trail.tmp"; bool isActive() const { return _trail.isActive(); } void setActive(bool on) { _trail.setActive(on); } @@ -82,10 +80,14 @@ public: FileResult save() { DataStore* ds = the_mesh.getDataStore(); if (!ds) return FILE_NO_FS; - File f = ds->openWrite(TRAIL_FILE); + // Through a temp file: a write cut short (flash full, power lost mid-save) + // keeps the previous saved trail instead of leaving a torn /trail. + File f = ds->openWrite(TRAIL_TMP); if (!f) return FILE_FAILED; bool ok = _trail.writeTo(f); f.close(); + if (ok) ok = ds->commitFile(TRAIL_TMP, TRAIL_FILE); + else ds->removeFile(TRAIL_TMP); return ok ? FILE_OK : FILE_FAILED; } FileResult load() { diff --git a/examples/companion_radio/ui-core/WaypointModel.h b/examples/companion_radio/ui-core/WaypointModel.h index e0703aa2..8ddac259 100644 --- a/examples/companion_radio/ui-core/WaypointModel.h +++ b/examples/companion_radio/ui-core/WaypointModel.h @@ -26,10 +26,12 @@ public: void save() { DataStore* ds = the_mesh.getDataStore(); if (!ds) return; - File f = ds->openWrite("/waypoints"); + File f = ds->openWrite("/waypoints.tmp"); // temp + rename: a cut-short write keeps the old file if (!f) return; - _store.writeTo(f); + bool ok = _store.writeTo(f); f.close(); + if (ok) ds->commitFile("/waypoints.tmp", "/waypoints"); + else ds->removeFile("/waypoints.tmp"); } // `label` cut to what a waypoint holds (WAYPOINT_LABEL_LEN - 1 bytes) at a diff --git a/examples/companion_radio/ui-lvgl/HistoryStore.h b/examples/companion_radio/ui-lvgl/HistoryStore.h index fce61f5d..f2a8d616 100644 --- a/examples/companion_radio/ui-lvgl/HistoryStore.h +++ b/examples/companion_radio/ui-lvgl/HistoryStore.h @@ -193,11 +193,19 @@ private: uint32_t run = m - got < h.cap - slot ? m - got : h.cap - slot; if (fseek(f, sizeof(Hdr) + (long)slot * sizeof(E), SEEK_SET) != 0) break; size_t r = fread(&out[got], sizeof(E), run, f); + for (size_t i = 0; i < r; i++) sanitize(out[got + i]); got += (uint32_t)r; if (r != run) break; } return got; } + // The card comes out and goes on a computer: a damaged or edited record + // must not leave the text unterminated or a path longer than its buffer. + template + static void sanitize(E& e) { + e.text[sizeof(e.text) - 1] = '\0'; + if (((e.path_len >> 6) + 1) * (e.path_len & 63) > (int)sizeof(e.path)) e.path_len = 0; + } static bool writeHdr(FILE* f, const Hdr& h) { return fseek(f, 0, SEEK_SET) == 0 && fwrite(&h, sizeof(h), 1, f) == 1; }