Merge pull request #11 from agessaman/feat/observer-alert

Implement fault alert system with region scoping and PSK handling
This commit is contained in:
Adam Gessaman
2026-05-17 21:31:55 -07:00
committed by GitHub
12 changed files with 942 additions and 11 deletions
+115
View File
@@ -0,0 +1,115 @@
# Fault Alerts (Group Channel)
This document describes MeshCore repeater fault alerts, including configuration, CLI commands, and operational behavior.
The repeater can broadcast a one-line fault notification on a configured group channel when WiFi or any active MQTT slot has been disconnected longer than a configurable threshold.
The alert is sent over **LoRa** as a `PAYLOAD_TYPE_GRP_TXT` flood packet on the configured channel (with sender = device name) - *not* over MQTT. This is intentional: the MQTT path is what's broken, so the only working delivery is the mesh itself. Anyone in radio range subscribed to the same channel/hashtag in their companion app will see the alert inline with normal channel chat.
> **A small list of community channels is intentionally NOT supported.** Fault alerts are operator-infrastructure noise - broadcasting them on shared community channels would spam every node in the area (and on `#test` / `#bot` would amplify via well-known auto-responders). The currently banned destinations are:
>
> - The well-known **Public** group PSK (`izOH6cXN6mrJ5e26oRXNcg==`)
> - **`#test`** (`sha256("#test")[0..15]`)
> - **`#bot`** (`sha256("#bot")[0..15]`)
>
> The list lives in `BANNED_ALERT_CHANNELS[]` in [src/helpers/AlertReporter.cpp](src/helpers/AlertReporter.cpp); adding a new entry is one line (label + 32 hex chars). The matcher runs at both the CLI validation step (`set alert.psk`, `set alert.hashtag`) and the alert-send path, so a saved-config bypass is still refused at runtime. You must point alerts at a **private PSK** (`set alert.psk`) or a non-banned **hashtag channel** (`set alert.hashtag`) before alerts can fire.
## Scope and routing
Alert floods ride the **repeater's default scope** by default (the same TransportKey used for adverts and channel broadcasts - set via `region default ...`). Operators can override on a per-alert-feature basis with `set alert.region <name>`:
- If `alert.region` is set and the name resolves via `RegionMap`, that region's TransportKey is used.
- If `alert.region` is unset, or the name doesn't resolve, the repeater's `default_scope` is used.
- If both are null, the alert is sent unscoped (matches the pre-scoped firmware's behavior).
`alert.region` is stored as-is - it does **not** create the region. Use `region put <name>` first if it doesn't exist.
## What triggers an alert
- **WiFi**: continuously down for at least `alert.wifi` minutes (default 30)
- **MQTT slot N**: enabled, has connected at least once since boot, and has been disconnected for at least `alert.mqtt` minutes (default 240, i.e. 4 h)
A "recovered" message is sent once when the underlying connection comes back. After firing, a fault is rate-limited by `alert.interval` (default 60 minutes) before it can re-fire - this prevents flapping links from spamming the channel.
## Defaults
| Setting | Default | Notes |
|---------|---------|-------|
| `alert` | `off` | Master enable for automatic fault alerts |
| `alert.psk` | *(unset)* | Private channel secret as **32 hex chars** (16-byte channel key) - the same format the mobile app's "Share Channel" emits, and what every other secret-shaped CLI command (e.g. `prv.key`) uses. |
| `alert.hashtag` | *(unset)* | Informational only; set via `set alert.hashtag` to pre-derive `alert.psk` from `sha256("#name")[0..15]`. Cleared when `alert.psk` is set directly. |
| `alert.region` | *(unset)* | Optional region name; overrides the repeater's `default_scope` for alert sends only. Empty = use `default_scope`. Looked up lazily via `RegionMap`; unknown names silently fall back to `default_scope`. |
| `alert.wifi` | `30` (min) | 0 disables WiFi alerts |
| `alert.mqtt` | `240` (min) | 0 disables MQTT alerts |
| `alert.interval` | `60` (min) | Minutes between repeat alerts of the same fault. **Hard floor of 60 min** so a flapping link can't spam the mesh; the CLI rejects lower values and AlertReporter clamps stale prefs at runtime. |
> `alert.psk` is unset on a fresh flash. **Alerts cannot fire and `alert test` will refuse to send until you configure either `alert.psk` directly or `alert.hashtag` (which derives one).** The sender shown on outgoing alert messages is always the node name (`set name ...`); there is no separate `alert.name`.
## CLI
Get:
- `get alert` - master on/off
- `get alert.psk` - the active 32-hex-char PSK (or `(unset)`) (**serial console only**)
- `get alert.hashtag` - the originating hashtag (or `(unset)`, e.g. after `set alert.psk` overrides the hashtag-derived key)
- `get alert.region` - alert-only scope override (or `(unset, using default scope)`)
- `get alert.wifi` / `get alert.mqtt` / `get alert.interval`
Set:
- `set alert on` / `set alert off`
- `set alert.psk <hex>` - 32 hex chars (16-byte channel secret); rejects banned channels (Public, `#test`, `#bot`). Paste the mobile app's "Share Channel" output as-is. Clears `alert.hashtag` since the new key is operator-supplied.
- `set alert.psk` (no argument) - clears both `alert.psk` and `alert.hashtag`
- `set alert.hashtag <name>` - derives the 16-byte key from `sha256("#name")` *once*, stores it as `alert.psk`, and remembers the hashtag for `get alert.hashtag`. `#` prefix is added if omitted (so `alerts` and `#alerts` are equivalent). Refuses banned hashtag names.
- `set alert.hashtag` (no argument) - clears both `alert.psk` and `alert.hashtag`
- `set alert.region <name>` - alert-only scope override (no region-map mutation; unknown names silently fall back to `default_scope`)
- `set alert.region` (no argument) - clear override, use `default_scope`
- `set alert.wifi <minutes>` (0-1440; 0 = disabled)
- `set alert.mqtt <minutes>` (0-10080; 0 = disabled)
- `set alert.interval <minutes>` (60-10080; 60-minute floor to protect mesh airtime)
Action:
- `alert test` - send a one-off `[test] alert channel ok` immediately on the configured channel; ignores `alert on/off` so operators can verify the channel before enabling fault firing. Returns an error if no channel is configured.
- `alert test <message>` - send a custom test message: `[test] <message>`.
## Example: dedicated hashtag channel (recommended for operator groups)
```bash
set alert.hashtag ops-alerts # stored as "#ops-alerts"; key = sha256("#ops-alerts")[0..15]
set alert.wifi 10 # tighter for ops monitoring
set alert.mqtt 60
set alert on
alert test
```
Anyone running a companion app and subscribed to the `#ops-alerts` hashtag channel will see the alerts inline.
## Example: dedicated alerts channel with a private PSK
Generate a 16-byte random PSK as 32 hex chars (`openssl rand -hex 16`), or use the companion app's "Add channel" feature and copy the "Share Channel" output. Then:
```bash
set alert.psk <32_hex_chars> # 16-byte channel secret; mobile "Share Channel" pastes in directly
set alert.wifi 10
set alert.mqtt 60
set alert on
alert test
```
Subscribers running a MeshCore companion app should add a channel with the same PSK; alerts will appear in that channel's chat view. (Pick any local name for it - the sender of incoming alert messages is the repeater's node name.)
## Sample messages
```
MyObserver: WiFi down 47m (reason 201)
MyObserver: WiFi recovered after 1h3m
MyObserver: MQTT slot 1 (analyzer-us) down 4h12m
MyObserver: MQTT slot 1 (analyzer-us) recovered after 4h45m
```
## Notes
- A reboot during an outage resets the timer; the alert won't double-fire because `millis()` starts at 0 at boot. The fault must persist `alert.wifi` / `alert.mqtt` minutes from boot.
- Fault state is stored in RAM only - no persistence across reboots.
- The MQTT-slot watcher uses a separate per-slot `current_outage_started_ms` field that is reset on each reconnect, distinct from the `first_disconnect_time` shown in `mqttN.diag` (which remains a "first disconnect since boot" counter for diagnostics).
- WiFi-down alerts can only be delivered if the LoRa radio is up. There is no fallback path.
- Banned channels (Public, `#test`, `#bot`) are **rejected** at both `set alert.psk` / `set alert.hashtag` and at the alert-send path, so even if you somehow set one via a saved config file, the firmware will silently refuse to broadcast on it. To add another banned channel, append a row to `BANNED_ALERT_CHANNELS[]` in [src/helpers/AlertReporter.cpp](src/helpers/AlertReporter.cpp); the format is `{ "label", "32-lowercase-hex-chars" }` (compute as `printf '#name' | openssl dgst -sha256 | cut -c1-32`).
- Alerts are sent via `sendFlood` with the resolved TransportKey codes attached, so they appear on the configured scope just like other broadcast traffic. Operators monitoring a specific region need to be subscribed to that region's scope to hear alerts.
+4 -9
View File
@@ -686,13 +686,8 @@ set timezone UTC-5 # UTC offset
Observer nodes include an optional SNMP v2c agent that exposes radio stats, MQTT connectivity, memory usage, and network information to standard monitoring tools. See [MQTT_SNMP.md](MQTT_SNMP.md) for setup and OID reference.
## Dependencies
- **PsychicMqttClient**: MQTT client library (supports WSS and direct MQTT)
- **ArduinoJson**: JSON message formatting
- **NTPClient**: Network time protocol client
- **Timezone**: Timezone conversion library (JChristensen/Timezone)
- **WiFi**: ESP32 WiFi functionality
- **Ed25519**: Cryptographic library for JWT token signing
- **JWTHelper**: Custom JWT token generation for device authentication
- **SNMP_Agent**: Optional SNMPv2c agent (0neblock/SNMP_Agent, observer builds only)
## Fault Alerts
Fault alerts broadcast LoRa group-channel notifications when WiFi or configured MQTT links stay down past configured thresholds, with optional recovery notices and rate limiting to avoid spam.
For configuration, CLI commands, examples, and operational notes, see [ALERTS.md](ALERTS.md).
+43
View File
@@ -947,6 +947,20 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc
#endif
_prefs.radio_watchdog_minutes = 5; // 5 minutes default
// Alert channel defaults — disabled by default, and the channel is left
// unconfigured so a freshly-flashed observer never broadcasts on the
// well-known Public hashtag. Operators must explicitly pick a private
// key (`set alert.psk`) or a hashtag (`set alert.hashtag`) before alerts
// can fire. The sender prefix on outgoing alert messages is always the
// node name (`set name ...`), so there's no separate `alert.name`.
_prefs.alert_enabled = 0;
_prefs.alert_psk_hex[0] = '\0';
_prefs.alert_hashtag[0] = '\0';
_prefs.alert_region[0] = '\0'; // empty = use default_scope
_prefs.alert_wifi_minutes = 30; // 30 minutes
_prefs.alert_mqtt_minutes = 240; // 4 hours
_prefs.alert_min_interval_min = 60; // re-arm window: 1 hour
// bridge defaults
_prefs.bridge_enabled = 1; // enabled
_prefs.bridge_delay = 500; // milliseconds
@@ -1074,6 +1088,15 @@ void MyMesh::begin(FILESYSTEM *fs) {
}
#endif
// Wire fault-alert reporter. begin() is safe regardless of bridge state.
// Passing `this` as the callbacks lets the reporter resolve a TransportKey
// scope (alert.region override, falling back to default_scope) so alert
// floods ride the same scope as adverts/channel messages.
_alerter.begin(&_prefs, this, this);
#if defined(WITH_MQTT_BRIDGE)
_alerter.setBridge(bridge);
#endif
radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr);
radio_driver.setTxPower(_prefs.tx_power_dbm);
@@ -1100,6 +1123,24 @@ void MyMesh::sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint3
}
}
bool MyMesh::resolveAlertScope(TransportKey& dest) {
// Prefer an explicit alert.region override; look it up lazily via
// RegionMap so the operator can name a region that doesn't exist yet
// without polluting region_map state — we just silently fall through
// to default_scope on miss.
if (_prefs.alert_region[0]) {
auto r = region_map.findByNamePrefix(_prefs.alert_region);
if (r && region_map.getTransportKeysFor(*r, &dest, 1) > 0 && !dest.isNull()) {
return true;
}
}
if (!default_scope.isNull()) {
dest = default_scope;
return true;
}
return false;
}
void MyMesh::applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) {
set_radio_at = futureMillis(2000); // give CLI reply some time to be sent back, before applying temp radio params
pending_freq = freq;
@@ -1425,6 +1466,8 @@ void MyMesh::loop() {
uptime_millis += now - last_millis;
last_millis = now;
_alerter.onLoop(now);
#ifdef WITH_SNMP
// Push radio stats to SNMP agent every 2 seconds
if (_snmp_agent.isRunning()) {
+12
View File
@@ -34,6 +34,7 @@
#endif
#include <helpers/AdvertDataHelpers.h>
#include <helpers/AlertReporter.h>
#include <helpers/ArduinoHelpers.h>
#include <helpers/ClientACL.h>
#include <helpers/CommonCLI.h>
@@ -130,6 +131,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks {
#ifdef WITH_SNMP
MeshSNMPAgent _snmp_agent;
#endif
AlertReporter _alerter;
void putNeighbour(const mesh::Identity& id, uint32_t timestamp, float snr);
uint8_t handleLoginReq(const mesh::Identity& sender, const uint8_t* secret, uint32_t sender_timestamp, const uint8_t* data, bool is_flood);
@@ -211,6 +213,10 @@ public:
// CommonCLICallbacks
void applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) override;
void onAlertConfigChanged() override { _alerter.onConfigChanged(); }
bool sendAlertText(const char* text) override { return _alerter.sendText(text); }
bool resolveAlertScope(TransportKey& dest) override;
bool formatFileSystem() override;
void sendSelfAdvertisement(int delay_millis, bool flood) override;
void updateAdvertTimer() override;
@@ -265,10 +271,16 @@ public:
bridge->setStatsSources(this, _radio, _cli.getBoard(), _ms);
#endif
bridge->begin();
#ifdef WITH_MQTT_BRIDGE
_alerter.setBridge(bridge);
#endif
}
else
{
bridge->end();
#ifdef WITH_MQTT_BRIDGE
_alerter.setBridge(nullptr);
#endif
}
}
+28
View File
@@ -675,6 +675,16 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc
_prefs.gps_interval = 0;
_prefs.advert_loc_policy = ADVERT_LOC_PREFS;
// Alert channel defaults (same as repeater; off by default and unconfigured).
// Operator must pick `set alert.psk` or `set alert.hashtag` before alerts fire.
_prefs.alert_enabled = 0;
_prefs.alert_psk_hex[0] = '\0';
_prefs.alert_hashtag[0] = '\0';
_prefs.alert_region[0] = '\0';
_prefs.alert_wifi_minutes = 30;
_prefs.alert_mqtt_minutes = 240;
_prefs.alert_min_interval_min = 60;
// bridge defaults (same as repeater)
_prefs.bridge_enabled = 1; // enabled
_prefs.bridge_delay = 500; // milliseconds
@@ -795,6 +805,24 @@ void MyMesh::sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint3
}
}
bool MyMesh::resolveAlertScope(TransportKey& dest) {
// Same resolution policy as simple_repeater: alert.region > default_scope.
// The room server doesn't currently embed an AlertReporter, but keeping
// the override in lockstep means the callback path works the same on both
// builds and we won't get caught out if/when it does.
if (_prefs.alert_region[0]) {
auto r = region_map.findByNamePrefix(_prefs.alert_region);
if (r && region_map.getTransportKeysFor(*r, &dest, 1) > 0 && !dest.isNull()) {
return true;
}
}
if (!default_scope.isNull()) {
dest = default_scope;
return true;
}
return false;
}
void MyMesh::sendFloodReply(mesh::Packet* packet, unsigned long delay_millis, uint8_t path_hash_size) {
if (recv_pkt_region && !recv_pkt_region->isWildcard()) { // if _request_ packet scope is known, send reply with same scope
TransportKey scope;
+1
View File
@@ -198,6 +198,7 @@ public:
// CommonCLICallbacks
void applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) override;
bool resolveAlertScope(TransportKey& dest) override;
bool formatFileSystem() override;
void sendSelfAdvertisement(int delay_millis, bool flood) override;
void updateAdvertTimer() override;
+313
View File
@@ -0,0 +1,313 @@
#include "AlertReporter.h"
#include <Utils.h>
#include <Packet.h>
#include <string.h>
#include <stdio.h>
// Header layout for PAYLOAD_TYPE_GRP_TXT before encryption:
// [0..3] timestamp (uint32_t LE) — also helps make packet_hash unique
// [4] TXT_TYPE_PLAIN
// [5..] "<sender>: <text>" (null-terminated by sender for legacy parsers)
#ifndef MAX_ALERT_TEXT_LEN
// Conservative ceiling: matches BaseChatMesh::MAX_TEXT_LEN (10 * 16 = 160) and
// stays under MAX_PACKET_PAYLOAD - 4(timestamp) - 1(type) - CIPHER_MAC_SIZE - 1.
#define MAX_ALERT_TEXT_LEN 160
#endif
#ifndef ALERT_TXT_TYPE_PLAIN
#define ALERT_TXT_TYPE_PLAIN 0
#endif
#ifdef MQTT_DEBUG
#include <Arduino.h>
#define ALERT_DEBUG_PRINTLN(...) Serial.printf("Alert: " __VA_ARGS__); Serial.println()
#else
#define ALERT_DEBUG_PRINTLN(...) do {} while (0)
#endif
AlertReporter::AlertReporter()
: _prefs(nullptr), _mesh(nullptr), _callbacks(nullptr),
#ifdef WITH_MQTT_BRIDGE
_bridge(nullptr),
#endif
_next_check_ms(0) {
#ifdef WITH_MQTT_BRIDGE
memset(&_wifi, 0, sizeof(_wifi));
memset(&_mqtt, 0, sizeof(_mqtt));
#endif
}
void AlertReporter::begin(NodePrefs* prefs, mesh::Mesh* mesh, CommonCLICallbacks* callbacks) {
_prefs = prefs;
_mesh = mesh;
_callbacks = callbacks;
onConfigChanged();
}
#ifdef WITH_MQTT_BRIDGE
void AlertReporter::setBridge(MQTTBridge* bridge) {
_bridge = bridge;
}
#endif
// Channels banned as fault-alert destinations. Fault alerts are noisy
// operator-infrastructure messages; routing them to community channels would
// flood every nearby companion app (and amplify via well-known auto-responder
// bots), so the firmware refuses these keys at both CLI set-time and at
// runtime in resolveChannel.
//
// Provenance for each row can be re-derived with:
// printf '#name' | openssl dgst -sha256 | cut -c1-32
// or for the Public PSK:
// echo 'izOH6cXN6mrJ5e26oRXNcg==' | base64 -d | xxd -p -c 16
//
// To ban an additional channel: append one new row; no other code changes
// required. Both the table entries and `alert_psk_hex` are 32 lowercase hex
// chars (16-byte secret), so the matcher is a direct strcmp.
struct BannedAlertChannel {
const char* label;
const char* secret_hex; // 32 lowercase hex chars (no 0x, no separators)
};
static const BannedAlertChannel BANNED_ALERT_CHANNELS[] = {
// Public group PSK ("izOH6cXN6mrJ5e26oRXNcg==")
{ "PUBLIC", "8b3387e9c5cdea6ac9e5edbaa115cd72" },
// sha256("#test")[0..15] — auto-responders in many regions
{ "#test", "9cd8fcf22a47333b591d96a2b848b73f" },
// sha256("#bot")[0..15] — generic bot channel, frequent auto-responders
{ "#bot", "eb50a1bcb3e4e5d7bf69a57c9dada211" },
};
const char* alertReporterBannedChannelMatch(const uint8_t* secret16) {
char hex[33];
mesh::Utils::toHex(hex, secret16, 16);
for (size_t i = 0; i < sizeof(BANNED_ALERT_CHANNELS) / sizeof(BANNED_ALERT_CHANNELS[0]); i++) {
if (strcmp(hex, BANNED_ALERT_CHANNELS[i].secret_hex) == 0) {
return BANNED_ALERT_CHANNELS[i].label;
}
}
return nullptr;
}
const char* alertReporterBannedChannelMatchHex(const char* psk_hex) {
if (!psk_hex || strlen(psk_hex) != 32) return nullptr;
uint8_t secret[16];
if (!mesh::Utils::fromHex(secret, 16, psk_hex)) return nullptr;
return alertReporterBannedChannelMatch(secret);
}
bool AlertReporter::resolveChannel(mesh::GroupChannel& out) const {
if (!_prefs) return false;
// alert_psk_hex is the single source of truth — `set alert.hashtag`
// pre-derives the hex-encoded PSK from sha256("#name")[0..15] at CLI time.
// Only 16-byte secrets (32 hex chars) are supported; 32-byte channel keys
// are not used anywhere in MeshCore practice and not represented in the
// banned table either.
const char* psk = _prefs->alert_psk_hex;
if (strlen(psk) != 32) return false;
memset(out.secret, 0, sizeof(out.secret));
if (!mesh::Utils::fromHex(out.secret, 16, psk)) return false;
// Belt-and-suspenders against an operator pasting a banned PSK directly
// into alert.psk, or a hashtag whose hash somehow collides with one of the
// banned 16-byte secrets (astronomically improbable, but free to check).
const char* banned = alertReporterBannedChannelMatch(out.secret);
if (banned) {
ALERT_DEBUG_PRINTLN("refused banned channel '%s' for alert", banned);
return false;
}
mesh::Utils::sha256(out.hash, sizeof(out.hash), out.secret, 16);
return true;
}
void AlertReporter::onConfigChanged() {
// Reset transient state so a config change re-arms the edge detector.
#ifdef WITH_MQTT_BRIDGE
_wifi.state = OK;
_wifi.fired_at_ms = 0;
for (size_t i = 0; i < sizeof(_mqtt) / sizeof(_mqtt[0]); i++) {
_mqtt[i].state = OK;
_mqtt[i].fired_at_ms = 0;
}
#endif
}
bool AlertReporter::sendChannel(const char* text) {
if (!_mesh || !_prefs) return false;
mesh::GroupChannel channel;
if (!resolveChannel(channel)) return false;
// Build "<sender>: <text>" plaintext payload. Sender = node name (current).
uint8_t buf[5 + MAX_ALERT_TEXT_LEN + 32];
uint32_t timestamp = _mesh->getRTCClock()->getCurrentTime();
memcpy(buf, &timestamp, 4);
buf[4] = ALERT_TXT_TYPE_PLAIN;
const char* sender = _prefs->node_name[0] ? _prefs->node_name : "node";
int n = snprintf((char*)&buf[5], MAX_ALERT_TEXT_LEN, "%s: %s", sender, text);
if (n < 0) return false;
if (n >= MAX_ALERT_TEXT_LEN) n = MAX_ALERT_TEXT_LEN - 1;
mesh::Packet* pkt = _mesh->createGroupDatagram(PAYLOAD_TYPE_GRP_TXT, channel,
buf, 5 + (size_t)n);
if (!pkt) {
ALERT_DEBUG_PRINTLN("createGroupDatagram failed (pool empty?)");
return false;
}
// Ride the repeater's default scope (or `alert.region` override) when the
// host MyMesh provides one — same path MyMesh uses for adverts and
// broadcast channel messages. Falls back to plain (unscoped) flood when
// no callbacks are wired or no scope is configured, matching the
// pre-scoped behavior on builds without RegionMap.
//
// path_hash_size must honor the repeater's configured path.hash.mode (1, 2,
// or 3-byte hashes); the Mesh.h default of 1 would silently downgrade
// observers running on 2/3-byte regional meshes.
const uint8_t path_hash_size = (uint8_t)(_prefs->path_hash_mode + 1);
TransportKey scope;
bool have_scope = _callbacks && _callbacks->resolveAlertScope(scope) && !scope.isNull();
if (have_scope) {
uint16_t codes[2];
codes[0] = scope.calcTransportCode(pkt);
codes[1] = 0;
_mesh->sendFlood(pkt, codes, 0, path_hash_size);
} else {
_mesh->sendFlood(pkt, 0, path_hash_size);
}
ALERT_DEBUG_PRINTLN("sent: %s", text);
return true;
}
bool AlertReporter::sendText(const char* text) {
// sendText() is the manual entry point (`alert test` CLI). Deliberately
// does NOT check alert_enabled so operators can verify the PSK / hashtag
// setup without enabling automatic fault firing.
if (!_prefs || !text || !*text) return false;
return sendChannel(text);
}
void AlertReporter::formatAge(unsigned long age_ms, char* out, size_t out_size) const {
unsigned long secs = age_ms / 1000UL;
unsigned long h = secs / 3600UL;
unsigned long m = (secs % 3600UL) / 60UL;
if (h > 0) {
snprintf(out, out_size, "%luh%lum", h, m);
} else {
snprintf(out, out_size, "%lum", m);
}
}
void AlertReporter::onLoop(unsigned long now_ms) {
if (!_prefs || !_prefs->alert_enabled) return;
if (!_mesh) return;
// Throttle: ~5 s cadence. The thresholds are minutes-scale so this is fine.
if ((long)(now_ms - _next_check_ms) < 0) return;
_next_check_ms = now_ms + 5000UL;
#ifdef WITH_MQTT_BRIDGE
// Clamp to a 60-minute floor regardless of what's in NodePrefs. The CLI
// already enforces this on set, but a stale prefs file or future field
// tweak shouldn't be able to drag the floor below 1 hour and let a
// flapping link spam the mesh.
uint16_t cfg_min = _prefs->alert_min_interval_min;
if (cfg_min < 60) cfg_min = 60;
unsigned long min_interval_ms = (unsigned long)cfg_min * 60000UL;
// -------- WiFi fault --------
if (_prefs->alert_wifi_minutes > 0) {
unsigned long wifi_disc_ms = MQTTBridge::getLastWifiDisconnectTime();
unsigned long wifi_conn_ms = MQTTBridge::getWifiConnectedAtMillis();
bool wifi_down = (wifi_disc_ms != 0 && wifi_conn_ms == 0);
unsigned long down_ms = wifi_down ? (now_ms - wifi_disc_ms) : 0;
unsigned long thresh_ms = (unsigned long)_prefs->alert_wifi_minutes * 60000UL;
if (_wifi.state == OK) {
if (wifi_down && down_ms >= thresh_ms &&
(now_ms - _wifi.fired_at_ms) >= min_interval_ms) {
char age[16];
formatAge(down_ms, age, sizeof(age));
uint8_t reason = MQTTBridge::getLastWifiDisconnectReason();
char text[80];
if (reason != 0) {
snprintf(text, sizeof(text), "WiFi down %s (reason %u)", age, (unsigned)reason);
} else {
snprintf(text, sizeof(text), "WiFi down %s", age);
}
if (sendChannel(text)) {
_wifi.state = FIRING;
_wifi.fired_at_ms = now_ms;
_wifi.last_outage_started_ms = wifi_disc_ms;
}
}
} else { // FIRING
if (!wifi_down) {
unsigned long total = (wifi_conn_ms != 0 && _wifi.last_outage_started_ms != 0)
? (wifi_conn_ms - _wifi.last_outage_started_ms) : 0;
char age[16];
formatAge(total, age, sizeof(age));
char text[80];
snprintf(text, sizeof(text), "WiFi recovered after %s", age);
sendChannel(text);
_wifi.state = OK;
}
}
} else if (_wifi.state == FIRING) {
_wifi.state = OK; // threshold disabled mid-fault: silently re-arm
}
// -------- MQTT slot faults --------
if (_prefs->alert_mqtt_minutes > 0 && _bridge != nullptr) {
int n = MQTTBridge::getRuntimeSlotCount();
if (n > (int)(sizeof(_mqtt) / sizeof(_mqtt[0]))) n = (int)(sizeof(_mqtt) / sizeof(_mqtt[0]));
unsigned long thresh_ms = (unsigned long)_prefs->alert_mqtt_minutes * 60000UL;
for (int i = 0; i < n; i++) {
Fault& f = _mqtt[i];
if (!_bridge->isSlotEnabledAndAttempted(i)) {
if (f.state == FIRING) f.state = OK; // slot disabled mid-fault
continue;
}
unsigned long outage_start = _bridge->getSlotCurrentOutageStartMs(i);
bool down = (outage_start != 0);
unsigned long down_ms = down ? (now_ms - outage_start) : 0;
if (f.state == OK) {
if (down && down_ms >= thresh_ms &&
(now_ms - f.fired_at_ms) >= min_interval_ms) {
char age[16];
formatAge(down_ms, age, sizeof(age));
char text[100];
snprintf(text, sizeof(text), "MQTT slot %d (%s) down %s",
i + 1, _bridge->getSlotPresetName(i), age);
if (sendChannel(text)) {
f.state = FIRING;
f.fired_at_ms = now_ms;
f.last_outage_started_ms = outage_start;
}
}
} else { // FIRING
if (!down) {
unsigned long total = (f.last_outage_started_ms != 0)
? (now_ms - f.last_outage_started_ms) : 0;
char age[16];
formatAge(total, age, sizeof(age));
char text[100];
snprintf(text, sizeof(text), "MQTT slot %d (%s) recovered after %s",
i + 1, _bridge->getSlotPresetName(i), age);
sendChannel(text);
f.state = OK;
}
}
}
}
#else
(void)now_ms;
#endif
}
+109
View File
@@ -0,0 +1,109 @@
#pragma once
#include <Arduino.h>
#include <Mesh.h>
#include "CommonCLI.h"
#ifdef WITH_MQTT_BRIDGE
#include "bridges/MQTTBridge.h"
#endif
/**
* Returns the label of a banned alert channel if \a secret16 matches one of
* the channels in the BANNED_ALERT_CHANNELS table (e.g. "PUBLIC", "#test",
* "#bot"), or nullptr otherwise. Centralized here so both AlertReporter and
* the CommonCLI `set alert.psk` / `set alert.hashtag` handlers can share one
* source of truth — adding a new banned channel is a one-line table edit.
*/
const char* alertReporterBannedChannelMatch(const uint8_t* secret16);
/**
* Convenience: hex-decodes \a psk_hex (32 lowercase/uppercase hex chars) and
* forwards to alertReporterBannedChannelMatch. Returns nullptr if not banned
* (or if the input isn't a valid 32-char hex string — only 16-byte secrets
* are present in the banned table).
*/
const char* alertReporterBannedChannelMatchHex(const char* psk_hex);
/**
* \brief Send-only group-channel "fault alert" reporter for repeater/observer
* builds.
*
* Polls WiFi and per-MQTT-slot outage timers from MQTTBridge. When any timer
* exceeds its configured threshold, floods a single PAYLOAD_TYPE_GRP_TXT
* message on the configured alert channel ("WiFi down 47m — MyObserver"),
* then arms a "recovered" message for the next state transition.
*
* The alert channel must be explicitly configured to either a private hex
* PSK (`set alert.psk`) or a hashtag name (`set alert.hashtag`); the
* well-known PUBLIC group key (and a small list of other auto-responder
* channels — see BANNED_ALERT_CHANNELS in AlertReporter.cpp) are rejected on
* purpose so fault alerts never spam community channels.
*
* Edge-triggered + rate-limited via NodePrefs::alert_min_interval_min so a
* flapping link cannot spam the channel.
*
* Designed to compile and run on any repeater build:
* - The channel-send path uses only mesh::Mesh primitives that already
* exist in the Dispatcher hierarchy (createGroupDatagram + sendFlood).
* - WiFi/MQTT polling is #ifdef WITH_MQTT_BRIDGE-gated; without it, the
* reporter still supports manual `alert test` sends.
*/
class AlertReporter {
public:
AlertReporter();
/**
* Wire up the reporter. Must be called from MyMesh::begin() after prefs
* are loaded. \a callbacks is optional — when non-null the reporter uses
* it to resolve a TransportKey scope for outgoing alert floods (so the
* packet rides the repeater's default scope or an `alert.region` override).
*/
void begin(NodePrefs* prefs, mesh::Mesh* mesh, CommonCLICallbacks* callbacks = nullptr);
#ifdef WITH_MQTT_BRIDGE
/** Bridge can be (re)created lazily; pass nullptr to detach. */
void setBridge(MQTTBridge* bridge);
#endif
/**
* Re-derive the cached GroupChannel from \a alert_psk_hex. Call from the
* CLI hot-reload hook after `set alert.psk` / `set alert.hashtag` / `set alert on|off`.
*/
void onConfigChanged();
/**
* Cooperative tick. Fast: returns immediately if disabled, throttled
* internally to ~5 s checks. Safe to call every loop().
*/
void onLoop(unsigned long now_ms);
/**
* Send an arbitrary text immediately (used by `alert test` CLI). Returns
* false when disabled, PSK invalid, or the underlying flood-send fails.
* Bypasses the rate limiter and edge logic.
*/
bool sendText(const char* text);
private:
bool resolveChannel(mesh::GroupChannel& out) const;
bool sendChannel(const char* text);
void formatAge(unsigned long age_ms, char* out, size_t out_size) const;
enum FaultState { OK, FIRING };
struct Fault {
FaultState state;
unsigned long fired_at_ms; // millis() when we last sent a "down" alert
unsigned long last_outage_started_ms; // remembered so the recovered msg can quote duration
};
NodePrefs* _prefs;
mesh::Mesh* _mesh;
CommonCLICallbacks* _callbacks;
#ifdef WITH_MQTT_BRIDGE
MQTTBridge* _bridge;
Fault _wifi;
Fault _mqtt[RUNTIME_MQTT_SLOTS];
#endif
unsigned long _next_check_ms;
};
+234 -2
View File
@@ -2,7 +2,9 @@
#include "CommonCLI.h"
#include "TxtDataHelpers.h"
#include "AdvertDataHelpers.h"
#include "AlertReporter.h" // for alertReporterBannedChannelMatch()
#include <RTClib.h>
#include <Utils.h>
#ifndef BRIDGE_MAX_BAUD
#define BRIDGE_MAX_BAUD 115200
@@ -278,7 +280,32 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) {
if (file.available() >= (int)sizeof(_prefs->radio_watchdog_minutes)) {
file.read((uint8_t *)&_prefs->radio_watchdog_minutes, sizeof(_prefs->radio_watchdog_minutes)); // 316
}
// next: 317
// Alert channel fields (appended; older files won't have them — defaults from MyMesh ctor remain)
if (file.available() >= (int)sizeof(_prefs->alert_enabled)) {
file.read((uint8_t *)&_prefs->alert_enabled, sizeof(_prefs->alert_enabled));
}
if (file.available() >= (int)sizeof(_prefs->alert_psk_hex)) {
file.read((uint8_t *)&_prefs->alert_psk_hex, sizeof(_prefs->alert_psk_hex));
}
if (file.available() >= (int)sizeof(_prefs->alert_wifi_minutes)) {
file.read((uint8_t *)&_prefs->alert_wifi_minutes, sizeof(_prefs->alert_wifi_minutes));
}
if (file.available() >= (int)sizeof(_prefs->alert_mqtt_minutes)) {
file.read((uint8_t *)&_prefs->alert_mqtt_minutes, sizeof(_prefs->alert_mqtt_minutes));
}
if (file.available() >= (int)sizeof(_prefs->alert_min_interval_min)) {
file.read((uint8_t *)&_prefs->alert_min_interval_min, sizeof(_prefs->alert_min_interval_min));
}
if (file.available() >= (int)sizeof(_prefs->alert_hashtag)) {
file.read((uint8_t *)&_prefs->alert_hashtag, sizeof(_prefs->alert_hashtag));
}
if (file.available() >= (int)sizeof(_prefs->alert_region)) {
file.read((uint8_t *)&_prefs->alert_region, sizeof(_prefs->alert_region));
}
// ensure null termination after raw read
_prefs->alert_psk_hex[sizeof(_prefs->alert_psk_hex) - 1] = '\0';
_prefs->alert_hashtag[sizeof(_prefs->alert_hashtag) - 1] = '\0';
_prefs->alert_region[sizeof(_prefs->alert_region) - 1] = '\0';
// sanitise bad pref values
_prefs->rx_delay_base = constrain(_prefs->rx_delay_base, 0, 20.0f);
@@ -401,7 +428,14 @@ void CommonCLI::savePrefs(FILESYSTEM* fs) {
file.write((uint8_t *)&_prefs->snmp_enabled, sizeof(_prefs->snmp_enabled)); // 291
file.write((uint8_t *)&_prefs->snmp_community, sizeof(_prefs->snmp_community)); // 292
file.write((uint8_t *)&_prefs->radio_watchdog_minutes, sizeof(_prefs->radio_watchdog_minutes)); // 316
// next: 317
// Alert channel fields (appended)
file.write((uint8_t *)&_prefs->alert_enabled, sizeof(_prefs->alert_enabled));
file.write((uint8_t *)&_prefs->alert_psk_hex, sizeof(_prefs->alert_psk_hex));
file.write((uint8_t *)&_prefs->alert_wifi_minutes, sizeof(_prefs->alert_wifi_minutes));
file.write((uint8_t *)&_prefs->alert_mqtt_minutes, sizeof(_prefs->alert_mqtt_minutes));
file.write((uint8_t *)&_prefs->alert_min_interval_min, sizeof(_prefs->alert_min_interval_min));
file.write((uint8_t *)&_prefs->alert_hashtag, sizeof(_prefs->alert_hashtag));
file.write((uint8_t *)&_prefs->alert_region, sizeof(_prefs->alert_region));
file.close();
}
@@ -808,6 +842,21 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re
} else if (memcmp(command, "clear stats", 11) == 0) {
_callbacks->clearStats();
strcpy(reply, "(OK - stats reset)");
} else if (memcmp(command, "alert test", 10) == 0 && (command[10] == 0 || command[10] == ' ')) {
// Send a one-off test alert on the configured alert channel.
const char* extra = command[10] == ' ' ? &command[11] : "";
char text[120];
if (*extra) {
snprintf(text, sizeof(text), "[test] %s", extra);
} else {
strcpy(text, "[test] alert channel ok");
}
if (!_prefs->alert_psk_hex[0]) {
strcpy(reply, "Error: alert channel not configured (set alert.psk or set alert.hashtag)");
} else {
bool ok = _callbacks->sendAlertText(text);
strcpy(reply, ok ? "OK - alert sent" : "Error: alert send failed (bad PSK or PUBLIC key refused?)");
}
} else if (memcmp(command, "get ", 4) == 0) {
handleGetCmd(sender_timestamp, command, reply);
} else if (memcmp(command, "set ", 4) == 0) {
@@ -1528,6 +1577,173 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep
savePrefs();
strcpy(reply, "OK");
#endif
} else if (memcmp(config, "alert ", 6) == 0) {
// set alert on|off
const char* val = &config[6];
if (memcmp(val, "on", 2) == 0 && (val[2] == 0 || val[2] == ' ')) {
_prefs->alert_enabled = 1;
savePrefs();
_callbacks->onAlertConfigChanged();
strcpy(reply, "OK - alerts on");
} else if (memcmp(val, "off", 3) == 0 && (val[3] == 0 || val[3] == ' ')) {
_prefs->alert_enabled = 0;
savePrefs();
_callbacks->onAlertConfigChanged();
strcpy(reply, "OK - alerts off");
} else {
strcpy(reply, "Error: usage set alert on|off");
}
} else if (memcmp(config, "alert.psk", 9) == 0 && (config[9] == 0 || config[9] == ' ')) {
// `set alert.psk` with no argument clears the field (alerts then disabled
// until a new psk/hashtag is configured).
const char* val = (config[9] == ' ') ? &config[10] : "";
while (*val == ' ') val++;
size_t len = strlen(val);
if (len == 0) {
_prefs->alert_psk_hex[0] = '\0';
_prefs->alert_hashtag[0] = '\0';
savePrefs();
_callbacks->onAlertConfigChanged();
strcpy(reply, "OK - alert.psk cleared (alerts disabled until configured)");
} else if (val[0] == '#') {
strcpy(reply, "Error: use 'set alert.hashtag' for hashtag channels");
} else if (len != 32) {
// 16-byte channel secret = 32 hex chars. This is what the mobile app's
// "Share Channel" emits, what `set alert.hashtag` derives, and what the
// BANNED_ALERT_CHANNELS table holds. 32-byte channels aren't used
// anywhere in MeshCore practice.
strcpy(reply, "Error: PSK must be 32 hex chars (16-byte channel secret)");
} else {
// Validate all-hex, then normalize via fromHex/toHex so the stored
// form is always lowercase regardless of input case.
uint8_t raw[16];
bool all_hex = true;
for (size_t i = 0; i < len; i++) {
if (!mesh::Utils::isHexChar(val[i])) { all_hex = false; break; }
}
if (!all_hex || !mesh::Utils::fromHex(raw, 16, val)) {
strcpy(reply, "Error: PSK must be 32 hex chars (16-byte channel secret)");
} else {
char normalized[33];
mesh::Utils::toHex(normalized, raw, 16);
if (const char* banned = alertReporterBannedChannelMatchHex(normalized)) {
// Refuse any key on the banned channel list (Public PSK, well-known
// auto-responder hashtags like #test/#bot, etc.). Fault alerts on
// those channels would spam every node in the area.
sprintf(reply, "Error: refusing banned channel '%s'; pick a private key or hashtag", banned);
} else {
StrHelper::strncpy(_prefs->alert_psk_hex, normalized, sizeof(_prefs->alert_psk_hex));
// The new PSK is operator-supplied, so any previously-derived
// hashtag name is no longer accurate provenance — drop it.
_prefs->alert_hashtag[0] = '\0';
savePrefs();
_callbacks->onAlertConfigChanged();
strcpy(reply, "OK - alert.psk updated");
}
}
}
} else if (memcmp(config, "alert.hashtag", 13) == 0 && (config[13] == 0 || config[13] == ' ')) {
const char* val = (config[13] == ' ') ? &config[14] : "";
while (*val == ' ') val++;
size_t in_len = strlen(val);
if (in_len == 0) {
_prefs->alert_psk_hex[0] = '\0';
_prefs->alert_hashtag[0] = '\0';
savePrefs();
_callbacks->onAlertConfigChanged();
strcpy(reply, "OK - alert.hashtag cleared (alerts disabled until configured)");
} else {
// Canonical stored form is "#name" because the leading '#' is part of
// the sha256 input (matching the companion-app hashtag-channel
// derivation in docs/companion_protocol.md). Accept the user typing
// either "alerts" or "#alerts".
char hashtag[sizeof(_prefs->alert_hashtag)];
size_t need = (val[0] == '#') ? in_len : in_len + 1;
if (need >= sizeof(hashtag)) {
strcpy(reply, "Error: hashtag too long");
} else {
if (val[0] == '#') {
StrHelper::strncpy(hashtag, val, sizeof(hashtag));
} else {
hashtag[0] = '#';
StrHelper::strncpy(&hashtag[1], val, sizeof(hashtag) - 1);
}
// Derive the channel key once: first 16 bytes of sha256("#name"),
// store hex-encoded in alert_psk_hex. We don't re-derive on every
// send — operators can later override with `set alert.psk` without
// leaving stale hashtag text behind.
uint8_t digest[32];
mesh::Utils::sha256(digest, sizeof(digest),
(const uint8_t*)hashtag, (int)strlen(hashtag));
if (const char* banned = alertReporterBannedChannelMatch(digest)) {
// Hashtag derives to a banned key (e.g. `set alert.hashtag test`
// hits the #test entry). Refuse before clobbering existing config.
sprintf(reply, "Error: refusing banned channel '%s'", banned);
} else {
char hex[33];
mesh::Utils::toHex(hex, digest, 16);
StrHelper::strncpy(_prefs->alert_hashtag, hashtag, sizeof(_prefs->alert_hashtag));
StrHelper::strncpy(_prefs->alert_psk_hex, hex, sizeof(_prefs->alert_psk_hex));
savePrefs();
_callbacks->onAlertConfigChanged();
sprintf(reply, "OK - alert.hashtag: %s", _prefs->alert_hashtag);
}
}
}
} else if (memcmp(config, "alert.region", 12) == 0 && (config[12] == 0 || config[12] == ' ')) {
// `set alert.region <name>` overrides the repeater's default_scope for
// alert sends only. `set alert.region` (no arg) clears it. The name is
// looked up lazily via RegionMap at send time; we deliberately don't
// mutate the region map here, so naming an unknown region is allowed
// but will silently fall back to default_scope until the operator runs
// `region put` for it.
const char* val = (config[12] == ' ') ? &config[13] : "";
while (*val == ' ') val++;
size_t len = strlen(val);
if (len == 0) {
_prefs->alert_region[0] = '\0';
savePrefs();
_callbacks->onAlertConfigChanged();
strcpy(reply, "OK - alert.region cleared (using default scope)");
} else if (len >= sizeof(_prefs->alert_region)) {
strcpy(reply, "Error: alert.region too long");
} else {
StrHelper::strncpy(_prefs->alert_region, val, sizeof(_prefs->alert_region));
StrHelper::stripSurroundingQuotes(_prefs->alert_region, sizeof(_prefs->alert_region));
savePrefs();
_callbacks->onAlertConfigChanged();
sprintf(reply, "OK - alert.region: %s", _prefs->alert_region);
}
} else if (memcmp(config, "alert.wifi ", 11) == 0) {
int mins = (int)_atoi(&config[11]);
if (mins < 0 || mins > 1440) {
strcpy(reply, "Error: alert.wifi must be 0-1440 minutes (0=off)");
} else {
_prefs->alert_wifi_minutes = (uint16_t)mins;
savePrefs();
sprintf(reply, "OK - alert.wifi %d min%s", mins, mins == 0 ? " (disabled)" : "");
}
} else if (memcmp(config, "alert.mqtt ", 11) == 0) {
int mins = (int)_atoi(&config[11]);
if (mins < 0 || mins > 10080) {
strcpy(reply, "Error: alert.mqtt must be 0-10080 minutes (0=off)");
} else {
_prefs->alert_mqtt_minutes = (uint16_t)mins;
savePrefs();
sprintf(reply, "OK - alert.mqtt %d min%s", mins, mins == 0 ? " (disabled)" : "");
}
} else if (memcmp(config, "alert.interval ", 15) == 0) {
int mins = (int)_atoi(&config[15]);
// Floor at 60 min: faster re-fires would let a flapping link spam the
// mesh with a fresh GRP_TXT flood every minute — terrible for airtime.
if (mins < 60 || mins > 10080) {
strcpy(reply, "Error: alert.interval must be 60-10080 minutes");
} else {
_prefs->alert_min_interval_min = (uint16_t)mins;
savePrefs();
sprintf(reply, "OK - alert.interval %d min", mins);
}
} else if (memcmp(config, "adc.multiplier ", 15) == 0) {
_prefs->adc_multiplier = atof(&config[15]);
if (_board->setAdcMultiplier(_prefs->adc_multiplier)) {
@@ -1835,6 +2051,22 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep
#else
strcpy(reply, "ERROR: unsupported");
#endif
} else if (memcmp(config, "alert.hashtag", 13) == 0) {
sprintf(reply, "> %s", _prefs->alert_hashtag[0] ? _prefs->alert_hashtag : "(unset)");
} else if (sender_timestamp == 0 && memcmp(config, "alert.psk", 9) == 0) { // from serial command line only
sprintf(reply, "> %s", _prefs->alert_psk_hex[0] ? _prefs->alert_psk_hex : "(unset)");
} else if (memcmp(config, "alert.region", 12) == 0) {
sprintf(reply, "> %s", _prefs->alert_region[0] ? _prefs->alert_region : "(unset, using default scope)");
} else if (memcmp(config, "alert.wifi", 10) == 0) {
sprintf(reply, "> %u min%s", (unsigned)_prefs->alert_wifi_minutes,
_prefs->alert_wifi_minutes == 0 ? " (disabled)" : "");
} else if (memcmp(config, "alert.mqtt", 10) == 0) {
sprintf(reply, "> %u min%s", (unsigned)_prefs->alert_mqtt_minutes,
_prefs->alert_mqtt_minutes == 0 ? " (disabled)" : "");
} else if (memcmp(config, "alert.interval", 14) == 0) {
sprintf(reply, "> %u min", (unsigned)_prefs->alert_min_interval_min);
} else if (memcmp(config, "alert", 5) == 0 && (config[5] == 0 || config[5] == '\n' || config[5] == '\r')) {
sprintf(reply, "> %s", _prefs->alert_enabled ? "on" : "off");
} else if (memcmp(config, "adc.multiplier", 14) == 0) {
float adc_mult = _board->getAdcMultiplier();
if (adc_mult == 0.0f) {
+37
View File
@@ -104,6 +104,25 @@ struct NodePrefs { // persisted to file
uint8_t snmp_enabled; // boolean: 0=off, 1=on
char snmp_community[24]; // community string (default "public")
uint8_t radio_watchdog_minutes; // 0=disabled, 1-120 minutes
// Fault alert channel (LoRa group-channel "observer status" message on prolonged WiFi/MQTT outage).
// Sent over the radio (NOT over MQTT) so the alert still works while the MQTT path is broken.
// All fields are appended at the end of NodePrefs for binary-compatible upgrades.
uint8_t alert_enabled; // 0 = off (default), 1 = on
char alert_psk_hex[33]; // 32 lowercase hex chars (16-byte channel secret) + null; empty = alerts disabled. Banned keys (Public/#test/#bot) are rejected.
uint16_t alert_wifi_minutes; // WiFi-down threshold in minutes (0 = disabled), default 30
uint16_t alert_mqtt_minutes; // MQTT-down threshold in minutes (0 = disabled), default 240 (4 h)
uint16_t alert_min_interval_min; // min minutes between alerts for the same fault, default 60, floor 60
// When the operator configures via `set alert.hashtag <name>`, we derive
// alert_psk_hex from sha256("#name")[0..15] once and remember the hashtag
// text here purely for `get alert.hashtag` readback. A subsequent
// `set alert.psk` clears this field so it doesn't lie about provenance.
char alert_hashtag[24];
// Optional region name (e.g. "us", "eu"); empty = use the repeater's
// default_scope. Looked up lazily via RegionMap::findByNamePrefix at send
// time, so the operator can name a region that doesn't exist yet without
// polluting region_map state. Falls back to default_scope on miss.
char alert_region[31];
};
#ifdef WITH_MQTT_BRIDGE
@@ -274,6 +293,24 @@ public:
virtual void setRxBoostedGain(bool enable) {
// no op by default
};
// Fault-alert channel hooks (see NodePrefs::alert_*). The default no-op
// implementations keep CLI commands harmless on builds that don't wire up
// an AlertReporter.
virtual void onAlertConfigChanged() {
// no op by default
}
virtual bool sendAlertText(const char* /*text*/) {
return false; // no op by default
}
// Resolve the TransportKey scope to use for outgoing fault-alert floods.
// Implementations should consult NodePrefs::alert_region first (look up via
// RegionMap), then fall back to the repeater's default_scope, then return
// false if neither yields a usable key. AlertReporter falls back to an
// unscoped flood when this returns false.
virtual bool resolveAlertScope(TransportKey& /*dest*/) {
return false; // no op by default
}
};
class CommonCLI {
+23
View File
@@ -199,6 +199,25 @@ void MQTTBridge::formatMqttStatusReply(char* buf, size_t bufsize, const NodePref
uint8_t MQTTBridge::getLastWifiDisconnectReason() { return s_wifi_disconnect_reason; }
unsigned long MQTTBridge::getLastWifiDisconnectTime() { return s_wifi_disconnect_time; }
unsigned long MQTTBridge::getSlotCurrentOutageStartMs(int slot_index) const {
if (slot_index < 0 || slot_index >= RUNTIME_MQTT_SLOTS) return 0;
return _slots[slot_index].current_outage_started_ms;
}
bool MQTTBridge::isSlotEnabledAndAttempted(int slot_index) const {
if (slot_index < 0 || slot_index >= RUNTIME_MQTT_SLOTS) return false;
const MQTTSlot& s = _slots[slot_index];
return s.enabled && s.initial_connect_done;
}
const char* MQTTBridge::getSlotPresetName(int slot_index) const {
if (slot_index < 0 || slot_index >= RUNTIME_MQTT_SLOTS) return "?";
const MQTTSlot& s = _slots[slot_index];
if (s.preset && s.preset->name) return s.preset->name;
if (!s.enabled) return MQTT_PRESET_NONE;
return MQTT_PRESET_CUSTOM;
}
const char* MQTTBridge::wifiReasonStr(uint8_t reason) {
switch (reason) {
case 2: return "auth expired";
@@ -998,6 +1017,7 @@ void MQTTBridge::initSlotClients() {
_slots[index].last_tls_stack_err = 0;
_slots[index].last_sock_errno = 0;
_slots[index].last_error_time = 0;
_slots[index].current_outage_started_ms = 0; // clear current-outage timer for AlertReporter
updateCachedConnectionStatus();
publishStatusToSlot(index);
});
@@ -1007,6 +1027,9 @@ void MQTTBridge::initSlotClients() {
if (_slots[index].first_disconnect_time == 0) {
_slots[index].first_disconnect_time = millis();
}
if (_slots[index].current_outage_started_ms == 0) {
_slots[index].current_outage_started_ms = millis();
}
_slots[index].connected = false;
updateCachedConnectionStatus();
});
+23
View File
@@ -96,6 +96,12 @@ private:
unsigned long last_error_time; // millis() of last error
uint32_t disconnect_count; // Number of disconnect callbacks since boot
unsigned long first_disconnect_time; // millis() of first disconnect after boot
// Current-outage timer (used by AlertReporter to fire faults after a sustained
// outage). Reset to 0 on each successful connect, set to millis() on first
// disconnect-after-connect. first_disconnect_time is intentionally separate
// so the existing 'mqttN.diag' "first_disc" semantics don't change.
unsigned long current_outage_started_ms;
};
MQTTSlot _slots[RUNTIME_MQTT_SLOTS];
@@ -381,6 +387,23 @@ public:
bool isReady() const;
static unsigned long getWifiConnectedAtMillis();
/**
* Per-slot outage accessors used by AlertReporter to detect prolonged
* MQTT broker outages. Indices are 0..RUNTIME_MQTT_SLOTS-1.
*
* - getSlotCurrentOutageStartMs(): millis() of the current outage start
* (0 when the slot is connected). Reset on each reconnect.
* - isSlotEnabledAndAttempted(): true when the slot is enabled (preset
* != "none") and has reached at least one connect attempt — i.e. it is
* meaningful to alarm on its connection state.
* - getSlotPresetName(): preset name for friendly status text. Returns
* "custom"/"none"/preset->name; never null.
*/
unsigned long getSlotCurrentOutageStartMs(int slot_index) const;
bool isSlotEnabledAndAttempted(int slot_index) const;
const char* getSlotPresetName(int slot_index) const;
static int getRuntimeSlotCount() { return RUNTIME_MQTT_SLOTS; }
/** Resolved origin for MQTT JSON: node_name when mqtt_origin is empty, else mqtt_origin (with quote stripping). */
static void getEffectiveMqttOrigin(const NodePrefs* prefs, char* buf, size_t buf_size);
static void formatMqttStatusReply(char* buf, size_t bufsize, const NodePrefs* prefs);