Pārlūkot izejas kodu

Merge branch 'default-scope' into dev

Scott Powell 3 mēneši atpakaļ
vecāks
revīzija
d7a3d41843

+ 10 - 0
docs/cli_commands.md

@@ -714,6 +714,16 @@ This document provides an overview of CLI commands that can be sent to MeshCore
 
 ---
 
+#### View or change the default scope region for this node
+**Usage:** 
+- `region default`
+- `region default {name|<null>}`
+
+**Parameters:**
+- `name`: Region name,  or <null> to reset/clear
+
+---
+
 #### Create a new region
 **Usage:** 
 - `region put <name> [parent_name]`

+ 6 - 2
examples/companion_radio/DataStore.cpp

@@ -230,7 +230,9 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no
     file.read((uint8_t *)&_prefs.gps_interval, sizeof(_prefs.gps_interval));               // 86
     file.read((uint8_t *)&_prefs.autoadd_config, sizeof(_prefs.autoadd_config));           // 87
     file.read((uint8_t *)&_prefs.autoadd_max_hops, sizeof(_prefs.autoadd_max_hops));       // 88
-    file.read((uint8_t *)&_prefs.rx_boosted_gain, sizeof(_prefs.rx_boosted_gain)); // 89
+    file.read((uint8_t *)&_prefs.rx_boosted_gain, sizeof(_prefs.rx_boosted_gain));         // 89
+    file.read((uint8_t *)_prefs.default_scope_name, sizeof(_prefs.default_scope_name));    // 90
+    file.read((uint8_t *)_prefs.default_scope_key, sizeof(_prefs.default_scope_key));     // 121
 
     file.close();
   }
@@ -268,7 +270,9 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_
     file.write((uint8_t *)&_prefs.gps_interval, sizeof(_prefs.gps_interval));               // 86
     file.write((uint8_t *)&_prefs.autoadd_config, sizeof(_prefs.autoadd_config));           // 87
     file.write((uint8_t *)&_prefs.autoadd_max_hops, sizeof(_prefs.autoadd_max_hops));       // 88
-    file.write((uint8_t *)&_prefs.rx_boosted_gain, sizeof(_prefs.rx_boosted_gain)); // 89
+    file.write((uint8_t *)&_prefs.rx_boosted_gain, sizeof(_prefs.rx_boosted_gain));         // 89
+    file.write((uint8_t *)_prefs.default_scope_name, sizeof(_prefs.default_scope_name));    // 90
+    file.write((uint8_t *)_prefs.default_scope_key, sizeof(_prefs.default_scope_key));     // 121
 
     file.close();
   }

+ 62 - 15
examples/companion_radio/MyMesh.cpp

@@ -50,7 +50,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+
 #define CMD_SEND_CONTROL_DATA         55   // v8+
 #define CMD_GET_STATS                 56   // v8+, second byte is stats type
 #define CMD_SEND_ANON_REQ             57
@@ -59,6 +59,8 @@
 #define CMD_GET_ALLOWED_REPEAT_FREQ   60
 #define CMD_SET_PATH_HASH_MODE        61
 #define CMD_SEND_CHANNEL_DATA         62
+#define CMD_SET_DEFAULT_FLOOD_SCOPE   63
+#define CMD_GET_DEFAULT_FLOOD_SCOPE   64
 
 // Stats sub-types for CMD_GET_STATS
 #define STATS_TYPE_CORE               0
@@ -93,6 +95,7 @@
 #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
 
 #define MAX_CHANNEL_DATA_LENGTH       (MAX_FRAME_SIZE - 9)
 
@@ -479,27 +482,32 @@ bool MyMesh::allowPacketForward(const mesh::Packet* packet) {
   return _prefs.client_repeat != 0;
 }
 
-void MyMesh::sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, uint32_t delay_millis) {
-  // 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, _prefs.path_hash_mode + 1);
   } else {
     uint16_t codes[2];
-    codes[0] = send_scope.calcTransportCode(pkt);
+    codes[0] = scope.calcTransportCode(pkt);
     codes[1] = 0;  // REVISIT: set to 'home' Region, for sender/return region?
     sendFlood(pkt, codes, delay_millis, _prefs.path_hash_mode + 1);
   }
 }
+
+void MyMesh::sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, uint32_t delay_millis) {
+  // TODO: dynamic send_scope, depending on recipient and current 'home' Region
+  TransportKey default_scope;
+  memcpy(&default_scope.key, _prefs.default_scope_key, sizeof(default_scope.key));
+
+  auto scope = send_scope.isNull() ? &default_scope : &send_scope;
+  sendFloodScoped(*scope, pkt, delay_millis);
+}
 void MyMesh::sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pkt, uint32_t delay_millis) {
   // TODO: have per-channel send_scope
-  if (send_scope.isNull()) {
-    sendFlood(pkt, delay_millis, _prefs.path_hash_mode + 1);
-  } 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, _prefs.path_hash_mode + 1);
-  }
+  TransportKey default_scope;
+  memcpy(&default_scope.key, _prefs.default_scope_key, sizeof(default_scope.key));
+
+  auto scope = send_scope.isNull() ? &default_scope : &send_scope;
+  sendFloodScoped(*scope, pkt, delay_millis);
 }
 
 void MyMesh::onMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp,
@@ -893,6 +901,17 @@ void MyMesh::begin(bool has_display) {
   strcpy(_prefs.node_name, pub_key_hex);
 #endif
 
+  // if build provides default-scope, init with that
+#ifdef DEFAULT_FLOOD_SCOPE_NAME
+  strcpy(_prefs.default_scope_name, DEFAULT_FLOOD_SCOPE_NAME);
+  {
+    TransportKeyStore temp;
+    TransportKey key;
+    temp.getAutoKeyFor(0, "#" DEFAULT_FLOOD_SCOPE_NAME, key);
+    memcpy(_prefs.default_scope_key, key.key, sizeof(key.key));
+  }
+#endif
+
   // load persisted prefs
   _store->loadPrefs(_prefs, sensors.node_lat, sensors.node_lon);
 
@@ -1210,7 +1229,9 @@ void MyMesh::handleCmdFrame(size_t len) {
     if (pkt) {
       if (len >= 2 && cmd_frame[1] == 1) { // optional param (1 = flood, 0 = zero hop)
         unsigned long delay_millis = 0;
-        sendFlood(pkt, delay_millis, _prefs.path_hash_mode + 1);
+        TransportKey default_scope;
+        memcpy(&default_scope.key, _prefs.default_scope_key, sizeof(default_scope.key));
+        sendFloodScoped(default_scope, pkt, delay_millis);
       } else {
         sendZeroHop(pkt);
       }
@@ -1862,13 +1883,39 @@ 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) {
+    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 {
+      memset(_prefs.default_scope_name, 0, sizeof(_prefs.default_scope_name));  // set default scope to null
+      memset(_prefs.default_scope_key, 0, sizeof(_prefs.default_scope_key));
+      savePrefs();
+      writeOKFrame();
+    }
+  } else if (cmd_frame[0] == CMD_GET_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
+    }
   } 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) {

+ 2 - 1
examples/companion_radio/MyMesh.h

@@ -5,7 +5,7 @@
 #include "AbstractUITask.h"
 
 /*------------ Frame Protocol --------------*/
-#define FIRMWARE_VER_CODE 10
+#define FIRMWARE_VER_CODE 11
 
 #ifndef FIRMWARE_BUILD_DATE
 #define FIRMWARE_BUILD_DATE "20 Mar 2026"
@@ -112,6 +112,7 @@ protected:
   bool filterRecvFloodPacket(mesh::Packet* packet) override;
   bool allowPacketForward(const mesh::Packet* packet) override;
 
+  void sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis);
   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;
 

+ 2 - 0
examples/companion_radio/NodePrefs.h

@@ -32,4 +32,6 @@ struct NodePrefs {  // persisted to file
   uint8_t client_repeat;
   uint8_t path_hash_mode;    // which path mode to use when sending
   uint8_t autoadd_max_hops;  // 0 = no limit, 1 = direct (0 hops), N = up to N-1 hops (max 64)
+  char default_scope_name[31];
+  uint8_t default_scope_key[16];
 };

+ 76 - 110
examples/simple_repeater/MyMesh.cpp

@@ -413,6 +413,19 @@ bool MyMesh::isLooped(const mesh::Packet* packet, const uint8_t max_counters[])
   return n >= max_counters[hash_size];
 }
 
+void MyMesh::sendFloodReply(mesh::Packet* packet, unsigned long delay_millis, uint8_t path_hash_size) {
+  if (recv_pkt_region && !recv_pkt_region->isWildcard()) {  // if _request_ packet scope is known, send reply with same scope
+    TransportKey scope;
+    if (region_map.getTransportKeysFor(*recv_pkt_region, &scope, 1) > 0) {
+      sendFloodScoped(scope, packet, delay_millis, path_hash_size);
+    } else {
+      sendFlood(packet, delay_millis, path_hash_size);  // send un-scoped
+    }
+  } else {
+    sendFlood(packet, delay_millis, path_hash_size);  // send un-scoped
+  }
+}
+
 bool MyMesh::allowPacketForward(const mesh::Packet *packet) {
   if (_prefs.disable_fwd) return false;
   if (packet->isRouteFlood() && packet->getPathHashCount() >= _prefs.flood_max) return false;
@@ -578,10 +591,10 @@ void MyMesh::onAnonDataRecv(mesh::Packet *packet, const uint8_t *secret, const m
       // let this sender know path TO here, so they can use sendDirect(), and ALSO encode the response
       mesh::Packet* path = createPathReturn(sender, secret, packet->path, packet->path_len,
                                             PAYLOAD_TYPE_RESPONSE, reply_data, reply_len);
-      if (path) sendFlood(path, SERVER_RESPONSE_DELAY, packet->getPathHashSize());
+      if (path) sendFloodReply(path, SERVER_RESPONSE_DELAY, packet->getPathHashSize());
     } else if (reply_path_len < 0) {
       mesh::Packet* reply = createDatagram(PAYLOAD_TYPE_RESPONSE, sender, secret, reply_data, reply_len);
-      if (reply) sendFlood(reply, SERVER_RESPONSE_DELAY, packet->getPathHashSize());
+      if (reply) sendFloodReply(reply, SERVER_RESPONSE_DELAY, packet->getPathHashSize());
     } else {
       mesh::Packet* reply = createDatagram(PAYLOAD_TYPE_RESPONSE, sender, secret, reply_data, reply_len);
       uint8_t path_len = ((reply_path_hash_size - 1) << 6) | (reply_path_len & 63);
@@ -654,7 +667,7 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx,
         // let this sender know path TO here, so they can use sendDirect(), and ALSO encode the response
         mesh::Packet *path = createPathReturn(client->id, secret, packet->path, packet->path_len,
                                               PAYLOAD_TYPE_RESPONSE, reply_data, reply_len);
-        if (path) sendFlood(path, SERVER_RESPONSE_DELAY, packet->getPathHashSize());
+        if (path) sendFloodReply(path, SERVER_RESPONSE_DELAY, packet->getPathHashSize());
       } else {
         mesh::Packet *reply =
             createDatagram(PAYLOAD_TYPE_RESPONSE, client->id, secret, reply_data, reply_len);
@@ -662,7 +675,7 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx,
           if (client->out_path_len != OUT_PATH_UNKNOWN) { // we have an out_path, so send DIRECT
             sendDirect(reply, client->out_path, client->out_path_len, SERVER_RESPONSE_DELAY);
           } else {
-            sendFlood(reply, SERVER_RESPONSE_DELAY, packet->getPathHashSize());
+            sendFloodReply(reply, SERVER_RESPONSE_DELAY, packet->getPathHashSize());
           }
         }
       }
@@ -693,7 +706,7 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx,
         mesh::Packet *ack = createAck(ack_hash);
         if (ack) {
           if (client->out_path_len == OUT_PATH_UNKNOWN) {
-            sendFlood(ack, TXT_ACK_DELAY, packet->getPathHashSize());
+            sendFloodReply(ack, TXT_ACK_DELAY, packet->getPathHashSize());
           } else {
             sendDirect(ack, client->out_path, client->out_path_len, TXT_ACK_DELAY);
           }
@@ -721,7 +734,7 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx,
         auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, secret, temp, 5 + text_len);
         if (reply) {
           if (client->out_path_len == OUT_PATH_UNKNOWN) {
-            sendFlood(reply, CLI_REPLY_DELAY_MILLIS, packet->getPathHashSize());
+            sendFloodReply(reply, CLI_REPLY_DELAY_MILLIS, packet->getPathHashSize());
           } else {
             sendDirect(reply, client->out_path, client->out_path_len, CLI_REPLY_DELAY_MILLIS);
           }
@@ -831,7 +844,9 @@ void MyMesh::sendNodeDiscoverReq() {
 MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondClock &ms, mesh::RNG &rng,
                mesh::RTCClock &rtc, mesh::MeshTables &tables)
     : mesh::Mesh(radio, ms, rng, rtc, *new StaticPoolPacketManager(32), tables),
-      _cli(board, rtc, sensors, acl, &_prefs, this), telemetry(MAX_PACKET_PAYLOAD - 4), region_map(key_store), temp_map(key_store),
+      region_map(key_store), temp_map(key_store),
+      _cli(board, rtc, sensors, region_map, acl, &_prefs, this),
+      telemetry(MAX_PACKET_PAYLOAD - 4),
       discover_limiter(4, 120),  // max 4 every 2 minutes
       anon_limiter(4, 180)   // max 4 every 3 minutes
 #if defined(WITH_RS232_BRIDGE)
@@ -899,6 +914,8 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc
 
   pending_discover_tag = 0;
   pending_discover_until = 0;
+
+  memset(default_scope.key, 0, sizeof(default_scope.key));
 }
 
 void MyMesh::begin(FILESYSTEM *fs) {
@@ -910,6 +927,26 @@ void MyMesh::begin(FILESYSTEM *fs) {
   // TODO: key_store.begin();
   region_map.load(_fs);
 
+  // establish default-scope
+  {
+    RegionEntry* r = region_map.getDefaultRegion();
+    if (r) {
+      region_map.getTransportKeysFor(*r, &default_scope, 1);
+    } else {
+#ifdef DEFAULT_FLOOD_SCOPE_NAME
+      r = region_map.findByName(DEFAULT_FLOOD_SCOPE_NAME);
+      if (r == NULL) {
+        r = region_map.putRegion(DEFAULT_FLOOD_SCOPE_NAME, 0);  // auto-create the default scope region
+        if (r) { r->flags = 0; }   // Allow-flood
+      }
+      if (r) {
+        region_map.setDefaultRegion(r);
+        region_map.getTransportKeysFor(*r, &default_scope, 1);
+      }
+#endif
+    }
+  }
+
 #if defined(WITH_BRIDGE)
   if (_prefs.bridge_enabled) {
     bridge.begin();
@@ -933,6 +970,17 @@ void MyMesh::begin(FILESYSTEM *fs) {
 #endif
 }
 
+void MyMesh::sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis, uint8_t path_hash_size) {
+  if (scope.isNull()) {
+    sendFlood(pkt, delay_millis, path_hash_size);
+  } else {
+    uint16_t codes[2];
+    codes[0] = scope.calcTransportCode(pkt);
+    codes[1] = 0;  // REVISIT: set to 'home' Region, for sender/return region?
+    sendFlood(pkt, codes, delay_millis, path_hash_size);
+  }
+}
+
 void MyMesh::applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) {
   set_radio_at = futureMillis(2000); // give CLI reply some time to be sent back, before applying temp radio params
   pending_freq = freq;
@@ -960,7 +1008,7 @@ void MyMesh::sendSelfAdvertisement(int delay_millis, bool flood) {
   mesh::Packet *pkt = createSelfAdvert();
   if (pkt) {
     if (flood) {
-      sendFlood(pkt, delay_millis, _prefs.path_hash_mode + 1);
+      sendFloodScoped(default_scope, pkt, delay_millis, _prefs.path_hash_mode + 1);
     } else {
       sendZeroHop(pkt, delay_millis);
     }
@@ -1066,6 +1114,25 @@ void MyMesh::removeNeighbor(const uint8_t *pubkey, int key_len) {
 #endif
 }
 
+void MyMesh::startRegionsLoad() {
+  temp_map.resetFrom(region_map);   // rebuild regions in a temp instance
+  memset(load_stack, 0, sizeof(load_stack));
+  load_stack[0] = &temp_map.getWildcard();
+  region_load_active = true;
+}
+
+bool MyMesh::saveRegions() {
+  return region_map.save(_fs);
+}
+
+void MyMesh::onDefaultRegionChanged(const RegionEntry* r) {
+  if (r) {
+    region_map.getTransportKeysFor(*r, &default_scope, 1);
+  } else {
+    memset(default_scope.key, 0, sizeof(default_scope.key));
+  }
+}
+
 void MyMesh::formatStatsReply(char *reply) {
   StatsFormatHelper::formatCoreStats(reply, board, *_ms, _err_flags, _mgr);
 }
@@ -1175,107 +1242,6 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply
       Serial.printf("\n");
     }
     reply[0] = 0;
-  } else if (memcmp(command, "region", 6) == 0) {
-    reply[0] = 0;
-
-    const char* parts[4];
-    int n = mesh::Utils::parseTextParts(command, parts, 4, ' ');
-    if (n == 1) {
-      region_map.exportTo(reply, 160);
-    } else if (n >= 2 && strcmp(parts[1], "load") == 0) {
-      temp_map.resetFrom(region_map);   // rebuild regions in a temp instance
-      memset(load_stack, 0, sizeof(load_stack));
-      load_stack[0] = &temp_map.getWildcard();
-      region_load_active = true;
-    } else if (n >= 2 && strcmp(parts[1], "save") == 0) {
-      _prefs.discovery_mod_timestamp = rtc_clock.getCurrentTime();   // this node is now 'modified' (for discovery info)
-      savePrefs();
-      bool success = region_map.save(_fs);
-      strcpy(reply, success ? "OK" : "Err - save failed");
-    } else if (n >= 3 && strcmp(parts[1], "allowf") == 0) {
-      auto region = region_map.findByNamePrefix(parts[2]);
-      if (region) {
-        region->flags &= ~REGION_DENY_FLOOD;
-        strcpy(reply, "OK");
-      } else {
-        strcpy(reply, "Err - unknown region");
-      }
-    } else if (n >= 3 && strcmp(parts[1], "denyf") == 0) {
-      auto region = region_map.findByNamePrefix(parts[2]);
-      if (region) {
-        region->flags |= REGION_DENY_FLOOD;
-        strcpy(reply, "OK");
-      } else {
-        strcpy(reply, "Err - unknown region");
-      }
-    } else if (n >= 3 && strcmp(parts[1], "get") == 0) {
-      auto region = region_map.findByNamePrefix(parts[2]);
-      if (region) {
-        auto parent = region_map.findById(region->parent);
-        if (parent && parent->id != 0) {
-          sprintf(reply, " %s (%s) %s", region->name, parent->name, (region->flags & REGION_DENY_FLOOD) ? "" : "F");
-        } else {
-          sprintf(reply, " %s %s", region->name, (region->flags & REGION_DENY_FLOOD) ? "" : "F");
-        }
-      } else {
-        strcpy(reply, "Err - unknown region");
-      }
-    } else if (n >= 3 && strcmp(parts[1], "home") == 0) {
-      auto home = region_map.findByNamePrefix(parts[2]);
-      if (home) {
-        region_map.setHomeRegion(home);
-        sprintf(reply, " home is now %s", home->name);
-      } else {
-        strcpy(reply, "Err - unknown region");
-      }
-    } else if (n == 2 && strcmp(parts[1], "home") == 0) {
-      auto home = region_map.getHomeRegion();
-      sprintf(reply, " home is %s", home ? home->name : "*");
-    } else if (n >= 3 && strcmp(parts[1], "put") == 0) {
-      auto parent = n >= 4 ? region_map.findByNamePrefix(parts[3]) : &region_map.getWildcard();
-      if (parent == NULL) {
-        strcpy(reply, "Err - unknown parent");
-      } else {
-        auto region = region_map.putRegion(parts[2], parent->id);
-        if (region == NULL) {
-          strcpy(reply, "Err - unable to put");
-        } else {
-          strcpy(reply, "OK");
-        }
-      }
-    } else if (n >= 3 && strcmp(parts[1], "remove") == 0) {
-      auto region = region_map.findByName(parts[2]);
-      if (region) {
-        if (region_map.removeRegion(*region)) {
-          strcpy(reply, "OK");
-        } else {
-          strcpy(reply, "Err - not empty");
-        }
-      } else {
-        strcpy(reply, "Err - not found");
-      }
-    } else if (n >= 3 && strcmp(parts[1], "list") == 0) {
-      uint8_t mask = 0;
-      bool invert = false;
-      
-      if (strcmp(parts[2], "allowed") == 0) {
-        mask = REGION_DENY_FLOOD;
-        invert = false;  // list regions that DON'T have DENY flag
-      } else if (strcmp(parts[2], "denied") == 0) {
-        mask = REGION_DENY_FLOOD;
-        invert = true;   // list regions that DO have DENY flag
-      } else {
-        strcpy(reply, "Err - use 'allowed' or 'denied'");
-        return;
-      }
-      
-      int len = region_map.exportNamesTo(reply, 160, mask, invert);
-      if (len == 0) {
-        strcpy(reply, "-none-");
-      }
-    } else {
-      strcpy(reply, "Err - ??");
-    }
   } else if (memcmp(command, "discover.neighbors", 18) == 0) {
     const char* sub = command + 18;
     while (*sub == ' ') sub++;
@@ -1300,7 +1266,7 @@ void MyMesh::loop() {
   if (next_flood_advert && millisHasNowPassed(next_flood_advert)) {
     mesh::Packet *pkt = createSelfAdvert();
     uint32_t delay_millis = 0;
-    if (pkt) sendFlood(pkt, delay_millis, _prefs.path_hash_mode + 1);
+    if (pkt) sendFloodScoped(default_scope, pkt, delay_millis, _prefs.path_hash_mode + 1);
 
     updateFloodAdvertTimer(); // schedule next flood advert
     updateAdvertTimer();      // also schedule local advert (so they don't overlap)

+ 10 - 0
examples/simple_repeater/MyMesh.h

@@ -97,6 +97,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks {
   RegionMap region_map, temp_map;
   RegionEntry* load_stack[8];
   RegionEntry* recv_pkt_region;
+  TransportKey default_scope;
   RateLimiter discover_limiter, anon_limiter;
   uint32_t pending_discover_tag;
   unsigned long pending_discover_until;
@@ -172,6 +173,8 @@ protected:
   bool onPeerPathRecv(mesh::Packet* packet, int sender_idx, const uint8_t* secret, uint8_t* path, uint8_t path_len, uint8_t extra_type, uint8_t* extra, uint8_t extra_len) override;
   void onControlDataRecv(mesh::Packet* packet) override;
 
+  void sendFloodReply(mesh::Packet* packet, unsigned long delay_millis, uint8_t path_hash_size);
+
 public:
   MyMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::MillisecondClock& ms, mesh::RNG& rng, mesh::RTCClock& rtc, mesh::MeshTables& tables);
 
@@ -189,6 +192,9 @@ public:
     _cli.savePrefs(_fs);
   }
 
+  void sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis, uint8_t path_hash_size);
+
+  // CommonCLICallbacks
   void applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) override;
   bool formatFileSystem() override;
   void sendSelfAdvertisement(int delay_millis, bool flood) override;
@@ -208,11 +214,15 @@ public:
   void formatStatsReply(char *reply) override;
   void formatRadioStatsReply(char *reply) override;
   void formatPacketStatsReply(char *reply) override;
+  void startRegionsLoad() override;
+  bool saveRegions() override;
+  void onDefaultRegionChanged(const RegionEntry* r) override;
 
   mesh::LocalIdentity& getSelfId() override { return self_id; }
 
   void saveIdentity(const mesh::LocalIdentity& new_id) override;
   void clearStats() override;
+
   void handleCommand(uint32_t sender_timestamp, char* command, char* reply);
   void loop();
 

+ 131 - 10
examples/simple_room_server/MyMesh.cpp

@@ -75,7 +75,7 @@ void MyMesh::pushPostToClient(ClientInfo *client, PostInfo &post) {
   if (reply) {
     if (client->out_path_len == OUT_PATH_UNKNOWN) {
       unsigned long delay_millis = 0;
-      sendFlood(reply, delay_millis, _prefs.path_hash_mode + 1);
+      sendFloodScoped(default_scope, reply, delay_millis, _prefs.path_hash_mode + 1); // REVISIT
       client->extra.room.ack_timeout = futureMillis(PUSH_ACK_TIMEOUT_FLOOD);
     } else {
       sendDirect(reply, client->out_path, client->out_path_len);
@@ -286,6 +286,23 @@ bool MyMesh::allowPacketForward(const mesh::Packet *packet) {
   return true;
 }
 
+bool MyMesh::filterRecvFloodPacket(mesh::Packet* pkt) {
+  // just try to determine region for packet (apply later in allowPacketForward())
+  if (pkt->getRouteType() == ROUTE_TYPE_TRANSPORT_FLOOD) {
+    recv_pkt_region = region_map.findMatch(pkt, REGION_DENY_FLOOD);
+  } else if (pkt->getRouteType() == ROUTE_TYPE_FLOOD) {
+    if (region_map.getWildcard().flags & REGION_DENY_FLOOD) {
+      recv_pkt_region = NULL;
+    } else {
+      recv_pkt_region =  &region_map.getWildcard();
+    }
+  } else {
+    recv_pkt_region = NULL;
+  }
+  // do normal processing
+  return false;
+}
+
 void MyMesh::onAnonDataRecv(mesh::Packet *packet, const uint8_t *secret, const mesh::Identity &sender,
                             uint8_t *data, size_t len) {
   if (packet->getPayloadType() == PAYLOAD_TYPE_ANON_REQ) { // received an initial request by a possible admin
@@ -361,14 +378,14 @@ void MyMesh::onAnonDataRecv(mesh::Packet *packet, const uint8_t *secret, const m
       // let this sender know path TO here, so they can use sendDirect(), and ALSO encode the response
       mesh::Packet *path = createPathReturn(sender, client->shared_secret, packet->path, packet->path_len,
                                             PAYLOAD_TYPE_RESPONSE, reply_data, 13);
-      if (path) sendFlood(path, SERVER_RESPONSE_DELAY, packet->getPathHashSize());
+      if (path) sendFloodReply(path, SERVER_RESPONSE_DELAY, packet->getPathHashSize());
     } else {
       mesh::Packet *reply = createDatagram(PAYLOAD_TYPE_RESPONSE, sender, client->shared_secret, reply_data, 13);
       if (reply) {
         if (client->out_path_len != OUT_PATH_UNKNOWN) { // we have an out_path, so send DIRECT
           sendDirect(reply, client->out_path, client->out_path_len, SERVER_RESPONSE_DELAY);
         } else {
-          sendFlood(reply, SERVER_RESPONSE_DELAY, packet->getPathHashSize());
+          sendFloodReply(reply, SERVER_RESPONSE_DELAY, packet->getPathHashSize());
         }
       }
     }
@@ -458,7 +475,7 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx,
       if (send_ack) {
         if (client->out_path_len == OUT_PATH_UNKNOWN) {
           mesh::Packet *ack = createAck(ack_hash);
-          if (ack) sendFlood(ack, TXT_ACK_DELAY, packet->getPathHashSize());
+          if (ack) sendFloodReply(ack, TXT_ACK_DELAY, packet->getPathHashSize());
           delay_millis = TXT_ACK_DELAY + REPLY_DELAY_MILLIS;
         } else {
           uint32_t d = TXT_ACK_DELAY;
@@ -491,7 +508,7 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx,
         auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, secret, temp, 5 + text_len);
         if (reply) {
           if (client->out_path_len == OUT_PATH_UNKNOWN) {
-            sendFlood(reply, delay_millis + SERVER_RESPONSE_DELAY, packet->getPathHashSize());
+            sendFloodReply(reply, delay_millis + SERVER_RESPONSE_DELAY, packet->getPathHashSize());
           } else {
             sendDirect(reply, client->out_path, client->out_path_len, delay_millis + SERVER_RESPONSE_DELAY);
           }
@@ -546,14 +563,14 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx,
             // let this sender know path TO here, so they can use sendDirect(), and ALSO encode the response
             mesh::Packet *path = createPathReturn(client->id, secret, packet->path, packet->path_len,
                                                   PAYLOAD_TYPE_RESPONSE, reply_data, reply_len);
-            if (path) sendFlood(path, SERVER_RESPONSE_DELAY, packet->getPathHashSize());
+            if (path) sendFloodReply(path, SERVER_RESPONSE_DELAY, packet->getPathHashSize());
           } else {
             mesh::Packet *reply = createDatagram(PAYLOAD_TYPE_RESPONSE, client->id, secret, reply_data, reply_len);
             if (reply) {
               if (client->out_path_len != OUT_PATH_UNKNOWN) { // we have an out_path, so send DIRECT
                 sendDirect(reply, client->out_path, client->out_path_len, SERVER_RESPONSE_DELAY);
               } else {
-                sendFlood(reply, SERVER_RESPONSE_DELAY, packet->getPathHashSize());
+                sendFloodReply(reply, SERVER_RESPONSE_DELAY, packet->getPathHashSize());
               }
             }
           }
@@ -595,12 +612,16 @@ void MyMesh::onAckRecv(mesh::Packet *packet, uint32_t ack_crc) {
 MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondClock &ms, mesh::RNG &rng,
                mesh::RTCClock &rtc, mesh::MeshTables &tables)
     : mesh::Mesh(radio, ms, rng, rtc, *new StaticPoolPacketManager(32), tables),
-      _cli(board, rtc, sensors, acl, &_prefs, this), telemetry(MAX_PACKET_PAYLOAD - 4) {
+      region_map(key_store), temp_map(key_store),
+      _cli(board, rtc, sensors, region_map, acl, &_prefs, this),
+      telemetry(MAX_PACKET_PAYLOAD - 4)
+{
   last_millis = 0;
   uptime_millis = 0;
   next_local_advert = next_flood_advert = 0;
   dirty_contacts_expiry = 0;
   _logging = false;
+  region_load_active = false;
   set_radio_at = revert_radio_at = 0;
 
   // defaults
@@ -637,6 +658,8 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc
   next_push = 0;
   memset(posts, 0, sizeof(posts));
   _num_posted = _num_post_pushes = 0;
+
+  memset(default_scope.key, 0, sizeof(default_scope.key));
 }
 
 void MyMesh::begin(FILESYSTEM *fs) {
@@ -646,6 +669,27 @@ void MyMesh::begin(FILESYSTEM *fs) {
   _cli.loadPrefs(_fs);
 
   acl.load(_fs, self_id);
+  region_map.load(_fs);
+
+  // establish default-scope
+  {
+    RegionEntry* r = region_map.getDefaultRegion();
+    if (r) {
+      region_map.getTransportKeysFor(*r, &default_scope, 1);
+    } else {
+#ifdef DEFAULT_FLOOD_SCOPE_NAME
+      r = region_map.findByName(DEFAULT_FLOOD_SCOPE_NAME);
+      if (r == NULL) {
+        r = region_map.putRegion(DEFAULT_FLOOD_SCOPE_NAME, 0);  // auto-create the default scope region
+        if (r) { r->flags = 0; }   // Allow-flood
+      }
+      if (r) {
+        region_map.setDefaultRegion(r);
+        region_map.getTransportKeysFor(*r, &default_scope, 1);
+      }
+#endif
+    }
+  }
 
   radio_set_params(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr);
   radio_set_tx_power(_prefs.tx_power_dbm);
@@ -660,6 +704,30 @@ void MyMesh::begin(FILESYSTEM *fs) {
 #endif
 }
 
+void MyMesh::sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis, uint8_t path_hash_size) {
+  if (scope.isNull()) {
+    sendFlood(pkt, delay_millis, path_hash_size);
+  } else {
+    uint16_t codes[2];
+    codes[0] = scope.calcTransportCode(pkt);
+    codes[1] = 0;  // REVISIT: set to 'home' Region, for sender/return region?
+    sendFlood(pkt, codes, delay_millis, path_hash_size);
+  }
+}
+
+void MyMesh::sendFloodReply(mesh::Packet* packet, unsigned long delay_millis, uint8_t path_hash_size) {
+  if (recv_pkt_region && !recv_pkt_region->isWildcard()) {  // if _request_ packet scope is known, send reply with same scope
+    TransportKey scope;
+    if (region_map.getTransportKeysFor(*recv_pkt_region, &scope, 1) > 0) {
+      sendFloodScoped(scope, packet, delay_millis, path_hash_size);
+    } else {
+      sendFlood(packet, delay_millis, path_hash_size);   // send un-scoped
+    }
+  } else {
+    sendFlood(packet, delay_millis, path_hash_size);   // send un-scoped
+  }
+}
+
 void MyMesh::applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) {
   set_radio_at = futureMillis(2000); // give CLI reply some time to be sent back, before applying temp radio params
   pending_freq = freq;
@@ -687,7 +755,7 @@ void MyMesh::sendSelfAdvertisement(int delay_millis, bool flood) {
   mesh::Packet *pkt = createSelfAdvert();
   if (pkt) {
     if (flood) {
-      sendFlood(pkt, delay_millis, _prefs.path_hash_mode + 1);
+      sendFloodScoped(default_scope, pkt, delay_millis, _prefs.path_hash_mode + 1);
     } else {
       sendZeroHop(pkt, delay_millis);
     }
@@ -744,6 +812,25 @@ void MyMesh::saveIdentity(const mesh::LocalIdentity &new_id) {
   store.save("_main", new_id);
 }
 
+void MyMesh::startRegionsLoad() {
+  temp_map.resetFrom(region_map);   // rebuild regions in a temp instance
+  memset(load_stack, 0, sizeof(load_stack));
+  load_stack[0] = &temp_map.getWildcard();
+  region_load_active = true;
+}
+
+bool MyMesh::saveRegions() {
+  return region_map.save(_fs);
+}
+
+void MyMesh::onDefaultRegionChanged(const RegionEntry* r) {
+  if (r) {
+    region_map.getTransportKeysFor(*r, &default_scope, 1);
+  } else {
+    memset(default_scope.key, 0, sizeof(default_scope.key));
+  }
+}
+
 void MyMesh::clearStats() {
   radio_driver.resetStats();
   resetStats();
@@ -764,6 +851,40 @@ void MyMesh::formatPacketStatsReply(char *reply) {
 }
 
 void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply) {
+  if (region_load_active) {
+    if (StrHelper::isBlank(command)) {  // empty/blank line, signal to terminate 'load' operation
+      region_map = temp_map;  // copy over the temp instance as new current map
+      region_load_active = false;
+
+      sprintf(reply, "OK - loaded %d regions", region_map.getCount());
+    } else {
+      char *np = command;
+      while (*np == ' ') np++;   // skip indent
+      int indent = np - command;
+
+      char *ep = np;
+      while (RegionMap::is_name_char(*ep)) ep++;
+      if (*ep) { *ep++ = 0; }  // set null terminator for end of name
+
+      while (*ep && *ep != 'F') ep++;  // look for (optional) flags
+
+      if (indent > 0 && indent < 8 && strlen(np) > 0) {
+        auto parent = load_stack[indent - 1];
+        if (parent) {
+          auto old = region_map.findByName(np);
+          auto nw = temp_map.putRegion(np, parent->id, old ? old->id : 0);  // carry-over the current ID (if name already exists)
+          if (nw) {
+            nw->flags = old ? old->flags : (*ep == 'F' ? 0 : REGION_DENY_FLOOD);   // carry-over flags from curr
+
+            load_stack[indent] = nw;  // keep pointers to parent regions, to resolve parent_id's
+          }
+        }
+      }
+      reply[0] = 0;
+    }
+    return;
+  }
+
   while (*command == ' ')
     command++; // skip leading spaces
 
@@ -865,7 +986,7 @@ void MyMesh::loop() {
   if (next_flood_advert && millisHasNowPassed(next_flood_advert)) {
     mesh::Packet *pkt = createSelfAdvert();
     uint32_t delay_millis = 0;
-    if (pkt) sendFlood(pkt, delay_millis, _prefs.path_hash_mode + 1);
+    if (pkt) sendFloodScoped(default_scope, pkt, delay_millis, _prefs.path_hash_mode + 1);
 
     updateFloodAdvertTimer(); // schedule next flood advert
     updateAdvertTimer();      // also schedule local advert (so they don't overlap)

+ 17 - 0
examples/simple_room_server/MyMesh.h

@@ -20,6 +20,7 @@
 #include <helpers/CommonCLI.h>
 #include <helpers/StatsFormatHelper.h>
 #include <helpers/ClientACL.h>
+#include <helpers/RegionMap.h>
 #include <RTClib.h>
 #include <target.h>
 
@@ -93,7 +94,10 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks {
   uint64_t uptime_millis;
   unsigned long next_local_advert, next_flood_advert;
   bool _logging;
+  bool region_load_active;
   NodePrefs _prefs;
+  TransportKeyStore key_store;
+  RegionMap region_map, temp_map;
   ClientACL acl;
   CommonCLI _cli;
   unsigned long dirty_contacts_expiry;
@@ -104,6 +108,9 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks {
   int next_post_idx;
   PostInfo posts[MAX_UNSYNCED_POSTS];   // cyclic queue
   CayenneLPP telemetry;
+  RegionEntry* load_stack[8];
+  RegionEntry* recv_pkt_region;
+  TransportKey default_scope;
   unsigned long set_radio_at, revert_radio_at;
   float pending_freq;
   float pending_bw;
@@ -144,6 +151,8 @@ protected:
     return _prefs.multi_acks;
   }
 
+  bool filterRecvFloodPacket(mesh::Packet* pkt) override;
+
   bool allowPacketForward(const mesh::Packet* packet) override;
   void onAnonDataRecv(mesh::Packet* packet, const uint8_t* secret, const mesh::Identity& sender, uint8_t* data, size_t len) override;
   int searchPeersByHash(const uint8_t* hash) override ;
@@ -158,6 +167,8 @@ protected:
   }
 #endif
 
+  void sendFloodReply(mesh::Packet* packet, unsigned long delay_millis, uint8_t path_hash_size);
+
 public:
   MyMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::MillisecondClock& ms, mesh::RNG& rng, mesh::RTCClock& rtc, mesh::MeshTables& tables);
 
@@ -175,6 +186,9 @@ public:
     _cli.savePrefs(_fs);
   }
 
+  void sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis, uint8_t path_hash_size);
+
+  // CommonCLICallbacks
   void applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) override;
   bool formatFileSystem() override;
   void sendSelfAdvertisement(int delay_millis, bool flood) override;
@@ -196,6 +210,9 @@ public:
   void formatStatsReply(char *reply) override;
   void formatRadioStatsReply(char *reply) override;
   void formatPacketStatsReply(char *reply) override;
+  void startRegionsLoad() override;
+  bool saveRegions() override;
+  void onDefaultRegionChanged(const RegionEntry* r) override;
 
   mesh::LocalIdentity& getSelfId() override { return self_id; }
 

+ 26 - 1
examples/simple_sensor/SensorMesh.cpp

@@ -696,7 +696,9 @@ void SensorMesh::onAckRecv(mesh::Packet* packet, uint32_t ack_crc) {
 
 SensorMesh::SensorMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::MillisecondClock& ms, mesh::RNG& rng, mesh::RTCClock& rtc, mesh::MeshTables& tables)
      : mesh::Mesh(radio, ms, rng, rtc, *new StaticPoolPacketManager(32), tables),
-      _cli(board, rtc, sensors, acl, &_prefs, this), telemetry(MAX_PACKET_PAYLOAD - 4)
+      region_map(key_store),
+      _cli(board, rtc, sensors, region_map, acl, &_prefs, this),
+      telemetry(MAX_PACKET_PAYLOAD - 4)
 {
   next_local_advert = next_flood_advert = 0;
   dirty_contacts_expiry = 0;
@@ -729,6 +731,8 @@ SensorMesh::SensorMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::Millise
   _prefs.gps_enabled = 0;
   _prefs.gps_interval = 0;
   _prefs.advert_loc_policy = ADVERT_LOC_PREFS;
+
+  memset(default_scope.key, 0, sizeof(default_scope.key));
 }
 
 void SensorMesh::begin(FILESYSTEM* fs) {
@@ -738,6 +742,27 @@ void SensorMesh::begin(FILESYSTEM* fs) {
   _cli.loadPrefs(_fs);
 
   acl.load(_fs, self_id);
+  region_map.load(_fs);
+
+  // establish default-scope
+  {
+    RegionEntry* r = region_map.getDefaultRegion();
+    if (r) {
+      region_map.getTransportKeysFor(*r, &default_scope, 1);
+    } else {
+#ifdef DEFAULT_FLOOD_SCOPE_NAME
+      r = region_map.findByName(DEFAULT_FLOOD_SCOPE_NAME);
+      if (r == NULL) {
+        r = region_map.putRegion(DEFAULT_FLOOD_SCOPE_NAME, 0);  // auto-create the default scope region
+        if (r) { r->flags = 0; }   // Allow-flood
+      }
+      if (r) {
+        region_map.setDefaultRegion(r);
+        region_map.getTransportKeysFor(*r, &default_scope, 1);
+      }
+#endif
+    }
+  }
 
   radio_set_params(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr);
   radio_set_tx_power(_prefs.tx_power_dbm);

+ 4 - 0
examples/simple_sensor/SensorMesh.h

@@ -22,6 +22,7 @@
 #include <helpers/CommonCLI.h>
 #include <helpers/StatsFormatHelper.h>
 #include <helpers/ClientACL.h>
+#include <helpers/RegionMap.h>
 #include <RTClib.h>
 #include <target.h>
 
@@ -138,6 +139,9 @@ private:
   uint8_t reply_data[MAX_PACKET_PAYLOAD];
   unsigned long dirty_contacts_expiry;
   CayenneLPP telemetry;
+  TransportKeyStore key_store;
+  RegionMap region_map;
+  TransportKey default_scope;
   uint32_t last_read_time;
   int matching_peer_indexes[MAX_SEARCH_RESULTS];
   int num_alert_tasks;

+ 559 - 430
src/helpers/CommonCLI.cpp

@@ -207,7 +207,7 @@ uint8_t CommonCLI::buildAdvertData(uint8_t node_type, uint8_t* app_data) {
   }
 }
 
-void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, char* reply) {
+void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* reply) {
     if (memcmp(command, "poweroff", 8) == 0 || memcmp(command, "shutdown", 8) == 0) {
       _board->powerOff();  // doesn't return
     } else if (memcmp(command, "reboot", 6) == 0) {
@@ -289,437 +289,10 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch
     } else if (memcmp(command, "clear stats", 11) == 0) {
       _callbacks->clearStats();
       strcpy(reply, "(OK - stats reset)");
-    /*
-     * GET commands
-     */
     } else if (memcmp(command, "get ", 4) == 0) {
-      const char* config = &command[4];
-      if (memcmp(config, "dutycycle", 9) == 0) {
-        float dc = 100.0f / (_prefs->airtime_factor + 1.0f);
-        int dc_int = (int)dc;
-        int dc_frac = (int)((dc - dc_int) * 10.0f + 0.5f);
-        sprintf(reply, "> %d.%d%%", dc_int, dc_frac);
-      } else if (memcmp(config, "af", 2) == 0) {
-        sprintf(reply, "> %s", StrHelper::ftoa(_prefs->airtime_factor));
-      } else if (memcmp(config, "int.thresh", 10) == 0) {
-        sprintf(reply, "> %d", (uint32_t) _prefs->interference_threshold);
-      } else if (memcmp(config, "agc.reset.interval", 18) == 0) {
-        sprintf(reply, "> %d", ((uint32_t) _prefs->agc_reset_interval) * 4);
-      } else if (memcmp(config, "multi.acks", 10) == 0) {
-        sprintf(reply, "> %d", (uint32_t) _prefs->multi_acks);
-      } else if (memcmp(config, "allow.read.only", 15) == 0) {
-        sprintf(reply, "> %s", _prefs->allow_read_only ? "on" : "off");
-      } else if (memcmp(config, "flood.advert.interval", 21) == 0) {
-        sprintf(reply, "> %d", ((uint32_t) _prefs->flood_advert_interval));
-      } else if (memcmp(config, "advert.interval", 15) == 0) {
-        sprintf(reply, "> %d", ((uint32_t) _prefs->advert_interval) * 2);
-      } else if (memcmp(config, "guest.password", 14) == 0) {
-        sprintf(reply, "> %s", _prefs->guest_password);
-      } else if (sender_timestamp == 0 && memcmp(config, "prv.key", 7) == 0) {  // from serial command line only
-        uint8_t prv_key[PRV_KEY_SIZE];
-        int len = _callbacks->getSelfId().writeTo(prv_key, PRV_KEY_SIZE);
-        mesh::Utils::toHex(tmp, prv_key, len);
-        sprintf(reply, "> %s", tmp);
-      } else if (memcmp(config, "name", 4) == 0) {
-        sprintf(reply, "> %s", _prefs->node_name);
-      } else if (memcmp(config, "repeat", 6) == 0) {
-        sprintf(reply, "> %s", _prefs->disable_fwd ? "off" : "on");
-      } else if (memcmp(config, "lat", 3) == 0) {
-        sprintf(reply, "> %s", StrHelper::ftoa(_prefs->node_lat));
-      } else if (memcmp(config, "lon", 3) == 0) {
-        sprintf(reply, "> %s", StrHelper::ftoa(_prefs->node_lon));
-#if defined(USE_SX1262) || defined(USE_SX1268)
-      } else if (memcmp(config, "radio.rxgain", 12) == 0) {
-        sprintf(reply, "> %s", _prefs->rx_boosted_gain ? "on" : "off");
-#endif
-      } else if (memcmp(config, "radio", 5) == 0) {
-        char freq[16], bw[16];
-        strcpy(freq, StrHelper::ftoa(_prefs->freq));
-        strcpy(bw, StrHelper::ftoa3(_prefs->bw));
-        sprintf(reply, "> %s,%s,%d,%d", freq, bw, (uint32_t)_prefs->sf, (uint32_t)_prefs->cr);
-      } else if (memcmp(config, "rxdelay", 7) == 0) {
-        sprintf(reply, "> %s", StrHelper::ftoa(_prefs->rx_delay_base));
-      } else if (memcmp(config, "txdelay", 7) == 0) {
-        sprintf(reply, "> %s", StrHelper::ftoa(_prefs->tx_delay_factor));
-      } else if (memcmp(config, "flood.max", 9) == 0) {
-        sprintf(reply, "> %d", (uint32_t)_prefs->flood_max);
-      } else if (memcmp(config, "direct.txdelay", 14) == 0) {
-        sprintf(reply, "> %s", StrHelper::ftoa(_prefs->direct_tx_delay_factor));
-      } else if (memcmp(config, "owner.info", 10) == 0) {
-        *reply++ = '>';
-        *reply++ = ' ';
-        const char* sp = _prefs->owner_info;
-        while (*sp) {
-          *reply++ = (*sp == '\n') ? '|' : *sp;    // translate newline back to orig '|'
-          sp++;
-        }
-        *reply = 0;  // set null terminator
-      } else if (memcmp(config, "path.hash.mode", 14) == 0) {
-        sprintf(reply, "> %d", (uint32_t)_prefs->path_hash_mode);
-      } else if (memcmp(config, "loop.detect", 11) == 0) {
-        if (_prefs->loop_detect == LOOP_DETECT_OFF) {
-          strcpy(reply, "> off");
-        } else if (_prefs->loop_detect == LOOP_DETECT_MINIMAL) {
-          strcpy(reply, "> minimal");
-        } else if (_prefs->loop_detect == LOOP_DETECT_MODERATE) {
-          strcpy(reply, "> moderate");
-        } else {
-          strcpy(reply, "> strict");
-        }
-      } else if (memcmp(config, "tx", 2) == 0 && (config[2] == 0 || config[2] == ' ')) {
-        sprintf(reply, "> %d", (int32_t) _prefs->tx_power_dbm);
-      } else if (memcmp(config, "freq", 4) == 0) {
-        sprintf(reply, "> %s", StrHelper::ftoa(_prefs->freq));
-      } else if (memcmp(config, "public.key", 10) == 0) {
-        strcpy(reply, "> ");
-        mesh::Utils::toHex(&reply[2], _callbacks->getSelfId().pub_key, PUB_KEY_SIZE);
-      } else if (memcmp(config, "role", 4) == 0) {
-        sprintf(reply, "> %s", _callbacks->getRole());
-      } else if (memcmp(config, "bridge.type", 11) == 0) {
-        sprintf(reply, "> %s",
-#ifdef WITH_RS232_BRIDGE
-                "rs232"
-#elif WITH_ESPNOW_BRIDGE
-                "espnow"
-#else
-                "none"
-#endif
-        );
-#ifdef WITH_BRIDGE
-      } else if (memcmp(config, "bridge.enabled", 14) == 0) {
-        sprintf(reply, "> %s", _prefs->bridge_enabled ? "on" : "off");
-      } else if (memcmp(config, "bridge.delay", 12) == 0) {
-        sprintf(reply, "> %d", (uint32_t)_prefs->bridge_delay);
-      } else if (memcmp(config, "bridge.source", 13) == 0) {
-        sprintf(reply, "> %s", _prefs->bridge_pkt_src ? "logRx" : "logTx");
-#endif
-#ifdef WITH_RS232_BRIDGE
-      } else if (memcmp(config, "bridge.baud", 11) == 0) {
-        sprintf(reply, "> %d", (uint32_t)_prefs->bridge_baud);
-#endif
-#ifdef WITH_ESPNOW_BRIDGE
-      } else if (memcmp(config, "bridge.channel", 14) == 0) {
-        sprintf(reply, "> %d", (uint32_t)_prefs->bridge_channel);
-      } else if (memcmp(config, "bridge.secret", 13) == 0) {
-        sprintf(reply, "> %s", _prefs->bridge_secret);
-#endif
-      } else if (memcmp(config, "bootloader.ver", 14) == 0) {
-      #ifdef NRF52_PLATFORM
-          char ver[32];
-          if (_board->getBootloaderVersion(ver, sizeof(ver))) {
-              sprintf(reply, "> %s", ver);
-          } else {
-              strcpy(reply, "> unknown");
-          }
-      #else
-          strcpy(reply, "ERROR: unsupported");
-      #endif
-      } else if (memcmp(config, "adc.multiplier", 14) == 0) {
-        float adc_mult = _board->getAdcMultiplier();
-        if (adc_mult == 0.0f) {
-          strcpy(reply, "Error: unsupported by this board");
-        } else {
-          sprintf(reply, "> %.3f", adc_mult);
-        }
-      // Power management commands
-      } else if (memcmp(config, "pwrmgt.support", 14) == 0) {
-#ifdef NRF52_POWER_MANAGEMENT
-        strcpy(reply, "> supported");
-#else
-        strcpy(reply, "> unsupported");
-#endif
-      } else if (memcmp(config, "pwrmgt.source", 13) == 0) {
-#ifdef NRF52_POWER_MANAGEMENT
-        strcpy(reply, _board->isExternalPowered() ? "> external" : "> battery");
-#else
-        strcpy(reply, "ERROR: Power management not supported");
-#endif
-      } else if (memcmp(config, "pwrmgt.bootreason", 17) == 0) {
-#ifdef NRF52_POWER_MANAGEMENT
-        sprintf(reply, "> Reset: %s; Shutdown: %s",
-          _board->getResetReasonString(_board->getResetReason()),
-          _board->getShutdownReasonString(_board->getShutdownReason()));
-#else
-        strcpy(reply, "ERROR: Power management not supported");
-#endif
-      } else if (memcmp(config, "pwrmgt.bootmv", 13) == 0) {
-#ifdef NRF52_POWER_MANAGEMENT
-        sprintf(reply, "> %u mV", _board->getBootVoltage());
-#else
-        strcpy(reply, "ERROR: Power management not supported");
-#endif
-      } else {
-        sprintf(reply, "??: %s", config);
-      }
-    /*
-     * SET commands
-     */
+      handleGetCmd(sender_timestamp, command, reply);
     } else if (memcmp(command, "set ", 4) == 0) {
-      const char* config = &command[4];
-      if (memcmp(config, "dutycycle ", 10) == 0) {
-        float dc = atof(&config[10]);
-        if (dc < 1 || dc > 100) {
-          strcpy(reply, "ERROR: dutycycle must be 1-100");
-        } else {
-          _prefs->airtime_factor = (100.0f / dc) - 1.0f;
-          savePrefs();
-          float actual = 100.0f / (_prefs->airtime_factor + 1.0f);
-          int a_int = (int)actual;
-          int a_frac = (int)((actual - a_int) * 10.0f + 0.5f);
-          sprintf(reply, "OK - %d.%d%%", a_int, a_frac);
-        }
-      } else if (memcmp(config, "af ", 3) == 0) {
-        _prefs->airtime_factor = atof(&config[3]);
-        savePrefs();
-        strcpy(reply, "OK");
-      } else if (memcmp(config, "int.thresh ", 11) == 0) {
-        _prefs->interference_threshold = atoi(&config[11]);
-        savePrefs();
-        strcpy(reply, "OK");
-      } else if (memcmp(config, "agc.reset.interval ", 19) == 0) {
-        _prefs->agc_reset_interval = atoi(&config[19]) / 4;
-        savePrefs();
-        sprintf(reply, "OK - interval rounded to %d", ((uint32_t) _prefs->agc_reset_interval) * 4);
-      } else if (memcmp(config, "multi.acks ", 11) == 0) {
-        _prefs->multi_acks = atoi(&config[11]);
-        savePrefs();
-        strcpy(reply, "OK");
-      } else if (memcmp(config, "allow.read.only ", 16) == 0) {
-        _prefs->allow_read_only = memcmp(&config[16], "on", 2) == 0;
-        savePrefs();
-        strcpy(reply, "OK");
-      } else if (memcmp(config, "flood.advert.interval ", 22) == 0) {
-        int hours = _atoi(&config[22]);
-        if ((hours > 0 && hours < 3) || (hours > 168)) {
-          strcpy(reply, "Error: interval range is 3-168 hours");
-        } else {
-          _prefs->flood_advert_interval = (uint8_t)(hours);
-          _callbacks->updateFloodAdvertTimer();
-          savePrefs();
-          strcpy(reply, "OK");
-        }
-      } else if (memcmp(config, "advert.interval ", 16) == 0) {
-        int mins = _atoi(&config[16]);
-        if ((mins > 0 && mins < MIN_LOCAL_ADVERT_INTERVAL) || (mins > 240)) {
-          sprintf(reply, "Error: interval range is %d-240 minutes", MIN_LOCAL_ADVERT_INTERVAL);
-        } else {
-          _prefs->advert_interval = (uint8_t)(mins / 2);
-          _callbacks->updateAdvertTimer();
-          savePrefs();
-          strcpy(reply, "OK");
-        }
-      } else if (memcmp(config, "guest.password ", 15) == 0) {
-        StrHelper::strncpy(_prefs->guest_password, &config[15], sizeof(_prefs->guest_password));
-        savePrefs();
-        strcpy(reply, "OK");
-      } else if (memcmp(config, "prv.key ", 8) == 0) {
-        uint8_t prv_key[PRV_KEY_SIZE];
-        bool success = mesh::Utils::fromHex(prv_key, PRV_KEY_SIZE, &config[8]);
-        // only allow rekey if key is valid
-        if (success && mesh::LocalIdentity::validatePrivateKey(prv_key)) {
-          mesh::LocalIdentity new_id;
-          new_id.readFrom(prv_key, PRV_KEY_SIZE);
-          _callbacks->saveIdentity(new_id);
-          strcpy(reply, "OK, reboot to apply! New pubkey: ");
-          mesh::Utils::toHex(&reply[33], new_id.pub_key, PUB_KEY_SIZE);
-        } else {
-          strcpy(reply, "Error, bad key");
-        }
-      } else if (memcmp(config, "name ", 5) == 0) {
-        if (isValidName(&config[5])) {
-          StrHelper::strncpy(_prefs->node_name, &config[5], sizeof(_prefs->node_name));
-          savePrefs();
-          strcpy(reply, "OK");
-        } else {
-          strcpy(reply, "Error, bad chars");
-        }
-      } else if (memcmp(config, "repeat ", 7) == 0) {
-        _prefs->disable_fwd = memcmp(&config[7], "off", 3) == 0;
-        savePrefs();
-        strcpy(reply, _prefs->disable_fwd ? "OK - repeat is now OFF" : "OK - repeat is now ON");
-#if defined(USE_SX1262) || defined(USE_SX1268)
-      } else if (memcmp(config, "radio.rxgain ", 13) == 0) {
-        _prefs->rx_boosted_gain = memcmp(&config[13], "on", 2) == 0;
-        strcpy(reply, "OK");
-        savePrefs();
-        _callbacks->setRxBoostedGain(_prefs->rx_boosted_gain);
-#endif
-      } else if (memcmp(config, "radio ", 6) == 0) {
-        strcpy(tmp, &config[6]);
-        const char *parts[4];
-        int num = mesh::Utils::parseTextParts(tmp, parts, 4);
-        float freq  = num > 0 ? strtof(parts[0], nullptr) : 0.0f;
-        float bw    = num > 1 ? strtof(parts[1], nullptr) : 0.0f;
-        uint8_t sf  = num > 2 ? atoi(parts[2]) : 0;
-        uint8_t cr  = num > 3 ? atoi(parts[3]) : 0;
-        if (freq >= 150.0f && freq <= 2500.0f && sf >= 5 && sf <= 12 && cr >= 5 && cr <= 8 && bw >= 7.0f && bw <= 500.0f) {
-          _prefs->sf = sf;
-          _prefs->cr = cr;
-          _prefs->freq = freq;
-          _prefs->bw = bw;
-          _callbacks->savePrefs();
-          strcpy(reply, "OK - reboot to apply");
-        } else {
-          strcpy(reply, "Error, invalid radio params");
-        }
-      } else if (memcmp(config, "lat ", 4) == 0) {
-        _prefs->node_lat = atof(&config[4]);
-        savePrefs();
-        strcpy(reply, "OK");
-      } else if (memcmp(config, "lon ", 4) == 0) {
-        _prefs->node_lon = atof(&config[4]);
-        savePrefs();
-        strcpy(reply, "OK");
-      } else if (memcmp(config, "rxdelay ", 8) == 0) {
-        float db = atof(&config[8]);
-        if (db >= 0) {
-          _prefs->rx_delay_base = db;
-          savePrefs();
-          strcpy(reply, "OK");
-        } else {
-          strcpy(reply, "Error, cannot be negative");
-        }
-      } else if (memcmp(config, "txdelay ", 8) == 0) {
-        float f = atof(&config[8]);
-        if (f >= 0) {
-          _prefs->tx_delay_factor = f;
-          savePrefs();
-          strcpy(reply, "OK");
-        } else {
-          strcpy(reply, "Error, cannot be negative");
-        }
-      } else if (memcmp(config, "flood.max ", 10) == 0) {
-        uint8_t m = atoi(&config[10]);
-        if (m <= 64) {
-          _prefs->flood_max = m;
-          savePrefs();
-          strcpy(reply, "OK");
-        } else {
-          strcpy(reply, "Error, max 64");
-        }
-      } else if (memcmp(config, "direct.txdelay ", 15) == 0) {
-        float f = atof(&config[15]);
-        if (f >= 0) {
-          _prefs->direct_tx_delay_factor = f;
-          savePrefs();
-          strcpy(reply, "OK");
-        } else {
-          strcpy(reply, "Error, cannot be negative");
-        }
-      } else if (memcmp(config, "owner.info ", 11) == 0) {
-        config += 11;
-        char *dp = _prefs->owner_info;
-        while (*config && dp - _prefs->owner_info < sizeof(_prefs->owner_info)-1) {
-          *dp++ = (*config == '|') ? '\n' : *config;    // translate '|' to newline chars
-          config++;
-        }
-        *dp = 0;
-        savePrefs();
-        strcpy(reply, "OK");
-      } else if (memcmp(config, "path.hash.mode ", 15) == 0) {
-        config += 15;
-        uint8_t mode = atoi(config);
-        if (mode < 3) {
-          _prefs->path_hash_mode = mode;
-          savePrefs();
-          strcpy(reply, "OK");
-        } else {
-          strcpy(reply, "Error, must be 0,1, or 2");
-        }
-      } else if (memcmp(config, "loop.detect ", 12) == 0) {
-        config += 12;
-        uint8_t mode;
-        if (memcmp(config, "off", 3) == 0) {
-          mode = LOOP_DETECT_OFF;
-        } else if (memcmp(config, "minimal", 7) == 0) {
-          mode = LOOP_DETECT_MINIMAL;
-        } else if (memcmp(config, "moderate", 8) == 0) {
-          mode = LOOP_DETECT_MODERATE;
-        } else if (memcmp(config, "strict", 6) == 0) {
-          mode = LOOP_DETECT_STRICT;
-        } else {
-          mode = 0xFF;
-          strcpy(reply, "Error, must be: off, minimal, moderate, or strict");
-        }
-        if (mode != 0xFF) {
-          _prefs->loop_detect = mode;
-          savePrefs();
-          strcpy(reply, "OK");
-        }
-      } else if (memcmp(config, "tx ", 3) == 0) {
-        _prefs->tx_power_dbm = atoi(&config[3]);
-        savePrefs();
-        _callbacks->setTxPower(_prefs->tx_power_dbm);
-        strcpy(reply, "OK");
-      } else if (sender_timestamp == 0 && memcmp(config, "freq ", 5) == 0) {
-        _prefs->freq = atof(&config[5]);
-        savePrefs();
-        strcpy(reply, "OK - reboot to apply");
-#ifdef WITH_BRIDGE
-      } else if (memcmp(config, "bridge.enabled ", 15) == 0) {
-        _prefs->bridge_enabled = memcmp(&config[15], "on", 2) == 0;
-        _callbacks->setBridgeState(_prefs->bridge_enabled);
-        savePrefs();
-        strcpy(reply, "OK");
-      } else if (memcmp(config, "bridge.delay ", 13) == 0) {
-        int delay = _atoi(&config[13]);
-        if (delay >= 0 && delay <= 10000) {
-          _prefs->bridge_delay = (uint16_t)delay;
-          savePrefs();
-          strcpy(reply, "OK");
-        } else {
-          strcpy(reply, "Error: delay must be between 0-10000 ms");
-        }
-      } else if (memcmp(config, "bridge.source ", 14) == 0) {
-        _prefs->bridge_pkt_src = memcmp(&config[14], "rx", 2) == 0;
-        savePrefs();
-        strcpy(reply, "OK");
-#endif
-#ifdef WITH_RS232_BRIDGE
-      } else if (memcmp(config, "bridge.baud ", 12) == 0) {
-        uint32_t baud = atoi(&config[12]);
-        if (baud >= 9600 && baud <= BRIDGE_MAX_BAUD) {
-          _prefs->bridge_baud = (uint32_t)baud;
-          _callbacks->restartBridge();
-          savePrefs();
-          strcpy(reply, "OK");
-        } else {
-          sprintf(reply, "Error: baud rate must be between 9600-%d",BRIDGE_MAX_BAUD);
-        }
-#endif
-#ifdef WITH_ESPNOW_BRIDGE
-      } else if (memcmp(config, "bridge.channel ", 15) == 0) {
-        int ch = atoi(&config[15]);
-        if (ch > 0 && ch < 15) {
-          _prefs->bridge_channel = (uint8_t)ch;
-          _callbacks->restartBridge();
-          savePrefs();
-          strcpy(reply, "OK");
-        } else {
-          strcpy(reply, "Error: channel must be between 1-14");
-        }
-      } else if (memcmp(config, "bridge.secret ", 14) == 0) {
-        StrHelper::strncpy(_prefs->bridge_secret, &config[14], sizeof(_prefs->bridge_secret));
-        _callbacks->restartBridge();
-        savePrefs();
-        strcpy(reply, "OK");
-#endif
-      } else if (memcmp(config, "adc.multiplier ", 15) == 0) {
-        _prefs->adc_multiplier = atof(&config[15]);
-        if (_board->setAdcMultiplier(_prefs->adc_multiplier)) {
-          savePrefs();
-          if (_prefs->adc_multiplier == 0.0f) {
-            strcpy(reply, "OK - using default board multiplier");
-          } else {
-            sprintf(reply, "OK - multiplier set to %.3f", _prefs->adc_multiplier);
-          }
-        } else {
-          _prefs->adc_multiplier = 0.0f;
-          strcpy(reply, "Error: unsupported by this board");
-        };
-      } else {
-        sprintf(reply, "unknown config: %s", config);
-      }
+      handleSetCmd(sender_timestamp, command, reply);
     } else if (sender_timestamp == 0 && strcmp(command, "erase") == 0) {
       bool s = _callbacks->formatFileSystem();
       sprintf(reply, "File system erase: %s", s ? "OK" : "Err");
@@ -771,6 +344,8 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch
           *(dp-1) = 0; // remove last CR
         }
       }
+    } else if (memcmp(command, "region", 6) == 0) {
+      handleRegionCmd(command, reply);
 #if ENV_INCLUDE_GPS == 1
     } else if (memcmp(command, "gps on", 6) == 0) {
       if (_sensors->setSettingValue("gps", "1")) {
@@ -886,3 +461,557 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch
       strcpy(reply, "Unknown command");
     }
 }
+
+void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* reply) {
+  const char* config = &command[4];
+  if (memcmp(config, "dutycycle ", 10) == 0) {
+    float dc = atof(&config[10]);
+    if (dc < 1 || dc > 100) {
+      strcpy(reply, "ERROR: dutycycle must be 1-100");
+    } else {
+      _prefs->airtime_factor = (100.0f / dc) - 1.0f;
+      savePrefs();
+      float actual = 100.0f / (_prefs->airtime_factor + 1.0f);
+      int a_int = (int)actual;
+      int a_frac = (int)((actual - a_int) * 10.0f + 0.5f);
+      sprintf(reply, "OK - %d.%d%%", a_int, a_frac);
+    }
+  } else if (memcmp(config, "af ", 3) == 0) {
+    _prefs->airtime_factor = atof(&config[3]);
+    savePrefs();
+    strcpy(reply, "OK");
+  } else if (memcmp(config, "int.thresh ", 11) == 0) {
+    _prefs->interference_threshold = atoi(&config[11]);
+    savePrefs();
+    strcpy(reply, "OK");
+  } else if (memcmp(config, "agc.reset.interval ", 19) == 0) {
+    _prefs->agc_reset_interval = atoi(&config[19]) / 4;
+    savePrefs();
+    sprintf(reply, "OK - interval rounded to %d", ((uint32_t) _prefs->agc_reset_interval) * 4);
+  } else if (memcmp(config, "multi.acks ", 11) == 0) {
+    _prefs->multi_acks = atoi(&config[11]);
+    savePrefs();
+    strcpy(reply, "OK");
+  } else if (memcmp(config, "allow.read.only ", 16) == 0) {
+    _prefs->allow_read_only = memcmp(&config[16], "on", 2) == 0;
+    savePrefs();
+    strcpy(reply, "OK");
+  } else if (memcmp(config, "flood.advert.interval ", 22) == 0) {
+    int hours = _atoi(&config[22]);
+    if ((hours > 0 && hours < 3) || (hours > 168)) {
+      strcpy(reply, "Error: interval range is 3-168 hours");
+    } else {
+      _prefs->flood_advert_interval = (uint8_t)(hours);
+      _callbacks->updateFloodAdvertTimer();
+      savePrefs();
+      strcpy(reply, "OK");
+    }
+  } else if (memcmp(config, "advert.interval ", 16) == 0) {
+    int mins = _atoi(&config[16]);
+    if ((mins > 0 && mins < MIN_LOCAL_ADVERT_INTERVAL) || (mins > 240)) {
+      sprintf(reply, "Error: interval range is %d-240 minutes", MIN_LOCAL_ADVERT_INTERVAL);
+    } else {
+      _prefs->advert_interval = (uint8_t)(mins / 2);
+      _callbacks->updateAdvertTimer();
+      savePrefs();
+      strcpy(reply, "OK");
+    }
+  } else if (memcmp(config, "guest.password ", 15) == 0) {
+    StrHelper::strncpy(_prefs->guest_password, &config[15], sizeof(_prefs->guest_password));
+    savePrefs();
+    strcpy(reply, "OK");
+  } else if (memcmp(config, "prv.key ", 8) == 0) {
+    uint8_t prv_key[PRV_KEY_SIZE];
+    bool success = mesh::Utils::fromHex(prv_key, PRV_KEY_SIZE, &config[8]);
+    // only allow rekey if key is valid
+    if (success && mesh::LocalIdentity::validatePrivateKey(prv_key)) {
+      mesh::LocalIdentity new_id;
+      new_id.readFrom(prv_key, PRV_KEY_SIZE);
+      _callbacks->saveIdentity(new_id);
+      strcpy(reply, "OK, reboot to apply! New pubkey: ");
+      mesh::Utils::toHex(&reply[33], new_id.pub_key, PUB_KEY_SIZE);
+    } else {
+      strcpy(reply, "Error, bad key");
+    }
+  } else if (memcmp(config, "name ", 5) == 0) {
+    if (isValidName(&config[5])) {
+      StrHelper::strncpy(_prefs->node_name, &config[5], sizeof(_prefs->node_name));
+      savePrefs();
+      strcpy(reply, "OK");
+    } else {
+      strcpy(reply, "Error, bad chars");
+    }
+  } else if (memcmp(config, "repeat ", 7) == 0) {
+    _prefs->disable_fwd = memcmp(&config[7], "off", 3) == 0;
+    savePrefs();
+    strcpy(reply, _prefs->disable_fwd ? "OK - repeat is now OFF" : "OK - repeat is now ON");
+#if defined(USE_SX1262) || defined(USE_SX1268)
+  } else if (memcmp(config, "radio.rxgain ", 13) == 0) {
+    _prefs->rx_boosted_gain = memcmp(&config[13], "on", 2) == 0;
+    strcpy(reply, "OK");
+    savePrefs();
+    _callbacks->setRxBoostedGain(_prefs->rx_boosted_gain);
+#endif
+  } else if (memcmp(config, "radio ", 6) == 0) {
+    strcpy(tmp, &config[6]);
+    const char *parts[4];
+    int num = mesh::Utils::parseTextParts(tmp, parts, 4);
+    float freq  = num > 0 ? strtof(parts[0], nullptr) : 0.0f;
+    float bw    = num > 1 ? strtof(parts[1], nullptr) : 0.0f;
+    uint8_t sf  = num > 2 ? atoi(parts[2]) : 0;
+    uint8_t cr  = num > 3 ? atoi(parts[3]) : 0;
+    if (freq >= 150.0f && freq <= 2500.0f && sf >= 5 && sf <= 12 && cr >= 5 && cr <= 8 && bw >= 7.0f && bw <= 500.0f) {
+      _prefs->sf = sf;
+      _prefs->cr = cr;
+      _prefs->freq = freq;
+      _prefs->bw = bw;
+      _callbacks->savePrefs();
+      strcpy(reply, "OK - reboot to apply");
+    } else {
+      strcpy(reply, "Error, invalid radio params");
+    }
+  } else if (memcmp(config, "lat ", 4) == 0) {
+    _prefs->node_lat = atof(&config[4]);
+    savePrefs();
+    strcpy(reply, "OK");
+  } else if (memcmp(config, "lon ", 4) == 0) {
+    _prefs->node_lon = atof(&config[4]);
+    savePrefs();
+    strcpy(reply, "OK");
+  } else if (memcmp(config, "rxdelay ", 8) == 0) {
+    float db = atof(&config[8]);
+    if (db >= 0) {
+      _prefs->rx_delay_base = db;
+      savePrefs();
+      strcpy(reply, "OK");
+    } else {
+      strcpy(reply, "Error, cannot be negative");
+    }
+  } else if (memcmp(config, "txdelay ", 8) == 0) {
+    float f = atof(&config[8]);
+    if (f >= 0) {
+      _prefs->tx_delay_factor = f;
+      savePrefs();
+      strcpy(reply, "OK");
+    } else {
+      strcpy(reply, "Error, cannot be negative");
+    }
+  } else if (memcmp(config, "flood.max ", 10) == 0) {
+    uint8_t m = atoi(&config[10]);
+    if (m <= 64) {
+      _prefs->flood_max = m;
+      savePrefs();
+      strcpy(reply, "OK");
+    } else {
+      strcpy(reply, "Error, max 64");
+    }
+  } else if (memcmp(config, "direct.txdelay ", 15) == 0) {
+    float f = atof(&config[15]);
+    if (f >= 0) {
+      _prefs->direct_tx_delay_factor = f;
+      savePrefs();
+      strcpy(reply, "OK");
+    } else {
+      strcpy(reply, "Error, cannot be negative");
+    }
+  } else if (memcmp(config, "owner.info ", 11) == 0) {
+    config += 11;
+    char *dp = _prefs->owner_info;
+    while (*config && dp - _prefs->owner_info < sizeof(_prefs->owner_info)-1) {
+      *dp++ = (*config == '|') ? '\n' : *config;    // translate '|' to newline chars
+      config++;
+    }
+    *dp = 0;
+    savePrefs();
+    strcpy(reply, "OK");
+  } else if (memcmp(config, "path.hash.mode ", 15) == 0) {
+    config += 15;
+    uint8_t mode = atoi(config);
+    if (mode < 3) {
+      _prefs->path_hash_mode = mode;
+      savePrefs();
+      strcpy(reply, "OK");
+    } else {
+      strcpy(reply, "Error, must be 0,1, or 2");
+    }
+  } else if (memcmp(config, "loop.detect ", 12) == 0) {
+    config += 12;
+    uint8_t mode;
+    if (memcmp(config, "off", 3) == 0) {
+      mode = LOOP_DETECT_OFF;
+    } else if (memcmp(config, "minimal", 7) == 0) {
+      mode = LOOP_DETECT_MINIMAL;
+    } else if (memcmp(config, "moderate", 8) == 0) {
+      mode = LOOP_DETECT_MODERATE;
+    } else if (memcmp(config, "strict", 6) == 0) {
+      mode = LOOP_DETECT_STRICT;
+    } else {
+      mode = 0xFF;
+      strcpy(reply, "Error, must be: off, minimal, moderate, or strict");
+    }
+    if (mode != 0xFF) {
+      _prefs->loop_detect = mode;
+      savePrefs();
+      strcpy(reply, "OK");
+    }
+  } else if (memcmp(config, "tx ", 3) == 0) {
+    _prefs->tx_power_dbm = atoi(&config[3]);
+    savePrefs();
+    _callbacks->setTxPower(_prefs->tx_power_dbm);
+    strcpy(reply, "OK");
+  } else if (sender_timestamp == 0 && memcmp(config, "freq ", 5) == 0) {
+    _prefs->freq = atof(&config[5]);
+    savePrefs();
+    strcpy(reply, "OK - reboot to apply");
+#ifdef WITH_BRIDGE
+  } else if (memcmp(config, "bridge.enabled ", 15) == 0) {
+    _prefs->bridge_enabled = memcmp(&config[15], "on", 2) == 0;
+    _callbacks->setBridgeState(_prefs->bridge_enabled);
+    savePrefs();
+    strcpy(reply, "OK");
+  } else if (memcmp(config, "bridge.delay ", 13) == 0) {
+    int delay = _atoi(&config[13]);
+    if (delay >= 0 && delay <= 10000) {
+      _prefs->bridge_delay = (uint16_t)delay;
+      savePrefs();
+      strcpy(reply, "OK");
+    } else {
+      strcpy(reply, "Error: delay must be between 0-10000 ms");
+    }
+  } else if (memcmp(config, "bridge.source ", 14) == 0) {
+    _prefs->bridge_pkt_src = memcmp(&config[14], "rx", 2) == 0;
+    savePrefs();
+    strcpy(reply, "OK");
+#endif
+#ifdef WITH_RS232_BRIDGE
+  } else if (memcmp(config, "bridge.baud ", 12) == 0) {
+    uint32_t baud = atoi(&config[12]);
+    if (baud >= 9600 && baud <= BRIDGE_MAX_BAUD) {
+      _prefs->bridge_baud = (uint32_t)baud;
+      _callbacks->restartBridge();
+      savePrefs();
+      strcpy(reply, "OK");
+    } else {
+      sprintf(reply, "Error: baud rate must be between 9600-%d",BRIDGE_MAX_BAUD);
+    }
+#endif
+#ifdef WITH_ESPNOW_BRIDGE
+  } else if (memcmp(config, "bridge.channel ", 15) == 0) {
+    int ch = atoi(&config[15]);
+    if (ch > 0 && ch < 15) {
+      _prefs->bridge_channel = (uint8_t)ch;
+      _callbacks->restartBridge();
+      savePrefs();
+      strcpy(reply, "OK");
+    } else {
+      strcpy(reply, "Error: channel must be between 1-14");
+    }
+  } else if (memcmp(config, "bridge.secret ", 14) == 0) {
+    StrHelper::strncpy(_prefs->bridge_secret, &config[14], sizeof(_prefs->bridge_secret));
+    _callbacks->restartBridge();
+    savePrefs();
+    strcpy(reply, "OK");
+#endif
+  } else if (memcmp(config, "adc.multiplier ", 15) == 0) {
+    _prefs->adc_multiplier = atof(&config[15]);
+    if (_board->setAdcMultiplier(_prefs->adc_multiplier)) {
+      savePrefs();
+      if (_prefs->adc_multiplier == 0.0f) {
+        strcpy(reply, "OK - using default board multiplier");
+      } else {
+        sprintf(reply, "OK - multiplier set to %.3f", _prefs->adc_multiplier);
+      }
+    } else {
+      _prefs->adc_multiplier = 0.0f;
+      strcpy(reply, "Error: unsupported by this board");
+    };
+  } else {
+    sprintf(reply, "unknown config: %s", config);
+  }
+}
+
+void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* reply) {
+  const char* config = &command[4];
+  if (memcmp(config, "dutycycle", 9) == 0) {
+    float dc = 100.0f / (_prefs->airtime_factor + 1.0f);
+    int dc_int = (int)dc;
+    int dc_frac = (int)((dc - dc_int) * 10.0f + 0.5f);
+    sprintf(reply, "> %d.%d%%", dc_int, dc_frac);
+  } else if (memcmp(config, "af", 2) == 0) {
+    sprintf(reply, "> %s", StrHelper::ftoa(_prefs->airtime_factor));
+  } else if (memcmp(config, "int.thresh", 10) == 0) {
+    sprintf(reply, "> %d", (uint32_t) _prefs->interference_threshold);
+  } else if (memcmp(config, "agc.reset.interval", 18) == 0) {
+    sprintf(reply, "> %d", ((uint32_t) _prefs->agc_reset_interval) * 4);
+  } else if (memcmp(config, "multi.acks", 10) == 0) {
+    sprintf(reply, "> %d", (uint32_t) _prefs->multi_acks);
+  } else if (memcmp(config, "allow.read.only", 15) == 0) {
+    sprintf(reply, "> %s", _prefs->allow_read_only ? "on" : "off");
+  } else if (memcmp(config, "flood.advert.interval", 21) == 0) {
+    sprintf(reply, "> %d", ((uint32_t) _prefs->flood_advert_interval));
+  } else if (memcmp(config, "advert.interval", 15) == 0) {
+    sprintf(reply, "> %d", ((uint32_t) _prefs->advert_interval) * 2);
+  } else if (memcmp(config, "guest.password", 14) == 0) {
+    sprintf(reply, "> %s", _prefs->guest_password);
+  } else if (sender_timestamp == 0 && memcmp(config, "prv.key", 7) == 0) {  // from serial command line only
+    uint8_t prv_key[PRV_KEY_SIZE];
+    int len = _callbacks->getSelfId().writeTo(prv_key, PRV_KEY_SIZE);
+    mesh::Utils::toHex(tmp, prv_key, len);
+    sprintf(reply, "> %s", tmp);
+  } else if (memcmp(config, "name", 4) == 0) {
+    sprintf(reply, "> %s", _prefs->node_name);
+  } else if (memcmp(config, "repeat", 6) == 0) {
+    sprintf(reply, "> %s", _prefs->disable_fwd ? "off" : "on");
+  } else if (memcmp(config, "lat", 3) == 0) {
+    sprintf(reply, "> %s", StrHelper::ftoa(_prefs->node_lat));
+  } else if (memcmp(config, "lon", 3) == 0) {
+    sprintf(reply, "> %s", StrHelper::ftoa(_prefs->node_lon));
+#if defined(USE_SX1262) || defined(USE_SX1268)
+  } else if (memcmp(config, "radio.rxgain", 12) == 0) {
+    sprintf(reply, "> %s", _prefs->rx_boosted_gain ? "on" : "off");
+#endif
+  } else if (memcmp(config, "radio", 5) == 0) {
+    char freq[16], bw[16];
+    strcpy(freq, StrHelper::ftoa(_prefs->freq));
+    strcpy(bw, StrHelper::ftoa3(_prefs->bw));
+    sprintf(reply, "> %s,%s,%d,%d", freq, bw, (uint32_t)_prefs->sf, (uint32_t)_prefs->cr);
+  } else if (memcmp(config, "rxdelay", 7) == 0) {
+    sprintf(reply, "> %s", StrHelper::ftoa(_prefs->rx_delay_base));
+  } else if (memcmp(config, "txdelay", 7) == 0) {
+    sprintf(reply, "> %s", StrHelper::ftoa(_prefs->tx_delay_factor));
+  } else if (memcmp(config, "flood.max", 9) == 0) {
+    sprintf(reply, "> %d", (uint32_t)_prefs->flood_max);
+  } else if (memcmp(config, "direct.txdelay", 14) == 0) {
+    sprintf(reply, "> %s", StrHelper::ftoa(_prefs->direct_tx_delay_factor));
+  } else if (memcmp(config, "owner.info", 10) == 0) {
+    *reply++ = '>';
+    *reply++ = ' ';
+    const char* sp = _prefs->owner_info;
+    while (*sp) {
+      *reply++ = (*sp == '\n') ? '|' : *sp;    // translate newline back to orig '|'
+      sp++;
+    }
+    *reply = 0;  // set null terminator
+  } else if (memcmp(config, "path.hash.mode", 14) == 0) {
+    sprintf(reply, "> %d", (uint32_t)_prefs->path_hash_mode);
+  } else if (memcmp(config, "loop.detect", 11) == 0) {
+    if (_prefs->loop_detect == LOOP_DETECT_OFF) {
+      strcpy(reply, "> off");
+    } else if (_prefs->loop_detect == LOOP_DETECT_MINIMAL) {
+      strcpy(reply, "> minimal");
+    } else if (_prefs->loop_detect == LOOP_DETECT_MODERATE) {
+      strcpy(reply, "> moderate");
+    } else {
+      strcpy(reply, "> strict");
+    }
+  } else if (memcmp(config, "tx", 2) == 0 && (config[2] == 0 || config[2] == ' ')) {
+    sprintf(reply, "> %d", (int32_t) _prefs->tx_power_dbm);
+  } else if (memcmp(config, "freq", 4) == 0) {
+    sprintf(reply, "> %s", StrHelper::ftoa(_prefs->freq));
+  } else if (memcmp(config, "public.key", 10) == 0) {
+    strcpy(reply, "> ");
+    mesh::Utils::toHex(&reply[2], _callbacks->getSelfId().pub_key, PUB_KEY_SIZE);
+  } else if (memcmp(config, "role", 4) == 0) {
+    sprintf(reply, "> %s", _callbacks->getRole());
+  } else if (memcmp(config, "bridge.type", 11) == 0) {
+    sprintf(reply, "> %s",
+#ifdef WITH_RS232_BRIDGE
+            "rs232"
+#elif WITH_ESPNOW_BRIDGE
+            "espnow"
+#else
+            "none"
+#endif
+    );
+#ifdef WITH_BRIDGE
+  } else if (memcmp(config, "bridge.enabled", 14) == 0) {
+    sprintf(reply, "> %s", _prefs->bridge_enabled ? "on" : "off");
+  } else if (memcmp(config, "bridge.delay", 12) == 0) {
+    sprintf(reply, "> %d", (uint32_t)_prefs->bridge_delay);
+  } else if (memcmp(config, "bridge.source", 13) == 0) {
+    sprintf(reply, "> %s", _prefs->bridge_pkt_src ? "logRx" : "logTx");
+#endif
+#ifdef WITH_RS232_BRIDGE
+  } else if (memcmp(config, "bridge.baud", 11) == 0) {
+    sprintf(reply, "> %d", (uint32_t)_prefs->bridge_baud);
+#endif
+#ifdef WITH_ESPNOW_BRIDGE
+  } else if (memcmp(config, "bridge.channel", 14) == 0) {
+    sprintf(reply, "> %d", (uint32_t)_prefs->bridge_channel);
+  } else if (memcmp(config, "bridge.secret", 13) == 0) {
+    sprintf(reply, "> %s", _prefs->bridge_secret);
+#endif
+  } else if (memcmp(config, "bootloader.ver", 14) == 0) {
+  #ifdef NRF52_PLATFORM
+      char ver[32];
+      if (_board->getBootloaderVersion(ver, sizeof(ver))) {
+          sprintf(reply, "> %s", ver);
+      } else {
+          strcpy(reply, "> unknown");
+      }
+  #else
+      strcpy(reply, "ERROR: unsupported");
+  #endif
+  } else if (memcmp(config, "adc.multiplier", 14) == 0) {
+    float adc_mult = _board->getAdcMultiplier();
+    if (adc_mult == 0.0f) {
+      strcpy(reply, "Error: unsupported by this board");
+    } else {
+      sprintf(reply, "> %.3f", adc_mult);
+    }
+  // Power management commands
+  } else if (memcmp(config, "pwrmgt.support", 14) == 0) {
+#ifdef NRF52_POWER_MANAGEMENT
+    strcpy(reply, "> supported");
+#else
+    strcpy(reply, "> unsupported");
+#endif
+  } else if (memcmp(config, "pwrmgt.source", 13) == 0) {
+#ifdef NRF52_POWER_MANAGEMENT
+    strcpy(reply, _board->isExternalPowered() ? "> external" : "> battery");
+#else
+    strcpy(reply, "ERROR: Power management not supported");
+#endif
+  } else if (memcmp(config, "pwrmgt.bootreason", 17) == 0) {
+#ifdef NRF52_POWER_MANAGEMENT
+    sprintf(reply, "> Reset: %s; Shutdown: %s",
+      _board->getResetReasonString(_board->getResetReason()),
+      _board->getShutdownReasonString(_board->getShutdownReason()));
+#else
+    strcpy(reply, "ERROR: Power management not supported");
+#endif
+  } else if (memcmp(config, "pwrmgt.bootmv", 13) == 0) {
+#ifdef NRF52_POWER_MANAGEMENT
+    sprintf(reply, "> %u mV", _board->getBootVoltage());
+#else
+    strcpy(reply, "ERROR: Power management not supported");
+#endif
+  } else {
+    sprintf(reply, "??: %s", config);
+  }
+}
+
+void CommonCLI::handleRegionCmd(char* command, char* reply) {
+  reply[0] = 0;
+
+  const char* parts[4];
+  int n = mesh::Utils::parseTextParts(command, parts, 4, ' ');
+  if (n == 1) {
+    _region_map->exportTo(reply, 160);
+  } else if (n >= 2 && strcmp(parts[1], "load") == 0) {
+    _callbacks->startRegionsLoad();
+  } else if (n >= 2 && strcmp(parts[1], "save") == 0) {
+    _prefs->discovery_mod_timestamp = getRTCClock()->getCurrentTime();   // this node is now 'modified' (for discovery info)
+    savePrefs();
+    bool success = _callbacks->saveRegions();
+    strcpy(reply, success ? "OK" : "Err - save failed");
+  } else if (n >= 3 && strcmp(parts[1], "allowf") == 0) {
+    auto region = _region_map->findByNamePrefix(parts[2]);
+    if (region) {
+      region->flags &= ~REGION_DENY_FLOOD;
+      strcpy(reply, "OK");
+    } else {
+      strcpy(reply, "Err - unknown region");
+    }
+  } else if (n >= 3 && strcmp(parts[1], "denyf") == 0) {
+    auto region = _region_map->findByNamePrefix(parts[2]);
+    if (region) {
+      region->flags |= REGION_DENY_FLOOD;
+      strcpy(reply, "OK");
+    } else {
+      strcpy(reply, "Err - unknown region");
+    }
+  } else if (n >= 3 && strcmp(parts[1], "get") == 0) {
+    auto region = _region_map->findByNamePrefix(parts[2]);
+    if (region) {
+      auto parent = _region_map->findById(region->parent);
+      if (parent && parent->id != 0) {
+        sprintf(reply, " %s (%s) %s", region->name, parent->name, (region->flags & REGION_DENY_FLOOD) ? "" : "F");
+      } else {
+        sprintf(reply, " %s %s", region->name, (region->flags & REGION_DENY_FLOOD) ? "" : "F");
+      }
+    } else {
+      strcpy(reply, "Err - unknown region");
+    }
+  } else if (n >= 3 && strcmp(parts[1], "home") == 0) {
+    auto home = _region_map->findByNamePrefix(parts[2]);
+    if (home) {
+      _region_map->setHomeRegion(home);
+      sprintf(reply, " home is now %s", home->name);
+    } else {
+      strcpy(reply, "Err - unknown region");
+    }
+  } else if (n == 2 && strcmp(parts[1], "home") == 0) {
+    auto home = _region_map->getHomeRegion();
+    sprintf(reply, " home is %s", home ? home->name : "*");
+  } else if (n >= 3 && strcmp(parts[1], "default") == 0) {
+    if (strcmp(parts[2], "<null>") == 0) {
+      _region_map->setDefaultRegion(NULL);
+      _callbacks->onDefaultRegionChanged(NULL);
+      _callbacks->saveRegions();  // persist in one atomic step
+      sprintf(reply, " default scope is now <null>");
+    } else {
+      auto def = _region_map->findByNamePrefix(parts[2]);
+      if (def == NULL) {
+        def = _region_map->putRegion(parts[2], 0);  // auto-create the default region
+      }
+      if (def) {
+        def->flags = 0;   // make sure allow flood enabled
+        _region_map->setDefaultRegion(def);
+        _callbacks->onDefaultRegionChanged(def);
+        _callbacks->saveRegions();  // persist in one atomic step
+        sprintf(reply, " default scope is now %s", def->name);
+      } else {
+        strcpy(reply, "Err - region table full");
+      }
+    }
+  } else if (n == 2 && strcmp(parts[1], "default") == 0) {
+    auto def = _region_map->getDefaultRegion();
+    sprintf(reply, " default scope is %s", def ? def->name : "<null>");
+  } else if (n >= 3 && strcmp(parts[1], "put") == 0) {
+    auto parent = n >= 4 ? _region_map->findByNamePrefix(parts[3]) : &(_region_map->getWildcard());
+    if (parent == NULL) {
+      strcpy(reply, "Err - unknown parent");
+    } else {
+      auto region = _region_map->putRegion(parts[2], parent->id);
+      if (region == NULL) {
+        strcpy(reply, "Err - unable to put");
+      } else {
+        region->flags = 0;   // New default: enable flood
+        strcpy(reply, "OK - (flood allowed)");
+      }
+    }
+  } else if (n >= 3 && strcmp(parts[1], "remove") == 0) {
+    auto region = _region_map->findByName(parts[2]);
+    if (region) {
+      if (_region_map->removeRegion(*region)) {
+        strcpy(reply, "OK");
+      } else {
+        strcpy(reply, "Err - not empty");
+      }
+    } else {
+      strcpy(reply, "Err - not found");
+    }
+  } else if (n >= 3 && strcmp(parts[1], "list") == 0) {
+    uint8_t mask = 0;
+    bool invert = false;
+    
+    if (strcmp(parts[2], "allowed") == 0) {
+      mask = REGION_DENY_FLOOD;
+      invert = false;  // list regions that DON'T have DENY flag
+    } else if (strcmp(parts[2], "denied") == 0) {
+      mask = REGION_DENY_FLOOD;
+      invert = true;   // list regions that DO have DENY flag
+    } else {
+      strcpy(reply, "Err - use 'allowed' or 'denied'");
+      return;
+    }
+    
+    int len = _region_map->exportNamesTo(reply, 160, mask, invert);
+    if (len == 0) {
+      strcpy(reply, "-none-");
+    }
+  } else {
+    strcpy(reply, "Err - ??");
+  }
+}

+ 19 - 3
src/helpers/CommonCLI.h

@@ -4,6 +4,7 @@
 #include <helpers/IdentityStore.h>
 #include <helpers/SensorManager.h>
 #include <helpers/ClientACL.h>
+#include <helpers/RegionMap.h>
 
 #if defined(WITH_RS232_BRIDGE) || defined(WITH_ESPNOW_BRIDGE)
 #define WITH_BRIDGE
@@ -88,6 +89,16 @@ public:
   virtual void clearStats() = 0;
   virtual void applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) = 0;
 
+  virtual void startRegionsLoad() {
+    // no op by default
+  }
+  virtual bool saveRegions() {
+    return false;
+  }
+  virtual void onDefaultRegionChanged(const RegionEntry* r) {
+    // no op by default
+  }
+
   virtual void setBridgeState(bool enable) {
     // no op by default
   };
@@ -107,6 +118,7 @@ class CommonCLI {
   CommonCLICallbacks* _callbacks;
   mesh::MainBoard* _board;
   SensorManager* _sensors;
+  RegionMap* _region_map;
   ClientACL* _acl;
   char tmp[PRV_KEY_SIZE*2 + 4];
 
@@ -114,12 +126,16 @@ class CommonCLI {
   void savePrefs();
   void loadPrefsInt(FILESYSTEM* _fs, const char* filename);
 
+  void handleRegionCmd(char* command, char* reply);
+  void handleGetCmd(uint32_t sender_timestamp, char* command, char* reply);
+  void handleSetCmd(uint32_t sender_timestamp, char* command, char* reply);
+
 public:
-  CommonCLI(mesh::MainBoard& board, mesh::RTCClock& rtc, SensorManager& sensors, ClientACL& acl, NodePrefs* prefs, CommonCLICallbacks* callbacks)
-      : _board(&board), _rtc(&rtc), _sensors(&sensors), _acl(&acl), _prefs(prefs), _callbacks(callbacks) { }
+  CommonCLI(mesh::MainBoard& board, mesh::RTCClock& rtc, SensorManager& sensors, RegionMap& region_map, ClientACL& acl, NodePrefs* prefs, CommonCLICallbacks* callbacks)
+      : _board(&board), _rtc(&rtc), _sensors(&sensors), _region_map(&region_map), _acl(&acl), _prefs(prefs), _callbacks(callbacks) { }
 
   void loadPrefs(FILESYSTEM* _fs);
   void savePrefs(FILESYSTEM* _fs);
-  void handleCommand(uint32_t sender_timestamp, const char* command, char* reply);
+  void handleCommand(uint32_t sender_timestamp, char* command, char* reply);
   uint8_t buildAdvertData(uint8_t node_type, uint8_t* app_data);
 };

+ 34 - 17
src/helpers/RegionMap.cpp

@@ -42,7 +42,8 @@ private:
 
 
 RegionMap::RegionMap(TransportKeyStore& store) : _store(&store) {
-  next_id = 1; num_regions = 0; home_id = 0;
+  next_id = 1; num_regions = 0;
+  default_id = home_id = 0;
   wildcard.id = wildcard.parent = 0;
   wildcard.flags = 0;  // default behaviour, allow flood and direct
   strcpy(wildcard.name, "*");
@@ -79,9 +80,11 @@ bool RegionMap::load(FILESYSTEM* _fs, const char* path) {
     if (file) {
       uint8_t pad[128];
 
-      num_regions = 0; next_id = 1; home_id = 0;
+      num_regions = 0; next_id = 1;
+      default_id = home_id = 0;
 
-      bool success = file.read(pad, 5) == 5;  // reserved header
+      bool success = file.read(pad, 3) == 3;  // reserved header
+      success = success && file.read((uint8_t *) &default_id, sizeof(default_id)) == sizeof(default_id);
       success = success && file.read((uint8_t *) &home_id, sizeof(home_id)) == sizeof(home_id);
       success = success && file.read((uint8_t *) &wildcard.flags, sizeof(wildcard.flags)) == sizeof(wildcard.flags);
       success = success && file.read((uint8_t *) &next_id, sizeof(next_id)) == sizeof(next_id);
@@ -117,7 +120,8 @@ bool RegionMap::save(FILESYSTEM* _fs, const char* path) {
     uint8_t pad[128];
     memset(pad, 0, sizeof(pad));
 
-    bool success = file.write(pad, 5) == 5;  // reserved header
+    bool success = file.write(pad, 3) == 3;  // reserved header
+    success = success && file.write((uint8_t *) &default_id, sizeof(default_id)) == sizeof(default_id);
     success = success && file.write((uint8_t *) &home_id, sizeof(home_id)) == sizeof(home_id);
     success = success && file.write((uint8_t *) &wildcard.flags, sizeof(wildcard.flags)) == sizeof(wildcard.flags);
     success = success && file.write((uint8_t *) &next_id, sizeof(next_id)) == sizeof(next_id);
@@ -164,24 +168,29 @@ RegionEntry* RegionMap::putRegion(const char* name, uint16_t parent_id, uint16_t
   return region;
 }
 
+int RegionMap::getTransportKeysFor(const RegionEntry& src, TransportKey dest[], int max_num) {
+  int num;
+  if (src.name[0] == '$') {   // private region
+    num = _store->loadKeysFor(src.id, dest, max_num);
+  } else if (src.name[0] == '#') {   // auto hashtag region
+    _store->getAutoKeyFor(src.id, src.name, dest[0]);
+    num = 1;
+  } else {   // new: implicit auto hashtag region
+    char tmp[sizeof(src.name)];
+    tmp[0] = '#';
+    strcpy(&tmp[1], src.name);
+    _store->getAutoKeyFor(src.id, tmp, dest[0]);
+    num = 1;
+  }
+  return num;
+}
+
 RegionEntry* RegionMap::findMatch(mesh::Packet* packet, uint8_t mask) {
   for (int i = 0; i < num_regions; i++) {
     auto region = &regions[i];
     if ((region->flags & mask) == 0) {   // does region allow this? (per 'mask' param)
       TransportKey keys[4];
-      int num;
-      if (region->name[0] == '$') {   // private region
-        num = _store->loadKeysFor(region->id, keys, 4);
-      } else if (region->name[0] == '#') {   // auto hashtag region
-        _store->getAutoKeyFor(region->id, region->name, keys[0]);
-        num = 1;
-      } else {   // new: implicit auto hashtag region
-        char tmp[sizeof(region->name)];
-        tmp[0] = '#';
-        strcpy(&tmp[1], region->name);
-        _store->getAutoKeyFor(region->id, tmp, keys[0]);
-        num = 1;
-      }
+      int num = getTransportKeysFor(*region, keys, 4);
       for (int j = 0; j < num; j++) {
         uint16_t code = keys[j].calcTransportCode(packet);
         if (packet->transport_codes[0] == code) {   // a match!!
@@ -237,6 +246,14 @@ void RegionMap::setHomeRegion(const RegionEntry* home) {
   home_id = home ? home->id : 0;
 }
 
+RegionEntry* RegionMap::getDefaultRegion() {
+  return default_id == 0 ? NULL : findById(default_id);
+}
+
+void RegionMap::setDefaultRegion(const RegionEntry* def) {
+  default_id = def ? def->id : 0;
+}
+
 bool RegionMap::removeRegion(const RegionEntry& region) {
   if (region.id == 0) return false;  // failed (cannot remove the wildcard Region)
 

+ 6 - 1
src/helpers/RegionMap.h

@@ -16,11 +16,13 @@ struct RegionEntry {
   uint16_t parent;
   uint8_t flags;
   char name[31];
+
+  bool isWildcard() const { return id == 0; }
 };
 
 class RegionMap {
   TransportKeyStore* _store;
-  uint16_t next_id, home_id;
+  uint16_t next_id, home_id, default_id;
   uint16_t num_regions;
   RegionEntry regions[MAX_REGION_ENTRIES];
   RegionEntry wildcard;
@@ -43,6 +45,8 @@ public:
   RegionEntry* findById(uint16_t id);
   RegionEntry* getHomeRegion();   // NOTE: can be NULL
   void setHomeRegion(const RegionEntry* home);
+  RegionEntry* getDefaultRegion();   // NOTE: can be NULL
+  void setDefaultRegion(const RegionEntry* def);
   bool removeRegion(const RegionEntry& region);
   bool clear();
   void resetFrom(const RegionMap& src) { num_regions = 0; next_id = src.next_id; }
@@ -50,6 +54,7 @@ public:
   const RegionEntry* getByIdx(int i) const { return &regions[i]; }
   const RegionEntry* getRoot() const { return &wildcard; }
   int exportNamesTo(char *dest, int max_len, uint8_t mask, bool invert = false);
+  int getTransportKeysFor(const RegionEntry& src, TransportKey dest[], int max_num);
 
   void    exportTo(Stream& out) const;
   size_t  exportTo(char *dest, size_t max_len) const;