Add persistent LoRa OTA speed control and harden timing

Add get/set ota.speed and OTA CLI aliases for factors from 0.05 to 3,
defaulting to the existing pace. Persist the setting independently of the
OTA workspace and apply it to OTA packet pacing, relays, discovery and
adverts while retaining the configured airtime budget.

Preserve physical proof/retry allowances, account for both participating
radio profiles, keep optional deadlines armed across millis rollover, and
exclude intentional pacing from observer stale-packet age. Keep local
verification at its normal rate and retain a valid saved speed if backup
recovery cannot finish. Add documentation and CI regression coverage.

Validation: 1,491 native tests and 26 CLI/integration tests passed. Built
ESP32 full repeater, ESP32 full Companion, nRF52 full Companion and STM32
USB Companion successfully, including their memory/size checks.
This commit is contained in:
mikecarper committed 2026-09-15 06:16:29 -07:00
1 parent a07f39c6dd
commit b2f2b5bf02
22 files changed
+1032 -36

No files matched your search

+7
View File
@@ -25,6 +25,7 @@
#if defined(ENABLE_OTA)
#include "ota/OtaCli.h"
#include "ota/OtaContext.h" // persist/sync OTA policy + signer allowlist with NodePrefs
#include "ota/OtaSpeedConfig.h"
#endif
#ifndef BRIDGE_MAX_BAUD
@@ -734,6 +735,9 @@ static void formatSnrDbX4Short(char* dest, size_t dest_len, int16_t snr_x4) {
void CommonCLI::loadPrefs(FILESYSTEM* fs) {
_radio_profiles.begin(fs, _callbacks->getProfileRadio(), _rtc, true);
#if defined(ENABLE_OTA)
mesh::ota::beginSpeedConfig(fs);
#endif
const bool display_settings_loaded = mesh::ui::loadDisplayPowerSettings(fs, false);
(void)display_settings_loaded;
bool is_fresh_install = false;
@@ -2467,6 +2471,9 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re
if (mesh::wireless::control().handle(command, reply, 160, millis(),
_callbacks->wirelessCommandSource(sender_timestamp))) return;
if (_radio_profiles.handle(command, reply)) return;
#if defined(ENABLE_OTA)
if (mesh::ota::handleSpeedCommand(command, reply, 160)) return;
#endif
if (strncmp(command, "set tempradio ", 14) == 0) {
handleCommand(sender_timestamp, command + 4, reply);
return;
+9
View File
@@ -104,6 +104,15 @@ public:
return StaticPoolPacketManager::peekNextOutbound(now);
}
bool deferOutboundForPacing(mesh::Packet* packet, uint32_t now, uint32_t scheduled_for) override {
if (!StaticPoolPacketManager::deferOutbound(packet, scheduled_for)) return false;
uint32_t previous;
if ((int32_t)(scheduled_for - now) > 0 && lookupAge(packet, &previous)) {
recordAge(packet, previous + (scheduled_for - now));
}
return true;
}
mesh::Packet* getNextDroppedOutbound() override {
return _dropped.removeByIdx(0);
}
+8 -3
View File
@@ -1,5 +1,6 @@
#include "OtaCli.h"
#include "OtaContext.h"
#include "OtaSpeedConfig.h"
#include "FolderMotaStore.h" // `ota pull <id> folder` destination (set_mid on the connected folder store)
#include "OtaVerify.h"
#include "OtaSelf.h"
@@ -152,11 +153,14 @@ static bool is_cmd(const char* a, const char* names, const char** rest) {
static bool handle_dev(const char* d, char* reply, OtaContext& c);
bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board) {
if (handleSpeedCommand(command, reply, 160)) return true;
const char* a = command + 3;
if (*a != 0 && *a != ' ') return false;
while (*a == ' ') a++;
if (!ota_acquire_context(reply, 160)) return true;
OtaContext& c = ota_ctx();
c.manager.set_clock(millis());
c.manager.set_speed(speedFactor());
const char* rest = a;
// ---- raw / internal primitives, tucked under `ota dev ...` ----
@@ -1003,9 +1007,10 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
sprintf(reply, "OK OTA reach = %ld hop%s (saved)%s", h, h == 1 ? "" : "s", h == 0 ? " - direct only" : "");
} else { // show current policy
uint8_t af = c.manager.autofetch();
char speed[16]; formatSpeed(speed, sizeof(speed));
#if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE)
bool cache_ready = c.ensureSdCache();
sprintf(reply, "ota config: cache=%s/%u autofetch=%s autoinstall=%s checkpoint=%u advert=%umin hops=%u keys=%u",
snprintf(reply, 160, "ota config: speed=%sx cache=%s/%u autofetch=%s autoinstall=%s checkpoint=%u advert=%umin hops=%u keys=%u", speed,
cache_ready ? (c.sd_cache.autoCaptureEnabled() ? "on" : "off") : "unavailable",
cache_ready ? (unsigned)c.sd_cache.capturedCount() : 0,
af == OtaManager::AUTOFETCH_ANY ? "any" : af == OtaManager::AUTOFETCH_SIGNED ? "signed" : "off",
@@ -1014,11 +1019,11 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
(unsigned)c.manager.max_hops(), (unsigned)c.allow.count());
#else
#if defined(OTA_SEEDER_ONLY)
sprintf(reply, "ota config: mode=seeder-only autofetch=off autoinstall=off checkpoint=%u advert=%umin hops=%u",
snprintf(reply, 160, "ota config: speed=%sx mode=seeder-only autofetch=off autoinstall=off checkpoint=%u advert=%umin hops=%u", speed,
(unsigned)c.manager.checkpoint_blocks(), (unsigned)c.manager.advert_mins(),
(unsigned)c.manager.max_hops());
#else
sprintf(reply, "ota config: autofetch=%s autoinstall=%s checkpoint=%u advert=%umin hops=%u keys=%u (persisted)",
snprintf(reply, 160, "ota config: speed=%sx autofetch=%s autoinstall=%s checkpoint=%u advert=%umin hops=%u keys=%u (persisted)", speed,
af == OtaManager::AUTOFETCH_ANY ? "any" : af == OtaManager::AUTOFETCH_SIGNED ? "signed" : "off",
c.autoinstall == OtaContext::AUTOINSTALL_TRUSTED ? "trusted" : "off",
(unsigned)c.manager.checkpoint_blocks(), (unsigned)c.manager.advert_mins(),
+50 -17
View File
@@ -21,6 +21,37 @@ OtaManager::~OtaManager() {
#endif
}
bool OtaManager::set_speed(float speed) {
if (!validSpeed(speed)) return false;
if (_speed == speed) return true;
const float ratio = speed / _speed;
// Retry and proof-turnaround deadlines retain their physical allowance.
const float retry_ratio = (speed < 1.0f ? speed : 1.0f) / (_speed < 1.0f ? _speed : 1.0f);
auto rescale = [&](uint32_t& deadline, float change, bool optional = false) {
if ((int32_t)(deadline - _now_ms) > 0) {
const double remaining = ceil((double)(deadline - _now_ms) / change);
const uint32_t delay = remaining >= INT32_MAX ? INT32_MAX : (uint32_t)remaining;
deadline = optional ? armedDeadline(_now_ms, delay) : _now_ms + delay;
}
};
for (uint8_t i = 0; i < _n_manifest_jobs; ++i) rescale(_manifest_jobs[i].ready_at, ratio);
for (uint8_t i = 0; i < _n_serve_jobs; ++i) {
if (_serve_jobs[i].proof_ready_at) rescale(_serve_jobs[i].proof_ready_at, retry_ratio, true);
}
for (uint8_t i = 0; i < _n_src; ++i) {
if (_sources[i].query_pending) rescale(_sources[i].query_at, ratio);
if (_sources[i].query_retry_at) rescale(_sources[i].query_retry_at, retry_ratio, true);
}
_speed = speed;
// A live change must not treat a response train paced at the old speed as
// lost, or immediately exhaust manifest/leaf retries on the next tick.
noteFetchActivity();
for (auto& slot : _reasm) {
if (slot.awaiting_proof) slot.proof_request_at = armedDeadline(_now_ms, proofGraceMs());
}
return true;
}
bool OtaManager::expandCatalog() {
if (_catalog_heap || OTA_INLINE_CATALOG >= OTA_MAX_CATALOG) return false;
CatRow* expanded = static_cast<CatRow*>(malloc(sizeof(CatRow) * OTA_MAX_CATALOG));
@@ -336,7 +367,7 @@ void OtaManager::handleQuery(const uint8_t* m, uint16_t n) {
}
if (q.want_fragments == 0 || (missing != 0 && (q.want_fragments & missing) == missing)) {
s.query_pending = false;
s.query_retry_at = _now_ms + OTA_CATALOG_RETRY_MS;
s.query_retry_at = armedDeadline(_now_ms, retryDelay(OTA_CATALOG_RETRY_MS));
}
}
if (_n_serve == 0 || memcmp(q.seeder_id, _seeder_id, 4) != 0) return; // (2) only WE answer queries to us
@@ -480,16 +511,16 @@ bool OtaManager::queueManifestJob(const uint8_t* mid, uint16_t want_mask) {
}
uint32_t OtaManager::manifestEgressGapMs() const {
if (_radio_packet_airtime_ms == 0) return OTA_MANIFEST_EGRESS_MIN_GAP_MS;
if (_radio_packet_airtime_ms == 0) return pacedDelay(OTA_MANIFEST_EGRESS_MIN_GAP_MS);
uint64_t gap = (uint64_t)_radio_packet_airtime_ms * _tx_spacing_permille;
gap = (gap + 999u) / 1000u;
if (gap < OTA_MANIFEST_EGRESS_MIN_GAP_MS) gap = OTA_MANIFEST_EGRESS_MIN_GAP_MS;
if (gap > OTA_MANIFEST_EGRESS_MAX_GAP_MS) gap = OTA_MANIFEST_EGRESS_MAX_GAP_MS;
return (uint32_t)gap;
return pacedDelay((uint32_t)gap);
}
uint32_t OtaManager::proofEgressGapMs() const {
if (_radio_packet_airtime_ms == 0) return OTA_PROOF_EGRESS_MIN_GAP_MS;
if (_radio_packet_airtime_ms == 0) return retryDelay(OTA_PROOF_EGRESS_MIN_GAP_MS);
// The adapter may have one response transmitting and two more admitted. Start this timer when the final
// DATA is accepted, so cover all three packet-service intervals before opening the legacy-request turn.
uint64_t gap = (uint64_t)_radio_packet_airtime_ms * _tx_spacing_permille
@@ -497,7 +528,9 @@ uint32_t OtaManager::proofEgressGapMs() const {
gap = (gap + 999u) / 1000u;
if (gap < OTA_PROOF_EGRESS_MIN_GAP_MS) gap = OTA_PROOF_EGRESS_MIN_GAP_MS;
if (gap > OTA_PROOF_EGRESS_MAX_GAP_MS) gap = OTA_PROOF_EGRESS_MAX_GAP_MS;
return (uint32_t)gap;
// This is a physical queue-drain/legacy-response allowance, not an optional
// transmit delay. Faster settings cannot make those packets finish earlier.
return retryDelay((uint32_t)gap);
}
void OtaManager::clearPendingEgress() {
@@ -709,7 +742,7 @@ void OtaManager::serviceEgress() {
(_serve_wire_len + fragment_data - 1u) / fragment_data);
job.pending_mask &= valid_mask;
if (job.pending_mask == 0) {
job.proof_ready_at = _now_ms + proofEgressGapMs();
job.proof_ready_at = armedDeadline(_now_ms, proofEgressGapMs());
return;
}
uint8_t fragment = 0;
@@ -752,7 +785,7 @@ void OtaManager::serviceEgress() {
// proactive proof into the radio queue at the same instant: the two half-duplex transmissions would
// collide and force the receiver onto its multi-second retry tick. Include every response that the
// adapter can already have admitted ahead of this proof, then retain a bounded fast-link floor.
job.proof_ready_at = _now_ms + proofEgressGapMs();
job.proof_ready_at = armedDeadline(_now_ms, proofEgressGapMs());
}
}
return;
@@ -884,7 +917,7 @@ void OtaManager::scheduleQuery(const uint8_t* seeder, const uint8_t* digest) {
if (s.query_pending || s.have_catalog ||
(s.query_owned && s.query_retry_at && (int32_t)(_now_ms - s.query_retry_at) < 0)) return;
uint32_t j = (rd_u32le(seeder) ^ rd_u32le(digest) ^ rd_u32le(_seeder_id)) % OTA_QUERY_SPREAD_MS;
s.query_at = _now_ms + OTA_QUERY_MIN_MS + j;
s.query_at = _now_ms + pacedDelay(OTA_QUERY_MIN_MS + j);
s.query_pending = true;
s.query_owned = true;
return;
@@ -909,7 +942,7 @@ void OtaManager::queryAll() {
s.query_pending = false;
s.query_owned = true;
s.query_retries = 0;
s.query_retry_at = _now_ms + OTA_CATALOG_RETRY_MS;
s.query_retry_at = armedDeadline(_now_ms, retryDelay(OTA_CATALOG_RETRY_MS));
uint32_t want = 0;
if (s.have_total) {
uint32_t full = s.have_total >= 32 ? UINT32_MAX : ((1UL << s.have_total) - 1);
@@ -953,7 +986,7 @@ void OtaManager::handleHave(const uint8_t* m, uint16_t n) {
s.query_retries = 0;
s.query_retry_at = 0;
} else {
s.query_retry_at = _now_ms + OTA_CATALOG_RETRY_MS;
s.query_retry_at = armedDeadline(_now_ms, retryDelay(OTA_CATALOG_RETRY_MS));
}
}
for (uint8_t r = 0; r < hv.n_rows && hv.rows; r++) {
@@ -1123,7 +1156,7 @@ static uint8_t count_mask_bits(uint16_t mask) {
}
uint32_t OtaManager::fetchRetryTimeoutMs() const {
if (_radio_packet_airtime_ms == 0) return OTA_FETCH_RETRY_FALLBACK_MS;
if (_radio_packet_airtime_ms == 0) return retryDelay(OTA_FETCH_RETRY_FALLBACK_MS);
// Estimate every packet still owed in the current flight, plus the recovery request itself. DATA uses
// the measured maximum packet airtime; smaller REQ/PROOF packets are deliberately overestimated. Each
@@ -1150,11 +1183,11 @@ uint32_t OtaManager::fetchRetryTimeoutMs() const {
service += OTA_FETCH_RETRY_GUARD_MS;
if (service < OTA_FETCH_RETRY_MIN_MS) service = OTA_FETCH_RETRY_MIN_MS;
if (service > OTA_FETCH_RETRY_MAX_MS) service = OTA_FETCH_RETRY_MAX_MS;
return (uint32_t)service;
return retryDelay((uint32_t)service);
}
uint32_t OtaManager::proofGraceMs() const {
if (_radio_packet_airtime_ms == 0) return OTA_PROOF_GRACE_MS;
if (_radio_packet_airtime_ms == 0) return retryDelay(OTA_PROOF_GRACE_MS);
uint32_t path_transmissions = _observed_path_transmissions;
if (path_transmissions == 0) path_transmissions = (uint32_t)_max_hops + 1u;
uint64_t grace = (uint64_t)_radio_packet_airtime_ms * path_transmissions;
@@ -1162,7 +1195,7 @@ uint32_t OtaManager::proofGraceMs() const {
grace += 100u;
if (grace < OTA_PROOF_GRACE_MS) grace = OTA_PROOF_GRACE_MS;
if (grace > OTA_FETCH_RETRY_MAX_MS) grace = OTA_FETCH_RETRY_MAX_MS;
return (uint32_t)grace;
return retryDelay((uint32_t)grace);
}
void OtaManager::finishFlight() {
@@ -1756,7 +1789,7 @@ bool OtaManager::handleData(const uint8_t* m, uint16_t n) {
// can arrive without another request/response turn. An older server, or a lost proof, falls back through
// serviceEgress() to the existing REQ_PROOF wire message.
slot.awaiting_proof = true;
slot.proof_request_at = _now_ms + proofGraceMs();
slot.proof_request_at = armedDeadline(_now_ms, proofGraceMs());
return true;
}
@@ -1969,7 +2002,7 @@ void OtaManager::loop() {
want = full & ~s.have_mask;
}
sendQuery(s.seeder, s.digest, 0, want);
s.query_retry_at = _now_ms + OTA_CATALOG_RETRY_MS;
s.query_retry_at = armedDeadline(_now_ms, retryDelay(OTA_CATALOG_RETRY_MS));
} else if (s.query_owned && !s.have_catalog && !s.query_pending && s.query_retry_at &&
(int32_t)(_now_ms - s.query_retry_at) >= 0 && s.query_retries < OTA_CATALOG_MAX_RETRY) {
uint32_t want = 0;
@@ -1979,7 +2012,7 @@ void OtaManager::loop() {
}
s.query_retries++;
sendQuery(s.seeder, s.digest, 0, want);
s.query_retry_at = _now_ms + OTA_CATALOG_RETRY_MS;
s.query_retry_at = armedDeadline(_now_ms, retryDelay(OTA_CATALOG_RETRY_MS));
}
}
if (_fstate == VERIFYING_STAGED) {
+13
View File
@@ -4,6 +4,7 @@
#include <stddef.h>
#include "OtaFormat.h"
#include "OtaMemoryPolicy.h"
#include "OtaTiming.h"
#include "OtaProtocol.h"
#include "OtaByteIO.h"
#include "OtaStore.h"
@@ -386,6 +387,17 @@ public:
void queryAll();
// Coarse clock for source/catalog ages + LRU (the Mesh adapter feeds millis; 0 in host tests is fine).
void set_clock(uint32_t ms) { _now_ms = ms; }
bool set_speed(float speed);
float speed() const { return _speed; }
uint32_t pacedDelay(uint32_t ms) const { return scaleDelay(ms, _speed); }
// Faster pacing cannot shorten a loss-recovery deadline below the existing
// physical packet-flight allowance. Slower pacing expands that allowance.
uint32_t retryDelay(uint32_t ms) const { return scaleDelay(ms, _speed < 1.0f ? _speed : 1.0f); }
uint32_t loopIntervalMs(uint32_t normal_ms) const {
// Resume verification and local leaf comparisons do no radio waiting.
return _fstate == VERIFYING_STAGED || (_fstate == WANT_LEAVES && _diffing)
? normal_ms : retryDelay(normal_ms);
}
// Feed the actual TempRadio packet airtime and dispatcher transmit-spacing factor. The fetch retry
// deadline then follows SF/BW, configured airtime budget, flight width, and observed/configured path.
void set_link_timing(uint32_t max_packet_airtime_ms, uint16_t tx_spacing_permille) {
@@ -728,6 +740,7 @@ private:
bool _flight_dirty = false; // a timeout/bad proof required recovery in this flight
uint32_t _fetch_wait_since_ms = 0; // last new fragment/proof/request; retry deadline anchor
uint32_t _radio_packet_airtime_ms = 0; // measured for MAX_TRANS_UNIT at active SF/BW
float _speed = OTA_SPEED_DEFAULT;
uint16_t _tx_spacing_permille = 2000; // 1/duty-cycle; default airtime factor 1 => 50% TX
uint8_t _observed_path_transmissions = 0; // source + relays; 0 falls back to configured max_hops+1
uint32_t _packets_sent = 0; // OTA packets accepted by the radio adapter (wrap-safe)
+132
View File
@@ -0,0 +1,132 @@
#if defined(ENABLE_OTA)
#include "OtaSpeedConfig.h"
#include <helpers/CLICommandUtils.h>
#include <helpers/PersistentStoreFormat.h>
#include <stdio.h>
#include <string.h>
namespace mesh { namespace ota {
namespace {
FILESYSTEM* settings_fs = nullptr;
float factor = OTA_SPEED_DEFAULT;
bool held = false;
constexpr size_t ImageSize = 12;
const char* const Path = "/ota_speed";
const char* const Temp = "/ota_speed.tmp";
const char* const Backup = "/ota_speed.bak";
bool readImage(const char* path, uint8_t* image) {
#if defined(NRF52_PLATFORM)
File file(*settings_fs);
if (!file.open(path, FILE_O_READ)) return false;
#elif defined(STM32_PLATFORM)
File file = settings_fs->open(path, FILE_O_READ);
#else
File file = settings_fs->open(path, "r");
#endif
if (!file) return false;
const bool complete = file.size() == ImageSize && file.read(image, ImageSize) == (int)ImageSize;
file.close();
if (!complete) return false;
float value;
memcpy(&value, image + 4, sizeof(value));
return !memcmp(image, "OS\1\0", 4) && validSpeed(value)
&& storage::readLE32(image + 8) == storage::updateCRC32(0xffffffffU, image, 8);
}
bool saveSpeed(float value) {
if (!settings_fs || held) return false;
uint8_t image[ImageSize] = {'O', 'S', 1, 0};
memcpy(image + 4, &value, sizeof(value));
storage::writeLE32(image + 8, storage::updateCRC32(0xffffffffU, image, 8));
if (settings_fs->exists(Temp) && !settings_fs->remove(Temp)) return false;
#if defined(NRF52_PLATFORM)
File file(*settings_fs);
if (!file.open(Temp, FILE_O_WRITE)) return false;
#elif defined(STM32_PLATFORM)
File file = settings_fs->open(Temp, FILE_O_WRITE);
#else
File file = settings_fs->open(Temp, "w");
#endif
if (!file) return false;
bool ok = file.write(image, sizeof(image)) == sizeof(image);
file.flush(); file.close();
uint8_t verify[ImageSize];
if (!ok || !readImage(Temp, verify) || memcmp(image, verify, sizeof(image))) return false;
if (settings_fs->exists(Backup) && !settings_fs->remove(Backup)) return false;
const bool had_primary = settings_fs->exists(Path);
if (had_primary && !settings_fs->rename(Path, Backup)) return false;
if (!settings_fs->rename(Temp, Path)) {
if (had_primary && !settings_fs->rename(Backup, Path)) held = true;
return false;
}
if (had_primary) settings_fs->remove(Backup);
factor = value; // publish only after the complete image commits
return true;
}
}
void beginSpeedConfig(FILESYSTEM* fs) {
settings_fs = fs; factor = OTA_SPEED_DEFAULT; held = false;
if (!fs) return;
uint8_t image[ImageSize];
bool loaded = readImage(Path, image);
if (!loaded && readImage(Backup, image)) {
// A verified backup is still authoritative when storage cannot repair the
// rename gap. Keep its pace instead of silently reverting to the faster 1x.
memcpy(&factor, image + 4, sizeof(factor));
if (fs->exists(Path) && !fs->remove(Path)) { held = true; return; }
if (!fs->rename(Backup, Path)) { held = true; return; }
loaded = true;
}
if (loaded) memcpy(&factor, image + 4, sizeof(factor));
else held = fs->exists(Path) || fs->exists(Backup);
}
float speedFactor() { return factor; }
void formatSpeed(char* text, size_t capacity) {
const uint32_t scaled = (uint32_t)(factor * 1000000.0f + 0.5f);
snprintf(text, capacity, "%u.%06u", (unsigned)(scaled / 1000000), (unsigned)(scaled % 1000000));
if (!capacity) return;
size_t n = strlen(text);
while (n && text[n - 1] == '0') text[--n] = 0;
if (n && text[n - 1] == '.') text[n - 1] = 0;
}
bool handleSpeedCommand(const char* command, char* reply, size_t capacity) {
const char* value = nullptr;
bool require_value = false, read_only = false;
const char* const forms[] = {"set ota.speed", "get ota.speed", "ota config speed", "ota cfg speed", "ota set speed", "ota speed"};
for (size_t i = 0; i < sizeof(forms) / sizeof(forms[0]); ++i) {
const size_t n = strlen(forms[i]);
if (!strncmp(command, forms[i], n) && (command[n] == 0 || command[n] == ' ')) {
value = command + n;
while (*value == ' ') ++value;
require_value = i == 0; read_only = i == 1;
break;
}
}
if (!value) return false;
if ((require_value && !*value) || (read_only && *value)) {
snprintf(reply, capacity, "ERR usage: set ota.speed <0.05..3> | get ota.speed");
return true;
}
if (*value) {
float parsed;
if (!cli::parseDecimalStrict(value, parsed) || !validSpeed(parsed)) {
snprintf(reply, capacity, "ERR ota.speed must be 0.05..3 (1=current speed)");
return true;
}
if (!saveSpeed(parsed)) {
snprintf(reply, capacity, "ERR ota.speed save failed; speed unchanged");
return true;
}
}
char number[16]; formatSpeed(number, sizeof(number));
snprintf(reply, capacity, *value ? "OK ota.speed=%sx (saved)" : "> ota.speed=%sx", number);
return true;
}
} }
#endif
+15
View File
@@ -0,0 +1,15 @@
#pragma once
#include <helpers/IdentityStore.h>
#include "OtaTiming.h"
namespace mesh { namespace ota {
// A small, separate settings image shared by every OTA-capable role. Reading
// or changing speed never borrows the Companion's OTA workspace.
void beginSpeedConfig(FILESYSTEM* fs);
float speedFactor();
void formatSpeed(char* text, size_t capacity);
bool handleSpeedCommand(const char* command, char* reply, size_t capacity);
} }
+71
View File
@@ -0,0 +1,71 @@
#pragma once
#include <stdint.h>
#include <math.h>
namespace mesh {
namespace ota {
constexpr float OTA_SPEED_DEFAULT = 1.0f;
constexpr float OTA_SPEED_MIN = 0.05f;
constexpr float OTA_SPEED_MAX = 3.0f;
inline bool validSpeed(float speed) {
return isfinite(speed) && speed >= OTA_SPEED_MIN && speed <= OTA_SPEED_MAX;
}
// Relative deadlines must fit the signed half of millis(), including after
// rollover. Keep zero immediate and preserve every existing delay at 1x.
inline uint32_t scaleDelay(uint32_t delay_ms, float speed) {
if (!validSpeed(speed)) speed = OTA_SPEED_DEFAULT;
const double scaled = ceil((double)delay_ms / speed);
return scaled >= INT32_MAX ? INT32_MAX : (uint32_t)scaled;
}
// For optional deadlines whose owners use zero to mean "not armed". A timer
// that lands exactly on millis rollover must still fire (one millisecond late).
inline uint32_t armedDeadline(uint32_t now, uint32_t delay_ms) {
const uint32_t deadline = now + delay_ms;
return deadline ? deadline : 1;
}
// Extra quiet time between OTA packets when slowing an otherwise immediately
// drainable queue. Actual RF airtime and the dispatcher's airtime budget remain
// unchanged. At 1x and above, the existing queue can drain at its normal rate.
inline uint32_t packetQuietTime(uint32_t airtime_ms, float speed) {
if (!validSpeed(speed) || speed >= OTA_SPEED_DEFAULT) return 0;
const uint32_t cycle = scaleDelay(airtime_ms, speed);
return cycle > airtime_ms ? cycle - airtime_ms : 0;
}
inline uint32_t relayDelay(uint32_t delay_ms, float speed) {
// DispatcherAction packs the priority above its 24-bit delay. An extreme
// slow setting must not spill into those priority bits.
const uint32_t scaled = scaleDelay(delay_ms, speed);
return scaled > 0x00ffffffU ? 0x00ffffffU : scaled;
}
// Advert intervals can reach seven days; at 0.05x that is 140 days. Count
// down in real elapsed milliseconds instead of overflowing a millis deadline.
class LongTimer {
uint64_t remaining_ = 0;
uint32_t last_ = 0;
public:
bool ready(uint32_t now) {
const uint32_t elapsed = now - last_;
last_ = now;
remaining_ = remaining_ > elapsed ? remaining_ - elapsed : 0;
return remaining_ == 0;
}
void arm(uint32_t now, uint32_t delay_ms, float speed) {
last_ = now;
remaining_ = (uint64_t)ceil((double)delay_ms / (validSpeed(speed) ? speed : 1.0f));
}
void rescale(uint32_t now, float old_speed, float new_speed) {
ready(now);
remaining_ = (uint64_t)ceil((double)remaining_ * old_speed / new_speed);
}
};
} // namespace ota
} // namespace mesh