Relay opaque flood packets through repeaters

This commit is contained in:
mikecarper committed 2026-07-31 13:28:45 -07:00
1 parent 9103b40786
commit 86160e9323
7 files changed
+213 -60

No files matched your search

+3 -3
View File
@@ -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
+15 -10
View File
@@ -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
+5 -3
View File
@@ -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.
+9 -8
View File
@@ -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:
+36 -23
View File
@@ -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;
+10 -11
View File
@@ -2,17 +2,16 @@
#include <Dispatcher.h>
#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 {
+135 -2
View File
@@ -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;