diff --git a/build.sh b/build.sh index f0e081cc..42f244b7 100755 --- a/build.sh +++ b/build.sh @@ -1745,9 +1745,9 @@ is_lora_ota_build() { return 1 fi - # OTA is deliberately opt-in. The standard repeater remains a normal, - # sensor-enabled build; its explicit _lora_ota_no_external_sensors sibling - # is the constrained image distributed over LoRa. + # The OTA manager, staging store, and self-install path are deliberately opt-in. The standard repeater + # remains a normal, sensor-enabled build, but Mesh transport still relays OTA floods opaquely during + # TempRadio. Its explicit _lora_ota_no_external_sensors sibling is the constrained self-updatable image. if [[ "$env_name_lc" != *lora_ota_no_external_sensors ]]; then return 1 fi diff --git a/docs/ota_easy.md b/docs/ota_easy.md index 66019138..94eab2b8 100644 --- a/docs/ota_easy.md +++ b/docs/ota_easy.md @@ -38,21 +38,25 @@ location and change it when necessary. Both paths require: -- An OTA-enabled build whose artifact filename contains `-ota-` on the destination, plus an OTA-enabled - repeater on every intermediate hop. The `-ota-` stamp confirms that LoRa OTA is compiled into the build. - Portable logging, portable MQTT, and untagged builds cannot perform LoRa OTA; FULL logging OTA builds can. +- An OTA-enabled build whose artifact filename contains `-ota-` on the destination. The `-ota-` stamp confirms + that the node can discover, download, verify, and install LoRa OTA. Intermediate repeaters do **not** need an + OTA-enabled build: current repeater firmware relays OTA packets opaquely without storing or installing them. + Portable logging, portable MQTT, and untagged builds cannot install LoRa OTA; FULL logging OTA builds can. - An OTA-enabled MeshCore source connected to the computer by USB serial, or an ESP32 WiFi companion/FULL ESP32 source connected over WiFi as described below. - Overlapping `tempradio` windows on the source, destination, and every repeater needed between them. -LoRa OTA packets are generated, received, and relayed only while `tempradio` is active. If any required -window closes, the transfer stops making progress and can resume during a later overlapping window. +LoRa OTA packets are generated, consumed, and relayed only while `tempradio` is active. Intermediate repeaters +apply their normal forwarding filters, duplicate checks, and flood limits; they do not interpret the OTA +payload. If any required window closes, the transfer stops making progress and can resume during a later +overlapping window. `build.sh` provides a `*_repeater_lora_ota_no_external_sensors` build for every standalone ESP32 and nRF52 -repeater target. The normal repeater build keeps its external-sensor support. The `-ota-` sibling omits -optional external I2C environmental sensors to preserve the update workspace, while retaining board-native -features such as its display, buttons, battery monitoring, and integrated GPS. RP2040 and STM32 repeaters do -not currently have a safe self-apply path and therefore cannot install LoRa firmware updates. +repeater target. The normal repeater build keeps its external-sensor support and can serve as an intermediate +OTA relay, but it cannot download or install an update for itself. The `-ota-` sibling omits optional external +I2C environmental sensors to preserve the update workspace, while retaining board-native features such as its +display, buttons, battery monitoring, and integrated GPS. RP2040 and STM32 repeaters do not currently have a +safe self-apply path, but current repeater firmware can still relay OTA packets opaquely during TempRadio. ESP32 `*-full-ota-*` artifacts retain all compiled features and enable LoRa OTA for every FULL role, including room servers, sensors, observers, and bridges. A FULL image requires its expanded partition table: @@ -289,7 +293,8 @@ ota status - **Nothing appears in `ota ls`:** confirm that `motatool serve` is still running and every required node has an active `tempradio 909.950,250,5,5,120` window. - **The CLI says LoRa OTA is not included:** that firmware does not contain the LoRa OTA feature. - `tempradio` cannot enable it; install a supported `-ota-` repeater build over WiFi or USB first. + If it is the source or destination, install a supported `-ota-` build over WiFi or USB first. An intermediate + repeater does not need the OTA CLI and can relay opaquely while its matching `tempradio` window is active. - **The update is marked `[other hw]`:** it is for a different board or firmware role. Do not install it. - **An nRF52 node does not list a full update:** this is intentional. nRF52 accepts only an in-place delta. - **nRF52 reports no bootloader apply support:** install the exact-board in-place-delta OTAFIX bootloader diff --git a/docs/ota_protocol.md b/docs/ota_protocol.md index fc909e88..cbae222a 100644 --- a/docs/ota_protocol.md +++ b/docs/ota_protocol.md @@ -362,10 +362,12 @@ Message types: from a different peer. No hard ACKs, no global ordering. - **Relay:** replies are flooded, so transparent relay needs no per-requester addressing, and the transfer is trustless (the fetcher verifies every block against the signed root). Any neighbor may serve any - fragment it has. + fragment it has. A repeater without `ENABLE_OTA` transports `PAYLOAD_TYPE_OTA` opaquely and does not need + the manager, staging store, installer, or destination bootloader. - **Hop limit + duty cycle:** OTA floods accumulate one path-hash per relay (the mesh's flood routing). A - node *accepts* a packet only if it arrived within `ota config hops` hops (default 3; `0` = direct only) - and *relays* it only while still under that limit, appending its own hash. Relays are lowest-priority and + node with the OTA manager *accepts* a packet only if it arrived within `ota config hops` hops (default 3; + `0` = direct only) and *relays* it only while still under that limit, appending its own hash. Relay-only + repeaters instead use their ordinary flood limits and forwarding filters. Relays are lowest-priority and are skipped when the packet pool runs low (the source retries), so heavy OTA can never monopolise a repeater's RAM or starve real traffic. diff --git a/docs/ota_user_guide.md b/docs/ota_user_guide.md index 91777aa4..18fe49b0 100644 --- a/docs/ota_user_guide.md +++ b/docs/ota_user_guide.md @@ -4,11 +4,12 @@ This guide is for **node operators**: how to update your MeshCore device's firmw plain language. No cables, no programmer - your node can download a new firmware from a neighbour and install it. (For the technical wire format, see [the OTA protocol spec](ota_protocol.md).) -LoRa OTA is present only in supported Keymind repeater build artifacts whose filename contains `-ota-`. -Use an `-ota-` build on the source, receiver, and every intermediate repeater. MQTT bridge, logging, -untagged, companion, room-server, sensor, and roles without `tempradio` support exclude LoRa OTA. -OTA radio traffic is accepted, generated, and relayed only while `tempradio` is actually running on that node. -Every source, receiver, and intermediate repeater must therefore have an overlapping temporary-radio window. +LoRa OTA download and installation are present only in supported Keymind artifacts whose filename contains +`-ota-`. Use an `-ota-` build on the source and receiver. Intermediate repeaters do not need OTA-enabled +firmware: current repeater builds transport OTA floods opaquely, subject to their normal forwarding filters, +duplicate checks, and flood limits. OTA radio traffic is accepted, generated, and relayed only while +`tempradio` is actually running on that node. Every source, receiver, and intermediate repeater must therefore +have an overlapping temporary-radio window. The recommended temporary OTA settings use 250 kHz bandwidth, SF5, CR5, and a 120-minute window. For a North American node currently configured for 909.950 MHz, run this on every participating node: @@ -19,11 +20,11 @@ tempradio 909.950,250,5,5,120 Use the node's current permitted regional frequency in place of `909.950` when necessary. -> **Is my node supported?** Choose a supported repeater artifact carrying the `-ota-` filename stamp. +> **Can my node install the update?** Choose a supported repeater artifact carrying the `-ota-` filename stamp. > LoRa OTA firmware is available for supported **ESP32** boards and nRF52 repeater targets. Every nRF52 > installation also requires the OTAFIX bootloader built for that exact board; having an OTA-capable -> application image alone is not enough. Check the bootloader release for an exact board match before -> attempting an update. +> application image alone is not enough. An intermediate repeater only relays packets and needs neither the +> `-ota-` image nor OTAFIX. Check the bootloader release for an exact board match before attempting an update. The following nRF52 repeater targets gained firmware-side LoRa OTA support in this release without losing their normal external-sensor support: diff --git a/src/Mesh.cpp b/src/Mesh.cpp index 81600383..1bde2c93 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -350,12 +350,12 @@ void __attribute__((noinline)) Mesh::serviceLoopMaintenance() { } bool Mesh::allowPacketTransmit(const Packet* packet) const { -#if defined(ENABLE_OTA) + // This is an egress guard, separate from the receive-side TempRadio check below. A relay can queue an OTA + // packet just before its temporary window closes; never let that delayed packet leak onto the normal channel. if (packet != NULL && packet->getPayloadType() == PAYLOAD_TYPE_OTA && !isTempRadioActive()) { return false; } -#endif if (packet != NULL && _active_flood_retry_count != 0) { for (int i = 0; i < MAX_FLOOD_RETRY_SLOTS; i++) { if (!_flood_retries[i].active || !_flood_retries[i].queued @@ -845,63 +845,69 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { break; } case PAYLOAD_TYPE_RAW_CUSTOM: { - if (pkt->isRouteDirect() && !_tables->wasSeen(pkt)) { + if (!_tables->wasSeen(pkt)) { _tables->markSeen(pkt); - onRawDataRecv(pkt); - //action = routeRecvPacket(pkt); don't flood route these (yet) + if (pkt->isRouteDirect()) onRawDataRecv(pkt); + // Repeaters transport opaque custom floods without needing to understand their application payload. + action = routeRecvPacket(pkt); } break; } case PAYLOAD_TYPE_MULTIPART: - if (pkt->payload_len > 2) { + if (pkt->payload_len > 2 && !_tables->wasSeen(pkt)) { + _tables->markSeen(pkt); uint8_t remaining = pkt->payload[0] >> 4; // num of packets in this multipart sequence still to be sent uint8_t type = pkt->payload[0] & 0x0F; if (type == PAYLOAD_TYPE_ACK && pkt->payload_len >= 5) { // a multipart ACK - if (!_tables->wasSeen(pkt)) { - _tables->markSeen(pkt); - Packet tmp; - tmp.header = pkt->header; - tmp.path_len = Packet::copyPath(tmp.path, pkt->path, pkt->path_len); - tmp.payload_len = pkt->payload_len - 1; - memcpy(tmp.payload, &pkt->payload[1], tmp.payload_len); - uint32_t ack_crc; - memcpy(&ack_crc, tmp.payload, 4); + Packet tmp; + tmp.header = pkt->header; + tmp.path_len = Packet::copyPath(tmp.path, pkt->path, pkt->path_len); + tmp.payload_len = pkt->payload_len - 1; + memcpy(tmp.payload, &pkt->payload[1], tmp.payload_len); + uint32_t ack_crc; + memcpy(&ack_crc, tmp.payload, 4); - onAckRecv(&tmp, ack_crc); - //action = routeRecvPacket(&tmp); // NOTE: currently not needed, as multipart ACKs not sent Flood - } + onAckRecv(&tmp, ack_crc); } else { // FUTURE: other multipart types?? } + // Multipart contents are application semantics. A repeater still transports a valid framed flood. + action = routeRecvPacket(pkt); } break; -#if defined(ENABLE_OTA) case PAYLOAD_TYPE_OTA: { // OTA is invisible outside an actually-running temporary-radio window. In particular, do not add it // to the seen table: a copy heard on the normal channel must not suppress one received after temp radio starts. if (!isTempRadioActive()) break; uint8_t n = pkt->getPathHashCount(); // hops travelled to reach us (flood path-hash count) +#if defined(ENABLE_OTA) // Accept-gate (duty-cycle horizon): ignore OTA from further than our hop limit - neither process nor // relay it. 0 = only directly-received OTA. Runtime-tunable via `ota config hops`. if (n > getOtaHopLimit()) break; +#endif // ALWAYS process every accepted copy: OTA handlers are idempotent, and "eventually reliable" retries // deliberately re-send IDENTICAL requests - if we gated processing on hasSeen(), the dedup would // suppress those retries and the transfer could never recover from a lost reply. hasSeen() is used // ONLY to avoid re-flooding the same packet more than once. bool seen = _tables->wasSeen(pkt); if (!seen) _tables->markSeen(pkt); +#if defined(ENABLE_OTA) ota::ota_ctx().manager.set_clock(_ms->getMillis()); // discovery jitter/ages ota::ota_ctx().manager.on_message(pkt->payload, pkt->payload_len); // central OTA receive (beacon/query/ // have/manifest/data/proof; all roles) ota::ota_ctx().track_session(ota::ota_ctx().manager.fetchState(), _ms->getMillis()); onOtaRecv(pkt); // optional per-example hook +#endif // Re-flood at the LOWEST priority and only while still under the hop limit, so OTA never competes with // mesh traffic. The free-pool guard keeps heavy OTA from monopolising the shared packet pool - dropping - // a relay is safe (OTA is best-effort; the source retries). + // a relay is safe (OTA is best-effort; the source retries). A relay-only build has no OTA-specific hop + // preference, so its ordinary repeater flood limits in allowPacketForward() remain authoritative. if (!seen && pkt->isRouteFlood() && !pkt->isMarkedDoNotRetransmit() +#if defined(ENABLE_OTA) && n < getOtaHopLimit() +#endif && (n + 1) * pkt->getPathHashSize() <= MAX_PATH_SIZE && _mgr->getFreeCount() > OTA_FWD_MIN_FREE && allowPacketForward(pkt)) { @@ -911,10 +917,17 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { } break; } -#endif default: - MESH_DEBUG_PRINTLN("%s Mesh::onRecvPacket(): unknown payload type, header: %d", getLogDateTime(), (int) pkt->header); - // Don't flood route unknown packet types! action = routeRecvPacket(pkt); + // Payload interpretation and mesh transport are separate concerns. A repeater must carry a future or + // application-defined flood type even when this firmware has no local handler for it. Framing validation, + // filterRecvFloodPacket(), duplicate detection, route limits, and allowPacketForward() remain the rejection + // gates. Unknown direct packets have no generic destination semantics and are still released. + if (pkt->isRouteFlood() && !_tables->wasSeen(pkt)) { + _tables->markSeen(pkt); + action = routeRecvPacket(pkt); + } else { + MESH_DEBUG_PRINTLN("%s Mesh::onRecvPacket(): unhandled payload type, header: %d", getLogDateTime(), (int) pkt->header); + } break; } return action; diff --git a/src/Mesh.h b/src/Mesh.h index 1fdaa4fc..ad43fc16 100644 --- a/src/Mesh.h +++ b/src/Mesh.h @@ -2,17 +2,16 @@ #include -#if defined(ENABLE_OTA) - // OTA-over-LoRa: lowest TX priority (selected only after all real traffic). The hop limit is runtime- - // tunable (OtaManager::max_hops(), `ota config hops`); the default lives in OtaManager.h. - #ifndef OTA_TX_PRIORITY - #define OTA_TX_PRIORITY 250 - #endif - // An OTA flood is relayed only while this many pool slots stay free, so heavy OTA (best-effort, low- - // priority) can never monopolise the shared packet pool and starve real traffic. - #ifndef OTA_FWD_MIN_FREE - #define OTA_FWD_MIN_FREE 4 - #endif +// OTA-over-LoRa transport is understood by every Mesh role even when the OTA manager/installer is not +// compiled in. Repeaters can therefore relay PAYLOAD_TYPE_OTA opaquely while they are on TempRadio. +// OTA traffic always uses the lowest TX priority (selected only after all real traffic). +#ifndef OTA_TX_PRIORITY +#define OTA_TX_PRIORITY 250 +#endif +// Keep this many pool slots free while relaying OTA, so best-effort firmware traffic cannot monopolise the +// shared packet pool and starve real traffic. +#ifndef OTA_FWD_MIN_FREE +#define OTA_FWD_MIN_FREE 4 #endif namespace mesh { diff --git a/test/test_trace_retry/test_trace_retry.cpp b/test/test_trace_retry/test_trace_retry.cpp index b67e3f84..0b855077 100644 --- a/test/test_trace_retry/test_trace_retry.cpp +++ b/test/test_trace_retry/test_trace_retry.cpp @@ -51,11 +51,27 @@ public: void clear(const mesh::Packet*) override { } }; +class ForwardingTestTables : public mesh::MeshTables { +public: + bool seen = false; + int mark_seen_calls = 0; + + bool wasSeen(const mesh::Packet*) override { return seen; } + void markSeen(const mesh::Packet*) override { + seen = true; + mark_seen_calls++; + } + void markSent(const mesh::Packet*) override { } + void clear(const mesh::Packet*) override { } +}; + class TraceTestMesh : public mesh::Mesh { public: bool forwardFloods = false; bool floodRetriesAllowed = true; bool groupPacketObserved = false; + bool tempRadioActive = false; + bool rejectFloods = false; TraceTestMesh(mesh::Radio& radio, mesh::MillisecondClock& ms, mesh::RNG& rng, mesh::RTCClock& rtc, mesh::PacketManager& mgr, mesh::MeshTables& tables) @@ -82,8 +98,8 @@ public: onSendComplete(packet); } - void receivePacket(mesh::Packet* packet) { - onRecvPacket(packet); + mesh::DispatcherAction receivePacket(mesh::Packet* packet) { + return onRecvPacket(packet); } mesh::DispatcherAction routePacket(mesh::Packet* packet) { @@ -94,6 +110,18 @@ public: return forwardFloods; } + bool filterRecvFloodPacket(mesh::Packet*) override { + return rejectFloods; + } + + bool isTempRadioActive() const override { + return tempRadioActive; + } + + bool canTransmit(const mesh::Packet* packet) const { + return allowPacketTransmit(packet); + } + bool allowFloodRetry(const mesh::Packet*) const override { return floodRetriesAllowed; } @@ -103,6 +131,111 @@ public: } }; +static mesh::Packet makeFloodPacket(uint8_t payload_type) { + mesh::Packet packet; + packet.header = ROUTE_TYPE_FLOOD | (payload_type << PH_TYPE_SHIFT); + packet.setPathHashSizeAndCount(1, 0); + packet.payload_len = 1; + packet.payload[0] = 0x42; + return packet; +} + +TEST(RepeaterTransport, UnknownFloodPayloadIsRelayedWhenForwardingAllowsIt) { + TraceTestClock clock; + TraceTestRTC rtc; + TraceTestRNG rng; + TraceTestRadio radio; + ForwardingTestTables tables; + StaticPoolPacketManager manager(12); + TraceTestMesh node(radio, clock, rng, rtc, manager, tables); + node.forwardFloods = true; + + mesh::Packet packet = makeFloodPacket(0x0D); // deliberately unassigned payload type + mesh::DispatcherAction action = node.receivePacket(&packet); + + EXPECT_NE(ACTION_RELEASE, action); + EXPECT_EQ(1, packet.getPathHashCount()); + EXPECT_EQ(1, tables.mark_seen_calls); +} + +TEST(RepeaterTransport, UnknownFloodPayloadHonorsReceiveAndForwardingRejections) { + TraceTestClock clock; + TraceTestRTC rtc; + TraceTestRNG rng; + TraceTestRadio radio; + ForwardingTestTables tables; + StaticPoolPacketManager manager(12); + TraceTestMesh node(radio, clock, rng, rtc, manager, tables); + + mesh::Packet filtered = makeFloodPacket(0x0D); + node.forwardFloods = true; + node.rejectFloods = true; + EXPECT_EQ(ACTION_RELEASE, node.receivePacket(&filtered)); + EXPECT_EQ(0, tables.mark_seen_calls); + + mesh::Packet forwarding_disabled = makeFloodPacket(0x0D); + node.rejectFloods = false; + node.forwardFloods = false; + EXPECT_EQ(ACTION_RELEASE, node.receivePacket(&forwarding_disabled)); + EXPECT_EQ(1, tables.mark_seen_calls); +} + +TEST(RepeaterTransport, OtaFloodRelaysWithoutOtaManagerOnlyDuringTempRadio) { + TraceTestClock clock; + TraceTestRTC rtc; + TraceTestRNG rng; + TraceTestRadio radio; + ForwardingTestTables tables; + StaticPoolPacketManager manager(12); + TraceTestMesh node(radio, clock, rng, rtc, manager, tables); + node.forwardFloods = true; + + mesh::Packet packet = makeFloodPacket(PAYLOAD_TYPE_OTA); + EXPECT_FALSE(node.canTransmit(&packet)); + EXPECT_EQ(ACTION_RELEASE, node.receivePacket(&packet)); + EXPECT_EQ(0, tables.mark_seen_calls); + EXPECT_EQ(0, packet.getPathHashCount()); + + node.tempRadioActive = true; + EXPECT_TRUE(node.canTransmit(&packet)); + mesh::DispatcherAction action = node.receivePacket(&packet); + EXPECT_NE(ACTION_RELEASE, action); + EXPECT_EQ(OTA_TX_PRIORITY, (action >> 24) - 1); + EXPECT_EQ(1, tables.mark_seen_calls); + EXPECT_EQ(1, packet.getPathHashCount()); + + node.tempRadioActive = false; + EXPECT_FALSE(node.canTransmit(&packet)); // queued-near-expiry packets cannot leak onto the normal channel +} + +TEST(RepeaterTransport, OpaqueKnownFloodPayloadsAreRelayed) { + TraceTestClock clock; + TraceTestRTC rtc; + TraceTestRNG rng; + TraceTestRadio radio; + StaticPoolPacketManager manager(12); + + { + ForwardingTestTables tables; + TraceTestMesh node(radio, clock, rng, rtc, manager, tables); + node.forwardFloods = true; + mesh::Packet custom = makeFloodPacket(PAYLOAD_TYPE_RAW_CUSTOM); + EXPECT_NE(ACTION_RELEASE, node.receivePacket(&custom)); + EXPECT_EQ(1, custom.getPathHashCount()); + } + + { + ForwardingTestTables tables; + TraceTestMesh node(radio, clock, rng, rtc, manager, tables); + node.forwardFloods = true; + mesh::Packet multipart = makeFloodPacket(PAYLOAD_TYPE_MULTIPART); + multipart.payload_len = 3; + multipart.payload[0] = PAYLOAD_TYPE_TXT_MSG; + EXPECT_NE(ACTION_RELEASE, node.receivePacket(&multipart)); + EXPECT_EQ(1, multipart.getPathHashCount()); + } +} + TEST(RTCClock, UniqueSequenceCanFollowAnIntentionalBackwardCorrection) { TraceTestRTC rtc; rtc.now = 100;