Files
firmware/src/airtime.cpp
T
Thomas Göttgens f36a1ea821 nRF52: reclaim flash to bring rak4631 back under its size budget (#11873)
* build(nrf52): drop unused TinyUSB classes and assert function-name strings

Only the CDC class is used on nRF52. Disable the MSC, HID, MIDI, vendor and
video class drivers in the Adafruit TinyUSB config, and pass an empty
__ASSERT_FUNC so assert() no longer embeds __PRETTY_FUNCTION__ strings.
File and line are still reported.

rak4631 estimate: ~7.4 KB flash, ~2.3 KB RAM.

* fix(nrf52): link only the secp256r1 cc310 curve domain

CRYS_ECPKI_GetEcDomain indexes ecDomainsFuncP, which references the
parameter tables of all eleven cc310 curves. Bluefruit LESC pairing only
requests secp256r1, so override the lookup to return that domain alone.

rak4631 estimate: ~7.4 KB flash.

* fix(airtime): replace powf in the channel-utilization EMA fold

foldChannelUtil was the only powf caller on nRF52. The exponent is an
integer step count, so raise the EMA factor by squaring instead; a
multi-day sleep still folds in at most 32 multiplications.

rak4631 estimate: ~1.9 KB flash.

* fix(graphics): use double sin/cos in the compass renderers

The compass renderers were the only sinf/cosf callers on nRF52 screen
builds, pulling in the float trig kernels next to the double ones GeoCoord
already links. Call the double variants instead.

rak4631 estimate: ~3.2 KB flash.

* fix(motion): use double atan2 for magnetometer heading fallbacks

MMC5983MA, QMC6309 and the InkHUD map centre were the remaining
application atan2f callers. The double atan2 is already linked, so the
float variant only added atan2f, __ieee754_atan2f and atanf. The saving
lands once meshtastic/Fusion#1 removes the library's atan2f as well.

rak4631 estimate: ~0.8 KB flash with Fusion#1.

* fix(hopscale): trim diagnostic logging to state changes and anomalies

Drop the save/restore confirmations, the hourly histogram and trend dumps,
the denominator step logs and the per-packet hop_limit log (printPacket
already reports HopLim). Keep the save-failure and histogram-full warnings,
the congestion on/off transition and a single periodic status line, and
remove lastScaledPerHop, which only fed the logs.

* fix(hopscale): silence cppcheck uselessAssignmentArg on restored count

* perf(crypto): use full-schedule AES128/AES256 for AES-CCM

aesSetKey used AESSmall128/AESSmall256, which re-derive round keys for
every block. AES128/AES256 precompute the schedule, encrypt faster and are
already linked by encryptAESCtr, so the AESSmall*/AESTiny* code drops out.
No change on ESP32, where AESSmall* already aliases AES128/AES256.

rak4631 estimate: ~3.9 KB flash; cipher object up to 184 bytes larger.

* perf(nrf52): use the shared software CTR for AES-256 and remove tiny-aes

CryptoCell only accelerates AES-128, which stays on hardware. AES-256 CTR
now calls CryptoEngine::encryptAESCtr (rweather CTR<AES256>, already
linked) instead of the in-tree tiny-aes copy, whose sources were removed
in the previous commit. Output is identical.

rak4631 estimate: ~0.8 KB flash.

* perf(mesh): use std::map for pending retransmissions and API port timestamps

NextHopRouter::pending and PhoneAPI::lastPortNumToRadio were the only
unordered_map instances linked on nRF52. Switching them to std::map, which
is already linked, drops the libstdc++ hashtable, rehash policy and prime
table. GlobalPacketId gains operator<; the unused hash functor is removed.

rak4631 estimate: ~2.1 KB flash.

* perf: parse sensor decimals without strtod

The WS85 serial parser (strtof) and DFRobotLarkSensor (String::toFloat)
were the only callers of newlib's strtod. Add parseDecimalFloat to
meshUtils for plain [+-]digits[.digits] fields and use it at both sites.
Covered by test_type_conversions against strtof.

rak4631 estimate: ~4.5 KB flash.

* perf(gps): compute tan from sin/cos in UTM and OSGR conversion

latLongToUTM and latLongToOSGR were the only tan callers. sin and cos are
already linked, so deriving tan from them drops tan and __kernel_tan.

rak4631 estimate: ~1.1 KB flash.

* fix(graphics): only dispatch the theme menu when TFT coloring is enabled

The Theme option is only offered with GRAPHICS_TFT_COLORING_ENABLED, but
handleMenuSwitch dispatched ThemeMenu unconditionally, linking kThemes and
the theme accessors into monochrome builds where the menu is unreachable.

rak4631 estimate: ~1 KB flash.

* fix(senxx): trim diagnostic logging to errors and user-visible actions

Keep all errors and warnings and a single version line; shorten the admin
action messages; drop progress chatter, state save/restore confirmations and
the per-reading and VOC-state debug dumps. The nested VOC restore branch
collapses to one condition with the same behaviour.

rak4631 estimate: ~2 KB flash.

* build(nrf52): define CRYPTO_AES_NO_DECRYPT

CTR and CCM only encrypt, so the AES inverse tables and round helpers are
dead code on nRF52. Takes effect once the Crypto dependency includes
meshtastic/Crypto#5.

rak4631 estimate: ~1.0 KB flash.
2026-09-17 09:36:41 +00:00

364 lines
12 KiB
C++

#include "airtime.h"
#include "NodeDB.h"
#include "UptimeClock.h"
#include "configuration.h"
#include <algorithm>
#include <assert.h>
#include <cmath>
#include <string.h>
AirTime *airTime = NULL;
AirTime *AirTime::Held::armReentryCheck(AirTime *a)
{
#ifdef AIRTIME_REENTRY_CHECK
// Before the lock: a nested take blocks forever, so a later check would never run.
assert(!a->reentryFlag);
a->reentryFlag = true;
#endif
return a;
}
AirTime::Held::~Held()
{
#ifdef AIRTIME_REENTRY_CHECK
owner->reentryFlag = false;
#else
(void)owner;
#endif
}
// --- the lock-free core -------------------------------------------------------------------------
// Every method here requires the lock, and says so in its signature. None can take it: Windows has
// no lock to reach.
void AirTime::Windows::logAirtime(reportTypes reportType, uint32_t airtime_ms, const Held &held)
{
// A packet may be logged immediately after waking from light sleep. Sync first so
// the packet is counted in the current wall-time bucket, not a stale awake-time bucket.
syncNow(held);
// The caller logs, once the lock is released.
if (reportType == TX_LOG) {
this->airtimes.periodTX[0] = this->airtimes.periodTX[0] + airtime_ms;
this->utilizationTX[this->getPeriodUtilHour(held)] += airtime_ms;
} else if (reportType == RX_LOG) {
this->airtimes.periodRX[0] = this->airtimes.periodRX[0] + airtime_ms;
} else if (reportType == RX_ALL_LOG) {
this->airtimes.periodRX_ALL[0] = this->airtimes.periodRX_ALL[0] + airtime_ms;
}
// Log all airtime type for channel utilization
this->channelUtilization[this->getPeriodUtilMinute(held)] += airtime_ms;
}
uint8_t AirTime::Windows::getPeriodUtilMinute(const Held &)
{
return (secSinceBoot / 10) % CHANNEL_UTILIZATION_PERIODS;
}
uint8_t AirTime::Windows::getPeriodUtilHour(const Held &)
{
return (secSinceBoot / 60) % MINUTES_IN_HOUR;
}
void AirTime::Windows::syncNow(const Held &held)
{
// Monotonic uptime, not RTC/network time: a user, GPS, or NTP clock change must not move
// airtime accounting. Pure read; the main loop publishes the wrap carry it derives from.
uint32_t nowSecs = Time::getUptimeSecs();
if (firstTime) {
memset(this->utilizationTX, 0, sizeof(this->utilizationTX));
memset(this->channelUtilization, 0, sizeof(this->channelUtilization));
memset(this->airtimes.periodTX, 0, sizeof(this->airtimes.periodTX));
memset(this->airtimes.periodRX, 0, sizeof(this->airtimes.periodRX));
memset(this->airtimes.periodRX_ALL, 0, sizeof(this->airtimes.periodRX_ALL));
this->secSinceBoot = nowSecs;
firstTime = false;
return;
}
if (nowSecs == this->secSinceBoot) {
return;
}
uint32_t oldSecSinceBoot = this->secSinceBoot;
this->secSinceBoot = nowSecs;
// Historical airtime reports use 1-hour buckets. If multiple hours elapsed while
// asleep, rotate each crossed bucket or clear the whole report window.
uint32_t elapsedAirtimePeriods = (this->secSinceBoot / SECONDS_PER_PERIOD) - (oldSecSinceBoot / SECONDS_PER_PERIOD);
if (elapsedAirtimePeriods >= PERIODS_TO_LOG) {
memset(this->airtimes.periodTX, 0, sizeof(this->airtimes.periodTX));
memset(this->airtimes.periodRX, 0, sizeof(this->airtimes.periodRX));
memset(this->airtimes.periodRX_ALL, 0, sizeof(this->airtimes.periodRX_ALL));
} else {
// Hand the count to runOnce() rather than tracing each crossing here: this runs under
// the lock, and a UART write would stall every other caller waiting on it.
this->rotationsPendingLog += elapsedAirtimePeriods;
for (uint32_t h = 0; h < elapsedAirtimePeriods; h++) {
for (int i = PERIODS_TO_LOG - 2; i >= 0; --i) {
this->airtimes.periodTX[i + 1] = this->airtimes.periodTX[i];
this->airtimes.periodRX[i + 1] = this->airtimes.periodRX[i];
this->airtimes.periodRX_ALL[i + 1] = this->airtimes.periodRX_ALL[i];
}
this->airtimes.periodTX[0] = 0;
this->airtimes.periodRX[0] = 0;
this->airtimes.periodRX_ALL[0] = 0;
}
}
// Channel utilization is a rolling 60-second view split into six 10-second buckets.
// Clear every bucket crossed while asleep so old airtime decays by real elapsed time.
uint32_t elapsedUtilPeriods = (this->secSinceBoot / 10) - (oldSecSinceBoot / 10);
// Fold one reading per crossed bucket, each before that bucket is cleared, so one delayed sync
// lands where the same number of 10 s syncs would have. Bounded: six clears empty the window.
const uint32_t steppedUtilPeriods = std::min<uint32_t>(elapsedUtilPeriods, CHANNEL_UTILIZATION_PERIODS);
for (uint32_t i = 1; i <= steppedUtilPeriods; i++) {
foldChannelUtil(channelUtilizationPercentRaw(held), 1, held);
this->channelUtilization[((oldSecSinceBoot / 10) + i) % CHANNEL_UTILIZATION_PERIODS] = 0;
}
// Anything past a full window is elapsed time against an already-empty ring, so it folds as
// idle in closed form rather than looping over a sleep that may have lasted days.
foldChannelUtil(0.0f, elapsedUtilPeriods - steppedUtilPeriods, held);
// TX utilization is a rolling 60-minute view used by duty-cycle checks.
uint32_t elapsedUtilTXPeriods = (this->secSinceBoot / 60) - (oldSecSinceBoot / 60);
if (elapsedUtilTXPeriods >= MINUTES_IN_HOUR) {
memset(this->utilizationTX, 0, sizeof(this->utilizationTX));
} else {
for (uint32_t i = 1; i <= elapsedUtilTXPeriods; i++) {
this->utilizationTX[((oldSecSinceBoot / 60) + i) % MINUTES_IN_HOUR] = 0;
}
}
}
bool AirTime::Windows::airtimeReport(reportTypes reportType, uint32_t *out, size_t count, const Held &held)
{
if (!out || count > PERIODS_TO_LOG)
return false;
// Reports may be requested before runOnce() executes after wake.
syncNow(held);
const uint32_t *src = nullptr;
if (reportType == TX_LOG) {
src = this->airtimes.periodTX;
} else if (reportType == RX_LOG) {
src = this->airtimes.periodRX;
} else if (reportType == RX_ALL_LOG) {
src = this->airtimes.periodRX_ALL;
}
if (!src)
return false;
memcpy(out, src, count * sizeof(*out));
return true;
}
float AirTime::Windows::channelUtilizationPercentRaw(const Held &)
{
uint32_t sum = 0;
for (uint32_t i = 0; i < CHANNEL_UTILIZATION_PERIODS; i++) {
sum += this->channelUtilization[i];
}
return (float(sum) / float(CHANNEL_UTILIZATION_PERIODS * 10 * 1000)) * 100;
}
float AirTime::Windows::channelUtilizationPercent(const Held &held)
{
// Gate decisions should see buckets that have decayed across light-sleep time.
syncNow(held);
return channelUtilizationPercentRaw(held);
}
void AirTime::Windows::foldChannelUtil(float sample, uint32_t steps, const Held &)
{
if (steps == 0)
return;
if (!hasChannelUtilSample) {
// Seed from the first reading, or a node booting onto a busy channel reports it quiet
// for a whole time constant.
channelUtilAvg = sample;
hasChannelUtilSample = true;
steps--;
}
if (steps > 0) {
// Integer power by squaring; powf would link ~1.9 KB of float libm for this one call.
float base = 1.0f - 1.0f / float(CHANNEL_UTILIZATION_EMA_DIVISOR);
float retained = 1.0f;
for (uint32_t e = steps; e != 0; e >>= 1) {
if (e & 1)
retained *= base;
base *= base;
}
channelUtilAvg = sample + (channelUtilAvg - sample) * retained;
}
}
float AirTime::Windows::smoothedChannelUtilizationPercent(const Held &held)
{
syncNow(held);
// Nothing folded yet before the first bucket crossing, and 0 would read as an idle channel
// rather than as no data.
return hasChannelUtilSample ? channelUtilAvg : channelUtilizationPercentRaw(held);
}
float AirTime::Windows::utilizationTXPercent(const Held &held)
{
// Duty-cycle checks use this value, so keep it current even outside the periodic thread.
syncNow(held);
uint32_t sum = 0;
for (uint32_t i = 0; i < MINUTES_IN_HOUR; i++) {
sum += this->utilizationTX[i];
}
return (float(sum) / float(MS_IN_HOUR)) * 100;
}
// Minutes we must be silent before sending again. Does not sync, and walks the ring as if the index
// were an age; both are wrong and both are pinned by characterisation tests. See airtime.h's TODO.
uint8_t AirTime::Windows::getSilentMinutes(float txPercent, float dutyCycle, const Held &)
{
float newTxPercent = txPercent;
for (int8_t i = MINUTES_IN_HOUR - 1; i >= 0; --i) {
newTxPercent -= ((float)this->utilizationTX[i] / (MS_IN_MINUTE * MINUTES_IN_HOUR / 100));
if (newTxPercent < dutyCycle)
return MINUTES_IN_HOUR - 1 - i;
}
return MINUTES_IN_HOUR;
}
// --- the locking shell --------------------------------------------------------------------------
// Each takes the lock exactly once and delegates. Nothing below calls another method on `this`.
void AirTime::logAirtime(reportTypes reportType, uint32_t airtime_ms)
{
{
Held held(this);
w.logAirtime(reportType, airtime_ms, held);
}
// Outside the lock: DEBUG_PORT.log() blocks on a UART write, and `lock` is a plain binary
// semaphore with no priority inheritance, so holding it here would stall the radio thread.
if (reportType == TX_LOG) {
LOG_DEBUG("Packet TX: %ums", airtime_ms);
} else if (reportType == RX_LOG) {
LOG_DEBUG("Packet RX: %ums", airtime_ms);
} else if (reportType == RX_ALL_LOG) {
LOG_DEBUG("Packet RX (noise?) : %ums", airtime_ms);
}
}
void AirTime::airtimeRotatePeriod()
{
// Preserve the public helper while keeping all rotation logic in one monotonic-time path.
Held held(this);
w.syncNow(held);
}
bool AirTime::airtimeReport(reportTypes reportType, uint32_t *out, size_t count)
{
Held held(this);
return w.airtimeReport(reportType, out, count, held);
}
uint32_t AirTime::getSecondsSinceBoot()
{
// Keep HTTP/debug reporting aligned with the same monotonic clock used by the buckets.
Held held(this);
w.syncNow(held);
return w.secSinceBoot;
}
float AirTime::channelUtilizationPercent()
{
Held held(this);
return w.channelUtilizationPercent(held);
}
float AirTime::smoothedChannelUtilizationPercent()
{
Held held(this);
return w.smoothedChannelUtilizationPercent(held);
}
float AirTime::utilizationTXPercent()
{
Held held(this);
return w.utilizationTXPercent(held);
}
// These lock like everything else, because they call the core rather than the public accessors.
// Both read under the lock and warn after it, for the reason logAirtime() does.
bool AirTime::isTxAllowedChannelUtil(bool polite)
{
uint8_t percentage = (polite ? polite_channel_util_percent : max_channel_util_percent);
float utilization;
{
Held held(this);
utilization = w.channelUtilizationPercent(held);
}
if (utilization < percentage)
return true;
LOG_WARN("Ch. util >%d%%. Skip send", percentage);
return false;
}
bool AirTime::isTxAllowedAirUtil()
{
float effectiveDutyCycle = getEffectiveDutyCycle();
if (!config.lora.override_duty_cycle && effectiveDutyCycle < 100) {
float limit = effectiveDutyCycle * polite_duty_cycle_percent / 100;
float utilization;
{
Held held(this);
utilization = w.utilizationTXPercent(held);
}
if (utilization < limit)
return true;
LOG_WARN("TX air util. >%f%%. Skip send", limit);
return false;
}
return true;
}
uint8_t AirTime::getSilentMinutes(float txPercent, float dutyCycle)
{
Held held(this);
return w.getSilentMinutes(txPercent, dutyCycle, held);
}
AirTime::AirTime() : concurrency::OSThread("AirTime") {}
int32_t AirTime::runOnce()
{
uint32_t rotations;
{
Held held(this);
w.syncNow(held);
rotations = w.rotationsPendingLog;
w.rotationsPendingLog = 0;
}
// Outside the lock, for the reason logAirtime() gives. Any caller can cross an hour, but only
// this thread reports it, so a crossing raised elsewhere is traced at most one tick late.
if (rotations > 0) {
LOG_DEBUG("Rotate airtimes, crossed %u hour(s)", rotations);
}
return (1000 * 1);
}