diff --git a/docs/ota_wizard.md b/docs/ota_wizard.md index 0937670cc..bb6530294 100644 --- a/docs/ota_wizard.md +++ b/docs/ota_wizard.md @@ -30,6 +30,10 @@ direct admin guard. An older Full Companion may serve OTA files but cannot provide this guarded admin connection; the wizard will ask for a source firmware upgrade instead of proceeding with that connection. +For this wizard, use an ESP32 source instead of RAK4631 Full. The RAK image's +current flash limit prevents adding the new direct admin helper. RAK4631 +Repeaters and Room Servers remain supported destinations. + The source and destination must already use the same main-channel settings and hear each other directly. The destination needs working LoRa OTA support and its admin password. A stock nRF52 bootloader may need a matching local diff --git a/examples/companion_radio/CompanionFeatures.h b/examples/companion_radio/CompanionFeatures.h index c6a2d65f6..aae0bc4ff 100644 --- a/examples/companion_radio/CompanionFeatures.h +++ b/examples/companion_radio/CompanionFeatures.h @@ -74,6 +74,15 @@ #ifndef COMPANION_FEATURE_OTA_CLI #define COMPANION_FEATURE_OTA_CLI 0 #endif +// Keep RAK4631 Full's existing feature set inside its application boundary. +// Other Full boards expose the new USB OTA admin helper by default. +#ifndef COMPANION_FEATURE_DIRECT_OTA_ADMIN + #if defined(NRF52_PLATFORM) && defined(RAK_4631) + #define COMPANION_FEATURE_DIRECT_OTA_ADMIN 0 + #else + #define COMPANION_FEATURE_DIRECT_OTA_ADMIN COMPANION_FEATURE_OTA_CLI + #endif +#endif #ifndef COMPANION_FEATURE_NETWORK_TERMINAL #define COMPANION_FEATURE_NETWORK_TERMINAL 0 #endif @@ -93,6 +102,9 @@ #if COMPANION_FEATURE_OTA_CLI && !defined(ENABLE_OTA) #error "COMPANION_FEATURE_OTA_CLI requires ENABLE_OTA" #endif +#if COMPANION_FEATURE_DIRECT_OTA_ADMIN && !COMPANION_FEATURE_OTA_CLI + #error "COMPANION_FEATURE_DIRECT_OTA_ADMIN requires COMPANION_FEATURE_OTA_CLI" +#endif #if COMPANION_FEATURE_NETWORK_TERMINAL && !defined(ENABLE_USB_INTERFACE) #error "COMPANION_FEATURE_NETWORK_TERMINAL requires ENABLE_USB_INTERFACE" diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 4d026e76c..408371af1 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -2526,7 +2526,7 @@ uint8_t MyMesh::handleTrackerStatusRequest(const ContactInfo& from, uint32_t tag } #endif -#if COMPANION_FEATURE_OTA_CLI +#if COMPANION_FEATURE_DIRECT_OTA_ADMIN uint8_t MyMesh::getContactTxRadio(const mesh::Identity& peer) const { return _ota_direct_admin.selected(peer, _ms->getMillis()) ? static_cast(mesh::RADIO_TX_PRIMARY) : BaseChatMesh::getContactTxRadio(peer); @@ -6239,6 +6239,30 @@ void MyMesh::resetUsbHostSessionInput() { } } +#if COMPANION_FEATURE_DIRECT_OTA_ADMIN +// Keep the opt-in USB maintenance modes out of the large Binary dispatcher. +__attribute__((noinline, cold)) bool MyMesh::handleDirectOtaFrame(size_t len) { + if (cmd_frame[0] != CMD_SEND_PATH_DISCOVERY_REQ || len != 2 + PUB_KEY_SIZE + || cmd_frame[1] < 1 || cmd_frame[1] > 3) return false; + // Mode 0 remains flood path discovery. Stock firmware rejects these reserved + // modes without transmitting; every maintenance frame here is DIRECT0. + ContactInfo* recipient = lookupContactByPubKey(&cmd_frame[2], PUB_KEY_SIZE); + if (!recipient) { + writeErrFrame(ERR_CODE_NOT_FOUND); + } else if (cmd_frame[1] == 1) { + _ota_direct_admin.arm(recipient->id, recipient->getSharedSecret(self_id), _ms->getMillis()); + writeOKFrame(); + } else if (cmd_frame[1] == 2) { + if (_ota_direct_admin.sendReturnPath(*this, recipient->id, _ms->getMillis())) writeOKFrame(); + else writeErrFrame(ERR_CODE_BAD_STATE); + } else { + if (_ota_direct_admin.matches(recipient->id, _ms->getMillis())) _ota_direct_admin.clear(); + writeOKFrame(); + } + return true; +} +#endif + void MyMesh::handleCmdFrame(size_t len) { if (len == 0) { writeErrFrame(ERR_CODE_ILLEGAL_ARG); @@ -6259,12 +6283,16 @@ void MyMesh::handleCmdFrame(size_t len) { #endif servicePendingSerialReply(); +#if COMPANION_FEATURE_DIRECT_OTA_ADMIN + if (handleDirectOtaFrame(len)) return; +#endif const bool starts_long_lived_request = (cmd_frame[0] == CMD_SEND_LOGIN && len >= 1 + PUB_KEY_SIZE) || (cmd_frame[0] == CMD_SEND_ANON_REQ && len > 1 + PUB_KEY_SIZE) || (cmd_frame[0] == CMD_SEND_STATUS_REQ && len >= 1 + PUB_KEY_SIZE) || (cmd_frame[0] == CMD_SEND_PATH_DISCOVERY_REQ - && len >= 2 + PUB_KEY_SIZE && cmd_frame[1] == 0) + && len >= 2 + PUB_KEY_SIZE + && (!COMPANION_FEATURE_DIRECT_OTA_ADMIN || cmd_frame[1] == 0)) || (cmd_frame[0] == CMD_SEND_TELEMETRY_REQ && len >= 4 + PUB_KEY_SIZE) || (cmd_frame[0] == CMD_SEND_BINARY_REQ @@ -7054,7 +7082,7 @@ void MyMesh::handleCmdFrame(size_t len) { char *password = (char *)&cmd_frame[1 + PUB_KEY_SIZE]; cmd_frame[len] = 0; // ensure null terminator in password if (recipient) { -#if COMPANION_FEATURE_OTA_CLI +#if COMPANION_FEATURE_DIRECT_OTA_ADMIN _ota_direct_admin.beginLogin(recipient->id, _ms->getMillis()); #endif if (!beginPendingRequest(mesh::CompanionDelayedReplies::Login, *recipient)) { @@ -7110,25 +7138,6 @@ void MyMesh::handleCmdFrame(size_t len) { } else { writeErrFrame(ERR_CODE_NOT_FOUND); // contact not found } -#if COMPANION_FEATURE_OTA_CLI - } else if (cmd_frame[0] == CMD_SEND_PATH_DISCOVERY_REQ - && len == 2 + PUB_KEY_SIZE && cmd_frame[1] >= 1 && cmd_frame[1] <= 3) { - // Mode 0 remains the existing flood path discovery. These opt-in modes - // never emit flood RF and stock firmware rejects them without sending RF. - ContactInfo* recipient = lookupContactByPubKey(&cmd_frame[2], PUB_KEY_SIZE); - if (!recipient) { - writeErrFrame(ERR_CODE_NOT_FOUND); - } else if (cmd_frame[1] == 1) { - _ota_direct_admin.arm(recipient->id, recipient->getSharedSecret(self_id), _ms->getMillis()); - writeOKFrame(); - } else if (cmd_frame[1] == 2) { - if (_ota_direct_admin.sendReturnPath(*this, recipient->id, _ms->getMillis())) writeOKFrame(); - else writeErrFrame(ERR_CODE_BAD_STATE); - } else { - if (_ota_direct_admin.matches(recipient->id, _ms->getMillis())) _ota_direct_admin.clear(); - writeOKFrame(); - } -#endif } else if (cmd_frame[0] == CMD_SEND_PATH_DISCOVERY_REQ && len >= 2 + PUB_KEY_SIZE && cmd_frame[1] == 0) { uint8_t *pub_key = &cmd_frame[2]; ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); @@ -7841,7 +7850,7 @@ void MyMesh::handleCmdFrame(size_t len) { if (pkt) { uint8_t priority = cmd_frame[1]; if (tryParsePacket(pkt, &cmd_frame[2], len - 2)) { -#if COMPANION_FEATURE_OTA_CLI +#if COMPANION_FEATURE_DIRECT_OTA_ADMIN if (pkt->getPayloadType() == PAYLOAD_TYPE_OTA && _ota_direct_admin.active(_ms->getMillis())) { pkt->tx_radio = mesh::RADIO_TX_PRIMARY; @@ -10827,7 +10836,7 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, size_t reply_capacity = 160; while (*command == ' ' || *command == '\t') command++; -#if COMPANION_FEATURE_OTA_CLI +#if COMPANION_FEATURE_DIRECT_OTA_ADMIN if (sender_timestamp == 0 && !strcmp(command, "get ota.direct")) { snprintf(reply, reply_capacity, "> ota.direct v=1 active=%u busy=%u", _ota_direct_admin.active(_ms->getMillis()) ? 1 : 0, diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 609ed5bb0..bf75f3b7c 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -64,7 +64,7 @@ namespace mesh { class BeaconManager; } #endif #include #include -#if COMPANION_FEATURE_OTA_CLI +#if COMPANION_FEATURE_DIRECT_OTA_ADMIN #include #endif #include @@ -530,7 +530,7 @@ protected: bool onContactOverwrite(const ContactInfo& contact) override; void onContactReferenceChanged(const ContactInfo* previous, ContactInfo* replacement) override; bool onContactPathRecv(ContactInfo& from, uint8_t* in_path, uint8_t in_path_len, uint8_t* out_path, uint8_t out_path_len, uint8_t extra_type, uint8_t* extra, uint8_t extra_len) override; -#if COMPANION_FEATURE_OTA_CLI +#if COMPANION_FEATURE_DIRECT_OTA_ADMIN uint8_t getContactTxRadio(const mesh::Identity& peer) const override; void onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_idx, const uint8_t* secret, uint8_t* data, size_t len) override; @@ -647,8 +647,9 @@ public: bool canRecoverUsbLogging() const; private: -#if COMPANION_FEATURE_OTA_CLI +#if COMPANION_FEATURE_DIRECT_OTA_ADMIN mesh::CompanionDirectAdminGuard _ota_direct_admin; + bool handleDirectOtaFrame(size_t len); #endif #if MESH_ENABLE_FLEET_CONTROL // Exact ownership of the optional radio copy during two-part admission. diff --git a/src/helpers/CompanionDirectAdminGuard.h b/src/helpers/CompanionDirectAdminGuard.h index 372a177a4..890644838 100644 --- a/src/helpers/CompanionDirectAdminGuard.h +++ b/src/helpers/CompanionDirectAdminGuard.h @@ -42,8 +42,11 @@ public: void beginLogin(const Identity& peer, uint32_t now) { if (matches(peer, now)) login_ = true; } + __attribute__((noinline)) bool matchesSecret(const uint8_t* secret, uint32_t now) { + return active(now) && secret && !memcmp(secret_, secret, sizeof(secret_)); + } bool accepts(const Packet* packet, uint8_t type, const uint8_t* secret, uint32_t now) { - if (!active(now) || !secret || memcmp(secret_, secret, sizeof(secret_))) return true; + if (!matchesSecret(secret, now)) return true; if (!packet || packet->getPathHashCount() != 0) return false; if (packet->isRouteDirect()) return true; // Unknown ACL return paths cause old servers to flood the first login @@ -51,8 +54,9 @@ public: return login_ && type == PAYLOAD_TYPE_RESPONSE && packet->isRouteFlood(); } bool acceptsPath(const Packet* packet, const uint8_t* secret, uint8_t path_len, uint32_t now) { - if (!active(now) || !secret || memcmp(secret_, secret, sizeof(secret_))) return true; - return !(path_len & 63) && accepts(packet, PAYLOAD_TYPE_PATH, secret, now); + if (!matchesSecret(secret, now)) return true; + return !(path_len & 63) && packet && packet->getPathHashCount() == 0 + && packet->isRouteDirect(); } bool sendReturnPath(Mesh& mesh, const Identity& peer, uint32_t now) { if (!matches(peer, now)) return false; diff --git a/test/test_companion_direct_admin_guard.py b/test/test_companion_direct_admin_guard.py index 9ff43565d..a51f98fe9 100644 --- a/test/test_companion_direct_admin_guard.py +++ b/test/test_companion_direct_admin_guard.py @@ -38,11 +38,12 @@ struct MyMesh:BaseChatMesh { void dispatch(size_t len){ response=-1; #if FULL - @BINARY@ + if(handleDirectOtaFrame(len)) return; #else (void)len; #endif } + bool handleDirectOtaFrame(size_t len); void mode(int value){cmd_frame[0]=52;cmd_frame[1]=value;memcpy(cmd_frame+2,contact.id.pub_key,32);dispatch(34);} bool command(const char* command,uint32_t sender_timestamp,char* reply){ const size_t reply_capacity=160; @@ -65,6 +66,7 @@ struct MyMesh:BaseChatMesh { } }; @CALLBACKS@ +@BINARY@ int main(){ MyMesh m;char reply[160]{}; m.mode(1); @@ -117,13 +119,14 @@ class CompanionDirectAdminGuardTest(unittest.TestCase): if compiler is None: self.skipTest("a host C++17 compiler is required") source = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text() - binary = extract_braced(source, "if (cmd_frame[0] == CMD_SEND_PATH_DISCOVERY_REQ\n && len == 2 + PUB_KEY_SIZE").replace("#endif", "") + binary = extract_braced(source, "bool MyMesh::handleDirectOtaFrame(") getter = extract_braced(source, 'if (sender_timestamp == 0 && !strcmp(command, "get ota.direct"))') callbacks = "\n".join(extract_braced(source, signature) for signature in ( "uint8_t MyMesh::getContactTxRadio(", "void MyMesh::onPeerDataRecv(", "bool MyMesh::onPeerPathRecv(")) raw = extract_braced(source, "if (pkt->getPayloadType() == PAYLOAD_TYPE_OTA\n && _ota_direct_admin.active") self.assertIn("_ota_direct_admin.beginLogin(recipient->id", source) - self.assertIn("&& len >= 2 + PUB_KEY_SIZE && cmd_frame[1] == 0)", source) + self.assertIn("if (handleDirectOtaFrame(len)) return;", source) + self.assertIn("&& (!COMPANION_FEATURE_DIRECT_OTA_ADMIN || cmd_frame[1] == 0))", source) mesh = (ROOT / "src/Mesh.cpp").read_text() start = mesh.index("Packet* Mesh::createPathReturn(const uint8_t* dest_hash,") end = mesh.index("\nPacket* Mesh::createDatagram(", start) @@ -134,7 +137,7 @@ class CompanionDirectAdminGuardTest(unittest.TestCase): folder = Path(folder) (folder / "Mesh.h").write_text(stub) for full in (0, 1): - code = HARNESS.replace("@BINARY@", binary).replace("@GETTER@", getter) + code = HARNESS.replace("@BINARY@", binary if full else "").replace("@GETTER@", getter) code = code.replace("@RAW_ROUTE@", raw) code = code.replace("@CALLBACKS@", callbacks if full else "") (folder / "test.cpp").write_text(code) @@ -144,9 +147,34 @@ class CompanionDirectAdminGuardTest(unittest.TestCase): str(folder / "test.cpp"), "-o", str(executable)], capture_output=True, text=True, timeout=60) self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + result = subprocess.run([str(executable)], capture_output=True, text=True, timeout=10) self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + def test_direct_helper_defaults_preserve_existing_rak_full_features(self): + compiler = shutil.which("g++") or shutil.which("clang++") + if compiler is None: + self.skipTest("a host C++ preprocessor is required") + full = ["COMPANION_RADIO_FULL=1", "ENABLE_OTA=1", "ENABLE_USB_INTERFACE=1", "BLE_PIN_CODE=123456"] + cases = (("esp32", full + ["ESP32_PLATFORM=1", "WIFI_SSID=fixture"], 1, 1), + ("nrf52", full + ["NRF52_PLATFORM=1"], 1, 1), + ("rak4631", full + ["NRF52_PLATFORM=1", "RAK_4631=1"], 0, 1), + ("reduced", ["NRF52_PLATFORM=1", "RAK_4631=1"], 0, 0), + ("rak override", full + ["NRF52_PLATFORM=1", "RAK_4631=1", "COMPANION_FEATURE_DIRECT_OTA_ADMIN=1"], 1, 1)) + code = ('#include "examples/companion_radio/CompanionFeatures.h"\n' + 'DIRECT_HELPER COMPANION_FEATURE_DIRECT_OTA_ADMIN\n' + 'EXISTING_OTA COMPANION_FEATURE_OTA_CLI\n' + 'EXISTING_TEMP COMPANION_FEATURE_TEMP_RADIO\n') + for name, flags, direct, existing in cases: + with self.subTest(profile=name): + result = subprocess.run([compiler, "-E", "-P", "-x", "c++", "-I" + str(ROOT), + "-I" + str(ROOT / "src"), *["-D" + flag for flag in flags], "-"], + input=code, capture_output=True, text=True, timeout=30) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn(f"DIRECT_HELPER {direct}", result.stdout) + self.assertIn(f"EXISTING_OTA {existing}", result.stdout) + self.assertIn(f"EXISTING_TEMP {existing}", result.stdout) + if __name__ == "__main__": unittest.main() diff --git a/tools/lora_ota/ota_wizard_companion.py b/tools/lora_ota/ota_wizard_companion.py index f61cecdb2..5275b2ffd 100644 --- a/tools/lora_ota/ota_wizard_companion.py +++ b/tools/lora_ota/ota_wizard_companion.py @@ -417,7 +417,9 @@ class CompanionTransport: try: await self._guard_mode(key, 1) except CompanionError as exc: - raise CompanionError("The USB radio needs current Full Companion firmware with the zero-hop OTA admin guard") from exc + raise CompanionError( + "The USB radio needs current Full Companion firmware with the zero-hop OTA admin guard. " + "RAK4631 Full cannot fit this helper; use an ESP32 Full Companion or USB Repeater.") from exc self._guarded_key = key if key not in self._authenticated: # The library's send_login_sync accepts any login-success