From be9f3db8c66a65f9212d4fb7890e22518d4fe83b Mon Sep 17 00:00:00 2001 From: MarekZegare4 Date: Fri, 26 Jun 2026 10:55:24 +0200 Subject: [PATCH 01/29] feat(companion): on-device room login with saved passwords Log in to a room server from the device UI with no phone app: picking a room prompts for its password (blank allowed for open rooms), and a context-menu "Login..." allows re-login. Successful passwords are persisted to a dedicated /room_pw file so a previously-used room logs back in after reboot without retyping; a failed login forgets the (now-stale) saved password so the next attempt prompts again. The room-password file is written via a temp file + atomic rename (new DataStore::commitFile helper), matching the crash-safety of contacts/channels persistence. Co-Authored-By: Claude Opus 4.8 --- examples/companion_radio/AbstractUITask.h | 4 + examples/companion_radio/DataStore.cpp | 4 + examples/companion_radio/DataStore.h | 5 + examples/companion_radio/MyMesh.cpp | 116 ++++++++++++++++++ examples/companion_radio/MyMesh.h | 23 +++- .../companion_radio/ui-new/QuickMsgScreen.h | 106 +++++++++++++++- examples/companion_radio/ui-new/UITask.cpp | 9 ++ examples/companion_radio/ui-new/UITask.h | 1 + release-notes.md | 1 + 9 files changed, 267 insertions(+), 2 deletions(-) diff --git a/examples/companion_radio/AbstractUITask.h b/examples/companion_radio/AbstractUITask.h index f2c9e2c9..6424dfc7 100644 --- a/examples/companion_radio/AbstractUITask.h +++ b/examples/companion_radio/AbstractUITask.h @@ -47,6 +47,10 @@ public: // A repeater rebroadcast of one of our channel sends was heard (seq from // lastChannelRelaySeq()) — drives the channel "relayed into mesh" marker. virtual void onChannelRelayed(uint32_t seq) { (void)seq; } + // Result of an on-device-UI-triggered MyMesh::sendRoomLogin() arrived. + // pub_key is the contact's key prefix (>=4 bytes valid); permissions is the + // room/repeater ACL byte (only meaningful when success is true). + virtual void onRoomLoginResult(const uint8_t* pub_key, bool success, uint8_t permissions) { (void)pub_key; (void)success; (void)permissions; } // True only when a BLE central is actually bonded/connected. On a dual // (BLE+USB) interface hasConnection() is always true (USB counts), so use // this for BLE-specific UI like the pairing-PIN prompt. diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index 4126b5f9..09627ca3 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -176,6 +176,10 @@ File DataStore::openWrite(const char* filename) { return ::openWrite(_fs, filename); } +bool DataStore::commitFile(const char* tmp_path, const char* final_path) { + return commitTempFile(_fs, tmp_path, final_path); +} + bool DataStore::removeFile(const char* filename) { return _fs->remove(filename); } diff --git a/examples/companion_radio/DataStore.h b/examples/companion_radio/DataStore.h index b789e68f..13579f0f 100644 --- a/examples/companion_radio/DataStore.h +++ b/examples/companion_radio/DataStore.h @@ -46,6 +46,11 @@ public: File openRead(const char* filename); File openRead(FILESYSTEM* fs, const char* filename); File openWrite(const char* filename); + // Atomically replace final_path with a fully-written temp file (see + // openWrite()). Use for small custom records that want the same crash-safety + // as contacts/channels: write everything to a .tmp, then commit. Returns + // false if the swap fails (the previous good file is left untouched). + bool commitFile(const char* tmp_path, const char* final_path); bool removeFile(const char* filename); bool removeFile(FILESYSTEM* fs, const char* filename); uint32_t getStorageUsedKb() const; diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 01fcef48..066d124c 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -870,6 +870,24 @@ void MyMesh::onContactResponse(const ContactInfo &contact, const uint8_t *data, i += 6; // pub_key_prefix } _serial->writeFrame(out_frame, i); + } else if (ui_pending_login && memcmp(&ui_pending_login, contact.id.pub_key, 4) == 0) { // check for on-device UI login response + ui_pending_login = 0; + + bool success; + uint8_t permissions = 0; + if (memcmp(&data[4], "OK", 2) == 0) { // legacy Repeater login OK response + success = true; + } else if (data[4] == RESP_SERVER_LOGIN_OK) { // new login response + uint16_t keep_alive_secs = ((uint16_t)data[5]) * 16; + if (keep_alive_secs > 0) { + startConnection(contact, keep_alive_secs); + } + success = true; + permissions = data[7]; // ACL permissions + } else { + success = false; + } + _ui->onRoomLoginResult(contact.id.pub_key, success, permissions); } else if (len > 4 && // check for status response pending_status && memcmp(&pending_status, contact.id.pub_key, 4) == 0 // legacy matching scheme @@ -910,6 +928,104 @@ void MyMesh::onContactResponse(const ContactInfo &contact, const uint8_t *data, } } +#define ROOM_PW_FILE "/room_pw" +#define ROOM_PW_TMP "/room_pw.tmp" +#define MAX_SAVED_ROOM_PASSWORDS 16 + +namespace { + struct RoomPwRec { + uint8_t key[4]; // pub-key prefix + char pw[16]; // up to 15 chars + NUL + }; +} + +bool MyMesh::getRoomPassword(const uint8_t* pub_key, char* out_password, uint8_t max_len) { + File f = _store->openRead(ROOM_PW_FILE); + if (!f) return false; + + RoomPwRec rec; + bool found = false; + while (f.read((uint8_t *)&rec, sizeof(rec)) == sizeof(rec)) { + if (memcmp(rec.key, pub_key, 4) == 0) { + strncpy(out_password, rec.pw, max_len - 1); + out_password[max_len - 1] = 0; + found = true; + break; + } + } + f.close(); + return found; +} + +bool MyMesh::saveRoomPassword(const uint8_t* pub_key, const char* password) { + // The table is tiny (<= MAX_SAVED_ROOM_PASSWORDS * 20 bytes), so just load + // it whole, update/append/evict in RAM, then rewrite -- simpler and just + // as crash-safe as a record seek given how rarely this runs (once per new + // room login). + RoomPwRec recs[MAX_SAVED_ROOM_PASSWORDS]; + int count = 0; + File rf = _store->openRead(ROOM_PW_FILE); + if (rf) { + RoomPwRec rec; + while (count < MAX_SAVED_ROOM_PASSWORDS && rf.read((uint8_t *)&rec, sizeof(rec)) == sizeof(rec)) { + if (memcmp(rec.key, pub_key, 4) != 0) { // drop stale entry for this key -- replaced below + recs[count++] = rec; + } + } + rf.close(); + } + + RoomPwRec new_rec; + memcpy(new_rec.key, pub_key, 4); + strncpy(new_rec.pw, password, sizeof(new_rec.pw) - 1); + new_rec.pw[sizeof(new_rec.pw) - 1] = 0; + + if (count < MAX_SAVED_ROOM_PASSWORDS) { + recs[count++] = new_rec; + } else { // table full and not already present -- evict oldest (front) + memmove(&recs[0], &recs[1], sizeof(RoomPwRec) * (MAX_SAVED_ROOM_PASSWORDS - 1)); + recs[MAX_SAVED_ROOM_PASSWORDS - 1] = new_rec; + } + + // Write to a temp file and atomically swap it over /room_pw, so an + // interrupted save leaves the previous good table intact rather than a + // truncated mix (mirrors how contacts/channels are persisted). + File wf = _store->openWrite(ROOM_PW_TMP); + if (!wf) return false; + size_t want = sizeof(RoomPwRec) * count; + bool ok = (wf.write((uint8_t *)recs, want) == want); + wf.close(); + if (!ok) { _store->removeFile(ROOM_PW_TMP); return false; } // keep previous good file + return _store->commitFile(ROOM_PW_TMP, ROOM_PW_FILE); +} + +void MyMesh::forgetRoomPassword(const uint8_t* pub_key) { + RoomPwRec recs[MAX_SAVED_ROOM_PASSWORDS]; + int count = 0; + File rf = _store->openRead(ROOM_PW_FILE); + if (!rf) return; + + RoomPwRec rec; + bool removed = false; + while (count < MAX_SAVED_ROOM_PASSWORDS && rf.read((uint8_t *)&rec, sizeof(rec)) == sizeof(rec)) { + if (memcmp(rec.key, pub_key, 4) == 0) { + removed = true; + } else { + recs[count++] = rec; + } + } + rf.close(); + if (!removed) return; // nothing to do, avoid a pointless rewrite + + File wf = _store->openWrite(ROOM_PW_TMP); + if (!wf) return; + size_t want = sizeof(RoomPwRec) * count; + bool ok = (wf.write((uint8_t *)recs, want) == want); + wf.close(); + if (!ok) { _store->removeFile(ROOM_PW_TMP); return; } // keep previous good file + _store->commitFile(ROOM_PW_TMP, ROOM_PW_FILE); +} + bool MyMesh::onContactPathRecv(ContactInfo& contact, uint8_t* in_path, uint8_t in_path_len, uint8_t* out_path, uint8_t out_path_len, uint8_t extra_type, uint8_t* extra, uint8_t extra_len) { if (extra_type == PAYLOAD_TYPE_RESPONSE && extra_len > 4) { uint32_t tag; diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index e8db12d5..a6129b06 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -208,10 +208,30 @@ private: bool getChannelForSave(uint8_t channel_idx, ChannelDetails& ch) override { return getChannel(channel_idx, ch); } void clearPendingReqs() { - pending_login = pending_status = pending_telemetry = pending_discovery = pending_req = 0; + pending_login = pending_status = pending_telemetry = pending_discovery = pending_req = ui_pending_login = 0; } public: + // On-device UI login to a room/repeater contact — no phone app required. + // The room server's ACL grants permission per-identity (self_id), not per + // command source, so this reuses the same sendLogin() the BLE CMD_SEND_LOGIN + // path uses; the async result lands in onContactResponse() and is pushed to + // the UI via AbstractUITask::onRoomLoginResult(). + bool sendRoomLogin(const ContactInfo& contact, const char* password) { + uint32_t est_timeout; + if (sendLogin(contact, password, est_timeout) == MSG_SEND_FAILED) return false; + clearPendingReqs(); + memcpy(&ui_pending_login, contact.id.pub_key, 4); // match this in onContactResponse() + return true; + } + + // On-device-saved room/repeater login passwords, persisted to flash (own + // small file, independent of /contacts3) so a room that's already been + // logged into doesn't need its password retyped after reboot. + bool saveRoomPassword(const uint8_t* pub_key, const char* password); + bool getRoomPassword(const uint8_t* pub_key, char* out_password, uint8_t max_len); + void forgetRoomPassword(const uint8_t* pub_key); + void savePrefs() { _store->savePrefs(_prefs, sensors.node_lat, sensors.node_lon); } void saveRTCTime() { _store->saveRTCTime(); } DataStore* getDataStore() const { return _store; } @@ -299,6 +319,7 @@ private: DataStore* _store; NodePrefs _prefs; uint32_t pending_login; + uint32_t ui_pending_login; // like pending_login, but triggered by on-device UI instead of BLE/USB app uint32_t pending_status; uint32_t pending_telemetry, pending_discovery; // pending _TELEMETRY_REQ uint32_t pending_req; // pending _BINARY_REQ diff --git a/examples/companion_radio/ui-new/QuickMsgScreen.h b/examples/companion_radio/ui-new/QuickMsgScreen.h index 5bfcd323..dfd69598 100644 --- a/examples/companion_radio/ui-new/QuickMsgScreen.h +++ b/examples/companion_radio/ui-new/QuickMsgScreen.h @@ -20,6 +20,7 @@ class QuickMsgScreen : public UIScreen { uint16_t _sorted[MAX_CONTACTS]; ContactInfo _sel_contact; bool _room_mode; // true = picking a room server, false = picking a DM contact + bool _login_mode; // true while KEYBOARD is collecting a room-login password // CHANNEL_PICK int _channel_sel, _channel_scroll; @@ -662,7 +663,7 @@ class QuickMsgScreen : public UIScreen { public: QuickMsgScreen(UITask* task, KeyboardWidget* kb) : _task(task), _kb(kb), _phase(MODE_SELECT), _mode_sel(0), - _contact_sel(0), _contact_scroll(0), _num_contacts(0), _room_mode(false), + _contact_sel(0), _contact_scroll(0), _num_contacts(0), _room_mode(false), _login_mode(false), _channel_sel(0), _channel_scroll(0), _num_channels(0), _sel_channel_idx(0), _sending_to_channel(false), _msg_sel(0), _msg_scroll(0), _active_msg_count(0), @@ -749,6 +750,62 @@ public: } } + // Rooms successfully logged in to this power-on session. RAM-only — the + // server's ACL (see ClientACL) is the real permission store and survives + // reboot on its own, but the device has no way to query it, so this is just + // a local memo to skip re-prompting for a password already entered this + // session when the same room is picked again. + static const int ROOM_LOGIN_TABLE_SIZE = 8; + uint8_t _room_login_prefix[ROOM_LOGIN_TABLE_SIZE][4]; + int _room_login_head = 0, _room_login_count = 0; + + bool isRoomLoggedIn(const uint8_t* pub_key) const { + for (int i = 0; i < _room_login_count; i++) + if (memcmp(_room_login_prefix[i], pub_key, 4) == 0) return true; + return false; + } + + void markRoomLoggedIn(const uint8_t* pub_key) { + if (isRoomLoggedIn(pub_key)) return; + int pos; + if (_room_login_count < ROOM_LOGIN_TABLE_SIZE) { + pos = (_room_login_head + _room_login_count) % ROOM_LOGIN_TABLE_SIZE; + _room_login_count++; + } else { + pos = _room_login_head; + _room_login_head = (_room_login_head + 1) % ROOM_LOGIN_TABLE_SIZE; + } + memcpy(_room_login_prefix[pos], pub_key, 4); + } + + // Password of the room-login attempt currently in flight -- set right + // before sendRoomLogin(), read back in onRoomLoginResult() so a successful + // attempt can be persisted (see MyMesh::saveRoomPassword()). + char _login_pw[16]; + + void startRoomLogin(const char* password) { + strncpy(_login_pw, password, sizeof(_login_pw) - 1); + _login_pw[sizeof(_login_pw) - 1] = 0; + bool sent = the_mesh.sendRoomLogin(_sel_contact, password); + _task->showAlert(sent ? "Logging in..." : "Login failed", sent ? 1000 : 1500); + } + + // Result of an on-device sendRoomLogin() (MyMesh::onContactResponse(), routed + // via AbstractUITask::onRoomLoginResult()). Surfaces as a transient alert. + void onRoomLoginResult(const uint8_t* pub_key, bool success, uint8_t permissions) { + (void)permissions; + if (success) { + markRoomLoggedIn(pub_key); + the_mesh.saveRoomPassword(pub_key, _login_pw); + } else { + // Saved password (if any) no longer works -- forget it so the next + // ENTER on this room falls back to a manual prompt instead of + // silently retrying the same bad password forever. + the_mesh.forgetRoomPassword(pub_key); + } + _task->showAlert(success ? "Login OK" : "Login failed", 1200); + } + // Look up a contact by 4-byte pub_key prefix (as stored in DmHistEntry). bool contactByPrefix(const uint8_t* prefix, ContactInfo& out) const { int total = the_mesh.getNumContacts(); @@ -832,6 +889,7 @@ public: _sending_to_channel = false; _room_mode = false; + _login_mode = false; buildContactList(); buildChannelList(); @@ -1443,6 +1501,17 @@ public: } else if (_phase == CONTACT_PICK) { // Context menu consumes all input while open if (_ctx_menu.active) { + if (_room_mode) { + auto res = _ctx_menu.handleInput(c); + if (res == PopupMenu::SELECTED && _num_contacts > 0) { + if (the_mesh.getContactByIdx(_sorted[_contact_sel], _sel_contact)) { + _login_mode = true; + _kb->begin("", 15); // room/repeater password: max 15 chars + _phase = KEYBOARD; + } + } + return true; + } // LEFT/RIGHT cycle Notif/Melody in-place (menu stays open). if (!_pin_picker_active && _num_contacts > 0) { bool left = (c == KEY_LEFT || c == KEY_PREV); @@ -1545,6 +1614,22 @@ public: if (c == KEY_ENTER && _num_contacts > 0) { if (the_mesh.getContactByIdx(_sorted[_contact_sel], _sel_contact)) { if (_pick_target) { commitPickTargetDM(_sel_contact); return true; } + if (_room_mode && !isRoomLoggedIn(_sel_contact.id.pub_key)) { + // Posting to a room requires a login handshake first (even with a + // blank password) — go straight to the password prompt instead of + // a history view that would silently fail to send. + char saved_pw[sizeof(_login_pw)]; + if (the_mesh.getRoomPassword(_sel_contact.id.pub_key, saved_pw, sizeof(saved_pw))) { + // Logged in to this room before, on an earlier boot -- retry + // with the remembered password instead of prompting again. + startRoomLogin(saved_pw); + } else { + _login_mode = true; + _kb->begin("", 15); // room/repeater password: max 15 chars + _phase = KEYBOARD; + } + return true; + } _task->clearDMUnread(_sel_contact.id.pub_key); _dm_hist_sel = -1; _dm_hist_scroll = 0; @@ -1554,6 +1639,11 @@ public: } return true; } + if (c == KEY_CONTEXT_MENU && _num_contacts > 0 && _room_mode) { + _ctx_menu.begin("Room options", 1); + _ctx_menu.addItem("Login..."); + return true; + } if (c == KEY_CONTEXT_MENU && _num_contacts > 0 && !_room_mode) { static const char* NOTIF_LABELS[] = { "default", "OFF", "ON" }; ContactInfo ci; @@ -1831,6 +1921,20 @@ public: } else if (_phase == KEYBOARD) { auto res = _kb->handleInput(c); + if (_login_mode) { + if (res == KeyboardWidget::CANCELLED) { + _login_mode = false; + _phase = CONTACT_PICK; + } else if (res == KeyboardWidget::DONE) { + // Blank password is valid (guest/no-password rooms) — unlike normal + // message text, an empty submit here is a deliberate "log in with no + // password" attempt, so it isn't suppressed like an empty message is. + _login_mode = false; + startRoomLogin(_kb->buf); + _phase = CONTACT_PICK; + } + return true; + } if (res == KeyboardWidget::CANCELLED) { if (_share_mode) { _share_mode = false; _task->gotoHomeScreen(); } else { _phase = MSG_PICK; } diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 28b93626..48628e08 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1645,6 +1645,15 @@ void UITask::onChannelRelayed(uint32_t seq) { ((QuickMsgScreen*)quick_msg)->markChannelRelayed(seq); } +void UITask::onRoomLoginResult(const uint8_t* pub_key, bool success, uint8_t permissions) { + ((QuickMsgScreen*)quick_msg)->onRoomLoginResult(pub_key, success, permissions); + // Unlike the keypress-driven showAlert() calls elsewhere, this fires from a + // background mesh response with no keypress to schedule a redraw — without + // forcing one, the alert's short expiry can lapse before the next scheduled + // refresh ever draws it. + _next_refresh = 0; +} + void UITask::addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text, uint32_t sender_timestamp) { ((QuickMsgScreen*)quick_msg)->addDMMsg(pub_key, outgoing, text, sender_timestamp); } diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 07517965..154429e1 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -249,6 +249,7 @@ public: void addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text, uint32_t sender_timestamp = 0) override; void onMsgAck(uint32_t ack_crc) override; void onChannelRelayed(uint32_t seq) override; + void onRoomLoginResult(const uint8_t* pub_key, bool success, uint8_t permissions) override; int getDMUnreadTotal() const; int getMsgCount() const { return _msgcount; } int getChannelUnreadCount() const; diff --git a/release-notes.md b/release-notes.md index 467d0037..68f0ddbb 100644 --- a/release-notes.md +++ b/release-notes.md @@ -10,6 +10,7 @@ - **Trail auto-pause** — recording **freezes on stops** (banking elapsed time and breaking the map line across the idle gap) and **resumes on movement** without ending the session; the home-screen blink keeps going while paused. - **Collapsible Tools** — tools are grouped into fold-in-place **Location / Comms / System** sections, the same model as Settings (Tools always opens folded to the section list), and the home carousel now uses **page-indicator icons** instead of dots. - **Waypoint coordinate editor** — add a waypoint by scroll-editing its latitude/longitude digit by digit. +- **On-device room login with saved passwords** — log in to a room server straight from the device (no phone app): pick the room and the password prompt appears automatically (a blank password works for open rooms), or re-login any time via the room's **context-menu "Login…"**. The password is **remembered across reboots**, so a room you've used before logs back in without retyping; a failed login (e.g. the server's password changed) forgets the stale password so the next attempt prompts again. Saved passwords are written with the same atomic, crash-safe persistence as contacts and channels. ### Fixes From ac8ddc7e28196a047f7ab15d091cad8e21a3a379 Mon Sep 17 00:00:00 2001 From: MarekZegare4 Date: Fri, 26 Jun 2026 10:59:35 +0200 Subject: [PATCH 02/29] fix(companion): forget saved room password when its contact is removed CMD_REMOVE_CONTACT already drops the contact's advert blob; also drop any saved /room_pw login for that key so a deleted room doesn't leave an orphaned (and now useless) password behind until FIFO eviction. Co-Authored-By: Claude Opus 4.8 --- examples/companion_radio/MyMesh.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 066d124c..c36ade7c 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -1939,6 +1939,7 @@ void MyMesh::handleCmdFrame(size_t len) { ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); if (recipient && removeContact(*recipient)) { _store->deleteBlobByKey(pub_key, PUB_KEY_SIZE); + forgetRoomPassword(pub_key); // drop any saved room login -- useless without the contact dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); writeOKFrame(); } else { From c81b95b8b9636f057ef22847f210a6d239174890 Mon Sep 17 00:00:00 2001 From: MarekZegare4 Date: Fri, 26 Jun 2026 11:34:08 +0200 Subject: [PATCH 03/29] feat(companion): persist app/USB-entered room passwords on device The on-device login path already saved room passwords to /room_pw; do the same for logins issued by the phone/USB app (CMD_SEND_LOGIN). The password is stashed when the login is sent and persisted once the server confirms, gated to ADV_TYPE_ROOM contacts; a failed login forgets any stale saved password, mirroring the on-device path. This lets the device post to a room standalone after a reboot without re-prompting. Co-Authored-By: Claude Opus 4.8 --- examples/companion_radio/MyMesh.cpp | 13 +++++++++++++ examples/companion_radio/MyMesh.h | 1 + release-notes.md | 2 +- 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index c36ade7c..44575f70 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -845,11 +845,13 @@ void MyMesh::onContactResponse(const ContactInfo &contact, const uint8_t *data, pending_login = 0; int i = 0; + bool login_ok = false; if (memcmp(&data[4], "OK", 2) == 0) { // legacy Repeater login OK response out_frame[i++] = PUSH_CODE_LOGIN_SUCCESS; out_frame[i++] = 0; // legacy: is_admin = false memcpy(&out_frame[i], contact.id.pub_key, 6); i += 6; // pub_key_prefix + login_ok = true; } else if (data[4] == RESP_SERVER_LOGIN_OK) { // new login response uint16_t keep_alive_secs = ((uint16_t)data[5]) * 16; if (keep_alive_secs > 0) { @@ -863,12 +865,21 @@ void MyMesh::onContactResponse(const ContactInfo &contact, const uint8_t *data, i += 4; // NEW: include server timestamp out_frame[i++] = data[7]; // NEW (v7): ACL permissions out_frame[i++] = data[12]; // FIRMWARE_VER_LEVEL + login_ok = true; } else { out_frame[i++] = PUSH_CODE_LOGIN_FAIL; out_frame[i++] = 0; // reserved memcpy(&out_frame[i], contact.id.pub_key, 6); i += 6; // pub_key_prefix } + // Persist app/USB-entered room passwords too, so the device can later post + // to that room standalone (after reboot, no phone) without re-prompting -- + // same store the on-device login path uses. Rooms only; a failed login + // forgets any stale saved password, mirroring onRoomLoginResult(). + if (contact.type == ADV_TYPE_ROOM) { + if (login_ok) saveRoomPassword(contact.id.pub_key, pending_login_pw); + else forgetRoomPassword(contact.id.pub_key); + } _serial->writeFrame(out_frame, i); } else if (ui_pending_login && memcmp(&ui_pending_login, contact.id.pub_key, 4) == 0) { // check for on-device UI login response ui_pending_login = 0; @@ -2185,6 +2196,8 @@ void MyMesh::handleCmdFrame(size_t len) { } else { clearPendingReqs(); memcpy(&pending_login, recipient->id.pub_key, 4); // match this to onContactResponse() + strncpy(pending_login_pw, password, sizeof(pending_login_pw) - 1); // saved on success if it's a room + pending_login_pw[sizeof(pending_login_pw) - 1] = 0; out_frame[0] = RESP_CODE_SENT; out_frame[1] = (result == MSG_SEND_SENT_FLOOD) ? 1 : 0; memcpy(&out_frame[2], &pending_login, 4); diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index a6129b06..da9c5c82 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -320,6 +320,7 @@ private: NodePrefs _prefs; uint32_t pending_login; uint32_t ui_pending_login; // like pending_login, but triggered by on-device UI instead of BLE/USB app + char pending_login_pw[16]; // password of the in-flight app/USB login, persisted on success for ADV_TYPE_ROOM (see saveRoomPassword) uint32_t pending_status; uint32_t pending_telemetry, pending_discovery; // pending _TELEMETRY_REQ uint32_t pending_req; // pending _BINARY_REQ diff --git a/release-notes.md b/release-notes.md index 68f0ddbb..0ae0a7e5 100644 --- a/release-notes.md +++ b/release-notes.md @@ -10,7 +10,7 @@ - **Trail auto-pause** — recording **freezes on stops** (banking elapsed time and breaking the map line across the idle gap) and **resumes on movement** without ending the session; the home-screen blink keeps going while paused. - **Collapsible Tools** — tools are grouped into fold-in-place **Location / Comms / System** sections, the same model as Settings (Tools always opens folded to the section list), and the home carousel now uses **page-indicator icons** instead of dots. - **Waypoint coordinate editor** — add a waypoint by scroll-editing its latitude/longitude digit by digit. -- **On-device room login with saved passwords** — log in to a room server straight from the device (no phone app): pick the room and the password prompt appears automatically (a blank password works for open rooms), or re-login any time via the room's **context-menu "Login…"**. The password is **remembered across reboots**, so a room you've used before logs back in without retyping; a failed login (e.g. the server's password changed) forgets the stale password so the next attempt prompts again. Saved passwords are written with the same atomic, crash-safe persistence as contacts and channels. +- **On-device room login with saved passwords** — log in to a room server straight from the device (no phone app): pick the room and the password prompt appears automatically (a blank password works for open rooms), or re-login any time via the room's **context-menu "Login…"**. The password is **remembered across reboots**, so a room you've used before logs back in without retyping; a failed login (e.g. the server's password changed) forgets the stale password so the next attempt prompts again. Room passwords entered via the **phone app** are saved on the device too, so it can post to that room standalone after a reboot. Saved passwords are written with the same atomic, crash-safe persistence as contacts and channels. ### Fixes From e80092522ce1e52dec7512236dc2088a00580855 Mon Sep 17 00:00:00 2001 From: MarekZegare4 Date: Fri, 26 Jun 2026 11:51:06 +0200 Subject: [PATCH 04/29] docs(solo): document on-device room login with saved passwords MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a "Rooms — logging in" section and a room context-menu entry to the Messages screen docs: auto password prompt, passwords remembered across reboots, self-healing on failure, re-login via Login…, app-entered passwords also saved, and the ASCII-only keyboard caveat. Co-Authored-By: Claude Opus 4.8 --- .../message_screen/message_screen.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/solo_features/message_screen/message_screen.md b/docs/solo_features/message_screen/message_screen.md index 31362f2f..c025ca5b 100644 --- a/docs/solo_features/message_screen/message_screen.md +++ b/docs/solo_features/message_screen/message_screen.md @@ -40,6 +40,19 @@ Sensor placeholders appear automatically in the placeholder picker when the corr --- +### Rooms — logging in + +Posting to a **room server** requires a login handshake first, so the device can log in on its own — no phone app needed. The first time you press **Enter** on a room, a password prompt opens automatically; type the room's password and press the **✓** key (leave it empty and submit for open / no-password rooms). + +- **Passwords are remembered across reboots.** After a successful login the password is saved on the device, so picking that room again — even after a power cycle — logs back in silently without re-prompting. +- **A wrong or changed password self-heals.** If a saved password stops working (e.g. the server's password was changed), the failed login forgets it, so the next **Enter** prompts you to type a new one. +- **Re-login any time** with **Hold Enter** on the room → **Login…** (see the room context menu below) — useful to switch to a new password without waiting for a failure. +- Passwords set from the **phone app** are saved on the device too, so it can post to that room standalone after a reboot. + +> The on-screen keyboard is limited to ASCII (letters, digits and common symbols); accented characters such as `ą`/`ę` can't be typed on the device. A password containing them can still be set from the phone app — the device stores and replays it byte-for-byte. + +--- + ### Message history | OLED | E-Ink | @@ -92,6 +105,12 @@ A location is any `lat,lon` pair in the text — exactly what the `{loc}` placeh When **Pin to dial** is selected, a slot picker opens (Slot 1–6 showing current occupant name or "empty"). Choosing a slot that already holds another contact moves the new contact there. +In the **Rooms** list the context menu instead offers a single item: + +| Item | Action | +| ------- | ---------------------------------------------------------------------------- | +| Login… | Opens the password prompt to (re-)log in to this room (see Rooms — logging in) | + --- ### Context menu — channel list From 5d71fde980e7fb88ab52d2107d67d01b326a47fd Mon Sep 17 00:00:00 2001 From: MarekZegare4 Date: Fri, 26 Jun 2026 14:11:48 +0200 Subject: [PATCH 05/29] fix(companion): prompt to enable GPS when starting a trail with GPS off Starting trail recording with GPS switched off used to call setActive(true) immediately -- the session timer ran while the screen sat on "Waiting for GPS fix" forever and recorded nothing, with no hint that GPS was the problem. Choosing "Start tracking" now opens a "GPS is off" confirmation (Enable GPS & start / Cancel); confirming enables GPS and starts the session. Behaviour is unchanged when GPS is already on. Gated on a new UITask::hasGPS() so the prompt only appears on boards that expose a toggleable GPS, not where GPS is simply absent. Co-Authored-By: Claude Opus 4.8 --- .../tools_screen/tools_screen.md | 2 +- examples/companion_radio/ui-new/TrailScreen.h | 33 +++++++++++++++++-- examples/companion_radio/ui-new/UITask.cpp | 10 ++++++ examples/companion_radio/ui-new/UITask.h | 1 + release-notes.md | 1 + 5 files changed, 44 insertions(+), 3 deletions(-) diff --git a/docs/solo_features/tools_screen/tools_screen.md b/docs/solo_features/tools_screen/tools_screen.md index dbb6a483..4673c5c1 100644 --- a/docs/solo_features/tools_screen/tools_screen.md +++ b/docs/solo_features/tools_screen/tools_screen.md @@ -104,7 +104,7 @@ Cycle views with **LEFT / RIGHT**: | Item | Action | | --------------------- | --------------------------------------------------- | -| Start / Stop tracking | Begin or end a recording session | +| Start / Stop tracking | Begin or end a recording session. If **GPS is off**, choosing Start asks **"GPS is off — Enable GPS & start"** so a session can't silently run with nothing to record | | Mark here | Drop a waypoint at the current GPS fix (see below) | | Waypoints… | Open the waypoint list / navigation / add-by-coords | | Share my pos | Send your current position as a one-shot `[LOC]` message — pick a contact or channel (see **Live Share**) | diff --git a/examples/companion_radio/ui-new/TrailScreen.h b/examples/companion_radio/ui-new/TrailScreen.h index d2ca5b27..fc3b05eb 100644 --- a/examples/companion_radio/ui-new/TrailScreen.h +++ b/examples/companion_radio/ui-new/TrailScreen.h @@ -79,7 +79,7 @@ class TrailScreen : public UIScreen { // correctly (settings rows cycle with LEFT/RIGHT; everything else is Enter). // Live-share *config* lives in its own tool (Tools › Live Share); the map only // keeps the one-shot "Share my pos" action. - enum MenuLevel { ML_MAIN, ML_FILE, ML_SETTINGS }; + enum MenuLevel { ML_MAIN, ML_FILE, ML_SETTINGS, ML_CONFIRM_GPS }; PopupMenu _action_menu; uint8_t _menu_level = ML_MAIN; uint8_t _act_map[16]; // max rows on any one level; pushAction guards the cap @@ -152,6 +152,16 @@ public: } auto res = _action_menu.handleInput(c); if (res == PopupMenu::SELECTED) { + // GPS-off confirmation popup: rows aren't ActionIds, route by level. + if (_menu_level == ML_CONFIRM_GPS) { + if (_action_menu.selectedIndex() == 0) { // "Enable GPS & start" + _task->toggleGPS(); // off → on (persists, shows GPS alert) + _store->setActive(true); + _task->showAlert("GPS on, tracking started", 1200); + } + _menu_level = ML_MAIN; // popup already closed by handleInput() + return true; + } int sel = _action_menu.selectedIndex(); ActionId act = (sel >= 0 && sel < _act_count) ? (ActionId)_act_map[sel] : ACT_TOGGLE; switch (act) { @@ -163,7 +173,16 @@ public: case ACT_GRID: case ACT_AUTOPAUSE: cycleSetting(act, 1); reopenSettingsAt(sel); return true; case ACT_SHARE_NOW: shareMyLocationNow(); break; - case ACT_TOGGLE: handleToggle(); break; + case ACT_TOGGLE: + // Starting a trail with GPS switched off logs nothing and just + // sits on "Waiting for GPS fix" forever -- prompt to enable it + // first (only on boards that actually have a toggleable GPS). + if (!_store->isActive() && _task->hasGPS() && !_task->getGPSState()) { + buildGpsConfirmMenu(); + return true; + } + handleToggle(); + break; case ACT_MARK: _wp.markHere(); break; case ACT_WAYPOINTS: _wp.openList(); break; case ACT_SAVE: handleSave(); break; @@ -330,6 +349,16 @@ private: pushAction(ACT_SETTINGS, "Settings..."); } + // Confirmation shown when "Start tracking" is chosen with GPS off. Rows are + // plain (not ActionIds); handled by _menu_level in the SELECTED branch. + void buildGpsConfirmMenu() { + _menu_level = ML_CONFIRM_GPS; + _act_count = 0; + _action_menu.begin("GPS is off", 2); + _action_menu.addItem("Enable GPS & start"); + _action_menu.addItem("Cancel"); + } + // Trail-file submenu — only the operations that make sense right now. void buildFileMenu() { _menu_level = ML_FILE; diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 48628e08..51030eaf 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -2616,6 +2616,16 @@ bool UITask::getGPSState() { return false; } +bool UITask::hasGPS() { + if (_sensors != NULL) { + int num = _sensors->getNumSettings(); + for (int i = 0; i < num; i++) { + if (strcmp(_sensors->getSettingName(i), "gps") == 0) return true; + } + } + return false; +} + void UITask::toggleGPS() { if (_sensors != NULL) { // toggle GPS on/off diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 154429e1..39908e85 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -323,6 +323,7 @@ public: void cycleBuzzerMode(); // ON → OFF → Auto → ON int getBuzzerMode(); // 0=ON, 1=OFF, 2=Auto bool getGPSState(); + bool hasGPS(); // true if this board exposes a toggleable GPS (distinct from GPS being off) void toggleGPS(); void applyBrightness(); void setBrightnessLevel(uint8_t level); diff --git a/release-notes.md b/release-notes.md index 0ae0a7e5..d6a43596 100644 --- a/release-notes.md +++ b/release-notes.md @@ -21,6 +21,7 @@ - **Nearby Nodes** — live `[LOC]` senders now respect the type filter and sort by their shared position; the distance-sorted list refreshes so live shares bubble to the top. - **Map** — live contacts are labelled before waypoints, so a person's name shows rather than a nearby waypoint's. - **GPS status icon** is hidden when GPS is turned off in Settings, instead of sitting there empty. +- **Trail — start with GPS off** now prompts **"GPS is off — Enable GPS & start"** instead of silently starting a session that shows "Waiting for GPS fix" forever and records nothing. - Null-guarded the Locator target picker and clamped the loc-share channel index on load. ### Under the hood From 5c4607cca9d4a27e137f4e6be04bbf2e65f85ef9 Mon Sep 17 00:00:00 2001 From: MarekZegare4 Date: Fri, 26 Jun 2026 17:48:05 +0200 Subject: [PATCH 06/29] fix(companion): clear stale Locator/Live Share/favourite refs on delete Deleting a waypoint left the Locator pointed at coordinates that no longer existed (it's a coordinate snapshot, so nothing noticed). Removing a contact was worse: nothing cleared its favourite slot, its Locator/Live Share target, or its per-contact mute/melody entry, so all four kept referencing a pubkey that no longer resolved to anything. - WaypointsView's Delete now calls UITask::clearTargetIfWaypoint() first. - New AbstractUITask::onContactRemoved() hook, called from MyMesh.cpp's CMD_REMOVE_CONTACT handler, clears the favourite slot, the Locator target, and dm_notif/dm_melody entries for that pubkey. Live Share's DM target turns auto-share off instead of guessing a new recipient. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/AbstractUITask.h | 4 ++ examples/companion_radio/MyMesh.cpp | 1 + examples/companion_radio/ui-new/UITask.cpp | 46 +++++++++++++++++++ examples/companion_radio/ui-new/UITask.h | 12 +++++ .../companion_radio/ui-new/WaypointsView.h | 2 + 5 files changed, 65 insertions(+) diff --git a/examples/companion_radio/AbstractUITask.h b/examples/companion_radio/AbstractUITask.h index 6424dfc7..88bc0fec 100644 --- a/examples/companion_radio/AbstractUITask.h +++ b/examples/companion_radio/AbstractUITask.h @@ -73,5 +73,9 @@ public: virtual void onSharedLocation(const uint8_t* pub_key, const char* name, int32_t lat_1e6, int32_t lon_1e6, uint32_t ts, bool verified) {} + // A contact was removed (companion app / CLI command). Lets UI state that + // references contacts by pubkey (favourite slots, the Locator/Live Share + // target) drop a reference that would otherwise dangle. Default no-op. + virtual void onContactRemoved(const uint8_t* pub_key) {} virtual void loop() = 0; }; diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 44575f70..1288aa6f 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -1951,6 +1951,7 @@ void MyMesh::handleCmdFrame(size_t len) { if (recipient && removeContact(*recipient)) { _store->deleteBlobByKey(pub_key, PUB_KEY_SIZE); forgetRoomPassword(pub_key); // drop any saved room login -- useless without the contact + if (_ui) _ui->onContactRemoved(pub_key); // drop any favourite slot / Locator / Live Share target pointed at it dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); writeOKFrame(); } else { diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 51030eaf..c778b209 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -2399,6 +2399,52 @@ void UITask::clearTarget() { resetLocator(); } +void UITask::clearTargetIfWaypoint(int32_t lat_1e6, int32_t lon_1e6) { + if (!_node_prefs || !_node_prefs->locator_has_target || _node_prefs->locator_target_kind != 0) return; + if (_node_prefs->locator_lat_1e6 != lat_1e6 || _node_prefs->locator_lon_1e6 != lon_1e6) return; + clearTarget(); + the_mesh.savePrefs(); +} + +void UITask::onContactRemoved(const uint8_t* pub_key) { + if (!_node_prefs || !pub_key) return; + bool changed = false; + + int slot = findFavouriteSlot(pub_key); + if (slot >= 0) { clearFavouriteSlot(slot); changed = true; } + + if (_node_prefs->locator_has_target && _node_prefs->locator_target_kind == 1 + && memcmp(_node_prefs->locator_key, pub_key, NodePrefs::FAVOURITE_PREFIX_LEN) == 0) { + clearTarget(); + changed = true; + } + // Fail closed rather than guess a new recipient: a contact target that's + // gone just turns auto-share off, it doesn't fall back to some other target. + if (_node_prefs->loc_share_target_type == 1 + && memcmp(_node_prefs->loc_share_dm_prefix, pub_key, NodePrefs::FAVOURITE_PREFIX_LEN) == 0) { + _node_prefs->loc_share_enabled = 0; + changed = true; + } + // Per-contact mute/melody overrides — only 16 slots shared across every + // contact, so an orphaned entry isn't just stale, it can eventually starve + // new overrides for contacts that still exist. Keyed by a 4-byte prefix + // (narrower than the 6-byte one above), so compare only that many bytes. + for (int i = 0; i < NodePrefs::DM_NOTIF_TABLE_MAX; i++) { + if (_node_prefs->dm_notif[i].state && memcmp(_node_prefs->dm_notif[i].prefix, pub_key, 4) == 0) { + memset(&_node_prefs->dm_notif[i], 0, sizeof(_node_prefs->dm_notif[i])); + changed = true; + } + } + for (int i = 0; i < NodePrefs::DM_MELODY_TABLE_MAX; i++) { + if (_node_prefs->dm_melody[i].slot && memcmp(_node_prefs->dm_melody[i].prefix, pub_key, 4) == 0) { + memset(&_node_prefs->dm_melody[i], 0, sizeof(_node_prefs->dm_melody[i])); + changed = true; + } + } + + if (changed) the_mesh.savePrefs(); +} + // Homing beeper: while armed with a target and inside the radius, emit a short // tick whose interval shrinks linearly with distance — slow at the edge, rapid // near the centre. Polls distance a few times a second; silent outside the diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 39908e85..38a4c2f7 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -207,6 +207,18 @@ public: // Unset the active target (locator_has_target = 0). Distinct from setTarget() // because there's no "kind" for nothing — clearing is its own operation. void clearTarget(); + // One-shot: if the active target is exactly this waypoint, clear it and + // persist immediately (setTargetNow()'s save-on-the-spot policy) — called + // from waypoint deletion so the Locator can't keep pointing at a spot + // that no longer exists. + void clearTargetIfWaypoint(int32_t lat_1e6, int32_t lon_1e6); + // A contact was removed (companion app / CLI): drop any UI reference to its + // pubkey that would otherwise dangle — a pinned favourite slot, the Locator + // target if it was this contact, the Live Share target if it was this + // contact (auto-share turns off rather than guessing a new recipient), and + // any per-contact mute/melody override (those tables have only 16 shared + // slots, so an orphan isn't just stale — it can starve other contacts). + void onContactRemoved(const uint8_t* pub_key) override; // Resolve a person target (6-byte pubkey prefix) to a current position: // prefers an active [LOC] live share, falls back to their last-advertised // GPS fix. Returns false when neither is known. Optional live/ts report diff --git a/examples/companion_radio/ui-new/WaypointsView.h b/examples/companion_radio/ui-new/WaypointsView.h index ab66f71f..76e4b95d 100644 --- a/examples/companion_radio/ui-new/WaypointsView.h +++ b/examples/companion_radio/ui-new/WaypointsView.h @@ -269,6 +269,8 @@ public: } } else if (sel == 1) { // Delete if (wi >= 0 && wi < _task->waypoints().count()) { + const Waypoint& w = _task->waypoints().at(wi); + _task->clearTargetIfWaypoint(w.lat_1e6, w.lon_1e6); // don't leave the Locator pointed at a deleted spot _task->waypoints().remove(wi); _task->saveWaypoints(); if (_sel >= wpListCount()) _sel = wpListCount() - 1; From 3c34809af77f0b3bf4e1cc6ef8d5b7d05cce1312 Mon Sep 17 00:00:00 2001 From: MarekZegare4 Date: Fri, 26 Jun 2026 17:48:16 +0200 Subject: [PATCH 07/29] fix(companion): stable per-slot channel indices (channels2 -> channels3) CMD_SET_CHANNEL saves immediately after clearing a channel slot, and loadChannels() reassigned indices 0,1,2... sequentially on every load, skipping gaps. Removing a channel and rebooting would then silently shift every later channel down a slot -- anything that remembers a channel by index (Live Share's target, the bot's channel, per-channel melody bitmasks) could end up pointing at the wrong channel. channels3 records now carry their original slot index instead of padding, so a removed channel just leaves a hole. One-time migration: if channels3 doesn't exist yet, load the legacy channels2 with its old sequential semantics, then resave as channels3 so the fallback never runs again. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/DataStore.cpp | 73 +++++++++++++++++++------- 1 file changed, 54 insertions(+), 19 deletions(-) diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index 09627ca3..38a046d6 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -732,16 +732,24 @@ void DataStore::saveContacts(DataStoreHost* host, bool (*filter)(const ContactIn } void DataStore::loadChannels(DataStoreHost* host) { - File file = openRead(_getContactsChannelsFS(), "/channels2"); + FILESYSTEM* fs = _getContactsChannelsFS(); + File file = openRead(fs, "/channels3"); if (file) { + // /channels3: the leading 4-byte field's first byte is the channel's + // original slot index (see saveChannels()) — load it back into that + // exact slot. The old /channels2 format instead reassigned indices + // 0,1,2… sequentially on every load, which silently shifted every + // later channel down a slot once an earlier one was removed — anything + // that remembers a channel by index (Live Share's target, the bot's + // channel, per-channel melody) would then point at the wrong channel + // after the next reboot. bool full = false; - uint8_t channel_idx = 0; uint8_t skipped = 0; while (!full) { ChannelDetails ch; - uint8_t unused[4]; + uint8_t hdr[4]; - bool success = (file.read(unused, 4) == 4); + bool success = (file.read(hdr, 4) == 4); success = success && (file.read((uint8_t *)ch.name, 32) == 32); success = success && (file.read((uint8_t *)ch.channel.secret, 32) == 32); @@ -764,44 +772,71 @@ void DataStore::loadChannels(DataStoreHost* host) { // as a C string regardless of how the file was written. ch.name[31] = '\0'; - if (host->onChannelLoaded(channel_idx, ch)) { - channel_idx++; - } else { - full = true; - } + if (!host->onChannelLoaded(hdr[0], ch)) full = true; } file.close(); if (skipped > 0) { MESH_DEBUG_PRINTLN("loadChannels: skipped %u corrupted/empty channel entr%s", (unsigned)skipped, skipped == 1 ? "y" : "ies"); } + return; + } + + // One-time migration from the old /channels2 format (sequential index, + // reassigned on every load — the bug /channels3 above replaces). Loads + // with that old semantics once, then resaves as /channels3 so this + // fallback is never hit again on this device. + file = openRead(fs, "/channels2"); + if (file) { + bool full = false; + uint8_t channel_idx = 0; + while (!full) { + ChannelDetails ch; + uint8_t unused[4]; + + bool success = (file.read(unused, 4) == 4); + success = success && (file.read((uint8_t *)ch.name, 32) == 32); + success = success && (file.read((uint8_t *)ch.channel.secret, 32) == 32); + if (!success) break; // EOF + + bool secret_empty = true; + for (int b = 0; b < 32; b++) if (ch.channel.secret[b] != 0) { secret_empty = false; break; } + if (secret_empty) continue; + ch.name[31] = '\0'; + + if (host->onChannelLoaded(channel_idx, ch)) channel_idx++; + else full = true; + } + file.close(); + saveChannels(host); // write /channels3 so the migration runs only once } } void DataStore::saveChannels(DataStoreHost* host) { FILESYSTEM* fs = _getContactsChannelsFS(); // Same atomic temp-then-rename pattern as saveContacts() — never truncate the - // live /channels2 before the new copy is fully written. - File file = ::openWrite(fs, "/channels2.tmp"); + // live /channels3 before the new copy is fully written. + File file = ::openWrite(fs, "/channels3.tmp"); if (!file) return; bool ok = true; uint8_t channel_idx = 0; ChannelDetails ch; - uint8_t unused[4]; - memset(unused, 0, 4); while (host->getChannelForSave(channel_idx, ch)) { - channel_idx++; + uint8_t idx = channel_idx++; // getChannelForSave() returns every slot up to MAX_GROUP_CHANNELS, so skip // the unused ones (all-zero secret) rather than writing all 40 — otherwise - // the file is always ~2.7 KB and wears the flash needlessly. loadChannels() - // already compacts empty entries on read, so the loaded result is identical. + // the file is always ~2.7 KB and wears the flash needlessly. Unlike the old + // /channels2 format, loadChannels() no longer compacts: the slot index + // travels with the record (hdr[0] below) so a removed channel just leaves + // a hole instead of shifting every later index down a slot. bool empty = true; for (int b = 0; b < 32; b++) if (ch.channel.secret[b]) { empty = false; break; } if (empty) continue; - bool success = (file.write(unused, 4) == 4); + uint8_t hdr[4] = { idx, 0, 0, 0 }; + bool success = (file.write(hdr, 4) == 4); success = success && (file.write((uint8_t *)ch.name, 32) == 32); success = success && (file.write((uint8_t *)ch.channel.secret, 32) == 32); if (!success) { ok = false; break; } // write failed @@ -809,9 +844,9 @@ void DataStore::saveChannels(DataStoreHost* host) { file.close(); if (ok) { - commitTempFile(fs, "/channels2.tmp", "/channels2"); + commitTempFile(fs, "/channels3.tmp", "/channels3"); } else { - fs->remove("/channels2.tmp"); // keep the previous good /channels2 + fs->remove("/channels3.tmp"); // keep the previous good /channels3 } } From 43c3f43e106501f30b4c2b3874d8506dde1f7c2f Mon Sep 17 00:00:00 2001 From: MarekZegare4 Date: Sat, 27 Jun 2026 00:31:06 +0200 Subject: [PATCH 08/29] fix(companion): also clear stale contact refs on silent auto-eviction onContactOverwrite() (the contact-table-full LRU eviction path) deleted the contact's blob and notified the companion app, but never called the new onContactRemoved() cleanup -- so a Favourites Dial slot, Locator target, or Live Share target could still go stale, just via the silent auto-evict path instead of an explicit removal. This is likely the main real-world cause of the "(gone)" tile the docs described, since auto-eviction happens far more often than an explicit CMD_REMOVE_CONTACT. Also sync the two docs that described the old (now wrong) behaviour: Locator's "survives delete" claim, and Favourites Dial's "(gone) until reassigned" claim. Co-Authored-By: Claude Sonnet 4.6 --- docs/solo_features/favourites_dial/favourites_dial.md | 2 +- docs/solo_features/tools_screen/tools_screen.md | 2 +- examples/companion_radio/AbstractUITask.h | 8 +++++--- examples/companion_radio/MyMesh.cpp | 1 + 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/solo_features/favourites_dial/favourites_dial.md b/docs/solo_features/favourites_dial/favourites_dial.md index e71b2815..6f636aff 100644 --- a/docs/solo_features/favourites_dial/favourites_dial.md +++ b/docs/solo_features/favourites_dial/favourites_dial.md @@ -29,7 +29,7 @@ Navigate tiles with **UP / DOWN / LEFT / RIGHT**. Pressing a directional key at Filled tiles show an unread message count in the top-right corner when there are unread DMs from that contact. The contact name is ellipsized to make room for the badge. -If a pinned contact has been removed from the contacts list, the tile shows `(gone)` until the slot is reassigned. +If a pinned contact is removed from the contacts list — explicitly, or auto-evicted to make room when the table is full — its slot is freed automatically and goes back to an empty `+` tile. --- diff --git a/docs/solo_features/tools_screen/tools_screen.md b/docs/solo_features/tools_screen/tools_screen.md index 4673c5c1..0cd842f6 100644 --- a/docs/solo_features/tools_screen/tools_screen.md +++ b/docs/solo_features/tools_screen/tools_screen.md @@ -253,7 +253,7 @@ The tool holds both directions of sharing in one flat list. Navigate with **UP/D -A single **geofence** that beeps and shows an alert when you cross **into** or **out of** a radius. The target can be a **saved waypoint** (a fixed place — "tell me when I'm back at camp") or a **live contact** (a person sharing their position via Live Share — "alert me when my friend gets near / falls behind"). A waypoint target is a **snapshot** (coordinate + label copied), so it keeps working even if you later edit or delete that waypoint; a contact target follows the person's latest shared position. +A single **geofence** that beeps and shows an alert when you cross **into** or **out of** a radius. The target can be a **saved waypoint** (a fixed place — "tell me when I'm back at camp") or a **live contact** (a person sharing their position via Live Share — "alert me when my friend gets near / falls behind"). A waypoint target is a **snapshot** (coordinate + label copied), so it keeps working even if you later edit that waypoint; a contact target follows the person's latest shared position. **Deleting** the target's waypoint, or the target contact being removed from the contacts list, clears the Locator target back to `none` instead of leaving it pointed at something that's gone. Navigate with **UP/DOWN**, change a value with **LEFT/RIGHT** (or **Enter**); **Cancel/Back** saves and returns to Tools. diff --git a/examples/companion_radio/AbstractUITask.h b/examples/companion_radio/AbstractUITask.h index 88bc0fec..f1ac9024 100644 --- a/examples/companion_radio/AbstractUITask.h +++ b/examples/companion_radio/AbstractUITask.h @@ -73,9 +73,11 @@ public: virtual void onSharedLocation(const uint8_t* pub_key, const char* name, int32_t lat_1e6, int32_t lon_1e6, uint32_t ts, bool verified) {} - // A contact was removed (companion app / CLI command). Lets UI state that - // references contacts by pubkey (favourite slots, the Locator/Live Share - // target) drop a reference that would otherwise dangle. Default no-op. + // A contact is gone — removed explicitly (companion app / CLI command) or + // silently auto-evicted to make room when the contact table is full. Lets + // UI state that references contacts by pubkey (favourite slots, the + // Locator/Live Share target) drop a reference that would otherwise dangle. + // Default no-op. virtual void onContactRemoved(const uint8_t* pub_key) {} virtual void loop() = 0; }; diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 1288aa6f..12a08769 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -352,6 +352,7 @@ uint8_t MyMesh::getAutoAddMaxHops() const { void MyMesh::onContactOverwrite(const uint8_t* pub_key) { _store->deleteBlobByKey(pub_key, PUB_KEY_SIZE); // delete from storage + if (_ui) _ui->onContactRemoved(pub_key); // same cleanup as an explicit CMD_REMOVE_CONTACT if (_serial->isConnected()) { out_frame[0] = PUSH_CODE_CONTACT_DELETED; memcpy(&out_frame[1], pub_key, PUB_KEY_SIZE); From 6a394e2d7ab36555dab3fe3eb745a2dedc7ceb92 Mon Sep 17 00:00:00 2001 From: MarekZegare4 Date: Sat, 27 Jun 2026 00:39:10 +0200 Subject: [PATCH 09/29] fix(companion): clear stale channel-index refs when a channel is removed CMD_SET_CHANNEL clearing a slot (empty secret) left bot_channel_idx, loc_share_channel_idx, and the per-channel melody bitmasks pointing at that index. A new channel added later at the same slot would then silently inherit the old one's bot target, Live Share target, or notification melody. New onChannelRemoved() hook, mirroring onContactRemoved(), turns the bot/Live Share channel target off (fail closed) and clears the melody bits for that index. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/AbstractUITask.h | 5 +++++ examples/companion_radio/MyMesh.cpp | 13 ++++++++++++ examples/companion_radio/ui-new/UITask.cpp | 23 ++++++++++++++++++++++ examples/companion_radio/ui-new/UITask.h | 4 ++++ 4 files changed, 45 insertions(+) diff --git a/examples/companion_radio/AbstractUITask.h b/examples/companion_radio/AbstractUITask.h index f1ac9024..3d0fa85b 100644 --- a/examples/companion_radio/AbstractUITask.h +++ b/examples/companion_radio/AbstractUITask.h @@ -79,5 +79,10 @@ public: // Locator/Live Share target) drop a reference that would otherwise dangle. // Default no-op. virtual void onContactRemoved(const uint8_t* pub_key) {} + // A channel slot was cleared (companion app set it to an empty secret). + // Drop any setting that referenced it by index — otherwise a new channel + // added later at the same slot would silently inherit the old one's bot/ + // share target or notification melody. Default no-op. + virtual void onChannelRemoved(uint8_t channel_idx) {} virtual void loop() = 0; }; diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 12a08769..8e9224e3 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -1665,6 +1665,11 @@ void MyMesh::startInterface(BaseSerialInterface &serial) { serial.enable(); } +static bool isAllZero(const uint8_t* buf, size_t n) { + for (size_t i = 0; i < n; i++) if (buf[i]) return false; + return true; +} + void MyMesh::handleCmdFrame(size_t len) { if (cmd_frame[0] == CMD_DEVICE_QUERY && len >= 2) { // sent when app establishes connection app_target_ver = cmd_frame[1]; // which version of protocol does app understand @@ -2378,6 +2383,12 @@ void MyMesh::handleCmdFrame(size_t len) { memcpy(channel.channel.secret, &cmd_frame[2 + 32], 32); // 256-bit key if (setChannel(channel_idx, channel)) { saveChannels(); + // An all-zero secret is this codebase's "empty slot" sentinel (same + // check loadChannels()/saveChannels() use) -- the app just cleared this + // channel, so drop anything that referenced it by index, the same way + // onContactRemoved() does for contacts. + if (_ui && isAllZero(channel.channel.secret, sizeof(channel.channel.secret))) + _ui->onChannelRemoved(channel_idx); writeOKFrame(); } else { writeErrFrame(ERR_CODE_NOT_FOUND); // bad channel_idx @@ -2390,6 +2401,8 @@ void MyMesh::handleCmdFrame(size_t len) { memcpy(channel.channel.secret, &cmd_frame[2 + 32], 16); // 128-bit key if (setChannel(channel_idx, channel)) { saveChannels(); + if (_ui && isAllZero(channel.channel.secret, sizeof(channel.channel.secret))) + _ui->onChannelRemoved(channel_idx); writeOKFrame(); } else { writeErrFrame(ERR_CODE_NOT_FOUND); // bad channel_idx diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index c778b209..4a95278e 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -2445,6 +2445,29 @@ void UITask::onContactRemoved(const uint8_t* pub_key) { if (changed) the_mesh.savePrefs(); } +void UITask::onChannelRemoved(uint8_t channel_idx) { + if (!_node_prefs) return; + bool changed = false; + + if (_node_prefs->bot_channel_enabled && _node_prefs->bot_channel_idx == channel_idx) { + _node_prefs->bot_channel_enabled = 0; + changed = true; + } + // Fail closed, same policy as onContactRemoved()'s Live Share case. + if (_node_prefs->loc_share_target_type == 0 && _node_prefs->loc_share_channel_idx == channel_idx) { + _node_prefs->loc_share_enabled = 0; + changed = true; + } + uint64_t mask = 1ULL << channel_idx; + if (_node_prefs->ch_notif_melody_set & mask) { + _node_prefs->ch_notif_melody_set &= ~mask; + _node_prefs->ch_notif_melody_2 &= ~mask; + changed = true; + } + + if (changed) the_mesh.savePrefs(); +} + // Homing beeper: while armed with a target and inside the radius, emit a short // tick whose interval shrinks linearly with distance — slow at the edge, rapid // near the centre. Polls distance a few times a second; silent outside the diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 38a4c2f7..1a80f414 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -219,6 +219,10 @@ public: // any per-contact mute/melody override (those tables have only 16 shared // slots, so an orphan isn't just stale — it can starve other contacts). void onContactRemoved(const uint8_t* pub_key) override; + // A channel slot was cleared: turn off anything armed against it by index + // (the bot's channel, Live Share's channel target) and drop its per-channel + // melody bit, so a future channel re-added at the same slot starts clean. + void onChannelRemoved(uint8_t channel_idx) override; // Resolve a person target (6-byte pubkey prefix) to a current position: // prefers an active [LOC] live share, falls back to their last-advertised // GPS fix. Returns false when neither is known. Optional live/ts report From 7a5247d5d40f39521e5315993c8b633a99fb4d1f Mon Sep 17 00:00:00 2001 From: MarekZegare4 Date: Sun, 28 Jun 2026 11:00:45 +0200 Subject: [PATCH 10/29] fix(companion): show the sender's own timestamp for synced/queued messages DM, room and channel history always stamped a message with receipt time (rtc_clock.getCurrentTime()), even though the sender's real timestamp was already available (and, for DMs/rooms, already threaded through to addDMMsg -- just never used for display). Live-received messages hid this because receipt lags origination by only seconds, but a room-sync replay or an offline-queued message held by a repeater can arrive long after it was sent, so a burst of backlog messages all showed as "just now". storeDMMsg() now prefers msg_ts (falling back to receipt time only when unknown). addChannelMsg() gained a timestamp parameter, threaded from onChannelMessageRecv() down through AbstractUITask/UITask, with the same fallback. The DM dedup check and outgoing-message timestamps are unaffected (they use msg_ts directly, already correct). Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/AbstractUITask.h | 2 +- examples/companion_radio/MyMesh.cpp | 2 +- examples/companion_radio/ui-new/QuickMsgScreen.h | 14 ++++++++++---- examples/companion_radio/ui-new/UITask.cpp | 4 ++-- examples/companion_radio/ui-new/UITask.h | 2 +- 5 files changed, 15 insertions(+), 9 deletions(-) diff --git a/examples/companion_radio/AbstractUITask.h b/examples/companion_radio/AbstractUITask.h index 3d0fa85b..9ec3b482 100644 --- a/examples/companion_radio/AbstractUITask.h +++ b/examples/companion_radio/AbstractUITask.h @@ -65,7 +65,7 @@ public: virtual void msgRead(int msgcount) = 0; virtual void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount, uint8_t contact_type = 0, const uint8_t* pub_key = nullptr) = 0; virtual void notify(UIEventType t = UIEventType::none) = 0; - virtual void addChannelMsg(uint8_t channel_idx, const char* text) {} + virtual void addChannelMsg(uint8_t channel_idx, const char* text, uint32_t timestamp = 0) {} virtual void addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text, uint32_t sender_timestamp = 0) {} // A node shared its current position via a [LOC] message. pub_key is the // sender's key prefix for a verified DM share, or null for a channel share diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 8e9224e3..d0f33f6c 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -721,7 +721,7 @@ void MyMesh::onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packe _serial->writeFrame(frame, 1); } #ifdef DISPLAY_CLASS - if (_ui) _ui->addChannelMsg(channel_idx, text); + if (_ui) _ui->addChannelMsg(channel_idx, text, timestamp); if (_ui) _ui->notify(UIEventType::channelMessage); const char *channel_name = "Unknown"; ChannelDetails channel_details; diff --git a/examples/companion_radio/ui-new/QuickMsgScreen.h b/examples/companion_radio/ui-new/QuickMsgScreen.h index dfd69598..b560f0b1 100644 --- a/examples/companion_radio/ui-new/QuickMsgScreen.h +++ b/examples/companion_radio/ui-new/QuickMsgScreen.h @@ -398,7 +398,11 @@ class QuickMsgScreen : public UIScreen { } memcpy(_dm_hist[pos].prefix, pub_key, 4); _dm_hist[pos].outgoing = outgoing ? 1 : 0; - _dm_hist[pos].timestamp = rtc_clock.getCurrentTime(); + // Prefer the sender's own timestamp — a room-sync replay or an + // offline-queued message held by a repeater can arrive long after it was + // actually sent, so "now" would mislabel every backlog message as fresh. + // Fall back to receipt time only when the sender's timestamp is unknown. + _dm_hist[pos].timestamp = msg_ts ? msg_ts : rtc_clock.getCurrentTime(); strncpy(_dm_hist[pos].text, text, sizeof(DmHistEntry::text) - 1); _dm_hist[pos].text[sizeof(DmHistEntry::text) - 1] = '\0'; _dm_hist[pos].ack_status = (outgoing && ack_tag) ? ACK_PENDING : ACK_NONE; @@ -677,8 +681,10 @@ public: } // Returns the ring position the message was stored at, or -1 if rejected, so - // the outgoing path can attach a relay seq to that exact entry. - int addChannelMsg(uint8_t ch_idx, const char* text) { + // the outgoing path can attach a relay seq to that exact entry. `timestamp` + // is the sender's own send time (0 = unknown/outgoing — use receipt time); + // see storeDMMsg() for why this matters for synced/queued backlog messages. + int addChannelMsg(uint8_t ch_idx, const char* text, uint32_t timestamp = 0) { // Guard against bogus channel indices (e.g. findChannelIdx() returned -1 // and was cast to uint8_t → 255). Storing such an entry would burn a ring // slot for a message that no visible channel can ever surface. @@ -698,7 +704,7 @@ public: _hist_head = (_hist_head + 1) % CH_HIST_MAX; } _hist[pos].ch_idx = ch_idx; - _hist[pos].timestamp = rtc_clock.getCurrentTime(); + _hist[pos].timestamp = timestamp ? timestamp : rtc_clock.getCurrentTime(); strncpy(_hist[pos].text, text, sizeof(_hist[pos].text) - 1); _hist[pos].text[sizeof(_hist[pos].text) - 1] = '\0'; _hist[pos].relay_status = ACK_NONE; diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 4a95278e..bb5046a1 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1628,9 +1628,9 @@ int UITask::getRecentDMContacts(uint8_t out[][NodePrefs::FAVOURITE_PREFIX_LEN], return ((QuickMsgScreen*)quick_msg)->getRecentDMContacts(out, max); } -void UITask::addChannelMsg(uint8_t channel_idx, const char* text) { +void UITask::addChannelMsg(uint8_t channel_idx, const char* text, uint32_t timestamp) { _last_notif_ch_idx = (int)channel_idx; - ((QuickMsgScreen*)quick_msg)->addChannelMsg(channel_idx, text); + ((QuickMsgScreen*)quick_msg)->addChannelMsg(channel_idx, text, timestamp); } int UITask::getChannelUnreadCount() const { diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 1a80f414..d4e72e0a 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -261,7 +261,7 @@ public: void stopMelody(); bool isMelodyPlaying(); void showAlert(const char* text, int duration_millis); - void addChannelMsg(uint8_t channel_idx, const char* text) override; + void addChannelMsg(uint8_t channel_idx, const char* text, uint32_t timestamp = 0) override; void addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text, uint32_t sender_timestamp = 0) override; void onMsgAck(uint32_t ack_crc) override; void onChannelRelayed(uint32_t seq) override; From 31d602c88a04b52d38fd39bd2ecc6f4b56943d9e Mon Sep 17 00:00:00 2001 From: MarekZegare4 Date: Sun, 28 Jun 2026 12:27:16 +0200 Subject: [PATCH 11/29] fix(companion): reply to the right author in rooms (and keep nicks UTF-8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A room is viewed through DM history, where each post is stored "Author: text" and shown attributed to that author. The reply path, though, built the @[nick] prefix from _sel_contact.name — the room server's own name — so replying to someone's post in a room addressed the room, not the person. New buildDmReplyPrefix() derives the addressee from the same author the history shows (via dmDisplayParts): the post's author in a room, the contact name in a 1:1 DM. It also stores the nick raw (UTF-8), like the channel reply path, instead of running it through the lossy display transliterator — the prefix is sent over the air verbatim, so a non-ASCII nick was previously corrupted on the wire. Co-Authored-By: Claude Opus 4.8 --- .../companion_radio/ui-new/QuickMsgScreen.h | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/examples/companion_radio/ui-new/QuickMsgScreen.h b/examples/companion_radio/ui-new/QuickMsgScreen.h index b560f0b1..533bcb5e 100644 --- a/examples/companion_radio/ui-new/QuickMsgScreen.h +++ b/examples/companion_radio/ui-new/QuickMsgScreen.h @@ -258,6 +258,19 @@ class QuickMsgScreen : public UIScreen { return true; } + // Build "@[nick] " into _reply_prefix for a reply to a DM/room post, raw + // (UTF-8) so it goes out over the air intact — like buildChannelReplyPrefix, + // and unlike the old per-site code which ran the name through the lossy + // display transliterator. Addresses the same author the history shows + // (dmDisplayParts): in a room every post is stored "Author: text", so the + // addressee is that author, not the room server's own name (_sel_contact.name); + // a plain 1:1 DM uses the contact name. Caller ensures the post is incoming. + void buildDmReplyPrefix(const DmHistEntry& e) { + char nick[32]; + dmDisplayParts(e, _sel_contact.type == ADV_TYPE_ROOM, _sel_contact.name, nick, sizeof(nick)); + snprintf(_reply_prefix, sizeof(_reply_prefix), "@[%.31s] ", nick); + } + void startReply(bool to_channel) { _sending_to_channel = to_channel; _reply_mode = true; @@ -1788,10 +1801,7 @@ public: int ring_pos = dmHistEntryForContact(_sel_contact.id.pub_key, _dm_hist_sel); if (ring_pos >= 0) { bool reply_ok = !_dm_hist[ring_pos].outgoing; - if (reply_ok) { - char _tname[32]; DisplayDriver::translateUTF8Static(_tname, _sel_contact.name, sizeof(_tname)); - snprintf(_reply_prefix, sizeof(_reply_prefix), "@[%.31s] ", _tname); - } + if (reply_ok) buildDmReplyPrefix(_dm_hist[ring_pos]); buildFsMenu(_dm_hist[ring_pos].text, reply_ok); } } @@ -1847,10 +1857,7 @@ public: int ring_pos = dmHistEntryForContact(_sel_contact.id.pub_key, _dm_hist_sel); if (ring_pos >= 0) { bool reply_ok = !_dm_hist[ring_pos].outgoing; - if (reply_ok) { - char _tname[32]; DisplayDriver::translateUTF8Static(_tname, _sel_contact.name, sizeof(_tname)); - snprintf(_reply_prefix, sizeof(_reply_prefix), "@[%.31s] ", _tname); - } + if (reply_ok) buildDmReplyPrefix(_dm_hist[ring_pos]); buildFsMenu(_dm_hist[ring_pos].text, reply_ok); } return true; From fe19d1b0a7744f194bcc183adeb90c52f3643ebe Mon Sep 17 00:00:00 2001 From: MarekZegare4 Date: Mon, 29 Jun 2026 17:43:17 +0200 Subject: [PATCH 12/29] refactor(companion): dedup age formatting + reply-prefix parsing; drop dead code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidation pass over the message/nearby UI, no functional change beyond one intentional display tweak: - Age tags: NearbyScreen::fmtAge, the nearby list's inline column, and QuickMsgScreen::fmtMsgAge each reimplemented the same s/m/h bucket ladder on top of geo::fmtAgeShort. All now delegate to it (fmtMsgAge removed). Visible effect: ages over 24h render as "Nd" instead of capped hours, matching the Locator target picker which already used fmtAgeShort. - Reply prefix: the "@[nick] " parse was duplicated in skipReplyPrefix() and FullscreenMsgView::render(). Extracted to one msgReplyBody() helper (body, plus optional addressee nick) — one place to handle its edge cases. - LiveTrack: the expiry predicate was duplicated in expire()/isActive(); extracted to a private expired() helper. - Removed a dead M_PI define (and unused ) in NearbyScreen.h, and a comment pointing at a CODE_REVIEW.md that doesn't exist. Co-Authored-By: Claude Opus 4.8 --- examples/companion_radio/LiveTrack.h | 20 ++++----- .../ui-new/FullscreenMsgView.h | 45 ++++++++++++------- .../companion_radio/ui-new/NearbyScreen.h | 28 ++++-------- .../companion_radio/ui-new/QuickMsgScreen.h | 25 +++-------- 4 files changed, 52 insertions(+), 66 deletions(-) diff --git a/examples/companion_radio/LiveTrack.h b/examples/companion_radio/LiveTrack.h index 3fe45e21..78e71e6b 100644 --- a/examples/companion_radio/LiveTrack.h +++ b/examples/companion_radio/LiveTrack.h @@ -51,14 +51,9 @@ public: } // Drop entries not refreshed within EXPIRY_SECS. `now` is RTC epoch seconds. - // Guarded against now < ts (RTC stepped backwards) so a clock fix can't wipe - // the table. void expire(uint32_t now) { - for (int i = 0; i < CAPACITY; i++) { - if (_e[i].used && now > _e[i].ts && (now - _e[i].ts) > EXPIRY_SECS) { - _e[i].used = false; - } - } + for (int i = 0; i < CAPACITY; i++) + if (_e[i].used && expired(_e[i], now)) _e[i].used = false; } void clear() { for (int i = 0; i < CAPACITY; i++) _e[i].used = false; } @@ -66,9 +61,7 @@ public: // Slot-wise access (caller skips inactive slots via isActive()). const Entry& slotAt(int i) const { return _e[i]; } bool isActive(int i, uint32_t now) const { - const Entry& e = _e[i]; - if (!e.used) return false; - return !(now > e.ts && (now - e.ts) > EXPIRY_SECS); + return _e[i].used && !expired(_e[i], now); } // Number of currently non-expired entries. @@ -94,6 +87,13 @@ public: private: Entry _e[CAPACITY] = {}; + // An entry is stale once EXPIRY_SECS have passed since its last update. + // Guarded against now < ts (RTC stepped backwards) so a clock fix can't + // mass-expire the table. + static bool expired(const Entry& e, uint32_t now) { + return now > e.ts && (now - e.ts) > EXPIRY_SECS; + } + // Match an existing entry: verified shares by key, channel shares by name. int find(const uint8_t* key, const char* name, bool verified) const { for (int i = 0; i < CAPACITY; i++) { diff --git a/examples/companion_radio/ui-new/FullscreenMsgView.h b/examples/companion_radio/ui-new/FullscreenMsgView.h index 4390a0f9..30bcdff4 100644 --- a/examples/companion_radio/ui-new/FullscreenMsgView.h +++ b/examples/companion_radio/ui-new/FullscreenMsgView.h @@ -10,12 +10,35 @@ static const int FS_CHARS_MAX = 80; // max bytes per wrapped line // fullscreen message view and the history list are never laid out in the same // frame (opening the fullscreen view early-returns before the list loop runs), // so one static buffer serves both. This keeps ~1.5 KB of line buffers off the -// render-call stack (see CODE_REVIEW.md "Render-path stack peak") at the cost of -// a fixed RAM allocation. Only used inside render()/wrap helpers — never across -// a yield, so the single instance is safe. +// render-call stack at the cost of a fixed RAM allocation. Only used inside +// render()/wrap helpers — never across a yield, so the single instance is safe. static char s_wrap_trans[512]; static char s_wrap_lines[12][FS_CHARS_MAX]; +// Parse a leading "@[nick] " reply prefix. Returns the message body that +// follows it (and any leading whitespace); when nick/nick_n are supplied, +// fills nick with the addressee, or "" when there's no prefix. One parser for +// both the history list (body only — see QuickMsgScreen::skipReplyPrefix) and +// the fullscreen view (which also shows the "To:" nick). +static inline const char* msgReplyBody(const char* text, char* nick = nullptr, int nick_n = 0) { + if (nick && nick_n > 0) nick[0] = '\0'; + const char* body = text; + if (text[0] == '@' && text[1] == '[') { + const char* close = strchr(text + 2, ']'); + if (close && close[1] == ' ' && close[2]) { + if (nick && nick_n > 0) { + int len = (int)(close - text) - 2; + if (len > nick_n - 1) len = nick_n - 1; + memcpy(nick, text + 2, len); + nick[len] = '\0'; + } + body = close + 2; + } + } + while (*body == '\n' || *body == '\r' || *body == ' ') body++; + return body; +} + struct FullscreenMsgView { int scroll; bool active; @@ -80,19 +103,9 @@ struct FullscreenMsgView { const int lineH = display.getLineHeight(); const int max_px = display.width() - 6; - // detect @recipient at start of message - char to_nick[32] = ""; - const char* body = text; - if (text[0] == '@' && text[1] == '[') { - const char* close = strchr(text + 2, ']'); - if (close && close[1] == ' ' && close[2]) { - int len = (int)(close - text) - 2; - if (len >= (int)sizeof(to_nick)) len = sizeof(to_nick) - 1; - memcpy(to_nick, text + 2, len); - to_nick[len] = '\0'; - body = close + 2; - } - } + // "@[nick] " reply prefix → "To:" header + body (shared parser). + char to_nick[32]; + const char* body = msgReplyBody(text, to_nick, sizeof(to_nick)); const int cw = display.getCharWidth(); const int header_h = to_nick[0] ? (lineH * 2 + 4) : (lineH + 2); diff --git a/examples/companion_radio/ui-new/NearbyScreen.h b/examples/companion_radio/ui-new/NearbyScreen.h index 57a503ff..a971b4ee 100644 --- a/examples/companion_radio/ui-new/NearbyScreen.h +++ b/examples/companion_radio/ui-new/NearbyScreen.h @@ -1,12 +1,7 @@ #pragma once -#include #include "../GeoUtils.h" #include "NavView.h" -#ifndef M_PI - #define M_PI 3.14159265358979323846 -#endif - // ── Nearby Nodes ────────────────────────────────────────────────────────────── // One list / detail / action-menu interaction path over two sources: // SRC_STORED — contacts known to the mesh (distance / bearing / last-heard) @@ -111,14 +106,13 @@ class NearbyScreen : public UIScreen { bool useImperial() const { return _task && _task->useImperial(); } + // Full "X ago" form for the detail view, built on the shared short-age tag + // so there's one bucket ladder (see geo::fmtAgeShort). static void fmtAge(char* buf, int n, uint32_t lastmod) { - uint32_t now = rtc_clock.getCurrentTime(); - if (now < lastmod || lastmod == 0) { snprintf(buf, n, "unknown"); return; } - uint32_t age = now - lastmod; - if (age < 60) snprintf(buf, n, "%us ago", age); - else if (age < 3600) snprintf(buf, n, "%um ago", age / 60); - else if (age < 86400) snprintf(buf, n, "%uh ago", age / 3600); - else snprintf(buf, n, ">1d ago"); + char s[8]; + geo::fmtAgeShort(s, sizeof(s), rtc_clock.getCurrentTime(), lastmod); + if (!s[0]) { snprintf(buf, n, "unknown"); return; } + snprintf(buf, n, "%s ago", s); } static const char* typeName(uint8_t t) { @@ -715,14 +709,8 @@ public: if (_source == SRC_SCAN) { snprintf(right, sizeof(right), "%d", (int)e.rssi); } else if (_sort == SORT_TIME) { - uint32_t now = rtc_clock.getCurrentTime(); - if (e.lastmod == 0 || now < e.lastmod) snprintf(right, sizeof(right), "?"); - else { - uint32_t age = now - e.lastmod; - if (age < 60) snprintf(right, sizeof(right), "%us", age); - else if (age < 3600) snprintf(right, sizeof(right), "%um", age / 60); - else snprintf(right, sizeof(right), "%uh", age / 3600); - } + geo::fmtAgeShort(right, sizeof(right), rtc_clock.getCurrentTime(), e.lastmod); + if (!right[0]) snprintf(right, sizeof(right), "?"); // unknown / RTC not synced } else { if (e.dist_km >= 0.0f) geo::fmtDist(right, sizeof(right), e.dist_km, useImperial()); else strncpy(right, "?GPS", sizeof(right)); diff --git a/examples/companion_radio/ui-new/QuickMsgScreen.h b/examples/companion_radio/ui-new/QuickMsgScreen.h index 533bcb5e..183ce51f 100644 --- a/examples/companion_radio/ui-new/QuickMsgScreen.h +++ b/examples/companion_radio/ui-new/QuickMsgScreen.h @@ -209,15 +209,9 @@ class QuickMsgScreen : public UIScreen { return r; } - // Strip "@[nick] " reply prefix from a message body for compact list display. - static const char* skipReplyPrefix(const char* text) { - if (text[0] == '@' && text[1] == '[') { - const char* close = strchr(text + 2, ']'); - if (close && close[1] == ' ' && close[2]) text = close + 2; - } - while (*text == '\n' || *text == '\r' || *text == ' ') text++; - return text; - } + // Strip the "@[nick] " reply prefix for compact list display (body only). + // Shares the one parser with the fullscreen view — see msgReplyBody(). + static const char* skipReplyPrefix(const char* text) { return msgReplyBody(text); } // Split a DM-history entry into the name to show as the author and the body to // show beneath it. Room servers carry many guests, so incoming room posts are @@ -351,15 +345,6 @@ class QuickMsgScreen : public UIScreen { } - static void fmtMsgAge(char* buf, int n, uint32_t timestamp, uint32_t now) { - if (timestamp == 0 || now < timestamp) { buf[0] = '\0'; return; } - uint32_t age = now - timestamp; - if (age < 60) snprintf(buf, n, "%us", age); - else if (age < 3600) snprintf(buf, n, "%um", age / 60); - else if (age < 86400) snprintf(buf, n, "%uh", age / 3600); - else snprintf(buf, n, ">1d"); - } - // count history entries for a specific channel int histCountForChannel(int ch_idx) const { int n = 0; @@ -1207,7 +1192,7 @@ public: const char* body = dmDisplayParts(e, is_room, filtered_name, sender_buf, sizeof(sender_buf)); const char* sender = sender_buf; - char age[6]; fmtMsgAge(age, sizeof(age), e.timestamp, now_ts); + char age[6]; geo::fmtAgeShort(age, sizeof(age), now_ts, e.timestamp); int age_w = age[0] ? display.getTextWidth(age) + 3 : 0; if (sel) { @@ -1368,7 +1353,7 @@ public: msg_part[sizeof(msg_part) - 1] = '\0'; } - char age[6]; fmtMsgAge(age, sizeof(age), _hist[ring_pos].timestamp, now_ts); + char age[6]; geo::fmtAgeShort(age, sizeof(age), now_ts, _hist[ring_pos].timestamp); int age_w = age[0] ? display.getTextWidth(age) + 3 : 0; if (sel) { From e44df01b9b039a7439495d3153fdb6d586c8c0c0 Mon Sep 17 00:00:00 2001 From: MarekZegare4 Date: Mon, 29 Jun 2026 17:51:15 +0200 Subject: [PATCH 13/29] docs: add a solo UI framework developer guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document the reusable building blocks the solo UI has grown — the UIScreen model and screen wiring, DisplayDriver layout metrics, drawList/AccordionList, the popup/keyboard/digit-editor/fullscreen/nav components, the geo + state-store + persistence helpers, mini-icons, input conventions, and the cross-cutting gotchas (single-thread render, e-ink pacing, reference cleanup). Ends with a worked "add a new Tools screen" example. No code change; the helper layer is already well-factored (the recent dedup passes closed the remaining gaps), so this captures it for contributors rather than extracting more. Linked from the README docs table. Co-Authored-By: Claude Opus 4.8 --- README.md | 1 + docs/design/solo_ui_framework.md | 320 +++++++++++++++++++++++++++++++ 2 files changed, 321 insertions(+) create mode 100644 docs/design/solo_ui_framework.md diff --git a/README.md b/README.md index f49f67b3..c75159d8 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,7 @@ Updating to a newer version usually does not require erasing flash unless the re | [Settings Screen](./docs/solo_features/settings_screen/settings_screen.md) | All settings sections with values and interactions | | [Screen Lock](./docs/solo_features/screen_lock/screen_lock.md) | Lock/unlock sequence, lock screen, auto-lock | | [Tools Screen](./docs/solo_features/tools_screen/tools_screen.md) | GPS trail & waypoints, compass, navigation, nearby nodes, ringtone editor, auto-reply bot, auto-advert, live location sharing, locator, diagnostics, repeater | +| [Solo UI framework](./docs/design/solo_ui_framework.md) | **Developer guide** — the reusable building blocks (screens, lists, popups, mini-icons, geo/persistence helpers) and how to add a new feature | ### Upstream MeshCore diff --git a/docs/design/solo_ui_framework.md b/docs/design/solo_ui_framework.md new file mode 100644 index 00000000..41c8c3d2 --- /dev/null +++ b/docs/design/solo_ui_framework.md @@ -0,0 +1,320 @@ +# Solo UI framework — a guide for adding features + +[Go back](../../README.md) + +This is a developer guide to the reusable building blocks behind the +`companion_radio` **solo** firmware UI (the `ui-new` screens). It is not a +user manual — for what each screen *does*, see [solo_features](../solo_features/). +The goal here is so that adding a new screen or feature means *wiring together +existing helpers*, not reinventing list scrolling, text wrapping, or persistence. + +Everything below lives under `examples/companion_radio/` unless a path says +otherwise. The screen fragments (`ui-new/*.h`) are all `#include`d, in order, +into one translation unit (`ui-new/UITask.cpp`) — so a `static inline` helper in +an earlier header is visible to later ones. Header-include order in `UITask.cpp` +therefore matters; new screens go near the others. + +--- + +## 1. The screen model + +Every screen implements `UIScreen` (`src/helpers/ui/UIScreen.h`): + +```cpp +class UIScreen { +public: + virtual int render(DisplayDriver& display) = 0; // returns ms until the next render + virtual bool handleInput(char c) { return false; } + virtual void poll() { } +}; +``` + +- **`render()`** draws one frame and returns *how long until it wants to be + drawn again*, in milliseconds. Return a big number (`2000`) for a static + screen, a small one (`50`–`200`) while something animates or a popup is open. + This return value is the main lever for the e-ink cost/latency trade-off — see + §9. `UITask` owns `startFrame()`/`endFrame()`; **`render()` must not call them.** +- **`handleInput(c)`** gets one key (`KEY_*`, see §7). Return `true` if consumed. +- **`poll()`** runs every loop tick regardless of focus — rare, for background + housekeeping (e.g. the shutdown button). + +Most screens also define a non-virtual **`enter()`** that resets per-visit state +(`_sel = 0`, `_dirty = false`, …). It is called by the screen's `goto…()` wrapper, +not by the base class. + +### Wiring a screen into UITask + +1. Add a `UIScreen* my_screen;` member in `UITask.h` (near the others). +2. Construct it in `UITask::begin()` (`UITask.cpp`): + `my_screen = new MyScreen(this, …);` +3. Add a navigator: + ```cpp + void UITask::gotoMyScreen() { + ((MyScreen*)my_screen)->enter(); + setCurrScreen(my_screen); // sets curr + schedules an immediate redraw + } + ``` +4. Reach it from somewhere — usually a row in `ToolsScreen.h` (add an `Action` + enum value, a row in the right section table, and a `dispatch()` case). + +That's the whole contract. The constructor takes `UITask* task` plus whatever +it needs (`NodePrefs*`, `KeyboardWidget*`, …); the task back-pointer is how a +screen calls shared services (`_task->showAlert(...)`, `_task->waypoints()`, …). + +--- + +## 2. Layout metrics & text (DisplayDriver) + +`DisplayDriver` (`src/helpers/ui/DisplayDriver.h`) abstracts OLED vs e-ink and, +crucially, font scale: landscape e-ink renders text at 2×, so **never hard-code +pixel sizes** — derive everything from these: + +| Call | Meaning | +| --- | --- | +| `getLineHeight()` | pixel rows per text line (8 at 1×, 16 at 2×) | +| `lineStep()` | row pitch = line height + gap; use for row `y` stepping | +| `getCharWidth()` / `getTextWidth(s)` | advance width; `getTextWidth` is font-accurate | +| `headerH()` / `listStart()` | title-bar height / first content row `y` | +| `listVisible(itemH)` | how many rows fit below the header | +| `valCol()` | conventional x for a right-hand value column | +| `width()` / `height()` | panel size in px | +| `isEink()` | true only on landscape e-ink; branch on this, not on pixel counts | + +Drawing helpers (all clip/measure for you): + +- `drawCenteredHeader(title)` — plain centered title + separator. +- `drawInvertedHeader(label)` — filled title bar (used by detail views). +- `drawSelectionRow(x, y, w, h, sel)` — the highlight bar behind a list row. +- `drawTextEllipsized(x, y, max_w, str)` — truncates with `…`; **use this for + any user string** (names, labels) so long/UTF-8 text can't overrun. +- `drawTextCentered(mid_x, y, str)`. +- `translateUTF8ToBlocks(dst, src, n)` — map UTF-8 to the panel's glyph set for + *display only*. Never run text through it before sending it over the air or + storing it (it is lossy) — see the reply-prefix note in §5. + +--- + +## 3. Lists — `drawList` + +`drawList` (`ui-new/icons.h`) is the workhorse for any scrolling list. It +computes the visible window from font metrics, keeps `sel` in view, reserves the +scrollbar column, draws each visible row through your callback, and draws the +indicator: + +```cpp +drawList(display, count, _sel, _scroll, [&](int idx, int y, bool sel, int reserve) { + display.drawSelectionRow(0, y - 1, display.width() - reserve, display.lineStep() - 1, sel); + display.drawTextEllipsized(2, y, display.width() - reserve - 4, items[idx].name); +}); +``` + +`reserve` is the width the scrollbar took (0 when the list fits) — subtract it +from any right-aligned content so nothing slides under the indicator. The row +callback owns its own selection bar (so a screen can style it). For the +fold-in-place pattern (sections that expand/collapse) use `AccordionList` +instead (`ui-new/AccordionList.h`) — same idea, two callbacks (header + item). + +Standalone scroll indicators (`drawScrollIndicator`, `…Px`) and the reserve +calculator (`scrollIndicatorReserve`) are exposed for hand-laid lists. + +--- + +## 4. Reusable components + +| Component | Header | Use for | +| --- | --- | --- | +| `PopupMenu` | `PopupMenu.h` | a modal action menu over any screen | +| `AccordionList` | `AccordionList.h` | collapsible sectioned lists (Tools, Settings) | +| `KeyboardWidget` | `KeyboardWidget.h` | on-screen text entry | +| `DigitEditor` | `DigitEditor.h` | scroll-edit one number, digit by digit | +| `FullscreenMsgView` | `FullscreenMsgView.h` | scrollable full-message reader + word wrap | +| `NavView` | `NavView.h` | bearing/distance/ETA "navigate to a point" view | + +All follow the same shape: a `begin(...)` to open, an `active` flag, a +`handleInput(c)` returning a small `Result` enum, and a `render()`/`draw()`. +Typical embedding: + +```cpp +if (_menu.active) { // popup eats input while open + auto r = _menu.handleInput(c); + if (r == PopupMenu::SELECTED) runAction(_menu.selectedIndex()); + return true; +} +... +_menu.begin("Options", 6); // open it +_menu.addItem("Navigate"); _menu.addItem("Ping"); +_menu.active = true; +``` + +`KeyboardWidget` additionally supports **placeholders** (`{loc}`, `{time}`, +sensor tokens) via `addPlaceholder()` / `clearPlaceholders()`; the shared +`kbAddSensorPlaceholders()` (`ui-new/SensorPlaceholders.h`) adds only the tokens +the board's sensors actually provide. Expand them with `expandMsg()` at send time. + +`FullscreenMsgView::wrapLines()` is a standalone pixel-accurate word-wrapper +(O(n), variable-width-font aware) reusable by any multi-line layout; it writes +into the shared `s_wrap_trans` / `s_wrap_lines` scratch (single-threaded render, +never held across a yield — see §9). + +--- + +## 5. Domain helpers + +**Geo (`GeoUtils.h`, namespace `geo`, all pure/header-inline):** + +- `haversineKm(lat1,lon1,lat2,lon2)`, `bearingDeg(...)`, `bearingCardinal(deg)`. +- `fmtDist(buf,n,km,imperial)` — "850m"/"2.3km" or feet/miles. +- `fmtAgeShort(buf,n,now,ts)` — compact "12s"/"5m"/"3h"/"2d" tag, "" for unknown. + **This is the one age formatter** — don't reimplement the s/m/h ladder. +- `parseLatLon(text, lat, lon, label?, n?)` — pull a `lat,lon` out of message + text; reads the `[WAY]` label if tagged. +- `parseLocShare(text, lat, lon)` — true only for an explicit `[LOC]` share. + +Coordinates are **int32 degrees × 1e6** everywhere (GPS, contacts, trail, +prefs). The message tags are `LOCATION_MSG_TAG` (`[LOC]`, the sender's own live +position) and `WAYPOINT_MSG_TAG` (`[WAY]`, a saved point to share); both stay +human-readable on clients that don't know them. + +**State stores:** `TrailStore` (`Trail.h`, GPS breadcrumb ring + GPX export), +`LiveTrackStore` (`LiveTrack.h`, RAM table of others' `[LOC]` positions, expiring), +`WaypointStore` (`Waypoint.h`, persisted saved points). Reach them via the task +(`_task->trail()`, `_task->liveTrack()`, `_task->waypoints()`). + +**Message reply prefix:** `msgReplyBody(text, nick?, n?)` (`FullscreenMsgView.h`) +parses a leading `@[nick] ` reply marker, returning the body and optionally the +addressee. Use it instead of re-scanning for `@[`. The stored/sent prefix is raw +UTF-8 (it goes over the air) — never transliterate it. + +--- + +## 6. Mini-icons & the status bar + +Small glyphs are authored as ASCII art and packed at compile time +(`ui-new/icons.h`): + +```cpp +MINI_ICON(ICON_FOO, 5, + packRow("..#.."), + packRow(".###."), + packRow("#####")); +``` + +Draw with `miniIconDraw(display, x, topY, ICON_FOO)` (auto-scaled & centered), +`miniIconDrawTop` (exact placement), or the boxed/slot variants +(`drawBoxedIcon` = lit when active, `drawSlotIcon` = plain). Bigger page glyphs +use `BIG_ICON` / `bigIconDraw`. The home status bar composes these right-to-left +with a `blinkOn()` cadence for "leave it on and forget" broadcasts (auto-advert, +Live Share, trail, repeater) — follow that pattern when adding an indicator: +always shown on e-ink, blinking on OLED. + +--- + +## 7. Input + +Keys arrive as the `KEY_*` codes in `UIScreen.h`: `KEY_UP/DOWN/LEFT/RIGHT`, +`KEY_ENTER`, `KEY_CANCEL`, `KEY_CONTEXT_MENU` (the "Hold Enter" menu key). + +Use the **prev/next convention** for value changes so the rotary encoder and the +D-pad agree: `keyIsPrev(c)` (LEFT or encoder-prev) and `keyIsNext(c)` (RIGHT or +encoder-next). `KEY_CANCEL` and `KEY_CONTEXT_MENU` stay screen-specific. Joystick +rotation is handled upstream (`rotateJoystickKey`) — screens see already-rotated +keys. + +--- + +## 8. Persistence + +Device settings live in one `NodePrefs` struct (`NodePrefs.h`), saved via +`the_mesh.savePrefs()` and loaded by `DataStore.cpp`. Rules when adding a field: + +- **Append only**, and bump `NodePrefs::SCHEMA_SENTINEL`. Serialization is + binary-positional, so order is the on-disk format; never insert in the middle. +- Add a matching `rd(...)` in `DataStore::loadPrefsInt()` and a `file.write(...)` + in `savePrefs()`, in the same position, and **clamp on load** (an upgrader's + file lacks the field and reads stray bytes — clamp to a sane default). Saves + are atomic (temp-file + rename), so a crash mid-save can't corrupt settings. + +**The `_dirty` convention:** a multi-field editor screen mutates `_node_prefs` +live for instant feedback but only calls `savePrefs()` once, on exit, gated by a +`_dirty` flag — so LEFT/RIGHT value-cycling doesn't thrash flash. A one-shot +action from a popup (no exit hook) saves immediately. Follow whichever matches +your screen. + +**The shared "active target"** (Locator/Nav destination) is set through +`UITask::setTarget()` (defines it), `setTargetNow()` (defines + saves + toast), +or `clearTarget()` — one definition used by the Locator screen, the map, and the +Nearby/Waypoints "Set as target" actions. Resolve a person's current position +with `resolvePersonPos()` (live `[LOC]` share, else last-advertised fix). + +--- + +## 9. Conventions & gotchas + +- **Single-threaded render.** Rendering and input run on one thread, so shared + static scratch (`s_wrap_*`) is safe *as long as it's never held across a + yield*. Don't add scratch that outlives one `render()`. +- **e-ink blocks.** `endFrame()` on e-ink stalls the main loop for hundreds of + ms. Keep `render()`'s return value honest so the panel isn't redrawn more than + needed, and don't depend on `loop()` cadence for timing that must be exact + (the ringtone player moved to a hardware timer for this reason). +- **Reference cleanup.** Anything that remembers a contact by pubkey (favourite + slot, Locator/Live-Share target, per-contact mute/melody) or a channel by + index must drop that reference when the entity goes away — hook + `UITask::onContactRemoved()` / `onChannelRemoved()`. New per-contact or + per-channel state should clear there too. +- **Toasts:** `_task->showAlert("msg", duration_ms)` overlays a transient banner + over any screen; no redraw plumbing needed. +- **Strings:** always `strncpy`+NUL or `snprintf`; treat every name/label as + untrusted-length and render through `drawTextEllipsized`. + +--- + +## 10. Worked example — a new Tools screen + +```cpp +// ui-new/MyToolScreen.h — included by UITask.cpp near the other screens +#pragma once +#include "icons.h" // drawList + mini-icons +#include "../NodePrefs.h" + +class MyToolScreen : public UIScreen { + UITask* _task; + NodePrefs* _prefs; + int _sel = 0, _scroll = 0; + bool _dirty = false; + static const int ROWS = 3; +public: + MyToolScreen(UITask* t, NodePrefs* p) : _task(t), _prefs(p) {} + void enter() { _sel = 0; _scroll = 0; _dirty = false; } + + int render(DisplayDriver& d) override { + d.setTextSize(1); + d.drawCenteredHeader("MY TOOL"); + drawList(d, ROWS, _sel, _scroll, [&](int i, int y, bool sel, int reserve) { + d.drawSelectionRow(0, y - 1, d.width() - reserve, d.lineStep() - 1, sel); + d.setCursor(4, y); + d.print(i == 0 ? "Alpha" : i == 1 ? "Bravo" : "Charlie"); + }); + return 500; + } + + bool handleInput(char c) override { + if (c == KEY_CANCEL) { + if (_dirty) { the_mesh.savePrefs(); _dirty = false; } + _task->gotoToolsScreen(); + return true; + } + if (c == KEY_UP) { _sel = (_sel + ROWS - 1) % ROWS; return true; } + if (c == KEY_DOWN) { _sel = (_sel + 1) % ROWS; return true; } + if (keyIsPrev(c) || keyIsNext(c) || c == KEY_ENTER) { + /* mutate _prefs…, set _dirty = true */ return true; + } + return false; + } +}; +``` + +Then: `#include "MyToolScreen.h"` in `UITask.cpp`, add the member + constructor + +`gotoMyToolScreen()` (§1), and add a row in `ToolsScreen.h`. Done — scrolling, +the scrollbar, font scaling, e-ink pacing and persistence batching all come from +the framework. From 089048a036037cd6bc562ed4a7999e3cdaca291f Mon Sep 17 00:00:00 2001 From: MarekZegare4 Date: Mon, 29 Jun 2026 18:01:29 +0200 Subject: [PATCH 14/29] refactor(ui): align screens to the documented UI conventions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit pass against docs/design/solo_ui_framework.md, converging deviations: - prev/next: replace hand-rolled (c == KEY_LEFT || c == KEY_PREV) with keyIsPrev()/keyIsNext() in QuickMsgScreen, RingtoneEditor, TrailScreen, FullscreenMsgView. Two were real rotary-encoder gaps, not just style: NearbyScreen's filter and WaypointsView's hemisphere toggle used raw KEY_LEFT/RIGHT with no encoder twin, so the encoder couldn't drive them. - NearbyScreen's list right column used a manual setCursor(width - getTextWidth(...)) instead of the existing drawTextRightAlign() helper (which also UTF-8-translates) — now uses it, like Diagnostics/Repeater. - RepeaterScreen hand-rolled the whole drawList skeleton (scroll clamp + reserve + row loop + indicator); collapsed onto drawList(). No D-pad behaviour change; more uniform encoder support. Net -19 lines. Co-Authored-By: Claude Opus 4.8 --- .../ui-new/FullscreenMsgView.h | 4 +-- .../companion_radio/ui-new/NearbyScreen.h | 7 +++-- .../companion_radio/ui-new/QuickMsgScreen.h | 8 +++--- .../companion_radio/ui-new/RepeaterScreen.h | 26 +++---------------- .../ui-new/RingtoneEditorScreen.h | 4 +-- examples/companion_radio/ui-new/TrailScreen.h | 8 +++--- .../companion_radio/ui-new/WaypointsView.h | 2 +- 7 files changed, 20 insertions(+), 39 deletions(-) diff --git a/examples/companion_radio/ui-new/FullscreenMsgView.h b/examples/companion_radio/ui-new/FullscreenMsgView.h index 30bcdff4..ed84dd9c 100644 --- a/examples/companion_radio/ui-new/FullscreenMsgView.h +++ b/examples/companion_radio/ui-new/FullscreenMsgView.h @@ -156,8 +156,8 @@ struct FullscreenMsgView { Result handleInput(char c) { if (c == KEY_UP) { if (scroll > 0) scroll--; return NONE; } if (c == KEY_DOWN) { if (scroll < _max_scroll) scroll++; return NONE; } - if (c == KEY_LEFT) return NEXT; - if (c == KEY_RIGHT) return PREV; + if (keyIsPrev(c)) return NEXT; // page between messages (encoder too) + if (keyIsNext(c)) return PREV; if (c == KEY_CONTEXT_MENU) return REPLY; if (c == KEY_ENTER || c == KEY_CANCEL) return CLOSE; return NONE; diff --git a/examples/companion_radio/ui-new/NearbyScreen.h b/examples/companion_radio/ui-new/NearbyScreen.h index a971b4ee..d07b0f5d 100644 --- a/examples/companion_radio/ui-new/NearbyScreen.h +++ b/examples/companion_radio/ui-new/NearbyScreen.h @@ -715,8 +715,7 @@ public: if (e.dist_km >= 0.0f) geo::fmtDist(right, sizeof(right), e.dist_km, useImperial()); else strncpy(right, "?GPS", sizeof(right)); } - display.setCursor(display.width() - display.getTextWidth(right) - 2 - reserve, y); - display.print(right); + display.drawTextRightAlign(display.width() - reserve - 2, y, right); }); } @@ -779,8 +778,8 @@ public: _detail_refresh_ms = millis(); return true; } - if (c == KEY_LEFT) { _filter = (_filter + F_COUNT - 1) % F_COUNT; refresh(); return true; } - if (c == KEY_RIGHT) { _filter = (_filter + 1) % F_COUNT; refresh(); return true; } + if (keyIsPrev(c)) { _filter = (_filter + F_COUNT - 1) % F_COUNT; refresh(); return true; } + if (keyIsNext(c)) { _filter = (_filter + 1) % F_COUNT; refresh(); return true; } return false; } }; diff --git a/examples/companion_radio/ui-new/QuickMsgScreen.h b/examples/companion_radio/ui-new/QuickMsgScreen.h index 183ce51f..0f8317ea 100644 --- a/examples/companion_radio/ui-new/QuickMsgScreen.h +++ b/examples/companion_radio/ui-new/QuickMsgScreen.h @@ -1518,8 +1518,8 @@ public: } // LEFT/RIGHT cycle Notif/Melody in-place (menu stays open). if (!_pin_picker_active && _num_contacts > 0) { - bool left = (c == KEY_LEFT || c == KEY_PREV); - bool right = (c == KEY_RIGHT || c == KEY_NEXT); + bool left = keyIsPrev(c); + bool right = keyIsNext(c); if (left || right) { static const char* NOTIF_LABELS[] = { "default", "OFF", "ON" }; static const char* ML[] = { "global", "M1", "M2" }; @@ -1674,8 +1674,8 @@ public: if (_ctx_menu.active) { // LEFT/RIGHT cycle Notif/Melody/Fav in-place (menu stays open). if (_num_channels > 0) { - bool left = (c == KEY_LEFT || c == KEY_PREV); - bool right = (c == KEY_RIGHT || c == KEY_NEXT); + bool left = keyIsPrev(c); + bool right = keyIsNext(c); if (left || right) { static const char* NOTIF_LABELS[] = { "default", "OFF", "ON" }; static const char* ML[] = { "global", "M1", "M2" }; diff --git a/examples/companion_radio/ui-new/RepeaterScreen.h b/examples/companion_radio/ui-new/RepeaterScreen.h index 3e546634..b606d059 100644 --- a/examples/companion_radio/ui-new/RepeaterScreen.h +++ b/examples/companion_radio/ui-new/RepeaterScreen.h @@ -143,39 +143,21 @@ public: display.setColor(DisplayDriver::LIGHT); display.drawCenteredHeader("REPEATER"); - const int item_h = display.lineStep(); - const int start_y = display.listStart(); - int visible = display.listVisible(item_h); - if (visible < 1) visible = 1; - // Config only — live forwarding stats live on Tools › Diagnostics. - int total = _item_count; - if (_sel < _scroll) _scroll = _sel; - if (_sel >= _scroll + visible) _scroll = _sel - visible + 1; - int max_scroll = total - visible; - if (max_scroll < 0) max_scroll = 0; - if (_scroll > max_scroll) _scroll = max_scroll; - if (_scroll < 0) _scroll = 0; - - const int reserve = scrollIndicatorReserve(display, total, visible); - for (int i = 0; i < visible && (_scroll + i) < total; i++) { - int row = _scroll + i; - int y = start_y + i * item_h; - char val[16]; - bool sel = (row == _sel); + drawList(display, _item_count, _sel, _scroll, [&](int row, int y, bool sel, int reserve) { int item = _items[row]; - display.drawSelectionRow(0, y - 1, display.width() - reserve, item_h - 1, sel); + display.drawSelectionRow(0, y - 1, display.width() - reserve, display.lineStep() - 1, sel); display.setCursor(2, y); display.print(itemLabel(item)); if (item == IT_RFREQ && sel && _editor.active()) { _editor.render(display, display.valCol(), y); } else { + char val[16]; itemValue(item, p, val, sizeof(val)); display.drawTextRightAlign(display.width() - reserve - 2, y, val); } display.setColor(DisplayDriver::LIGHT); - } - drawScrollIndicator(display, start_y, visible * item_h, total, visible, _scroll); + }); display.setColor(DisplayDriver::LIGHT); if (_picker.menu.active) _picker.menu.render(display); return (_picker.menu.active || _editor.active()) ? 50 : 500; diff --git a/examples/companion_radio/ui-new/RingtoneEditorScreen.h b/examples/companion_radio/ui-new/RingtoneEditorScreen.h index 8b9de323..26ac7696 100644 --- a/examples/companion_radio/ui-new/RingtoneEditorScreen.h +++ b/examples/companion_radio/ui-new/RingtoneEditorScreen.h @@ -164,8 +164,8 @@ public: bool handleInput(char c) override { bool up = (c == KEY_UP); bool down = (c == KEY_DOWN); - bool left = (c == KEY_LEFT || c == KEY_PREV); - bool right = (c == KEY_RIGHT || c == KEY_NEXT); + bool left = keyIsPrev(c); + bool right = keyIsNext(c); bool enter = (c == KEY_ENTER); bool menu_key = (c == KEY_CONTEXT_MENU); bool cancel = (c == KEY_CANCEL); diff --git a/examples/companion_radio/ui-new/TrailScreen.h b/examples/companion_radio/ui-new/TrailScreen.h index fc3b05eb..1f2d2e2f 100644 --- a/examples/companion_radio/ui-new/TrailScreen.h +++ b/examples/companion_radio/ui-new/TrailScreen.h @@ -143,8 +143,8 @@ public: if (_action_menu.active) { // LEFT/RIGHT cycles the focused value — only meaningful in the Settings // submenu; the popup stays open so the user can keep tapping. - if (c == KEY_LEFT || c == KEY_RIGHT || c == KEY_PREV || c == KEY_NEXT) { - int dir = (c == KEY_RIGHT || c == KEY_NEXT) ? 1 : -1; + if (keyIsPrev(c) || keyIsNext(c)) { + int dir = keyIsNext(c) ? 1 : -1; int idx = _action_menu.selectedIndex(); if (idx >= 0 && idx < _act_count && _menu_level == ML_SETTINGS) cycleSetting((ActionId)_act_map[idx], dir); @@ -207,8 +207,8 @@ public: } if (c == KEY_CONTEXT_MENU) { openActionMenu(); return true; } - if (c == KEY_LEFT || c == KEY_PREV) { _view = (uint8_t)((_view + V_COUNT - 1) % V_COUNT); return true; } - if (c == KEY_RIGHT || c == KEY_NEXT) { _view = (uint8_t)((_view + 1) % V_COUNT); return true; } + if (keyIsPrev(c)) { _view = (uint8_t)((_view + V_COUNT - 1) % V_COUNT); return true; } + if (keyIsNext(c)) { _view = (uint8_t)((_view + 1) % V_COUNT); return true; } if (_view == V_SUMMARY && c == KEY_UP) { _summary_scroll = (_summary_scroll > 0) ? _summary_scroll - 1 : _summary_max_scroll; return true; diff --git a/examples/companion_radio/ui-new/WaypointsView.h b/examples/companion_radio/ui-new/WaypointsView.h index 76e4b95d..5af1d8c9 100644 --- a/examples/companion_radio/ui-new/WaypointsView.h +++ b/examples/companion_radio/ui-new/WaypointsView.h @@ -309,7 +309,7 @@ public: if (c == KEY_CANCEL) { _mode = OFF; return true; } if (c == KEY_UP) { _add_sel = (_add_sel > 0) ? _add_sel - 1 : 3; return true; } if (c == KEY_DOWN) { _add_sel = (_add_sel < 3) ? _add_sel + 1 : 0; return true; } - if (c == KEY_LEFT || c == KEY_RIGHT) { + if (keyIsPrev(c) || keyIsNext(c)) { if (_add_sel == 0) _add_lat_neg = !_add_lat_neg; // N <-> S else if (_add_sel == 1) _add_lon_neg = !_add_lon_neg; // E <-> W return true; From 325b200d072680e2fc44951f7958b8d6053eca63 Mon Sep 17 00:00:00 2001 From: MarekZegare4 Date: Mon, 29 Jun 2026 18:13:05 +0200 Subject: [PATCH 15/29] fix(companion): compile-time tripwire for NodePrefs serialization drift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NodePrefs is written/read field-by-field in DataStore::savePrefs/loadPrefsInt; the on-disk format is the struct's field layout, with nothing checking that the two hand-written sequences still match the struct. A forgotten read/write silently misaligns every later field — the highest-blast-radius convention in the codebase. Add a static_assert on sizeof(NodePrefs): changing a data member trips it with a checklist (sync save/load, clamp on load, bump SCHEMA_SENTINEL, update the size). A manual checkpoint, not a proof, but it turns silent drift into a build break. Size is variant-stable (all arrays sized by NodePrefs' own constants), verified on both nRF52 boards. Co-Authored-By: Claude Opus 4.8 --- examples/companion_radio/NodePrefs.h | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 45b131b6..09ba91c9 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -385,6 +385,23 @@ struct NodePrefs { // persisted to file } }; +// ── Serialization tripwire ─────────────────────────────────────────────────── +// NodePrefs is written/read field-by-field, in order, by DataStore::savePrefs() +// and loadPrefsInt(); the on-disk format IS the struct's field layout. There is +// no automatic check that those two hand-written sequences match the struct, so +// a forgotten read/write silently misaligns EVERY field after it. +// +// This assert is the manual checkpoint. Changing a data member changes sizeof +// and trips it. When it trips, do ALL of the following, then update the number: +// 1. add the field's rd(...) in DataStore::loadPrefsInt(), in struct order +// 2. add the field's write(...) in DataStore::savePrefs(), in struct order +// 3. clamp it on load (an upgrader's file lacks it → stray bytes) +// 4. bump SCHEMA_SENTINEL's low byte +// (Padding can also shift sizeof; a "false" trip just means re-check + rebump.) +static_assert(sizeof(NodePrefs) == 2488, + "NodePrefs layout changed — sync DataStore save/load + clamp, bump " + "SCHEMA_SENTINEL, then update this size (see steps above)."); + // Bounds for a usable repeater radio profile. The frequency range is passed in // by the caller from RadioLibWrapper::getFreqBounds() — the radio chip's own // validated range — so a profile that passes here is guaranteed to actually take From b624d03e966eab96b5f9b3119133bee6baba74b3 Mon Sep 17 00:00:00 2001 From: MarekZegare4 Date: Mon, 29 Jun 2026 18:17:38 +0200 Subject: [PATCH 16/29] docs(companion): make the entity-reference cleanup contract explicit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The onContactRemoved/onChannelRemoved hooks already clear every NodePrefs field keyed on a contact pubkey or channel index (verified complete), but the convention was opt-in with no reminder — new per-entity state could silently forget to register, the class of bug fixed earlier this session. Add a CONTRACT comment above each hook listing what it covers and instructing additions to land there, plus a terse [del->onContactRemoved] / [del->onChannelRemoved] tag on each of the eight keyed fields in NodePrefs.h. Now editing either side (struct field or hook) points at the other. Comments only; no behaviour change. Co-Authored-By: Claude Opus 4.8 --- examples/companion_radio/NodePrefs.h | 16 ++++++++-------- examples/companion_radio/ui-new/UITask.cpp | 10 ++++++++++ 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 09ba91c9..545e8991 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -87,7 +87,7 @@ struct NodePrefs { // persisted to file uint16_t home_pages_mask; // bitmask of visible home pages (bit0=Clock..bit8=Shutdown); 0=all visible uint8_t bot_enabled; // 0=disabled, 1=DM bot active (responds to all DMs) uint8_t bot_channel_enabled; // 0=disabled, 1=channel bot active for bot_channel_idx - uint8_t bot_channel_idx; // channel index for channel bot + uint8_t bot_channel_idx; // channel index for channel bot [del→onChannelRemoved] char bot_trigger[64]; // DM trigger phrase (case-insensitive contains; "*" = any DM) char bot_reply_dm[140]; // auto-reply text for DM char bot_reply_ch[140]; // auto-reply text for channel @@ -100,7 +100,7 @@ struct NodePrefs { // persisted to file uint8_t buzzer_auto; // 0=manual (default), 1=auto-mute when BT connected struct DmNotifEntry { uint8_t prefix[4]; uint8_t state; }; // state: 0=default,1=muted,2=force-on static const int DM_NOTIF_TABLE_MAX = 16; - DmNotifEntry dm_notif[DM_NOTIF_TABLE_MAX]; // 16*5 = 80 bytes + DmNotifEntry dm_notif[DM_NOTIF_TABLE_MAX]; // 16*5 = 80 bytes [del→onContactRemoved] uint8_t dashboard_fields[3]; // 0=None,1=Batt V,2=Temp,3=Hum,4=Pres,5=GPS,6=Alt,7=Lux,8=CO2,9=Nodes,10=Msgs,11=Batt % uint32_t advert_auto_interval_sec; // periodic 0-hop advert with GPS: 0=off, else seconds // Second melody slot (same packing as ringtone_*) @@ -114,12 +114,12 @@ struct NodePrefs { // persisted to file // Advert sound filter: 0=all adverts, 1=zero-hop only uint8_t advert_sound_scope; // Per-channel melody override (2 bitmasks, 1 bit per channel) - uint64_t ch_notif_melody_set; // bit i = channel i has explicit melody + uint64_t ch_notif_melody_set; // bit i = channel i has explicit melody [del→onChannelRemoved] uint64_t ch_notif_melody_2; // bit i = use melody 2 (else melody 1, when set bit is set) // Per-DM melody table struct DmMelodyEntry { uint8_t prefix[4]; uint8_t slot; }; // slot: 0=global,1=melody1,2=melody2 static const int DM_MELODY_TABLE_MAX = 16; - DmMelodyEntry dm_melody[DM_MELODY_TABLE_MAX]; + DmMelodyEntry dm_melody[DM_MELODY_TABLE_MAX]; // [del→onContactRemoved] uint8_t use_lemon_font; // 0=default Adafruit font, 1=Lemon font (Unicode, pixel-accurate wrap) uint8_t display_rotation; // 0-3; only used on e-ink displays // Home screen page order: each byte = HomePageBit + 1. 0 terminates the list. @@ -138,7 +138,7 @@ struct NodePrefs { // persisted to file // Layout transposes between landscape (3×2) and portrait (2×3). static const uint8_t FAVOURITES_COUNT = 6; static const uint8_t FAVOURITE_PREFIX_LEN = 6; - uint8_t favourite_contacts[FAVOURITES_COUNT][FAVOURITE_PREFIX_LEN]; + uint8_t favourite_contacts[FAVOURITES_COUNT][FAVOURITE_PREFIX_LEN]; // [del→onContactRemoved] // GPS trail cadence. Logging on/off is a runtime state (Tools › Trail), // not a persisted preference. @@ -229,8 +229,8 @@ struct NodePrefs { // persisted to file // Configured from the Map (Trail screen) "Live share" menu. uint8_t loc_share_enabled; // 0=off (default), 1=auto-sharing on uint8_t loc_share_target_type; // 0=channel, 1=DM contact - uint8_t loc_share_channel_idx; // target channel index (when target_type==0) - uint8_t loc_share_dm_prefix[6]; // target contact pubkey prefix (when target_type==1) + uint8_t loc_share_channel_idx; // target channel index (when target_type==0) [del→onChannelRemoved] + uint8_t loc_share_dm_prefix[6]; // target contact pubkey prefix (when target_type==1) [del→onContactRemoved] uint8_t loc_share_move_idx; // movement gate level (index into locShareMoveMeters) uint8_t loc_share_interval_idx; // min send interval (index into locShareIntervalSecs) uint8_t loc_share_heartbeat_idx; // stationary heartbeat (index into locShareHeartbeatSecs) @@ -251,7 +251,7 @@ struct NodePrefs { // persisted to file // re-reads the latest [LOC] position each evaluation (keyed by pubkey prefix), // so the geofence follows a moving person ("alert when my friend is near"). uint8_t locator_target_kind; // 0=waypoint (static), 1=live contact - uint8_t locator_key[6]; // contact pubkey prefix when target_kind==1 + uint8_t locator_key[6]; // contact pubkey prefix when target_kind==1 [del→onContactRemoved] // Trail auto-pause — when tracking, automatically freeze the trail (timer + // sampling) after the device has sat still for this long, and resume on the diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index bb5046a1..7ad34643 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -2406,6 +2406,12 @@ void UITask::clearTargetIfWaypoint(int32_t lat_1e6, int32_t lon_1e6) { the_mesh.savePrefs(); } +// CONTRACT: every NodePrefs field that keys on a contact pubkey/prefix is +// cleared here, so a removed contact can't leave a dangling reference. If you +// add such a field, add its cleanup below (and mark the field in NodePrefs.h). +// Currently covered: favourite_contacts, locator_key, loc_share_dm_prefix, +// dm_notif[], dm_melody[]. Called for both explicit removal and silent +// auto-eviction (see MyMesh CMD_REMOVE_CONTACT / onContactOverwrite). void UITask::onContactRemoved(const uint8_t* pub_key) { if (!_node_prefs || !pub_key) return; bool changed = false; @@ -2445,6 +2451,10 @@ void UITask::onContactRemoved(const uint8_t* pub_key) { if (changed) the_mesh.savePrefs(); } +// CONTRACT: every NodePrefs field that keys on a channel index is cleared here, +// so a channel re-added at a freed slot can't inherit the old one's settings. +// If you add such a field, add its cleanup below (and mark it in NodePrefs.h). +// Currently covered: bot_channel_idx, loc_share_channel_idx, ch_notif_melody_*. void UITask::onChannelRemoved(uint8_t channel_idx) { if (!_node_prefs) return; bool changed = false; From bcca97a84878a4ba050274b969536a2946920ddb Mon Sep 17 00:00:00 2001 From: MarekZegare4 Date: Mon, 29 Jun 2026 18:20:47 +0200 Subject: [PATCH 17/29] docs(companion): document the screen-fragment single-TU contract The ui-new/*.h screens are header fragments compiled only as part of UITask.cpp, in include order. Two implicit rules a contributor can trip on: include order (a static inline helper / shared scratch is visible only to later fragments) and single-TU-only (some fragments define external-linkage symbols at file scope, e.g. NearbyScreen::FILTER_LABELS, so reusing one from a second .cpp is a duplicate-symbol link error). Spell both out in a contract comment at the inclusion block, and raise the single-TU note to a callout in the framework guide. Comments only. Co-Authored-By: Claude Opus 4.8 --- docs/design/solo_ui_framework.md | 7 +++++++ examples/companion_radio/ui-new/UITask.cpp | 11 +++++++++++ 2 files changed, 18 insertions(+) diff --git a/docs/design/solo_ui_framework.md b/docs/design/solo_ui_framework.md index 41c8c3d2..a684e019 100644 --- a/docs/design/solo_ui_framework.md +++ b/docs/design/solo_ui_framework.md @@ -14,6 +14,13 @@ into one translation unit (`ui-new/UITask.cpp`) — so a `static inline` helper an earlier header is visible to later ones. Header-include order in `UITask.cpp` therefore matters; new screens go near the others. +> **Single-TU only.** These fragments compile *only* as part of `UITask.cpp`. +> Some define external-linkage symbols at file scope (e.g. +> `NearbyScreen::FILTER_LABELS`), so including a fragment from a second `.cpp` +> is a duplicate-symbol link error. Anything genuinely shared across TUs must +> live in a real header (`icons.h`, `GeoUtils.h`, `DisplayDriver.h`), not a +> screen fragment. + --- ## 1. The screen model diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 7ad34643..ac666167 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -116,6 +116,17 @@ public: static const int QUICK_MSGS_MAX = 10; +// ── Screen fragments — included into THIS translation unit only ─────────────── +// These headers are not standalone: they are compiled solely as part of +// UITask.cpp, in the order below. Two consequences a new screen must respect: +// • Order matters. A `static inline` helper (drawList, msgReplyBody, geo::…) +// or a shared scratch buffer (FullscreenMsgView's s_wrap_*) is only visible +// to fragments included *after* the one that defines it. Add new screens +// after their dependencies. +// • Single-TU only. Some fragments define external-linkage symbols at file +// scope (e.g. NearbyScreen::FILTER_LABELS), so including any of them from a +// second .cpp is a duplicate-symbol link error. Keep them UITask-internal; +// anything genuinely shareable belongs in a real header (icons.h, GeoUtils.h). #include "FullscreenMsgView.h" #include "SensorPlaceholders.h" #include "SettingsScreen.h" From eedd47d1e1613ac8e76f6485c9e9052a877d55fa Mon Sep 17 00:00:00 2001 From: MarekZegare4 Date: Mon, 29 Jun 2026 18:37:04 +0200 Subject: [PATCH 18/29] refactor(companion): hoist screen entry into virtual UIScreen::onShow() Replace the ad-hoc enter()/markClean() entry methods (which lived outside the UIScreen interface and were invoked via casts from each gotoX) with a virtual onShow() lifecycle hook called centrally by setCurrScreen(). This removes the "forgot to call enter() in a new navigator" footgun and the unchecked cast smell: 12 navigators collapse to one-line setCurrScreen(x) calls, and override enforces signature match. Two entries that carry a parameter/variant keep an explicit typed call after setCurrScreen(): RingtoneEditor::selectSlot(slot) and TrailScreen::showMapView(). Behaviour-preserving: only screens that previously had enter() get an onShow() override; Splash/Home/QuickMsg/Diag keep no reset as before. Co-Authored-By: Claude Opus 4.8 --- docs/design/solo_ui_framework.md | 21 ++--- .../companion_radio/ui-new/AutoAdvertScreen.h | 2 +- examples/companion_radio/ui-new/BotScreen.h | 2 +- .../companion_radio/ui-new/CompassScreen.h | 2 +- .../ui-new/DashboardConfigScreen.h | 2 +- .../companion_radio/ui-new/LiveShareScreen.h | 2 +- .../companion_radio/ui-new/LocatorScreen.h | 2 +- .../companion_radio/ui-new/NearbyScreen.h | 2 +- .../companion_radio/ui-new/RepeaterScreen.h | 2 +- .../ui-new/RingtoneEditorScreen.h | 6 +- .../companion_radio/ui-new/SettingsScreen.h | 2 +- examples/companion_radio/ui-new/ToolsScreen.h | 2 +- examples/companion_radio/ui-new/TrailScreen.h | 9 ++- examples/companion_radio/ui-new/UITask.cpp | 79 +++++-------------- src/helpers/ui/UIScreen.h | 5 ++ 15 files changed, 55 insertions(+), 85 deletions(-) diff --git a/docs/design/solo_ui_framework.md b/docs/design/solo_ui_framework.md index a684e019..7771a4b5 100644 --- a/docs/design/solo_ui_framework.md +++ b/docs/design/solo_ui_framework.md @@ -33,6 +33,7 @@ public: virtual int render(DisplayDriver& display) = 0; // returns ms until the next render virtual bool handleInput(char c) { return false; } virtual void poll() { } + virtual void onShow() { } // reset per-visit state }; ``` @@ -44,23 +45,23 @@ public: - **`handleInput(c)`** gets one key (`KEY_*`, see §7). Return `true` if consumed. - **`poll()`** runs every loop tick regardless of focus — rare, for background housekeeping (e.g. the shutdown button). - -Most screens also define a non-virtual **`enter()`** that resets per-visit state -(`_sel = 0`, `_dirty = false`, …). It is called by the screen's `goto…()` wrapper, -not by the base class. +- **`onShow()`** is called by `setCurrScreen()` every time the screen becomes + current — override it to reset per-visit state (`_sel = 0`, `_dirty = false`, + sub-views). Default no-op for screens that keep state across visits. Because + it's invoked centrally, a navigator can't forget to reset on show. ### Wiring a screen into UITask 1. Add a `UIScreen* my_screen;` member in `UITask.h` (near the others). 2. Construct it in `UITask::begin()` (`UITask.cpp`): `my_screen = new MyScreen(this, …);` -3. Add a navigator: +3. Add a navigator — usually just the one line (the cast-free `onShow()` runs + inside `setCurrScreen`): ```cpp - void UITask::gotoMyScreen() { - ((MyScreen*)my_screen)->enter(); - setCurrScreen(my_screen); // sets curr + schedules an immediate redraw - } + void UITask::gotoMyScreen() { setCurrScreen(my_screen); } ``` + Only screens needing a *parameter* at entry add a typed call after it (e.g. + `gotoRingtoneEditor` → `selectSlot(slot)`, `gotoMapScreen` → `showMapView()`). 4. Reach it from somewhere — usually a row in `ToolsScreen.h` (add an `Action` enum value, a row in the right section table, and a `dispatch()` case). @@ -292,7 +293,7 @@ class MyToolScreen : public UIScreen { static const int ROWS = 3; public: MyToolScreen(UITask* t, NodePrefs* p) : _task(t), _prefs(p) {} - void enter() { _sel = 0; _scroll = 0; _dirty = false; } + void onShow() override { _sel = 0; _scroll = 0; _dirty = false; } int render(DisplayDriver& d) override { d.setTextSize(1); diff --git a/examples/companion_radio/ui-new/AutoAdvertScreen.h b/examples/companion_radio/ui-new/AutoAdvertScreen.h index f6c0a286..22aa4cc0 100644 --- a/examples/companion_radio/ui-new/AutoAdvertScreen.h +++ b/examples/companion_radio/ui-new/AutoAdvertScreen.h @@ -20,7 +20,7 @@ class AutoAdvertScreen : public UIScreen { public: AutoAdvertScreen(UITask* task, NodePrefs* prefs) : _task(task), _prefs(prefs) {} - void enter() { _dirty = false; } + void onShow() override { _dirty = false; } int render(DisplayDriver& display) override { display.setTextSize(1); diff --git a/examples/companion_radio/ui-new/BotScreen.h b/examples/companion_radio/ui-new/BotScreen.h index 2b153a90..c43441ea 100644 --- a/examples/companion_radio/ui-new/BotScreen.h +++ b/examples/companion_radio/ui-new/BotScreen.h @@ -42,7 +42,7 @@ class BotScreen : public UIScreen { public: BotScreen(UITask* task, NodePrefs* prefs, KeyboardWidget* kb) : _task(task), _prefs(prefs), _kb(kb) {} - void enter() { + void onShow() override { _sel = 0; _scroll = 0; _kb_field = -1; diff --git a/examples/companion_radio/ui-new/CompassScreen.h b/examples/companion_radio/ui-new/CompassScreen.h index dcadb5af..c37ea150 100644 --- a/examples/companion_radio/ui-new/CompassScreen.h +++ b/examples/companion_radio/ui-new/CompassScreen.h @@ -27,7 +27,7 @@ class CompassScreen : public UIScreen { public: CompassScreen(UITask* task) : _task(task) {} - void enter() {} + void onShow() override {} int render(DisplayDriver& display) override { display.setTextSize(1); diff --git a/examples/companion_radio/ui-new/DashboardConfigScreen.h b/examples/companion_radio/ui-new/DashboardConfigScreen.h index a370f05e..88278d5a 100644 --- a/examples/companion_radio/ui-new/DashboardConfigScreen.h +++ b/examples/companion_radio/ui-new/DashboardConfigScreen.h @@ -37,7 +37,7 @@ class DashboardConfigScreen : public UIScreen { public: DashboardConfigScreen(UITask* task, NodePrefs* prefs) : _task(task), _prefs(prefs) {} - void enter() { _sel = 0; _dirty = false; } + void onShow() override { _sel = 0; _dirty = false; } int render(DisplayDriver& display) override { display.setTextSize(1); diff --git a/examples/companion_radio/ui-new/LiveShareScreen.h b/examples/companion_radio/ui-new/LiveShareScreen.h index 83b33878..40e73b33 100644 --- a/examples/companion_radio/ui-new/LiveShareScreen.h +++ b/examples/companion_radio/ui-new/LiveShareScreen.h @@ -36,7 +36,7 @@ class LiveShareScreen : public UIScreen { public: LiveShareScreen(UITask* task, NodePrefs* prefs) : _task(task), _prefs(prefs) {} - void enter() { _dirty = false; _sel = 0; _scroll = 0; } + void onShow() override { _dirty = false; _sel = 0; _scroll = 0; } // Resolve the configured target's display name (channel name or contact name). void currentTargetName(char* buf, int n) { diff --git a/examples/companion_radio/ui-new/LocatorScreen.h b/examples/companion_radio/ui-new/LocatorScreen.h index b6dd5112..7034b3ce 100644 --- a/examples/companion_radio/ui-new/LocatorScreen.h +++ b/examples/companion_radio/ui-new/LocatorScreen.h @@ -72,7 +72,7 @@ class LocatorScreen : public UIScreen { public: LocatorScreen(UITask* task, NodePrefs* prefs) : _task(task), _prefs(prefs) {} - void enter() { _dirty = false; _sel = 0; _scroll = 0; _picking = false; } + void onShow() override { _dirty = false; _sel = 0; _scroll = 0; _picking = false; } void valueLabel(Kind k, char* buf, int n) { switch (k) { diff --git a/examples/companion_radio/ui-new/NearbyScreen.h b/examples/companion_radio/ui-new/NearbyScreen.h index d07b0f5d..eb21c3b3 100644 --- a/examples/companion_radio/ui-new/NearbyScreen.h +++ b/examples/companion_radio/ui-new/NearbyScreen.h @@ -587,7 +587,7 @@ public: _sort_label[0] = '\0'; } - void enter() { + void onShow() override { _sel = _scroll = 0; _detail = false; _nav = false; diff --git a/examples/companion_radio/ui-new/RepeaterScreen.h b/examples/companion_radio/ui-new/RepeaterScreen.h index b606d059..66619bf7 100644 --- a/examples/companion_radio/ui-new/RepeaterScreen.h +++ b/examples/companion_radio/ui-new/RepeaterScreen.h @@ -128,7 +128,7 @@ class RepeaterScreen : public UIScreen { public: RepeaterScreen(UITask* task) : _task(task), _dirty(false), _sel(0), _scroll(0), _item_count(1) {} - void enter() { + void onShow() override { _dirty = false; _sel = 0; _scroll = 0; _picker.menu.active = false; _editor.freq.active = false; _picker.saving = false; _picker.deleting = false; diff --git a/examples/companion_radio/ui-new/RingtoneEditorScreen.h b/examples/companion_radio/ui-new/RingtoneEditorScreen.h index 26ac7696..37f71043 100644 --- a/examples/companion_radio/ui-new/RingtoneEditorScreen.h +++ b/examples/companion_radio/ui-new/RingtoneEditorScreen.h @@ -57,7 +57,9 @@ class RingtoneEditorScreen : public UIScreen { public: RingtoneEditorScreen(UITask* task, NodePrefs* prefs) : _task(task), _prefs(prefs), _slot(0) {} - void enter(int slot = 0) { + // Load a melody slot for editing. Called by gotoRingtoneEditor() right after + // setCurrScreen() (whose onShow() can't carry the slot argument). + void selectSlot(int slot = 0) { _slot = (slot == 1) ? 1 : 0; bool s2 = (_slot == 1); _bpm_idx = (_prefs && (s2 ? _prefs->ringtone2_bpm_idx : _prefs->ringtone_bpm_idx) < 5) @@ -197,7 +199,7 @@ public: break; case MI_SWITCH: _task->stopMelody(); - this->enter(1 - _slot); + this->selectSlot(1 - _slot); break; case MI_DURATION: break; // already handled by LEFT/RIGHT case MI_BPM: break; // already handled by LEFT/RIGHT diff --git a/examples/companion_radio/ui-new/SettingsScreen.h b/examples/companion_radio/ui-new/SettingsScreen.h index 09fbc3b8..32cb17b4 100644 --- a/examples/companion_radio/ui-new/SettingsScreen.h +++ b/examples/companion_radio/ui-new/SettingsScreen.h @@ -651,7 +651,7 @@ public: } - void markClean() { + void onShow() override { _dirty = false; resetList(); _editor.freq.active = false; diff --git a/examples/companion_radio/ui-new/ToolsScreen.h b/examples/companion_radio/ui-new/ToolsScreen.h index a066d3b4..87c66cbb 100644 --- a/examples/companion_radio/ui-new/ToolsScreen.h +++ b/examples/companion_radio/ui-new/ToolsScreen.h @@ -58,7 +58,7 @@ public: ToolsScreen(UITask* task) : _task(task) {} // Open folded at the section list each time Tools is entered from Home. - void enter() { + void onShow() override { static uint8_t sizes[SECTION_COUNT]; for (int i = 0; i < SECTION_COUNT; i++) sizes[i] = SECTIONS[i].count; _acc.begin(sizes, SECTION_COUNT); diff --git a/examples/companion_radio/ui-new/TrailScreen.h b/examples/companion_radio/ui-new/TrailScreen.h index 1f2d2e2f..15709c82 100644 --- a/examples/companion_radio/ui-new/TrailScreen.h +++ b/examples/companion_radio/ui-new/TrailScreen.h @@ -96,7 +96,7 @@ public: TrailScreen(UITask* task, TrailStore* store) : _task(task), _store(store), _wp(task, store) {} - void enter() { + void onShow() override { _view = V_SUMMARY; _summary_scroll = 0; _list_scroll = 0; @@ -107,9 +107,10 @@ public: _wp.reset(); } - // Enter straight into the Map view (used by the Home "Map" page). Remembers - // that we came from Home so KEY_CANCEL returns there, not to Tools. - void enterMap() { enter(); _view = V_MAP; _return_home = true; } + // Switch straight into the Map view (used by the Home "Map" page). Layered on + // top of onShow()'s reset by gotoMapScreen(); remembers we came from Home so + // KEY_CANCEL returns there, not to Tools. + void showMapView() { _view = V_MAP; _return_home = true; } int render(DisplayDriver& display) override { display.setTextSize(1); diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index ac666167..c1e8a070 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1473,74 +1473,34 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no setCurrScreen(splash); } -void UITask::gotoSettingsScreen() { - ((SettingsScreen*)settings)->markClean(); - setCurrScreen(settings); -} - -void UITask::gotoToolsScreen() { - ((ToolsScreen*)tools_screen)->enter(); - setCurrScreen(tools_screen); -} +// onShow() is invoked by setCurrScreen(), so most navigators are just that. +void UITask::gotoSettingsScreen() { setCurrScreen(settings); } +void UITask::gotoToolsScreen() { setCurrScreen(tools_screen); } +void UITask::gotoBotScreen() { setCurrScreen(bot_screen); } +void UITask::gotoNearbyScreen() { setCurrScreen(nearby_screen); } +void UITask::gotoDashboardConfig() { setCurrScreen(dashboard_config); } +void UITask::gotoTrailScreen() { setCurrScreen(trail_screen); } +void UITask::gotoCompassScreen() { setCurrScreen(compass_screen); } +void UITask::gotoDiagnosticsScreen() { setCurrScreen(diag_screen); } +void UITask::gotoRepeaterScreen() { setCurrScreen(repeater_screen); } +void UITask::gotoLiveShareScreen() { setCurrScreen(live_share_screen); } +// Ringtone takes a slot argument that onShow() can't carry — pass it after the +// reset (setCurrScreen's onShow runs first, then this layers the slot on top). void UITask::gotoRingtoneEditor(int slot) { - ((RingtoneEditorScreen*)ringtone_edit)->enter(slot); setCurrScreen(ringtone_edit); + ((RingtoneEditorScreen*)ringtone_edit)->selectSlot(slot); } -void UITask::gotoBotScreen() { - ((BotScreen*)bot_screen)->enter(); - setCurrScreen(bot_screen); -} - -void UITask::gotoNearbyScreen() { - ((NearbyScreen*)nearby_screen)->enter(); - setCurrScreen(nearby_screen); -} - -void UITask::gotoDashboardConfig() { - ((DashboardConfigScreen*)dashboard_config)->enter(); - setCurrScreen(dashboard_config); -} - -void UITask::gotoTrailScreen() { - ((TrailScreen*)trail_screen)->enter(); - setCurrScreen(trail_screen); -} - +// Map is a sub-view variant of the Trail screen: reset via onShow(), then +// switch into the map view. void UITask::gotoMapScreen() { - ((TrailScreen*)trail_screen)->enterMap(); setCurrScreen(trail_screen); + ((TrailScreen*)trail_screen)->showMapView(); } -void UITask::gotoCompassScreen() { - ((CompassScreen*)compass_screen)->enter(); - setCurrScreen(compass_screen); -} - -void UITask::gotoDiagnosticsScreen() { - setCurrScreen(diag_screen); -} - -void UITask::gotoRepeaterScreen() { - ((RepeaterScreen*)repeater_screen)->enter(); - setCurrScreen(repeater_screen); -} - -void UITask::gotoLiveShareScreen() { - ((LiveShareScreen*)live_share_screen)->enter(); - setCurrScreen(live_share_screen); -} - -void UITask::gotoLocatorScreen() { - ((LocatorScreen*)locator_screen)->enter(); - setCurrScreen(locator_screen); -} - -void UITask::gotoAutoAdvertScreen() { - ((AutoAdvertScreen*)auto_advert_screen)->enter(); - setCurrScreen(auto_advert_screen); -} +void UITask::gotoLocatorScreen() { setCurrScreen(locator_screen); } +void UITask::gotoAutoAdvertScreen() { setCurrScreen(auto_advert_screen); } // Public method to handle ping result callback void UITask::handlePingResult(uint32_t tag, int16_t snr_out_x4, int16_t snr_back_x4, uint32_t rtt_ms) { @@ -1789,6 +1749,7 @@ void UITask::userLedHandler() { void UITask::setCurrScreen(UIScreen* c) { curr = c; + if (c) c->onShow(); // central per-visit reset hook (see UIScreen::onShow) _next_refresh = 100; } diff --git a/src/helpers/ui/UIScreen.h b/src/helpers/ui/UIScreen.h index bdde8e04..db2793b0 100644 --- a/src/helpers/ui/UIScreen.h +++ b/src/helpers/ui/UIScreen.h @@ -57,5 +57,10 @@ public: virtual int render(DisplayDriver& display) =0; // return value is number of millis until next render virtual bool handleInput(char c) { return false; } virtual void poll() { } + // Called by UITask::setCurrScreen() each time this screen becomes current — + // the place to reset per-visit state (selection, dirty flag, sub-views). + // Default no-op for screens that keep state across visits. Because it's + // invoked centrally, a new screen can't "forget" to be reset on show. + virtual void onShow() { } }; From 15716d2b03d90538c39dae727fd412d6bd9a6882 Mon Sep 17 00:00:00 2001 From: MarekZegare4 Date: Mon, 29 Jun 2026 18:42:59 +0200 Subject: [PATCH 19/29] fix(companion): nullptr-init screen pointers so a forgotten new() fails safe Adding a screen touches 4 sites; 3 (member decl, gotoX decl, gotoX def) are compile-checked, but a missed `new XScreen()` in begin() left the pointer uninitialised and crashed at first navigation. Give every screen member an in-class nullptr initialiser and bail early in setCurrScreen(nullptr) so the mistake is an inert no-op instead of a null deref. Document the 4-site registration contract on the member block. A full registry table was considered and rejected: the named gotoXScreen() methods are a depended-upon API (~30 call sites, menu dispatch + back-nav), so a table would add an enum + indirection without removing them. Co-Authored-By: Claude Opus 4.8 --- examples/companion_radio/ui-new/UITask.cpp | 7 +++- examples/companion_radio/ui-new/UITask.h | 39 ++++++++++++---------- 2 files changed, 28 insertions(+), 18 deletions(-) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index c1e8a070..e848d171 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1748,8 +1748,13 @@ void UITask::userLedHandler() { } void UITask::setCurrScreen(UIScreen* c) { + // Fail safe on a null target: a screen pointer left uninitialised (member + // declared + navigator wired, but the `new XScreen()` line forgotten in + // begin()) stays nullptr thanks to the in-class initialisers. Bail here so + // that mistake is an inert no-op instead of a null deref in render()/poll(). + if (!c) return; curr = c; - if (c) c->onShow(); // central per-visit reset hook (see UIScreen::onShow) + c->onShow(); // central per-visit reset hook (see UIScreen::onShow) _next_refresh = 100; } diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index d4e72e0a..510f30d3 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -68,23 +68,28 @@ class UITask : public AbstractUITask { unsigned long _analogue_pin_read_millis = millis(); #endif - UIScreen* splash; - UIScreen* home; - UIScreen* settings; - UIScreen* quick_msg; - UIScreen* tools_screen; - UIScreen* ringtone_edit; - UIScreen* bot_screen; - UIScreen* nearby_screen; - UIScreen* dashboard_config; - UIScreen* auto_advert_screen; - UIScreen* live_share_screen; - UIScreen* locator_screen; - UIScreen* trail_screen; - UIScreen* compass_screen; - UIScreen* diag_screen; - UIScreen* repeater_screen; - UIScreen* curr; + // Registering a new screen touches 4 sites: (1) the member below, (2) the + // `new XScreen()` in begin(), (3) the gotoXScreen() declaration further down, + // (4) its one-line definition in UITask.cpp. Sites 1/3/4 are compile-checked; + // only a forgotten (2) can slip through — the nullptr initialisers here turn + // that into an inert no-op (see UITask::setCurrScreen) rather than a crash. + UIScreen* splash = nullptr; + UIScreen* home = nullptr; + UIScreen* settings = nullptr; + UIScreen* quick_msg = nullptr; + UIScreen* tools_screen = nullptr; + UIScreen* ringtone_edit = nullptr; + UIScreen* bot_screen = nullptr; + UIScreen* nearby_screen = nullptr; + UIScreen* dashboard_config = nullptr; + UIScreen* auto_advert_screen = nullptr; + UIScreen* live_share_screen = nullptr; + UIScreen* locator_screen = nullptr; + UIScreen* trail_screen = nullptr; + UIScreen* compass_screen = nullptr; + UIScreen* diag_screen = nullptr; + UIScreen* repeater_screen = nullptr; + UIScreen* curr = nullptr; CayenneLPP _dash_lpp; TrailStore _trail; WaypointStore _waypoints; From 0e6bd743a6ce2da4bedbfd88e914c8c01e7fea4d Mon Sep 17 00:00:00 2001 From: MarekZegare4 Date: Mon, 29 Jun 2026 18:47:51 +0200 Subject: [PATCH 20/29] refactor(companion): unify screen save-on-exit via UITask::savePrefsIfDirty() The "_dirty bool, then if (_dirty) the_mesh.savePrefs() on exit" pattern was duplicated across 9 screens with subtle divergence: some cleared the flag after saving, some left it set and relied on onShow() to reset. Replace all 12 exit sites with a single _task->savePrefsIfDirty(flag) helper that saves once only if dirty and always clears the flag, so the "did we touch flash?" answer lives in one place and the reset is consistent. Edit sites still mark the flag manually (inherent to change tracking); only the persist-on-exit boilerplate is centralised. Co-Authored-By: Claude Opus 4.8 --- examples/companion_radio/ui-new/AutoAdvertScreen.h | 2 +- examples/companion_radio/ui-new/BotScreen.h | 2 +- examples/companion_radio/ui-new/DashboardConfigScreen.h | 2 +- examples/companion_radio/ui-new/LiveShareScreen.h | 2 +- examples/companion_radio/ui-new/LocatorScreen.h | 2 +- examples/companion_radio/ui-new/QuickMsgScreen.h | 8 ++------ examples/companion_radio/ui-new/RepeaterScreen.h | 2 +- examples/companion_radio/ui-new/SettingsScreen.h | 2 +- examples/companion_radio/ui-new/TrailScreen.h | 6 +++--- examples/companion_radio/ui-new/UITask.cpp | 7 +++++++ examples/companion_radio/ui-new/UITask.h | 5 +++++ 11 files changed, 24 insertions(+), 16 deletions(-) diff --git a/examples/companion_radio/ui-new/AutoAdvertScreen.h b/examples/companion_radio/ui-new/AutoAdvertScreen.h index 22aa4cc0..87e032fd 100644 --- a/examples/companion_radio/ui-new/AutoAdvertScreen.h +++ b/examples/companion_radio/ui-new/AutoAdvertScreen.h @@ -51,7 +51,7 @@ public: bool handleInput(char c) override { if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) { - if (_dirty) the_mesh.savePrefs(); + _task->savePrefsIfDirty(_dirty); _task->gotoToolsScreen(); return true; } diff --git a/examples/companion_radio/ui-new/BotScreen.h b/examples/companion_radio/ui-new/BotScreen.h index c43441ea..d8fa90aa 100644 --- a/examples/companion_radio/ui-new/BotScreen.h +++ b/examples/companion_radio/ui-new/BotScreen.h @@ -141,7 +141,7 @@ public: } if (cancel) { - if (_dirty) the_mesh.savePrefs(); + _task->savePrefsIfDirty(_dirty); _task->gotoToolsScreen(); return true; } diff --git a/examples/companion_radio/ui-new/DashboardConfigScreen.h b/examples/companion_radio/ui-new/DashboardConfigScreen.h index 88278d5a..e2f307ae 100644 --- a/examples/companion_radio/ui-new/DashboardConfigScreen.h +++ b/examples/companion_radio/ui-new/DashboardConfigScreen.h @@ -65,7 +65,7 @@ public: bool handleInput(char c) override { if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) { - if (_dirty) the_mesh.savePrefs(); + _task->savePrefsIfDirty(_dirty); _task->gotoHomeScreen(); return true; } diff --git a/examples/companion_radio/ui-new/LiveShareScreen.h b/examples/companion_radio/ui-new/LiveShareScreen.h index 40e73b33..a7d8cf63 100644 --- a/examples/companion_radio/ui-new/LiveShareScreen.h +++ b/examples/companion_radio/ui-new/LiveShareScreen.h @@ -161,7 +161,7 @@ public: bool handleInput(char c) override { if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) { - if (_dirty) { the_mesh.savePrefs(); _dirty = false; } + _task->savePrefsIfDirty(_dirty); _task->gotoToolsScreen(); return true; } diff --git a/examples/companion_radio/ui-new/LocatorScreen.h b/examples/companion_radio/ui-new/LocatorScreen.h index 7034b3ce..09b5d65c 100644 --- a/examples/companion_radio/ui-new/LocatorScreen.h +++ b/examples/companion_radio/ui-new/LocatorScreen.h @@ -293,7 +293,7 @@ public: return true; } if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) { - if (_dirty) { the_mesh.savePrefs(); _dirty = false; } // engine re-seeded per edit + _task->savePrefsIfDirty(_dirty); // engine re-seeded per edit _task->gotoToolsScreen(); return true; } diff --git a/examples/companion_radio/ui-new/QuickMsgScreen.h b/examples/companion_radio/ui-new/QuickMsgScreen.h index 0f8317ea..f3bcc109 100644 --- a/examples/companion_radio/ui-new/QuickMsgScreen.h +++ b/examples/companion_radio/ui-new/QuickMsgScreen.h @@ -1606,9 +1606,7 @@ public: // sel == 1 (Notif) and sel == 2 (Melody): already cycled via LEFT/RIGHT; ENTER just closes. } } - if (res != PopupMenu::NONE && _ctx_dirty) { - the_mesh.savePrefs(); _ctx_dirty = false; - } + if (res != PopupMenu::NONE) _task->savePrefsIfDirty(_ctx_dirty); return true; } if (c == KEY_CANCEL) { _room_mode = false; _phase = MODE_SELECT; return true; } @@ -1718,9 +1716,7 @@ public: } // sel 1/2/3 already handled by LEFT/RIGHT; ENTER just closes. } - if (res != PopupMenu::NONE && _ctx_dirty) { - the_mesh.savePrefs(); _ctx_dirty = false; - } + if (res != PopupMenu::NONE) _task->savePrefsIfDirty(_ctx_dirty); return true; } if (c == KEY_CANCEL) { _phase = MODE_SELECT; return true; } diff --git a/examples/companion_radio/ui-new/RepeaterScreen.h b/examples/companion_radio/ui-new/RepeaterScreen.h index 66619bf7..ba8e5d40 100644 --- a/examples/companion_radio/ui-new/RepeaterScreen.h +++ b/examples/companion_radio/ui-new/RepeaterScreen.h @@ -211,7 +211,7 @@ public: } if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) { - if (_dirty) the_mesh.savePrefs(); + _task->savePrefsIfDirty(_dirty); _task->gotoToolsScreen(); return true; } diff --git a/examples/companion_radio/ui-new/SettingsScreen.h b/examples/companion_radio/ui-new/SettingsScreen.h index 32cb17b4..e6405ec7 100644 --- a/examples/companion_radio/ui-new/SettingsScreen.h +++ b/examples/companion_radio/ui-new/SettingsScreen.h @@ -754,7 +754,7 @@ public: } if (c == KEY_CANCEL) { - if (_dirty) the_mesh.savePrefs(); + _task->savePrefsIfDirty(_dirty); _task->gotoHomeScreen(); return true; } diff --git a/examples/companion_radio/ui-new/TrailScreen.h b/examples/companion_radio/ui-new/TrailScreen.h index 15709c82..543682d9 100644 --- a/examples/companion_radio/ui-new/TrailScreen.h +++ b/examples/companion_radio/ui-new/TrailScreen.h @@ -192,16 +192,16 @@ public: case ACT_EXPORT: handleExport(); break; case ACT_EXPORT_SAVED: handleExportSaved(); break; } - if (_cfg_dirty) { the_mesh.savePrefs(); _cfg_dirty = false; } + _task->savePrefsIfDirty(_cfg_dirty); } else if (res == PopupMenu::CANCELLED) { - if (_cfg_dirty) { the_mesh.savePrefs(); _cfg_dirty = false; } + _task->savePrefsIfDirty(_cfg_dirty); if (_menu_level != ML_MAIN) buildMainMenu(); // Cancel backs out of a submenu } return true; } if (c == KEY_CANCEL) { - if (_cfg_dirty) { the_mesh.savePrefs(); _cfg_dirty = false; } + _task->savePrefsIfDirty(_cfg_dirty); if (_return_home) _task->gotoHomeScreen(); else _task->gotoToolsScreen(); return true; diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index e848d171..d17a813e 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1758,6 +1758,13 @@ void UITask::setCurrScreen(UIScreen* c) { _next_refresh = 100; } +bool UITask::savePrefsIfDirty(bool& dirty) { + if (!dirty) return false; + the_mesh.savePrefs(); + dirty = false; + return true; +} + /* hardware-agnostic pre-shutdown activity should be done here */ diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 510f30d3..00514644 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -355,6 +355,11 @@ public: void applyPowerSave(); // hardware duty-cycle RX on/off from prefs void applyApc(); // Adaptive Power Control on/off from prefs void applyRadioParams(); // freq/bw/sf/cr from prefs (radio preset change) + // Save-on-exit helper for the screen `_dirty` pattern: persists NodePrefs once + // only if `dirty`, then clears the flag. Standardises the screens' exit paths + // (some used to leave the flag set, relying on onShow() to reset it) and keeps + // the "did we touch flash?" answer in one place. Returns whether it saved. + bool savePrefsIfDirty(bool& dirty); void applyFont(); void applyRotation(); void applyFullRefreshInterval(); From cc88e89a6482be1e00cffec6b5086b93f03ac1d8 Mon Sep 17 00:00:00 2001 From: MarekZegare4 Date: Mon, 29 Jun 2026 18:53:18 +0200 Subject: [PATCH 21/29] refactor(companion): extract drawRowSelection() for the canonical list-row bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every drawList/AccordionList row opened with the same hand-written display.drawSelectionRow(0, y-1, width-reserve, lineStep-1, sel) line — 14 copies of the same geometry and magic offsets. Add a drawRowSelection(d, y, sel, reserve) helper in icons.h next to drawList and route the canonical sites through it (Bot/LiveShare/Locator/Repeater/Settings/Tools/Nearby/ QuickMsg/Waypoints). Rows that intentionally differ (full-width DashboardConfig/QuickMsg, the keyboard/ringtone grids) keep their explicit drawSelectionRow() call — the helper is opt-in rather than baked into drawList, so legitimate variants aren't forced into one geometry. Framework doc updated to match. Co-Authored-By: Claude Opus 4.8 --- docs/design/solo_ui_framework.md | 9 ++++++--- examples/companion_radio/ui-new/BotScreen.h | 2 +- examples/companion_radio/ui-new/LiveShareScreen.h | 2 +- examples/companion_radio/ui-new/LocatorScreen.h | 4 ++-- examples/companion_radio/ui-new/NearbyScreen.h | 2 +- examples/companion_radio/ui-new/QuickMsgScreen.h | 6 +++--- examples/companion_radio/ui-new/RepeaterScreen.h | 2 +- examples/companion_radio/ui-new/SettingsScreen.h | 4 ++-- examples/companion_radio/ui-new/ToolsScreen.h | 4 ++-- examples/companion_radio/ui-new/WaypointsView.h | 4 +--- examples/companion_radio/ui-new/icons.h | 10 ++++++++++ 11 files changed, 30 insertions(+), 19 deletions(-) diff --git a/docs/design/solo_ui_framework.md b/docs/design/solo_ui_framework.md index 7771a4b5..9da87a67 100644 --- a/docs/design/solo_ui_framework.md +++ b/docs/design/solo_ui_framework.md @@ -111,14 +111,17 @@ indicator: ```cpp drawList(display, count, _sel, _scroll, [&](int idx, int y, bool sel, int reserve) { - display.drawSelectionRow(0, y - 1, display.width() - reserve, display.lineStep() - 1, sel); + drawRowSelection(display, y, sel, reserve); // canonical highlight bar display.drawTextEllipsized(2, y, display.width() - reserve - 4, items[idx].name); }); ``` `reserve` is the width the scrollbar took (0 when the list fits) — subtract it from any right-aligned content so nothing slides under the indicator. The row -callback owns its own selection bar (so a screen can style it). For the +callback owns its own selection bar: `drawRowSelection(d, y, sel, reserve)` +(`ui-new/icons.h`) draws the standard one (full row minus reserve, one pixel +short); call `display.drawSelectionRow()` directly only when a row needs a +non-standard geometry (full-width, custom height). For the fold-in-place pattern (sections that expand/collapse) use `AccordionList` instead (`ui-new/AccordionList.h`) — same idea, two callbacks (header + item). @@ -299,7 +302,7 @@ public: d.setTextSize(1); d.drawCenteredHeader("MY TOOL"); drawList(d, ROWS, _sel, _scroll, [&](int i, int y, bool sel, int reserve) { - d.drawSelectionRow(0, y - 1, d.width() - reserve, d.lineStep() - 1, sel); + drawRowSelection(d, y, sel, reserve); d.setCursor(4, y); d.print(i == 0 ? "Alpha" : i == 1 ? "Bravo" : "Charlie"); }); diff --git a/examples/companion_radio/ui-new/BotScreen.h b/examples/companion_radio/ui-new/BotScreen.h index d8fa90aa..cbc9c975 100644 --- a/examples/companion_radio/ui-new/BotScreen.h +++ b/examples/companion_radio/ui-new/BotScreen.h @@ -75,7 +75,7 @@ public: "Trigger Ch", "Reply Ch", "Commands", "Quiet from", "Quiet to" }; drawList(display, ITEM_COUNT, _sel, _scroll, [&](int i, int y, bool sel, int reserve) { - display.drawSelectionRow(0, y - 1, display.width() - reserve, display.lineStep() - 1, sel); + drawRowSelection(display, y, sel, reserve); display.setCursor(2, y); display.print(labels[i]); display.setCursor(val_x, y); diff --git a/examples/companion_radio/ui-new/LiveShareScreen.h b/examples/companion_radio/ui-new/LiveShareScreen.h index a7d8cf63..726b3757 100644 --- a/examples/companion_radio/ui-new/LiveShareScreen.h +++ b/examples/companion_radio/ui-new/LiveShareScreen.h @@ -92,7 +92,7 @@ public: const int valx = display.width() / 2 + 6; drawList(display, ROW_COUNT, _sel, _scroll, [&](int i, int y, bool sel, int reserve) { Row r = rows(i); - display.drawSelectionRow(0, y - 1, display.width() - reserve, display.lineStep() - 1, sel); + drawRowSelection(display, y, sel, reserve); display.setCursor(4, y); display.print(r.label); char val[24]; diff --git a/examples/companion_radio/ui-new/LocatorScreen.h b/examples/companion_radio/ui-new/LocatorScreen.h index 09b5d65c..0cf7fba0 100644 --- a/examples/companion_radio/ui-new/LocatorScreen.h +++ b/examples/companion_radio/ui-new/LocatorScreen.h @@ -116,7 +116,7 @@ public: const int valx = display.width() / 2 + 6; drawList(display, rc, _sel, _scroll, [&](int i, int y, bool sel, int reserve) { Row r = rows(i); - display.drawSelectionRow(0, y - 1, display.width() - reserve, display.lineStep() - 1, sel); + drawRowSelection(display, y, sel, reserve); display.setCursor(4, y); display.print(r.label); char val[24]; @@ -265,7 +265,7 @@ public: display.drawCenteredHeader("PICK TARGET"); uint32_t now = rtc_clock.getCurrentTime(); drawList(display, _target_n, _pick_sel, _pick_scroll, [&](int i, int y, bool sel, int reserve) { - display.drawSelectionRow(0, y - 1, display.width() - reserve, display.lineStep() - 1, sel); + drawRowSelection(display, y, sel, reserve); const Target& t = _targets[i]; char row[36]; if (t.kind != 1) { diff --git a/examples/companion_radio/ui-new/NearbyScreen.h b/examples/companion_radio/ui-new/NearbyScreen.h index eb21c3b3..45d0d00f 100644 --- a/examples/companion_radio/ui-new/NearbyScreen.h +++ b/examples/companion_radio/ui-new/NearbyScreen.h @@ -686,7 +686,7 @@ public: drawList(display, _count, _sel, _scroll, [&](int idx, int y, bool sel, int reserve) { const Entry& e = _entries[idx]; - display.drawSelectionRow(0, y - 1, display.width() - reserve, item_h - 1, sel); + drawRowSelection(display, y, sel, reserve); char filt[32]; int tx = 2; diff --git a/examples/companion_radio/ui-new/QuickMsgScreen.h b/examples/companion_radio/ui-new/QuickMsgScreen.h index f3bcc109..fa0bcd2d 100644 --- a/examples/companion_radio/ui-new/QuickMsgScreen.h +++ b/examples/companion_radio/ui-new/QuickMsgScreen.h @@ -1046,7 +1046,7 @@ public: drawList(display, _num_contacts, _contact_sel, _contact_scroll, [&](int list_idx, int y, bool sel, int reserve) { int mesh_idx = _sorted[list_idx]; - display.drawSelectionRow(0, y - 1, display.width() - reserve, item_h - 1, sel); + drawRowSelection(display, y, sel, reserve); ContactInfo c; if (the_mesh.getContactByIdx(mesh_idx, c)) { @@ -1079,7 +1079,7 @@ public: } drawList(display, _num_channels, _channel_sel, _channel_scroll, [&](int list_idx, int y, bool sel, int reserve) { - display.drawSelectionRow(0, y - 1, display.width() - reserve, item_h - 1, sel); + drawRowSelection(display, y, sel, reserve); ChannelDetails ch; if (the_mesh.getChannel(_channel_indices[list_idx], ch)) { uint8_t unread = _ch_unread[_channel_indices[list_idx]]; @@ -1437,7 +1437,7 @@ public: int total_msg_items = 1 + _active_msg_count; drawList(display, total_msg_items, _msg_sel, _msg_scroll, [&](int idx, int y, bool sel, int reserve) { - display.drawSelectionRow(0, y - 1, display.width() - reserve, item_h - 1, sel); + drawRowSelection(display, y, sel, reserve); if (idx == 0) { display.setCursor(2, y); diff --git a/examples/companion_radio/ui-new/RepeaterScreen.h b/examples/companion_radio/ui-new/RepeaterScreen.h index ba8e5d40..5b4682bb 100644 --- a/examples/companion_radio/ui-new/RepeaterScreen.h +++ b/examples/companion_radio/ui-new/RepeaterScreen.h @@ -146,7 +146,7 @@ public: // Config only — live forwarding stats live on Tools › Diagnostics. drawList(display, _item_count, _sel, _scroll, [&](int row, int y, bool sel, int reserve) { int item = _items[row]; - display.drawSelectionRow(0, y - 1, display.width() - reserve, display.lineStep() - 1, sel); + drawRowSelection(display, y, sel, reserve); display.setCursor(2, y); display.print(itemLabel(item)); if (item == IT_RFREQ && sel && _editor.active()) { diff --git a/examples/companion_radio/ui-new/SettingsScreen.h b/examples/companion_radio/ui-new/SettingsScreen.h index e6405ec7..b8322acf 100644 --- a/examples/companion_radio/ui-new/SettingsScreen.h +++ b/examples/companion_radio/ui-new/SettingsScreen.h @@ -422,7 +422,7 @@ class SettingsScreen : public UIScreen { void renderItem(DisplayDriver& display, int item, int y, bool sel) { NodePrefs* p = _task->getNodePrefs(); - display.drawSelectionRow(0, y - 1, display.width() - _reserve, display.lineStep() - 1, sel); + drawRowSelection(display, y, sel, _reserve); display.setCursor(2, y); @@ -671,7 +671,7 @@ public: [&](int sec, int y, bool sel, int reserve, bool collapsed) { _reserve = reserve; display.setColor(DisplayDriver::LIGHT); - display.drawSelectionRow(0, y - 1, display.width() - reserve, display.lineStep() - 1, sel); + drawRowSelection(display, y, sel, reserve); display.setCursor(2, y); display.print(collapsed ? "+" : "-"); display.print(" "); diff --git a/examples/companion_radio/ui-new/ToolsScreen.h b/examples/companion_radio/ui-new/ToolsScreen.h index 87c66cbb..9f15a27a 100644 --- a/examples/companion_radio/ui-new/ToolsScreen.h +++ b/examples/companion_radio/ui-new/ToolsScreen.h @@ -75,7 +75,7 @@ public: _acc.render(display, // Section header: "[+/-] Name" [&](int sec, int y, bool sel, int reserve, bool collapsed) { - display.drawSelectionRow(0, y - 1, display.width() - reserve, display.lineStep() - 1, sel); + drawRowSelection(display, y, sel, reserve); display.setCursor(2, y); display.print(collapsed ? "+" : "-"); const int icon_x = 2 + cw + 2; @@ -85,7 +85,7 @@ public: }, // Item: indented " Label" [&](int sec, int item, int y, bool sel, int reserve) { - display.drawSelectionRow(0, y - 1, display.width() - reserve, display.lineStep() - 1, sel); + drawRowSelection(display, y, sel, reserve); const int icon_x = 2 + cw + 2; // align item icons under the header icon // drawIcon(display, icon_x, y, SECTIONS[sec].tools[item].icon); // icons disabled for now, don't fit visually display.setCursor(icon_x + g, y); diff --git a/examples/companion_radio/ui-new/WaypointsView.h b/examples/companion_radio/ui-new/WaypointsView.h index 5af1d8c9..8b921523 100644 --- a/examples/companion_radio/ui-new/WaypointsView.h +++ b/examples/companion_radio/ui-new/WaypointsView.h @@ -153,12 +153,10 @@ class WaypointsView { int n = wpListCount(); int total = n + 1; // final row = "+ Add by coords" - const int step = display.lineStep(); - int32_t mylat, mylon; bool have = ownPos(mylat, mylon); drawList(display, total, _sel, _scroll, [&](int row, int y, bool sel, int reserve) { - display.drawSelectionRow(0, y - 1, display.width() - reserve, step - 1, sel); + drawRowSelection(display, y, sel, reserve); if (row == n) { // the synthetic "Add" row display.setCursor(2, y); display.print("+ Add by coords"); diff --git a/examples/companion_radio/ui-new/icons.h b/examples/companion_radio/ui-new/icons.h index e3cbb315..f52f575e 100644 --- a/examples/companion_radio/ui-new/icons.h +++ b/examples/companion_radio/ui-new/icons.h @@ -499,6 +499,16 @@ inline int drawList(DisplayDriver& d, int total, int sel, int& scroll, RenderRow return visible; } +// Canonical selection bar for a drawList() row: spans the row width minus the +// scroll-indicator `reserve`, one pixel short of the row height, anchored one +// pixel above `y` (the row's text baseline-top). Call as the first line of a +// row callback, then draw content over it. Captures the geometry every list row +// repeated by hand; rows that intentionally differ (full-width, custom height) +// still call display.drawSelectionRow() directly. +inline void drawRowSelection(DisplayDriver& d, int y, bool sel, int reserve) { + d.drawSelectionRow(0, y - 1, d.width() - reserve, d.lineStep() - 1, sel); +} + // ── Big ASCII-art icons (skeleton, not yet used) ───────────────────────────── // Same authoring idea as the mini-icons but for full page glyphs up to 32 px // wide: one uint32_t per row. The existing XBM bitmaps below (logo/bluetooth/…) From 35622b769373f886894a77369e0a8eec7b8cc97c Mon Sep 17 00:00:00 2001 From: MarekZegare4 Date: Mon, 29 Jun 2026 18:55:36 +0200 Subject: [PATCH 22/29] docs(solo-ui): sync framework guide with onShow/savePrefsIfDirty/fail-safe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three spots lagged the recent refactors: - §8 + §10 still taught the inline `if (_dirty) { savePrefs(); _dirty=false; }` pattern; now point at `_task->savePrefsIfDirty(_dirty)`. - §1 wiring contract now notes the nullptr-init / setCurrScreen null-guard fail-safe (steps 1/3/4 are compile-checked, only a missed `new` slips through). Co-Authored-By: Claude Opus 4.8 --- docs/design/solo_ui_framework.md | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/docs/design/solo_ui_framework.md b/docs/design/solo_ui_framework.md index 9da87a67..ef3abbd9 100644 --- a/docs/design/solo_ui_framework.md +++ b/docs/design/solo_ui_framework.md @@ -65,9 +65,14 @@ public: 4. Reach it from somewhere — usually a row in `ToolsScreen.h` (add an `Action` enum value, a row in the right section table, and a `dispatch()` case). -That's the whole contract. The constructor takes `UITask* task` plus whatever -it needs (`NodePrefs*`, `KeyboardWidget*`, …); the task back-pointer is how a -screen calls shared services (`_task->showAlert(...)`, `_task->waypoints()`, …). +That's the whole contract. Steps 1, 3 and 4 are compiler-checked (a mismatch +won't link); only a forgotten step 2 can slip through — every screen pointer is +nullptr-initialised in `UITask.h` and `setCurrScreen()` bails on null, so a +missed `new` is an inert no-op rather than a null deref. + +The constructor takes `UITask* task` plus whatever it needs (`NodePrefs*`, +`KeyboardWidget*`, …); the task back-pointer is how a screen calls shared +services (`_task->showAlert(...)`, `_task->waypoints()`, …). --- @@ -246,10 +251,13 @@ Device settings live in one `NodePrefs` struct (`NodePrefs.h`), saved via are atomic (temp-file + rename), so a crash mid-save can't corrupt settings. **The `_dirty` convention:** a multi-field editor screen mutates `_node_prefs` -live for instant feedback but only calls `savePrefs()` once, on exit, gated by a -`_dirty` flag — so LEFT/RIGHT value-cycling doesn't thrash flash. A one-shot -action from a popup (no exit hook) saves immediately. Follow whichever matches -your screen. +live for instant feedback but only persists once, on exit, gated by a `_dirty` +flag — so LEFT/RIGHT value-cycling doesn't thrash flash. Set `_dirty = true` at +each edit site, then on the exit path call `_task->savePrefsIfDirty(_dirty)` +(`UITask`) — it saves once *iff* dirty and clears the flag, so every screen's +save-on-exit reads the same and the "did we touch flash?" decision lives in one +place. A one-shot action from a popup (no exit hook) calls `the_mesh.savePrefs()` +immediately. Follow whichever matches your screen. **The shared "active target"** (Locator/Nav destination) is set through `UITask::setTarget()` (defines it), `setTargetNow()` (defines + saves + toast), @@ -311,7 +319,7 @@ public: bool handleInput(char c) override { if (c == KEY_CANCEL) { - if (_dirty) { the_mesh.savePrefs(); _dirty = false; } + _task->savePrefsIfDirty(_dirty); // saves once iff dirty, then clears _task->gotoToolsScreen(); return true; } From c60a7a7f646f607d605388e938b3438f17735392 Mon Sep 17 00:00:00 2001 From: MarekZegare4 Date: Mon, 29 Jun 2026 19:19:43 +0200 Subject: [PATCH 23/29] feat(companion): GPS averaging for waypoint marking (Mark avg) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a "Mark avg" trail setting (Off/5/10/30 s). When set, Tools › Trail › Mark here samples the GPS fix once a second for the chosen window and stores the mean position instead of one instantaneous fix — a steadier, more accurate mark for a precise spot. A progress screen shows time left + sample count; Cancel aborts; the window then opens the label keyboard as usual. Off (default) keeps marking instant. Sampling runs in WaypointsView::poll() (forwarded from TrailScreen::poll()) so it ticks on the main loop, independent of the slow e-ink render cadence; int64 accumulators avoid overflow over 30 samples. Persisted as NodePrefs::gps_avg_idx (schema 0xC0DE0016): struct field + option table, DataStore rd/write/clamp in lockstep. sizeof unchanged (the uint8_t fits existing tail padding) so the 2488 tripwire still holds. Co-Authored-By: Claude Opus 4.8 --- .../tools_screen/tools_screen.md | 3 + examples/companion_radio/DataStore.cpp | 4 + examples/companion_radio/NodePrefs.h | 19 ++++- examples/companion_radio/ui-new/TrailScreen.h | 19 ++++- .../companion_radio/ui-new/WaypointsView.h | 80 ++++++++++++++++++- 5 files changed, 119 insertions(+), 6 deletions(-) diff --git a/docs/solo_features/tools_screen/tools_screen.md b/docs/solo_features/tools_screen/tools_screen.md index 0cd842f6..65778505 100644 --- a/docs/solo_features/tools_screen/tools_screen.md +++ b/docs/solo_features/tools_screen/tools_screen.md @@ -127,6 +127,7 @@ Cycle views with **LEFT / RIGHT**: | ---------- | --------- | ------------------------------------------------------- | | Min dist | always | Sample gate, 4 levels — metric: 5/10/25/100 m, imperial: 15/30/75/300 ft | | Auto-pause | always | Off / 1 / 2 / 5 min — auto-freeze the trail after a stop, resume on movement (see below) | +| Mark avg | always | Off / 5 / 10 / 30 s — GPS averaging for **Mark here** (see Waypoints below) | | Readout | Summary view | Summary shows Speed or Pace (in the global unit system) | | Grid | Map view | Toggle scale grid on the map | @@ -140,6 +141,8 @@ A waypoint is a saved spot — your car, camp, a water source — that you can n **Dropping a waypoint** — **Hold Enter → Mark here**. This captures the current GPS fix and opens the on-screen keyboard for a short label (up to 11 characters — e.g. `CAR`, `CAMP`, `H2O`). Leaving it blank auto-names it `WP1`, `WP2`, … Marking works whether or not the trail is being recorded; it needs a GPS fix (otherwise it reports *No GPS fix*). +**GPS averaging** — with **Settings → Mark avg** set (5 / 10 / 30 s), *Mark here* doesn't snapshot a single fix; it samples the GPS once a second for that window and stores the **mean** position, for a steadier mark than one instantaneous reading (handy for a precise spot — a cache, a car, a trailhead). A short screen shows the time left and the sample count while it runs; **Cancel** aborts. When the window closes it opens the label keyboard as usual. With **Mark avg = Off** (the default) marking is instant. + **Adding by coordinates** — open **Hold Enter → Waypoints** and select the **+ Add by coords** row (always the last entry in the list). This creates a waypoint without being there — no GPS fix required (handy for a meeting point or a spot read off a map). It opens a small form with three editable rows plus **Save**: | OLED | E-Ink | diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index 38a046d6..8b0d0fc7 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -418,6 +418,9 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no rd(&_prefs.locator_target_kind, sizeof(_prefs.locator_target_kind)); rd(_prefs.locator_key, sizeof(_prefs.locator_key)); if (_prefs.locator_target_kind > 1) _prefs.locator_target_kind = 0; + // → 0xC0DE0016: GPS-averaging duration for waypoint marking. + rd(&_prefs.gps_avg_idx, sizeof(_prefs.gps_avg_idx)); + if (_prefs.gps_avg_idx >= NodePrefs::GPS_AVG_COUNT) _prefs.gps_avg_idx = 0; // Pre-0x10 files leave stray sentinel bytes here, same as a never-configured // device. Either way there's no valid saved profile, so default to a profile // in the same band as the companion's own network (_prefs.freq, already read @@ -618,6 +621,7 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_ file.write((uint8_t *)&_prefs.locator_beeper, sizeof(_prefs.locator_beeper)); file.write((uint8_t *)&_prefs.locator_target_kind, sizeof(_prefs.locator_target_kind)); file.write((uint8_t *)_prefs.locator_key, sizeof(_prefs.locator_key)); + file.write((uint8_t *)&_prefs.gps_avg_idx, sizeof(_prefs.gps_avg_idx)); // Tail sentinel — must be last. See NodePrefs::SCHEMA_SENTINEL. Its write is // the one we check: once the flash fills, writes return 0, so a good diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 545e8991..7dcb3fd7 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -264,6 +264,12 @@ struct NodePrefs { // persisted to file // discrete arrive/leave alert (locator_mode). uint8_t locator_beeper; // 0=off (default), 1=on + // GPS averaging for waypoint marking — when set, "Mark here" samples the GPS + // fix for this many seconds and stores the mean position, for a more accurate + // mark than a single instantaneous fix. 0 = off (instant mark, the default). + // Index into gpsAvgSecs(). [Tools › Trail › Settings › Mark avg] + uint8_t gps_avg_idx; + // Single source of truth for the live-share option tables (shared by the Map // UI labels and the auto-send engine in UITask). static const uint8_t LOC_SHARE_MOVE_COUNT = 4; @@ -295,6 +301,17 @@ struct NodePrefs { // persisted to file return L[m < LOCATOR_MODE_COUNT ? m : 0]; } + // GPS-averaging durations for waypoint marking (seconds). 0 = off (instant). + static const uint8_t GPS_AVG_COUNT = 4; + static uint16_t gpsAvgSecs(uint8_t idx) { + static const uint16_t S[GPS_AVG_COUNT] = { 0, 5, 10, 30 }; + return S[idx < GPS_AVG_COUNT ? idx : 0]; + } + static const char* gpsAvgLabel(uint8_t idx) { + static const char* L[GPS_AVG_COUNT] = { "Off", "5s", "10s", "30s" }; + return L[idx < GPS_AVG_COUNT ? idx : 0]; + } + // Trail auto-pause delays (seconds). 0 = off. static const uint8_t TRAIL_AUTOPAUSE_COUNT = 4; static uint16_t trailAutoPauseSecs(uint8_t idx) { @@ -315,7 +332,7 @@ struct NodePrefs { // persisted to file // adding/removing/reordering fields in DataStore::savePrefs/loadPrefsInt so // older saves are detected on load and skipped (zero-init defaults kept). // High 24 bits identify the file format; low byte is the schema revision. - static const uint32_t SCHEMA_SENTINEL = 0xC0DE0015; + static const uint32_t SCHEMA_SENTINEL = 0xC0DE0016; // Bit-index for each home page. Used by page_order (entries store bit+1) and // by home_pages_mask. Single source of truth — both HomeScreen::pageBit/bitToPage diff --git a/examples/companion_radio/ui-new/TrailScreen.h b/examples/companion_radio/ui-new/TrailScreen.h index 543682d9..1ad33722 100644 --- a/examples/companion_radio/ui-new/TrailScreen.h +++ b/examples/companion_radio/ui-new/TrailScreen.h @@ -72,7 +72,7 @@ class TrailScreen : public UIScreen { // (Start/Stop tracking, Reset). PopupMenu stores label pointers verbatim, so // the labels live in member buffers that get refreshed in openActionMenu() // and after every LEFT/RIGHT cycle. - enum ActionId { ACT_MIN_DIST, ACT_UNITS, ACT_GRID, ACT_AUTOPAUSE, ACT_TOGGLE, ACT_SAVE, ACT_LOAD, ACT_RESET, ACT_EXPORT, ACT_EXPORT_SAVED, ACT_MARK, ACT_WAYPOINTS, ACT_FILE, ACT_SETTINGS, + enum ActionId { ACT_MIN_DIST, ACT_UNITS, ACT_GRID, ACT_AUTOPAUSE, ACT_MARK_AVG, ACT_TOGGLE, ACT_SAVE, ACT_LOAD, ACT_RESET, ACT_EXPORT, ACT_EXPORT_SAVED, ACT_MARK, ACT_WAYPOINTS, ACT_FILE, ACT_SETTINGS, ACT_SHARE_NOW }; // The action popup is multi-level: a short main menu, plus "Trail file…" and // "Settings…" submenus. _menu_level tracks which is open so input is routed @@ -88,6 +88,7 @@ class TrailScreen : public UIScreen { char _act_units_label[24]; char _act_grid_label[16]; char _act_autopause_label[24]; + char _act_mark_avg_label[24]; char _act_toggle_label[20]; static const int SUMMARY_ITEM_COUNT = 5; @@ -136,6 +137,10 @@ public: return _store->isActive() ? 1000 : 5000; } + // Forward the loop tick to the waypoint UI so GPS averaging (Mark avg) can + // sample independently of the render cadence (slow on e-ink). + void poll() override { _wp.poll(); } + bool handleInput(char c) override { // Waypoint management UI consumes all input while active. if (_wp.active()) return _wp.handleInput(c); @@ -172,6 +177,7 @@ public: case ACT_MIN_DIST: case ACT_UNITS: case ACT_GRID: + case ACT_MARK_AVG: case ACT_AUTOPAUSE: cycleSetting(act, 1); reopenSettingsAt(sel); return true; case ACT_SHARE_NOW: shareMyLocationNow(); break; case ACT_TOGGLE: @@ -318,6 +324,8 @@ private: "Grid: %s", _map_grid ? "ON" : "OFF"); snprintf(_act_autopause_label, sizeof(_act_autopause_label), "Auto-pause: %s", NodePrefs::trailAutoPauseLabel(p ? p->trail_autopause_idx : 0)); + snprintf(_act_mark_avg_label, sizeof(_act_mark_avg_label), + "Mark avg: %s", NodePrefs::gpsAvgLabel(p ? p->gps_avg_idx : 0)); snprintf(_act_toggle_label, sizeof(_act_toggle_label), "%s tracking", _store->isActive() ? "Stop" : "Start"); } @@ -382,6 +390,7 @@ private: _action_menu.begin("Settings", 4); pushAction(ACT_MIN_DIST, _act_min_dist_label); pushAction(ACT_AUTOPAUSE, _act_autopause_label); + pushAction(ACT_MARK_AVG, _act_mark_avg_label); if (_view == V_SUMMARY) pushAction(ACT_UNITS, _act_units_label); if (_view == V_MAP) pushAction(ACT_GRID, _act_grid_label); } @@ -393,6 +402,7 @@ private: if (act == ACT_UNITS && p) { cycleUnits(p, dir); _cfg_dirty = true; refreshActionLabels(); return true; } if (act == ACT_GRID) { _map_grid = !_map_grid; refreshActionLabels(); return true; } if (act == ACT_AUTOPAUSE && p){ cycleAutoPause(p, dir); _cfg_dirty = true; refreshActionLabels(); return true; } + if (act == ACT_MARK_AVG && p){ cycleMarkAvg(p, dir); _cfg_dirty = true; refreshActionLabels(); return true; } return false; } @@ -435,6 +445,13 @@ private: : (idx + NodePrefs::TRAIL_AUTOPAUSE_COUNT - 1) % NodePrefs::TRAIL_AUTOPAUSE_COUNT; p->trail_autopause_idx = idx; } + void cycleMarkAvg(NodePrefs* p, int dir) { + uint8_t idx = p->gps_avg_idx; + if (idx >= NodePrefs::GPS_AVG_COUNT) idx = 0; + idx = (dir > 0) ? (idx + 1) % NodePrefs::GPS_AVG_COUNT + : (idx + NodePrefs::GPS_AVG_COUNT - 1) % NodePrefs::GPS_AVG_COUNT; + p->gps_avg_idx = idx; + } // One-shot manual share: build "[LOC]lat,lon" and hand it to the Messages // screen, where the user picks a DM or channel recipient. (Auto live-share diff --git a/examples/companion_radio/ui-new/WaypointsView.h b/examples/companion_radio/ui-new/WaypointsView.h index 8b921523..5d03b405 100644 --- a/examples/companion_radio/ui-new/WaypointsView.h +++ b/examples/companion_radio/ui-new/WaypointsView.h @@ -20,11 +20,22 @@ class WaypointsView { TrailStore* _store; // Sub-modes layered over the trail views. OFF = the component is dormant. - enum Mode { OFF, LIST, NAV, ADD }; + enum Mode { OFF, LIST, NAV, ADD, AVG }; uint8_t _mode = OFF; int _sel = 0; int _scroll = 0; + // GPS averaging (Tools › Trail › Settings › Mark avg). When enabled, markHere() + // accumulates fixes for gps_avg_idx seconds and marks the mean position — a + // steadier mark than one instantaneous fix. Sampling runs in poll() on a 1 s + // gate, independent of (slow, on e-ink) redraws. int64 sums: 30 samples × + // ~180e6 overflows int32. + long long _avg_sum_lat = 0, _avg_sum_lon = 0; + uint32_t _avg_n = 0; // fixes accumulated so far + uint32_t _avg_end_ms = 0; // millis() when the averaging window closes + uint32_t _avg_next_ms = 0; // millis() of the next sample + uint16_t _avg_total_s = 0; // configured window length (for the readout) + PopupMenu _ctx; // Rename / Delete / Send on a selected waypoint bool _kb_active = false; int _kb_rename_idx = -1; // -1 = marking new, ≥0 = renaming that index @@ -99,6 +110,17 @@ class WaypointsView { _kb_active = true; } + // Capture a mark position and open the keyboard for its label. Shared by the + // instant "Mark here" and the end of GPS averaging. _mode goes OFF: the + // keyboard takes over the screen, and there's no sub-view to return to once + // the label is committed (active() then tracks _kb_active alone). + void beginLabel(int32_t lat, int32_t lon) { + _mode = OFF; + _mark_lat = lat; _mark_lon = lon; _mark_ts = (uint32_t)rtc_clock.getCurrentTime(); + _kb_rename_idx = -1; + openKb("", WAYPOINT_LABEL_LEN - 1); + } + // Commit a mark-here / rename label from the keyboard. void commitLabel(const char* buf) { if (_kb_rename_idx >= 0) { @@ -144,6 +166,23 @@ class WaypointsView { } } + // GPS-averaging progress screen. Sampling itself happens in poll(); this only + // reports remaining time and the running sample count. + void renderAvg(DisplayDriver& display) { + display.setColor(DisplayDriver::LIGHT); + display.drawCenteredHeader("AVERAGING GPS"); + const int top = display.listStart(); + const int step = display.lineStep(); + int remain = (int)((int32_t)(_avg_end_ms - millis()) / 1000); + if (remain < 0) remain = 0; + char line[28]; + snprintf(line, sizeof(line), "%ds left (of %us)", remain, (unsigned)_avg_total_s); + display.setCursor(2, top); display.print(line); + snprintf(line, sizeof(line), "Samples: %u", (unsigned)_avg_n); + display.setCursor(2, top + step); display.print(line); + display.setCursor(2, top + 2 * step); display.print("Cancel to abort"); + } + void renderWpList(DisplayDriver& display) { display.setColor(DisplayDriver::LIGHT); char title[24]; @@ -220,9 +259,35 @@ public: int32_t lat, lon; if (!ownPos(lat, lon)) { _task->showAlert("No GPS fix", 1000); return; } if (_task->waypoints().full()) { _task->showAlert("Waypoints full", 1000); return; } - _mark_lat = lat; _mark_lon = lon; _mark_ts = (uint32_t)rtc_clock.getCurrentTime(); - _kb_rename_idx = -1; - openKb("", WAYPOINT_LABEL_LEN - 1); + NodePrefs* p = _task->getNodePrefs(); + uint8_t avg_idx = p ? p->gps_avg_idx : 0; + if (avg_idx == 0) { beginLabel(lat, lon); return; } // instant mark (default) + // Averaging: seed with the current fix, then sample for N more seconds. + _avg_total_s = NodePrefs::gpsAvgSecs(avg_idx); + _avg_sum_lat = lat; _avg_sum_lon = lon; _avg_n = 1; + uint32_t now = millis(); + _avg_end_ms = now + (uint32_t)_avg_total_s * 1000; + _avg_next_ms = now + 1000; + _mode = AVG; + } + + // Accumulate GPS fixes while averaging. Driven from TrailScreen::poll() so it + // ticks on the main loop, not on the (slow on e-ink) render cadence. No-op + // unless an averaging window is open. + void poll() { + if (_mode != AVG) return; + uint32_t now = millis(); + if ((int32_t)(now - _avg_next_ms) >= 0) { + int32_t lat, lon; + if (ownPos(lat, lon)) { _avg_sum_lat += lat; _avg_sum_lon += lon; _avg_n++; } + _avg_next_ms = now + 1000; + } + if ((int32_t)(now - _avg_end_ms) >= 0) { // window closed → mark the mean + if (_avg_n == 0) { _task->showAlert("No GPS fix", 1000); _mode = OFF; return; } + int32_t mlat = (int32_t)(_avg_sum_lat / (long long)_avg_n); + int32_t mlon = (int32_t)(_avg_sum_lon / (long long)_avg_n); + beginLabel(mlat, mlon); // opens the label keyboard + } } // Only called while active(). @@ -231,6 +296,7 @@ public: display.setColor(DisplayDriver::LIGHT); if (_kb_active) return _task->keyboard().render(display); // keyboard owns the screen if (_mode == ADD) { renderAddForm(display); return 1000; } + if (_mode == AVG) { renderAvg(display); return display.isEink() ? 1000 : 300; } if (_mode == NAV) { renderWpNav(display); return 1000; } renderWpList(display); // LIST if (_ctx.active) _ctx.render(display); @@ -293,6 +359,12 @@ public: return true; } + // Averaging window — any cancel aborts the mark; otherwise just wait it out. + if (_mode == AVG) { + if (c == KEY_CANCEL) _mode = OFF; + return true; + } + // ADD form: scroll-edit lat/lon (magnitude) + hemisphere toggle + label. if (_mode == ADD) { // The digit editor, while open, consumes all input (UP/DOWN change the From 4d795ade51bd6f3c1dd09597bc44932f31a5b4dd Mon Sep 17 00:00:00 2001 From: MarekZegare4 Date: Mon, 29 Jun 2026 19:27:54 +0200 Subject: [PATCH 24/29] =?UTF-8?q?feat(companion):=20track-back=20=E2=80=94?= =?UTF-8?q?=20retrace=20the=20recorded=20trail=20to=20its=20start?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Tools › Trail → Hold Enter → Track back (shown when the trail has ≥2 points). It reuses NavView but, instead of a single fixed target, snaps onto the route at the nearest recorded breadcrumb and walks the points in reverse, auto-advancing the target as each is reached (within 20 m). The header shows points remaining ("Back: 12 pt" → "Trail start"); reaching the start toasts "Back at start" and exits. Cancel leaves at any time. Lives in WaypointsView as a new sub-mode; advancement runs in poll() (forwarded from TrailScreen) so it tracks GPS independently of the render cadence. An EtaTracker drives the approach/ETA line and is reset on each breadcrumb advance to avoid a bogus closing-speed spike. Co-Authored-By: Claude Opus 4.8 --- .../tools_screen/tools_screen.md | 5 ++ examples/companion_radio/ui-new/TrailScreen.h | 5 +- .../companion_radio/ui-new/WaypointsView.h | 74 +++++++++++++++++-- 3 files changed, 78 insertions(+), 6 deletions(-) diff --git a/docs/solo_features/tools_screen/tools_screen.md b/docs/solo_features/tools_screen/tools_screen.md index 65778505..e901f209 100644 --- a/docs/solo_features/tools_screen/tools_screen.md +++ b/docs/solo_features/tools_screen/tools_screen.md @@ -107,6 +107,7 @@ Cycle views with **LEFT / RIGHT**: | Start / Stop tracking | Begin or end a recording session. If **GPS is off**, choosing Start asks **"GPS is off — Enable GPS & start"** so a session can't silently run with nothing to record | | Mark here | Drop a waypoint at the current GPS fix (see below) | | Waypoints… | Open the waypoint list / navigation / add-by-coords | +| Track back | Retrace the recorded route back to its start (needs ≥2 points; see below) | | Share my pos | Send your current position as a one-shot `[LOC]` message — pick a contact or channel (see **Live Share**) | | Trail file… | Open the file submenu (below) | | Settings… | Open the settings submenu (below) | @@ -135,6 +136,10 @@ Cycle views with **LEFT / RIGHT**: **Auto-pause** — when set, a recording trail automatically **pauses** after the device has stayed within ~15 m of one spot for the chosen delay: the elapsed timer and point sampling both freeze, and the map line breaks across the idle gap. It **resumes on its own** as soon as you move again. This keeps a stop (a break, a meal, parking) out of your distance and average-speed stats without you having to remember to stop and restart tracking. A paused trail is still "on" (the **G** marker keeps blinking) — the Summary **Status** row shows `paused`. The stop is detected with its own coarse movement gate, independent of **Min dist**, so GPS jitter while you're parked doesn't keep it awake. +### Track back + +**Hold Enter → Track back** retraces the trail you just recorded, back to where you started — useful for returning the same way in poor visibility or unfamiliar ground. It reuses the navigation view (distance + two absolute bearings; see *Waypoints › Navigating*), but instead of a single fixed target it walks the recorded breadcrumbs in reverse: it snaps onto the route at the **nearest recorded point**, guides you to it, then automatically advances to the next earlier point as you reach each one (within ~20 m). The header shows how many points remain (`Back: 12 pt`), reading `Trail start` on the final leg; arriving there shows `Back at start` and exits. **Cancel** leaves track-back at any time. It needs a trail with at least two points and a GPS fix; it doesn't require tracking to still be running. + ### Waypoints A waypoint is a saved spot — your car, camp, a water source — that you can navigate back to later. Waypoints are **independent of the trail**: they live in their own flash file (`/waypoints`), survive a reboot, and are **not** cleared by *Reset trail*. Up to 16 can be stored — the Waypoints list header shows how many are in use (e.g. `WAYPOINTS 3/16`). diff --git a/examples/companion_radio/ui-new/TrailScreen.h b/examples/companion_radio/ui-new/TrailScreen.h index 1ad33722..3b967e41 100644 --- a/examples/companion_radio/ui-new/TrailScreen.h +++ b/examples/companion_radio/ui-new/TrailScreen.h @@ -73,7 +73,7 @@ class TrailScreen : public UIScreen { // the labels live in member buffers that get refreshed in openActionMenu() // and after every LEFT/RIGHT cycle. enum ActionId { ACT_MIN_DIST, ACT_UNITS, ACT_GRID, ACT_AUTOPAUSE, ACT_MARK_AVG, ACT_TOGGLE, ACT_SAVE, ACT_LOAD, ACT_RESET, ACT_EXPORT, ACT_EXPORT_SAVED, ACT_MARK, ACT_WAYPOINTS, ACT_FILE, ACT_SETTINGS, - ACT_SHARE_NOW }; + ACT_SHARE_NOW, ACT_TRACKBACK }; // The action popup is multi-level: a short main menu, plus "Trail file…" and // "Settings…" submenus. _menu_level tracks which is open so input is routed // correctly (settings rows cycle with LEFT/RIGHT; everything else is Enter). @@ -192,6 +192,7 @@ public: break; case ACT_MARK: _wp.markHere(); break; case ACT_WAYPOINTS: _wp.openList(); break; + case ACT_TRACKBACK: _wp.startTrackBack(); break; case ACT_SAVE: handleSave(); break; case ACT_LOAD: handleLoad(); break; case ACT_RESET: handleReset(); break; @@ -353,6 +354,8 @@ private: // "Waypoints" opens the nav list — always available; it hosts the list, // backtrack (Trail-start row) and the "+ Add by coords" entry. pushAction(ACT_WAYPOINTS, "Waypoints..."); + // Track back retraces the recorded route to its start; needs ≥2 points. + if (_store->count() >= 2) pushAction(ACT_TRACKBACK, "Track back"); pushAction(ACT_SHARE_NOW, "Share my pos"); if (fileMenuHasItems()) pushAction(ACT_FILE, "Trail file..."); pushAction(ACT_SETTINGS, "Settings..."); diff --git a/examples/companion_radio/ui-new/WaypointsView.h b/examples/companion_radio/ui-new/WaypointsView.h index 5d03b405..9c10f976 100644 --- a/examples/companion_radio/ui-new/WaypointsView.h +++ b/examples/companion_radio/ui-new/WaypointsView.h @@ -20,11 +20,18 @@ class WaypointsView { TrailStore* _store; // Sub-modes layered over the trail views. OFF = the component is dormant. - enum Mode { OFF, LIST, NAV, ADD, AVG }; + enum Mode { OFF, LIST, NAV, ADD, AVG, TRACKBACK }; uint8_t _mode = OFF; int _sel = 0; int _scroll = 0; + // Track-back (Tools › Trail › Track back): retrace the recorded trail in + // reverse using NavView. _tb_idx is the current target breadcrumb; it walks + // down to 0 (the start) as each is reached within TB_ARRIVE_M. + static const int TB_ARRIVE_M = 20; + int _tb_idx = 0; + navview::EtaTracker _tb_eta; + // GPS averaging (Tools › Trail › Settings › Mark avg). When enabled, markHere() // accumulates fixes for gps_avg_idx seconds and marks the mean position — a // steadier mark than one instantaneous fix. Sampling runs in poll() on a 1 s @@ -226,6 +233,20 @@ class WaypointsView { navview::draw(display, have, mylat, mylon, tlat, tlon, label, cogv, cog, useImperial()); } + // Navigate to the current track-back breadcrumb. The header doubles as the + // progress readout: "Trail start" on the last leg, else points still to go. + void renderTrackBack(DisplayDriver& display) { + if (_tb_idx < 0 || _tb_idx >= _store->count()) { _mode = OFF; return; } + const TrailPoint& t = _store->at(_tb_idx); + int32_t mylat, mylon; bool have = ownPos(mylat, mylon); + int cog; bool cogv = _task->currentCourse(cog); + char label[20]; + if (_tb_idx == 0) snprintf(label, sizeof(label), "Trail start"); + else snprintf(label, sizeof(label), "Back: %d pt", _tb_idx); + navview::draw(display, have, mylat, mylon, t.lat_1e6, t.lon_1e6, + label, cogv, cog, useImperial(), &_tb_eta); + } + // Resolve a combined-list row to a nav target. Row 0 is the trail start when // a trail exists; the rest are saved waypoints. bool rowTarget(int row, int32_t& lat, int32_t& lon, const char*& label) { @@ -271,11 +292,34 @@ public: _mode = AVG; } - // Accumulate GPS fixes while averaging. Driven from TrailScreen::poll() so it - // ticks on the main loop, not on the (slow on e-ink) render cadence. No-op - // unless an averaging window is open. + // Retrace the recorded trail back to its start. Snaps onto the route at the + // nearest recorded point, then NavView guides to each earlier breadcrumb in + // turn (poll() advances the target) until the start is reached. + void startTrackBack() { + if (_store->count() < 2) { _task->showAlert("No trail", 1000); return; } + int idx = _store->count() - 1; // default: the newest end of the trail + int32_t lat, lon; + if (ownPos(lat, lon)) { // else snap to the nearest recorded point + float best = 1e30f; + for (int i = 0; i < _store->count(); i++) { + float d = geo::haversineKm(lat, lon, _store->at(i).lat_1e6, _store->at(i).lon_1e6); + if (d < best) { best = d; idx = i; } + } + } + _tb_idx = idx; + _tb_eta.reset(); + _mode = TRACKBACK; + } + + // Driven from TrailScreen::poll() so the position-tracking sub-modes tick on + // the main loop, not on the (slow on e-ink) render cadence. void poll() { - if (_mode != AVG) return; + if (_mode == AVG) pollAvg(); + else if (_mode == TRACKBACK) pollTrackBack(); + } + + // Accumulate GPS fixes while averaging; mark the mean when the window closes. + void pollAvg() { uint32_t now = millis(); if ((int32_t)(now - _avg_next_ms) >= 0) { int32_t lat, lon; @@ -290,6 +334,18 @@ public: } } + // Advance the track-back target when the current breadcrumb is reached; once + // at the start (index 0) and within range, announce arrival and exit. + void pollTrackBack() { + int32_t lat, lon; + if (!ownPos(lat, lon)) return; + float d_m = geo::haversineKm(lat, lon, _store->at(_tb_idx).lat_1e6, + _store->at(_tb_idx).lon_1e6) * 1000.0f; + if (d_m > (float)TB_ARRIVE_M) return; + if (_tb_idx > 0) { _tb_idx--; _tb_eta.reset(); } + else { _task->showAlert("Back at start", 1500); _mode = OFF; } + } + // Only called while active(). int render(DisplayDriver& display) { display.setTextSize(1); @@ -297,6 +353,7 @@ public: if (_kb_active) return _task->keyboard().render(display); // keyboard owns the screen if (_mode == ADD) { renderAddForm(display); return 1000; } if (_mode == AVG) { renderAvg(display); return display.isEink() ? 1000 : 300; } + if (_mode == TRACKBACK) { renderTrackBack(display); return 1000; } if (_mode == NAV) { renderWpNav(display); return 1000; } renderWpList(display); // LIST if (_ctx.active) _ctx.render(display); @@ -400,6 +457,13 @@ public: return true; } + // Track-back — launched from the action menu, so Cancel returns to the + // trail views (not the waypoint list). + if (_mode == TRACKBACK) { + if (c == KEY_CANCEL) _mode = OFF; + return true; + } + // List (row 0 is the synthetic "Trail start" when a trail exists; the final // row is "+ Add by coords"). Cancel returns control to the trail views. int n = wpListCount(); From f6baa5c38cc19f77d7773b94e4a1d6e2cb8f45e7 Mon Sep 17 00:00:00 2001 From: MarekZegare4 Date: Mon, 29 Jun 2026 22:41:01 +0200 Subject: [PATCH 25/29] refactor(companion): extract MessageHistory store from QuickMsgScreen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the two RAM history rings (channel + DM) out of the 1988-line QuickMsgScreen into a self-contained MessageHistory.h component, mirroring the WaypointsView extraction from TrailScreen. The store owns the ring buffers, per-entry delivery state (channel relay echo, DM end-to-end ACK + auto-resend) and the per-channel unread counters; the screen keeps all view state (selection, scroll, fullscreen readers, the unread "viewing session" bookkeeping) and reaches entries through accessors. Behaviour-preserving: - The shared types (AckState, ChHistEntry, DmHistEntry, MSG_TEXT_BUF) are file-scope in MessageHistory.h, so the phase machine still refers to them unqualified — only data + storage logic moved. - addChannelMsg keeps a thin screen forwarder that computes the "viewing" flag (a phase fact the store can't see) and returns the ring pos; afterSend uses armChannelRelay(pos, seq) instead of poking the ring. - The public API called via UITask/MyMesh/the bot (addChannelMsg, addDMMsg, markDmDelivered, markChannelRelayed, tickDmResends, clearAllChannelUnread, getTotalChannelUnread, getRecentDMContacts) stays on QuickMsgScreen as forwarders, so no caller changes. QuickMsgScreen 1988 -> 1738 lines; MessageHistory.h 324. Both solo boards build. Single-TU fragment: included by UITask.cpp before QuickMsgScreen.h. Co-Authored-By: Claude Opus 4.8 --- .../companion_radio/ui-new/MessageHistory.h | 324 ++++++++++++++ .../companion_radio/ui-new/QuickMsgScreen.h | 408 ++++-------------- examples/companion_radio/ui-new/UITask.cpp | 1 + 3 files changed, 404 insertions(+), 329 deletions(-) create mode 100644 examples/companion_radio/ui-new/MessageHistory.h diff --git a/examples/companion_radio/ui-new/MessageHistory.h b/examples/companion_radio/ui-new/MessageHistory.h new file mode 100644 index 00000000..1bdb5cb4 --- /dev/null +++ b/examples/companion_radio/ui-new/MessageHistory.h @@ -0,0 +1,324 @@ +#pragma once +// Message history store for QuickMsgScreen: two RAM ring buffers (channel + DM) +// with their per-entry delivery state (channel "relayed into mesh" echo, DM +// end-to-end ACK + auto-resend) and the per-channel unread counters. Pure +// storage + queries — no UI/phase state lives here. The screen keeps selection, +// scroll, the unread "viewing session" bookkeeping, the room-login table, and +// all rendering, and reaches entries through the accessors below. +// +// Single-TU fragment: included by UITask.cpp before QuickMsgScreen.h. AckState, +// MSG_TEXT_BUF and the two entry structs are file-scope (not nested) so the +// phase machine in QuickMsgScreen keeps referring to them unqualified. + +// Outgoing-message delivery state. DM: a real end-to-end ACK (✓ delivered to +// the recipient). Channel: only a "relayed into mesh" echo from a repeater (no +// recipient ACK exists for floods), so a missing echo is NOT shown as failure. +enum AckState : uint8_t { ACK_NONE = 0, ACK_PENDING, ACK_OK, ACK_FAIL }; + +// History text holds a full received message. Channel messages carry the sender +// embedded as "Name: body" in the payload, so a message can be up to the +// over-the-air maximum (MAX_TEXT_LEN). Size the buffers to that + NUL, otherwise +// long messages (and Polish text, where each accented char is two UTF-8 bytes) +// get their tail clipped. +static const int MSG_TEXT_BUF = MAX_TEXT_LEN + 1; + +struct ChHistEntry { + uint8_t ch_idx; + char text[MSG_TEXT_BUF]; + uint32_t timestamp; + uint8_t relay_status; // AckState; only PENDING/OK used (no failure for floods) + uint32_t relay_seq; // MyMesh relay seq to match against onChannelRelayed() +}; + +struct DmHistEntry { + uint8_t prefix[4]; + uint8_t outgoing; + char text[MSG_TEXT_BUF]; + uint32_t timestamp; + uint8_t ack_status; // AckState; meaningful only when outgoing + uint32_t ack_tag; // expected_ack CRC to match against onMsgAck() + uint32_t ack_deadline_ms; // millis() by which a pending ACK must arrive + // Sender-perspective message timestamp: the send timestamp for outgoing + // (reused verbatim on resend so the recipient treats it as a retry), or the + // sender_timestamp for incoming (used to dedup retried copies). 0 = unknown. + uint32_t msg_ts; + uint8_t attempt; // last attempt number sent (outgoing); next resend = attempt+1 + uint8_t resends_left; // remaining auto-resends before the marker shows ✗ +}; + +class MessageHistory { +public: + // Shared ring for all channels combined; each slot carries a full MSG_TEXT_BUF, + // so this ring dominates RAM — kept modest to leave heap headroom (see + // DM_HIST_MAX). The DM ring is likewise bounded. + static const int CH_HIST_MAX = 48; + static const int DM_HIST_MAX = 32; + + MessageHistory() + : _hist_head(0), _hist_count(0), _dm_hist_head(0), _dm_hist_count(0) { + memset(_ch_unread, 0, sizeof(_ch_unread)); + } + + // ── Channel ring ────────────────────────────────────────────────────────── + + // Append a channel message. `viewing` = the user is currently in this + // channel's history (so it isn't counted unread). `timestamp` is the sender's + // own send time (0 = unknown — use receipt time). Returns the ring position + // (an opaque handle for armChannelRelay / chAtPos), or -1 if rejected. + int addChannelMsg(uint8_t ch_idx, const char* text, bool viewing, uint32_t timestamp = 0) { + // Guard against bogus channel indices (e.g. findChannelIdx() returned -1 + // and was cast to uint8_t → 255). Storing such an entry would burn a ring + // slot for a message that no visible channel can ever surface. + if (ch_idx >= MAX_GROUP_CHANNELS) return -1; + int pos; + if (_hist_count < CH_HIST_MAX) { + pos = (_hist_head + _hist_count) % CH_HIST_MAX; + _hist_count++; + } else { + pos = _hist_head; + // Evicting the oldest entry — drop its share of the unread counter so + // the badge can't claim a message the ring no longer holds. + uint8_t evicted = _hist[pos].ch_idx; + if (evicted < MAX_GROUP_CHANNELS && _ch_unread[evicted] > 0) { + _ch_unread[evicted]--; + } + _hist_head = (_hist_head + 1) % CH_HIST_MAX; + } + _hist[pos].ch_idx = ch_idx; + _hist[pos].timestamp = timestamp ? timestamp : rtc_clock.getCurrentTime(); + strncpy(_hist[pos].text, text, sizeof(_hist[pos].text) - 1); + _hist[pos].text[sizeof(_hist[pos].text) - 1] = '\0'; + _hist[pos].relay_status = ACK_NONE; + _hist[pos].relay_seq = 0; + + if (!viewing && _ch_unread[ch_idx] < 99) _ch_unread[ch_idx]++; + return pos; + } + + // count history entries for a specific channel + int histCountForChannel(int ch_idx) const { + int n = 0; + for (int i = 0; i < _hist_count; i++) { + if (_hist[(_hist_head + i) % CH_HIST_MAX].ch_idx == (uint8_t)ch_idx) n++; + } + return n; + } + + // get ring-buffer position of j-th history entry for channel (newest first) + int histEntryForChannel(int ch_idx, int j) const { + int n = 0; + for (int i = _hist_count - 1; i >= 0; i--) { + int pos = (_hist_head + i) % CH_HIST_MAX; + if (_hist[pos].ch_idx == (uint8_t)ch_idx) { + if (n == j) return pos; + n++; + } + } + return -1; + } + + // Called when a repeater echo of one of our channel sends is heard. + void markChannelRelayed(uint32_t seq) { + if (seq == 0) return; + for (int i = 0; i < _hist_count; i++) { + ChHistEntry& e = _hist[(_hist_head + i) % CH_HIST_MAX]; + if (e.relay_status == ACK_PENDING && e.relay_seq == seq) { + e.relay_status = ACK_OK; + return; + } + } + } + + // Arm the "relayed into mesh" marker on a just-sent entry (pos from + // addChannelMsg) — MyMesh tracked the flood it originated and will report a + // heard repeater echo by seq. + void armChannelRelay(int pos, uint32_t seq) { + if (pos < 0 || pos >= CH_HIST_MAX) return; + _hist[pos].relay_status = ACK_PENDING; + _hist[pos].relay_seq = seq; + } + + ChHistEntry& chAtPos(int pos) { return _hist[pos]; } + const ChHistEntry& chAtPos(int pos) const { return _hist[pos]; } + + // ── Per-channel unread counters ───────────────────────────────────────────── + uint8_t chUnread(int ch) const { + return (ch >= 0 && ch < MAX_GROUP_CHANNELS) ? _ch_unread[ch] : 0; + } + void setChUnread(int ch, uint8_t v) { + if (ch >= 0 && ch < MAX_GROUP_CHANNELS) _ch_unread[ch] = v; + } + void clearAllChannelUnread() { memset(_ch_unread, 0, sizeof(_ch_unread)); } + int getTotalChannelUnread() const { + int total = 0; + for (int i = 0; i < MAX_GROUP_CHANNELS; i++) total += _ch_unread[i]; + return total; + } + + // ── DM ring ───────────────────────────────────────────────────────────────── + + // ack_tag != 0 marks an outgoing DM as awaiting an end-to-end ACK by + // ack_deadline_ms; 0 means "sent, no confirmation possible" (no path / incoming). + // msg_ts = sender-perspective timestamp (send ts for outgoing / sender_timestamp + // for incoming); resends = remaining auto-resends for an outgoing pending DM. + void storeDMMsg(const uint8_t* pub_key, bool outgoing, const char* text, + uint32_t ack_tag = 0, uint32_t ack_deadline_ms = 0, + uint32_t msg_ts = 0, uint8_t resends = 0) { + int pos; + if (_dm_hist_count < DM_HIST_MAX) { + pos = (_dm_hist_head + _dm_hist_count) % DM_HIST_MAX; + _dm_hist_count++; + } else { + pos = _dm_hist_head; + _dm_hist_head = (_dm_hist_head + 1) % DM_HIST_MAX; + } + memcpy(_dm_hist[pos].prefix, pub_key, 4); + _dm_hist[pos].outgoing = outgoing ? 1 : 0; + // Prefer the sender's own timestamp — a room-sync replay or an + // offline-queued message held by a repeater can arrive long after it was + // actually sent, so "now" would mislabel every backlog message as fresh. + // Fall back to receipt time only when the sender's timestamp is unknown. + _dm_hist[pos].timestamp = msg_ts ? msg_ts : rtc_clock.getCurrentTime(); + strncpy(_dm_hist[pos].text, text, sizeof(DmHistEntry::text) - 1); + _dm_hist[pos].text[sizeof(DmHistEntry::text) - 1] = '\0'; + _dm_hist[pos].ack_status = (outgoing && ack_tag) ? ACK_PENDING : ACK_NONE; + _dm_hist[pos].ack_tag = ack_tag; + _dm_hist[pos].ack_deadline_ms = ack_deadline_ms; + _dm_hist[pos].msg_ts = msg_ts; + _dm_hist[pos].attempt = 0; + _dm_hist[pos].resends_left = (outgoing && ack_tag) ? resends : 0; + } + + void addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text, + uint32_t sender_timestamp = 0) { + // Drop retried copies of an incoming DM: a resend reuses the sender's + // timestamp and text but carries a fresh packet hash, so the mesh dup-filter + // lets it through. Match on prefix + sender_timestamp + text to suppress it. + if (!outgoing && sender_timestamp != 0) { + for (int i = 0; i < _dm_hist_count; i++) { + const DmHistEntry& e = _dm_hist[(_dm_hist_head + i) % DM_HIST_MAX]; + if (!e.outgoing && e.msg_ts == sender_timestamp && + memcmp(e.prefix, pub_key, 4) == 0 && strcmp(e.text, text) == 0) + return; // duplicate retry — already in history + } + } + storeDMMsg(pub_key, outgoing, text, 0, 0, outgoing ? 0 : sender_timestamp, 0); + } + + int dmHistCountForContact(const uint8_t* prefix) const { + int n = 0; + for (int i = 0; i < _dm_hist_count; i++) + if (memcmp(_dm_hist[(_dm_hist_head + i) % DM_HIST_MAX].prefix, prefix, 4) == 0) n++; + return n; + } + + int dmHistEntryForContact(const uint8_t* prefix, int j) const { // j=0 = newest + int n = 0; + for (int i = _dm_hist_count - 1; i >= 0; i--) { + int pos = (_dm_hist_head + i) % DM_HIST_MAX; + if (memcmp(_dm_hist[pos].prefix, prefix, 4) == 0) { + if (n == j) return pos; + n++; + } + } + return -1; + } + + // Effective status for display. A pending ACK only reads as failed once its + // deadline has passed AND no auto-resends remain — while resends_left > 0 the + // entry stays pending (tickDmResends() retries / finalises it). Safety net for + // when the tick hasn't run yet; the tick is the authority that writes ACK_FAIL. + AckState dmEffectiveStatus(const DmHistEntry& e) const { + if (e.ack_status == ACK_PENDING && e.resends_left == 0 && + (int32_t)(millis() - e.ack_deadline_ms) >= 0) + return ACK_FAIL; + return (AckState)e.ack_status; + } + + // Called when an end-to-end ACK arrives (routed from MyMesh::onAckRecv). + // Marks the matching pending outgoing DM as delivered. + void markDmDelivered(uint32_t ack_crc) { + if (ack_crc == 0) return; + for (int i = 0; i < _dm_hist_count; i++) { + DmHistEntry& e = _dm_hist[(_dm_hist_head + i) % DM_HIST_MAX]; + if (e.outgoing && e.ack_status == ACK_PENDING && e.ack_tag == ack_crc) { + e.ack_status = ACK_OK; + return; + } + } + } + + // 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 (✗). + void tickDmResends() { + uint32_t now = millis(); + for (int i = 0; i < _dm_hist_count; i++) { + DmHistEntry& e = _dm_hist[(_dm_hist_head + i) % DM_HIST_MAX]; + if (!e.outgoing || e.ack_status != ACK_PENDING) continue; + if ((int32_t)(now - e.ack_deadline_ms) < 0) continue; // still waiting + if (e.resends_left == 0) { e.ack_status = ACK_FAIL; continue; } + ContactInfo c; + if (!contactByPrefix(e.prefix, c)) { e.ack_status = ACK_FAIL; continue; } + 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, + expected_ack, est_timeout) > 0 && expected_ack) { + e.attempt = next_attempt; + e.ack_tag = expected_ack; // each attempt has a distinct ACK CRC + e.ack_deadline_ms = now + est_timeout + 4000; + e.resends_left--; + } else { + e.ack_status = ACK_FAIL; // couldn't compose/send — give up + } + } + } + + // Recent DM contacts, newest first, deduped. Resolves the 4-byte _dm_hist + // prefix to a 6-byte pub_key prefix by walking the contact list once per + // unique sender. Returns the number filled. + int getRecentDMContacts(uint8_t out[][NodePrefs::FAVOURITE_PREFIX_LEN], int max) const { + int n = 0; + for (int i = _dm_hist_count - 1; i >= 0 && n < max; i--) { + int pos = (_dm_hist_head + i) % DM_HIST_MAX; + const uint8_t* p4 = _dm_hist[pos].prefix; + // Skip if already collected. + bool dup = false; + for (int j = 0; j < n; j++) if (memcmp(out[j], p4, 4) == 0) { dup = true; break; } + if (dup) continue; + // Find a real contact whose pub_key starts with this 4-byte prefix. + for (int idx = 0; ; idx++) { + ContactInfo c; + if (!the_mesh.getContactByIdx(idx, c)) break; + if (memcmp(c.id.pub_key, p4, 4) == 0) { + memcpy(out[n], c.id.pub_key, NodePrefs::FAVOURITE_PREFIX_LEN); + n++; + break; + } + } + } + return n; + } + + DmHistEntry& dmAtPos(int pos) { return _dm_hist[pos]; } + const DmHistEntry& dmAtPos(int pos) const { return _dm_hist[pos]; } + +private: + // Look up a contact by 4-byte pub_key prefix (as stored in DmHistEntry). + bool contactByPrefix(const uint8_t* prefix, ContactInfo& out) const { + int total = the_mesh.getNumContacts(); + for (int i = 0; i < total; i++) { + ContactInfo c; + if (!the_mesh.getContactByIdx(i, c)) continue; + if (memcmp(c.id.pub_key, prefix, 4) == 0) { out = c; return true; } + } + return false; + } + + ChHistEntry _hist[CH_HIST_MAX]; + int _hist_head, _hist_count; + uint8_t _ch_unread[MAX_GROUP_CHANNELS]; + + DmHistEntry _dm_hist[DM_HIST_MAX]; + int _dm_hist_head, _dm_hist_count; +}; diff --git a/examples/companion_radio/ui-new/QuickMsgScreen.h b/examples/companion_radio/ui-new/QuickMsgScreen.h index fa0bcd2d..d54a1a3b 100644 --- a/examples/companion_radio/ui-new/QuickMsgScreen.h +++ b/examples/companion_radio/ui-new/QuickMsgScreen.h @@ -26,7 +26,6 @@ class QuickMsgScreen : public UIScreen { int _channel_sel, _channel_scroll; int _num_channels; uint8_t _channel_indices[MAX_GROUP_CHANNELS]; - uint8_t _ch_unread[MAX_GROUP_CHANNELS]; int _sel_channel_idx; bool _sending_to_channel; @@ -41,17 +40,12 @@ class QuickMsgScreen : public UIScreen { int _active_msgs[QUICK_MSGS_MAX]; int _active_msg_count; - // CHANNEL_HIST + // CHANNEL_HIST — selection + the unread "viewing session" bookkeeping. The + // history ring itself and the per-channel unread counters live in _history. int _hist_sel, _hist_scroll; FullscreenMsgView _fs; - int _unread_at_entry; // _ch_unread value when entering CHANNEL_HIST + int _unread_at_entry; // channel unread count when entering CHANNEL_HIST int _viewing_max_seen; // highest _hist_sel reached in current session - // Shared ring for all channels combined. addChannelMsg() decrements the - // matching _ch_unread when an entry is evicted so the badge can't claim - // unread messages that no longer exist in the ring. Each slot carries a full - // MSG_TEXT_BUF, so this ring dominates the screen's RAM footprint — kept - // modest to leave heap headroom (see DM_HIST_MAX). - static const int CH_HIST_MAX = 48; // KEYBOARD KeyboardWidget* _kb; @@ -90,48 +84,15 @@ class QuickMsgScreen : public UIScreen { // auto-share target (channel / DM) instead of composing a message. bool _pick_target = false; - // History text holds a full received message. Channel messages carry the - // sender embedded as "Name: body" in the payload, so a message can be up to - // the over-the-air maximum (MAX_TEXT_LEN). Size the buffers to that + NUL, - // otherwise long messages (and Polish text, where each accented char is two - // UTF-8 bytes) get their tail clipped. - static const int MSG_TEXT_BUF = MAX_TEXT_LEN + 1; + // The message-history rings (channel + DM), their per-entry delivery state and + // per-channel unread counters live in this store (see MessageHistory.h). The + // phase machine below keeps only the view state — selection, scroll, the + // fullscreen readers — and reaches entries through _history's accessors. The + // shared types (AckState, ChHistEntry, DmHistEntry, MSG_TEXT_BUF) are file- + // scope, so they're still referred to unqualified throughout this screen. + MessageHistory _history; - // Outgoing-message delivery state. DM: a real end-to-end ACK (✓ delivered to - // the recipient). Channel: only a "relayed into mesh" echo from a repeater (no - // recipient ACK exists for floods), so a missing echo is NOT shown as failure. - enum AckState : uint8_t { ACK_NONE = 0, ACK_PENDING, ACK_OK, ACK_FAIL }; - - struct ChHistEntry { - uint8_t ch_idx; - char text[MSG_TEXT_BUF]; - uint32_t timestamp; - uint8_t relay_status; // AckState; only PENDING/OK used (no failure for floods) - uint32_t relay_seq; // MyMesh relay seq to match against onChannelRelayed() - }; - ChHistEntry _hist[CH_HIST_MAX]; - int _hist_head, _hist_count; - - // DM_HIST - struct DmHistEntry { - uint8_t prefix[4]; - uint8_t outgoing; - char text[MSG_TEXT_BUF]; - uint32_t timestamp; - uint8_t ack_status; // AckState; meaningful only when outgoing - uint32_t ack_tag; // expected_ack CRC to match against onMsgAck() - uint32_t ack_deadline_ms; // millis() by which a pending ACK must arrive - // Sender-perspective message timestamp: the send timestamp for outgoing - // (reused verbatim on resend so the recipient treats it as a retry), or the - // sender_timestamp for incoming (used to dedup retried copies). 0 = unknown. - uint32_t msg_ts; - uint8_t attempt; // last attempt number sent (outgoing); next resend = attempt+1 - uint8_t resends_left; // remaining auto-resends before the marker shows ✗ - }; - // Each slot carries a full MSG_TEXT_BUF; kept modest to bound heap use. - static const int DM_HIST_MAX = 32; - DmHistEntry _dm_hist[DM_HIST_MAX]; - int _dm_hist_head, _dm_hist_count; + // DM_HIST view state (the ring itself is in _history). int _dm_hist_sel, _dm_hist_scroll; FullscreenMsgView _dm_fs; @@ -344,29 +305,6 @@ class QuickMsgScreen : public UIScreen { } } - - // count history entries for a specific channel - int histCountForChannel(int ch_idx) const { - int n = 0; - for (int i = 0; i < _hist_count; i++) { - if (_hist[(_hist_head + i) % CH_HIST_MAX].ch_idx == (uint8_t)ch_idx) n++; - } - return n; - } - - // get ring-buffer position of j-th history entry for channel (newest first) - int histEntryForChannel(int ch_idx, int j) const { - int n = 0; - for (int i = _hist_count - 1; i >= 0; i--) { - int pos = (_hist_head + i) % CH_HIST_MAX; - if (_hist[pos].ch_idx == (uint8_t)ch_idx) { - if (n == j) return pos; - n++; - } - } - return -1; - } - // Delivery marker, drawn with the current ink colour and auto-scaled to the // font (see icons.h). Pending = a row of dots, one per send (so it grows with // each auto-resend); delivered = ✓; failed = ✗; ACK_NONE = nothing. @@ -379,68 +317,6 @@ class QuickMsgScreen : public UIScreen { } } - // ack_tag != 0 marks an outgoing DM as awaiting an end-to-end ACK by - // ack_deadline_ms; 0 means "sent, no confirmation possible" (no path / incoming). - // msg_ts = sender-perspective timestamp (send ts for outgoing / sender_timestamp - // for incoming); resends = remaining auto-resends for an outgoing pending DM. - void storeDMMsg(const uint8_t* pub_key, bool outgoing, const char* text, - uint32_t ack_tag = 0, uint32_t ack_deadline_ms = 0, - uint32_t msg_ts = 0, uint8_t resends = 0) { - int pos; - if (_dm_hist_count < DM_HIST_MAX) { - pos = (_dm_hist_head + _dm_hist_count) % DM_HIST_MAX; - _dm_hist_count++; - } else { - pos = _dm_hist_head; - _dm_hist_head = (_dm_hist_head + 1) % DM_HIST_MAX; - } - memcpy(_dm_hist[pos].prefix, pub_key, 4); - _dm_hist[pos].outgoing = outgoing ? 1 : 0; - // Prefer the sender's own timestamp — a room-sync replay or an - // offline-queued message held by a repeater can arrive long after it was - // actually sent, so "now" would mislabel every backlog message as fresh. - // Fall back to receipt time only when the sender's timestamp is unknown. - _dm_hist[pos].timestamp = msg_ts ? msg_ts : rtc_clock.getCurrentTime(); - strncpy(_dm_hist[pos].text, text, sizeof(DmHistEntry::text) - 1); - _dm_hist[pos].text[sizeof(DmHistEntry::text) - 1] = '\0'; - _dm_hist[pos].ack_status = (outgoing && ack_tag) ? ACK_PENDING : ACK_NONE; - _dm_hist[pos].ack_tag = ack_tag; - _dm_hist[pos].ack_deadline_ms = ack_deadline_ms; - _dm_hist[pos].msg_ts = msg_ts; - _dm_hist[pos].attempt = 0; - _dm_hist[pos].resends_left = (outgoing && ack_tag) ? resends : 0; - } - - // Effective status for display. A pending ACK only reads as failed once its - // deadline has passed AND no auto-resends remain — while resends_left > 0 the - // entry stays pending (tickDmResends() retries / finalises it). Safety net for - // when the tick hasn't run yet; the tick is the authority that writes ACK_FAIL. - AckState dmEffectiveStatus(const DmHistEntry& e) const { - if (e.ack_status == ACK_PENDING && e.resends_left == 0 && - (int32_t)(millis() - e.ack_deadline_ms) >= 0) - return ACK_FAIL; - return (AckState)e.ack_status; - } - - int dmHistCountForContact(const uint8_t* prefix) const { - int n = 0; - for (int i = 0; i < _dm_hist_count; i++) - if (memcmp(_dm_hist[(_dm_hist_head + i) % DM_HIST_MAX].prefix, prefix, 4) == 0) n++; - return n; - } - - int dmHistEntryForContact(const uint8_t* prefix, int j) const { // j=0 = newest - int n = 0; - for (int i = _dm_hist_count - 1; i >= 0; i--) { - int pos = (_dm_hist_head + i) % DM_HIST_MAX; - if (memcmp(_dm_hist[pos].prefix, prefix, 4) == 0) { - if (n == j) return pos; - n++; - } - } - return -1; - } - void afterSend(bool ok, const char* msg) { _reply_mode = false; _share_mode = false; @@ -453,21 +329,18 @@ class QuickMsgScreen : public UIScreen { int pos = addChannelMsg(_sel_channel_idx, entry); // Arm the "relayed into mesh" marker on this exact entry — MyMesh tracked // the flood it just originated and reports a heard repeater echo by seq. - if (pos >= 0) { - _hist[pos].relay_status = ACK_PENDING; - _hist[pos].relay_seq = the_mesh.lastChannelRelaySeq(); - } + if (pos >= 0) _history.armChannelRelay(pos, the_mesh.lastChannelRelaySeq()); // After inserting sent msg at index 0, the unread index range is stale. // User is active in this channel — treat as fully read. - _ch_unread[_sel_channel_idx] = 0; + _history.setChUnread(_sel_channel_idx, 0); _unread_at_entry = 0; _viewing_max_seen = 0; _task->showAlert("Sent!", 600); } else if (ok) { NodePrefs* np = _task->getNodePrefs(); uint8_t resends = np ? np->dm_resend_count : 0; - storeDMMsg(_sel_contact.id.pub_key, true, msg, _last_ack_tag, - _last_ack_deadline_ms, _last_send_ts, resends); + _history.storeDMMsg(_sel_contact.id.pub_key, true, msg, _last_ack_tag, + _last_ack_deadline_ms, _last_send_ts, resends); _dm_hist_sel = 0; _dm_hist_scroll = 0; _phase = DM_HIST; @@ -522,7 +395,7 @@ class QuickMsgScreen : public UIScreen { for (int i = 0; i < total; i++) { if (!the_mesh.getContactByIdx(i, c) || c.type != ADV_TYPE_CHAT) continue; if (!show_all && !(c.flags & 0x01)) continue; - counts[_num_contacts] = dmHistCountForContact(c.id.pub_key); + counts[_num_contacts] = _history.dmHistCountForContact(c.id.pub_key); _sorted[_num_contacts++] = i; } // Sort by message count descending; contacts with no messages keep original order. @@ -671,88 +544,25 @@ public: _msg_sel(0), _msg_scroll(0), _active_msg_count(0), _hist_sel(0), _hist_scroll(0), _unread_at_entry(0), _viewing_max_seen(0), - _hist_head(0), _hist_count(0), - _dm_hist_head(0), _dm_hist_count(0), _dm_hist_sel(-1), _dm_hist_scroll(0), _ctx_dirty(false), _pin_picker_active(false), _dm_direct_entry(false), _reply_mode(false) { - memset(_ch_unread, 0, sizeof(_ch_unread)); + // The history rings + per-channel unread counters init in MessageHistory. } - // Returns the ring position the message was stored at, or -1 if rejected, so - // the outgoing path can attach a relay seq to that exact entry. `timestamp` - // is the sender's own send time (0 = unknown/outgoing — use receipt time); - // see storeDMMsg() for why this matters for synced/queued backlog messages. + // Public entry points (routed from MyMesh / the bot via UITask) — thin + // forwarders to the history store. addChannelMsg computes the "viewing" flag + // (a phase-machine fact the store can't see) and returns the ring position so + // the outgoing path can attach a relay seq to that exact entry. int addChannelMsg(uint8_t ch_idx, const char* text, uint32_t timestamp = 0) { - // Guard against bogus channel indices (e.g. findChannelIdx() returned -1 - // and was cast to uint8_t → 255). Storing such an entry would burn a ring - // slot for a message that no visible channel can ever surface. - if (ch_idx >= MAX_GROUP_CHANNELS) return -1; - int pos; - if (_hist_count < CH_HIST_MAX) { - pos = (_hist_head + _hist_count) % CH_HIST_MAX; - _hist_count++; - } else { - pos = _hist_head; - // Evicting the oldest entry — drop its share of the unread counter so - // the badge can't claim a message the ring no longer holds. - uint8_t evicted = _hist[pos].ch_idx; - if (evicted < MAX_GROUP_CHANNELS && _ch_unread[evicted] > 0) { - _ch_unread[evicted]--; - } - _hist_head = (_hist_head + 1) % CH_HIST_MAX; - } - _hist[pos].ch_idx = ch_idx; - _hist[pos].timestamp = timestamp ? timestamp : rtc_clock.getCurrentTime(); - strncpy(_hist[pos].text, text, sizeof(_hist[pos].text) - 1); - _hist[pos].text[sizeof(_hist[pos].text) - 1] = '\0'; - _hist[pos].relay_status = ACK_NONE; - _hist[pos].relay_seq = 0; - bool viewing = (_phase == CHANNEL_HIST && _sel_channel_idx == (int)ch_idx); - if (!viewing && _ch_unread[ch_idx] < 99) _ch_unread[ch_idx]++; - return pos; + return _history.addChannelMsg(ch_idx, text, viewing, timestamp); } - - // Called when a repeater echo of one of our channel sends is heard. - void markChannelRelayed(uint32_t seq) { - if (seq == 0) return; - for (int i = 0; i < _hist_count; i++) { - ChHistEntry& e = _hist[(_hist_head + i) % CH_HIST_MAX]; - if (e.relay_status == ACK_PENDING && e.relay_seq == seq) { - e.relay_status = ACK_OK; - return; - } - } - } - + void markChannelRelayed(uint32_t seq) { _history.markChannelRelayed(seq); } void addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text, uint32_t sender_timestamp = 0) { - // Drop retried copies of an incoming DM: a resend reuses the sender's - // timestamp and text but carries a fresh packet hash, so the mesh dup-filter - // lets it through. Match on prefix + sender_timestamp + text to suppress it. - if (!outgoing && sender_timestamp != 0) { - for (int i = 0; i < _dm_hist_count; i++) { - const DmHistEntry& e = _dm_hist[(_dm_hist_head + i) % DM_HIST_MAX]; - if (!e.outgoing && e.msg_ts == sender_timestamp && - memcmp(e.prefix, pub_key, 4) == 0 && strcmp(e.text, text) == 0) - return; // duplicate retry — already in history - } - } - storeDMMsg(pub_key, outgoing, text, 0, 0, outgoing ? 0 : sender_timestamp, 0); - } - - // Called when an end-to-end ACK arrives (routed from MyMesh::onAckRecv). - // Marks the matching pending outgoing DM as delivered. - void markDmDelivered(uint32_t ack_crc) { - if (ack_crc == 0) return; - for (int i = 0; i < _dm_hist_count; i++) { - DmHistEntry& e = _dm_hist[(_dm_hist_head + i) % DM_HIST_MAX]; - if (e.outgoing && e.ack_status == ACK_PENDING && e.ack_tag == ack_crc) { - e.ack_status = ACK_OK; - return; - } - } + _history.addDMMsg(pub_key, outgoing, text, sender_timestamp); } + void markDmDelivered(uint32_t ack_crc) { _history.markDmDelivered(ack_crc); } // Rooms successfully logged in to this power-on session. RAM-only — the // server's ACL (see ClientACL) is the real permission store and survives @@ -810,55 +620,15 @@ public: _task->showAlert(success ? "Login OK" : "Login failed", 1200); } - // Look up a contact by 4-byte pub_key prefix (as stored in DmHistEntry). - bool contactByPrefix(const uint8_t* prefix, ContactInfo& out) const { - int total = the_mesh.getNumContacts(); - for (int i = 0; i < total; i++) { - ContactInfo c; - if (the_mesh.getContactByIdx(i, c) && memcmp(c.id.pub_key, prefix, 4) == 0) { - out = c; - return true; - } - } - return false; - } - // Background tick (called every UI loop, regardless of the active screen) that - // drives auto-resend of on-device DMs. When a pending DM passes its deadline - // with no ACK: resend with the next attempt# (reusing the original timestamp so - // the recipient dedups) while resends remain, else mark it failed (✗). - void tickDmResends() { - uint32_t now = millis(); - for (int i = 0; i < _dm_hist_count; i++) { - DmHistEntry& e = _dm_hist[(_dm_hist_head + i) % DM_HIST_MAX]; - if (!e.outgoing || e.ack_status != ACK_PENDING) continue; - if ((int32_t)(now - e.ack_deadline_ms) < 0) continue; // still waiting - if (e.resends_left == 0) { e.ack_status = ACK_FAIL; continue; } - ContactInfo c; - if (!contactByPrefix(e.prefix, c)) { e.ack_status = ACK_FAIL; continue; } - 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, - expected_ack, est_timeout) > 0 && expected_ack) { - e.attempt = next_attempt; - e.ack_tag = expected_ack; // each attempt has a distinct ACK CRC - e.ack_deadline_ms = now + est_timeout + 4000; - e.resends_left--; - } else { - e.ack_status = ACK_FAIL; // couldn't compose/send — give up - } - } - } + // drives auto-resend of on-device DMs — forwarded to the history store. + void tickDmResends() { _history.tickDmResends(); } int getDMUnreadTotal() const { return _task->getDMUnreadTotal(); } - int getTotalChannelUnread() const { - int total = 0; - for (int i = 0; i < MAX_GROUP_CHANNELS; i++) total += _ch_unread[i]; - return total; - } + int getTotalChannelUnread() const { return _history.getTotalChannelUnread(); } void markReadAlert(int n) { char msg[32]; @@ -866,10 +636,11 @@ public: _task->showAlert(msg, 800); } - void clearAllChannelUnread() { - memset(_ch_unread, 0, sizeof(_ch_unread)); - } + void clearAllChannelUnread() { _history.clearAllChannelUnread(); } + // Update the viewing-session unread bookkeeping (UI state) and push the + // resulting count down into the store. The ring lives in _history, but this + // "what has the user seen on screen" logic is pure phase-machine state. void updateChannelUnread() { if (_sel_channel_idx < 0 || _sel_channel_idx >= MAX_GROUP_CHANNELS) return; // histEntryForChannel is newest-first: index 0 = newest (unread), higher = older. @@ -881,7 +652,7 @@ public: if (seen_to > _viewing_max_seen) _viewing_max_seen = seen_to; // Each step down from 0 sees one more message; seen count = max_seen + 1. int remaining = _unread_at_entry - (_viewing_max_seen + 1); - _ch_unread[_sel_channel_idx] = (uint8_t)(remaining > 0 ? remaining : 0); + _history.setChUnread(_sel_channel_idx, (uint8_t)(remaining > 0 ? remaining : 0)); } void reset() { @@ -908,30 +679,9 @@ public: _viewing_max_seen = 0; } - // Recent DM contacts, newest first, deduped. Resolves the 4-byte - // _dm_hist prefix to a 6-byte pub_key prefix by walking the contact - // list once per unique sender. Returns the number filled. + // Recent DM contacts, newest first, deduped (forwarded to the history store). int getRecentDMContacts(uint8_t out[][NodePrefs::FAVOURITE_PREFIX_LEN], int max) const { - int n = 0; - for (int i = _dm_hist_count - 1; i >= 0 && n < max; i--) { - int pos = (_dm_hist_head + i) % DM_HIST_MAX; - const uint8_t* p4 = _dm_hist[pos].prefix; - // Skip if already collected. - bool dup = false; - for (int j = 0; j < n; j++) if (memcmp(out[j], p4, 4) == 0) { dup = true; break; } - if (dup) continue; - // Find a real contact whose pub_key starts with this 4-byte prefix. - for (int idx = 0; ; idx++) { - ContactInfo c; - if (!the_mesh.getContactByIdx(idx, c)) break; - if (memcmp(c.id.pub_key, p4, 4) == 0) { - memcpy(out[n], c.id.pub_key, NodePrefs::FAVOURITE_PREFIX_LEN); - n++; - break; - } - } - } - return n; + return _history.getRecentDMContacts(out, max); } // Jump straight into a contact's DM history (used by the Favourites dial). @@ -1082,7 +832,7 @@ public: drawRowSelection(display, y, sel, reserve); ChannelDetails ch; if (the_mesh.getChannel(_channel_indices[list_idx], ch)) { - uint8_t unread = _ch_unread[_channel_indices[list_idx]]; + uint8_t unread = _history.chUnread(_channel_indices[list_idx]); char badge[5] = ""; int bw = 0; if (unread > 0) { @@ -1106,21 +856,21 @@ public: display.translateUTF8ToBlocks(filtered_name, _sel_contact.name, sizeof(filtered_name)); if (_dm_fs.active && _dm_hist_sel >= 0) { - int ring_pos = dmHistEntryForContact(_sel_contact.id.pub_key, _dm_hist_sel); + int ring_pos = _history.dmHistEntryForContact(_sel_contact.id.pub_key, _dm_hist_sel); if (ring_pos >= 0) { - const DmHistEntry& e = _dm_hist[ring_pos]; + const DmHistEntry& e = _history.dmAtPos(ring_pos); char sender_buf[33]; const char* body = dmDisplayParts(e, _sel_contact.type == ADV_TYPE_ROOM, filtered_name, sender_buf, sizeof(sender_buf)); const char* sender = sender_buf; - int dm_count = dmHistCountForContact(_sel_contact.id.pub_key); + int dm_count = _history.dmHistCountForContact(_sel_contact.id.pub_key); int ret = _dm_fs.render(display, sender, body, _dm_hist_sel < dm_count - 1, _dm_hist_sel > 0); if (e.outgoing) { // delivery marker in the (inverted) header bar display.setColor(DisplayDriver::DARK); drawAckGlyph(display, 2 + display.getTextWidth(sender) + 3, 1, - dmEffectiveStatus(e), e.attempt + 1); + _history.dmEffectiveStatus(e), e.attempt + 1); display.setColor(DisplayDriver::LIGHT); } if (_ctx_menu.active) _ctx_menu.render(display); @@ -1138,7 +888,7 @@ public: display.drawTextCentered(display.width()/2, 0, title); display.fillRect(0, lh + 1, display.width(), display.sepH()); - int dm_count = dmHistCountForContact(_sel_contact.id.pub_key); + int dm_count = _history.dmHistCountForContact(_sel_contact.id.pub_key); uint32_t now_ts = rtc_clock.getCurrentTime(); bool is_room = (_sel_contact.type == ADV_TYPE_ROOM); @@ -1153,8 +903,8 @@ public: HistScroll hs = computeHistScroll(display, portrait_expand, dm_count, _dm_hist_scroll, hist_start_y, cby, lh, [&](int idx) -> const char* { - int rp = dmHistEntryForContact(_sel_contact.id.pub_key, idx); - return rp >= 0 ? _dm_hist[rp].text : nullptr; + int rp = _history.dmHistEntryForContact(_sel_contact.id.pub_key, idx); + return rp >= 0 ? _history.dmAtPos(rp).text : nullptr; }); int reserve = hs.reserve; { @@ -1163,10 +913,10 @@ public: for (int ii = 0; ii < MAX_VIS_BOXES && (_dm_hist_scroll + ii) < dm_count; ii++) { int bh = fixed_bh; if (portrait_expand) { - int rp = dmHistEntryForContact(_sel_contact.id.pub_key, _dm_hist_scroll + ii); + int rp = _history.dmHistEntryForContact(_sel_contact.id.pub_key, _dm_hist_scroll + ii); if (rp >= 0) { char hsb[33]; - const char* hbody = dmDisplayParts(_dm_hist[rp], is_room, filtered_name, hsb, sizeof(hsb)); + const char* hbody = dmDisplayParts(_history.dmAtPos(rp), is_room, filtered_name, hsb, sizeof(hsb)); display.translateUTF8ToBlocks(s_wrap_trans, hbody, sizeof(s_wrap_trans)); int nl = FullscreenMsgView::wrapLines(display, s_wrap_trans, display.width() - 6 - reserve, s_wrap_lines, 8); bh = (1 + (nl > 0 ? nl : 1)) * lh + 1; @@ -1184,10 +934,10 @@ public: int y = box_ys[i]; int bh = box_hs[i]; - int ring_pos = dmHistEntryForContact(_sel_contact.id.pub_key, item); + int ring_pos = _history.dmHistEntryForContact(_sel_contact.id.pub_key, item); if (ring_pos < 0) continue; - const DmHistEntry& e = _dm_hist[ring_pos]; + const DmHistEntry& e = _history.dmAtPos(ring_pos); char sender_buf[33]; const char* body = dmDisplayParts(e, is_room, filtered_name, sender_buf, sizeof(sender_buf)); const char* sender = sender_buf; @@ -1208,7 +958,7 @@ public: display.drawTextEllipsized(3, y + 1, display.width() - cw - 2 - age_w - reserve, sender); if (e.outgoing) { // delivery marker after "Me" int gx = 3 + display.getTextWidth(sender) + 3; - drawAckGlyph(display, gx, y + 1, dmEffectiveStatus(e), e.attempt + 1); + drawAckGlyph(display, gx, y + 1, _history.dmEffectiveStatus(e), e.attempt + 1); } if (age[0]) { display.setCursor(display.width() - age_w - reserve, y + 1); display.print(age); } if (!sel) display.setColor(DisplayDriver::LIGHT); @@ -1248,10 +998,10 @@ public: } else if (_phase == CHANNEL_HIST) { if (_fs.active && _hist_sel >= 0) { - int fs_hist_count = histCountForChannel(_sel_channel_idx); - int ring_pos = histEntryForChannel(_sel_channel_idx, _hist_sel); + int fs_hist_count = _history.histCountForChannel(_sel_channel_idx); + int ring_pos = _history.histEntryForChannel(_sel_channel_idx, _hist_sel); if (ring_pos >= 0) { - const char* ftext = _hist[ring_pos].text; + const char* ftext = _history.chAtPos(ring_pos).text; const char* fsep = strstr(ftext, ": "); char fsender[33], fmsg[MSG_TEXT_BUF]; if (fsep) { @@ -1266,7 +1016,7 @@ public: _hist_sel < fs_hist_count - 1, _hist_sel > 0); // Channels: ✓ only once a repeater echo confirms relay (see list view). - if (strcmp(fsender, "Me") == 0 && _hist[ring_pos].relay_status == ACK_OK) { + if (strcmp(fsender, "Me") == 0 && _history.chAtPos(ring_pos).relay_status == ACK_OK) { display.setColor(DisplayDriver::DARK); drawAckGlyph(display, 2 + display.getTextWidth(fsender) + 3, 1, ACK_OK); display.setColor(DisplayDriver::LIGHT); @@ -1287,7 +1037,7 @@ public: display.drawTextCentered(display.width()/2, 0, title); display.fillRect(0, lh + 1, display.width(), display.sepH()); - int ch_hist_count = histCountForChannel(_sel_channel_idx); + int ch_hist_count = _history.histCountForChannel(_sel_channel_idx); uint32_t now_ts = rtc_clock.getCurrentTime(); // Portrait e-ink (height > width): variable-height boxes that show the full @@ -1299,9 +1049,9 @@ public: HistScroll hs = computeHistScroll(display, portrait_expand, ch_hist_count, _hist_scroll, hist_start_y, cby, lh, [&](int idx) -> const char* { - int rp = histEntryForChannel(_sel_channel_idx, idx); + int rp = _history.histEntryForChannel(_sel_channel_idx, idx); if (rp < 0) return nullptr; - const char* t = _hist[rp].text; + const char* t = _history.chAtPos(rp).text; const char* s = strstr(t, ": "); return s ? s + 2 : t; }); @@ -1312,9 +1062,9 @@ public: for (int ii = 0; ii < MAX_VIS_BOXES && (_hist_scroll + ii) < ch_hist_count; ii++) { int bh = fixed_bh; if (portrait_expand) { - int rp = histEntryForChannel(_sel_channel_idx, _hist_scroll + ii); + int rp = _history.histEntryForChannel(_sel_channel_idx, _hist_scroll + ii); if (rp >= 0) { - const char* rtext = _hist[rp].text; + const char* rtext = _history.chAtPos(rp).text; const char* rsep = strstr(rtext, ": "); const char* rbody = rsep ? rsep + 2 : rtext; display.translateUTF8ToBlocks(s_wrap_trans, skipReplyPrefix(rbody), sizeof(s_wrap_trans)); @@ -1336,10 +1086,10 @@ public: int y = box_ys[i]; int bh = box_hs[i]; - int ring_pos = histEntryForChannel(_sel_channel_idx, item); + int ring_pos = _history.histEntryForChannel(_sel_channel_idx, item); if (ring_pos < 0) continue; - const char* text = _hist[ring_pos].text; + const char* text = _history.chAtPos(ring_pos).text; const char* sep = strstr(text, ": "); char sender[33], msg_part[MSG_TEXT_BUF]; if (sep) { @@ -1353,7 +1103,7 @@ public: msg_part[sizeof(msg_part) - 1] = '\0'; } - char age[6]; geo::fmtAgeShort(age, sizeof(age), now_ts, _hist[ring_pos].timestamp); + char age[6]; geo::fmtAgeShort(age, sizeof(age), now_ts, _history.chAtPos(ring_pos).timestamp); int age_w = age[0] ? display.getTextWidth(age) + 3 : 0; if (sel) { @@ -1370,7 +1120,7 @@ public: // Channels have no recipient ACK — only show ✓ once a repeater echo // confirms the send was relayed into the mesh; otherwise no marker // (absence is normal, not a failure). - if (strcmp(sender, "Me") == 0 && _hist[ring_pos].relay_status == ACK_OK) { + if (strcmp(sender, "Me") == 0 && _history.chAtPos(ring_pos).relay_status == ACK_OK) { int gx = 3 + display.getTextWidth(sender) + 3; drawAckGlyph(display, gx, y + 1, ACK_OK); } @@ -1710,8 +1460,8 @@ public: uint8_t ch_idx = _channel_indices[_channel_sel]; int sel = _ctx_menu.selectedIndex(); if (sel == 0) { - int cleared = (int)_ch_unread[ch_idx]; - _ch_unread[ch_idx] = 0; + int cleared = (int)_history.chUnread(ch_idx); + _history.setChUnread(ch_idx, 0); markReadAlert(cleared); } // sel 1/2/3 already handled by LEFT/RIGHT; ENTER just closes. @@ -1726,8 +1476,8 @@ public: if (c == KEY_ENTER && _num_channels > 0) { _sel_channel_idx = _channel_indices[_channel_sel]; if (_pick_target) { commitPickTargetChannel(_sel_channel_idx); return true; } - int hc = histCountForChannel(_sel_channel_idx); - _unread_at_entry = (int)_ch_unread[_sel_channel_idx]; + int hc = _history.histCountForChannel(_sel_channel_idx); + _unread_at_entry = (int)_history.chUnread(_sel_channel_idx); _hist_scroll = 0; _hist_sel = hc > 0 ? 0 : -1; _viewing_max_seen = _hist_sel >= 0 ? _hist_sel : 0; @@ -1760,7 +1510,7 @@ public: } } else if (_phase == DM_HIST) { - int dm_count = dmHistCountForContact(_sel_contact.id.pub_key); + int dm_count = _history.dmHistCountForContact(_sel_contact.id.pub_key); if (_dm_fs.active) { if (_ctx_menu.active) { auto res = _ctx_menu.handleInput(c); @@ -1779,11 +1529,11 @@ public: } else if (res == FullscreenMsgView::CLOSE) { _dm_fs.active = false; } else if (res == FullscreenMsgView::REPLY) { - int ring_pos = dmHistEntryForContact(_sel_contact.id.pub_key, _dm_hist_sel); + int ring_pos = _history.dmHistEntryForContact(_sel_contact.id.pub_key, _dm_hist_sel); if (ring_pos >= 0) { - bool reply_ok = !_dm_hist[ring_pos].outgoing; - if (reply_ok) buildDmReplyPrefix(_dm_hist[ring_pos]); - buildFsMenu(_dm_hist[ring_pos].text, reply_ok); + bool reply_ok = !_history.dmAtPos(ring_pos).outgoing; + if (reply_ok) buildDmReplyPrefix(_history.dmAtPos(ring_pos)); + buildFsMenu(_history.dmAtPos(ring_pos).text, reply_ok); } } return true; @@ -1835,17 +1585,17 @@ public: return true; } if (c == KEY_CONTEXT_MENU && _dm_hist_sel >= 0) { - int ring_pos = dmHistEntryForContact(_sel_contact.id.pub_key, _dm_hist_sel); + int ring_pos = _history.dmHistEntryForContact(_sel_contact.id.pub_key, _dm_hist_sel); if (ring_pos >= 0) { - bool reply_ok = !_dm_hist[ring_pos].outgoing; - if (reply_ok) buildDmReplyPrefix(_dm_hist[ring_pos]); - buildFsMenu(_dm_hist[ring_pos].text, reply_ok); + bool reply_ok = !_history.dmAtPos(ring_pos).outgoing; + if (reply_ok) buildDmReplyPrefix(_history.dmAtPos(ring_pos)); + buildFsMenu(_history.dmAtPos(ring_pos).text, reply_ok); } return true; } } else if (_phase == CHANNEL_HIST) { - int ch_hist_count = histCountForChannel(_sel_channel_idx); + int ch_hist_count = _history.histCountForChannel(_sel_channel_idx); if (_fs.active) { if (_ctx_menu.active) { auto res = _ctx_menu.handleInput(c); @@ -1864,9 +1614,9 @@ public: } else if (res == FullscreenMsgView::CLOSE) { _fs.active = false; } else if (res == FullscreenMsgView::REPLY) { - int ring_pos = histEntryForChannel(_sel_channel_idx, _hist_sel); + int ring_pos = _history.histEntryForChannel(_sel_channel_idx, _hist_sel); if (ring_pos >= 0) - buildFsMenu(_hist[ring_pos].text, buildChannelReplyPrefix(_hist[ring_pos].text)); + buildFsMenu(_history.chAtPos(ring_pos).text, buildChannelReplyPrefix(_history.chAtPos(ring_pos).text)); } return true; } @@ -1907,9 +1657,9 @@ public: return true; } if (c == KEY_CONTEXT_MENU && _hist_sel >= 0) { - int ring_pos = histEntryForChannel(_sel_channel_idx, _hist_sel); + int ring_pos = _history.histEntryForChannel(_sel_channel_idx, _hist_sel); if (ring_pos >= 0) - buildFsMenu(_hist[ring_pos].text, buildChannelReplyPrefix(_hist[ring_pos].text)); + buildFsMenu(_history.chAtPos(ring_pos).text, buildChannelReplyPrefix(_history.chAtPos(ring_pos).text)); return true; } diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index d17a813e..95334873 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -130,6 +130,7 @@ static const int QUICK_MSGS_MAX = 10; #include "FullscreenMsgView.h" #include "SensorPlaceholders.h" #include "SettingsScreen.h" +#include "MessageHistory.h" // RAM history rings (DM + channel) used by QuickMsgScreen #include "QuickMsgScreen.h" // ── Custom screens (separate files to ease upstream merges) ─────────────────── From 17cc51e84d85ed66f635dc8bc47cd4b0c32a03a6 Mon Sep 17 00:00:00 2001 From: MarekZegare4 Date: Mon, 29 Jun 2026 22:48:10 +0200 Subject: [PATCH 26/29] refactor(companion): dedupe notif/melody overrides via shared primitives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The eight per-contact/channel notification + melody accessors were four near-identical copies of two storage shapes. Collapse them onto two primitives: - maskPairGet/Set — a 3-state packed in a (presence, variant) channel-index bitmask pair. The notif and melody uses disagree on which state the variant bit means, so the caller passes the state value for variant-set (v_set). - prefTableGet/Set — the {prefix[4], value} DM table find/update/clear/ insert/overwrite-slot-0 logic, templated over the entry struct + a member pointer to its value field (DmNotifEntry::state / DmMelodyEntry::slot). The eight accessors are now thin wrappers; behaviour identical (verified state↔bit mappings). QuickMsgScreen 1738 -> 1715 lines. Co-Authored-By: Claude Opus 4.8 --- .../companion_radio/ui-new/QuickMsgScreen.h | 145 ++++++++---------- 1 file changed, 61 insertions(+), 84 deletions(-) diff --git a/examples/companion_radio/ui-new/QuickMsgScreen.h b/examples/companion_radio/ui-new/QuickMsgScreen.h index d54a1a3b..50e7805d 100644 --- a/examples/companion_radio/ui-new/QuickMsgScreen.h +++ b/examples/companion_radio/ui-new/QuickMsgScreen.h @@ -424,115 +424,92 @@ class QuickMsgScreen : public UIScreen { } // Returns per-channel notification state: 0=follow global, 1=muted, 2=force-on - uint8_t chNotifState(uint8_t ch_idx) const { - NodePrefs* p = _task->getNodePrefs(); - if (!p) return 0; - uint64_t mask = 1ULL << ch_idx; - if (!(p->ch_notif_override & mask)) return 0; - return (p->ch_notif_muted & mask) ? 1 : 2; + // Per-contact/channel notification + melody overrides share two storage + // shapes, so the eight accessors below are thin wrappers over two primitives: + // + // • Channels — a 3-state packed into a pair of channel-index bitmasks: a + // "presence" mask (is there an override at all) + a "variant" mask. The two + // uses disagree on which state the variant bit means, so the caller passes + // the state value that corresponds to variant-set (v_set). + // • DMs — a small {prefix[4], value} table: find by 4-byte prefix, update or + // clear (clear frees the slot), else insert into the first free slot, else + // overwrite slot 0. value 0 == "no override" == empty slot. + + static uint8_t maskPairGet(uint64_t presence, uint64_t variant, uint8_t idx, + uint8_t v_set, uint8_t v_clr) { + uint64_t m = 1ULL << idx; + if (!(presence & m)) return 0; + return (variant & m) ? v_set : v_clr; + } + static void maskPairSet(uint64_t& presence, uint64_t& variant, uint8_t idx, + uint8_t state, uint8_t v_set) { + uint64_t m = 1ULL << idx; + if (state == 0) { presence &= ~m; variant &= ~m; return; } + presence |= m; + if (state == v_set) variant |= m; else variant &= ~m; } + template + static uint8_t prefTableGet(const Entry* tbl, int n, const uint8_t* pub_key, + uint8_t Entry::* val) { + for (int i = 0; i < n; i++) + if (tbl[i].*val && memcmp(tbl[i].prefix, pub_key, 4) == 0) return tbl[i].*val; + return 0; + } + template + static void prefTableSet(Entry* tbl, int n, const uint8_t* pub_key, + uint8_t Entry::* val, uint8_t v) { + for (int i = 0; i < n; i++) + if (tbl[i].*val && memcmp(tbl[i].prefix, pub_key, 4) == 0) { + if (v == 0) memset(&tbl[i], 0, sizeof(tbl[i])); else tbl[i].*val = v; + return; + } + if (v == 0) return; + for (int i = 0; i < n; i++) + if (tbl[i].*val == 0) { memcpy(tbl[i].prefix, pub_key, 4); tbl[i].*val = v; return; } + memcpy(tbl[0].prefix, pub_key, 4); tbl[0].*val = v; // table full — overwrite slot 0 + } + + // Channel notif: state 1 = muted (variant bit set), 2 = force-on (variant clear). + uint8_t chNotifState(uint8_t ch_idx) const { + NodePrefs* p = _task->getNodePrefs(); + return p ? maskPairGet(p->ch_notif_override, p->ch_notif_muted, ch_idx, 1, 2) : 0; + } void setChNotifState(uint8_t ch_idx, uint8_t state) { NodePrefs* p = _task->getNodePrefs(); - if (!p) return; - uint64_t mask = 1ULL << ch_idx; - if (state == 0) { - p->ch_notif_override &= ~mask; - p->ch_notif_muted &= ~mask; - } else if (state == 1) { - p->ch_notif_override |= mask; - p->ch_notif_muted |= mask; - } else { - p->ch_notif_override |= mask; - p->ch_notif_muted &= ~mask; - } + if (p) maskPairSet(p->ch_notif_override, p->ch_notif_muted, ch_idx, state, 1); } uint8_t dmNotifState(const uint8_t* pub_key) const { NodePrefs* p = _task->getNodePrefs(); - if (!p) return 0; - for (int i = 0; i < NodePrefs::DM_NOTIF_TABLE_MAX; i++) - if (p->dm_notif[i].state && memcmp(p->dm_notif[i].prefix, pub_key, 4) == 0) - return p->dm_notif[i].state; - return 0; + return p ? prefTableGet(p->dm_notif, NodePrefs::DM_NOTIF_TABLE_MAX, pub_key, + &NodePrefs::DmNotifEntry::state) : 0; } - void setDmNotifState(const uint8_t* pub_key, uint8_t state) { NodePrefs* p = _task->getNodePrefs(); - if (!p) return; - for (int i = 0; i < NodePrefs::DM_NOTIF_TABLE_MAX; i++) { - if (p->dm_notif[i].state && memcmp(p->dm_notif[i].prefix, pub_key, 4) == 0) { - if (state == 0) { memset(&p->dm_notif[i], 0, sizeof(p->dm_notif[i])); } - else p->dm_notif[i].state = state; - return; - } - } - if (state == 0) return; - for (int i = 0; i < NodePrefs::DM_NOTIF_TABLE_MAX; i++) { - if (p->dm_notif[i].state == 0) { - memcpy(p->dm_notif[i].prefix, pub_key, 4); - p->dm_notif[i].state = state; - return; - } - } - // table full — overwrite slot 0 - memcpy(p->dm_notif[0].prefix, pub_key, 4); - p->dm_notif[0].state = state; + if (p) prefTableSet(p->dm_notif, NodePrefs::DM_NOTIF_TABLE_MAX, pub_key, + &NodePrefs::DmNotifEntry::state, state); } + // Channel melody: slot 1 = melody 1 (variant bit clear), 2 = melody 2 (set). uint8_t chNotifMelody(uint8_t ch_idx) const { NodePrefs* p = _task->getNodePrefs(); - if (!p) return 0; - uint64_t mask = 1ULL << ch_idx; - if (!(p->ch_notif_melody_set & mask)) return 0; - return (p->ch_notif_melody_2 & mask) ? 2 : 1; + return p ? maskPairGet(p->ch_notif_melody_set, p->ch_notif_melody_2, ch_idx, 2, 1) : 0; } - void setChNotifMelody(uint8_t ch_idx, uint8_t slot) { NodePrefs* p = _task->getNodePrefs(); - if (!p) return; - uint64_t mask = 1ULL << ch_idx; - if (slot == 0) { - p->ch_notif_melody_set &= ~mask; - p->ch_notif_melody_2 &= ~mask; - } else if (slot == 1) { - p->ch_notif_melody_set |= mask; - p->ch_notif_melody_2 &= ~mask; - } else { - p->ch_notif_melody_set |= mask; - p->ch_notif_melody_2 |= mask; - } + if (p) maskPairSet(p->ch_notif_melody_set, p->ch_notif_melody_2, ch_idx, slot, 2); } uint8_t dmMelodySlot(const uint8_t* pub_key) const { NodePrefs* p = _task->getNodePrefs(); - if (!p) return 0; - for (int i = 0; i < NodePrefs::DM_MELODY_TABLE_MAX; i++) - if (p->dm_melody[i].slot && memcmp(p->dm_melody[i].prefix, pub_key, 4) == 0) - return p->dm_melody[i].slot; - return 0; + return p ? prefTableGet(p->dm_melody, NodePrefs::DM_MELODY_TABLE_MAX, pub_key, + &NodePrefs::DmMelodyEntry::slot) : 0; } - void setDmMelody(const uint8_t* pub_key, uint8_t slot) { NodePrefs* p = _task->getNodePrefs(); - if (!p) return; - for (int i = 0; i < NodePrefs::DM_MELODY_TABLE_MAX; i++) { - if (p->dm_melody[i].slot && memcmp(p->dm_melody[i].prefix, pub_key, 4) == 0) { - if (slot == 0) memset(&p->dm_melody[i], 0, sizeof(p->dm_melody[i])); - else p->dm_melody[i].slot = slot; - return; - } - } - if (slot == 0) return; - for (int i = 0; i < NodePrefs::DM_MELODY_TABLE_MAX; i++) { - if (p->dm_melody[i].slot == 0) { - memcpy(p->dm_melody[i].prefix, pub_key, 4); - p->dm_melody[i].slot = slot; - return; - } - } - memcpy(p->dm_melody[0].prefix, pub_key, 4); - p->dm_melody[0].slot = slot; + if (p) prefTableSet(p->dm_melody, NodePrefs::DM_MELODY_TABLE_MAX, pub_key, + &NodePrefs::DmMelodyEntry::slot, slot); } public: From efb6a8b629c3ba636d33b9c0a32dc6915816bc25 Mon Sep 17 00:00:00 2001 From: MarekZegare4 Date: Mon, 29 Jun 2026 22:59:59 +0200 Subject: [PATCH 27/29] fix(companion): keep [+ send] visible in DM/room history when not selected In the DM/room message history, the message loop leaves the ink DARK when the last drawn box is the selected one, so the unselected [+ send] row printed dark-on-dark and vanished. Most visible in room servers, where there's always a selected message. Reset to LIGHT before drawing the row (matching the channel-history version). Pre-existing bug, independent of the history-store refactor. Co-Authored-By: Claude Opus 4.8 --- examples/companion_radio/ui-new/QuickMsgScreen.h | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/examples/companion_radio/ui-new/QuickMsgScreen.h b/examples/companion_radio/ui-new/QuickMsgScreen.h index 50e7805d..07904072 100644 --- a/examples/companion_radio/ui-new/QuickMsgScreen.h +++ b/examples/companion_radio/ui-new/QuickMsgScreen.h @@ -963,6 +963,11 @@ public: const char* ctxt = "[+ send]"; int ctw = display.getTextWidth(ctxt); int cbx = 1; + // Reset to LIGHT first: the message loop above leaves the ink DARK when the + // last drawn box was the selected one, which would render an unselected + // [+ send] invisibly (dark-on-dark) — most visible in rooms, where there's + // always a selected message. With it selected we invert (light bar/dark text). + display.setColor(DisplayDriver::LIGHT); if (compose_sel) { display.fillRect(cbx, cby - 1, ctw + 4, lh + 1); display.setColor(DisplayDriver::DARK); From 116ee3e7670cc47f631b112d6ca57556004a67af Mon Sep 17 00:00:00 2001 From: MarekZegare4 Date: Mon, 29 Jun 2026 23:05:11 +0200 Subject: [PATCH 28/29] refactor(companion): share history row-frame + compose button across DM/channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DM/room and channel history lists drew each message box's selection frame and the bottom-left [+ send] button with byte-identical inline code. Extract two small static helpers (drawHistRowFrame, drawComposeButton) and route both lists through them. Only the parts that were already identical are unified — the divergent per-type logic (sender parsing, ACK semantics, body handling) stays inline, so this is behaviour-preserving. The DM compose button now also gets the channel list's border (and its always-reset-to-LIGHT), folding in the earlier [+ send] visibility fix. Co-Authored-By: Claude Opus 4.8 --- .../companion_radio/ui-new/QuickMsgScreen.h | 84 +++++++------------ 1 file changed, 32 insertions(+), 52 deletions(-) diff --git a/examples/companion_radio/ui-new/QuickMsgScreen.h b/examples/companion_radio/ui-new/QuickMsgScreen.h index 07904072..08911c86 100644 --- a/examples/companion_radio/ui-new/QuickMsgScreen.h +++ b/examples/companion_radio/ui-new/QuickMsgScreen.h @@ -317,6 +317,34 @@ class QuickMsgScreen : public UIScreen { } } + // Selection frame for one history message box, shared by the DM/room and + // channel history lists. Selected = solid fill; unselected = outline with a + // filled header strip. Leaves the ink DARK (for the sender row drawn next). + static void drawHistRowFrame(DisplayDriver& d, int y, int bh, int reserve, int lh, bool sel) { + d.setColor(DisplayDriver::LIGHT); + if (sel) { + d.fillRect(0, y, d.width() - reserve, bh); + } else { + d.drawRect(0, y, d.width() - reserve, bh); + d.fillRect(1, y + 1, d.width() - 2 - reserve, lh); + } + d.setColor(DisplayDriver::DARK); + } + + // Bottom-left "[+ send]" compose button, shared by both history lists: + // bordered when idle, inverted (filled) when selected. Always reset to LIGHT + // first so it stays visible regardless of the ink the message loop left. + static void drawComposeButton(DisplayDriver& d, int cby, int lh, bool sel) { + const char* ctxt = "[+ send]"; + int ctw = d.getTextWidth(ctxt); + d.setColor(DisplayDriver::LIGHT); + if (sel) { d.fillRect(0, cby - 1, ctw + 4, lh + 2); d.setColor(DisplayDriver::DARK); } + else d.drawRect(0, cby - 1, ctw + 4, lh + 2); + d.setCursor(2, cby); + d.print(ctxt); + d.setColor(DisplayDriver::LIGHT); + } + void afterSend(bool ok, const char* msg) { _reply_mode = false; _share_mode = false; @@ -922,16 +950,7 @@ public: char age[6]; geo::fmtAgeShort(age, sizeof(age), now_ts, e.timestamp); int age_w = age[0] ? display.getTextWidth(age) + 3 : 0; - if (sel) { - display.setColor(DisplayDriver::LIGHT); - display.fillRect(0, y, display.width() - reserve, bh); - display.setColor(DisplayDriver::DARK); - } else { - display.setColor(DisplayDriver::LIGHT); - display.drawRect(0, y, display.width() - reserve, bh); - display.fillRect(1, y + 1, display.width() - 2 - reserve, lh); - display.setColor(DisplayDriver::DARK); - } + drawHistRowFrame(display, y, bh, reserve, lh, sel); display.drawTextEllipsized(3, y + 1, display.width() - cw - 2 - age_w - reserve, sender); if (e.outgoing) { // delivery marker after "Me" int gx = 3 + display.getTextWidth(sender) + 3; @@ -959,22 +978,7 @@ public: drawScrollIndicatorPx(display, hist_start_y + 1, hs.view_px, hs.total_px, hs.view_px, hs.scroll_px); - bool compose_sel = (_dm_hist_sel == -1); - const char* ctxt = "[+ send]"; - int ctw = display.getTextWidth(ctxt); - int cbx = 1; - // Reset to LIGHT first: the message loop above leaves the ink DARK when the - // last drawn box was the selected one, which would render an unselected - // [+ send] invisibly (dark-on-dark) — most visible in rooms, where there's - // always a selected message. With it selected we invert (light bar/dark text). - display.setColor(DisplayDriver::LIGHT); - if (compose_sel) { - display.fillRect(cbx, cby - 1, ctw + 4, lh + 1); - display.setColor(DisplayDriver::DARK); - } - display.setCursor(cbx + 2, cby); - display.print(ctxt); - display.setColor(DisplayDriver::LIGHT); + drawComposeButton(display, cby, lh, _dm_hist_sel == -1); if (_ctx_menu.active) _ctx_menu.render(display); return dm_count > 0 ? 500 : 2000; @@ -1088,16 +1092,7 @@ public: char age[6]; geo::fmtAgeShort(age, sizeof(age), now_ts, _history.chAtPos(ring_pos).timestamp); int age_w = age[0] ? display.getTextWidth(age) + 3 : 0; - if (sel) { - display.setColor(DisplayDriver::LIGHT); - display.fillRect(0, y, display.width() - reserve, bh); - display.setColor(DisplayDriver::DARK); - } else { - display.setColor(DisplayDriver::LIGHT); - display.drawRect(0, y, display.width() - reserve, bh); - display.fillRect(1, y + 1, display.width() - 2 - reserve, lh); - display.setColor(DisplayDriver::DARK); - } + drawHistRowFrame(display, y, bh, reserve, lh, sel); display.drawTextEllipsized(3, y + 1, display.width() - cw - 2 - age_w - reserve, sender); // Channels have no recipient ACK — only show ✓ once a repeater echo // confirms the send was relayed into the mesh; otherwise no marker @@ -1127,22 +1122,7 @@ public: drawScrollIndicatorPx(display, hist_start_y + 1, hs.view_px, hs.total_px, hs.view_px, hs.scroll_px); - // small compose button (bottom-left, always bordered, inverted when selected) - bool compose_sel = (_hist_sel == -1); - const char* ctxt = "[+ send]"; - int ctw = display.getTextWidth(ctxt); - int cbx = 1; - if (compose_sel) { - display.setColor(DisplayDriver::LIGHT); - display.fillRect(cbx - 1, cby - 1, ctw + 4, lh + 2); - display.setColor(DisplayDriver::DARK); - } else { - display.setColor(DisplayDriver::LIGHT); - display.drawRect(cbx - 1, cby - 1, ctw + 4, lh + 2); - } - display.setCursor(cbx + 1, cby); - display.print(ctxt); - display.setColor(DisplayDriver::LIGHT); + drawComposeButton(display, cby, lh, _hist_sel == -1); if (_ctx_menu.active) _ctx_menu.render(display); } else if (_phase == KEYBOARD) { From 9ce04835da9bc5b4034bbe4241aeae389f948206 Mon Sep 17 00:00:00 2001 From: MarekZegare4 Date: Tue, 30 Jun 2026 01:54:01 +0200 Subject: [PATCH 29/29] =?UTF-8?q?feat(companion):=20clock=20tools=20?= =?UTF-8?q?=E2=80=94=20alarm,=20countdown=20timer,=20stopwatch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Clock Tools screen (Enter on the home Clock page) with three time utilities, plus the engine that drives them from UITask::loop() so they work regardless of the current screen / display state: - Alarm: a single one-shot wake alarm. Persisted in NodePrefs as a local time-of-day; UITask schedules it as an ABSOLUTE fire instant recomputed from that time, so it survives RTC re-syncs (mesh/app/GPS/CLI all jump the clock) — small corrections still fire on time, a jump over the target still fires (late, up to 6 h). Disarms after firing. A bell icon signals an armed alarm on the clock face and the top status bar. - Timer: a millis-based countdown (sync-immune), big HH:MM:SS readout, rings even when off-screen. - Stopwatch: millis-based, keeps running in the background. Numeric fields use the shared framework DigitEditor (digit-by-digit, min/max enforced). The alarm/timer/ring engine lives in UITask alongside the locator/live-share engines (no screen-cast for time-critical logic). E-ink: live readouts refresh coarsely (and on any key); timing is exact regardless, and the countdown's buzzer fires on time. Rings override mute and are dismissed by any key (auto-stop after 1 min). Cannot wake from a full Shutdown (CPU/RAM powered down). NodePrefs: +alarm_on/alarm_hour/alarm_min (fit existing tail padding, sizeof unchanged 2488), SCHEMA_SENTINEL 0xC0DE0016 -> 0xC0DE0017, DataStore read/clamp/write in lockstep. Co-Authored-By: Claude Opus 4.8 --- .../clock_screen/clock_screen.md | 30 ++ examples/companion_radio/DataStore.cpp | 10 + examples/companion_radio/NodePrefs.h | 14 +- .../companion_radio/ui-new/ClockToolsScreen.h | 298 ++++++++++++++++++ examples/companion_radio/ui-new/UITask.cpp | 113 ++++++- examples/companion_radio/ui-new/UITask.h | 38 +++ examples/companion_radio/ui-new/icons.h | 8 + 7 files changed, 509 insertions(+), 2 deletions(-) create mode 100644 examples/companion_radio/ui-new/ClockToolsScreen.h diff --git a/docs/solo_features/clock_screen/clock_screen.md b/docs/solo_features/clock_screen/clock_screen.md index 06c54502..a4df91d9 100644 --- a/docs/solo_features/clock_screen/clock_screen.md +++ b/docs/solo_features/clock_screen/clock_screen.md @@ -59,3 +59,33 @@ Sensor fields show `--` when the sensor is not connected or has no data. **Hold Enter** (or press the **Context menu** key) on the Clock page to open the Dashboard Config screen, where each of the three field slots can be cycled with **LEFT/RIGHT**. + +--- + +### Clock tools — Alarm, Timer, Stopwatch + +**Press Enter** (short press) on the Clock page to open **Clock Tools**, a small menu with three time utilities. **Cancel** backs out one level (tool → menu → home). + +#### Alarm + +A single one-shot wake alarm. Rows: **Hour**, **Minute** and **Armed**. **Enter** on Hour or Minute opens the digit editor (LEFT/RIGHT moves between the tens/units, UP/DOWN changes the digit); **Enter** on Armed toggles ON/OFF. The configured time is shown next to the **Alarm** menu row when armed, and the setting persists across reboots. + +While an alarm is armed a bell icon signals it in two places: the top-left corner of the **Clock page** itself, and the **top status bar** of the other home pages (the status bar is hidden on the Clock page, which is why the clock face carries its own indicator). The bell is icon-only — the exact alarm time is on the **Alarm** row inside Clock Tools. + +The alarm is scheduled as an absolute fire instant, so it is **robust to clock re-syncs** — the mesh (every inbound packet), the companion app, GPS and the CLI can all jump the device clock at any moment. A correction that moves the clock a little still fires at the right wall-clock time; a jump that skips over the alarm time still fires (late). After firing once, the alarm disarms itself. + +The alarm only fires while the device is **awake** (it keeps running with the display off or locked). It cannot wake the device from a full **Shutdown** (the CPU and RAM are powered down), and needs a valid time source — it stays pending until the clock is synced. + +#### Timer (countdown) + +A large **HH:MM:SS** readout with one digit underlined. **LEFT/RIGHT** moves the cursor one digit at a time, **Up/Down** changes the digit under it (minute/second tens cap at 5, hours at 23), and **Enter** starts the countdown. While running it shows **H:MM:SS** — **Enter** stops it, **Cancel** returns to the menu and leaves it counting. When it reaches zero the device rings, even if you have navigated to another screen. + +#### Stopwatch + +**Enter** starts/stops; **Up/Down** resets when stopped; **Cancel** returns to the menu and leaves it running. + +#### Ringing + +When the alarm or timer fires the device plays a melody (overriding mute) and shows an alert. **Any key** silences it; otherwise it stops on its own after a minute. + +> **E-ink note:** the live timer/stopwatch readouts would thrash a slow e-paper panel if redrawn every second, so on e-ink they refresh only coarsely (and immediately on any key press). The underlying timing is exact regardless, and the countdown's buzzer always fires on time. diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index 8b0d0fc7..b8a5ad7e 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -421,6 +421,13 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no // → 0xC0DE0016: GPS-averaging duration for waypoint marking. rd(&_prefs.gps_avg_idx, sizeof(_prefs.gps_avg_idx)); if (_prefs.gps_avg_idx >= NodePrefs::GPS_AVG_COUNT) _prefs.gps_avg_idx = 0; + // → 0xC0DE0017: one-shot alarm clock (local time-of-day + armed flag). + rd(&_prefs.alarm_on, sizeof(_prefs.alarm_on)); + rd(&_prefs.alarm_hour, sizeof(_prefs.alarm_hour)); + rd(&_prefs.alarm_min, sizeof(_prefs.alarm_min)); + if (_prefs.alarm_on > 1) _prefs.alarm_on = 0; + if (_prefs.alarm_hour > 23) _prefs.alarm_hour = 0; + if (_prefs.alarm_min > 59) _prefs.alarm_min = 0; // Pre-0x10 files leave stray sentinel bytes here, same as a never-configured // device. Either way there's no valid saved profile, so default to a profile // in the same band as the companion's own network (_prefs.freq, already read @@ -622,6 +629,9 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_ file.write((uint8_t *)&_prefs.locator_target_kind, sizeof(_prefs.locator_target_kind)); file.write((uint8_t *)_prefs.locator_key, sizeof(_prefs.locator_key)); file.write((uint8_t *)&_prefs.gps_avg_idx, sizeof(_prefs.gps_avg_idx)); + file.write((uint8_t *)&_prefs.alarm_on, sizeof(_prefs.alarm_on)); + file.write((uint8_t *)&_prefs.alarm_hour, sizeof(_prefs.alarm_hour)); + file.write((uint8_t *)&_prefs.alarm_min, sizeof(_prefs.alarm_min)); // Tail sentinel — must be last. See NodePrefs::SCHEMA_SENTINEL. Its write is // the one we check: once the flash fills, writes return 0, so a good diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 7dcb3fd7..972392a5 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -270,6 +270,18 @@ struct NodePrefs { // persisted to file // Index into gpsAvgSecs(). [Tools › Trail › Settings › Mark avg] uint8_t gps_avg_idx; + // Alarm clock — a single one-shot wake alarm, configured from the Clock page + // (Enter › Alarm). Stored as a local time-of-day; the actual fire instant is + // (re)computed as an absolute time in tickBackground(), which is what makes it + // robust to RTC re-syncs: the mesh (every inbound packet), the companion app, + // GPS and the CLI can all jump getCurrentTime() at any moment, but an absolute + // target instant stays correct across small corrections and still fires (late) + // if the clock jumps over it. Fires once, then alarm_on clears. The minutnik + // (countdown) and stoper (stopwatch) are runtime-only and not persisted. + uint8_t alarm_on; // 0=off (default), 1=armed (one-shot) + uint8_t alarm_hour; // 0-23, local time + uint8_t alarm_min; // 0-59 + // Single source of truth for the live-share option tables (shared by the Map // UI labels and the auto-send engine in UITask). static const uint8_t LOC_SHARE_MOVE_COUNT = 4; @@ -332,7 +344,7 @@ struct NodePrefs { // persisted to file // adding/removing/reordering fields in DataStore::savePrefs/loadPrefsInt so // older saves are detected on load and skipped (zero-init defaults kept). // High 24 bits identify the file format; low byte is the schema revision. - static const uint32_t SCHEMA_SENTINEL = 0xC0DE0016; + static const uint32_t SCHEMA_SENTINEL = 0xC0DE0017; // Bit-index for each home page. Used by page_order (entries store bit+1) and // by home_pages_mask. Single source of truth — both HomeScreen::pageBit/bitToPage diff --git a/examples/companion_radio/ui-new/ClockToolsScreen.h b/examples/companion_radio/ui-new/ClockToolsScreen.h new file mode 100644 index 00000000..f6d80d4b --- /dev/null +++ b/examples/companion_radio/ui-new/ClockToolsScreen.h @@ -0,0 +1,298 @@ +#pragma once +// Clock tools — Alarm, Countdown timer (minutnik) and Stopwatch (stoper). +// Entered with Enter on the home CLOCK page. A small top menu picks one of the +// three tools; Cancel backs out a level (tool → menu → home). +// +// This screen is pure UI. The time-critical machinery lives in UITask, which +// drives it every loop regardless of the current screen: +// • Alarm — UITask schedules an absolute fire instant from NodePrefs' +// alarm_hour/min (robust to RTC re-syncs) and rings. Here we just edit the +// persisted fields and call onAlarmChanged() to re-schedule. +// • Timer — UITask owns the running countdown (startTimer / stopTimer / +// isTimerRunning / timerRemainingMs). It rings even when off-screen. +// • Stopwatch — purely a display utility with no background action, so its +// millis() state lives here; it keeps counting while you're elsewhere. +// +// Numeric fields (alarm hour/minute, timer h/m/s) are edited with the shared +// framework DigitEditor (digit-by-digit: LEFT/RIGHT cursor, UP/DOWN +/- the +// place, min/max enforced) — the same widget Settings/Repeater use for Freq, +// rather than a hand-rolled wrap. +// +// E-INK: the running timer/stopwatch readouts would thrash a slow e-paper panel +// if redrawn every second, so on e-ink the live readout refreshes only coarsely +// (and immediately on any key); the millis() timing underneath is unaffected. +// Included by UITask.cpp after the other screen fragments. + +#include "../NodePrefs.h" +#include "icons.h" // drawList / drawRowSelection +#include "DigitEditor.h" // shared digit-by-digit numeric editor + +class ClockToolsScreen : public UIScreen { + UITask* _task; + NodePrefs* _prefs; + + enum View : uint8_t { V_MENU, V_ALARM, V_TIMER, V_STOPWATCH }; + uint8_t _view = V_MENU; + int _sel = 0, _scroll = 0; // shared list cursor + bool _alarm_dirty = false; // unsaved edits to alarm_* (persist on exit) + + // Countdown config (the duration to start; the running countdown lives in UITask). + uint8_t _timer_h = 0, _timer_m = 5, _timer_s = 0; + uint8_t _timer_cur = 0; // digit cursor over HH:MM:SS, 0..5 (skips the colons) + + // Stopwatch (millis — display-only, no background action). + bool _sw_running = false; + uint32_t _sw_start_ms = 0; + uint32_t _sw_accum_ms = 0; + + // Shared numeric editor (alarm hour/minute) + which field it targets. The + // timer uses its own continuous digit cursor (per-place caps a single + // DigitEditor can't express), so it isn't listed here. + enum EditKind : uint8_t { EK_NONE, EK_A_HOUR, EK_A_MIN }; + DigitEditor _editor; + EditKind _edit_kind = EK_NONE; + + // E-ink: coarse refresh for the live readouts (millis underneath is exact). + static int liveTickMs(DisplayDriver& d, int oled_ms) { return d.isEink() ? 30000 : oled_ms; } + + static void fmtClock(char* b, int n, int hh, int mm) { snprintf(b, n, "%02d:%02d", hh, mm); } + + // millis → "H:MM:SS" (the countdown always shows hours). + static void fmtHMS(char* b, int n, uint32_t ms) { + uint32_t s = ms / 1000, h = s / 3600; s %= 3600; + uint32_t m = s / 60; s %= 60; + snprintf(b, n, "%lu:%02lu:%02lu", (unsigned long)h, (unsigned long)m, (unsigned long)s); + } + + // Stopwatch readout: "M:SS.t" while under an hour (tenths only on a fast + // panel), "H:MM:SS" once it rolls past an hour. + static void fmtStopwatch(char* b, int n, uint32_t ms, bool tenths) { + if (ms >= 3600000UL) { fmtHMS(b, n, ms); return; } + uint32_t total_s = ms / 1000, m = total_s / 60, s = total_s % 60; + if (tenths) snprintf(b, n, "%lu:%02lu.%lu", (unsigned long)m, (unsigned long)s, + (unsigned long)((ms % 1000) / 100)); + else snprintf(b, n, "%lu:%02lu", (unsigned long)m, (unsigned long)s); + } + + uint32_t stopwatchMs() const { + return _sw_accum_ms + (_sw_running ? (millis() - _sw_start_ms) : 0); + } + + // Step the digit under the timer cursor by dir (+1/−1), wrapping within that + // place and keeping each field valid: minute/second tens 0-5, hours 0-23. + void stepTimerDigit(int dir) { + uint8_t* f; int tens_max; bool is_hours = false; + if (_timer_cur < 2) { f = &_timer_h; tens_max = 2; is_hours = true; } + else if (_timer_cur < 4) { f = &_timer_m; tens_max = 5; } + else { f = &_timer_s; tens_max = 5; } + int t = *f / 10, u = *f % 10; + if (_timer_cur % 2 == 0) { // tens place + t = (t + dir + (tens_max + 1)) % (tens_max + 1); + if (is_hours && t == 2 && u > 3) u = 3; // 2X hours can't exceed 23 + } else { // units place + int umax = (is_hours && t == 2) ? 3 : 9; + u = (u + dir + (umax + 1)) % (umax + 1); + } + *f = (uint8_t)(t * 10 + u); + } + + // Open the DigitEditor on a numeric field (2 integer digits, no decimals). + void editField(EditKind kind, uint8_t value, uint8_t vmax) { + _edit_kind = kind; + _editor.begin((float)value, 0.0f, (float)vmax, 2, 0); + } + + // Feed a key to the open editor, write the (live) value back to its field, and + // close the editor when DigitEditor reports DONE/CANCELLED. + bool feedEditor(char c) { + DigitEditor::Result r = _editor.handleInput(c); + uint8_t v = (uint8_t)(_editor.value + 0.5f); + switch (_edit_kind) { + case EK_A_HOUR: _prefs->alarm_hour = v; _alarm_dirty = true; _task->onAlarmChanged(); break; + case EK_A_MIN: _prefs->alarm_min = v; _alarm_dirty = true; _task->onAlarmChanged(); break; + default: break; + } + if (r != DigitEditor::NONE) _edit_kind = EK_NONE; // editor closed + return true; + } + + // Draw a row's value column: the live editor when this row is the one being + // edited, otherwise the static text. + void drawValue(DisplayDriver& d, int y, int valx, int reserve, bool sel, + EditKind mykind, const char* text) { + if (sel && _editor.active && _edit_kind == mykind) _editor.render(d, valx, y); + else d.drawTextEllipsized(valx, y, d.width() - valx - reserve, text); + } + + // ── Sub-renders ─────────────────────────────────────────────────────────── + int renderMenu(DisplayDriver& d) { + d.drawCenteredHeader("CLOCK TOOLS"); + static const char* items[3] = { "Alarm", "Timer", "Stopwatch" }; + if (_sel > 2) _sel = 2; + drawList(d, 3, _sel, _scroll, [&](int i, int y, bool sel, int reserve) { + drawRowSelection(d, y, sel, reserve); + d.setCursor(4, y); + d.print(items[i]); + if (i == 0 && _prefs && _prefs->alarm_on) { // show the armed alarm time + char b[8]; fmtClock(b, sizeof(b), _prefs->alarm_hour, _prefs->alarm_min); + int vx = d.width() - d.getTextWidth(b) - reserve - 2; + d.setCursor(vx, y); d.print(b); + } + }); + return 60000; + } + + int renderAlarm(DisplayDriver& d) { + d.drawCenteredHeader("ALARM"); + if (!_prefs) return 60000; + const char* rows[3] = { "Hour", "Minute", "Armed" }; + if (_sel > 2) _sel = 2; + const int valx = d.width() / 2 + 6; + drawList(d, 3, _sel, _scroll, [&](int i, int y, bool sel, int reserve) { + drawRowSelection(d, y, sel, reserve); + d.setCursor(4, y); d.print(rows[i]); + char b[6]; + if (i == 0) snprintf(b, sizeof(b), "%02u", _prefs->alarm_hour); + else if (i == 1) snprintf(b, sizeof(b), "%02u", _prefs->alarm_min); + else snprintf(b, sizeof(b), "%s", _prefs->alarm_on ? "ON" : "OFF"); + EditKind k = (i == 0) ? EK_A_HOUR : (i == 1) ? EK_A_MIN : EK_NONE; + drawValue(d, y, valx, reserve, sel, k, b); + }); + return _editor.active ? 50 : 60000; + } + + int renderTimer(DisplayDriver& d) { + d.drawCenteredHeader("TIMER"); + const int cx = d.width() / 2; + if (_task->isTimerRunning()) { + char buf[16]; + fmtHMS(buf, sizeof(buf), _task->timerRemainingMs()); + d.setTextSize(2); + d.drawTextCentered(cx, d.listStart() + 4, buf); + d.setTextSize(1); + d.drawTextCentered(cx, d.height() - d.getLineHeight() - 1, "Ent=stop"); + return liveTickMs(d, 1000); + } + // Setting mode: big HH:MM:SS with the digit under the cursor underlined. + // LEFT/RIGHT move the cursor one digit, UP/DOWN change it, Enter starts. + char buf[12]; + snprintf(buf, sizeof(buf), "%02u:%02u:%02u", _timer_h, _timer_m, _timer_s); + d.setTextSize(2); + const int ty = d.listStart() + 2; + d.drawTextCentered(cx, ty, buf); + const int cw = d.getCharWidth(); // size-2 cell width + const int lh2 = d.getLineHeight(); + const int sx = cx - d.getTextWidth(buf) / 2; + const int char_idx = _timer_cur + (_timer_cur / 2); // skip the two colons + d.fillRect(sx + char_idx * cw, ty + lh2, cw - 1, 2); + d.setTextSize(1); + d.drawTextCentered(cx, d.height() - d.getLineHeight() - 1, "Up/Dn=set Ent=start"); + return 60000; + } + + int renderStopwatch(DisplayDriver& d) { + d.drawCenteredHeader("STOPWATCH"); + const int cx = d.width() / 2; + char buf[16]; + bool tenths = !d.isEink() && _sw_running; // tenths only on a fast panel + fmtStopwatch(buf, sizeof(buf), stopwatchMs(), tenths); + d.setTextSize(2); + d.drawTextCentered(cx, d.listStart() + 4, buf); + d.setTextSize(1); + const char* hint = _sw_running ? "Ent=stop" : "Ent=start Dn=reset"; + d.drawTextCentered(cx, d.height() - d.getLineHeight() - 1, hint); + return _sw_running ? liveTickMs(d, 100) : 60000; + } + + // ── Input per view ──────────────────────────────────────────────────────── + bool inputMenu(char c) { + if (c == KEY_CANCEL) { _task->gotoHomeScreen(); return true; } + if (c == KEY_UP) { _sel = (_sel > 0) ? _sel - 1 : 2; return true; } + if (c == KEY_DOWN) { _sel = (_sel < 2) ? _sel + 1 : 0; return true; } + if (c == KEY_ENTER) { + _view = (_sel == 0) ? V_ALARM : (_sel == 1) ? V_TIMER : V_STOPWATCH; + _sel = 0; _scroll = 0; + return true; + } + return true; + } + + bool inputAlarm(char c) { + if (!_prefs) { if (c == KEY_CANCEL) { _view = V_MENU; _sel = 0; } return true; } + if (_editor.active) return feedEditor(c); + if (c == KEY_CANCEL) { + _task->savePrefsIfDirty(_alarm_dirty); // persist alarm fields only if edited + _task->onAlarmChanged(); // re-schedule from the new time + _view = V_MENU; _sel = 0; _scroll = 0; + return true; + } + if (c == KEY_UP) { _sel = (_sel > 0) ? _sel - 1 : 2; return true; } + if (c == KEY_DOWN) { _sel = (_sel < 2) ? _sel + 1 : 0; return true; } + if (c == KEY_ENTER) { + if (_sel == 0) editField(EK_A_HOUR, _prefs->alarm_hour, 23); + else if (_sel == 1) editField(EK_A_MIN, _prefs->alarm_min, 59); + else { _prefs->alarm_on ^= 1; _alarm_dirty = true; _task->onAlarmChanged(); } + return true; + } + return true; + } + + bool inputTimer(char c) { + if (_task->isTimerRunning()) { + if (c == KEY_ENTER) { _task->stopTimer(); return true; } // stop/cancel countdown + if (c == KEY_CANCEL) { _view = V_MENU; _sel = 0; return true; } // keeps counting in background + return true; // any other key just forces a refresh (e-ink) + } + if (c == KEY_CANCEL) { _view = V_MENU; _sel = 0; return true; } + if (keyIsPrev(c)) { _timer_cur = (_timer_cur + 5) % 6; return true; } // cursor ← + if (keyIsNext(c)) { _timer_cur = (_timer_cur + 1) % 6; return true; } // cursor → + if (c == KEY_UP) { stepTimerDigit(+1); return true; } + if (c == KEY_DOWN) { stepTimerDigit(-1); return true; } + if (c == KEY_ENTER) { + uint32_t dur = (((uint32_t)_timer_h * 60 + _timer_m) * 60 + _timer_s) * 1000UL; + if (dur == 0) { _task->showAlert("Set a duration", 1000); return true; } + _task->startTimer(dur); + } + return true; + } + + bool inputStopwatch(char c) { + if (c == KEY_CANCEL) { _view = V_MENU; _sel = 0; return true; } // keeps running in background + if (c == KEY_ENTER) { + if (_sw_running) { _sw_accum_ms += millis() - _sw_start_ms; _sw_running = false; } + else { _sw_start_ms = millis(); _sw_running = true; } + return true; + } + if ((c == KEY_UP || c == KEY_DOWN) && !_sw_running) { _sw_accum_ms = 0; return true; } + return true; // other keys just refresh the readout + } + +public: + ClockToolsScreen(UITask* task, NodePrefs* prefs) : _task(task), _prefs(prefs) {} + + void onShow() override { + _view = V_MENU; _sel = 0; _scroll = 0; + _timer_cur = 0; + _editor.active = false; _edit_kind = EK_NONE; + } + + int render(DisplayDriver& display) override { + display.setTextSize(1); + display.setColor(DisplayDriver::LIGHT); + switch (_view) { + case V_ALARM: return renderAlarm(display); + case V_TIMER: return renderTimer(display); + case V_STOPWATCH: return renderStopwatch(display); + default: return renderMenu(display); + } + } + + bool handleInput(char c) override { + switch (_view) { + case V_ALARM: return inputAlarm(c); + case V_TIMER: return inputTimer(c); + case V_STOPWATCH: return inputStopwatch(c); + default: return inputMenu(c); + } + } +}; diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 95334873..d183b2e2 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -146,6 +146,7 @@ static const int QUICK_MSGS_MAX = 10; #include "DiagnosticsScreen.h" #include "RepeaterScreen.h" #include "ToolsScreen.h" +#include "ClockToolsScreen.h" // Alarm / Timer / Stopwatch (Clock page › Enter) #ifndef BATT_MIN_MILLIVOLTS #define BATT_MIN_MILLIVOLTS 3200 @@ -474,6 +475,13 @@ class HomeScreen : public UIScreen { } #endif + // Alarm armed — a persistent status cue (like mute), always shown (no blink). + if (_node_prefs && _node_prefs->alarm_on) { + int alX = battLeftX - ind - ind_gap; + drawBoxedIcon(display, alX, ind, ind_h, ICON_ALARM); + battLeftX = alX; + } + // BT connection indicator (left of muted/battery icons) int leftmostX = battLeftX; if (_task->isSerialEnabled()) { @@ -803,6 +811,16 @@ public: snprintf(buf, sizeof(buf),"%s %d %s %d", wd[ti->tm_wday], ti->tm_mday, mo[ti->tm_mon], 1900 + ti->tm_year); display.drawTextCentered(display.width() / 2, date_y, buf); + // Alarm armed: a small bell in the top-left corner. The status bar (and + // its bell) is hidden on this page, so signal the armed alarm here. Just + // the glyph — no time text — so it stays clear of the centred clock + // digits (which can reach the corner when seconds are shown), matching + // the icon-only status-bar indicator. The exact time is in Clock Tools. + if (_node_prefs && _node_prefs->alarm_on) { + display.setColor(DisplayDriver::LIGHT); + miniIconDrawTop(display, 0, 0, ICON_ALARM); + } + int sep_y = date_y + lh + 1; int dash0 = sep_y + display.sepH() + 2; display.fillRect(0, sep_y, display.width(), display.sepH()); @@ -1384,6 +1402,10 @@ public: _shutdown_init = true; // need to wait for button to be released return true; } + if (c == KEY_ENTER && _page == HomePage::CLOCK) { + _task->gotoClockTools(); // Alarm / Timer / Stopwatch + return true; + } if (c == KEY_CONTEXT_MENU && _page == HomePage::CLOCK) { _task->gotoDashboardConfig(); return true; @@ -1467,6 +1489,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no compass_screen = new CompassScreen(this); diag_screen = new DiagnosticsScreen(this); repeater_screen = new RepeaterScreen(this); + clock_tools = new ClockToolsScreen(this, node_prefs); applyBrightness(); applyFont(); applyRotation(); @@ -1484,8 +1507,84 @@ void UITask::gotoTrailScreen() { setCurrScreen(trail_screen); } void UITask::gotoCompassScreen() { setCurrScreen(compass_screen); } void UITask::gotoDiagnosticsScreen() { setCurrScreen(diag_screen); } void UITask::gotoRepeaterScreen() { setCurrScreen(repeater_screen); } +void UITask::gotoClockTools() { setCurrScreen(clock_tools); } void UITask::gotoLiveShareScreen() { setCurrScreen(live_share_screen); } +void UITask::wakeForAlarm() { + if (_display != NULL) _display->turnOn(); + _next_refresh = 0; // draw the alert overlay immediately +} + +// ── Clock tools engine (alarm / countdown / ring) ─────────────────────────── +// Lives here, not in ClockToolsScreen, so it fires regardless of the current +// screen. The melody overrides mute (playMelody → buzzer.playForced); the ring +// auto-stops after CLOCK_RING_MS if no key dismisses it (see UITask::loop). +static const char* CLOCK_ALARM_MELODY = "alarm:d=8,o=6,b=125:c,c,c,c,p,c,c,c,c,p"; +static const uint32_t CLOCK_RING_MS = 60000; +static const uint32_t CLOCK_ALARM_CATCHUP_SECS = 6 * 3600; // fire late up to 6 h, else reschedule + +// Next absolute wall instant matching alarm_hour:alarm_min in local time, +// strictly after now_wall (an alarm set to the current minute waits a day). +uint32_t UITask::computeAlarmNextFire(uint32_t now_wall) const { + int tz = _node_prefs ? _node_prefs->tz_offset_hours : 0; + int64_t now_local = (int64_t)now_wall + (int64_t)tz * 3600; + time_t t = (time_t)now_local; + struct tm* ti = gmtime(&t); + int64_t sod = ti->tm_hour * 3600 + ti->tm_min * 60 + ti->tm_sec; // secs since local midnight + int64_t target = (now_local - sod) + + (int64_t)_node_prefs->alarm_hour * 3600 + (int64_t)_node_prefs->alarm_min * 60; + if (target <= now_local) target += 86400; + return (uint32_t)(target - (int64_t)tz * 3600); +} + +void UITask::fireClockAlert(const char* label) { + snprintf(_ring_label, sizeof(_ring_label), "%s", label); + _ringing = true; + _ring_until_ms = millis() + CLOCK_RING_MS; + wakeForAlarm(); + showAlert(label, CLOCK_RING_MS); + playMelody(CLOCK_ALARM_MELODY); +} + +void UITask::evaluateAlarm() { + if (!_node_prefs || !_node_prefs->alarm_on) return; + uint32_t now_ms = millis(); + if (now_ms - _alarm_check_ms < 500) return; // ~2 Hz is plenty for a minute alarm + _alarm_check_ms = now_ms; + uint32_t now_wall = rtc_clock.getCurrentTime(); + if (now_wall < 1000000000UL) return; // need a real time sync first + if (_alarm_next_fire == 0) _alarm_next_fire = computeAlarmNextFire(now_wall); + if (now_wall < _alarm_next_fire) return; + if (now_wall - _alarm_next_fire < CLOCK_ALARM_CATCHUP_SECS) { + char lbl[20]; + snprintf(lbl, sizeof(lbl), "Alarm %02d:%02d", _node_prefs->alarm_hour, _node_prefs->alarm_min); + _node_prefs->alarm_on = 0; // one-shot + bool dirty = true; savePrefsIfDirty(dirty); + _alarm_next_fire = 0; + fireClockAlert(lbl); + } else { + // Clock jumped implausibly far past the target — reschedule rather than + // ringing absurdly late. + _alarm_next_fire = computeAlarmNextFire(now_wall); + } +} + +void UITask::tickClockTools() { + uint32_t now_ms = millis(); + // Ring maintenance: repeat the melody until dismissed or the window elapses. + if (_ringing) { + if (now_ms >= _ring_until_ms) { stopMelody(); _ringing = false; clearAlert(); } + else if (!isMelodyPlaying()) playMelody(CLOCK_ALARM_MELODY); + } + // Countdown timer (millis — sync-immune). + if (_timer_running && now_ms >= _timer_deadline_ms) { + _timer_running = false; + fireClockAlert("Timer done"); + } + // Alarm (wall clock — absolute schedule for sync robustness). + evaluateAlarm(); +} + // Ringtone takes a slot argument that onShow() can't carry — pass it after the // reset (setCurrScreen's onShow runs first, then this layers the slot on top). void UITask::gotoRingtoneEditor(int slot) { @@ -1985,6 +2084,14 @@ void UITask::loop() { } #endif + // A ringing alarm/timer is dismissed by ANY key, even when locked or on another + // screen — and that key is swallowed so it doesn't also act on the current view. + if (c != 0 && isRinging()) { + dismissRing(); + c = 0; + _next_refresh = 0; + } + if (c != 0) { if (!_locked && curr) { curr->handleInput(c); @@ -2022,6 +2129,10 @@ void UITask::loop() { if (curr) curr->poll(); + // Alarm + countdown run regardless of the current screen / display state, so + // they're driven here (not via the current screen's poll()). + tickClockTools(); + if (_display != NULL && _display->isOn()) { if (_locked && millis() > _lock_wake_until) { _display->turnOff(); @@ -2131,7 +2242,7 @@ void UITask::loop() { _auto_off = millis() + AUTO_OFF_MILLIS; } #endif - if (!_locked && autoOffMillis() > 0 && millis() > _auto_off) { + if (!_locked && autoOffMillis() > 0 && millis() > _auto_off && !isRinging()) { _display->turnOff(); #ifdef PIN_LED digitalWrite(PIN_LED, LOW); // turn off status LED with display to save power diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 00514644..f09cde43 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -89,6 +89,7 @@ class UITask : public AbstractUITask { UIScreen* compass_screen = nullptr; UIScreen* diag_screen = nullptr; UIScreen* repeater_screen = nullptr; + UIScreen* clock_tools = nullptr; UIScreen* curr = nullptr; CayenneLPP _dash_lpp; TrailStore _trail; @@ -126,6 +127,25 @@ class UITask : public AbstractUITask { void fireLocator(bool arrived); void locatorProximityBeeper(); + // Clock tools engine — owned here (not by ClockToolsScreen) so the one-shot + // alarm and the countdown timer fire every loop regardless of the current + // screen / display state. ClockToolsScreen is pure UI over this state. The + // alarm is scheduled as an ABSOLUTE wall instant, recomputed from the stored + // time-of-day, so it survives RTC re-syncs (mesh/app/GPS/CLI all jump the + // clock) — small corrections still fire on time, a jump over the target still + // fires (late). See evaluateAlarm(). Timer + ring are millis-based. + uint32_t _alarm_next_fire = 0; // unix; 0 = (re)compute lazily once time is valid + uint32_t _alarm_check_ms = 0; // throttle the wall-clock read to ~2 Hz + bool _timer_running = false; + uint32_t _timer_deadline_ms = 0; + bool _ringing = false; + uint32_t _ring_until_ms = 0; + char _ring_label[20] = {0}; + uint32_t computeAlarmNextFire(uint32_t now_wall) const; + void evaluateAlarm(); // alarm scheduling + fire detection + void fireClockAlert(const char* label); // wake + alert + melody + start ring + void tickClockTools(); // driven from loop(): ring + timer + alarm + // Course-over-ground ring — a heading source independent of trail recording. // Filled from the same periodic GPS poll regardless of _trail.isActive(). // Heading = bearing across the window (oldest→newest) once the cumulative @@ -245,6 +265,24 @@ public: void gotoCompassScreen(); void gotoDiagnosticsScreen(); void gotoRepeaterScreen(); + void gotoClockTools(); // Alarm / Timer / Stopwatch (from the home Clock page) + // Wake the display for an alarm/timer ring (force an immediate refresh). + void wakeForAlarm(); + // Clear any active alert overlay early (alarm dismiss). + void clearAlert() { _alert_expiry = 0; } + // Clock tools engine API — ClockToolsScreen drives these; the engine itself + // runs in tickClockTools() from loop() so it fires regardless of the screen. + void onAlarmChanged() { _alarm_next_fire = 0; } // re-schedule after an alarm edit + void startTimer(uint32_t duration_ms) { _timer_running = true; _timer_deadline_ms = millis() + duration_ms; } + void stopTimer() { _timer_running = false; } + bool isTimerRunning() const { return _timer_running; } + uint32_t timerRemainingMs() const { + if (!_timer_running) return 0; + uint32_t now = millis(); + return (now >= _timer_deadline_ms) ? 0 : (_timer_deadline_ms - now); + } + bool isRinging() const { return _ringing; } + void dismissRing() { stopMelody(); _ringing = false; clearAlert(); } TrailStore& trail() { return _trail; } WaypointStore& waypoints() { return _waypoints; } LiveTrackStore& liveTrack() { return _livetrack; } diff --git a/examples/companion_radio/ui-new/icons.h b/examples/companion_radio/ui-new/icons.h index f52f575e..d343be29 100644 --- a/examples/companion_radio/ui-new/icons.h +++ b/examples/companion_radio/ui-new/icons.h @@ -150,6 +150,14 @@ MINI_ICON(ICON_ADVERT, 6, // broadcast mast + radiating waves (auto-advert) packRow(".####."), packRow(".####.")); +MINI_ICON(ICON_ALARM, 5, // bell — an alarm is armed + packRow("..#.."), + packRow(".###."), + packRow(".###."), + packRow(".###."), + packRow("#####"), + packRow("..#..")); + MINI_ICON(ICON_TRAIL, 6, // map pin / location marker (GPS trail logging) packRow(".####."), packRow("######"),