Files
MeshCore/src/helpers/AlertFaultPolicy.h
T

297 lines
11 KiB
C++

#pragma once
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
// Pure WiFi/MQTT fault-alert policy used by AlertReporter. Keeping the edge
// detector, duration math, rate-limit floor, and message formatting here lets
// host tests drive a fake clock without Mesh, MQTTBridge, or a radio.
//
// WiFi outage state is an OutageSnapshot: down, started_ms, and the initiating
// disconnect reason. That is the only data AlertReporter passes into tick()
// and formatWifiAlert(). Last ESP-IDF DISCONNECTED event time/reason are not
// inputs — using them made "WiFi down" quote uptime and reason 8 (ASSOC_LEAVE
// from STA-backoff WiFi.disconnect()).
namespace AlertFaultPolicy {
static const uint32_t kCheckIntervalMs = 5000UL;
static const uint16_t kMinIntervalMinutes = 60;
static const uint32_t kMsPerMinute = 60000UL;
enum class State : uint8_t { OK, FIRING };
struct Fault {
State state;
uint32_t fired_at_ms; // millis() of last successful "down" send; 0 = never
uint32_t last_outage_started_ms; // remembered so recovered can quote duration
};
// Current-outage view consumed by tick() and formatWifiAlert().
// `down` is the authority: started_ms may be 0 when the STA dropped at
// millis()==0. While !down, packOutageSnapshot canonicalizes start/reason to 0.
struct OutageSnapshot {
bool down;
uint32_t started_ms;
uint8_t reason; // initiating reason; 0 = omit from the down message
};
enum class Action : uint8_t { None, FireDown, FireRecovered };
struct TickResult {
Action action;
uint32_t duration_ms;
};
static inline OutageSnapshot fromStartMs(uint32_t started_ms) {
OutageSnapshot snap{};
snap.down = started_ms != 0;
snap.started_ms = started_ms;
return snap;
}
// Single published word for the Core 0 writers (WiFi event / MQTT task) and
// the Core 1 reader (AlertReporter). 32-bit started_ms + 8-bit reason + down
// fit in 64 bits; atomic load/store of this word is the cross-task boundary.
static const uint64_t kOutageDownBit = 1ULL << 40;
static inline uint64_t packOutageSnapshot(OutageSnapshot s) {
if (!s.down) {
s.started_ms = 0;
s.reason = 0;
}
uint64_t v = (uint64_t)s.started_ms;
v |= (uint64_t)s.reason << 32;
if (s.down) v |= kOutageDownBit;
return v;
}
static inline OutageSnapshot unpackOutageSnapshot(uint64_t v) {
OutageSnapshot s{};
s.started_ms = (uint32_t)v;
s.reason = (uint8_t)(v >> 32);
s.down = (v & kOutageDownBit) != 0;
if (!s.down) {
s.started_ms = 0;
s.reason = 0;
}
return s;
}
// Status poll (handleWiFiConnection). `down` is independent of started_ms so a
// drop at millis()==0 is still down. Uses snapshot.down — not a separate
// last_connected flag — so a DISCONNECTED event that already opened the
// outage is not treated as a fresh connected→down edge on the next poll.
static inline OutageSnapshot applyWifiStatus(uint32_t now, bool connected,
OutageSnapshot cur,
bool initialized) {
if (connected) {
if (!initialized || cur.down) {
return OutageSnapshot{};
}
return cur;
}
if (!initialized || !cur.down) {
OutageSnapshot snap{};
snap.down = true;
snap.started_ms = now; // 0 is a legal start
snap.reason = cur.reason;
return snap;
}
return cur;
}
// STA_GOT_IP (and any observe-connected between 10 s polls). A disconnect +
// reconnect that never shares a status poll with the drop must still close
// the outage, or the next DISCONNECTED event keeps the first start/reason.
static inline OutageSnapshot applyWifiGotIp(OutageSnapshot /*cur*/) {
return OutageSnapshot{};
}
// ESP-IDF STA_DISCONNECTED event. Opens the outage if needed. While already
// down, the start is frozen and the first non-zero reason is kept — later
// reason 8 (ASSOC_LEAVE from WiFi.disconnect()) cannot replace it.
static inline OutageSnapshot applyWifiDisconnectEvent(uint32_t now, uint8_t reason,
OutageSnapshot cur) {
if (!cur.down) {
OutageSnapshot snap{};
snap.down = true;
snap.started_ms = now;
snap.reason = reason;
return snap;
}
if (cur.reason == 0 && reason != 0) {
cur.reason = reason;
}
return cur;
}
// Unsigned subtraction is the standard millis() idiom and remains correct
// across one 32-bit counter rollover.
static inline uint32_t elapsedMs(uint32_t now, uint32_t then) {
return now - then;
}
// Construction leaves next_check_ms at 0, so the first poll is always due.
// The signed delta matches AlertReporter's original (long)(now - next) < 0
// skip, which is wrap-safe for intervals well under 2^31 ms.
static inline bool checkDue(uint32_t now, uint32_t next_check_ms) {
return (int32_t)(now - next_check_ms) >= 0;
}
static inline uint32_t nextCheckMs(uint32_t now) {
return now + kCheckIntervalMs;
}
// Stale prefs or a future field tweak cannot drag the floor below 1 hour.
static inline uint32_t minIntervalMs(uint16_t cfg_minutes) {
uint16_t minutes = cfg_minutes < kMinIntervalMinutes ? kMinIntervalMinutes
: cfg_minutes;
return (uint32_t)minutes * kMsPerMinute;
}
static inline uint32_t thresholdMs(uint16_t minutes) {
return (uint32_t)minutes * kMsPerMinute;
}
static inline uint32_t downDurationMs(uint32_t now, const OutageSnapshot& snap) {
return snap.down ? elapsedMs(now, snap.started_ms) : 0;
}
static inline bool rateLimitAllows(uint32_t now, uint32_t fired_at_ms,
uint32_t min_interval_ms) {
// fired_at_ms == 0 means never fired since boot/config change. Treating it
// as a send at millis()==0 would suppress the first alert until uptime
// reached min_interval.
return fired_at_ms == 0 || elapsedMs(now, fired_at_ms) >= min_interval_ms;
}
// Decide whether to emit a down/recovered message. Does not mutate `f`:
// FireDown is committed only after a successful send; FireRecovered is
// committed after the send attempt (success or not), matching production.
static inline TickResult tick(const Fault& f, uint32_t now,
const OutageSnapshot& snap, uint32_t thresh_ms,
uint32_t min_interval_ms) {
TickResult result = {Action::None, 0};
if (f.state == State::OK) {
const uint32_t down_ms = downDurationMs(now, snap);
if (snap.down && down_ms >= thresh_ms &&
rateLimitAllows(now, f.fired_at_ms, min_interval_ms)) {
result.action = Action::FireDown;
result.duration_ms = down_ms;
}
} else if (!snap.down) {
result.action = Action::FireRecovered;
// FIRING always went through commitDown, so last_outage_started_ms is a
// real start — including 0 when the outage began at millis()==0.
result.duration_ms = elapsedMs(now, f.last_outage_started_ms);
}
return result;
}
static inline void commitDown(Fault& f, uint32_t now, uint32_t outage_start_ms) {
f.state = State::FIRING;
f.fired_at_ms = now;
f.last_outage_started_ms = outage_start_ms;
}
static inline void commitRecovered(Fault& f) {
f.state = State::OK;
}
static inline void reset(Fault& f) {
f.state = State::OK;
f.fired_at_ms = 0;
}
static inline void rearmIfDisabled(Fault& f) {
if (f.state == State::FIRING) f.state = State::OK;
}
static inline void formatAge(uint32_t age_ms, char* out, size_t out_size) {
if (!out || out_size == 0) return;
uint32_t secs = age_ms / 1000U;
uint32_t h = secs / 3600U;
uint32_t m = (secs % 3600U) / 60U;
if (h > 0) {
snprintf(out, out_size, "%uh%um", (unsigned)h, (unsigned)m);
} else {
snprintf(out, out_size, "%um", (unsigned)m);
}
}
static inline void formatNetworkDown(char* out, size_t out_size, const char* medium,
uint32_t duration_ms, uint8_t reason) {
if (!out || out_size == 0) return;
char age[16];
formatAge(duration_ms, age, sizeof(age));
if (reason != 0) {
snprintf(out, out_size, "%s down %s (reason %u)", medium, age, (unsigned)reason);
} else {
snprintf(out, out_size, "%s down %s", medium, age);
}
}
static inline void formatNetworkRecovered(char* out, size_t out_size,
const char* medium,
uint32_t duration_ms) {
if (!out || out_size == 0) return;
char age[16];
formatAge(duration_ms, age, sizeof(age));
snprintf(out, out_size, "%s recovered after %s", medium, age);
}
// Compatibility helpers retained for existing callers and native tests.
static inline void formatWifiDown(char* out, size_t out_size,
uint32_t duration_ms, uint8_t reason) {
formatNetworkDown(out, out_size, "WiFi", duration_ms, reason);
}
static inline void formatWifiRecovered(char* out, size_t out_size,
uint32_t duration_ms) {
formatNetworkRecovered(out, out_size, "WiFi", duration_ms);
}
static inline bool formatNetworkAlert(char* out, size_t out_size,
const char* medium, const TickResult& r,
const OutageSnapshot& snap) {
const char* label = (medium && *medium) ? medium : "Network";
if (r.action == Action::FireDown) {
formatNetworkDown(out, out_size, label, r.duration_ms, snap.reason);
return true;
}
if (r.action == Action::FireRecovered) {
formatNetworkRecovered(out, out_size, label, r.duration_ms);
return true;
}
return false;
}
// Production formatting entry: the same (TickResult, OutageSnapshot) pair
// AlertReporter feeds after tick(). Returns false when there is no message.
static inline bool formatWifiAlert(char* out, size_t out_size, const TickResult& r,
const OutageSnapshot& snap) {
return formatNetworkAlert(out, out_size, "WiFi", r, snap);
}
static inline void formatMqttDown(char* out, size_t out_size, int slot_1based,
const char* preset_name, uint32_t duration_ms) {
if (!out || out_size == 0) return;
char age[16];
formatAge(duration_ms, age, sizeof(age));
snprintf(out, out_size, "MQTT slot %d (%s) down %s", slot_1based,
preset_name ? preset_name : "?", age);
}
static inline void formatMqttRecovered(char* out, size_t out_size, int slot_1based,
const char* preset_name,
uint32_t duration_ms) {
if (!out || out_size == 0) return;
char age[16];
formatAge(duration_ms, age, sizeof(age));
snprintf(out, out_size, "MQTT slot %d (%s) recovered after %s", slot_1based,
preset_name ? preset_name : "?", age);
}
} // namespace AlertFaultPolicy