diff --git a/.github/workflows/rcc6-release-build.yml b/.github/workflows/rcc6-release-build.yml index eb87e7fe..e4901de9 100644 --- a/.github/workflows/rcc6-release-build.yml +++ b/.github/workflows/rcc6-release-build.yml @@ -85,9 +85,9 @@ jobs: out = Path("release") out.mkdir() - app_name = "NeonPocketMC-RCC6-Ultimate-Observer-v1.3.0-rc.3-app.bin" - recovery_name = "NeonPocketMC-RCC6-Ultimate-Observer-v1.3.0-rc.3-full-recovery-preserves-meshcore-settings.bin" - configurator_name = "NeonPocketMC-RCC6-Ultimate-Server-v1.3.0-rc.3-configurator.zip" + app_name = "NeonPocketMC-RCC6-Ultimate-Observer-v1.3.0-rc.4-app.bin" + recovery_name = "NeonPocketMC-RCC6-Ultimate-Observer-v1.3.0-rc.4-full-recovery-preserves-meshcore-settings.bin" + configurator_name = "NeonPocketMC-RCC6-Ultimate-Server-v1.3.0-rc.4-configurator.zip" (out / app_name).write_bytes(app) (out / recovery_name).write_bytes(merged) @@ -115,7 +115,7 @@ jobs: build_info = ( "Git commit: ${{ github.event.pull_request.head.sha || github.sha }}\n" "Target: heltec_rcc6_repeater_observer_mqtt\n" - "MeshCore base: official v1.17.0 + post-release SX126x RX-gain fix\n" + "MeshCore target baseline: official v1.17.1\n" "Observer base: agessaman/MeshCore b744b42a plus reviewed stability fixes through 8d1a0eb3\n" ) (out / "QUICK_START.txt").write_text(quick_start, encoding="utf-8") diff --git a/README.md b/README.md index 825e7072..e1555e2a 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ MeshCore repeater and room-server firmware for the **Heltec RadioCore RCC6-L62 / **Guided install:** [flasher.canadaverse.org](https://flasher.canadaverse.org/) -This project uses the official MeshCore 1.17.0 baseline, the post-release SX126x boosted-RX-gain persistence fix intended for the next receiver maintenance release, and the production MQTT observer base plus reviewed memory, reconnect, retry, and packet-path fixes from [`agessaman/MeshCore`](https://github.com/agessaman/MeshCore/tree/8d1a0eb3). There is no official upstream MeshCore 1.17.1 tag at the time of this release. +This project is synchronized with the target-relevant MeshCore 1.17.1 maintenance fixes, including SX126x boosted-RX-gain persistence and corrected server reply routing, plus the production MQTT observer base and reviewed memory, reconnect, retry, and packet-path fixes from [`agessaman/MeshCore`](https://github.com/agessaman/MeshCore/tree/8d1a0eb3). ## Choose the firmware profile @@ -54,7 +54,7 @@ The USB configurator accepts only the two full Room Server profiles (and the exi ## What each role does -- **RCC6 Ultimate observer/repeater:** forwards mesh traffic, observes packets, joins 2.4 GHz Wi-Fi, publishes to at most two MQTT brokers, and serves the full Ultimate dashboard. Current `v1.3.0-rc.3` rejects impossible RCC6 battery readings while retaining explicit saved-location advert sharing. Prior server/repeater releases remain available. +- **RCC6 Ultimate observer/repeater:** forwards mesh traffic, observes packets, joins 2.4 GHz Wi-Fi, publishes to at most two MQTT brokers, and serves the full Ultimate dashboard. Current `v1.3.0-rc.4` fixes saved-location adverts on GPS-less builds while retaining GPS controls on supported hardware. Prior server/repeater releases remain available. - **Room Server, both sizes:** hosts the standard MeshCore room/client protocol with 32 recent posts held in RAM. A reboot clears those buffered posts. Repeating is optional but defaults off; a separate repeater is recommended. - **Room Server, minimal:** LoRa room service and USB CLI only. It has no Wi-Fi, Web dashboard, or MQTT code to configure. - **Room Server, full:** adds 2.4 GHz AP/STA onboarding, the authenticated dashboard, and one-way MQTT observation. MQTT data is never injected into RF. @@ -65,15 +65,15 @@ All roles use fail-closed MeshCore storage and default their own adverts to **3- | Image | Setup after flashing | |---|---| -| Ultimate repeater/observer `v1.3.0-rc.3` | Supplied Windows/Linux network wizard | -| Ultimate Room Server full headless or full TFT | `v1.3.0-rc.3` network wizard; it also changes both room passwords | +| Ultimate repeater/observer `v1.3.0-rc.4` | Supplied Windows/Linux network wizard | +| Ultimate Room Server full headless or full TFT | `v1.3.0-rc.4` network wizard; it also changes both room passwords | | Room Server minimal headless or minimal TFT | USB serial CLI or the generic MeshCore USB configurator; no network wizard | Always attach a tuned LoRa antenna before powering or transmitting. Flash the selected application image at `0x10000`, leave USB connected, and do not deploy until the radio settings and passwords have been changed. ### Guided setup for observer and full profiles -Download `NeonPocketMC-RCC6-Ultimate-Server-v1.3.0-rc.3-configurator.zip` from the same release as the selected firmware. +Download `NeonPocketMC-RCC6-Ultimate-Server-v1.3.0-rc.4-configurator.zip` from the same release as the selected firmware. - **Windows:** double-click `Configure-RCC6-Windows.cmd`. - **Linux:** open the extracted folder in a terminal and run `sh configure-rcc6-linux.sh`. @@ -264,18 +264,18 @@ Install [esptool](https://docs.espressif.com/projects/esptool/en/latest/esp32c6/ The current RCC6 Ultimate server release files are: -- `NeonPocketMC-RCC6-Ultimate-Observer-v1.3.0-rc.3-app.bin` -- `NeonPocketMC-RCC6-Ultimate-Observer-v1.3.0-rc.3-full-recovery-preserves-meshcore-settings.bin` -- `NeonPocketMC-RCC6-Ultimate-Server-v1.3.0-rc.3-configurator.zip` +- `NeonPocketMC-RCC6-Ultimate-Observer-v1.3.0-rc.4-app.bin` +- `NeonPocketMC-RCC6-Ultimate-Observer-v1.3.0-rc.4-full-recovery-preserves-meshcore-settings.bin` +- `NeonPocketMC-RCC6-Ultimate-Server-v1.3.0-rc.4-configurator.zip` -Room Server `v1.3.0-rc.3` uses these profile-specific names: +Room Server `v1.3.0-rc.4` uses these profile-specific names: | Profile | Application update | Recovery image | |---|---|---| -| Minimal headless | `NeonPocketMC-RCC6-Ultimate-Room-Server-minimal-headless-v1.3.0-rc.3-app.bin` | `NeonPocketMC-RCC6-Ultimate-Room-Server-minimal-headless-v1.3.0-rc.3-full-recovery-preserves-meshcore-settings.bin` | -| Minimal TFT | `NeonPocketMC-RCC6-Ultimate-Room-Server-minimal-tft-v1.3.0-rc.3-app.bin` | `NeonPocketMC-RCC6-Ultimate-Room-Server-minimal-tft-v1.3.0-rc.3-full-recovery-preserves-meshcore-settings.bin` | -| Full headless | `NeonPocketMC-RCC6-Ultimate-Room-Server-full-headless-v1.3.0-rc.3-app.bin` | `NeonPocketMC-RCC6-Ultimate-Room-Server-full-headless-v1.3.0-rc.3-full-recovery-preserves-meshcore-settings.bin` | -| Full TFT | `NeonPocketMC-RCC6-Ultimate-Room-Server-full-tft-v1.3.0-rc.3-app.bin` | `NeonPocketMC-RCC6-Ultimate-Room-Server-full-tft-v1.3.0-rc.3-full-recovery-preserves-meshcore-settings.bin` | +| Minimal headless | `NeonPocketMC-RCC6-Ultimate-Room-Server-minimal-headless-v1.3.0-rc.4-app.bin` | `NeonPocketMC-RCC6-Ultimate-Room-Server-minimal-headless-v1.3.0-rc.4-full-recovery-preserves-meshcore-settings.bin` | +| Minimal TFT | `NeonPocketMC-RCC6-Ultimate-Room-Server-minimal-tft-v1.3.0-rc.4-app.bin` | `NeonPocketMC-RCC6-Ultimate-Room-Server-minimal-tft-v1.3.0-rc.4-full-recovery-preserves-meshcore-settings.bin` | +| Full headless | `NeonPocketMC-RCC6-Ultimate-Room-Server-full-headless-v1.3.0-rc.4-app.bin` | `NeonPocketMC-RCC6-Ultimate-Room-Server-full-headless-v1.3.0-rc.4-full-recovery-preserves-meshcore-settings.bin` | +| Full TFT | `NeonPocketMC-RCC6-Ultimate-Room-Server-full-tft-v1.3.0-rc.4-app.bin` | `NeonPocketMC-RCC6-Ultimate-Room-Server-full-tft-v1.3.0-rc.4-full-recovery-preserves-meshcore-settings.bin` | Do not substitute a similarly named observer, profile, or development artifact. @@ -295,7 +295,7 @@ Do not erase the whole flash if you want to retain identity and settings. Never ## Source and scope -- MeshCore base: 1.17.0. +- MeshCore target baseline: 1.17.1. - MQTT observer base: `agessaman/MeshCore` `observer-firmware` at `b744b42a`, plus selected reviewed stability fixes through `8d1a0eb3`. - RCC6 hardware mapping provenance: the separately tested `NeonPocketMC-RCC6` companion project. - Release targets: the established `heltec_rcc6_repeater_observer_mqtt` plus all four explicit Room Server profiles listed above. diff --git a/docs/releases/1.3-RC4.md b/docs/releases/1.3-RC4.md new file mode 100644 index 00000000..8658b61f --- /dev/null +++ b/docs/releases/1.3-RC4.md @@ -0,0 +1,9 @@ +# NeonPocketMC RCC6 Server Suite v1.3.0-rc.4 + +Experimental prerelease built from the exact tagged `main` commit and MeshCore 1.17.1. + +- Saved-coordinate adverts work on GPS-less RCC6 observer and Room Server profiles. +- Old live-location settings migrate to saved-coordinate mode on GPS-less builds. +- GPS-capable regression targets retain live-GPS controls. + +The release contains the observer, four Room Server profiles, guided configurator, exact source/licenses bundle, and checksums. diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 9e389e99..71a752d1 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -935,6 +935,10 @@ void MyMesh::begin(bool has_display) { _store->loadPrefs(_prefs); sensors.node_lat = _prefs.node_lat; sensors.node_lon = _prefs.node_lon; + if (_prefs.advert_loc_policy == ADVERT_LOC_SHARE && sensors.getLocationProvider() == nullptr) { + _prefs.advert_loc_policy = ADVERT_LOC_PREFS; + _store->savePrefs(_prefs); + } // sanitise bad pref values _prefs.rx_delay_base = constrain(_prefs.rx_delay_base, 0, 20.0f); @@ -1448,6 +1452,9 @@ void MyMesh::handleCmdFrame(size_t len) { if (len >= 4) { _prefs.advert_loc_policy = cmd_frame[3]; + if (_prefs.advert_loc_policy == ADVERT_LOC_SHARE && sensors.getLocationProvider() == nullptr) { + _prefs.advert_loc_policy = ADVERT_LOC_PREFS; + } if (len >= 5) { _prefs.multi_acks = cmd_frame[4]; } diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 4ec1803e..eba6d007 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -8,6 +8,7 @@ #define ADVERT_LOC_NONE 0 #define ADVERT_LOC_SHARE 1 +#define ADVERT_LOC_PREFS 2 class NodePrefs : public ConfigSerializer { // persisted to file public: @@ -135,4 +136,4 @@ class NodePrefs : public ConfigSerializer { // persisted to file // new accessor methods bool isRepeatEn() const { return repeat.disable_fwd == 0; } void setRepeatEn(bool en) { repeat.disable_fwd = en ? 0 : 1; } -}; \ No newline at end of file +}; diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index b032058e..6e1bb3a2 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -475,24 +475,30 @@ bool MyMesh::isLooped(const mesh::Packet* packet, const uint8_t max_counters[]) } 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 + TransportKey request_scope; + const bool request_was_unscoped = recv_pkt_region != NULL && recv_pkt_region->isWildcard(); + const bool request_scope_known = recv_pkt_region != NULL && !request_was_unscoped + && region_map.getTransportKeysFor(*recv_pkt_region, &request_scope, 1) > 0; + + switch (mesh::chooseReplyScope(request_scope_known, request_was_unscoped, !default_scope.isNull())) { + case mesh::REPLY_SCOPE_REQUEST: + sendFloodScoped(request_scope, packet, delay_millis, path_hash_size); + break; + case mesh::REPLY_SCOPE_DEFAULT: + sendFloodScoped(default_scope, packet, delay_millis, path_hash_size); + break; + case mesh::REPLY_SCOPE_NONE: + sendFlood(packet, delay_millis, path_hash_size); + break; } } bool MyMesh::allowPacketForward(const mesh::Packet *packet) { if (_prefs.disable_fwd) return false; - if (packet->isRouteFlood()) { - if (packet->getPathHashCount() >= _prefs.flood_max) return false; - if (packet->getRouteType() == ROUTE_TYPE_FLOOD && packet->getPathHashCount() >= _prefs.flood_max_unscoped) return false; - if (packet->getPayloadType() == PAYLOAD_TYPE_ADVERT && packet->getPathHashCount() >= _prefs.flood_max_advert) return false; + if (packet->isRouteFlood() + && mesh::isFloodHopLimitExceeded(packet, _prefs.flood_max, _prefs.flood_max_unscoped, + _prefs.flood_max_advert)) { + return false; } if (packet->isRouteFlood() && recv_pkt_region == NULL) { MESH_DEBUG_PRINTLN("allowPacketForward: unknown transport code, or wildcard not allowed for FLOOD packet"); @@ -692,17 +698,28 @@ void MyMesh::onAnonDataRecv(mesh::Packet *packet, const uint8_t *secret, const m if (reply_len == 0) return; // invalid request - if (packet->isRouteFlood()) { + ClientInfo* client = acl.getClient(sender.pub_key, PUB_KEY_SIZE); + const bool have_out_path = client != NULL && client->out_path_len != OUT_PATH_UNKNOWN; + const mesh::ReplyRoute route = + mesh::chooseReplyRoute(packet->isRouteFlood(), reply_path_len != 0xFF, have_out_path); + + if (route == mesh::REPLY_ROUTE_PATH_RETURN) { // 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) sendFloodReply(path, SERVER_RESPONSE_DELAY, packet->getPathHashSize()); - } else if (reply_path_len == 0xFF) { - mesh::Packet* reply = createDatagram(PAYLOAD_TYPE_RESPONSE, sender, secret, reply_data, reply_len); - if (reply) sendFloodReply(reply, SERVER_RESPONSE_DELAY, packet->getPathHashSize()); + return; + } + + mesh::Packet* reply = createDatagram(PAYLOAD_TYPE_RESPONSE, sender, secret, reply_data, reply_len); + if (reply == NULL) return; + + if (route == mesh::REPLY_ROUTE_DIRECT_SUPPLIED) { + sendDirect(reply, reply_path, reply_path_len, SERVER_RESPONSE_DELAY); + } else if (route == mesh::REPLY_ROUTE_DIRECT_OUT_PATH) { + sendDirect(reply, client->out_path, client->out_path_len, SERVER_RESPONSE_DELAY); } else { - mesh::Packet* reply = createDatagram(PAYLOAD_TYPE_RESPONSE, sender, secret, reply_data, reply_len); - if (reply) sendDirect(reply, reply_path, reply_path_len, SERVER_RESPONSE_DELAY); + sendFloodReply(reply, SERVER_RESPONSE_DELAY, packet->getPathHashSize()); } } } diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index e214667e..c478e789 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -41,6 +41,7 @@ #include #include #include +#include #include #include #include diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 48840cfe..e36458ea 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -358,10 +358,10 @@ uint32_t MyMesh::getDirectRetransmitDelay(const mesh::Packet *packet) { bool MyMesh::allowPacketForward(const mesh::Packet *packet) { if (_prefs.disable_fwd) return false; - if (packet->isRouteFlood()) { - if (packet->getPathHashCount() >= _prefs.flood_max) return false; - if (packet->getRouteType() == ROUTE_TYPE_FLOOD && packet->getPathHashCount() >= _prefs.flood_max_unscoped) return false; - if (packet->getPayloadType() == PAYLOAD_TYPE_ADVERT && packet->getPathHashCount() >= _prefs.flood_max_advert) return false; + if (packet->isRouteFlood() + && mesh::isFloodHopLimitExceeded(packet, _prefs.flood_max, _prefs.flood_max_unscoped, + _prefs.flood_max_advert)) { + return false; } return true; } @@ -1072,15 +1072,21 @@ bool MyMesh::resolveAlertScope(TransportKey& dest) { } 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 + TransportKey request_scope; + const bool request_was_unscoped = recv_pkt_region != NULL && recv_pkt_region->isWildcard(); + const bool request_scope_known = recv_pkt_region != NULL && !request_was_unscoped + && region_map.getTransportKeysFor(*recv_pkt_region, &request_scope, 1) > 0; + + switch (mesh::chooseReplyScope(request_scope_known, request_was_unscoped, !default_scope.isNull())) { + case mesh::REPLY_SCOPE_REQUEST: + sendFloodScoped(request_scope, packet, delay_millis, path_hash_size); + break; + case mesh::REPLY_SCOPE_DEFAULT: + sendFloodScoped(default_scope, packet, delay_millis, path_hash_size); + break; + case mesh::REPLY_SCOPE_NONE: + sendFlood(packet, delay_millis, path_hash_size); + break; } } diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index 8518f0f5..c94a8ac7 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include diff --git a/scripts/verify_rcc6_room_server.py b/scripts/verify_rcc6_room_server.py index 5fdb5c2e..70330ed9 100644 --- a/scripts/verify_rcc6_room_server.py +++ b/scripts/verify_rcc6_room_server.py @@ -43,7 +43,7 @@ def main() -> None: full_tft = section(ini, expected_envs[3]) require(common, "NEONPOCKET_RCC6_ROOM_SERVER=1", "NEONPOCKET_SAFE_SPIFFS_BOOTSTRAP=1", - "+<../examples/simple_room_server>") + "+<../examples/simple_room_server>", "FIRMWARE_VERSION='\"v1.17.1-") require(full, "WITH_MQTT_BRIDGE=1", "WEBCONFIG_AUTO_LAN=1", "MQTT_DEFAULT_SLOT1_PRESET", "MQTT_DEFAULT_SLOT2_PRESET", "+") @@ -82,10 +82,24 @@ def main() -> None: mesh_h = read("examples/simple_room_server/MyMesh.h") mesh_cpp = read("examples/simple_room_server/MyMesh.cpp") + common_cli = read("src/helpers/CommonCLI.cpp") + routing = read("src/helpers/RoutingPolicy.h") require(mesh_h, "struct RoomSnapshot", "void getRoomSnapshot", - "command requires USB serial or the room RF protocol") + "command requires USB serial or the room RF protocol", "helpers/RoutingPolicy.h") require(mesh_cpp, "room_active_clients", "room_posts", "room_pushes", - "NEONPOCKET_ROOM_SERVER_PROFILE", "get room.profile") + "NEONPOCKET_ROOM_SERVER_PROFILE", "get room.profile", "chooseReplyScope", + "isFloodHopLimitExceeded") + require(routing, "chooseReplyRoute", "chooseReplyScope", "isFloodHopLimitExceeded") + share_command = common_cli.index('strcmp(command, "gps advert share")') + gps_hardware_guard = common_cli.index("#if ENV_INCLUDE_GPS == 1", share_command) + if common_cli.index('strcmp(command, "gps advert prefs")') > share_command: + raise AssertionError("saved-coordinate advert policy must not require physical GPS hardware") + if "_sensors->getLocationProvider() != NULL" not in \ + common_cli[share_command:gps_hardware_guard]: + raise AssertionError("live-location advert policy must require an actual GPS provider") + require(common_cli, "normalized_advert_location", + "_prefs->advert_loc_policy = ADVERT_LOC_PREFS;", + "_sensors->getLocationProvider() == NULL") display_h = read("src/helpers/ui/NV3001BDisplay.h") display_cpp = read("src/helpers/ui/NV3001BDisplay.cpp") @@ -112,6 +126,7 @@ def main() -> None: require(mesh_h, "buildNeighborsJson", "has_location", "latitude_e6", "longitude_e6") require(mesh_cpp, "buildNeighborsJson", "has_location", "latitude_e6", "longitude_e6") repeater_mesh = read("examples/simple_repeater/MyMesh.cpp") + require(repeater_mesh, "chooseReplyRoute", "chooseReplyScope", "isFloodHopLimitExceeded") require(mesh_cpp, "if (battery_mv) telemetry.addVoltage") require(repeater_mesh, "if (battery_mv) telemetry.addVoltage") mqtt = read("src/helpers/bridges/MQTTBridge.cpp") diff --git a/scripts/webconfig_mock_server.py b/scripts/webconfig_mock_server.py index cced436a..380036e1 100644 --- a/scripts/webconfig_mock_server.py +++ b/scripts/webconfig_mock_server.py @@ -289,8 +289,8 @@ def apply_set(cfg, key, val): cfg["radio"].update(freq=f, bw=bw, sf=sf, cr=cr) return True, "OK - reboot to apply" - if key == "gps.adv_loc" and val not in ("0", "1", "2"): - return False, "Error: advert location must be 0, 1, or 2" + if key == "gps.adv_loc" and val not in ("0", "2"): + return False, "Error: advert location must be 0 or 2 on RCC6" if key == "mqtt.iata": if val == "": @@ -461,6 +461,11 @@ def is_secret_key(key): ("log", False, "log writes the packet log to the serial console, not here, and " "blocks the radio while it does. Use `log start` / `log stop`."), ("get acl", False, "get acl writes to the serial console, not here."), + ("gps on", False, "This RCC6 build has no live GPS provider."), + ("gps off", False, "This RCC6 build has no live GPS provider."), + ("gps sync", False, "This RCC6 build has no live GPS provider."), + ("gps setloc", False, "This RCC6 build has no live GPS provider."), + ("gps advert share", False, "This RCC6 build has no live GPS provider."), ] diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index e579ba24..bc46870a 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -64,6 +64,7 @@ static const size_t COM_PREFS_TAIL_BYTES = 5; void CommonCLI::loadPrefs(FILESYSTEM* fs) { bool is_fresh_install = false; bool is_upgrade = false; + bool normalized_advert_location = false; // Set when prefs came from one of the legacy binary files; they are republished // as /prefs.json below. The legacy file is never removed, so it stays available // as a fallback if the JSON save does not commit this boot. @@ -93,6 +94,10 @@ void CommonCLI::loadPrefs(FILESYSTEM* fs) { is_fresh_install = true; _prefs->bridge_pkt_src = 1; // Default to RX (logRx) for new installs } + if (_prefs->advert_loc_policy == ADVERT_LOC_SHARE && _sensors->getLocationProvider() == NULL) { + _prefs->advert_loc_policy = ADVERT_LOC_PREFS; + normalized_advert_location = true; + } #ifdef WITH_MQTT_BRIDGE // Load observer preferences (MQTT/WiFi/timezone/SNMP/alert) from /mqtt_prefs. // Readers (MQTTBridge, AlertReporter, observer CLI) use _mqtt_prefs directly — @@ -136,11 +141,15 @@ void CommonCLI::loadPrefs(FILESYSTEM* fs) { } else { MESH_DEBUG_PRINTLN("Prefs: deferring /prefs.json migration until /mqtt_prefs commits"); } + } else if (normalized_advert_location) { + savePrefs(fs, false); } #else if (loaded_from_legacy || _com_prefs_needs_upgrade) { savePrefs(fs); _com_prefs_needs_upgrade = false; + } else if (normalized_advert_location) { + savePrefs(fs); } #endif } @@ -981,6 +990,36 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re } } else if (memcmp(command, "region", 6) == 0) { handleRegionCmd(command, reply); + } else if (strcmp(command, "gps advert") == 0) { + switch (_prefs->advert_loc_policy) { + case ADVERT_LOC_NONE: + strcpy(reply, "> none"); + break; + case ADVERT_LOC_PREFS: + strcpy(reply, "> prefs"); + break; + case ADVERT_LOC_SHARE: + strcpy(reply, "> share"); + break; + default: + strcpy(reply, "error"); + } + } else if (strcmp(command, "gps advert none") == 0) { + _prefs->advert_loc_policy = ADVERT_LOC_NONE; + savePrefs(); + strcpy(reply, "ok"); + } else if (strcmp(command, "gps advert prefs") == 0) { + _prefs->advert_loc_policy = ADVERT_LOC_PREFS; + savePrefs(); + strcpy(reply, "ok"); + } else if (strcmp(command, "gps advert share") == 0) { + if (_sensors->getLocationProvider() != NULL) { + _prefs->advert_loc_policy = ADVERT_LOC_SHARE; + savePrefs(); + strcpy(reply, "ok"); + } else { + strcpy(reply, "gps provider not found"); + } #if ENV_INCLUDE_GPS == 1 } else if (memcmp(command, "gps on", 6) == 0) { if (_sensors->setSettingValue("gps", "1")) { @@ -1011,36 +1050,6 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re _prefs->node_lon = _sensors->node_lon; savePrefs(); strcpy(reply, "ok"); - } else if (memcmp(command, "gps advert", 10) == 0) { - if (strlen(command) == 10) { - switch (_prefs->advert_loc_policy) { - case ADVERT_LOC_NONE: - strcpy(reply, "> none"); - break; - case ADVERT_LOC_PREFS: - strcpy(reply, "> prefs"); - break; - case ADVERT_LOC_SHARE: - strcpy(reply, "> share"); - break; - default: - strcpy(reply, "error"); - } - } else if (memcmp(command+11, "none", 4) == 0) { - _prefs->advert_loc_policy = ADVERT_LOC_NONE; - savePrefs(); - strcpy(reply, "ok"); - } else if (memcmp(command+11, "share", 5) == 0) { - _prefs->advert_loc_policy = ADVERT_LOC_SHARE; - savePrefs(); - strcpy(reply, "ok"); - } else if (memcmp(command+11, "prefs", 5) == 0) { - _prefs->advert_loc_policy = ADVERT_LOC_PREFS; - savePrefs(); - strcpy(reply, "ok"); - } else { - strcpy(reply, "error"); - } } else if (memcmp(command, "gps", 3) == 0) { LocationProvider * l = _sensors->getLocationProvider(); if (l != NULL) { diff --git a/src/helpers/RoutingPolicy.h b/src/helpers/RoutingPolicy.h new file mode 100644 index 00000000..c4d54404 --- /dev/null +++ b/src/helpers/RoutingPolicy.h @@ -0,0 +1,54 @@ +#pragma once + +#include + +namespace mesh { + +/** + * \\brief Test a flood packet against the configured hop limits. + * \\param packet inbound flood packet (caller has already checked isRouteFlood()) + * \\param flood_max max hops for any flood packet + * \\param flood_max_unscoped max hops for ROUTE_TYPE_FLOOD (ie. un-scoped) packets + * \\param flood_max_advert max hops for ADVERT packets + * \\returns true if the packet has exceeded a limit, and must not be forwarded + */ +inline bool isFloodHopLimitExceeded(const Packet* packet, uint8_t flood_max, + uint8_t flood_max_unscoped, uint8_t flood_max_advert) { + uint8_t hops = packet->getPathHashCount(); + if (hops >= flood_max) return true; + if (packet->getRouteType() == ROUTE_TYPE_FLOOD && hops >= flood_max_unscoped) return true; + if (packet->getPayloadType() == PAYLOAD_TYPE_ADVERT && hops >= flood_max_advert) return true; + return false; +} + +/** \\brief How a server routes a reply back to the requesting client. */ +enum ReplyRoute : uint8_t { + REPLY_ROUTE_PATH_RETURN, + REPLY_ROUTE_DIRECT_SUPPLIED, + REPLY_ROUTE_DIRECT_OUT_PATH, + REPLY_ROUTE_FLOOD, +}; + +inline ReplyRoute chooseReplyRoute(bool inbound_is_flood, bool have_supplied_path, bool have_out_path) { + if (inbound_is_flood) return REPLY_ROUTE_PATH_RETURN; + if (have_supplied_path) return REPLY_ROUTE_DIRECT_SUPPLIED; + if (have_out_path) return REPLY_ROUTE_DIRECT_OUT_PATH; + return REPLY_ROUTE_FLOOD; +} + +/** \\brief Which transport scope a flooded reply should use. */ +enum ReplyScope : uint8_t { + REPLY_SCOPE_REQUEST, + REPLY_SCOPE_DEFAULT, + REPLY_SCOPE_NONE, +}; + +inline ReplyScope chooseReplyScope(bool request_scope_known, bool request_was_unscoped_flood, + bool default_scope_known) { + if (request_scope_known) return REPLY_SCOPE_REQUEST; + if (request_was_unscoped_flood) return REPLY_SCOPE_NONE; + if (default_scope_known) return REPLY_SCOPE_DEFAULT; + return REPLY_SCOPE_NONE; +} + +} // namespace mesh diff --git a/src/helpers/WebConfigKeys.h b/src/helpers/WebConfigKeys.h index cee73839..550168fd 100644 --- a/src/helpers/WebConfigKeys.h +++ b/src/helpers/WebConfigKeys.h @@ -66,8 +66,17 @@ static inline const char* wcAdvertLocationMode(const char* value) { return NULL; } +static inline bool wcIsValidAdvertLocationPolicyForBuild(const char* value, bool has_live_gps) { + const char* mode = wcAdvertLocationMode(value); + return mode != NULL && (value[0] != '1' || has_live_gps); +} + static inline bool wcIsValidAdvertLocationPolicy(const char* value) { - return wcAdvertLocationMode(value) != NULL; +#if ENV_INCLUDE_GPS == 1 + return wcIsValidAdvertLocationPolicyForBuild(value, true); +#else + return wcIsValidAdvertLocationPolicyForBuild(value, false); +#endif } // The admin password maps to the top-level `password` command, not a setter, so diff --git a/src/helpers/esp32/WebConfigServer.cpp b/src/helpers/esp32/WebConfigServer.cpp index 919f48b3..a957f9ff 100644 --- a/src/helpers/esp32/WebConfigServer.cpp +++ b/src/helpers/esp32/WebConfigServer.cpp @@ -71,6 +71,14 @@ static const char* wcCliUnavailable(const char* cmd) { if (strcmp(cmd, "get acl") == 0) { return "get acl writes to the serial console, not here."; } +#if ENV_INCLUDE_GPS != 1 + if (strcmp(cmd, "gps on") == 0 || strcmp(cmd, "gps off") == 0 || + strcmp(cmd, "gps sync") == 0 || strcmp(cmd, "gps setloc") == 0 || + strcmp(cmd, "gps advert share") == 0) { + return "This build has no live GPS provider. Use saved latitude/longitude " + "with `gps advert prefs` instead."; + } +#endif return NULL; } // The `password` command echoes the new password back in its reply, and replies @@ -881,7 +889,7 @@ void WebConfigServer::handleConfigPost(AsyncWebServerRequest* req) { return; } if (strcmp(key, "gps.adv_loc") == 0 && !wcIsValidAdvertLocationPolicy(val)) { - req->send(400, "application/json", "{\"error\":\"advert location must be none, live GPS, or saved coordinates\",\"key\":\"gps.adv_loc\"}"); + req->send(400, "application/json", "{\"error\":\"advert location must be none or saved coordinates on RCC6\",\"key\":\"gps.adv_loc\"}"); return; } if (isSecretKey(key) && strcmp(val, SECRET_SENTINEL) == 0) continue; // unchanged diff --git a/test/test_routing_policy/test_routing_policy.cpp b/test/test_routing_policy/test_routing_policy.cpp new file mode 100644 index 00000000..fc2b594a --- /dev/null +++ b/test/test_routing_policy/test_routing_policy.cpp @@ -0,0 +1,42 @@ +#include +#include "helpers/RoutingPolicy.h" + +using namespace mesh; + +static Packet makeFlood(uint8_t route_type, uint8_t payload_type, uint8_t hops) { + Packet p; + p.header = route_type | (payload_type << PH_TYPE_SHIFT); + p.setPathHashSizeAndCount(1, hops); + p.payload_len = 1; + return p; +} + +TEST(FloodHopLimit, AppliesUnscopedAndAdvertLimitsIndependently) { + auto unscoped = makeFlood(ROUTE_TYPE_FLOOD, PAYLOAD_TYPE_RESPONSE, 0); + EXPECT_TRUE(isFloodHopLimitExceeded(&unscoped, 64, 0, 8)); + + auto scoped = makeFlood(ROUTE_TYPE_TRANSPORT_FLOOD, PAYLOAD_TYPE_RESPONSE, 0); + EXPECT_FALSE(isFloodHopLimitExceeded(&scoped, 64, 0, 8)); + + auto advert = makeFlood(ROUTE_TYPE_TRANSPORT_FLOOD, PAYLOAD_TYPE_ADVERT, 8); + EXPECT_TRUE(isFloodHopLimitExceeded(&advert, 64, 64, 8)); +} + +TEST(ReplyRoute, UsesKnownDirectPathsBeforeFloodFallback) { + EXPECT_EQ(REPLY_ROUTE_PATH_RETURN, chooseReplyRoute(true, false, true)); + EXPECT_EQ(REPLY_ROUTE_DIRECT_SUPPLIED, chooseReplyRoute(false, true, true)); + EXPECT_EQ(REPLY_ROUTE_DIRECT_OUT_PATH, chooseReplyRoute(false, false, true)); + EXPECT_EQ(REPLY_ROUTE_FLOOD, chooseReplyRoute(false, false, false)); +} + +TEST(ReplyScope, PreservesOrFallsBackToAUsableScope) { + EXPECT_EQ(REPLY_SCOPE_REQUEST, chooseReplyScope(true, false, true)); + EXPECT_EQ(REPLY_SCOPE_NONE, chooseReplyScope(false, true, true)); + EXPECT_EQ(REPLY_SCOPE_DEFAULT, chooseReplyScope(false, false, true)); + EXPECT_EQ(REPLY_SCOPE_NONE, chooseReplyScope(false, false, false)); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_webconfig_keys/test_webconfig_keys.cpp b/test/test_webconfig_keys/test_webconfig_keys.cpp index c763b6f1..cc505f2d 100644 --- a/test/test_webconfig_keys/test_webconfig_keys.cpp +++ b/test/test_webconfig_keys/test_webconfig_keys.cpp @@ -22,8 +22,10 @@ TEST(WebConfigKeys, AllowsKnownScalarKeys) { TEST(WebConfigKeys, ValidatesAdvertLocationPolicyEnum) { EXPECT_TRUE(wcIsValidAdvertLocationPolicy("0")); - EXPECT_TRUE(wcIsValidAdvertLocationPolicy("1")); + EXPECT_FALSE(wcIsValidAdvertLocationPolicy("1")); EXPECT_TRUE(wcIsValidAdvertLocationPolicy("2")); + EXPECT_TRUE(wcIsValidAdvertLocationPolicyForBuild("1", true)); + EXPECT_FALSE(wcIsValidAdvertLocationPolicyForBuild("1", false)); EXPECT_FALSE(wcIsValidAdvertLocationPolicy(NULL)); EXPECT_FALSE(wcIsValidAdvertLocationPolicy("")); EXPECT_FALSE(wcIsValidAdvertLocationPolicy("3")); diff --git a/variants/heltec_rcc6/platformio.ini b/variants/heltec_rcc6/platformio.ini index b05e3312..92358dff 100644 --- a/variants/heltec_rcc6/platformio.ini +++ b/variants/heltec_rcc6/platformio.ini @@ -51,7 +51,7 @@ board_build.embed_files = src/certs/x509_crt_bundle.bin build_flags = ${heltec_rcc6.build_flags} -D NEONPOCKET_RCC6_REPEATER=1 - -D FIRMWARE_VERSION='"v1.17.0-rxfix-rcc6-ultimate-v1.3.0-rc.3"' + -D FIRMWARE_VERSION='"v1.17.1-rcc6-ultimate-v1.3.0-rc.4"' -D ADVERT_NAME='"RCC6 Ultimate Observer"' -D ADVERT_LAT=0.0 -D ADVERT_LON=0.0 @@ -90,7 +90,7 @@ build_flags = ${heltec_rcc6.build_flags} -D NEONPOCKET_RCC6_ROOM_SERVER=1 -D NEONPOCKET_SAFE_SPIFFS_BOOTSTRAP=1 - -D FIRMWARE_VERSION='"v1.17.0-rcc6-room-v1.3.0-rc.3"' + -D FIRMWARE_VERSION='"v1.17.1-rcc6-room-v1.3.0-rc.4"' -D ADVERT_NAME='"RCC6 Room Server"' -D ADVERT_LAT=0.0 -D ADVERT_LON=0.0 diff --git a/webui/index.html b/webui/index.html index fedef620..bbd037e4 100644 --- a/webui/index.html +++ b/webui/index.html @@ -406,8 +406,8 @@

Node

- -
Coordinates are included in adverts only when sharing is enabled.
+ +
Saved coordinates work on every build. Live GPS is accepted only on hardware with a compiled GPS provider.