Keep direct OTA support within each Companion's flash budget
This commit is contained in:
1 parent
c3a28e9ab8
commit
0a3a72984a
7 files changed
+95
-35
No files matched your search
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<uint8_t>(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,
|
||||
|
||||
@@ -64,7 +64,7 @@ namespace mesh { class BeaconManager; }
|
||||
#endif
|
||||
#include <helpers/TrackerProtocol.h>
|
||||
#include <helpers/CompanionMotaControl.h>
|
||||
#if COMPANION_FEATURE_OTA_CLI
|
||||
#if COMPANION_FEATURE_DIRECT_OTA_ADMIN
|
||||
#include <helpers/CompanionDirectAdminGuard.h>
|
||||
#endif
|
||||
#include <helpers/IdentityStore.h>
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user