Keep direct OTA support within each Companion's flash budget

This commit is contained in:
mikecarper committed 2026-10-26 20:17:54 -07:00
1 parent c3a28e9ab8
commit 0a3a72984a
7 files changed
+95 -35

No files matched your search

+4
View File
@@ -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"
+33 -24
View File
@@ -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,
+4 -3
View File
@@ -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.
+7 -3
View File
@@ -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;
+32 -4
View File
@@ -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()
+3 -1
View File
@@ -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