From be9f3db8c66a65f9212d4fb7890e22518d4fe83b Mon Sep 17 00:00:00 2001 From: MarekZegare4 Date: Fri, 26 Jun 2026 10:55:24 +0200 Subject: [PATCH] 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