test(mqtt): extract + test remaining inline MQTT decision points (Phase 6)

Close the host-testable gaps named in Phase 6 of STABILITY_TESTABILITY_HANDOFF.md
by moving the last inline decision logic into the pure, host-tested policy seams:

- WiFi STA reconnect backoff: extract the inline ladder + wrap-safe timing from
  handleWiFiConnection() into MQTTConnectionPolicy::{wifiReconnectBackoffMs,
  wifiReconnectDue,nextWifiBackoffAttempt}. Behavior-preserving (elapsedMs is the
  wrap-safe form of the old ULONG_MAX branch); ladder/clamp/attempt-cap unchanged.
- Publication outcome pairing: name the (packet, raw) -> delivered contract as
  MQTTPacketQueuePolicy::queuedPacketPublished() and wire both queue-drain sites;
  partial success = completed, not retried.
- Freeze MQTTPublicationType enum values in a test (the bridge-side MQTTMessageType
  alignment is already enforced by a compile-time static_assert).

Adds host tests for all three (exact boundaries + millis() rollover). Native suite
green (13 dirs); non-PSRAM observer firmware smoke build compiles.

WebConfig batch/reboot/stop state-machine extraction and queue-orchestration
coverage remain open (tracked in the Phase 6 status).
This commit is contained in:
agessaman
2026-07-19 00:50:15 -07:00
parent 2e1a1410ce
commit b0cf29fb33
7 changed files with 151 additions and 15 deletions
+29
View File
@@ -86,6 +86,35 @@ 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
+11
View File
@@ -87,6 +87,17 @@ struct RetryDecision {
uint32_t next_retry_ms;
};
// A queued packet counts as delivered if EITHER its structured-packet publish
// or its raw-frame publish reached at least one slot. Partial success (one
// succeeds while the other fails or was not attempted) is still success — the
// packet completes and is not retried. This is the (packet, raw) outcome pairing
// fed to retryDecision(); naming it keeps the "partial publish = done" contract
// explicit and host-tested rather than inline in the bridge's queue drain.
static inline bool queuedPacketPublished(bool packet_published,
bool raw_published) {
return packet_published || raw_published;
}
static inline RetryDecision retryDecision(bool any_published,
uint8_t retry_attempts,
uint32_t now) {
+12 -13
View File
@@ -2334,18 +2334,17 @@ bool MQTTBridge::handleWiFiConnection(unsigned long now) {
}
}
} else if (_wifi_disconnected_time > 0) {
unsigned long disconnected_duration = now - _wifi_disconnected_time;
static const unsigned long WIFI_BACKOFF_MS[] = { 15000, 30000, 60000, 120000, 300000 };
unsigned int idx = (_wifi_reconnect_backoff_attempt < 5) ? _wifi_reconnect_backoff_attempt : 4;
unsigned long delay_ms = WIFI_BACKOFF_MS[idx];
unsigned long elapsed_since_attempt = (now >= _last_wifi_reconnect_attempt)
? (now - _last_wifi_reconnect_attempt)
: (ULONG_MAX - _last_wifi_reconnect_attempt + now + 1);
if (disconnected_duration >= delay_ms && elapsed_since_attempt >= delay_ms) {
// Backoff ladder + wrap-safe timing live in MQTTConnectionPolicy (Phase 6),
// exercised by host tests. Behavior is unchanged: both the link-down
// duration and the since-last-attempt interval must clear the current rung
// (elapsedMs is the wrap-safe form of the old ULONG_MAX branch).
if (MQTTConnectionPolicy::wifiReconnectDue(
(uint32_t)now, (uint32_t)_wifi_disconnected_time,
(uint32_t)_last_wifi_reconnect_attempt,
_wifi_reconnect_backoff_attempt)) {
_last_wifi_reconnect_attempt = now;
if (_wifi_reconnect_backoff_attempt < 5) {
_wifi_reconnect_backoff_attempt++;
}
_wifi_reconnect_backoff_attempt =
MQTTConnectionPolicy::nextWifiBackoffAttempt(_wifi_reconnect_backoff_attempt);
WiFi.disconnect();
WiFi.begin(_obs->wifi_ssid, _obs->wifi_password);
}
@@ -2664,7 +2663,7 @@ void MQTTBridge::processPacketQueue() {
raw_published = publishRaw(&queued.packet_copy);
}
bool any_published = packet_published || raw_published;
bool any_published = MQTTPacketQueuePolicy::queuedPacketPublished(packet_published, raw_published);
const MQTTPacketQueuePolicy::RetryDecision retry =
MQTTPacketQueuePolicy::retryDecision(
any_published, queued.retry_attempts,
@@ -2790,7 +2789,7 @@ void MQTTBridge::processPacketQueue() {
raw_published = publishRaw(&queued.packet_copy);
}
bool any_published = packet_published || raw_published;
bool any_published = MQTTPacketQueuePolicy::queuedPacketPublished(packet_published, raw_published);
const MQTTPacketQueuePolicy::RetryDecision retry =
MQTTPacketQueuePolicy::retryDecision(
any_published, queued.retry_attempts,