Fix Cascade power saving, USB Companion, and mOTA flows

This commit is contained in:
mikecarper committed 2026-08-18 14:02:10 -07:00
1 parent 21bfc97712
commit 7a4da3ccfb
59 files changed
+3946 -2437

No files matched your search

+133 -18
View File
@@ -1,4 +1,5 @@
#include "ArduinoSerialInterface.h"
#include "CompanionFrameQueue.h"
#define RECV_STATE_IDLE 0
#define RECV_STATE_HDR_FOUND 1
@@ -11,6 +12,103 @@ void ArduinoSerialInterface::resetReceiveState() {
_secondaryControlSequencePos = 0;
_frame_len = 0;
rx_len = 0;
_last_rx_byte_ms = 0;
}
void ArduinoSerialInterface::serviceReceiveTimeout() {
if (_state != RECV_STATE_IDLE && _serial != nullptr
&& _serial->available() == 0
&& (uint32_t)(millis() - _last_rx_byte_ms) >= RX_FRAME_TIMEOUT_MS) {
// A truncated length-prefixed frame must not hold the MCU awake forever or
// turn bytes from a later session into the missing tail of the old frame.
resetReceiveState();
}
}
void ArduinoSerialInterface::resetTransmitState() {
_tx_queue_len = 0;
_tx_offset = 0;
}
bool ArduinoSerialInterface::enqueueFrame(const uint8_t src[], size_t len) {
if (src == nullptr || len == 0 || len > MAX_FRAME_SIZE) return false;
// MSG_WAITING is level-triggered. Retaining more than one copy only takes
// space away from command replies while a USB host is backpressuring us.
if (src[0] == 0x83) {
for (uint8_t i = 0; i < _tx_queue_len; ++i) {
if (_tx_queue[i].len > 3 && _tx_queue[i].buf[3] == 0x83) return true;
}
}
const bool delivery_required = mesh::companionFrameRequiresDelivery(src, len);
if (!delivery_required && _tx_queue_len >= TX_QUEUE_SIZE - 1) {
return false; // reserve one slot for a response or required push
}
if (_tx_queue_len == TX_QUEUE_SIZE) {
if (!delivery_required) return false;
// A required frame may replace queued best-effort traffic. Never replace
// the head after any of it has reached the host: doing so would splice two
// frames together on the byte stream.
const uint8_t first_evictable = _tx_offset == 0 ? 0 : 1;
int evict = -1;
for (int i = TX_QUEUE_SIZE - 1; i >= first_evictable; --i) {
const TxFrame& queued = _tx_queue[i];
if (!mesh::companionFrameRequiresDelivery(&queued.buf[3], queued.len - 3)) {
evict = i;
break;
}
}
if (evict < 0) return false;
for (uint8_t i = (uint8_t)evict; i + 1 < _tx_queue_len; ++i) {
_tx_queue[i] = _tx_queue[i + 1];
}
--_tx_queue_len;
}
TxFrame& frame = _tx_queue[_tx_queue_len++];
frame.len = (uint16_t)(len + 3);
frame.buf[0] = '>';
frame.buf[1] = (uint8_t)(len & 0xFF);
frame.buf[2] = (uint8_t)(len >> 8);
memcpy(&frame.buf[3], src, len);
return true;
}
void ArduinoSerialInterface::serviceTransmit() {
if (!_flow_ctl || _passthroughMode || _serial == nullptr) return;
if (!isConnected()) {
// A disconnected USB endpoint cannot make progress. Its host also loses
// any partial endpoint data on disconnect, so begin the next session clean.
resetTransmitState();
return;
}
while (_tx_queue_len > 0) {
TxFrame& frame = _tx_queue[0];
const size_t remaining = frame.len - _tx_offset;
int available = _serial->availableForWrite();
if (available <= 0) return;
// A UART or CDC FIFO can be smaller than MAX_FRAME_SIZE. Drain the frame in
// bounded chunks, retaining the offset and never allowing another frame to
// interleave until this header and body are complete.
const size_t attempt = (size_t)available < remaining
? (size_t)available : remaining;
size_t written = _serial->write(&frame.buf[_tx_offset], attempt);
if (written > attempt) written = attempt;
_tx_offset += (uint16_t)written;
if (_tx_offset < frame.len) return;
for (uint8_t i = 0; i + 1 < _tx_queue_len; ++i) {
_tx_queue[i] = _tx_queue[i + 1];
}
--_tx_queue_len;
_tx_offset = 0;
if (written < attempt) return;
}
}
bool ArduinoSerialInterface::checkControlSequence(uint8_t c,
@@ -39,6 +137,7 @@ void ArduinoSerialInterface::setPassthroughMode(bool enabled) {
_controlSequenceReceived = false;
_secondaryControlSequenceReceived = false;
resetReceiveState();
resetTransmitState();
}
bool ArduinoSerialInterface::takeControlSequence() {
@@ -57,37 +156,52 @@ void ArduinoSerialInterface::enable() {
_isEnabled = true;
_controlSequenceReceived = false;
_secondaryControlSequenceReceived = false;
_has_received_frame = false;
resetReceiveState();
resetTransmitState();
}
void ArduinoSerialInterface::disable() {
_isEnabled = false;
_controlSequenceReceived = false;
_secondaryControlSequenceReceived = false;
_has_received_frame = false;
resetReceiveState();
resetTransmitState();
}
bool ArduinoSerialInterface::isConnected() const {
if (_serial == nullptr) return false;
if (_conn_check) return _conn_check();
return true; // no way of knowing, so assume yes
}
void ArduinoSerialInterface::loop() {
serviceReceiveTimeout();
serviceTransmit();
}
bool ArduinoSerialInterface::isReadBusy() const {
return false;
return _state != RECV_STATE_IDLE;
}
bool ArduinoSerialInterface::isWriteBusy() const {
if (_passthroughMode) return false;
if (_passthroughMode || _serial == nullptr) return false;
if (_flow_ctl && isConnected()) {
return const_cast<Stream*>(_serial)->availableForWrite() < (int)(MAX_FRAME_SIZE + 3);
if (_tx_queue_len > 0) return true;
return const_cast<Stream*>(_serial)->availableForWrite() <= 0;
}
// while nobody drains the port the TX buffer stays full, so never report
// busy in that case: it would stall the paced streams on all interfaces
return false;
}
bool ArduinoSerialInterface::hasPendingIO() const {
return isReadBusy() || _tx_queue_len > 0;
}
size_t ArduinoSerialInterface::writeFrame(const uint8_t src[], size_t len) {
if (len > MAX_FRAME_SIZE) {
// frame is too big!
if (src == nullptr || len == 0 || len > MAX_FRAME_SIZE || _serial == nullptr) {
// invalid frame or an interface which has not begun yet
return 0;
}
if (_passthroughMode) return len;
@@ -95,29 +209,29 @@ size_t ArduinoSerialInterface::writeFrame(const uint8_t src[], size_t len) {
if (!isConnected()) {
return len; // nobody is listening, drop instead of filling the TX buffer
}
if (_serial->availableForWrite() < (int)(len + 3)) {
// a short write would tear the length prefixed framing, and as there is
// neither a checksum nor a resync marker the receiver would stay out of
// sync forever - so drop the whole frame instead
return 0;
}
if (!enqueueFrame(src, len)) return 0;
serviceTransmit();
return len;
}
uint8_t hdr[3];
hdr[0] = '>';
hdr[1] = (len & 0xFF); // LSB
hdr[2] = (len >> 8); // MSB
_serial->write(hdr, 3);
return _serial->write(src, len);
uint8_t frame[MAX_FRAME_SIZE + 3];
frame[0] = '>';
frame[1] = (uint8_t)(len & 0xFF); // LSB
frame[2] = (uint8_t)(len >> 8); // MSB
memcpy(&frame[3], src, len);
return _serial->write(frame, len + 3) == len + 3 ? len : 0;
}
size_t ArduinoSerialInterface::checkRecvFrame(uint8_t dest[]) {
if (_serial == nullptr || dest == nullptr) return 0;
serviceReceiveTimeout();
serviceTransmit();
if (_passthroughMode) return 0;
while (_serial->available()) {
int c = _serial->read();
if (c < 0) break;
_last_rx_byte_ms = millis();
switch (_state) {
case RECV_STATE_IDLE:
@@ -152,6 +266,7 @@ size_t ArduinoSerialInterface::checkRecvFrame(uint8_t dest[]) {
memcpy(dest, rx_buf, _frame_len);
_state = RECV_STATE_IDLE; // reset state, for next frame
_last_frame_ms = millis(); // a real client is talking to us
_has_received_frame = true;
return _frame_len;
}
}
+33 -5
View File
@@ -9,6 +9,14 @@ public:
typedef bool (*ConnectedCheck)();
private:
static constexpr uint8_t TX_QUEUE_SIZE = 4;
static constexpr uint32_t RX_FRAME_TIMEOUT_MS = 1000;
struct TxFrame {
uint16_t len;
uint8_t buf[MAX_FRAME_SIZE + 3];
};
bool _isEnabled;
bool _passthroughMode;
bool _controlSequenceReceived;
@@ -20,15 +28,24 @@ private:
uint16_t _frame_len;
uint16_t rx_len;
uint32_t _last_frame_ms;
uint32_t _last_rx_byte_ms;
bool _has_received_frame;
Stream* _serial;
const char* _controlSequence;
const char* _secondaryControlSequence;
ConnectedCheck _conn_check;
uint8_t rx_buf[MAX_FRAME_SIZE];
TxFrame _tx_queue[TX_QUEUE_SIZE];
uint8_t _tx_queue_len;
uint16_t _tx_offset;
bool checkControlSequence(uint8_t c, const char* sequence,
size_t& position, bool& received);
void resetReceiveState();
void serviceReceiveTimeout();
void resetTransmitState();
bool enqueueFrame(const uint8_t src[], size_t len);
void serviceTransmit();
public:
ArduinoSerialInterface()
@@ -36,8 +53,10 @@ public:
_controlSequenceReceived(false), _secondaryControlSequenceReceived(false),
_flow_ctl(false), _state(0), _controlSequencePos(0),
_secondaryControlSequencePos(0), _frame_len(0), rx_len(0),
_last_frame_ms(0), _serial(nullptr), _controlSequence(nullptr),
_secondaryControlSequence(nullptr), _conn_check(nullptr) {}
_last_frame_ms(0), _last_rx_byte_ms(0), _has_received_frame(false),
_serial(nullptr), _controlSequence(nullptr),
_secondaryControlSequence(nullptr), _conn_check(nullptr),
_tx_queue_len(0), _tx_offset(0) {}
void begin(Stream& serial, const char* controlSequence = nullptr,
const char* secondaryControlSequence = nullptr) {
@@ -48,7 +67,9 @@ public:
_controlSequenceReceived = false;
_secondaryControlSequenceReceived = false;
_last_frame_ms = 0;
_has_received_frame = false;
resetReceiveState();
resetTransmitState();
#ifdef RAK_4631
pinMode(WB_IO2, OUTPUT);
#endif
@@ -71,12 +92,17 @@ public:
// millis() of the last completely received frame, 0 if none since boot.
// Useful as an activity-based connection check where no DTR state exists.
uint32_t getLastFrameMillis() const { return _last_frame_ms; }
bool hasReceivedFrame() const { return _has_received_frame; }
// Optional: only hand a frame to the stream when it fits into the TX buffer
// as a whole, and report busy while it does not, so bulk streams get paced.
// Optional: queue complete frames and drain one at a time according to the
// stream's TX capacity, so short writes cannot discard or interleave bytes.
// Report busy while a frame remains queued so bulk streams get paced.
// Only enable this for streams which really implement availableForWrite()
// (USB-CDC does, the Print default returns 0).
void enableFlowControl(bool enable) { _flow_ctl = enable; }
void enableFlowControl(bool enable) {
if (!enable) resetTransmitState();
_flow_ctl = enable;
}
// BaseSerialInterface methods
void enable() override;
@@ -84,9 +110,11 @@ public:
bool isEnabled() const override { return _isEnabled; }
bool isConnected() const override;
void loop() override;
bool isReadBusy() const override;
bool isWriteBusy() const override;
bool hasPendingIO() const override;
size_t writeFrame(const uint8_t src[], size_t len) override;
size_t checkRecvFrame(uint8_t dest[]) override;
};
+5
View File
@@ -25,6 +25,11 @@ public:
// Returns true once for each pending Bluetooth pairing prompt. Non-BLE
// transports keep the default implementation so UI code can poll safely.
virtual bool takePairingRequest() { return false; }
// Multi-transport implementations can pin a sequence of response frames to
// the interface which supplied the current command. Single transports have
// nothing to route, so their default implementations are no-ops.
virtual void lockReplyRoute() {}
virtual void unlockReplyRoute() {}
virtual size_t writeFrame(const uint8_t src[], size_t len) = 0;
virtual size_t checkRecvFrame(uint8_t dest[]) = 0;
};
+7
View File
@@ -3689,6 +3689,13 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep
sprintf(reply, "OK - %s,%lu,%lu", enable ? "on" : "off",
(unsigned long)rx_us, (unsigned long)sleep_us);
}
} else if (strcmp(config, "powersaving on") == 0
|| strcmp(config, "powersaving off") == 0) {
const bool enabled = strcmp(&config[12], "on") == 0;
_prefs->powersaving_enabled = enabled ? 1 : 0;
_sensors->setPowerSavingEnabled(enabled);
savePrefs();
sprintf(reply, "OK - powersaving %s", enabled ? "on" : "off");
} else if (memcmp(config, "radio ", 6) == 0) {
strcpy(tmp, &config[6]);
const char *parts[4];
+3
View File
@@ -13,6 +13,9 @@
#ifndef DEFAULT_CAD_ENABLED
#define DEFAULT_CAD_ENABLED 0
#endif
#ifndef DEFAULT_POWERSAVING_ENABLED
#define DEFAULT_POWERSAVING_ENABLED 0
#endif
#if defined(ESP32_PLATFORM) || defined(USER_GPIO_CONTROL)
#include <helpers/UserGpio.h>
+88 -6
View File
@@ -1,6 +1,7 @@
#pragma once
#include "BaseSerialInterface.h"
#include "CompanionFrameQueue.h"
#ifndef MAX_INTERFACES
// ble, usb, wifi, ethernet
@@ -26,6 +27,31 @@ private:
bool _enabled = false;
RegisteredInterface _interfaces[MAX_INTERFACES] = {};
BaseSerialInterface* _lastRxInterface = nullptr;
BaseSerialInterface* _lockedReplyInterface = nullptr;
bool isAvailableReplyTarget(BaseSerialInterface* target) const {
if (target == nullptr) return false;
for (auto iface : _interfaces) {
if (iface.instance == target) {
return target->isEnabled() && target->isConnected();
}
}
return false;
}
void clearReplyRouteFor(BaseSerialInterface* target) {
// Keep a locked pointer until the response producer observes that its
// target is unavailable and aborts the transaction. Clearing it here would
// make the remaining frames fall back to broadcast on another interface.
if (_lockedReplyInterface == target) return;
if (_lastRxInterface == target) _lastRxInterface = nullptr;
}
BaseSerialInterface* replyTarget() const {
return _lockedReplyInterface != nullptr
? _lockedReplyInterface : _lastRxInterface;
}
public:
bool addInterface(InterfaceType type, BaseSerialInterface* iface) {
@@ -56,6 +82,7 @@ public:
// find and remove interface
for(int i = 0; i < MAX_INTERFACES; i++){
if(_interfaces[i].instance == iface){
clearReplyRouteFor(iface);
_interfaces[i] = {};
return true;
}
@@ -77,6 +104,7 @@ public:
for(auto iface : _interfaces){
if(iface.instance && iface.type == InterfaceType::Bluetooth){
iface.instance->disable();
clearReplyRouteFor(iface.instance);
}
}
}
@@ -105,6 +133,8 @@ public:
// enable all interfaces
void enable() override {
_enabled = true;
_lastRxInterface = nullptr;
_lockedReplyInterface = nullptr;
for(auto iface : _interfaces){
if(iface.instance){
iface.instance->enable();
@@ -115,6 +145,8 @@ public:
// disable all interfaces
void disable() override {
_enabled = false;
_lastRxInterface = nullptr;
_lockedReplyInterface = nullptr;
for(auto iface : _interfaces){
if(iface.instance){
iface.instance->disable();
@@ -132,9 +164,17 @@ public:
return false;
}
// check if any interface is connected
// A locked multi-frame reply belongs to one client. Treat losing that
// client as a disconnect even if another transport remains connected, so
// the producer aborts instead of leaking the remainder to somebody else.
if (_lockedReplyInterface != nullptr) {
return isAvailableReplyTarget(_lockedReplyInterface);
}
// check if any enabled interface is connected
for(auto iface : _interfaces){
if(iface.instance && iface.instance->isConnected()) {
if(iface.instance && iface.instance->isEnabled()
&& iface.instance->isConnected()) {
return true;
}
}
@@ -158,7 +198,12 @@ public:
return false;
}
// check if any interface is busy
// Pace a response stream against its destination. A slow inactive BLE or
// WiFi client must not stall a contact sync running over USB (or vice versa).
BaseSerialInterface* target = replyTarget();
if (isAvailableReplyTarget(target)) return target->isWriteBusy();
// With no requester yet, preserve the aggregate behavior used for pushes.
for(auto iface : _interfaces){
if(iface.instance && iface.instance->isEnabled() && iface.instance->isWriteBusy()){
return true;
@@ -202,13 +247,35 @@ public:
return false;
}
void lockReplyRoute() override {
if (isAvailableReplyTarget(_lastRxInterface)) {
_lockedReplyInterface = _lastRxInterface;
}
}
void unlockReplyRoute() override {
_lockedReplyInterface = nullptr;
}
size_t writeFrame(const uint8_t src[], size_t len) override {
// don't write when disabled or nothing provided
if(!_enabled || len == 0){
if(!_enabled || src == nullptr || len == 0){
return 0;
}
// write frame to all enabled interfaces
// Responses and delivery-required completion pushes belong to the client
// which supplied the latest command. Best-effort asynchronous observations
// remain broadcast so passive connected apps can keep their view fresh.
if (mesh::companionFrameRequiresDelivery(src, len)) {
BaseSerialInterface* target = replyTarget();
if (target != nullptr) {
if (!isAvailableReplyTarget(target)) return 0;
return target->writeFrame(src, len);
}
}
// Before any command establishes a reply route, or for best-effort pushes,
// write the frame to all enabled interfaces.
bool allSuccessful = true;
for(auto iface : _interfaces){
if(iface.instance && iface.instance->isEnabled()){
@@ -224,15 +291,30 @@ public:
size_t checkRecvFrame(uint8_t dest[]) override {
// don't read when disabled
if(!_enabled){
if(!_enabled || dest == nullptr){
return 0;
}
// Keep a multi-frame response transaction on its originating transport.
// Other interfaces retain their input until the producer unlocks the route.
if (_lockedReplyInterface != nullptr) {
if (!isAvailableReplyTarget(_lockedReplyInterface)) return 0;
size_t frameSize = _lockedReplyInterface->checkRecvFrame(dest);
if (frameSize > 0) _lastRxInterface = _lockedReplyInterface;
return frameSize;
}
if (_lastRxInterface != nullptr
&& !isAvailableReplyTarget(_lastRxInterface)) {
_lastRxInterface = nullptr;
}
// try to read a frame from any enabled interface
for(auto iface : _interfaces){
if(iface.instance && iface.instance->isEnabled()){
size_t frameSize = iface.instance->checkRecvFrame(dest);
if(frameSize > 0){
_lastRxInterface = iface.instance;
return frameSize;
}
}
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -4,7 +4,7 @@
#include "OtaStore.h"
// An OtaStore that captures an in-transit `.mota` onto a HOST folder over the mota-seeder link (the WRITE
// half of MotaSeederProto: OP_STAT/BEGIN/WRITE/SREAD/FIN). This is the destination for `ota pull <#> folder`:
// half of MotaSeederProto: OP_STAT/BEGIN/WRITE/SREAD/FIN). This is the destination for `ota pull <id> folder`:
// blocks stream straight to the host `<mid>.mota` - the device holds NO RAM/flash staging for it.
//
// begin(total) -> OP_BEGIN (host creates a 0xFF-filled <midhex>.mota.part)
+204 -56
View File
@@ -1,6 +1,6 @@
#include "OtaCli.h"
#include "OtaContext.h"
#include "FolderMotaStore.h" // `ota pull <#> folder` destination (set_mid on the connected folder store)
#include "FolderMotaStore.h" // `ota pull <id> folder` destination (set_mid on the connected folder store)
#include "OtaVerify.h"
#include "OtaSelf.h"
#include "OtaTargets.h" // ota_target_env_name(): human-readable name for a target_id (no string on the wire)
@@ -83,6 +83,21 @@ static const char* state_short(OtaManager::FetchState s) {
}
}
static const char* fetch_error_word(OtaManager::FetchError error) {
switch (error) {
case OtaManager::FETCH_ERROR_MANIFEST: return "invalid manifest";
case OtaManager::FETCH_ERROR_HASH_ALGO: return "unsupported hash";
case OtaManager::FETCH_ERROR_CODEC: return "unsupported codec";
case OtaManager::FETCH_ERROR_GEOMETRY: return "invalid geometry";
case OtaManager::FETCH_ERROR_TOO_LARGE: return "image too large";
case OtaManager::FETCH_ERROR_STORAGE: return "storage error";
case OtaManager::FETCH_ERROR_INTEGRITY: return "integrity check";
case OtaManager::FETCH_ERROR_MANIFEST_TIMEOUT: return "manifest timeout";
case OtaManager::FETCH_ERROR_LEAVES_TIMEOUT: return "leaves timeout";
default: return "none";
}
}
// Render the packed fw_version as "v1.2.3" (or "v1.2.3.4" when a prerelease byte is set).
static void ver_str(char* out, size_t cap, uint32_t v) {
FwVersion fw = FwVersion::unpack(v);
@@ -129,16 +144,16 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
if (is_cmd(a, "help|?|h", &rest)) {
#if defined(OTA_SEEDER_ONLY)
snprintf(reply, 160,
"OTA seeder: status | stats | ls=find images | get <#> folder=capture | cancel | "
"OTA seeder: status | stats | ls=find images | get <id> folder=capture | cancel | "
"announce | folder | config. LoRa install is disabled.");
#elif defined(NRF52_PLATFORM) && defined(OTA_FLASH_STORE) && !defined(OTA_SD_STORE)
strcpy(reply,
"OTA: status | stats | ls | get | install | rescue install <hash16> | cancel | announce | "
"self | folder | config | key");
"OTA: status | stats | ls | get <id> flash [rescue] | install | rescue install <hash16> | "
"cancel | announce | self | folder | config | key");
#else
snprintf(reply, 160,
"OTA: status | stats=admin ids/hashes | ls=find updates | get <#>=download | install | cancel | "
"announce | self | folder | cache | config | key. Use `ota ls [page]`.");
"OTA: status | stats | ls | get <id> flash | install | cancel | announce | self | folder | "
"cache | config | key. `ota ls [page]`; folder [validate].");
#endif
// ---- inventory dashboard: running fw (self), the one fetch session, serving state ----
@@ -162,7 +177,12 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
unsigned have = (unsigned)c.manager.blocksHave(), tot = (unsigned)c.manager.blocksTotal();
unsigned pct = tot ? (unsigned)((uint64_t)have * 100 / tot) : 0;
unsigned age = c.session_started_ms ? (unsigned)((millis() - c.session_started_ms) / 1000) : 0;
snprintf(dl, sizeof dl, "download: %s %u/%u (%u%%) id=%s %us", state_word(fs), have, tot, pct, midhx, age);
if (fs == OtaManager::FAILED)
snprintf(dl, sizeof dl, "download: failed (%s) %u/%u id=%s",
fetch_error_word(c.manager.fetchError()), have, tot, midhx);
else
snprintf(dl, sizeof dl, "download: %s %u/%u (%u%%) id=%s %us",
state_word(fs), have, tot, pct, midhx, age);
}
const char* hw = (c.hw_id[0]) ? c.hw_id : "?";
const char* tenv = ota_target_env_name(c.manager.target()); // env name, or "?" if not in the table
@@ -211,7 +231,12 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
unsigned have = (unsigned)c.manager.blocksHave(), tot = (unsigned)c.manager.blocksTotal();
unsigned pct = tot ? (unsigned)((uint64_t)have * 100 / tot) : 0;
unsigned age = c.session_started_ms ? (unsigned)((millis() - c.session_started_ms) / 1000) : 0;
snprintf(fbuf, sizeof fbuf, "fetch %s %u/%u %u%% id=%s %us", state_short(fs), have, tot, pct, fmid, age);
if (fs == OtaManager::FAILED)
snprintf(fbuf, sizeof fbuf, "fetch failed:%s %u/%u id=%s",
fetch_error_word(c.manager.fetchError()), have, tot, fmid);
else
snprintf(fbuf, sizeof fbuf, "fetch %s %u/%u %u%% id=%s %us",
state_short(fs), have, tot, pct, fmid, age);
}
uint8_t af = c.manager.autofetch();
snprintf(reply, 160, "OTA | fw %s id=%s body=%s %ub %uK | serv %u dg=%s | %s | af=%s hops=%u",
@@ -236,11 +261,18 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
snprintf(reply, CAP, "ERR update page %u out of range (1-%u)", (unsigned)page, (unsigned)pages);
return true;
}
int n = snprintf(reply, CAP, "Updates %u/%u (%u src) - `ota get <#>`:",
int n = snprintf(reply, CAP, "Updates %u/%u (%u src; refreshing):",
(unsigned)page, (unsigned)pages, (unsigned)c.manager.sourceCount());
OtaManager::FetchState fs = c.manager.fetchState();
const uint8_t* cur = (fs != OtaManager::IDLE) ? c.manager.fetchManifestId() : nullptr;
uint32_t myt = c.manager.target(); // effective target (EndF identity if present, else build flag)
#if defined(NRF52_PLATFORM)
const OtaBlCaps& list_bl = c.bootloaderCaps();
#endif
#if defined(NRF52_PLATFORM) && defined(OTA_FLASH_STORE) && !defined(OTA_SD_STORE)
SelfFwInfo list_self;
bool list_has_endf = ota_self_firmware(list_self) && list_self.valid;
#endif
uint32_t now = millis(); int shown = 0;
uint16_t first = (uint16_t)(page - 1) * PAGE_SIZE;
uint16_t last = first + PAGE_SIZE; if (last > count) last = count;
@@ -258,18 +290,32 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
}
uint32_t age = (now - h->last_ms) / 1000; if (age > 99999) age = 99999;
char ver[20]; ver_str(ver, sizeof ver, h->fw_version);
// What is this update for? "yours" if same target (hw+role) as us; else the target's env name when we
// know it (named locally from its 4-byte target_id - no string travels on the wire); else the raw
// target_id hex (an env this build's OtaTargets.h table doesn't know) or '?' for an unset target.
// Target equality alone is not an install-safety claim. Surface local codec/bootloader limitations,
// and flag the explicit rescue path when this internal-flash nRF52 has no valid running EndF.
char hwbuf[16];
const char* fit;
const char* env = ota_target_env_name(h->target_id);
if (myt && h->target_id == myt) fit = "yours";
else if (env) fit = env;
else if (h->target_id == 0) fit = "?";
if (myt && h->target_id == myt) {
bool installable = c.manager.codecOk(h->codec);
#if defined(NRF52_PLATFORM)
installable = installable && list_bl.present && list_bl.apply_abi >= MOTA_FORMAT_VER
&& h->codec < 16 && (list_bl.codec_mask & (1u << h->codec));
#if defined(OTA_SD_STORE)
installable = installable && (list_bl.storage_flags & OTA_BL_STORAGE_SD);
#endif
#endif
if (!installable) fit = "unsupported";
#if defined(NRF52_PLATFORM) && defined(OTA_FLASH_STORE) && !defined(OTA_SD_STORE)
else if (!list_has_endf) fit = "rescue";
#endif
else fit = "same target";
} else if (env) fit = env;
else if (h->target_id == 0) fit = "?";
else { snprintf(hwbuf, sizeof hwbuf, "hw %08X", (unsigned)h->target_id); fit = hwbuf; }
n += snprintf(reply + n, CAP - n, "\n %u) %s %s [%s] %un %us%s", (unsigned)(i + 1), ver,
codec_kind(h->codec), fit, (unsigned)h->n_seeders, (unsigned)age, tag);
char midhx[9]; mesh::Utils::toHex(midhx, h->mid, 4);
n += snprintf(reply + n, CAP - n, "\n %u) %s %s %s [%s] %un %us%s",
(unsigned)(i + 1), midhx, ver, codec_kind(h->codec), fit,
(unsigned)h->n_seeders, (unsigned)age, tag);
shown++;
}
if (shown == 0) strcpy(reply, "No updates seen yet - re-run `ota ls` in a few seconds (just asked around).");
@@ -283,19 +329,29 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
while (p[i] && p[i] != ' ' && i < (int)sizeof(selstr) - 1) { selstr[i] = p[i]; i++; }
selstr[i] = 0;
const char* dst = p + i; while (*dst == ' ') dst++;
if (selstr[0] == 0) { strcpy(reply, "usage: ota pull <#> <flash|folder> (see `ota ls`)"); return true; }
if (selstr[0] == 0) { strcpy(reply, "usage: ota pull <id> flash [rescue] | folder [validate] (see `ota ls`)"); return true; }
// resolve the catalogue row (index or explicit manifest_id)
const OtaManager::CatRow* sel = nullptr; uint8_t mid[4];
bool isnum = (selstr[0] == '#');
if (!isnum) { isnum = true; for (const char* x = selstr; *x; x++) if (*x < '0' || *x > '9') { isnum = false; break; } }
if (isnum) {
int idx = atoi(selstr[0] == '#' ? selstr + 1 : selstr);
if (idx >= 1 && idx <= c.manager.catalogCount()) sel = c.manager.catalogRow((uint8_t)(idx - 1));
} else if (mesh::Utils::fromHex(mid, 4, selstr)) {
// An eight-digit all-numeric manifest ID is still an ID, not a huge list index. Bare short decimal
// values retain the legacy index form; `#N` is the unambiguous explicit index spelling.
size_t selector_len = strlen(selstr);
bool explicit_index = selstr[0] == '#';
const char* index_text = explicit_index ? selstr + 1 : selstr;
bool decimal = *index_text != 0;
uint16_t index = 0;
for (const char* x = index_text; *x && decimal; x++) {
if (*x < '0' || *x > '9') decimal = false;
else if (index > 255 / 10 || (index == 255 / 10 && (uint8_t)(*x - '0') > 255 % 10)) decimal = false;
else index = (uint16_t)(index * 10 + (uint8_t)(*x - '0'));
}
bool use_index = explicit_index || (selector_len != 8 && decimal);
if (use_index && decimal) {
if (index >= 1 && index <= c.manager.catalogCount()) sel = c.manager.catalogRow((uint8_t)(index - 1));
} else if (!explicit_index && selector_len == 8 && mesh::Utils::fromHex(mid, 4, selstr)) {
for (uint8_t k = 0; k < c.manager.catalogCount(); k++)
if (memcmp(c.manager.catalogRow(k)->mid, mid, 4) == 0) { sel = c.manager.catalogRow(k); break; }
}
if (!sel) { strcpy(reply, "ERR no such update (see the numbers in `ota ls`)"); return true; }
if (!sel) { strcpy(reply, "ERR no such update (copy its eight-digit ID from `ota ls`)"); return true; }
// destination is MANDATORY: with none given, show the choices (flash always; folder iff a link is up).
if (*dst == 0) {
#if defined(OTA_SEEDER_ONLY)
@@ -315,40 +371,112 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
return true;
}
if (c.apply_pending) { strcpy(reply, "ERR busy applying"); return true; }
// optional 3rd token: `folder validate` -> leaf-diff warm-start against a seed motatool staged (capture only)
bool validate = false;
{ const char* q = dst; while (*q && *q != ' ') q++; while (*q == ' ') q++;
if (strncmp(q, "validate", 8) == 0) validate = true; }
uint8_t selmid[4]; uint32_t seltgt = sel->target_id; memcpy(selmid, sel->mid, 4); // sel may move on reset
OtaStore* store; const char* dname;
if (strncmp(dst, "flash", 5) == 0) {
// Parse exact destination/options. Prefix matches used to accept typos such as "flashgarbage" and an
// ignored third token, which is especially unsafe for the explicit no-EndF rescue acknowledgement.
char destination[8] = {0}, option[10] = {0}, extra[2] = {0};
int parts = sscanf(dst, "%7s %9s %1s", destination, option, extra);
if (parts < 1 || parts > 2) {
strcpy(reply, "ERR usage: ota pull <id> flash [rescue] | folder [validate]");
return true;
}
bool to_flash = strcmp(destination, "flash") == 0;
bool to_folder = strcmp(destination, "folder") == 0;
bool validate = to_folder && parts == 2 && strcmp(option, "validate") == 0;
bool rescue = to_flash && parts == 2 && strcmp(option, "rescue") == 0;
if ((!to_flash && !to_folder) || (parts == 2 && !validate && !rescue)) {
strcpy(reply, "ERR usage: ota pull <id> flash [rescue] | folder [validate]");
return true;
}
uint8_t selmid[4]; uint32_t seltgt = sel->target_id; uint8_t selcodec = sel->codec;
memcpy(selmid, sel->mid, 4); // sel may move when catalog traffic arrives
#if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE)
c.stopSdCacheFetch(); // explicit operator work owns the receive slot
#endif
OtaManager::FetchState current = c.manager.fetchState();
if (current != OtaManager::IDLE && current != OtaManager::FAILED) {
snprintf(reply, 160, "ERR OTA slot is %s; use `ota cancel` before replacing it", state_word(current));
return true;
}
OtaStore* store = nullptr; const char* dname = nullptr;
if (to_flash) {
#if defined(OTA_SEEDER_ONLY)
strcpy(reply, "ERR seeder-only build cannot stage or install firmware; use `folder`");
return true;
#else
#if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE)
c.stopSdCacheFetch(); // manual install download takes priority over archiving
#endif
store = &c.fetch_store; c.fetch_store.clear(); dname = "flash"; validate = false; // seed lives in the folder
#if defined(NRF52_PLATFORM) && !defined(OTA_SD_STORE)
c.manager.set_accept_full(false); // nRF52 flash can install only in-place deltas
#endif
#endif
} else if (strncmp(dst, "folder", 6) == 0) {
if (!c.folder_dest) { strcpy(reply, "ERR no folder connected (run motatool serve --tcp/--serial)"); return true; }
#if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE)
c.stopSdCacheFetch(); // an explicit host capture also takes priority
#endif
c.folder_dest->set_mid(selmid); store = c.folder_dest; dname = validate ? "folder+validate" : "folder";
if (!c.manager.codecOk(selcodec)) {
snprintf(reply, 160,
"ERR %s codec %u cannot be installed by this build; use `ota pull %s folder` to capture it",
codec_kind(selcodec), (unsigned)selcodec, selstr);
return true;
}
#if defined(NRF52_PLATFORM)
c.manager.set_accept_full(true); // capture can store a full image; it is not applied
const OtaBlCaps& bl = c.bootloaderCaps();
if (!bl.present) {
strcpy(reply, "ERR bootloader has no mOTA apply support; update it over USB first");
return true;
}
if (bl.apply_abi < MOTA_FORMAT_VER || selcodec >= 16 || !(bl.codec_mask & (1u << selcodec))) {
snprintf(reply, 160, "ERR bootloader cannot apply mOTA ABI %u codec %u (has abi=%u codecs=0x%x)",
MOTA_FORMAT_VER, (unsigned)selcodec, bl.apply_abi, bl.codec_mask);
return true;
}
#if defined(OTA_SD_STORE)
if (!(bl.storage_flags & OTA_BL_STORAGE_SD)) {
strcpy(reply, "ERR bootloader cannot apply an update staged on SD; update it over USB first");
return true;
}
#endif
} else { strcpy(reply, "ERR destination must be `flash` or `folder`"); return true; }
#endif
#if defined(NRF52_PLATFORM) && defined(OTA_FLASH_STORE) && !defined(OTA_SD_STORE)
SelfFwInfo self;
bool has_endf = ota_self_firmware(self) && self.valid;
if (!has_endf && !rescue) {
snprintf(reply, 160,
"ERR no EndF; retry `ota pull %s flash rescue`, then use `ota rescue install <hash16>`",
selstr);
return true;
}
if (!has_endf && seltgt != c.manager.target()) {
strcpy(reply, "ERR rescue requires an update for this exact target");
return true;
}
if (has_endf && rescue) {
strcpy(reply, "ERR rescue is only for a running firmware with no valid EndF; use `flash`");
return true;
}
#else
if (rescue) {
strcpy(reply, "ERR rescue is available only on internal-flash nRF52 builds");
return true;
}
#endif
store = &c.fetch_store; dname = rescue ? "flash+rescue" : "flash";
#endif
} else if (to_folder) {
if (!c.folder_dest) { strcpy(reply, "ERR no folder connected (run motatool serve --tcp/--serial)"); return true; }
c.folder_dest->set_mid(selmid); store = c.folder_dest; dname = validate ? "folder+validate" : "folder";
}
c.manager.reset_session();
c.fetch_to_folder = to_folder;
c.manager.set_fetch_store(store); // stage this pull to the chosen destination
c.manager.pull(selmid, seltgt, validate); // sets want + begins the manifest fetch now
OtaManager::PullResult result = to_folder
? c.manager.pull_archive(selmid, seltgt, validate)
: c.manager.pull(selmid, seltgt, false);
char midhx[9]; mesh::Utils::toHex(midhx, selmid, 4);
snprintf(reply, 160, "OK pulling mid=%s -> %s (primary traffic)", midhx, dname);
if (result != OtaManager::PULL_STARTED && result != OtaManager::PULL_RESUMED) {
c.manager.reset_session();
c.fetch_to_folder = false;
c.manager.set_fetch_store(&c.fetch_store);
const char* why = result == OtaManager::PULL_NO_STORE ? "no destination store"
: result == OtaManager::PULL_BUSY ? "receive slot busy" : "invalid manifest id";
snprintf(reply, 160, "ERR pull did not start: %s", why);
return true;
}
snprintf(reply, 160, "OK %s mid=%s -> %s (primary traffic)",
result == OtaManager::PULL_RESUMED ? "resuming" : "pulling", midhx, dname);
// ---- discard the current session (e.g. a stalled old fetch) to free the slot ----
} else if (is_cmd(a, "drop|cancel|stop", &rest)) {
@@ -359,6 +487,7 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
c.stopSdCacheFetch();
#endif
c.manager.reset_session(); c.manager.want(0); c.manager.want_mid(nullptr);
c.fetch_to_folder = false;
c.manager.set_fetch_store(&c.fetch_store); // revert to the default flash store (a folder pull switched it)
#if defined(NRF52_PLATFORM) && !defined(OTA_SD_STORE)
c.manager.set_accept_full(false);
@@ -405,6 +534,10 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
// the exact 8-byte base hash carried by the already-fetched package. The bootloader independently
// hashes the running app and refuses a mismatch before writing any application flash.
#if defined(NRF52_PLATFORM) && defined(OTA_FLASH_STORE) && !defined(OTA_SD_STORE) && !defined(OTA_SEEDER_ONLY)
if (c.fetch_to_folder) {
strcpy(reply, "ERR the complete update was captured to a folder, not staged for install; use `ota cancel`");
return true;
}
const char* hash_text = nullptr;
uint8_t operator_base_hash[8];
if (!is_cmd(rest, "install", &hash_text) ||
@@ -439,6 +572,10 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
return true;
}
#endif
if (c.fetch_to_folder) {
strcpy(reply, "ERR the complete update was captured to a folder, not staged for install; use `ota cancel`");
return true;
}
if (c.manager.fetchState() != OtaManager::COMPLETE || c.fetch_store.staged_size() == 0) {
sprintf(reply, "ERR no complete update fetched (fetch=%c %u/%u)",
fstate_char(c.manager.fetchState()), (unsigned)c.manager.blocksHave(),
@@ -455,7 +592,7 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
// node hosts MANY images (any architecture) it doesn't hold in flash. Trustless (fetchers verify). --
} else if (is_cmd(a, "folder|fold", &rest)) {
const char* p = rest;
if (strncmp(p, "on", 2) == 0) {
if (strcmp(p, "on") == 0) {
#if defined(OTA_FOLDER_SERIAL)
#if !defined(OTA_SEEDER_ONLY)
if (!c.serving) c.serving = ota_serve_self(c, 0); // keep serving our own fw alongside the folder
@@ -465,22 +602,29 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
#else
strcpy(reply, "ERR not built with OTA_FOLDER_SERIAL (set the seeder UART in platformio.ini)");
#endif
} else if (strncmp(p, "off", 3) == 0) {
} else if (strcmp(p, "off") == 0) {
c.detach_folder(); c.manager.announce();
#if defined(OTA_SEEDER_ONLY)
strcpy(reply, "OK folder detached (serving nothing)");
#else
strcpy(reply, "OK folder detached (still serving own fw)");
#endif
} else { // status + list served entries (* = our own fw)
int n = snprintf(reply, 159, "folder=%s serving=%u:", c.folder_active ? "on" : "off",
(unsigned)c.manager.servedCount());
} else if (*p == 0) { // status + list served entries (* = our own fw)
uint16_t offered = 0, advertised = 0;
bool have_stats = c.folderSourceStats(offered, advertised);
int n = have_stats
? snprintf(reply, 159, "folder=%s host=%u/%u serving=%u:", c.folder_active ? "on" : "off",
(unsigned)advertised, (unsigned)offered, (unsigned)c.manager.servedCount())
: snprintf(reply, 159, "folder=%s serving=%u:", c.folder_active ? "on" : "off",
(unsigned)c.manager.servedCount());
for (uint8_t i = 0; i < c.manager.servedCount() && n < 148; i++) {
const OtaManager::ServeEntry* e = c.manager.servedEntry(i);
if (!e) break;
char midhx[9]; mesh::Utils::toHex(midhx, e->mid, 4);
n += snprintf(reply + n, 159 - n, " %s%s/%08X", e->is_self ? "*" : "", midhx, (unsigned)e->target_id);
}
} else {
strcpy(reply, "ERR usage: ota folder [on|off]");
}
// ---- SD OTA archive: default-on capture of every advertised mOTA, retained and served after reboot. ----
@@ -674,6 +818,10 @@ static bool handle_dev(const char* d, char* reply, OtaContext& c) {
strcpy(reply, "OK announced");
} else if (strncmp(d, "verify", 6) == 0) {
if (c.fetch_to_folder && c.manager.fetchState() == OtaManager::COMPLETE) {
strcpy(reply, "ERR completed fetch is in the host folder, not local verification storage");
return true;
}
#if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE)
if (c.manager.fetchState() == OtaManager::COMPLETE) {
VerifyResult r = ota_verify(static_cast<const OtaStore&>(c.fetch_store), c.allow);
@@ -729,7 +877,7 @@ static bool handle_dev(const char* d, char* reply, OtaContext& c) {
#endif
c.manager.clear_primary();
c.serve_expected = 0; c.serving = false; c.releaseServeBuffer();
c.fetch_store.clear(); c.manager.reset_session();
c.fetch_store.clear(); c.manager.reset_session(); c.fetch_to_folder = false;
strcpy(reply, "OK cleared");
} else {
+23 -10
View File
@@ -114,12 +114,13 @@ struct OtaContext {
bool config_dirty = false; // CLI set a policy/key -> CommonCLI persists + clears
char hw_id[33] = {0}; // this device's hardware tag (from board.getOtaHwId(), set in begin)
// ---- Pull destinations (`ota pull <#> <dest>`): where a fetched .mota is staged. `flash` (fetch_store,
// ---- Pull destinations (`ota pull <id> <dest>`): where a fetched .mota is staged. `flash` (fetch_store,
// always present) or `folder` - an external host folder over the seeder link, registered by the app
// while a motatool `serve` connection is attached (else no `folder` dest is offered). Captures the
// container to the host as <mid>.mota so an exact firmware copy can be pulled for delta-building. ----
FolderMotaStore* folder_dest = nullptr; // non-null == a folder destination is currently connected
char folder_dest_info[24] = {0}; // human id of the link (e.g. "tcp 192.168.4.5", "serial")
bool fetch_to_folder = false; // COMPLETE belongs to a capture destination, never install it
void set_folder_dest(FolderMotaStore* fs, const char* info) {
folder_dest = fs;
strncpy(folder_dest_info, info ? info : "?", sizeof(folder_dest_info) - 1);
@@ -150,6 +151,11 @@ struct OtaContext {
}
bool apply_fetched_impl(const uint8_t* rescue_base_hash, char* msg) {
if (fetch_to_folder) {
strncpy(msg, "refused: completed fetch was captured to a folder, not local install storage", 96);
msg[95] = 0;
return false;
}
#if defined(OTA_SEEDER_ONLY)
strncpy(msg, "refused: this build serves mOTA images but cannot install one", 96);
msg[95] = 0;
@@ -234,6 +240,9 @@ struct OtaContext {
FOLDER_LINK_TCP,
};
FolderLink folderLink() const { return _folder_link; }
bool folderSourceStats(uint16_t& offered, uint16_t& advertised) const {
return manager.sourceStats(_folder_source, offered, advertised);
}
bool attach_folder_source(MotaSource* source, FolderLink link, const char* label,
char* msg, size_t cap) {
@@ -250,8 +259,11 @@ struct OtaContext {
}
if (folder_active && _folder_source == source) {
manager.refresh_sources();
snprintf(msg, cap, "OK folder refreshed (%s) - serving %u mOTA total",
label ? label : "external", (unsigned)manager.servedCount());
uint16_t offered = 0, advertised = 0;
manager.sourceStats(source, offered, advertised);
snprintf(msg, cap, "OK folder refreshed (%s): advertising %u/%u host mOTAs%s",
label ? label : "external", (unsigned)advertised, (unsigned)offered,
advertised < offered ? " (some omitted: serve cap/invalid/duplicate)" : "");
return true;
}
if (folder_active && _folder_source) {
@@ -268,13 +280,11 @@ struct OtaContext {
folder_active = true;
_folder_link = link;
_folder_source = source;
#if defined(OTA_SEEDER_ONLY)
snprintf(msg, cap, "OK folder attached (%s) - serving %u host mOTA total",
label ? label : "external", (unsigned)manager.servedCount());
#else
snprintf(msg, cap, "OK folder attached (%s) - serving %u mOTA total (own fw + folder)",
label ? label : "external", (unsigned)manager.servedCount());
#endif
uint16_t offered = 0, advertised = 0;
manager.sourceStats(source, offered, advertised);
snprintf(msg, cap, "OK folder attached (%s): advertising %u/%u host mOTAs%s",
label ? label : "external", (unsigned)advertised, (unsigned)offered,
advertised < offered ? " (some omitted: serve cap/invalid/duplicate)" : "");
return true;
}
@@ -333,6 +343,7 @@ struct OtaContext {
manager.want(0);
manager.want_mid(nullptr);
manager.set_fetch_store(&fetch_store);
fetch_to_folder = false;
_sd_cache_fetching = false;
}
@@ -398,6 +409,7 @@ struct OtaContext {
memcpy(_sd_cache_mid, row->mid, 4);
sd_cache.set_mid(row->mid);
manager.set_fetch_store(&sd_cache);
fetch_to_folder = false;
_sd_cache_fetching = true;
manager.pull_archive(row->mid, row->target_id);
return;
@@ -427,6 +439,7 @@ struct OtaContext {
target_id = 0;
#endif
manager.begin(target_id, send, ctx);
fetch_to_folder = false;
if (hw) { strncpy(hw_id, hw, sizeof(hw_id) - 1); hw_id[sizeof(hw_id) - 1] = 0; }
// a node only fetches firmware it can apply: ESP32 A/B -> sequential, nRF52 single-slot -> in-place
#if defined(OTA_SEEDER_ONLY)
+228 -50
View File
@@ -44,9 +44,14 @@ uint8_t* OtaManager::ensureScratch() {
void OtaManager::begin(uint32_t my_target_id, OtaSend send, void* ctx) {
_target = my_target_id; _send = send; _ctx = ctx;
_fstate = IDLE; _have = 0; _fbc = 0;
_fetch_error = FETCH_ERROR_NONE;
_archive_fetch = false; _validate = false;
clearFetchIntent();
_resume_verify_idx = 0; _resume_invalidated = false;
_resume_merkle.reset();
_n_serve = 0; _n_src_obj = 0; _view0.valid = false; _srcv.valid = false;
memset(_src_offered, 0, sizeof(_src_offered));
memset(_src_advertised, 0, sizeof(_src_advertised));
_n_src = 0; _n_cat = 0;
clearPendingEgress();
}
@@ -67,7 +72,7 @@ bool OtaManager::serve(const uint8_t* mota, uint32_t len) {
_view0.scratch = scratch; _view0.scratch_sz = OTA_PROOFGEN_SCRATCH; // <=1024 blocks (RAM .mota is small)
_view0.valid = true;
clearPendingEgress();
registerSelfEntry();
if (_n_src_obj) refresh_sources(); else registerSelfEntry();
return true;
}
@@ -84,7 +89,7 @@ bool OtaManager::serve_self(const uint8_t* manifest, uint16_t mfl, const uint8_t
_view0.scratch = proof_scratch; _view0.scratch_sz = proof_scratch_sz; // sized for our (large) image
_view0.valid = true;
clearPendingEgress();
registerSelfEntry();
if (_n_src_obj) refresh_sources(); else registerSelfEntry();
return true;
}
@@ -112,7 +117,10 @@ bool OtaManager::add_source(MotaSource* src) {
if (_src_list[i] == src) { refresh_sources(); return true; }
}
if (_n_src_obj >= OTA_MAX_SOURCE_OBJ) return false;
_src_list[_n_src_obj++] = src;
_src_list[_n_src_obj] = src;
_src_offered[_n_src_obj] = 0;
_src_advertised[_n_src_obj] = 0;
_n_src_obj++;
refresh_sources();
return true;
}
@@ -121,8 +129,15 @@ bool OtaManager::remove_source(MotaSource* src) {
if (!src) return false;
for (uint8_t i = 0; i < _n_src_obj; i++) {
if (_src_list[i] != src) continue;
for (uint8_t j = i + 1; j < _n_src_obj; j++) _src_list[j - 1] = _src_list[j];
_src_list[--_n_src_obj] = nullptr;
for (uint8_t j = i + 1; j < _n_src_obj; j++) {
_src_list[j - 1] = _src_list[j];
_src_offered[j - 1] = _src_offered[j];
_src_advertised[j - 1] = _src_advertised[j];
}
_n_src_obj--;
_src_list[_n_src_obj] = nullptr;
_src_offered[_n_src_obj] = 0;
_src_advertised[_n_src_obj] = 0;
refresh_sources();
return true;
}
@@ -134,10 +149,14 @@ void OtaManager::refresh_sources() {
_n_serve = 0;
if (_view0.valid) registerSelfEntry();
for (uint8_t s = 0; s < _n_src_obj; s++) {
_src_offered[s] = 0;
_src_advertised[s] = 0;
MotaSource* src = _src_list[s];
if (!src) continue;
uint8_t cnt = src->count();
for (uint8_t i = 0; i < cnt && _n_serve < OTA_MAX_SERVE; i++) {
_src_offered[s] = cnt;
for (uint8_t i = 0; i < cnt; i++) {
if (_n_serve >= OTA_MAX_SERVE) break;
MotaDesc d;
if (!src->describe(i, d)) continue;
if (d.block_count == 0 || (uint64_t)d.block_count * 4 > OTA_PROOFGEN_SCRATCH) continue;
@@ -161,14 +180,31 @@ void OtaManager::refresh_sources() {
e.target_id = d.target_id; e.fw_version = d.fw_version;
e.codec_id = d.codec_id; e.flags = d.flags; e.have_count = d.block_count; // a folder mota is fully held
e.is_self = false; e.src = src; e.src_idx = i; e.desc = d;
_src_advertised[s]++;
}
}
_srcv.valid = false; // a loaded source view may now be stale; reloads on demand
}
bool OtaManager::sourceStats(const MotaSource* src, uint16_t& offered, uint16_t& advertised) const {
offered = 0;
advertised = 0;
if (!src) return false;
for (uint8_t i = 0; i < _n_src_obj; i++) {
if (_src_list[i] != src) continue;
offered = _src_offered[i];
advertised = _src_advertised[i];
return true;
}
return false;
}
void OtaManager::clear_sources() {
clearPendingEgress();
_n_src_obj = 0; _srcv.valid = false;
memset(_src_list, 0, sizeof(_src_list));
memset(_src_offered, 0, sizeof(_src_offered));
memset(_src_advertised, 0, sizeof(_src_advertised));
_n_serve = 0;
if (_view0.valid) registerSelfEntry();
}
@@ -176,12 +212,8 @@ void OtaManager::clear_sources() {
void OtaManager::clear_primary() {
clearPendingEgress();
_view0.valid = false;
if (_n_serve > 0 && _serve[0].is_self) {
for (uint8_t i = 1; i < _n_serve; i++) {
_serve[i - 1] = _serve[i];
}
_n_serve--;
}
if (_n_src_obj) refresh_sources();
else _n_serve = 0;
}
int OtaManager::serveEntryIndex(const uint8_t* mid) const {
@@ -531,6 +563,40 @@ void OtaManager::serviceEgress() {
// ---------------- fetch ----------------
// A source's set digest is the lifetime of its catalog rows. When that digest changes (or the source table
// evicts the source), remove only that seeder's association from every row and recompute aggregate progress.
// Rows with no remaining source disappear, so numeric/MID selection cannot target firmware nobody offers.
void OtaManager::invalidateCatalogSeeder(const uint8_t* seeder) {
if (!seeder) return;
CatRow* catalog = catalogData();
for (uint16_t i = 0; i < _n_cat; ) {
CatRow& row = catalog[i];
int found = -1;
for (uint8_t k = 0; k < row.n_seeders; k++) {
if (memcmp(row.seeders[k], seeder, 4) == 0) { found = k; break; }
}
if (found < 0) { i++; continue; }
for (uint8_t k = (uint8_t)found + 1; k < row.n_seeders; k++) {
memcpy(row.seeders[k - 1], row.seeders[k], 4);
row.seeder_have[k - 1] = row.seeder_have[k];
row.seeder_last_ms[k - 1] = row.seeder_last_ms[k];
}
row.n_seeders--;
if (row.n_seeders == 0) {
for (uint16_t j = i + 1; j < _n_cat; j++) catalog[j - 1] = catalog[j];
_n_cat--;
continue;
}
row.have_max = 0;
row.last_ms = 0;
for (uint8_t k = 0; k < row.n_seeders; k++) {
if (row.seeder_have[k] > row.have_max) row.have_max = row.seeder_have[k];
if (row.seeder_last_ms[k] > row.last_ms) row.last_ms = row.seeder_last_ms[k];
}
i++;
}
}
// A tiny per-node BEACON: record the source; ask it for its catalog (OTA_QUERY) only when we're
// interested AND its set-digest is one we haven't catalogued yet (so a stable mesh is query-free).
void OtaManager::handleAdv(const uint8_t* m, uint16_t n) {
@@ -538,7 +604,16 @@ void OtaManager::handleAdv(const uint8_t* m, uint16_t n) {
if (!decode_adv(m, n, a)) return;
bool have_sid = (_seeder_id[0] | _seeder_id[1] | _seeder_id[2] | _seeder_id[3]) != 0;
if (have_sid && memcmp(a.seeder_id, _seeder_id, 4) == 0) return; // our own beacon, re-flooded
if (a.n_motas == 0) return; // source offers nothing
if (a.n_motas == 0) { // source explicitly withdrew its set
invalidateCatalogSeeder(a.seeder_id);
for (uint8_t i = 0; i < _n_src; i++) {
if (memcmp(_sources[i].seeder, a.seeder_id, 4) != 0) continue;
for (uint8_t j = i + 1; j < _n_src; j++) _sources[j - 1] = _sources[j];
_n_src--;
break;
}
return;
}
int slot = -1, lru = 0; // find/insert the source (LRU evict)
for (int i = 0; i < _n_src; i++) {
@@ -546,9 +621,20 @@ void OtaManager::handleAdv(const uint8_t* m, uint16_t n) {
if (_sources[i].last_ms < _sources[lru].last_ms) lru = i;
}
bool fresh = (slot < 0);
if (fresh) { slot = (_n_src < OTA_MAX_SOURCES) ? _n_src++ : lru; _sources[slot] = Source{}; }
if (fresh) {
// Passive HAVE traffic can leave rows for a source before its beacon is retained. Start its advertised
// digest with a clean association, and purge the evicted source when the fixed source table is full.
invalidateCatalogSeeder(a.seeder_id);
if (_n_src < OTA_MAX_SOURCES) slot = _n_src++;
else {
invalidateCatalogSeeder(_sources[lru].seeder);
slot = lru;
}
_sources[slot] = Source{};
}
Source& s = _sources[slot];
bool changed = fresh || memcmp(s.digest, a.set_digest, 4) != 0;
if (changed && !fresh) invalidateCatalogSeeder(s.seeder);
memcpy(s.seeder, a.seeder_id, 4); memcpy(s.digest, a.set_digest, 4);
s.n_motas = a.n_motas; s.last_ms = _now_ms;
if (changed) {
@@ -627,6 +713,13 @@ void OtaManager::handleHave(const uint8_t* m, uint16_t n) {
if (hv.frag_total == 0 || hv.frag_total > OTA_HAVE_MAX_FRAGMENTS || hv.frag_idx >= hv.frag_total) return;
bool have_sid = (_seeder_id[0] | _seeder_id[1] | _seeder_id[2] | _seeder_id[3]) != 0;
if (have_sid && memcmp(hv.seeder_id, _seeder_id, 4) == 0) return; // our own catalog
// Once a newer beacon changed this source's digest, delayed HAVE fragments from its old set must not
// resurrect rows we just invalidated. Unknown sources remain cacheable because HAVE is broadcast/passive.
for (uint8_t i = 0; i < _n_src; i++) {
if (memcmp(_sources[i].seeder, hv.seeder_id, 4) != 0) continue;
if (memcmp(_sources[i].digest, hv.set_digest, 4) != 0) return;
break;
}
// PASSIVE: every node caches rows it overhears. A source is catalogued only after EVERY advertised
// fragment arrived; otherwise a timed recovery QUERY asks for just the missing bitmap.
for (uint8_t i = 0; i < _n_src; i++) {
@@ -667,17 +760,30 @@ void OtaManager::handleHave(const uint8_t* m, uint16_t n) {
catalog[slot] = CatRow{};
memcpy(catalog[slot].mid, mid, 4);
memcpy(catalog[slot].seeders[0], hv.seeder_id, 4);
catalog[slot].seeder_have[0] = (uint16_t)have_count;
catalog[slot].seeder_last_ms[0] = _now_ms;
catalog[slot].n_seeders = 1;
} else {
CatRow& cc = catalog[slot]; // count DISTINCT sources (no double-count)
bool known = false;
int known = -1;
for (uint8_t k = 0; k < cc.n_seeders; k++)
if (memcmp(cc.seeders[k], hv.seeder_id, 4) == 0) { known = true; break; }
if (!known && cc.n_seeders < OTA_CAT_SEEDERS) memcpy(cc.seeders[cc.n_seeders++], hv.seeder_id, 4);
if (memcmp(cc.seeders[k], hv.seeder_id, 4) == 0) { known = k; break; }
if (known < 0 && cc.n_seeders < OTA_CAT_SEEDERS) {
known = cc.n_seeders++;
memcpy(cc.seeders[known], hv.seeder_id, 4);
}
if (known >= 0) {
cc.seeder_have[known] = (uint16_t)have_count;
cc.seeder_last_ms[known] = _now_ms;
}
}
CatRow& c = catalog[slot];
c.target_id = target; c.fw_version = fwver; c.codec = codec; c.flags = flags; c.last_ms = _now_ms;
if (have_count > c.have_max) c.have_max = have_count; // best-known progress among sources
c.target_id = target; c.fw_version = fwver; c.codec = codec; c.flags = flags;
c.have_max = 0; c.last_ms = 0;
for (uint8_t k = 0; k < c.n_seeders; k++) {
if (c.seeder_have[k] > c.have_max) c.have_max = c.seeder_have[k];
if (c.seeder_last_ms[k] > c.last_ms) c.last_ms = c.seeder_last_ms[k];
}
if (wantRow(mid, target, codec, flags)) startFetch(mid, target);
}
}
@@ -833,17 +939,79 @@ bool OtaManager::blockInPipeline(uint32_t block) const {
return findReassemblySlot(block) >= 0;
}
bool OtaManager::fetchActive() const {
return _fstate == FETCHING || _fstate == WANT_MANIFEST || _fstate == WANT_LEAVES
|| _fstate == VERIFYING_STAGED || _fstate == PAUSED;
}
void OtaManager::clearFetchIntent() {
_desired_target = 0;
_have_desired_mid = false;
memset(_desired_mid, 0, sizeof(_desired_mid));
}
void OtaManager::failFetch(FetchError error) {
_fetch_error = error;
_fstate = FAILED;
clearReassembly();
freeLeaves();
_validate = false;
_archive_fetch = false;
clearFetchIntent();
}
void OtaManager::completeFetch() {
_fetch_error = FETCH_ERROR_NONE;
_fstate = COMPLETE;
_validate = false;
_archive_fetch = false;
clearFetchIntent();
}
OtaManager::PullResult OtaManager::pull(const uint8_t* mid, uint32_t target, bool validate) {
if (!mid) return PULL_BAD_MID;
if (!_fetch) return PULL_NO_STORE;
if (fetchActive()) return PULL_BUSY;
_archive_fetch = false;
_desired_target = target;
memcpy(_desired_mid, mid, sizeof(_desired_mid));
_have_desired_mid = true;
reDiscover();
PullResult result = startFetch(mid, target, validate);
if (result != PULL_STARTED && result != PULL_RESUMED) clearFetchIntent();
return result;
}
OtaManager::PullResult OtaManager::pull_archive(const uint8_t* mid, uint32_t target, bool validate) {
if (!mid) return PULL_BAD_MID;
if (!_fetch) return PULL_NO_STORE;
if (fetchActive()) return PULL_BUSY;
_archive_fetch = true;
_desired_target = target;
memcpy(_desired_mid, mid, sizeof(_desired_mid));
_have_desired_mid = true;
reDiscover();
PullResult result = startFetch(mid, target, validate);
if (result != PULL_STARTED && result != PULL_RESUMED) {
_archive_fetch = false;
clearFetchIntent();
}
return result;
}
// Begin (or resume) fetching a chosen mid: try a staged-partial resume first, else request the manifest.
void OtaManager::startFetch(const uint8_t* mid, uint32_t target, bool validate) {
OtaManager::PullResult OtaManager::startFetch(const uint8_t* mid, uint32_t target, bool validate) {
(void)target;
if (!_fetch || _fstate == FETCHING || _fstate == WANT_MANIFEST
|| _fstate == WANT_LEAVES || _fstate == VERIFYING_STAGED
|| _fstate == PAUSED) return;
if (!mid) return PULL_BAD_MID;
if (!_fetch) return PULL_NO_STORE;
if (fetchActive()) return PULL_BUSY;
_fetch_error = FETCH_ERROR_NONE;
_validate = validate; // motatool folder-capture warm-start (seed leaf-diff)
// A validate pull is a FRESH seed capture, not a resume: the store already holds the seed's payload (not a
// real partial), so never adopt it via resumeStaged - always re-begin and run the manifest->leaves->diff.
if (!validate && resumeStaged(mid)) return; // (non-validate) resume a partial container left in flash
if (!validate && resumeStaged(mid)) return PULL_RESUMED; // adopt a partial container left in the store
memcpy(_fid, mid, 4);
_have = 0; _fbc = 0; _ftotal = 0; _fflags = 0;
_observed_path_transmissions = 0; // learn the actual source/relay path from this fetch's replies
_fstate = WANT_MANIFEST;
_mf_total = 0; _mf_mask = 0; _mf_len = 0; _mf_retries = 0; _loop_last_mfmask = 0; // fresh manifest reassembly
@@ -851,6 +1019,7 @@ void OtaManager::startFetch(const uint8_t* mid, uint32_t target, bool validate)
GetManifestMsg gm; memcpy(gm.manifest_id, _fid, 4); gm.want_mask = 0xFFFF; // first ask: send all fragments
uint8_t b[16];
emit(b, encode_get_manifest(b, sizeof(b), gm), false);
return PULL_STARTED;
}
void OtaManager::handleManifest(const uint8_t* m, uint16_t n) {
@@ -882,42 +1051,44 @@ void OtaManager::handleManifest(const uint8_t* m, uint16_t n) {
const uint8_t* mf = _mf_buf; // fully reassembled manifest-minus-leaves
uint32_t mfl = _mf_len;
if (mfl != MOTA_MFL) { _fstate = FAILED; return; } // manifest-minus-leaves is a fixed 197 bytes
if (mf[2] != HASH_ALGO_SHA256) { _fstate = FAILED; return; } // this implementation supports sha2-256 only
if (!_archive_fetch && !codecOk(mf[56])) { _fstate = IDLE; return; } // incompatible install codec
if (mfl != MOTA_MFL) { failFetch(FETCH_ERROR_MANIFEST); return; } // fixed 197-byte manifest
if (mf[2] != HASH_ALGO_SHA256) { failFetch(FETCH_ERROR_HASH_ALGO); return; }
if (!_archive_fetch && !codecOk(mf[56])) { failFetch(FETCH_ERROR_CODEC); return; }
uint32_t payload_size = rd_u32le(mf + 15);
uint8_t bsl = mf[19];
if (bsl >= 32) { _fstate = FAILED; return; }
if (bsl >= 32) { failFetch(FETCH_ERROR_GEOMETRY); return; }
uint32_t bs = 1u << bsl;
// a block must fit our reassembly buffer (and be non-empty) - reject an oversized block_size up front
if (bs == 0 || bs > OTA_MAX_BLOCK || payload_size == 0) { _fstate = FAILED; return; }
if (bs == 0 || bs > OTA_MAX_BLOCK || payload_size == 0) { failFetch(FETCH_ERROR_GEOMETRY); return; }
uint32_t bc = (payload_size + bs - 1) / bs;
if (bc > 0xFFFFu) { _fstate = FAILED; return; } // block_idx is uint16 on the wire - can't address more
if (bc > 0xFFFFu) { failFetch(FETCH_ERROR_TOO_LARGE); return; } // uint16 block index on the wire
if (_archive_fetch && (uint64_t)bc * 4 > OTA_PROOFGEN_SCRATCH) {
_fstate = FAILED; return; // retaining an image we cannot subsequently seed is useless
failFetch(FETCH_ERROR_TOO_LARGE); return; // retaining an image we cannot seed is useless
}
memcpy(_froot, mf + 20, 4);
uint32_t leaves_off = 8 + mfl;
uint32_t payload_off = leaves_off + bc * 4;
uint64_t total64 = (uint64_t)payload_off + payload_size + 5;
if (total64 > UINT32_MAX) { _fstate = FAILED; return; }
if (total64 > UINT32_MAX) { failFetch(FETCH_ERROR_TOO_LARGE); return; }
uint32_t total = (uint32_t)total64;
// Hand the store the parsed layout BEFORE begin(), so a partition-backed store (ESP32) can choose
// placement and refuse an unfittable fetch up front: a FULL payload streams to the inactive slot,
// a delta's whole container is staged together. (image_size at mf+11, is_full from flags at mf+1.)
bool is_full = (mf[1] & MFLAG_FULL) != 0;
if (!_fetch->plan_layout(is_full, rd_u32le(mf + 11), payload_off, payload_size)) { _fstate = FAILED; return; }
if (!_fetch->begin(total)) { _fstate = FAILED; return; }
if (!_fetch->plan_layout(is_full, rd_u32le(mf + 11), payload_off, payload_size)) {
failFetch(FETCH_ERROR_STORAGE); return;
}
if (!_fetch->begin(total)) { failFetch(FETCH_ERROR_STORAGE); return; }
// declare the metadata extent so a flash store can pin it (leaves are written all transfer long)
if (!_fetch->set_meta_size(payload_off)) { _fstate = FAILED; return; }
if (!_fetch->set_meta_size(payload_off)) { failFetch(FETCH_ERROR_STORAGE); return; }
uint8_t hdr[8];
memcpy(hdr, MOTA_MAGIC, 4);
wr_u32le(hdr + 4, total);
if (!_fetch->write(0, hdr, 8) ||
!_fetch->write(8, mf, mfl) ||
!_fetch->write(total - 5, MOTA_TRAILER, 5)) { _fstate = FAILED; return; }
!_fetch->write(total - 5, MOTA_TRAILER, 5)) { failFetch(FETCH_ERROR_STORAGE); return; }
_fflags = mf[1]; // manifest flags (FULL/SIGNED) of the fetch in progress (auto-install gate)
_fpoff = payload_off; _floff = leaves_off; _fpsize = payload_size; _fbc = bc; _fbs = bs;
@@ -1004,8 +1175,9 @@ void OtaManager::diffStep() {
OTA_DBG("OTA: leaf-diff %u/%u already valid; fetching the rest\n", (unsigned)_have, (unsigned)_fbc);
freeLeaves(); // clears _diffing + frees the buffer
if (_have >= _fbc) { // seed covered the whole image
_fstate = storedLeavesRootMatches() && _fetch->finalize()
? COMPLETE : FAILED;
if (!storedLeavesRootMatches()) failFetch(FETCH_ERROR_INTEGRITY);
else if (!_fetch->finalize()) failFetch(FETCH_ERROR_STORAGE);
else completeFetch();
return;
}
_fstate = FETCHING; requestMissing();
@@ -1045,6 +1217,7 @@ bool OtaManager::resumeStaged(const uint8_t* want_mid) {
_ftotal = total;
clearReassembly();
_observed_path_transmissions = 0;
_fetch_error = FETCH_ERROR_NONE;
beginStagedVerification();
OTA_DBG("OTA: RESUME verifying %u blocks total=%u\n",
(unsigned)bc, (unsigned)total);
@@ -1097,7 +1270,7 @@ void OtaManager::verifyStagedStep() {
const uint32_t index = _resume_verify_idx;
if (!_fetch->read(_floff + index * 4,
stored_leaf, sizeof(stored_leaf))) {
_fstate = FAILED;
failFetch(FETCH_ERROR_STORAGE);
return;
}
if (memcmp(stored_leaf, missing_leaf, sizeof(stored_leaf)) == 0) {
@@ -1106,7 +1279,7 @@ void OtaManager::verifyStagedStep() {
const uint32_t length = blockLen(index);
if (!_fetch->read(_fpoff + index * _fbs, _reasm[0].buf, length)) {
_fstate = FAILED;
failFetch(FETCH_ERROR_STORAGE);
return;
}
uint8_t computed_leaf[4];
@@ -1116,14 +1289,14 @@ void OtaManager::verifyStagedStep() {
// requests this block again; a stale marker must never bless bad bytes.
if (!_fetch->write(_floff + index * 4,
missing_leaf, sizeof(missing_leaf))) {
_fstate = FAILED;
failFetch(FETCH_ERROR_STORAGE);
return;
}
_resume_invalidated = true;
continue;
}
if (!_resume_merkle.add(computed_leaf)) {
_fstate = FAILED;
failFetch(FETCH_ERROR_INTEGRITY);
return;
}
_have++;
@@ -1135,10 +1308,13 @@ void OtaManager::verifyStagedStep() {
if (_have == _fbc) {
uint8_t root[4];
_fstate = _resume_merkle.finish(root)
&& memcmp(root, _froot, sizeof(root)) == 0
&& _fetch->finalize()
? COMPLETE : FAILED;
if (!_resume_merkle.finish(root) || memcmp(root, _froot, sizeof(root)) != 0) {
failFetch(FETCH_ERROR_INTEGRITY);
} else if (!_fetch->finalize()) {
failFetch(FETCH_ERROR_STORAGE);
} else {
completeFetch();
}
} else {
_fstate = FETCHING;
requestMissing();
@@ -1201,6 +1377,7 @@ bool OtaManager::handleProof(const uint8_t* m, uint16_t n) {
uint8_t leaf[4]; merkle_leaf(leaf, slot.buf, blen);
if (!_fetch->write(_fpoff + block * _fbs, slot.buf, blen) ||
!_fetch->write(_floff + block * 4, leaf, 4)) {
_fetch_error = FETCH_ERROR_STORAGE;
_fstate = PAUSED; clearReassembly(); return true;
}
_have++;
@@ -1218,8 +1395,9 @@ bool OtaManager::handleProof(const uint8_t* m, uint16_t n) {
// Every leaf must be readable and collectively match the manifest root.
// A scratch allocation/read failure is an integrity failure, never success.
clearReassembly();
_fstate = storedLeavesRootMatches() ? COMPLETE : FAILED;
if (_fstate == COMPLETE && !_fetch->finalize()) _fstate = FAILED;
if (!storedLeavesRootMatches()) failFetch(FETCH_ERROR_INTEGRITY);
else if (!_fetch->finalize()) failFetch(FETCH_ERROR_STORAGE);
else completeFetch();
OTA_DBG("OTA: transfer %s\n", _fstate == COMPLETE ? "COMPLETE" : "FAILED(integrity/storage)");
return true;
}
@@ -1361,7 +1539,7 @@ void OtaManager::loop() {
// the link and burn the retry cap while fragments are still arriving (mirrors FETCHING + WANT_LEAVES).
// Give up after a cap of stalled retries so an unreachable mid doesn't pin the single fetch slot forever.
if (_mf_mask == _loop_last_mfmask) {
if (++_mf_retries > OTA_MANIFEST_MAX_RETRY) { _fstate = FAILED; return; }
if (++_mf_retries > OTA_MANIFEST_MAX_RETRY) { failFetch(FETCH_ERROR_MANIFEST_TIMEOUT); return; }
GetManifestMsg gm; memcpy(gm.manifest_id, _fid, 4);
// request only the manifest fragments still missing; 0xFFFF ("send all") until we know frag_total
gm.want_mask = (_mf_total > 0) ? (uint16_t)(frag_full_mask(_mf_total) & ~_mf_mask) : 0xFFFF;
@@ -1378,7 +1556,7 @@ void OtaManager::loop() {
// tick congests the link (and burns the retry cap) while fragments are still streaming in. On a stall,
// ask for just the missing bitmap (anti-burst); give up (FAILED) after a cap of stalled retries.
if (_lv_mask == _loop_last_lvmask) {
if (++_lv_retries > OTA_LEAVES_MAX_RETRY) { freeLeaves(); _fstate = FAILED; return; }
if (++_lv_retries > OTA_LEAVES_MAX_RETRY) { failFetch(FETCH_ERROR_LEAVES_TIMEOUT); return; }
GetLeavesMsg gl; memcpy(gl.manifest_id, _fid, 4);
gl.want_mask = (_lv_total > 0) ? (uint16_t)(frag_full_mask(_lv_total) & ~_lv_mask) : 0xFFFF;
if (gl.want_mask == 0) gl.want_mask = 0xFFFF; // safety: never send an empty request
+44 -12
View File
@@ -84,7 +84,7 @@ typedef bool (*ServeReadFn)(void* ctx, uint32_t off, uint8_t* buf, uint32_t len)
#if defined(OTA_SD_STORE)
#define OTA_MAX_SERVE 255 // protocol maximum: own fw plus up to 254 persistent SD archive entries
#else
#define OTA_MAX_SERVE 12 // mOTAs THIS node offers (own fw + external folder); == one HAVE fragment
#define OTA_MAX_SERVE 24 // own fw plus a 23-step external bridge chain (fragmented OTA_HAVE)
#endif
#endif
#ifndef OTA_MAX_SOURCE_OBJ
@@ -190,6 +190,27 @@ public:
COMPLETE, FAILED, PAUSED
};
// A manual pull is acknowledged only after the manager has actually acquired the receive slot. This
// keeps the CLI from reporting "OK pulling" when there is no store or another transfer is still active.
enum PullResult : uint8_t {
PULL_STARTED, PULL_RESUMED, PULL_NO_STORE, PULL_BUSY, PULL_BAD_MID
};
// Terminal failures remain visible in `ota status` instead of collapsing back to an indistinguishable
// IDLE/no-download state. Values are deliberately coarse: they are operator diagnostics, not wire data.
enum FetchError : uint8_t {
FETCH_ERROR_NONE,
FETCH_ERROR_MANIFEST,
FETCH_ERROR_HASH_ALGO,
FETCH_ERROR_CODEC,
FETCH_ERROR_GEOMETRY,
FETCH_ERROR_TOO_LARGE,
FETCH_ERROR_STORAGE,
FETCH_ERROR_INTEGRITY,
FETCH_ERROR_MANIFEST_TIMEOUT,
FETCH_ERROR_LEAVES_TIMEOUT
};
// Sentinel for "no block" in the reassembly / peer-REQ / recently-served slots (a real block index is
// a small uint16, so 0xFFFFFFFF is never valid).
static const uint32_t NO_BLOCK = 0xFFFFFFFFu;
@@ -247,6 +268,9 @@ public:
// Call this before releasing or overwriting the primary image's backing buffer.
void clear_primary();
uint8_t servedCount() const { return _n_serve; } // total mOTAs we offer (own fw + folder)
// Per-source enumeration accounting. `offered` is what the attached host/source reported; `advertised`
// is what fit in this build's serve registry after validation/deduplication/capacity limits.
bool sourceStats(const MotaSource* src, uint16_t& offered, uint16_t& advertised) const;
// Read-only view of one served entry (for `ota serve` listing): mid/target/fwver/codec/flags + is_self.
const ServeEntry* servedEntry(uint8_t i) const { return i < _n_serve ? &_serve[i] : nullptr; }
// 4-byte fingerprint of our served set (== the set_digest carried in the beacon); for admin OTA stats.
@@ -274,7 +298,7 @@ public:
uint32_t wanted() const { return _desired_target; }
uint32_t target() const { return _target; } // this node's own OTA target_id (set in begin)
// Pull a SPECIFIC advertised mOTA by manifest_id (e.g. `ota pull <#>` picks the one more peers have),
// Pull a SPECIFIC advertised mOTA by manifest_id (the CLI also accepts a current catalog index),
// not just any firmware for the target. mid=nullptr clears the filter (accept any mid for the target).
void want_mid(const uint8_t* mid) {
if (mid) { for (int i = 0; i < 4; i++) _desired_mid[i] = mid[i]; _have_desired_mid = true; }
@@ -287,16 +311,10 @@ public:
// `validate` enables the motatool folder-capture warm-start: bulk-fetch the target leaves, diff a seed
// build already staged in the destination, and pull DATA only for the differing blocks. Ignored unless a
// seed is present (a plain fetch just re-transfers everything). Normal P2P pulls pass false.
void pull(const uint8_t* mid, uint32_t target, bool validate = false) {
_archive_fetch = false;
want(target); want_mid(mid); startFetch(mid, target, validate);
}
PullResult pull(const uint8_t* mid, uint32_t target, bool validate = false);
// Capture an advertised container for relaying, not installation. Archive pulls accept every codec and
// target because the receiver only verifies and stores bytes; it never tries to apply the result locally.
void pull_archive(const uint8_t* mid, uint32_t target) {
_archive_fetch = true;
want(target); want_mid(mid); startFetch(mid, target, false);
}
PullResult pull_archive(const uint8_t* mid, uint32_t target, bool validate = false);
// Ask every known source for its catalog (populates `ota neighbors`). Async - rows arrive via OTA_HAVE.
void queryAll();
// Coarse clock for source/catalog ages + LRU (the Mesh adapter feeds millis; 0 in host tests is fine).
@@ -371,7 +389,10 @@ public:
// Drop the current fetch session back to IDLE so a fresh `ota pull` / advert starts a new one.
void reset_session() {
_fstate = IDLE; _have = 0; _req_count = 0; _mf_retries = 0;
_fstate = IDLE; _have = 0; _fbc = 0; _ftotal = 0; _fflags = 0;
_req_count = 0; _mf_retries = 0;
_fetch_error = FETCH_ERROR_NONE;
clearFetchIntent();
clearReassembly();
_observed_path_transmissions = 0;
_mf_total = 0; _mf_mask = 0; _mf_len = 0; _loop_last_mfmask = 0;
@@ -382,6 +403,7 @@ public:
}
FetchState fetchState() const { return _fstate; }
FetchError fetchError() const { return _fetch_error; }
uint32_t blocksHave() const { return _have; }
uint32_t blocksTotal() const { return _fbc; }
uint8_t fetchPipelineWidth() const { return _pipeline_width; }
@@ -397,6 +419,8 @@ public:
uint32_t target_id, fw_version;
uint8_t codec, flags;
uint8_t seeders[OTA_CAT_SEEDERS][4]; // distinct sources advertising this mid (deduped; capped)
uint16_t seeder_have[OTA_CAT_SEEDERS]; // progress reported by each tracked source
uint32_t seeder_last_ms[OTA_CAT_SEEDERS]; // age of each tracked source's latest HAVE
uint8_t n_seeders; // count of the above (capped at OTA_CAT_SEEDERS) - "N+ nodes have it"
uint32_t have_max; // best block-count any source reported (== total when a full copy exists)
uint32_t last_ms;
@@ -433,8 +457,13 @@ private:
bool handleData(const uint8_t* m, uint16_t n);
bool handleReqProof(const uint8_t* m, uint16_t n);
bool handleProof(const uint8_t* m, uint16_t n);
void startFetch(const uint8_t* mid, uint32_t target, bool validate = false); // begin/resume a fetch
PullResult startFetch(const uint8_t* mid, uint32_t target, bool validate = false); // begin/resume a fetch
bool wantRow(const uint8_t* mid, uint32_t target, uint8_t codec, uint8_t flags) const; // fetch this row?
bool fetchActive() const;
void clearFetchIntent();
void failFetch(FetchError error);
void completeFetch();
void invalidateCatalogSeeder(const uint8_t* seeder);
void clearReassembly(); // forget every in-flight pipeline slot
void clearReassemblySlot(uint8_t slot);
int findReassemblySlot(uint32_t block) const;
@@ -491,6 +520,8 @@ private:
ServeEntry _serve[OTA_MAX_SERVE]; // catalog (what we advertise) - entry 0 is view0
uint8_t _n_serve = 0;
MotaSource* _src_list[OTA_MAX_SOURCE_OBJ] = {nullptr};
uint16_t _src_offered[OTA_MAX_SOURCE_OBJ] = {0};
uint16_t _src_advertised[OTA_MAX_SOURCE_OBJ] = {0};
uint8_t _n_src_obj = 0;
uint8_t _src_manifest[OTA_SRC_MANIFEST_MAX]; // manifest-minus-leaves of the loaded source mota
#if defined(ESP32_PLATFORM)
@@ -520,6 +551,7 @@ private:
// fetch
OtaStore* _fetch = nullptr;
FetchState _fstate = IDLE;
FetchError _fetch_error = FETCH_ERROR_NONE;
uint8_t _fid[4] = {0};
uint8_t _froot[4] = {0};
uint32_t _ftotal = 0, _fpoff = 0, _floff = 0, _fpsize = 0, _fbc = 0, _fbs = 0;
+30
View File
@@ -13,6 +13,36 @@
#define RX_POWERSAVING_BALANCED_LEVEL 5
#define RX_POWERSAVING_PROFILE_PREAMBLE 16
// Optional initial settings for infrastructure roles. They are intentionally
// disabled unless a build profile supplies them, so upstream/default builds
// retain continuous receive. Companion has its own historical defaults below.
#ifndef DEFAULT_RXPS_ENABLED
#define DEFAULT_RXPS_ENABLED 0
#endif
#ifndef DEFAULT_RXPS_LEVEL
#if DEFAULT_RXPS_ENABLED
#define DEFAULT_RXPS_LEVEL RX_POWERSAVING_BALANCED_LEVEL
#else
#define DEFAULT_RXPS_LEVEL 0
#endif
#endif
#ifndef DEFAULT_RXPS_PREAMBLE
#if DEFAULT_RXPS_ENABLED
#define DEFAULT_RXPS_PREAMBLE RX_POWERSAVING_PROFILE_PREAMBLE
#else
#define DEFAULT_RXPS_PREAMBLE 0
#endif
#endif
#if DEFAULT_RXPS_ENABLED
#if DEFAULT_RXPS_LEVEL < 1 || DEFAULT_RXPS_LEVEL > 10
#error "DEFAULT_RXPS_LEVEL must be between 1 and 10"
#endif
#if DEFAULT_RXPS_PREAMBLE != 16 && DEFAULT_RXPS_PREAMBLE != 32
#error "DEFAULT_RXPS_PREAMBLE must be 16 or 32"
#endif
#endif
// Initial settings for companions. Build flags can override the defaults;
// roles with runtime RXPS controls persist the operator's selection afterward.
#ifndef RXPS_FIXED_ENABLED