Regions - including settings nudge to remind user if no default nor channel region set. Set region by typing custom entry

This commit is contained in:
pelgraine
2026-04-19 18:17:25 +10:00
parent b536e24f8e
commit 5c275a8c23
7 changed files with 279 additions and 40 deletions
+72 -10
View File
@@ -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");
}
+83 -16
View File
@@ -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) {
+8
View File
@@ -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;
+9
View File
@@ -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.
@@ -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);
}
+104 -12
View File
@@ -3,6 +3,7 @@
#include <helpers/ui/UIScreen.h>
#include <helpers/ui/DisplayDriver.h>
#include <helpers/ChannelDetails.h>
#include <helpers/TransportKeyStore.h>
#include <MeshCore.h>
#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
}
+2 -1
View File
@@ -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
};