Files
MeshCore/src/helpers/MQTTConnectionPolicy.h
T
mikecarper 5af7b73ff9 Add safe ESP32 transports and Indicator UI
Reserve board-specific GPIOs, make ESP-NOW channel/format handling configurable, and coordinate WiFi, BLE, NTP, MQTT, and host actions safely.

Add the Indicator message page, exclusive transport selector, dynamic 480/320 rendering, larger transport typography, pairing cleanup, and corresponding tests and documentation.
2026-08-31 14:29:42 -07:00

317 lines
13 KiB
C++

#pragma once
#include <atomic>
#include <stdint.h>
// Pure timing and state-transition policy used by MQTTBridge's connection
// maintenance loop. Keeping these decisions independent of Arduino, WiFi, and
// the MQTT client lets host tests exercise the exact production policy with a
// deterministic clock.
namespace MQTTConnectionPolicy {
static const uint32_t kReconnectGuardMs = 15000UL;
static const uint32_t kStableResetMs = 120000UL;
static const uint32_t kCircuitBreakerProbeMs = 1800000UL;
static const uint32_t kRenewalThrottleMs = 60000UL;
static const uint32_t kSlotStaggerMs = 3000UL;
static const uint32_t kNtpRefreshIntervalMs = 86400000UL; // 24 hours
static const uint32_t kNtpRetryMs = 5000UL;
static const uint8_t kMaxFailuresAtMaxBackoff = 3;
static const uint32_t kDefaultJwtLifetimeSecs = 86400UL;
static const uint32_t kMaxJwtStaggerSecs = 300UL;
static const uint32_t kMinimumValidEpoch = 1000000000UL;
static const uint32_t kJwtReconnectSafetyMarginSecs = 60UL;
static const uint32_t kJwtClockThreshold = 1735689600UL; // 2025-01-01 UTC
// A wall clock at or past this instant was set from a real source (NTP or an
// admin); anything earlier is the firmware's unset-clock default (1715770351,
// 15 May 2024), so a delta spanning the sync is meaningless.
static const uint32_t kSyncedClockEpoch = kJwtClockThreshold;
// Unsigned subtraction is intentionally used: it is the standard millis()
// idiom and remains correct across a single 32-bit counter rollover.
static inline uint32_t elapsedMs(uint32_t now, uint32_t then) {
return now - then;
}
// A pending short retry takes precedence over the normal daily schedule. The
// signed deadline comparison is safe because the retry is only five seconds
// away; the daily path uses unsigned elapsed time and therefore survives one
// millis() rollover as well.
static inline bool ntpRefreshDue(uint32_t now, uint32_t last_sync,
uint32_t retry_at) {
if (retry_at != 0) {
return static_cast<int32_t>(now - retry_at) >= 0;
}
return elapsedMs(now, last_sync) >= kNtpRefreshIntervalMs;
}
static inline uint32_t ntpRetryAt(uint32_t now) {
const uint32_t retry_at = now + kNtpRetryMs;
// Zero is the "no retry scheduled" sentinel, so move that one rollover
// collision forward by one millisecond.
return retry_at == 0 ? 1 : retry_at;
}
// WiFi events can run on a different task from MQTT maintenance. Keep the
// callback to a single atomic edge latch: the MQTT task consumes the edge only
// after WiFi is connected and makes every clock/scheduler decision itself.
// Coalescing multiple GOT_IP events is intentional. Once the boot sync has
// succeeded, reconnects leave the explicit 24-hour deadline unchanged.
class NtpReconnectLatch {
public:
NtpReconnectLatch() : _pending(false) {}
void noteGotIp() {
_pending.store(true, std::memory_order_release);
}
bool consumeIfConnected(bool wifi_connected) {
if (!wifi_connected) return false;
return _pending.exchange(false, std::memory_order_acq_rel);
}
private:
std::atomic<bool> _pending;
};
static inline bool reconnectGuardActive(uint32_t now, uint32_t last_reconnect) {
return elapsedMs(now, last_reconnect) < kReconnectGuardMs;
}
static inline bool stableConnection(uint32_t now, uint32_t connected_at) {
return connected_at != 0 && elapsedMs(now, connected_at) >= kStableResetMs;
}
static inline uint32_t reconnectBackoffMs(uint8_t reconnect_backoff) {
static const uint32_t kBackoffMs[] = {
10000UL, 30000UL, 60000UL, 120000UL, 300000UL
};
const uint8_t index = reconnect_backoff < 5 ? reconnect_backoff : 4;
return kBackoffMs[index];
}
static inline uint32_t reconnectDelayMs(uint8_t reconnect_backoff, uint8_t slot_index) {
return reconnectBackoffMs(reconnect_backoff) +
static_cast<uint32_t>(slot_index) * kSlotStaggerMs;
}
static inline bool reconnectDue(uint32_t now, uint32_t last_attempt,
uint8_t reconnect_backoff, uint8_t slot_index) {
return elapsedMs(now, last_attempt) >= reconnectDelayMs(reconnect_backoff, slot_index);
}
struct BackoffAdvance {
uint8_t reconnect_backoff;
uint8_t max_backoff_failures;
bool circuit_breaker_tripped;
bool should_reconnect;
};
// Advance the ladder immediately before a due reconnect. The first visit to
// the 300-second rung changes level 4 to the saturated marker 5. Three later
// failures at that rung trip the breaker; the third does not launch another
// connection attempt.
static inline BackoffAdvance advanceBackoff(uint8_t reconnect_backoff,
uint8_t max_backoff_failures) {
BackoffAdvance result = {
reconnect_backoff, max_backoff_failures, false, true
};
if (result.reconnect_backoff < 5) {
result.reconnect_backoff++;
return result;
}
if (result.max_backoff_failures < UINT8_MAX) {
result.max_backoff_failures++;
}
if (result.max_backoff_failures >= kMaxFailuresAtMaxBackoff) {
result.circuit_breaker_tripped = true;
result.should_reconnect = false;
}
return result;
}
static inline bool circuitBreakerProbeDue(uint32_t now, uint32_t last_attempt) {
return elapsedMs(now, last_attempt) >= kCircuitBreakerProbeMs;
}
// WiFi station reconnect backoff. The bridge drives its own STA reconnect loop
// separate from the per-slot MQTT reconnects, with a slightly longer first rung
// (15 s vs the slot ladder's 10 s). Extracted from handleWiFiConnection() so the
// ladder and its wrap-safe timing are exercised by host tests instead of a
// second inline copy of the backoff math.
static inline uint32_t wifiReconnectBackoffMs(uint8_t attempt) {
static const uint32_t kBackoffMs[] = {
15000UL, 30000UL, 60000UL, 120000UL, 300000UL
};
const uint8_t index = attempt < 5 ? attempt : 4;
return kBackoffMs[index];
}
// A reconnect is due only once the link has been down for the current rung AND
// no attempt has been made within that rung (both measured wrap-safely). This
// mirrors the two-part guard the bridge applied inline.
static inline bool wifiReconnectDue(uint32_t now, uint32_t disconnected_since,
uint32_t last_attempt, uint8_t attempt) {
const uint32_t delay = wifiReconnectBackoffMs(attempt);
return elapsedMs(now, disconnected_since) >= delay &&
elapsedMs(now, last_attempt) >= delay;
}
// The attempt counter climbs to 5 and then saturates; the index clamp in
// wifiReconnectBackoffMs() holds it at the 300 s rung.
static inline uint8_t nextWifiBackoffAttempt(uint8_t attempt) {
return attempt < 5 ? static_cast<uint8_t>(attempt + 1) : attempt;
}
// Each later slot expires up to five percent of the base lifetime earlier,
// capped at five minutes per slot. Runtime slot indexes are bounded by the
// persisted MQTT slot count; the final clamp also prevents underflow if this
// helper is used with unexpected input.
static inline uint32_t jwtLifetimeSecs(uint32_t base_lifetime, uint8_t slot_index) {
uint32_t per_slot_stagger = base_lifetime / 20UL;
if (per_slot_stagger > kMaxJwtStaggerSecs) {
per_slot_stagger = kMaxJwtStaggerSecs;
}
uint64_t stagger = static_cast<uint64_t>(slot_index) * per_slot_stagger;
if (stagger > base_lifetime) {
stagger = base_lifetime;
}
return base_lifetime - static_cast<uint32_t>(stagger);
}
static inline uint32_t renewalBufferSecs(uint32_t lifetime_secs) {
uint32_t buffer = lifetime_secs / 10UL;
if (buffer < 60UL) buffer = 60UL;
if (buffer > 300UL) buffer = 300UL;
return buffer;
}
static inline bool tokenNeedsRenewal(bool time_synced, uint32_t current_time,
uint32_t token_expires_at,
uint32_t renewal_buffer_secs) {
if (!time_synced) {
return token_expires_at == 0;
}
if (token_expires_at < kMinimumValidEpoch) {
return true;
}
if (current_time >= token_expires_at) {
return true;
}
return current_time >= token_expires_at - renewal_buffer_secs;
}
// Reuse credentials only when their validity is known to outlast the next
// handshake. Uncertain credentials are refreshed before reconnecting.
static inline bool canReuseJwtForReconnect(bool time_synced, bool has_token,
bool force_mint,
uint32_t current_time,
uint32_t token_expires_at,
uint32_t renewal_buffer_secs) {
const uint32_t floor_secs =
renewal_buffer_secs > UINT32_MAX - kJwtReconnectSafetyMarginSecs
? UINT32_MAX
: renewal_buffer_secs + kJwtReconnectSafetyMarginSecs;
return time_synced && has_token && !force_mint &&
token_expires_at >= kMinimumValidEpoch &&
current_time < token_expires_at &&
(token_expires_at - current_time) > floor_secs;
}
static inline bool renewalAttemptAllowed(uint32_t now, uint32_t last_attempt) {
return elapsedMs(now, last_attempt) >= kRenewalThrottleMs;
}
static inline bool jwtClockAvailable(bool ntp_synced, uint32_t current_time) {
return ntp_synced || current_time >= kJwtClockThreshold;
}
// How a given MQTT slot fares at bridge setup on this hardware. MQTTBridge's
// setup loop iterates runtime slots in index order and connects the first
// `max_active` *enabled* slots, skipping the rest (each WSS/TLS link needs
// ~40 KB internal heap, so non-PSRAM caps at 2 concurrent, PSRAM at 5). Slots at
// or beyond the runtime array size (`slot_count`, e.g. 3 on non-PSRAM) are never
// iterated at all. Extracted so the CLI can tell the operator, at
// `set mqttN.preset` time, whether a slot will actually come up -- and so the
// exact rule is host-tested rather than hand-reasoned (it is easy to conflate
// slot_count with max_active).
enum class SlotActivation : uint8_t {
Connects, // enabled and within the concurrent-connection budget
Disabled, // slot has no preset ("none") -- not attempted
BeyondArray, // index >= slot_count: outside the runtime slot array here
OverActiveCap, // enabled, but lower-numbered slots already fill the budget
};
// `enabled` must have at least `slot_count` entries; `slot` is 0-based. Mirrors
// the bridge's first-come-by-index activation order exactly.
static inline SlotActivation classifySlotActivation(int slot, const bool* enabled,
int slot_count, int max_active) {
if (slot < 0) return SlotActivation::Disabled;
if (slot >= slot_count) return SlotActivation::BeyondArray;
if (enabled == nullptr || !enabled[slot]) return SlotActivation::Disabled;
int rank = 0; // this slot's position among enabled slots, counting by index
for (int i = 0; i <= slot; i++) {
if (enabled[i]) rank++;
}
return (rank <= max_active) ? SlotActivation::Connects
: SlotActivation::OverActiveCap;
}
enum class StaleTokenAction : uint8_t {
Defer,
Reconnect,
Bounce,
KeepAlive,
};
// A failed mint must not reconnect with credentials that a clock correction
// just invalidated. A live session only needs a bounce when its broker enforces
// token expiration on the existing connection.
static inline StaleTokenAction classifyStaleToken(bool minted, bool connected,
bool broker_enforces_exp) {
if (!minted) return StaleTokenAction::Defer;
if (!connected) return StaleTokenAction::Reconnect;
return broker_enforces_exp ? StaleTokenAction::Bounce
: StaleTokenAction::KeepAlive;
}
enum class ClockSource : uint8_t {
None,
System,
Rtc,
};
// RTCClock carries Unix seconds in a uint32_t. Validate the signed system
// time before converting it: time(nullptr) uses -1 for failure, which would
// otherwise become UINT32_MAX and look newer than every minimum-epoch check.
// Reject values beyond the RTC wire/storage range as well instead of wrapping
// them into an apparently plausible earlier date.
static inline bool checkedRtcEpoch(int64_t candidate,
uint32_t min_valid_epoch,
uint32_t& accepted) {
accepted = 0;
if (candidate < 0
|| static_cast<uint64_t>(candidate) > UINT32_MAX) {
return false;
}
const uint32_t converted = static_cast<uint32_t>(candidate);
if (converted < min_valid_epoch) return false;
accepted = converted;
return true;
}
// Prefer a plausible system clock, then an RTC. Server validation may not use
// a local clock as evidence that the requested NTP host answered.
static inline ClockSource chooseFallbackClock(bool validating_server,
uint32_t system_time,
uint32_t rtc_time,
uint32_t min_valid_epoch) {
if (validating_server) return ClockSource::None;
if (system_time >= min_valid_epoch) return ClockSource::System;
if (rtc_time >= min_valid_epoch) return ClockSource::Rtc;
return ClockSource::None;
}
} // namespace MQTTConnectionPolicy