diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index aa1d1289..897290cc 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -284,6 +284,14 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no _prefs.rx_fail_reboot_threshold = 3; // default: 3 } + // v1.7+ Meck region scope fields — may not exist in older prefs files + if (file.read((uint8_t *)_prefs.default_scope_name, sizeof(_prefs.default_scope_name)) != sizeof(_prefs.default_scope_name)) { + memset(_prefs.default_scope_name, 0, sizeof(_prefs.default_scope_name)); // default: unscoped + } + if (file.read((uint8_t *)_prefs.default_scope_key, sizeof(_prefs.default_scope_key)) != sizeof(_prefs.default_scope_key)) { + memset(_prefs.default_scope_key, 0, sizeof(_prefs.default_scope_key)); // default: null key + } + // Clamp to valid ranges if (_prefs.dark_mode > 1) _prefs.dark_mode = 0; if (_prefs.portrait_mode > 1) _prefs.portrait_mode = 0; @@ -349,6 +357,8 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_ file.write((uint8_t *)&_prefs.ui_font_style, sizeof(_prefs.ui_font_style)); // 103 file.write((uint8_t *)&_prefs.tx_fail_reset_threshold, sizeof(_prefs.tx_fail_reset_threshold)); // 104 file.write((uint8_t *)&_prefs.rx_fail_reboot_threshold, sizeof(_prefs.rx_fail_reboot_threshold)); // 105 + file.write((uint8_t *)_prefs.default_scope_name, sizeof(_prefs.default_scope_name)); // 106 + file.write((uint8_t *)_prefs.default_scope_key, sizeof(_prefs.default_scope_key)); // 137 file.close(); } @@ -600,8 +610,46 @@ void DataStore::loadChannels(DataStoreHost* host) { FILESYSTEM* fs = _getContactsChannelsFS(); // Crash recovery (same pattern as contacts) + if (!fs->exists("/channels3") && fs->exists("/channels3.tmp")) { + Serial.println("DataStore: recovering channels3 from .tmp file"); + fs->rename("/channels3.tmp", "/channels3"); + } + if (fs->exists("/channels3.tmp")) { + fs->remove("/channels3.tmp"); + } + + // Try channels3 (new format with scope_name) first + if (fs->exists("/channels3")) { + File file = openRead(fs, "/channels3"); + if (file) { + bool full = false; + uint8_t channel_idx = 0; + while (!full) { + ChannelDetails ch; + memset(ch.scope_name, 0, sizeof(ch.scope_name)); + 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); + success = success && (file.read((uint8_t *)ch.scope_name, 31) == 31); + + if (!success) break; // EOF + + if (host->onChannelLoaded(channel_idx, ch)) { + channel_idx++; + } else { + full = true; + } + } + file.close(); + return; // channels3 loaded successfully + } + } + + // Fall back to channels2 (legacy format without scope_name) if (!fs->exists("/channels2") && fs->exists("/channels2.tmp")) { - Serial.println("DataStore: recovering channels from .tmp file"); + Serial.println("DataStore: recovering channels2 from .tmp file"); fs->rename("/channels2.tmp", "/channels2"); } if (fs->exists("/channels2.tmp")) { @@ -614,6 +662,7 @@ void DataStore::loadChannels(DataStoreHost* host) { uint8_t channel_idx = 0; while (!full) { ChannelDetails ch; + memset(ch.scope_name, 0, sizeof(ch.scope_name)); // default: no scope uint8_t unused[4]; bool success = (file.read(unused, 4) == 4); @@ -629,13 +678,18 @@ void DataStore::loadChannels(DataStoreHost* host) { } } file.close(); + + // Migrate: save as channels3 and remove channels2 + Serial.println("DataStore: migrating channels2 → channels3"); + saveChannels(host); + fs->remove("/channels2"); } } void DataStore::saveChannels(DataStoreHost* host) { FILESYSTEM* fs = _getContactsChannelsFS(); - const char* finalPath = "/channels2"; - const char* tmpPath = "/channels2.tmp"; + const char* finalPath = "/channels3"; + const char* tmpPath = "/channels3.tmp"; File file = openWrite(fs, tmpPath); if (!file) { @@ -653,6 +707,7 @@ void DataStore::saveChannels(DataStoreHost* host) { bool success = (file.write(unused, 4) == 4); success = success && (file.write((uint8_t *)ch.name, 32) == 32); success = success && (file.write((uint8_t *)ch.channel.secret, 32) == 32); + success = success && (file.write((uint8_t *)ch.scope_name, 31) == 31); if (!success) { writeOk = false; @@ -665,7 +720,7 @@ void DataStore::saveChannels(DataStoreHost* host) { file.close(); // Reopen read-only to get true on-disk size (SPIFFS file.size() is unreliable before close) - size_t expectedBytes = channel_idx * 68; // 4 + 32 + 32 = 68 bytes per channel + size_t expectedBytes = channel_idx * 99; // 4 + 32 + 32 + 31 = 99 bytes per channel File verify = openRead(fs, tmpPath); size_t bytesWritten = verify ? verify.size() : 0; if (verify) verify.close(); @@ -710,7 +765,7 @@ void DataStore::checkAdvBlobFile() { } void DataStore::migrateToSecondaryFS() { - // migrate old adv_blobs, contacts3 and channels2 files to secondary FS if they don't already exist + // migrate old adv_blobs, contacts3 and channels3/channels2 files to secondary FS if they don't already exist if (!_fsExtra->exists("/adv_blobs")) { if (_fs->exists("/adv_blobs")) { File oldAdvBlobs = openRead(_fs, "/adv_blobs"); @@ -749,10 +804,14 @@ void DataStore::migrateToSecondaryFS() { _fs->remove("/contacts3"); } } - if (!_fsExtra->exists("/channels2")) { - if (_fs->exists("/channels2")) { - File oldFile = openRead(_fs, "/channels2"); - File newFile = openWrite(_fsExtra, "/channels2"); + if (!_fsExtra->exists("/channels3") && !_fsExtra->exists("/channels2")) { + // Migrate channels3 (preferred) or channels2 (legacy) to secondary FS + const char* srcName = _fs->exists("/channels3") ? "/channels3" + : _fs->exists("/channels2") ? "/channels2" + : nullptr; + if (srcName) { + File oldFile = openRead(_fs, srcName); + File newFile = openWrite(_fsExtra, srcName); if (oldFile && newFile) { uint8_t buf[64]; @@ -763,7 +822,7 @@ void DataStore::migrateToSecondaryFS() { } if (oldFile) oldFile.close(); if (newFile) newFile.close(); - _fs->remove("/channels2"); + _fs->remove(srcName); } } // cleanup nodes which have been testing the extra fs, copy _main.id and new_prefs back to primary @@ -806,6 +865,9 @@ void DataStore::migrateToSecondaryFS() { if (_fs->exists("/contacts3")) { _fs->remove("/contacts3"); } + if (_fs->exists("/channels3")) { + _fs->remove("/channels3"); + } if (_fs->exists("/channels2")) { _fs->remove("/channels2"); } diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index d18c77c5..6b67c60f 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -59,7 +59,7 @@ #define CMD_SEND_BINARY_REQ 50 #define CMD_FACTORY_RESET 51 #define CMD_SEND_PATH_DISCOVERY_REQ 52 -#define CMD_SET_FLOOD_SCOPE 54 // v8+ +#define CMD_SET_FLOOD_SCOPE_KEY 54 // v8+ (per-channel scope key from BLE app) #define CMD_SEND_CONTROL_DATA 55 // v8+ #define CMD_GET_STATS 56 // v8+, second byte is stats type @@ -70,6 +70,9 @@ #define CMD_SET_AUTOADD_CONFIG 58 #define CMD_GET_AUTOADD_CONFIG 59 #define CMD_SET_PATH_HASH_MODE 61 +#define CMD_SEND_CHANNEL_DATA 62 +#define CMD_SET_DEFAULT_FLOOD_SCOPE 63 // v1.15+ (device-wide default scope name+key) +#define CMD_GET_DEFAULT_FLOOD_SCOPE 64 // v1.15+ (query current default scope) // Stats sub-types for CMD_GET_STATS #define STATS_TYPE_CORE 0 @@ -102,6 +105,9 @@ #define RESP_CODE_TUNING_PARAMS 23 #define RESP_CODE_STATS 24 // v8+, second byte is stats type #define RESP_CODE_AUTOADD_CONFIG 25 +#define RESP_ALLOWED_REPEAT_FREQ 26 +#define RESP_CODE_CHANNEL_DATA_RECV 27 +#define RESP_CODE_DEFAULT_FLOOD_SCOPE 28 // v1.15+ #define SEND_TIMEOUT_BASE_MILLIS 500 #define FLOOD_SEND_TIMEOUT_FACTOR 16.0f @@ -567,19 +573,25 @@ bool MyMesh::filterRecvFloodPacket(mesh::Packet* packet) { return false; // never filter — let normal processing continue } -void MyMesh::sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, uint32_t delay_millis) { - Serial.printf("[sendFloodScoped] to '%s', delay=%lu, hash_mode=%d, bph=%d\n", - recipient.name, delay_millis, _prefs.path_hash_mode, _prefs.path_hash_mode + 1); - // TODO: dynamic send_scope, depending on recipient and current 'home' Region - if (send_scope.isNull()) { +void MyMesh::sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis) { + if (scope.isNull()) { sendFlood(pkt, delay_millis, getPathHashSize()); } else { uint16_t codes[2]; - codes[0] = send_scope.calcTransportCode(pkt); - codes[1] = 0; // REVISIT: set to 'home' Region, for sender/return region? + codes[0] = scope.calcTransportCode(pkt); + codes[1] = 0; // reserved for home region sendFlood(pkt, codes, delay_millis, getPathHashSize()); } } + +void MyMesh::sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, uint32_t delay_millis) { + Serial.printf("[sendFloodScoped] to '%s', delay=%lu, hash_mode=%d, bph=%d\n", + recipient.name, delay_millis, _prefs.path_hash_mode, _prefs.path_hash_mode + 1); + // DMs: use device default scope (no per-contact scope) + TransportKey default_scope; + memcpy(&default_scope.key, _prefs.default_scope_key, sizeof(default_scope.key)); + sendFloodScoped(default_scope, pkt, delay_millis); +} void MyMesh::sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pkt, uint32_t delay_millis) { // Capture payload fingerprint for repeat tracking before sending if (pkt->payload_len >= SENT_FINGERPRINT_SIZE) { @@ -592,15 +604,41 @@ void MyMesh::sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pk MESH_DEBUG_PRINTLN("SentTrack: captured fingerprint for channel msg"); } - // TODO: have per-channel send_scope - if (send_scope.isNull()) { - sendFlood(pkt, delay_millis, getPathHashSize()); + // Scope priority: per-channel scope → device default → unscoped + TransportKey scope; + const char* ch_scope = getChannelScopeName(channel); + if (ch_scope && ch_scope[0]) { + deriveScopeKey(ch_scope, scope); } else { - uint16_t codes[2]; - codes[0] = send_scope.calcTransportCode(pkt); - codes[1] = 0; // REVISIT: set to 'home' Region, for sender/return region? - sendFlood(pkt, codes, delay_millis, getPathHashSize()); + memcpy(scope.key, _prefs.default_scope_key, sizeof(scope.key)); } + sendFloodScoped(scope, pkt, delay_millis); +} + +bool MyMesh::deriveScopeKey(const char* scopeName, TransportKey& keyOut) { + if (!scopeName || scopeName[0] == '\0') { + memset(keyOut.key, 0, sizeof(keyOut.key)); + return false; + } + // Region names are stored without '#' prefix in user-facing code, + // but the key derivation prepends '#' (implicit auto hashtag region). + char tmp[32]; + snprintf(tmp, sizeof(tmp), "#%s", scopeName); + TransportKeyStore tempStore; + tempStore.getAutoKeyFor(0, tmp, keyOut); + return true; +} + +const char* MyMesh::getChannelScopeName(const mesh::GroupChannel& channel) { + ChannelDetails ch; + for (uint8_t i = 0; i < MAX_GROUP_CHANNELS; i++) { + if (getChannel(i, ch) && ch.name[0] != '\0') { + if (memcmp(ch.channel.secret, channel.secret, sizeof(channel.secret)) == 0) { + return ch.scope_name; + } + } + } + return nullptr; } void MyMesh::onMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp, @@ -2272,13 +2310,42 @@ void MyMesh::handleCmdFrame(size_t len) { } else { writeErrFrame(ERR_CODE_FILE_IO_ERROR); } - } else if (cmd_frame[0] == CMD_SET_FLOOD_SCOPE && len >= 2 && cmd_frame[1] == 0) { + } else if (cmd_frame[0] == CMD_SET_FLOOD_SCOPE_KEY && len >= 2 && cmd_frame[1] == 0) { if (len >= 2 + 16) { memcpy(send_scope.key, &cmd_frame[2], sizeof(send_scope.key)); // set curr scope TransportKey } else { memset(send_scope.key, 0, sizeof(send_scope.key)); // set scope to null } writeOKFrame(); + } else if (cmd_frame[0] == CMD_SET_DEFAULT_FLOOD_SCOPE && len >= 1) { + // v1.15+ — set device-wide default flood scope (name[31] + key[16]) + if (len >= 1+31+16) { + int n = strlen((char *) &cmd_frame[1]); + if (n > 0 && n < 31) { + strcpy(_prefs.default_scope_name, (char *) &cmd_frame[1]); + memcpy(_prefs.default_scope_key, &cmd_frame[1+31], 16); + savePrefs(); + writeOKFrame(); + } else { + writeErrFrame(ERR_CODE_ILLEGAL_ARG); + } + } else { + // Clear default scope (unscoped) + memset(_prefs.default_scope_name, 0, sizeof(_prefs.default_scope_name)); + memset(_prefs.default_scope_key, 0, sizeof(_prefs.default_scope_key)); + savePrefs(); + writeOKFrame(); + } + } else if (cmd_frame[0] == CMD_GET_DEFAULT_FLOOD_SCOPE) { + // v1.15+ — query current default flood scope + out_frame[0] = RESP_CODE_DEFAULT_FLOOD_SCOPE; + if (strlen(_prefs.default_scope_name) > 0) { + memcpy(&out_frame[1], _prefs.default_scope_name, 31); + memcpy(&out_frame[1+31], _prefs.default_scope_key, 16); + _serial->writeFrame(out_frame, 1+31+16); + } else { + _serial->writeFrame(out_frame, 1); // no name or key means null/unscoped + } } else if (cmd_frame[0] == CMD_SEND_CONTROL_DATA && len >= 2 && (cmd_frame[1] & 0x80) != 0) { auto resp = createControlData(&cmd_frame[1], len - 1); if (resp) { diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 9febf087..d294471e 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -171,6 +171,13 @@ public: bool setCustomPath(int contactIdx, const uint8_t* path, uint8_t pathLen, bool lock); void clearCustomPath(int contactIdx); + // Region scope helpers (public — used by SettingsScreen) + // Derive a TransportKey from a region scope name (e.g. "au-nsw" → "#au-nsw" → SHA256 → key). + // Returns true if name is non-empty and key was derived; false if name is empty (unscoped). + bool deriveScopeKey(const char* scopeName, TransportKey& keyOut); + // Look up per-channel scope name by GroupChannel secret match. Returns nullptr if no scope set. + const char* getChannelScopeName(const mesh::GroupChannel& channel); + protected: float getAirtimeBudgetFactor() const override; @@ -188,6 +195,7 @@ protected: uint8_t getPathHashSize() const override { return _prefs.path_hash_mode + 1; } void sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, uint32_t delay_millis=0) override; void sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pkt, uint32_t delay_millis=0) override; + void sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis=0); void logRxRaw(float snr, float rssi, const uint8_t raw[], int len) override; bool isAutoAddEnabled() const override; diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index dd6dd1ef..b36487ec 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -44,6 +44,15 @@ struct NodePrefs { // persisted to file uint8_t tx_fail_reset_threshold; // 0=disabled, 1-10, default 3 uint8_t rx_fail_reboot_threshold; // 0=disabled, 1-10, default 3 + // --- Region scope (MeshCore v1.15+ compatibility) --- + // Device-wide default region for flood messages. + // Empty default_scope_name = unscoped (legacy flood, reaches all repeaters). + // When set, all flood sends use ROUTE_TYPE_TRANSPORT_FLOOD with transport + // codes derived from this key. Per-channel scope (in ChannelDetails) takes + // priority when set. + char default_scope_name[31]; // e.g. "au-nsw", empty = unscoped + uint8_t default_scope_key[16]; // TransportKey derived from "#" + name + // --- Font helpers (inline, no overhead) --- // Returns the DisplayDriver text-size index for "small/body" text. // T-Deck Pro: 0 = built-in 6×8 (or 7pt with custom fonts), 1 = 9pt. diff --git a/examples/companion_radio/ui-new/ChannelScreen.h b/examples/companion_radio/ui-new/ChannelScreen.h index 3e120222..8564d4f0 100644 --- a/examples/companion_radio/ui-new/ChannelScreen.h +++ b/examples/companion_radio/ui-new/ChannelScreen.h @@ -1417,7 +1417,7 @@ public: // "Ent:New" only fits with Classic+TINY — wider fonts cause overlap NodePrefs* _fp = the_mesh.getNodePrefs(); if (_fp->ui_font_style == 0 && !_fp->large_font) { - const char* rightText = "Ent:New"; + const char* rightText = " Ent:New"; display.setCursor(display.width() - display.getTextWidth(rightText) - 2, footerY); display.print(rightText); } diff --git a/examples/companion_radio/ui-new/Settingsscreen.h b/examples/companion_radio/ui-new/Settingsscreen.h index 7efa8c49..5ccd7844 100644 --- a/examples/companion_radio/ui-new/Settingsscreen.h +++ b/examples/companion_radio/ui-new/Settingsscreen.h @@ -3,6 +3,7 @@ #include #include #include +#include #include #include "../NodePrefs.h" #include "MeckFonts.h" @@ -124,6 +125,7 @@ enum SettingsRowType : uint8_t { #endif ROW_GPS_BAUD, // GPS baud rate picker (requires reboot) ROW_PATH_HASH_SIZE, // Path hash size (1, 2, or 3 bytes per hop) + ROW_DEFAULT_SCOPE, // Device-wide default region (text editor) #ifdef MECK_WIFI_COMPANION ROW_WIFI_SETUP, // WiFi SSID/password configuration ROW_WIFI_TOGGLE, // WiFi radio on/off toggle @@ -212,13 +214,13 @@ enum FmPhase : uint8_t { // Max rows in the settings list (increased for contact sub-toggles + WiFi) #if defined(HAS_4G_MODEM) && defined(MECK_WIFI_COMPANION) -#define SETTINGS_MAX_ROWS 56 // Extra rows for IMEI, Carrier, APN, contacts, WiFi +#define SETTINGS_MAX_ROWS 57 // Extra rows for IMEI, Carrier, APN, contacts, WiFi, scope #elif defined(HAS_4G_MODEM) -#define SETTINGS_MAX_ROWS 54 // Extra rows for IMEI, Carrier, APN + contacts +#define SETTINGS_MAX_ROWS 55 // Extra rows for IMEI, Carrier, APN + contacts + scope #elif defined(MECK_WIFI_COMPANION) -#define SETTINGS_MAX_ROWS 50 // Extra rows for contacts + WiFi +#define SETTINGS_MAX_ROWS 51 // Extra rows for contacts + WiFi + scope #else -#define SETTINGS_MAX_ROWS 48 // Contacts section +#define SETTINGS_MAX_ROWS 49 // Contacts section + scope #endif #define SETTINGS_TEXT_BUF 33 // 32 chars + null @@ -402,6 +404,7 @@ private: addRow(ROW_MSG_NOTIFY); addRow(ROW_GPS_BAUD); addRow(ROW_PATH_HASH_SIZE); + addRow(ROW_DEFAULT_SCOPE); addRow(ROW_DARK_MODE); addRow(ROW_LARGE_FONT); addRow(ROW_FONT_STYLE); @@ -1746,6 +1749,17 @@ public: display.print(tmp); break; + case ROW_DEFAULT_SCOPE: + if (editing && _editMode == EDIT_TEXT) { + snprintf(tmp, sizeof(tmp), "Region: %s_", _editBuf); + } else if (_prefs->default_scope_name[0]) { + snprintf(tmp, sizeof(tmp), "Default Region: %s", _prefs->default_scope_name); + } else { + strcpy(tmp, "Default Region: (none)"); + } + display.print(tmp); + break; + case ROW_GPS_BAUD: { char baudStr[16]; if (editing && _editMode == EDIT_PICKER) { @@ -1901,17 +1915,22 @@ public: uint8_t chIdx = _rows[i].param; ChannelDetails ch; if (the_mesh.getChannel(chIdx, ch)) { - if (chIdx == 0) { - // Public channel - not deletable - snprintf(tmp, sizeof(tmp), " %s", ch.name); + if (editing && _editMode == EDIT_TEXT) { + // Editing scope for this channel + snprintf(tmp, sizeof(tmp), " %s [%s_]", ch.name, _editBuf); } else { - snprintf(tmp, sizeof(tmp), " %s", ch.name); + // Show channel name + scope tag + if (ch.scope_name[0]) { + snprintf(tmp, sizeof(tmp), " %s [%s]", ch.name, ch.scope_name); + } else { + snprintf(tmp, sizeof(tmp), " %s [*]", ch.name); + } if (selected) { - // Show delete hint on right + // Show edit/delete hints on right #if defined(LilyGo_T5S3_EPaper_Pro) - const char* hint = "Hold:Del"; + const char* hint = chIdx > 0 ? "Ent:Region Hold:Del" : "Ent:Region"; #else - const char* hint = "X:Del"; + const char* hint = chIdx > 0 ? "Ent:Region X:Del" : "Ent:Region"; #endif int hintW = display.getTextWidth(hint); display.setCursor(display.width() - hintW - 2, y); @@ -2040,6 +2059,9 @@ public: display.drawTextCentered(display.width() / 2, by + 4, tmp); } else if (_confirmAction == 2) { display.drawTextCentered(display.width() / 2, by + 4, "Apply radio changes?"); + } else if (_confirmAction == 3) { + display.drawTextCentered(display.width() / 2, by + 4, "Region not set."); + display.drawTextCentered(display.width() / 2, by + 15, "Leave unset?"); } #if defined(LilyGo_T5S3_EPaper_Pro) display.drawTextCentered(display.width() / 2, by + bh - 14, "Tap:Yes Boot:No"); @@ -2466,12 +2488,28 @@ public: rebuildRows(); } else if (_confirmAction == 2) { applyRadioParams(); + } else if (_confirmAction == 3) { + // Region nudge dismissed — user chose "Yes, leave unscoped" + _editMode = EDIT_NONE; + _confirmAction = 0; + _onboarding = false; + return false; // Let caller navigate away from settings } _editMode = EDIT_NONE; _confirmAction = 0; return true; } if (c == 'q' || c == 'Q') { + if (_confirmAction == 3) { + // Region nudge cancelled — scroll to Default Region row + _editMode = EDIT_NONE; + _confirmAction = 0; + // Find and scroll to ROW_DEFAULT_SCOPE + for (int r = 0; r < _numRows; r++) { + if (_rows[r].type == ROW_DEFAULT_SCOPE) { _cursor = r; break; } + } + return true; + } _editMode = EDIT_NONE; _confirmAction = 0; return true; @@ -2681,6 +2719,34 @@ public: rebuildRows(); } _editMode = EDIT_NONE; + } else if (type == ROW_DEFAULT_SCOPE) { + // Save device-wide default scope + strncpy(_prefs->default_scope_name, _editBuf, sizeof(_prefs->default_scope_name)); + _prefs->default_scope_name[30] = '\0'; + if (_editBuf[0]) { + TransportKey key; + the_mesh.deriveScopeKey(_editBuf, key); + memcpy(_prefs->default_scope_key, key.key, sizeof(_prefs->default_scope_key)); + } else { + memset(_prefs->default_scope_key, 0, sizeof(_prefs->default_scope_key)); + } + the_mesh.savePrefs(); + Serial.printf("Settings: Default scope set to '%s'\n", + _editBuf[0] ? _editBuf : "(unscoped)"); + _editMode = EDIT_NONE; + } else if (type == ROW_CHANNEL) { + // Save per-channel scope + uint8_t chIdx = _rows[_cursor].param; + ChannelDetails ch; + if (the_mesh.getChannel(chIdx, ch)) { + strncpy(ch.scope_name, _editBuf, sizeof(ch.scope_name)); + ch.scope_name[30] = '\0'; + the_mesh.setChannel(chIdx, ch); + the_mesh.saveChannels(); + Serial.printf("Settings: Channel %d scope set to '%s'\n", + chIdx, _editBuf[0] ? _editBuf : "(device default)"); + } + _editMode = EDIT_NONE; } #ifdef HAS_4G_MODEM else if (type == ROW_APN) { @@ -3146,7 +3212,18 @@ public: startFileMgr(); break; #endif - case ROW_CHANNEL: + case ROW_CHANNEL: { + // Enter on a channel row → edit its region scope + uint8_t chIdx = _rows[_cursor].param; + ChannelDetails ch; + if (the_mesh.getChannel(chIdx, ch)) { + startEditText(ch.scope_name); + } + break; + } + case ROW_DEFAULT_SCOPE: + startEditText(_prefs->default_scope_name); + break; case ROW_PUB_KEY: case ROW_FIRMWARE: // Not directly editable on Enter @@ -3183,6 +3260,21 @@ public: _confirmAction = 2; return true; } + // Nudge if no region is set anywhere (device default empty AND no per-channel scopes) + if (_prefs->default_scope_name[0] == '\0') { + bool anyChannelScoped = false; + for (uint8_t ci = 0; ci < MAX_GROUP_CHANNELS && !anyChannelScoped; ci++) { + ChannelDetails ch; + if (the_mesh.getChannel(ci, ch) && ch.name[0] != '\0' && ch.scope_name[0] != '\0') { + anyChannelScoped = true; + } + } + if (!anyChannelScoped) { + _editMode = EDIT_CONFIRM; + _confirmAction = 3; + return true; + } + } _onboarding = false; return false; // Let the caller handle navigation back } diff --git a/src/helpers/ChannelDetails.h b/src/helpers/ChannelDetails.h index b9d38d4f..528f06ef 100644 --- a/src/helpers/ChannelDetails.h +++ b/src/helpers/ChannelDetails.h @@ -6,4 +6,5 @@ struct ChannelDetails { mesh::GroupChannel channel; char name[32]; -}; + char scope_name[31]; // Region scope name (e.g. "au-nsw"), empty = use device default +}; \ No newline at end of file