From 77ec3741515ef76ff938b7419c9e770a5a23aebd Mon Sep 17 00:00:00 2001 From: Elektr0Vodka <211697683+Elektr0Vodka@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:10:06 +0200 Subject: [PATCH 1/3] feat(snmp): expose battery voltage as .1.4.0 The SNMP agent had no battery reading, so solar or battery powered observers could not be monitored for a dying pack. Add battery voltage in millivolts to the system group as .1.4.0 (0 when the board cannot measure it). The value is pushed from the mesh task together with the existing 2 second radio stats update, so the ADC is only read on Core 1, never from the SNMP loop on Core 0. The meshcomod companion SNMP agent uses the same OID, so one poller reads battery from both. --- MQTT_SNMP.md | 2 ++ examples/simple_repeater/MyMesh.cpp | 1 + examples/simple_room_server/MyMesh.cpp | 1 + src/helpers/SNMPAgent.cpp | 6 ++++++ src/helpers/SNMPAgent.h | 7 ++++++- 5 files changed, 16 insertions(+), 1 deletion(-) diff --git a/MQTT_SNMP.md b/MQTT_SNMP.md index 8274009229f..f33030852a2 100644 --- a/MQTT_SNMP.md +++ b/MQTT_SNMP.md @@ -43,6 +43,7 @@ All OIDs are under the private enterprise subtree `1.3.6.1.4.1.99999` (`.iso.org | `.1.1.0` | INTEGER | System uptime (seconds) | | `.1.2.0` | STRING | Firmware version | | `.1.3.0` | STRING | Node name | +| `.1.4.0` | INTEGER | Battery voltage (mV), 0 when the board cannot measure it | ### Radio (.2.x.0) @@ -91,6 +92,7 @@ $ snmpwalk -v2c -c public 192.168.50.95 1.3.6.1.4.1.99999 SNMPv2-SMI::enterprises.99999.1.1.0 = INTEGER: 18 # uptime 18s SNMPv2-SMI::enterprises.99999.1.2.0 = STRING: "v1.14.1" # firmware SNMPv2-SMI::enterprises.99999.1.3.0 = STRING: "Heltec Repeater" +SNMPv2-SMI::enterprises.99999.1.4.0 = INTEGER: 4105 # battery 4.105 V SNMPv2-SMI::enterprises.99999.2.1.0 = INTEGER: 3 # 3 packets received SNMPv2-SMI::enterprises.99999.2.4.0 = INTEGER: -96 # noise floor -96 dBm SNMPv2-SMI::enterprises.99999.2.5.0 = INTEGER: -25 # last RSSI -25 dBm diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 2d6a28655ef..31099a0c1d0 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -2269,6 +2269,7 @@ void MyMesh::loop() { getNumSentFlood(), getNumSentDirect(), getNumRecvFlood(), getNumRecvDirect(), getTotalAirTime() / 1000, uptime_millis / 1000); + _snmp_agent.updateBattery(board.getBattMilliVolts()); } } #endif diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 9d8931392b6..b518c5dbc43 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -1799,6 +1799,7 @@ void MyMesh::loop() { getNumSentFlood(), getNumSentDirect(), getNumRecvFlood(), getNumRecvDirect(), getTotalAirTime() / 1000, uptime_millis / 1000); + _snmp_agent.updateBattery(board.getBattMilliVolts()); } } #endif diff --git a/src/helpers/SNMPAgent.cpp b/src/helpers/SNMPAgent.cpp index eb29bd688bc..46e0426d69c 100644 --- a/src/helpers/SNMPAgent.cpp +++ b/src/helpers/SNMPAgent.cpp @@ -11,6 +11,7 @@ MeshSNMPAgent::MeshSNMPAgent() : _snmp("public"), _running(false), _uptime_secs(0), + _battery_mv(0), _packets_recv(0), _packets_sent(0), _recv_errors(0), _noise_floor(0), _last_rssi(0), _last_snr(0), _sent_flood(0), _sent_direct(0), _recv_flood(0), _recv_direct(0), @@ -34,6 +35,7 @@ void MeshSNMPAgent::begin(const char* community) { _snmp.addIntegerHandler(MESHCORE_OID_BASE ".1.1.0", &_uptime_secs); _snmp.addReadOnlyStaticStringHandler(MESHCORE_OID_BASE ".1.2.0", _firmware_version, sizeof(_firmware_version)); _snmp.addReadOnlyStaticStringHandler(MESHCORE_OID_BASE ".1.3.0", _node_name, sizeof(_node_name)); + _snmp.addIntegerHandler(MESHCORE_OID_BASE ".1.4.0", &_battery_mv); // Radio group (.2.x.0) _snmp.addIntegerHandler(MESHCORE_OID_BASE ".2.1.0", &_packets_recv); @@ -111,6 +113,10 @@ void MeshSNMPAgent::updateMQTTStats(int connected_slots, int queue_depth, int sk _mqtt_skipped_publishes = skipped_publishes; } +void MeshSNMPAgent::updateBattery(uint16_t battery_mv) { + _battery_mv = (int)battery_mv; +} + void MeshSNMPAgent::setNodeName(const char* name) { strncpy(_node_name, name, sizeof(_node_name) - 1); _node_name[sizeof(_node_name) - 1] = '\0'; diff --git a/src/helpers/SNMPAgent.h b/src/helpers/SNMPAgent.h index fe94b10fe10..a0f5bf236af 100644 --- a/src/helpers/SNMPAgent.h +++ b/src/helpers/SNMPAgent.h @@ -10,7 +10,7 @@ #define MESHCORE_OID_BASE ".1.3.6.1.4.1.99999" // OID layout: -// .1.x.0 = system (uptime, version, node name) +// .1.x.0 = system (uptime, version, node name, battery) // .2.x.0 = radio (packets, RSSI, SNR, noise floor, air time) // .3.x.0 = mqtt (connected slots, queue depth, skipped publishes) // .4.x.0 = memory (free heap, max alloc, internal free, PSRAM free) @@ -32,6 +32,10 @@ class MeshSNMPAgent { void updateMQTTStats(int connected_slots, int queue_depth, int skipped_publishes); + // Battery voltage in millivolts, 0 when the board cannot measure it. Pushed from the + // mesh task with the radio stats, so the ADC is only ever read from Core 1. + void updateBattery(uint16_t battery_mv); + void setNodeName(const char* name); void setFirmwareVersion(const char* version); @@ -46,6 +50,7 @@ class MeshSNMPAgent { int _uptime_secs; char _firmware_version[32]; char _node_name[32]; + int _battery_mv; // Radio OIDs int _packets_recv; From 0a96eae34c49eb0d89a9b0ca95c48d8a75dccc54 Mon Sep 17 00:00:00 2001 From: Elektr0Vodka <211697683+Elektr0Vodka@users.noreply.github.com> Date: Fri, 16 Oct 2026 03:07:59 +0200 Subject: [PATCH 2/3] feat(snmp): add tier 1 health, mesh, filter and duty gate OIDs Monitoring tools could see radio, MQTT and memory stats, but not why a node rebooted, whether it is overheating or congested, or what the packet filter and region gating are doing. Expose the values the firmware already keeps: - system: MCU temperature (.1.5), reset reason code and text (.1.6/.1.7) - radio: TX queue, flood/direct dups, RX airtime, TX duty cycle, error flags (.2.12-.2.17) - mqtt: NTP synced (.3.4); memory: lowest free heap since boot (.4.5) - duty gate (.6) and packet filter drop totals (.7), repeater only - neighbour count (.8.1) New running totals are Counter32 so pollers graph them as rates; the older INTEGER totals are unchanged. Battery and temperature are now read every 30 s instead of every 2 s, because the Heltec V4 battery read blocks the mesh task for 10 ms. The push code moves into MyMesh::pushSNMPStats() in both the repeater and the room server. --- MQTT_SNMP.md | 73 ++++++++++++++--- examples/simple_repeater/MyMesh.cpp | 88 ++++++++++++++++---- examples/simple_repeater/MyMesh.h | 1 + examples/simple_room_server/MyMesh.cpp | 59 ++++++++++---- examples/simple_room_server/MyMesh.h | 3 + src/helpers/SNMPAgent.cpp | 108 ++++++++++++++++++++++++- src/helpers/SNMPAgent.h | 93 +++++++++++++++++++-- src/helpers/bridges/MQTTBridge.cpp | 2 +- 8 files changed, 373 insertions(+), 54 deletions(-) diff --git a/MQTT_SNMP.md b/MQTT_SNMP.md index f33030852a2..3ffdd288dd4 100644 --- a/MQTT_SNMP.md +++ b/MQTT_SNMP.md @@ -36,6 +36,8 @@ snmpwalk -v2c -c public 1.3.6.1.4.1.99999 All OIDs are under the private enterprise subtree `1.3.6.1.4.1.99999` (`.iso.org.dod.internet.private.enterprises.99999`). +Levels (queue lengths, percentages, voltages) are INTEGER. Running totals added from `.2.13.0` onwards are Counter32, so LibreNMS, Zabbix and similar tools graph them as rates. They start again from 0 after a reboot or `clear stats`. The older totals in `.2.x.0` and `.3.3.0` are still INTEGER. + ### System (.1.x.0) | OID | Type | Description | @@ -43,7 +45,10 @@ All OIDs are under the private enterprise subtree `1.3.6.1.4.1.99999` (`.iso.org | `.1.1.0` | INTEGER | System uptime (seconds) | | `.1.2.0` | STRING | Firmware version | | `.1.3.0` | STRING | Node name | -| `.1.4.0` | INTEGER | Battery voltage (mV), 0 when the board cannot measure it | +| `.1.4.0` | INTEGER | Battery voltage (mV), 0 when the board cannot measure it. Read every 30 s | +| `.1.5.0` | INTEGER | MCU die temperature (whole °C), -127 when the board has no sensor. Read every 30 s | +| `.1.6.0` | INTEGER | Reset reason code for the current boot (ESP-IDF `esp_reset_reason_t`: 1 power-on, 3 software, 4 panic, 5/6/7 watchdog, 9 brownout) | +| `.1.7.0` | STRING | Reset reason as text, for example `Brownout (low voltage)` | ### Radio (.2.x.0) @@ -60,6 +65,12 @@ All OIDs are under the private enterprise subtree `1.3.6.1.4.1.99999` (`.iso.org | `.2.9.0` | INTEGER | Flood packets received | | `.2.10.0` | INTEGER | Direct packets received | | `.2.11.0` | INTEGER | Total air time (seconds) | +| `.2.12.0` | INTEGER | Packets waiting in the transmit queue | +| `.2.13.0` | Counter32 | Duplicate flood packets received | +| `.2.14.0` | Counter32 | Duplicate direct packets received | +| `.2.15.0` | Counter32 | Total receive air time (seconds) | +| `.2.16.0` | INTEGER | Transmit duty cycle over the last ~60 s (%) | +| `.2.17.0` | INTEGER | Error flags since boot (bitmask: 1 packet pool full, 2 CAD timeout, 4 start-RX timeout, 8 radio watchdog fired) | ### MQTT (.3.x.0) @@ -68,6 +79,7 @@ All OIDs are under the private enterprise subtree `1.3.6.1.4.1.99999` (`.iso.org | `.3.1.0` | INTEGER | Connected MQTT slot count | | `.3.2.0` | INTEGER | Packet queue depth | | `.3.3.0` | INTEGER | Skipped publishes (memory pressure) | +| `.3.4.0` | INTEGER | Clock set by NTP (1 yes, 0 no) | ### Memory (.4.x.0) @@ -77,6 +89,7 @@ All OIDs are under the private enterprise subtree `1.3.6.1.4.1.99999` (`.iso.org | `.4.2.0` | INTEGER | Max allocatable block (bytes) | | `.4.3.0` | INTEGER | Free internal RAM (bytes) | | `.4.4.0` | INTEGER | Free PSRAM (bytes, 0 if no PSRAM) | +| `.4.5.0` | INTEGER | Lowest free heap since boot (bytes). A value that keeps dropping points to a leak | ### Network (.5.x.0) @@ -84,6 +97,44 @@ All OIDs are under the private enterprise subtree `1.3.6.1.4.1.99999` (`.iso.org |---|---|---| | `.5.1.0` | INTEGER | WiFi RSSI (dBm) | +### Duty gate (.6.x.0, repeater only) + +Region gating state (`set dc.gate on`). Not present on room servers. + +| OID | Type | Description | +|---|---|---| +| `.6.1.0` | INTEGER | Gating enabled (1 yes, 0 no) | +| `.6.2.0` | INTEGER | Outer region layers currently gated (0 = none) | +| `.6.3.0` | INTEGER | Highest layer that can be gated | +| `.6.4.0` | INTEGER | Duty cycle threshold that starts gating (%) | +| `.6.5.0` | INTEGER | Hysteresis: layers come back below threshold minus this (%) | + +### Filter (.7.x.0, repeater only) + +Packet filter drop totals since boot or the last `clear stats`. In dry-run mode the drops are counted but the packets are still forwarded. Not present on room servers. + +| OID | Type | Description | +|---|---|---| +| `.7.1.0` | INTEGER | Filter enabled (1 yes, 0 no) | +| `.7.2.0` | INTEGER | Dry-run mode (1 yes, 0 no) | +| `.7.3.0` | Counter32 | Hop-limit drops, all payload types | +| `.7.4.0` | Counter32 | Rate-limit drops, all payload types | +| `.7.5.0` | Counter32 | Blocked-channel drops | +| `.7.6.0` | Counter32 | Path-hash size drops | +| `.7.7.0` | Counter32 | Malformed packet drops | +| `.7.8.0` | Counter32 | Advert window drops | +| `.7.9.0` | Counter32 | Path-prefix block list drops | +| `.7.10.0` | Counter32 | Sender rule drops | +| `.7.11.0` | Counter32 | Text rule drops | +| `.7.12.0` | Counter32 | Message age drops | +| `.7.13.0` | Counter32 | Estimated transmit air time the drops saved (ms) | + +### Neighbours (.8.x.0) + +| OID | Type | Description | +|---|---|---| +| `.8.1.0` | INTEGER | Neighbours in the neighbour table | + ## Example Output ``` @@ -93,31 +144,34 @@ SNMPv2-SMI::enterprises.99999.1.1.0 = INTEGER: 18 # uptime 18s SNMPv2-SMI::enterprises.99999.1.2.0 = STRING: "v1.14.1" # firmware SNMPv2-SMI::enterprises.99999.1.3.0 = STRING: "Heltec Repeater" SNMPv2-SMI::enterprises.99999.1.4.0 = INTEGER: 4105 # battery 4.105 V +SNMPv2-SMI::enterprises.99999.1.5.0 = INTEGER: 41 # MCU 41 °C +SNMPv2-SMI::enterprises.99999.1.6.0 = INTEGER: 1 +SNMPv2-SMI::enterprises.99999.1.7.0 = STRING: "Power-on reset" SNMPv2-SMI::enterprises.99999.2.1.0 = INTEGER: 3 # 3 packets received SNMPv2-SMI::enterprises.99999.2.4.0 = INTEGER: -96 # noise floor -96 dBm SNMPv2-SMI::enterprises.99999.2.5.0 = INTEGER: -25 # last RSSI -25 dBm SNMPv2-SMI::enterprises.99999.4.1.0 = INTEGER: 199612 # ~195 KB free heap SNMPv2-SMI::enterprises.99999.4.2.0 = INTEGER: 188404 # ~184 KB max alloc SNMPv2-SMI::enterprises.99999.5.1.0 = INTEGER: -22 # WiFi RSSI -22 dBm +SNMPv2-SMI::enterprises.99999.7.4.0 = Counter32: 12 # 12 rate-limit drops +SNMPv2-SMI::enterprises.99999.8.1.0 = INTEGER: 7 # 7 neighbours ``` ## Architecture The SNMP agent runs inside the existing MQTT FreeRTOS task on Core 0 (the WiFi/network core). No additional task is created. -- **Radio stats** are pushed from Core 1 (mesh task) every 2 seconds via `updateRadioStats()` -- **MQTT stats** are updated from Core 0 each MQTT task loop iteration +- **Radio, mesh, duty gate, filter and neighbour stats** are pushed from Core 1 (mesh task) every 2 seconds by `MyMesh::pushSNMPStats()` +- **Battery voltage and MCU temperature** are read on Core 1 every 30 seconds (some boards block on the ADC read) +- **Reset reason** is set once at startup +- **MQTT stats and NTP state** are updated from Core 0 each MQTT task loop iteration - **Memory and WiFi stats** are read directly in the SNMP loop on Core 0 The agent starts automatically once WiFi connects and `snmp_enabled = 1` in prefs. It adds approximately 2-3 KB RAM overhead plus per-OID handler storage. ## Build Configuration -SNMP is enabled at compile time with the `WITH_SNMP=1` build flag and included in observer firmware targets: - -- `heltec_v3_repeater_observer_mqtt` -- `heltec_v4_repeater_observer_mqtt` -- `Station_G2_repeater_observer_mqtt` +SNMP is enabled at compile time with the `WITH_SNMP=1` build flag. Most ESP32 observer targets have it; search the `variants/*/platformio.ini` files for `WITH_SNMP=1` to see which. To add SNMP to another observer variant, add the following to its `platformio.ini`: @@ -144,7 +198,8 @@ lib_deps = | `src/helpers/CommonCLI.h` | `NodePrefs` struct (`snmp_enabled`, `snmp_community` fields) | | `src/helpers/CommonCLI.cpp` | CLI command parsing and prefs persistence | | `src/helpers/bridges/MQTTBridge.cpp` | SNMP loop integration in `mqttTaskLoop()` | -| `examples/simple_repeater/MyMesh.cpp` | Radio stats push and agent initialization | +| `examples/simple_repeater/MyMesh.cpp` | Stats push (`pushSNMPStats`) and agent initialization | +| `examples/simple_room_server/MyMesh.cpp` | Stats push for room servers (no duty gate or filter groups) | ## Private Enterprise Number diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 31099a0c1d0..c6c444960b6 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -1263,6 +1263,9 @@ void MyMesh::begin(FILESYSTEM *fs) { if (_cli.getObserverPrefs()->snmp_enabled) { _snmp_agent.setNodeName(_prefs.node_name); _snmp_agent.setFirmwareVersion(getFirmwareVer()); + uint32_t reset_reason = board.getResetReason(); + _snmp_agent.setResetReason(reset_reason, board.getResetReasonString(reset_reason)); + _snmp_agent.enableRepeaterGroups(); // duty gate + filter bridge->setSNMPAgent(&_snmp_agent); } #endif @@ -2255,25 +2258,78 @@ void MyMesh::loop() { #endif #ifdef WITH_SNMP - // Push radio stats to SNMP agent every 2 seconds - if (_snmp_agent.isRunning()) { - static unsigned long last_snmp_stats = 0; - if (now - last_snmp_stats >= 2000) { - last_snmp_stats = now; - _snmp_agent.updateRadioStats( - radio_driver.getPacketsRecv(), radio_driver.getPacketsSent(), - radio_driver.getPacketsRecvErrors(), - (int16_t)_radio->getNoiseFloor(), - (int16_t)radio_driver.getLastRSSI(), - (int16_t)(radio_driver.getLastSNR() * 4), - getNumSentFlood(), getNumSentDirect(), - getNumRecvFlood(), getNumRecvDirect(), - getTotalAirTime() / 1000, uptime_millis / 1000); - _snmp_agent.updateBattery(board.getBattMilliVolts()); - } + if (_snmp_agent.isRunning()) pushSNMPStats(now); +#endif +} + +#ifdef WITH_SNMP +static uint32_t snmpSatAdd(uint32_t a, uint32_t b) { + return a > 0xFFFFFFFFu - b ? 0xFFFFFFFFu : a + b; // filter counters saturate, so the sum does too +} + +// Counters every 2 s; battery and MCU temperature every 30 s, because some boards +// block on the ADC read (Heltec V4 waits 10 ms) and neither value moves quickly. +void MyMesh::pushSNMPStats(uint32_t now) { + static uint32_t last_snmp_stats = 0; + static uint32_t last_snmp_health = 0; + static bool health_pushed = false; + + if (!health_pushed || now - last_snmp_health >= 30000) { + health_pushed = true; + last_snmp_health = now; + _snmp_agent.updateBattery(board.getBattMilliVolts()); + _snmp_agent.updateMcuTemperature(board.getMCUTemperature()); + } + + if (now - last_snmp_stats < 2000) return; + last_snmp_stats = now; + + _snmp_agent.updateRadioStats( + radio_driver.getPacketsRecv(), radio_driver.getPacketsSent(), + radio_driver.getPacketsRecvErrors(), + (int16_t)_radio->getNoiseFloor(), + (int16_t)radio_driver.getLastRSSI(), + (int16_t)(radio_driver.getLastSNR() * 4), + getNumSentFlood(), getNumSentDirect(), + getNumRecvFlood(), getNumRecvDirect(), + getTotalAirTime() / 1000, uptime_millis / 1000); + + int neighbour_count = 0; +#if MAX_NEIGHBOURS + for (int i = 0; i < MAX_NEIGHBOURS; i++) { + if (neighbours[i].heard_timestamp > 0) neighbour_count++; } #endif + _snmp_agent.updateMeshStats( + _mgr->getOutboundCount(0xFFFFFFFF), + ((SimpleMeshTables *)getTables())->getNumFloodDups(), + ((SimpleMeshTables *)getTables())->getNumDirectDups(), + getReceiveAirTime() / 1000, getTxDutyCyclePercent(), _err_flags, + neighbour_count); + + _snmp_agent.updateDutyGate(_prefs.dc_gate_enabled != 0, dc_gate_level, region_map.getMaxGateLevel(), + _prefs.dc_gate_threshold, _prefs.dc_gate_hysteresis); + + const Counters& c = _filter.getCounters(); + SNMPFilterTotals t; + t.enabled = _filter.isEnabled(); + t.dryrun = _filter.getPrefs().dryrun != 0; + for (int i = 0; i < PAYLOAD_TYPE_COUNT; i++) { + t.hops = snmpSatAdd(t.hops, c.hops[i]); + t.rate = snmpSatAdd(t.rate, c.rate[i]); + } + t.channel = c.channel; + t.hash = c.hash; + t.malformed = c.malformed; + t.advert = c.advert; + t.path = c.path; + t.sender = c.sender; + t.text = c.text; + t.age = c.age; + t.air_ms = c.air_ms; + _snmp_agent.updateFilterStats(t); } +#endif #if defined(WITH_MQTT_NEIGHBORS) #include "helpers/MQTTMessageBuilder.h" diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 58d01100bd8..7558b5a090d 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -148,6 +148,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks #endif #ifdef WITH_SNMP MeshSNMPAgent _snmp_agent; + void pushSNMPStats(uint32_t now); // mesh task: copy stats into the SNMP agent #endif #ifdef WITH_MQTT_BRIDGE AlertReporter _alerter; diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index b518c5dbc43..d7fd93fedfa 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -1785,26 +1785,51 @@ void MyMesh::loop() { #endif #ifdef WITH_SNMP - // Push radio stats to SNMP agent every 2 seconds - if (_snmp_agent.isRunning()) { - static unsigned long last_snmp_stats = 0; - if (now - last_snmp_stats >= 2000) { - last_snmp_stats = now; - _snmp_agent.updateRadioStats( - radio_driver.getPacketsRecv(), radio_driver.getPacketsSent(), - radio_driver.getPacketsRecvErrors(), - (int16_t)_radio->getNoiseFloor(), - (int16_t)radio_driver.getLastRSSI(), - (int16_t)(radio_driver.getLastSNR() * 4), - getNumSentFlood(), getNumSentDirect(), - getNumRecvFlood(), getNumRecvDirect(), - getTotalAirTime() / 1000, uptime_millis / 1000); - _snmp_agent.updateBattery(board.getBattMilliVolts()); - } - } + if (_snmp_agent.isRunning()) pushSNMPStats(now); #endif } +#ifdef WITH_SNMP +// Counters every 2 s; battery and MCU temperature every 30 s, because some boards +// block on the ADC read (Heltec V4 waits 10 ms) and neither value moves quickly. +void MyMesh::pushSNMPStats(uint32_t now) { + static uint32_t last_snmp_stats = 0; + static uint32_t last_snmp_health = 0; + static bool health_pushed = false; + + if (!health_pushed || now - last_snmp_health >= 30000) { + health_pushed = true; + last_snmp_health = now; + _snmp_agent.updateBattery(board.getBattMilliVolts()); + _snmp_agent.updateMcuTemperature(board.getMCUTemperature()); + } + + if (now - last_snmp_stats < 2000) return; + last_snmp_stats = now; + + _snmp_agent.updateRadioStats( + radio_driver.getPacketsRecv(), radio_driver.getPacketsSent(), + radio_driver.getPacketsRecvErrors(), + (int16_t)_radio->getNoiseFloor(), + (int16_t)radio_driver.getLastRSSI(), + (int16_t)(radio_driver.getLastSNR() * 4), + getNumSentFlood(), getNumSentDirect(), + getNumRecvFlood(), getNumRecvDirect(), + getTotalAirTime() / 1000, uptime_millis / 1000); + + int neighbour_count = 0; + for (int i = 0; i < MAX_NEIGHBOURS; i++) { + if (neighbours[i].heard_timestamp > 0) neighbour_count++; + } + _snmp_agent.updateMeshStats( + _mgr->getOutboundCount(0xFFFFFFFF), + ((SimpleMeshTables *)getTables())->getNumFloodDups(), + ((SimpleMeshTables *)getTables())->getNumDirectDups(), + getReceiveAirTime() / 1000, getTxDutyCyclePercent(), _err_flags, + neighbour_count); +} +#endif + #if defined(WITH_MQTT_NEIGHBORS) #include "helpers/MQTTMessageBuilder.h" #if defined(ESP_PLATFORM) diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index 5037bbb3bda..af909670648 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -212,6 +212,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks #endif #ifdef WITH_SNMP MeshSNMPAgent _snmp_agent; + void pushSNMPStats(uint32_t now); // mesh task: copy stats into the SNMP agent #endif #ifdef WITH_MQTT_BRIDGE AlertReporter _alerter; @@ -398,6 +399,8 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks if (_cli.getObserverPrefs()->snmp_enabled) { _snmp_agent.setNodeName(_prefs.node_name); _snmp_agent.setFirmwareVersion(getFirmwareVer()); + uint32_t reset_reason = _cli.getBoard()->getResetReason(); + _snmp_agent.setResetReason(reset_reason, _cli.getBoard()->getResetReasonString(reset_reason)); bridge->setSNMPAgent(&_snmp_agent); } #endif diff --git a/src/helpers/SNMPAgent.cpp b/src/helpers/SNMPAgent.cpp index 46e0426d69c..a15c8c70fc8 100644 --- a/src/helpers/SNMPAgent.cpp +++ b/src/helpers/SNMPAgent.cpp @@ -4,24 +4,38 @@ #include "NetworkLink.h" #include #include +#include #define SNMP_PORT 161 MeshSNMPAgent::MeshSNMPAgent() : _snmp("public"), _running(false), + _repeater_groups(false), _uptime_secs(0), _battery_mv(0), + _mcu_temp_c(SNMP_TEMP_UNAVAILABLE), + _reset_reason(0), _packets_recv(0), _packets_sent(0), _recv_errors(0), _noise_floor(0), _last_rssi(0), _last_snr(0), _sent_flood(0), _sent_direct(0), _recv_flood(0), _recv_direct(0), _total_air_time_secs(0), - _mqtt_connected_slots(0), _mqtt_queue_depth(0), _mqtt_skipped_publishes(0), - _free_heap(0), _max_alloc(0), _internal_free(0), _psram_free(0), - _wifi_rssi(-127) + _tx_queue_len(0), _flood_dups(0), _direct_dups(0), _rx_air_time_secs(0), + _tx_duty_percent(0), _err_flags(0), + _mqtt_connected_slots(0), _mqtt_queue_depth(0), _mqtt_skipped_publishes(0), _ntp_synced(0), + _free_heap(0), _max_alloc(0), _internal_free(0), _psram_free(0), _min_free_heap(0), + _wifi_rssi(-127), + _dc_gate_enabled(0), _dc_gate_level(0), _dc_gate_max_level(0), + _dc_gate_threshold(0), _dc_gate_hysteresis(0), + _filter_enabled(0), _filter_dryrun(0), + _filter_hops(0), _filter_rate(0), _filter_channel(0), _filter_hash(0), + _filter_malformed(0), _filter_advert(0), _filter_path(0), _filter_sender(0), + _filter_text(0), _filter_age(0), _filter_air_ms(0), + _neighbour_count(0) { _firmware_version[0] = '\0'; _node_name[0] = '\0'; + _reset_reason_text[0] = '\0'; } void MeshSNMPAgent::begin(const char* community) { @@ -36,6 +50,9 @@ void MeshSNMPAgent::begin(const char* community) { _snmp.addReadOnlyStaticStringHandler(MESHCORE_OID_BASE ".1.2.0", _firmware_version, sizeof(_firmware_version)); _snmp.addReadOnlyStaticStringHandler(MESHCORE_OID_BASE ".1.3.0", _node_name, sizeof(_node_name)); _snmp.addIntegerHandler(MESHCORE_OID_BASE ".1.4.0", &_battery_mv); + _snmp.addIntegerHandler(MESHCORE_OID_BASE ".1.5.0", &_mcu_temp_c); + _snmp.addIntegerHandler(MESHCORE_OID_BASE ".1.6.0", &_reset_reason); + _snmp.addReadOnlyStaticStringHandler(MESHCORE_OID_BASE ".1.7.0", _reset_reason_text); // Radio group (.2.x.0) _snmp.addIntegerHandler(MESHCORE_OID_BASE ".2.1.0", &_packets_recv); @@ -49,21 +66,56 @@ void MeshSNMPAgent::begin(const char* community) { _snmp.addIntegerHandler(MESHCORE_OID_BASE ".2.9.0", &_recv_flood); _snmp.addIntegerHandler(MESHCORE_OID_BASE ".2.10.0", &_recv_direct); _snmp.addIntegerHandler(MESHCORE_OID_BASE ".2.11.0", &_total_air_time_secs); + _snmp.addIntegerHandler(MESHCORE_OID_BASE ".2.12.0", &_tx_queue_len); + _snmp.addCounter32Handler(MESHCORE_OID_BASE ".2.13.0", &_flood_dups); + _snmp.addCounter32Handler(MESHCORE_OID_BASE ".2.14.0", &_direct_dups); + _snmp.addCounter32Handler(MESHCORE_OID_BASE ".2.15.0", &_rx_air_time_secs); + _snmp.addIntegerHandler(MESHCORE_OID_BASE ".2.16.0", &_tx_duty_percent); + _snmp.addIntegerHandler(MESHCORE_OID_BASE ".2.17.0", &_err_flags); // MQTT group (.3.x.0) _snmp.addIntegerHandler(MESHCORE_OID_BASE ".3.1.0", &_mqtt_connected_slots); _snmp.addIntegerHandler(MESHCORE_OID_BASE ".3.2.0", &_mqtt_queue_depth); _snmp.addIntegerHandler(MESHCORE_OID_BASE ".3.3.0", &_mqtt_skipped_publishes); + _snmp.addIntegerHandler(MESHCORE_OID_BASE ".3.4.0", &_ntp_synced); // Memory group (.4.x.0) _snmp.addIntegerHandler(MESHCORE_OID_BASE ".4.1.0", &_free_heap); _snmp.addIntegerHandler(MESHCORE_OID_BASE ".4.2.0", &_max_alloc); _snmp.addIntegerHandler(MESHCORE_OID_BASE ".4.3.0", &_internal_free); _snmp.addIntegerHandler(MESHCORE_OID_BASE ".4.4.0", &_psram_free); + _snmp.addIntegerHandler(MESHCORE_OID_BASE ".4.5.0", &_min_free_heap); // Network group (.5.x.0) _snmp.addIntegerHandler(MESHCORE_OID_BASE ".5.1.0", &_wifi_rssi); + if (_repeater_groups) { + // Duty gate group (.6.x.0) + _snmp.addIntegerHandler(MESHCORE_OID_BASE ".6.1.0", &_dc_gate_enabled); + _snmp.addIntegerHandler(MESHCORE_OID_BASE ".6.2.0", &_dc_gate_level); + _snmp.addIntegerHandler(MESHCORE_OID_BASE ".6.3.0", &_dc_gate_max_level); + _snmp.addIntegerHandler(MESHCORE_OID_BASE ".6.4.0", &_dc_gate_threshold); + _snmp.addIntegerHandler(MESHCORE_OID_BASE ".6.5.0", &_dc_gate_hysteresis); + + // Filter group (.7.x.0) + _snmp.addIntegerHandler(MESHCORE_OID_BASE ".7.1.0", &_filter_enabled); + _snmp.addIntegerHandler(MESHCORE_OID_BASE ".7.2.0", &_filter_dryrun); + _snmp.addCounter32Handler(MESHCORE_OID_BASE ".7.3.0", &_filter_hops); + _snmp.addCounter32Handler(MESHCORE_OID_BASE ".7.4.0", &_filter_rate); + _snmp.addCounter32Handler(MESHCORE_OID_BASE ".7.5.0", &_filter_channel); + _snmp.addCounter32Handler(MESHCORE_OID_BASE ".7.6.0", &_filter_hash); + _snmp.addCounter32Handler(MESHCORE_OID_BASE ".7.7.0", &_filter_malformed); + _snmp.addCounter32Handler(MESHCORE_OID_BASE ".7.8.0", &_filter_advert); + _snmp.addCounter32Handler(MESHCORE_OID_BASE ".7.9.0", &_filter_path); + _snmp.addCounter32Handler(MESHCORE_OID_BASE ".7.10.0", &_filter_sender); + _snmp.addCounter32Handler(MESHCORE_OID_BASE ".7.11.0", &_filter_text); + _snmp.addCounter32Handler(MESHCORE_OID_BASE ".7.12.0", &_filter_age); + _snmp.addCounter32Handler(MESHCORE_OID_BASE ".7.13.0", &_filter_air_ms); + } + + // Neighbour group (.8.x.0) + _snmp.addIntegerHandler(MESHCORE_OID_BASE ".8.1.0", &_neighbour_count); + _snmp.sortHandlers(); _running = true; } @@ -80,6 +132,7 @@ void MeshSNMPAgent::loop() { #else _psram_free = 0; #endif + _min_free_heap = (int)ESP.getMinFreeHeap(); const int signal = activeNetworkLink().rssi(); _wifi_rssi = signal == INT_MIN ? -127 : signal; @@ -107,16 +160,57 @@ void MeshSNMPAgent::updateRadioStats( _uptime_secs = (int)uptime_secs; } -void MeshSNMPAgent::updateMQTTStats(int connected_slots, int queue_depth, int skipped_publishes) { +void MeshSNMPAgent::updateMeshStats(uint32_t tx_queue_len, uint32_t flood_dups, uint32_t direct_dups, + uint32_t rx_air_time_secs, uint8_t tx_duty_percent, uint16_t err_flags, + int neighbour_count) { + _tx_queue_len = (int)tx_queue_len; + _flood_dups = flood_dups; + _direct_dups = direct_dups; + _rx_air_time_secs = rx_air_time_secs; + _tx_duty_percent = (int)tx_duty_percent; + _err_flags = (int)err_flags; + _neighbour_count = neighbour_count; +} + +void MeshSNMPAgent::updateMQTTStats(int connected_slots, int queue_depth, int skipped_publishes, bool ntp_synced) { _mqtt_connected_slots = connected_slots; _mqtt_queue_depth = queue_depth; _mqtt_skipped_publishes = skipped_publishes; + _ntp_synced = ntp_synced ? 1 : 0; } void MeshSNMPAgent::updateBattery(uint16_t battery_mv) { _battery_mv = (int)battery_mv; } +void MeshSNMPAgent::updateMcuTemperature(float celsius) { + _mcu_temp_c = std::isnan(celsius) ? SNMP_TEMP_UNAVAILABLE : (int)lroundf(celsius); +} + +void MeshSNMPAgent::updateDutyGate(bool enabled, uint8_t level, uint8_t max_level, uint8_t threshold, uint8_t hysteresis) { + _dc_gate_enabled = enabled ? 1 : 0; + _dc_gate_level = level; + _dc_gate_max_level = max_level; + _dc_gate_threshold = threshold; + _dc_gate_hysteresis = hysteresis; +} + +void MeshSNMPAgent::updateFilterStats(const SNMPFilterTotals& t) { + _filter_enabled = t.enabled ? 1 : 0; + _filter_dryrun = t.dryrun ? 1 : 0; + _filter_hops = t.hops; + _filter_rate = t.rate; + _filter_channel = t.channel; + _filter_hash = t.hash; + _filter_malformed = t.malformed; + _filter_advert = t.advert; + _filter_path = t.path; + _filter_sender = t.sender; + _filter_text = t.text; + _filter_age = t.age; + _filter_air_ms = t.air_ms; +} + void MeshSNMPAgent::setNodeName(const char* name) { strncpy(_node_name, name, sizeof(_node_name) - 1); _node_name[sizeof(_node_name) - 1] = '\0'; @@ -127,4 +221,10 @@ void MeshSNMPAgent::setFirmwareVersion(const char* version) { _firmware_version[sizeof(_firmware_version) - 1] = '\0'; } +void MeshSNMPAgent::setResetReason(uint32_t code, const char* text) { + _reset_reason = (int)code; + strncpy(_reset_reason_text, text ? text : "", sizeof(_reset_reason_text) - 1); + _reset_reason_text[sizeof(_reset_reason_text) - 1] = '\0'; +} + #endif // WITH_SNMP diff --git a/src/helpers/SNMPAgent.h b/src/helpers/SNMPAgent.h index a0f5bf236af..0928e286ef9 100644 --- a/src/helpers/SNMPAgent.h +++ b/src/helpers/SNMPAgent.h @@ -10,11 +10,37 @@ #define MESHCORE_OID_BASE ".1.3.6.1.4.1.99999" // OID layout: -// .1.x.0 = system (uptime, version, node name, battery) -// .2.x.0 = radio (packets, RSSI, SNR, noise floor, air time) -// .3.x.0 = mqtt (connected slots, queue depth, skipped publishes) -// .4.x.0 = memory (free heap, max alloc, internal free, PSRAM free) -// .5.x.0 = network (RSSI, or -127 when disconnected or the medium has no RSSI) +// .1.x.0 = system (uptime, version, node name, battery, MCU temperature, reset reason) +// .2.x.0 = radio (packets, RSSI, SNR, noise floor, air time, TX queue, dups, duty cycle) +// .3.x.0 = mqtt (connected slots, queue depth, skipped publishes, NTP synced) +// .4.x.0 = memory (free heap, max alloc, internal free, PSRAM free, min free heap) +// .5.x.0 = network (RSSI, or -127 when disconnected or the medium has no RSSI) +// .6.x.0 = duty gate (region gating state; repeater only) +// .7.x.0 = filter (packet filter drop totals; repeater only) +// .8.x.0 = neighbours (neighbour count) +// +// Levels are INTEGER. Running totals added after the first release are Counter32, so +// pollers graph them as rates. + +// MCU temperature value when the board has no sensor (or returned NaN). +#define SNMP_TEMP_UNAVAILABLE -127 + +// Packet filter drop totals, as pushed from the repeater's Filter counters. +struct SNMPFilterTotals { + bool enabled = false; + bool dryrun = false; + uint32_t hops = 0; // hop-limit drops, all payload types + uint32_t rate = 0; // rate-limit drops, all payload types + uint32_t channel = 0; + uint32_t hash = 0; + uint32_t malformed = 0; + uint32_t advert = 0; + uint32_t path = 0; + uint32_t sender = 0; + uint32_t text = 0; + uint32_t age = 0; + uint32_t air_ms = 0; // estimated TX airtime the drops saved +}; class MeshSNMPAgent { public: @@ -30,14 +56,30 @@ class MeshSNMPAgent { uint32_t recv_flood, uint32_t recv_direct, uint32_t total_air_time_secs, uint32_t uptime_secs); - void updateMQTTStats(int connected_slots, int queue_depth, int skipped_publishes); + // Mesh-side counters that RepeaterStats also reports. Called from the mesh task (Core 1). + void updateMeshStats(uint32_t tx_queue_len, uint32_t flood_dups, uint32_t direct_dups, + uint32_t rx_air_time_secs, uint8_t tx_duty_percent, uint16_t err_flags, + int neighbour_count); + + // Called from the MQTT task (Core 0). + void updateMQTTStats(int connected_slots, int queue_depth, int skipped_publishes, bool ntp_synced); // Battery voltage in millivolts, 0 when the board cannot measure it. Pushed from the - // mesh task with the radio stats, so the ADC is only ever read from Core 1. + // mesh task, so the ADC is only ever read from Core 1. void updateBattery(uint16_t battery_mv); + // MCU die temperature in whole degrees C. NaN maps to SNMP_TEMP_UNAVAILABLE. + void updateMcuTemperature(float celsius); + + // Repeater-only groups. Registered by begin() only after enableRepeaterGroups(). + void updateDutyGate(bool enabled, uint8_t level, uint8_t max_level, uint8_t threshold, uint8_t hysteresis); + void updateFilterStats(const SNMPFilterTotals& totals); + void enableRepeaterGroups() { _repeater_groups = true; } + + // String OIDs are copied when begin() registers them, so set these first. void setNodeName(const char* name); void setFirmwareVersion(const char* version); + void setResetReason(uint32_t code, const char* text); bool isRunning() const { return _running; } @@ -45,12 +87,16 @@ class MeshSNMPAgent { WiFiUDP _udp; SNMPAgent _snmp; bool _running; + bool _repeater_groups; // System OIDs int _uptime_secs; char _firmware_version[32]; char _node_name[32]; int _battery_mv; + int _mcu_temp_c; + int _reset_reason; + char _reset_reason_text[32]; // Radio OIDs int _packets_recv; @@ -64,20 +110,53 @@ class MeshSNMPAgent { int _recv_flood; int _recv_direct; int _total_air_time_secs; + int _tx_queue_len; + uint32_t _flood_dups; + uint32_t _direct_dups; + uint32_t _rx_air_time_secs; + int _tx_duty_percent; + int _err_flags; // MQTT OIDs int _mqtt_connected_slots; int _mqtt_queue_depth; int _mqtt_skipped_publishes; + int _ntp_synced; // Memory OIDs (updated in loop() since we're on Core 0 with WiFi) int _free_heap; int _max_alloc; int _internal_free; int _psram_free; + int _min_free_heap; // Network OIDs int _wifi_rssi; + + // Duty gate OIDs (repeater only) + int _dc_gate_enabled; + int _dc_gate_level; + int _dc_gate_max_level; + int _dc_gate_threshold; + int _dc_gate_hysteresis; + + // Filter OIDs (repeater only) + int _filter_enabled; + int _filter_dryrun; + uint32_t _filter_hops; + uint32_t _filter_rate; + uint32_t _filter_channel; + uint32_t _filter_hash; + uint32_t _filter_malformed; + uint32_t _filter_advert; + uint32_t _filter_path; + uint32_t _filter_sender; + uint32_t _filter_text; + uint32_t _filter_age; + uint32_t _filter_air_ms; + + // Neighbour OIDs + int _neighbour_count; }; #endif // WITH_SNMP diff --git a/src/helpers/bridges/MQTTBridge.cpp b/src/helpers/bridges/MQTTBridge.cpp index f2235a65646..60d109e08b3 100644 --- a/src/helpers/bridges/MQTTBridge.cpp +++ b/src/helpers/bridges/MQTTBridge.cpp @@ -1619,7 +1619,7 @@ void MQTTBridge::mqttTaskLoop() { for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { if (_slots[i].enabled && _slots[i].connected) connected++; } - _snmp_agent->updateMQTTStats(connected, _queue_count, _skipped_publishes); + _snmp_agent->updateMQTTStats(connected, _queue_count, _skipped_publishes, _ntp_synced); _snmp_agent->loop(); } } From 7a4928e2a810c58f579676ac6c5ff50640ace168 Mon Sep 17 00:00:00 2001 From: Elektr0Vodka <211697683+Elektr0Vodka@users.noreply.github.com> Date: Fri, 16 Oct 2026 17:20:04 +0200 Subject: [PATCH 3/3] fix(snmp): build room server neighbour count without WITH_MQTT_NEIGHBORS The room server's neighbour table only exists with WITH_MQTT_NEIGHBORS, so pushSNMPStats() broke the build on non-PSRAM room-server observers (Heltec_v2_room_server_observer_mqtt). Report 0 there instead. --- MQTT_SNMP.md | 2 +- examples/simple_room_server/MyMesh.cpp | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/MQTT_SNMP.md b/MQTT_SNMP.md index 3ffdd288dd4..447e7fdfd46 100644 --- a/MQTT_SNMP.md +++ b/MQTT_SNMP.md @@ -133,7 +133,7 @@ Packet filter drop totals since boot or the last `clear stats`. In dry-run mode | OID | Type | Description | |---|---|---| -| `.8.1.0` | INTEGER | Neighbours in the neighbour table | +| `.8.1.0` | INTEGER | Neighbours in the neighbour table (always 0 on room servers without PSRAM, which keep no table) | ## Example Output diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index d7fd93fedfa..af3736c5538 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -1818,9 +1818,11 @@ void MyMesh::pushSNMPStats(uint32_t now) { getTotalAirTime() / 1000, uptime_millis / 1000); int neighbour_count = 0; +#if defined(WITH_MQTT_NEIGHBORS) // the room server keeps no neighbour table without it for (int i = 0; i < MAX_NEIGHBOURS; i++) { if (neighbours[i].heard_timestamp > 0) neighbour_count++; } +#endif _snmp_agent.updateMeshStats( _mgr->getOutboundCount(0xFFFFFFFF), ((SimpleMeshTables *)getTables())->getNumFloodDups(),