diff --git a/AGENTS.md b/AGENTS.md
index b050b0b3d13..515a69a3686 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -65,6 +65,8 @@ not a side effect. Grouped by what pulls them in:
build / release build.sh, platformio.ini
packet filter examples/simple_repeater/MyMesh.{cpp,h}, docs/cli_commands.md
companion filter examples/companion_radio/MyMesh.{cpp,h}, docs/cli_commands.md
+companion net examples/companion_radio/{MyMesh.{cpp,h},main.cpp}, build.sh,
+ variants/*/platformio.ini (SNMP lib_deps), docs/cli_commands.md
region gating src/Dispatcher.{cpp,h}, src/helpers/RegionMap.{cpp,h},
src/helpers/CommonCLI.{cpp,h}, test/mocks/{Arduino,SHA256,Stream}.h
duty-cycle auto src/helpers/CommonRadioPrefs.{cpp,h}, src/helpers/CommonCLI.{cpp,h},
diff --git a/build.sh b/build.sh
index c9907609e18..403e4cbdc4b 100755
--- a/build.sh
+++ b/build.sh
@@ -126,6 +126,10 @@ disable_debug_flags() {
# build firmware for the provided pio env in $1
build_firmware() {
+ # Per-env -D flags (version, OTA variant) are appended below; restore the caller's
+ # flags on return so they do not leak into the next env of a multi-env build.
+ local SAVED_PIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS}"
+
# get env platform for post build actions
ENV_PLATFORM=($(get_platform_for_env $1))
@@ -170,6 +174,19 @@ build_firmware() {
# add firmware version info to end of existing platformio build flags in environment vars
export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DFIRMWARE_BUILD_DATE='\"${FIRMWARE_BUILD_DATE}\"' -DFIRMWARE_VERSION='\"${EMBEDDED_VERSION_STRING}\"'"
+ # DMC companion pull OTA (`ota check` / `ota update` / `ota branch`): the device reads
+ # /.json from the DutchMeshCore OTA server, which mirrors the dmc-companion-*
+ # releases. Both channel bases are baked in for `ota branch`; the build's own channel
+ # (dev for DMC_CHANNEL=dev) is the default. Only Wi-Fi capable ESP32 builds use them.
+ if [[ "$1" =~ _companion_radio_(wifi|ble)$ ]]; then
+ local OTA_URL="${COMPANION_OTA_URL:-https://ota.dutchmeshcore.nl/companion}"
+ local OTA_NATIVE="${OTA_URL}/v"
+ if [ "${DMC_CHANNEL:-}" == "dev" ]; then
+ OTA_NATIVE="${OTA_URL}/dev/v"
+ fi
+ export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DCOMPANION_OTA_VARIANT='\"$1\"' -DCOMPANION_OTA_BASE='\"${OTA_NATIVE}\"' -DCOMPANION_OTA_BASE_STABLE='\"${OTA_URL}/v\"' -DCOMPANION_OTA_BASE_DEV='\"${OTA_URL}/dev/v\"'"
+ fi
+
# disable debug flags if requested
disable_debug_flags
@@ -181,6 +198,13 @@ build_firmware() {
pio run -t mergebin -e $1
cp .pio/build/$1/firmware.bin out/${FIRMWARE_FILENAME}.bin 2>/dev/null || true
cp .pio/build/$1/firmware-merged.bin out/${FIRMWARE_FILENAME}-merged.bin 2>/dev/null || true
+ # Partition-table signature for the OTA server's manifest (partSig): `ota update`
+ # refuses an image built for a different partition layout than the device has.
+ if [[ "$1" =~ _companion_radio_(wifi|ble)$ ]] && [ -f ".pio/build/$1/partitions.bin" ]; then
+ if ! python3 scripts/partition_signature.py ".pio/build/$1/partitions.bin" > "out/$1.partsig"; then
+ rm -f "out/$1.partsig"
+ fi
+ fi
fi
# build .uf2 for nrf52 boards, copy .uf2 and .zip to out folder (e.g: RAK_4631_Repeater-v1.0.0-SHA.uf2)
@@ -202,6 +226,7 @@ build_firmware() {
cp .pio/build/$1/firmware.uf2 out/${FIRMWARE_FILENAME}.uf2 2>/dev/null || true
fi
+ export PLATFORMIO_BUILD_FLAGS="${SAVED_PIO_BUILD_FLAGS}"
}
# firmwares containing $1 will be built
diff --git a/dmc-docs/custom-cli.de.md b/dmc-docs/custom-cli.de.md
index 6282e452fdf..4113dbd763b 100644
--- a/dmc-docs/custom-cli.de.md
+++ b/dmc-docs/custom-cli.de.md
@@ -67,6 +67,20 @@ App aus. Wird über die Befehlskonsole der App, USB-Seriell oder Remote-CLI gese
Mit aktiviertem Repeat werden malformed/age/path-Nachrichten auch nicht weitergeleitet;
sender/text-Regeln blenden eine Nachricht nur für dich aus, sie wird für andere weiterhin weitergeleitet.
+## Companion SNMP, OTA und MAC
+
+Auf **Companion**-Builds, die sich mit WLAN verbinden können (ESP32 `*_companion_radio_wifi` und
+die ESP32-`*_companion_radio_ble`-Boards mit WLAN). Gespeichert in `/cnet.json`. Siehe
+[`docs/companion_snmp_ota_reference.md`](https://github.com/Dutch-MeshCore/MeshCore/blob/dmc-dev/docs/companion_snmp_ota_reference.md).
+
+- `get snmp` / `set snmp on|off` (standardmäßig **aus**; UDP 161, nur lesend, OID-Layout des Observers)
+- `get snmp.status` / `get snmp.community` / `set snmp.community ` (Standard `public`)
+- `ota check` / `ota update` / `ota status` (DutchMeshCore-OTA-Server, HTTPS)
+- `ota branch` / `ota branch stable|dev|default`
+- `get mac` / `get wifi.mac` / `get ble.mac` (jeder ESP32-Build)
+
+Kein OTA auf Boards mit 4 MB Flash (ThinkNode M2/M5, Xiao C3, LilyGo T-LoRa V2.1). Eine neue Partitionstabelle oder ein Board ohne zweiten App-Slot braucht weiterhin einen USB-Flash.
+
## Duty-Cycle-Region-Gating
`dc.gate.*`. Standardmäßig **aus**. Wirft Inter-Regions-Flood-Verkehr ab, wenn der
diff --git a/dmc-docs/custom-cli.en.md b/dmc-docs/custom-cli.en.md
index f30730dbf74..5f5d185489d 100644
--- a/dmc-docs/custom-cli.en.md
+++ b/dmc-docs/custom-cli.en.md
@@ -66,6 +66,20 @@ messages are never filtered. Saved to `/cfilter.json`. See
With repeat enabled, malformed/age/path messages are not repeated either; sender/text rules only
hide a message from you and it is still repeated for others.
+## Companion SNMP, OTA and MAC
+
+On **companion** builds that can join Wi-Fi (ESP32 `*_companion_radio_wifi`, and the ESP32
+`*_companion_radio_ble` boards with Wi-Fi). Saved to `/cnet.json`. See
+[`docs/companion_snmp_ota_reference.md`](https://github.com/Dutch-MeshCore/MeshCore/blob/dmc-dev/docs/companion_snmp_ota_reference.md).
+
+- `get snmp` / `set snmp on|off` (**off** by default; UDP 161, read-only, observer OID layout)
+- `get snmp.status` / `get snmp.community` / `set snmp.community ` (default `public`)
+- `ota check` / `ota update` / `ota status` (DutchMeshCore OTA server, HTTPS)
+- `ota branch` / `ota branch stable|dev|default`
+- `get mac` / `get wifi.mac` / `get ble.mac` (every ESP32 build)
+
+No OTA on 4 MB flash boards (ThinkNode M2/M5, Xiao C3, LilyGo T-LoRa V2.1). A new partition table, or a board without a second app slot, still needs a USB flash.
+
## Duty-cycle region gating
`dc.gate.*`. **Off** by default. Sheds inter-region flood traffic when the repeater has
diff --git a/dmc-docs/custom-cli.md b/dmc-docs/custom-cli.md
index 04e5777b20c..333d03e2755 100644
--- a/dmc-docs/custom-cli.md
+++ b/dmc-docs/custom-cli.md
@@ -67,6 +67,20 @@ directe berichten worden nooit gefilterd. Opgeslagen in `/cfilter.json`. Zie
Met repeat aan worden malformed/age/path-berichten ook niet doorgestuurd; sender/text-regels
verbergen een bericht alleen voor jou en het wordt nog steeds doorgestuurd voor anderen.
+## Companion SNMP, OTA en MAC
+
+Op **companion**-builds die met wifi kunnen verbinden (ESP32 `*_companion_radio_wifi`, en de
+ESP32 `*_companion_radio_ble`-boards met wifi). Opgeslagen in `/cnet.json`. Zie
+[`docs/companion_snmp_ota_reference.md`](https://github.com/Dutch-MeshCore/MeshCore/blob/dmc-dev/docs/companion_snmp_ota_reference.md).
+
+- `get snmp` / `set snmp on|off` (standaard **uit**; UDP 161, alleen lezen, OID-indeling van de observer)
+- `get snmp.status` / `get snmp.community` / `set snmp.community ` (standaard `public`)
+- `ota check` / `ota update` / `ota status` (DutchMeshCore OTA-server, HTTPS)
+- `ota branch` / `ota branch stable|dev|default`
+- `get mac` / `get wifi.mac` / `get ble.mac` (elke ESP32-build)
+
+Geen OTA op boards met 4 MB flash (ThinkNode M2/M5, Xiao C3, LilyGo T-LoRa V2.1). Een nieuwe partitietabel, of een board zonder tweede app-slot, heeft nog steeds een USB-flash nodig.
+
## Duty-cycle region gating
`dc.gate.*`. Standaard **uit**. Werpt inter-regio-floodverkeer af wanneer de repeater
diff --git a/docs/cli_commands.md b/docs/cli_commands.md
index be5ccb07d90..eb5cbb799b0 100644
--- a/docs/cli_commands.md
+++ b/docs/cli_commands.md
@@ -11,6 +11,7 @@ This document provides an overview of CLI commands that can be sent to MeshCore
- [Information](#info)
- [Packet Filter](#packet-filter-repeater-only)
- [Companion Receive Filter](#companion-receive-filter-companion-only)
+- [Companion SNMP, OTA and MAC](#companion-snmp-ota-and-mac-companion-only)
- [Configuration](#configuration)
- [Radio](#radio)
- [System](#system)
@@ -346,6 +347,26 @@ Find more information in [companion_filter_reference.md](companion_filter_refere
---
+## Companion SNMP, OTA and MAC (Companion Only)
+
+DMC companion builds that can join Wi-Fi (ESP32 `*_companion_radio_wifi`, and the ESP32 `*_companion_radio_ble` boards with Wi-Fi) have an SNMP agent and pull OTA from the DutchMeshCore OTA server. Sent through the app's command console, USB serial or remote CLI. Settings are persisted to `/cnet.json`.
+
+Find more information in [companion_snmp_ota_reference.md](companion_snmp_ota_reference.md)
+
+**Usage:**
+- `get snmp` / `set snmp ` — read-only SNMPv2c agent on UDP 161, off by default; same OID layout as the observer firmware
+- `get snmp.status` — `off`, `on, waiting for Wi-Fi` or `running on udp/161`
+- `get snmp.community` / `set snmp.community ` — default `public`; not readable over remote CLI
+- `ota check` — is a newer build published for this board?
+- `ota update` — check, then download, flash and reboot (HTTPS, verified)
+- `ota status` — `idle`, `starting` or the last failure
+- `ota branch` / `ota branch ` — the OTA channel
+- `get mac` / `get wifi.mac` / `get ble.mac` — factory MAC addresses (every ESP32 build; `n/a` elsewhere)
+
+**Note:** An update with a different partition table, or on a board without a second app slot, needs a USB flash. Boards with 4 MB flash (ThinkNode M2/M5, Xiao C3, LilyGo T-LoRa V2.1) have no OTA.
+
+---
+
## Configuration
### Radio
diff --git a/docs/companion_snmp_ota_reference.md b/docs/companion_snmp_ota_reference.md
new file mode 100644
index 00000000000..b2ead5714c0
--- /dev/null
+++ b/docs/companion_snmp_ota_reference.md
@@ -0,0 +1,146 @@
+# MeshCore Companion SNMP, OTA and MAC
+
+## Overview
+
+DMC-only network extras in the companion firmware:
+
+* **SNMP**: a read-only SNMPv2c agent on UDP 161, with the same OID layout as the
+ DMC observer firmware, so one poller template (LibreNMS, Zabbix, RTFM-EV) reads
+ companions and observers alike.
+* **OTA**: `ota check` / `ota update` pull the published build for this board from
+ the DutchMeshCore OTA server (`ota.dutchmeshcore.nl/companion`), the same server
+ the observer firmware updates from.
+* **MAC**: `get mac`, `get wifi.mac`, `get ble.mac` show the factory MAC addresses,
+ for DHCP reservations and MAC filtering.
+
+Commands are sent through the app's command console (`CMD_RUN_CLI_COMMAND`), USB
+serial, or remote CLI. Settings are saved to `/cnet.json`.
+
+---
+
+# Which builds have what
+
+SNMP and OTA need a Wi-Fi network, so they exist on ESP32 builds that can join one:
+
+| Build | SNMP | OTA | MAC |
+| --- | --- | --- | --- |
+| ESP32 `*_companion_radio_wifi` | yes | yes | yes |
+| ESP32 `*_companion_radio_wifi` with 4 MB flash (ThinkNode M2, ThinkNode M5, Xiao C3, LilyGo T-LoRa V2.1) | yes | no | yes |
+| ESP32 `*_companion_radio_ble` with Wi-Fi (Heltec V3, WSL3, V4, V4 R8, Wireless Paper, RAK3112, ThinkNode M7, Xiao S3, Xiao S3 WIO) | yes | yes | yes |
+| Other ESP32 `*_companion_radio_ble` | no | no | yes |
+| nRF52, RP2040 | no | no | `n/a` |
+
+OTA needs a TLS stack (~180 KB of flash) that the 4 MB boards have no room for, so
+they set `DMC_COMPANION_NO_OTA` and are updated over USB.
+
+On the BLE builds with Wi-Fi, Wi-Fi is off until it is set up:
+`set wifi.ssid `, `set wifi.pwd `, `set wifi.enabled 1` (a number:
+`on` reads as 0 and turns Wi-Fi off), then reboot.
+
+---
+
+# SNMP
+
+Off by default. Until `set snmp on` the agent is never started, so it costs no RAM.
+When on, it runs whenever Wi-Fi has an address and stops while the link is down.
+
+| Command | |
+| --- | --- |
+| `get snmp` | `on` or `off` |
+| `set snmp on\|off` | start or stop the agent (applies live) |
+| `get snmp.status` | `off`, `on, waiting for Wi-Fi` or `running on udp/161` |
+| `get snmp.community` | the community string (default `public`); not over remote CLI |
+| `set snmp.community ` | 1 to 32 characters, applies to the next request |
+
+The community string is the only access control: change it from `public` on any
+network you do not fully trust. The agent is read-only.
+
+## OIDs
+
+Base `.1.3.6.1.4.1.99999` (the same temporary enterprise number as the observer).
+Shared numbers keep the observer's meaning, type and unit. Anything the companion
+has no data for is not registered and reads `noSuchObject`, never a fake 0.
+
+| OID | Type | Value |
+| --- | --- | --- |
+| `.1.1.0` | Integer | uptime (s) |
+| `.1.2.0` | String | firmware version |
+| `.1.3.0` | String | node name |
+| `.1.4.0` | Integer | battery (mV), read every 30 s |
+| `.1.5.0` | Integer | MCU temperature (°C), `-127` when the board has no sensor |
+| `.1.6.0` | Integer | reset reason code |
+| `.1.7.0` | String | reset reason |
+| `.2.1.0` - `.2.3.0` | Integer | packets received, sent, receive errors |
+| `.2.4.0` | Integer | noise floor (dBm) |
+| `.2.5.0` | Integer | last RSSI (dBm) |
+| `.2.6.0` | Integer | last SNR × 4 |
+| `.2.7.0` - `.2.10.0` | Integer | sent flood, sent direct, received flood, received direct |
+| `.2.11.0` | Integer | TX air time (s) |
+| `.2.12.0` | Integer | TX queue length |
+| `.2.13.0`, `.2.14.0` | Counter32 | flood, direct duplicates |
+| `.2.15.0` | Counter32 | RX air time (s) |
+| `.2.17.0` | Integer | error flags (1 queue full, 2 CAD timeout, 4 start RX timeout) |
+| `.4.1.0` - `.4.5.0` | Integer | free heap, largest block, free internal, free PSRAM, lowest free heap |
+| `.5.1.0` | Integer | Wi-Fi RSSI (dBm), `-127` when not connected |
+| `.9.1.0` | Integer | app connected over Wi-Fi (0/1) |
+| `.9.3.0` | Integer | contacts |
+| `.9.4.0` | Integer | channels |
+| `.10.1.0` | Integer | receive filter on (0/1) |
+| `.10.2.0` - `.10.6.0` | Counter32 | receive filter: malformed, age, path, sender, text |
+
+Not registered: `.2.16` (TX duty cycle), `.3` (MQTT and NTP), `.6` (duty gate),
+`.7` (repeater packet filter), `.8` (neighbours), `.9.2` (meshcomod WebSocket clients).
+
+```
+snmpwalk -v2c -c public .1.3.6.1.4.1.99999
+```
+
+---
+
+# OTA
+
+| Command | |
+| --- | --- |
+| `ota check` | is a newer build published? |
+| `ota update` | check, then download, flash and reboot |
+| `ota status` | `idle`, `starting`, or the last failure |
+| `ota branch` | the channel in use |
+| `ota branch stable\|dev\|default` | pull from the stable or dev channel; `default` = the channel the build was made for |
+
+`ota update` answers straight away, then starts the download a few seconds later so
+the reply still reaches the app. Progress is printed on USB serial; the device
+reboots into the new build when it is written. A failure leaves the running build
+in place, and `ota status` shows why.
+
+How it works:
+
+1. The device reads `/.json`, where `` is
+ `https://ota.dutchmeshcore.nl/companion/v` (stable) or `.../companion/dev/v`
+ (dev) and `` is the PlatformIO env name baked in by `build.sh`. The server
+ builds these manifests from the `dmc-companion-*` releases.
+2. Versions compare numerically (`v1.18.0` < `v1.18.1`). The same version from
+ another commit counts as an update (a newer dev build). It never downgrades.
+3. `ota check` reads the manifest over plain HTTP. `ota update` reads it again over
+ HTTPS and downloads the image over HTTPS. Both are verified against a short list
+ of root CAs in `CompanionOtaCerts.h` (regenerate with
+ `scripts/gen_companion_ota_certs.py`).
+4. The image must sit under the same product's `fw/` path on the server.
+5. The update is refused, and needs a USB flash, when the new build has a different
+ partition table (the manifest's `partSig` against the flashed table) or the
+ board has no second app slot.
+
+A build made without `build.sh` has no server bases and answers
+`OTA not configured`.
+
+---
+
+# MAC
+
+| Command | Reply |
+| --- | --- |
+| `get mac` | `> wifi AA:BB:CC:DD:EE:FF, ble AA:BB:CC:DD:EE:01` |
+| `get wifi.mac` | Wi-Fi station MAC |
+| `get ble.mac` | Bluetooth MAC |
+
+Read from the chip's eFuse, so they answer with Wi-Fi or BLE off. Non-ESP32 builds
+reply `n/a`.
diff --git a/examples/companion_radio/CompanionNet.cpp b/examples/companion_radio/CompanionNet.cpp
new file mode 100644
index 00000000000..f1fe1bc699c
--- /dev/null
+++ b/examples/companion_radio/CompanionNet.cpp
@@ -0,0 +1,274 @@
+// DMC companion network extras: settings, CLI and the MyMesh side. Lives in its
+// own file so the shared upstream MyMesh.{cpp,h} only carry one-line calls into it.
+
+#include "MyMesh.h"
+#include
+
+#ifdef ESP32
+ #if __has_include()
+ #include
+ #else
+ #include
+ #endif
+#endif
+#ifdef DMC_COMPANION_WIFI
+ #include
+#endif
+
+void CompanionNetPrefs::reset() {
+ snmp_on = false;
+ strcpy(snmp_community, "public");
+ ota_channel = OTA_CHANNEL_NATIVE;
+}
+
+void CompanionNetPrefs::sanitize() {
+ snmp_community[sizeof(snmp_community) - 1] = '\0';
+ if (snmp_community[0] == '\0') strcpy(snmp_community, "public");
+ if (ota_channel > OTA_CHANNEL_DEV) ota_channel = OTA_CHANNEL_NATIVE;
+}
+
+bool loadCompanionNet(CompanionNetPrefs& prefs, DataStore& store) {
+ FILESYSTEM* fs = store.getPrimaryFS();
+ if (fs == nullptr || !fs->exists(CNET_PREFS_FILE)) return true; // nothing saved yet: defaults
+
+ File file = store.openRead(fs, CNET_PREFS_FILE);
+ if (!file) return false;
+ prefs.reset();
+ bool ok = prefs.loadSerial(file);
+ file.close();
+ prefs.sanitize();
+ return ok;
+}
+
+bool saveCompanionNet(CompanionNetPrefs& prefs, DataStore& store) {
+ FILESYSTEM* fs = store.getPrimaryFS();
+ if (fs == nullptr) return false;
+
+#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
+ fs->remove(CNET_PREFS_FILE);
+ File file = fs->open(CNET_PREFS_FILE, FILE_O_WRITE);
+#elif defined(RP2040_PLATFORM)
+ File file = fs->open(CNET_PREFS_FILE, "w");
+#else
+ File file = fs->open(CNET_PREFS_FILE, "w", true);
+#endif
+ if (!file) return false;
+ bool ok = prefs.saveSerial(file);
+ file.close();
+ return ok;
+}
+
+// ---- MAC addresses ----------------------------------------------------------
+// The factory MACs, read from eFuse, so they answer with the radio off too.
+// Needed for DHCP reservations and MAC filtering.
+
+#ifdef ESP32
+static void formatMac(esp_mac_type_t type, char* out) {
+ uint8_t m[6] = {0};
+ if (esp_read_mac(m, type) != ESP_OK) {
+ strcpy(out, "n/a");
+ return;
+ }
+ sprintf(out, "%02X:%02X:%02X:%02X:%02X:%02X", m[0], m[1], m[2], m[3], m[4], m[5]);
+}
+#endif
+
+bool companionMacCommand(const char* command, char* reply) {
+ bool all = strcmp(command, "get mac") == 0;
+ bool wifi = strcmp(command, "get wifi.mac") == 0;
+ bool ble = strcmp(command, "get ble.mac") == 0;
+ if (!all && !wifi && !ble) return false;
+#ifdef ESP32
+ char w[18], b[18];
+ formatMac(ESP_MAC_WIFI_STA, w);
+ formatMac(ESP_MAC_BT, b);
+ if (all) sprintf(reply, "> wifi %s, ble %s", w, b);
+ else if (wifi) sprintf(reply, "> %s", w);
+ else sprintf(reply, "> %s", b);
+#else
+ strcpy(reply, "> n/a");
+#endif
+ return true;
+}
+
+// ---- OTA ---------------------------------------------------------------------
+
+#ifdef DMC_COMPANION_OTA
+static bool s_ota_pending = false; // `ota update` accepted, flash starts from the main loop
+static uint32_t s_ota_start_at = 0;
+static char s_ota_base[96];
+static char s_ota_last[CNET_REPLY_SIZE] = "";
+
+static const char* otaBase(const CompanionNetPrefs& prefs) {
+ return ota_channel_base(prefs.ota_channel, COMPANION_OTA_BASE, COMPANION_OTA_BASE_STABLE, COMPANION_OTA_BASE_DEV);
+}
+
+static const char* otaChannelName(const char* base) {
+ return ota_channel_name(base, COMPANION_OTA_BASE_STABLE, COMPANION_OTA_BASE_DEV);
+}
+#endif
+
+static bool isOtaCommand(const char* command) {
+ return strcmp(command, "ota") == 0 || strncmp(command, "ota ", 4) == 0;
+}
+
+static bool otaCommand(CompanionNetPrefs& prefs, DataStore& store, const char* command, char* reply) {
+#ifndef DMC_COMPANION_WIFI
+ (void)prefs; (void)store; (void)command;
+ strcpy(reply, "Error: OTA needs a Wi-Fi build (*_companion_radio_wifi)");
+ return true;
+#elif defined(DMC_COMPANION_NO_OTA)
+ (void)prefs; (void)store; (void)command;
+ strcpy(reply, "Error: no OTA on 4 MB flash boards, update over USB");
+ return true;
+#elif !defined(DMC_COMPANION_OTA)
+ (void)prefs; (void)store; (void)command;
+ strcpy(reply, "Error: OTA not configured (build with build.sh)");
+ return true;
+#else
+ const char* arg = command[3] ? &command[4] : "";
+ if (strcmp(arg, "check") == 0) {
+ companionOtaRun(otaBase(prefs), true, reply);
+ return true;
+ }
+ if (strcmp(arg, "update") == 0) {
+ if (s_ota_pending) {
+ strcpy(reply, "> OTA already starting");
+ return true;
+ }
+ char found[CNET_REPLY_SIZE];
+ if (!companionOtaRun(otaBase(prefs), true, found)) {
+ strcpy(reply, found); // up to date, or why it cannot update
+ return true;
+ }
+ // Flash from the main loop a moment later, so this reply still reaches the
+ // app or the mesh before the download takes the CPU and the reboot follows.
+ StrHelper::strncpy(s_ota_base, otaBase(prefs), sizeof(s_ota_base));
+ s_ota_pending = true;
+ s_ota_start_at = millis() + 3000;
+ snprintf(reply, CNET_REPLY_SIZE, "> %s; installing, reboots when done", found);
+ return true;
+ }
+ if (strcmp(arg, "status") == 0) {
+ if (s_ota_pending) strcpy(reply, "> starting");
+ else if (s_ota_last[0]) snprintf(reply, CNET_REPLY_SIZE, "> last: %s", s_ota_last);
+ else strcpy(reply, "> idle");
+ return true;
+ }
+ if (strcmp(arg, "branch") == 0) {
+ const char* base = otaBase(prefs);
+ snprintf(reply, CNET_REPLY_SIZE, "> %s%s", otaChannelName(base),
+ prefs.ota_channel == OTA_CHANNEL_NATIVE ? " (build default)" : "");
+ return true;
+ }
+ if (strncmp(arg, "branch ", 7) == 0) {
+ uint8_t ch;
+ if (!ota_channel_parse(&arg[7], &ch)) {
+ strcpy(reply, "Error: use ota branch stable|dev|default");
+ return true;
+ }
+ prefs.ota_channel = ch;
+ saveCompanionNet(prefs, store);
+ snprintf(reply, CNET_REPLY_SIZE, "> OTA channel is now %s", otaChannelName(otaBase(prefs)));
+ return true;
+ }
+ strcpy(reply, "> ota [ check | update | status | branch [stable|dev|default] ]");
+ return true;
+#endif
+}
+
+// ---- SNMP --------------------------------------------------------------------
+
+static bool isSnmpCommand(const char* command) {
+ return strcmp(command, "get snmp") == 0 || strncmp(command, "get snmp.", 9) == 0 ||
+ strncmp(command, "set snmp ", 9) == 0 || strncmp(command, "set snmp.", 9) == 0;
+}
+
+static bool snmpCommand(CompanionNetPrefs& prefs, DataStore& store, const char* command, bool remote, char* reply) {
+#ifndef DMC_COMPANION_SNMP
+ (void)prefs; (void)store; (void)command; (void)remote;
+ strcpy(reply, "Error: SNMP is not in this build");
+ return true;
+#else
+ if (strcmp(command, "get snmp") == 0) {
+ strcpy(reply, prefs.snmp_on ? "> on" : "> off");
+ return true;
+ }
+ if (strcmp(command, "get snmp.status") == 0) {
+ if (!prefs.snmp_on) strcpy(reply, "> off");
+ else if (companionSnmpRunning()) strcpy(reply, "> running on udp/161");
+ else strcpy(reply, "> on, waiting for Wi-Fi");
+ return true;
+ }
+ if (strncmp(command, "set snmp ", 9) == 0) {
+ const char* v = &command[9];
+ bool on;
+ if (strcmp(v, "on") == 0 || strcmp(v, "1") == 0) on = true;
+ else if (strcmp(v, "off") == 0 || strcmp(v, "0") == 0) on = false;
+ else { strcpy(reply, "Error: use set snmp on|off"); return true; }
+ prefs.snmp_on = on; // the main loop starts or stops the agent
+ saveCompanionNet(prefs, store);
+ sprintf(reply, "> snmp is now %s", on ? "on" : "off");
+ return true;
+ }
+ if (strcmp(command, "get snmp.community") == 0) {
+ if (remote) { // the community is the only access control: keep it off the mesh
+ strcpy(reply, "Error: not available remotely");
+ } else {
+ sprintf(reply, "> %s", prefs.snmp_community);
+ }
+ return true;
+ }
+ if (strncmp(command, "set snmp.community ", 19) == 0) {
+ const char* v = &command[19];
+ size_t len = strlen(v);
+ if (len == 0 || len >= sizeof(prefs.snmp_community)) {
+ sprintf(reply, "Error: 1 to %d characters", (int)sizeof(prefs.snmp_community) - 1);
+ return true;
+ }
+ strcpy(prefs.snmp_community, v);
+ saveCompanionNet(prefs, store);
+ companionSnmpSetCommunity(prefs.snmp_community);
+ strcpy(reply, "> snmp.community updated");
+ return true;
+ }
+ strcpy(reply, "> snmp: get snmp | set snmp on|off | get snmp.status | get|set snmp.community");
+ return true;
+#endif
+}
+
+// ---- MyMesh ------------------------------------------------------------------
+
+// From handleCommand(). sender_timestamp is 0 for USB serial and the app, and
+// the sender's clock for remote CLI over the mesh.
+bool MyMesh::dmcNetCommand(const char* command, uint32_t sender_timestamp, char* reply) {
+ if (companionMacCommand(command, reply)) return true;
+ if (isOtaCommand(command)) return otaCommand(_cnet, *_store, command, reply);
+ if (isSnmpCommand(command)) return snmpCommand(_cnet, *_store, command, sender_timestamp != 0, reply);
+ return false;
+}
+
+// From the main loop.
+void MyMesh::dmcNetLoop() {
+#ifdef DMC_COMPANION_SNMP
+ companionSnmpLoop(*this, _cnet, WiFi.status() == WL_CONNECTED);
+#endif
+#ifdef DMC_COMPANION_OTA
+ if (s_ota_pending && (int32_t)(millis() - s_ota_start_at) >= 0) {
+ s_ota_pending = false;
+ companionOtaRun(s_ota_base, false, s_ota_last); // reboots on success
+ Serial.printf("OTA: %s\n", s_ota_last);
+ }
+#endif
+}
+
+int MyMesh::dmcNumChannels() {
+ int n = 0;
+#ifdef MAX_GROUP_CHANNELS
+ ChannelDetails ch;
+ for (int i = 0; i < MAX_GROUP_CHANNELS; i++) {
+ if (getChannel(i, ch) && ch.name[0]) n++;
+ }
+#endif
+ return n;
+}
diff --git a/examples/companion_radio/CompanionNet.h b/examples/companion_radio/CompanionNet.h
new file mode 100644
index 00000000000..fa5130f5d72
--- /dev/null
+++ b/examples/companion_radio/CompanionNet.h
@@ -0,0 +1,78 @@
+#pragma once
+
+#include
+#include
+
+// DMC companion network extras, for builds that can join a Wi-Fi network:
+// - SNMP agent (read-only SNMPv2c on UDP 161), off until `set snmp on`
+// - pull OTA from the DutchMeshCore OTA server (`ota check|update|status|branch`)
+// - `get mac`, `get wifi.mac`, `get ble.mac` (every ESP32 build)
+// Ported from meshcomod (Elektr0Vodka/meshcomod) and the DMC observer firmware.
+// Settings persist as JSON in CNET_PREFS_FILE, apart from NodePrefs.
+
+#if defined(ESP32) && defined(ENABLE_WIFI_INTERFACE)
+ #define DMC_COMPANION_WIFI 1
+#endif
+
+// build.sh bakes the server bases and the variant (the PlatformIO env name) in.
+// Boards with 4 MB flash set DMC_COMPANION_NO_OTA: the TLS stack the download
+// needs (~180 KB) leaves them no headroom.
+#if defined(DMC_COMPANION_WIFI) && !defined(DMC_COMPANION_NO_OTA) && \
+ defined(COMPANION_OTA_VARIANT) && defined(COMPANION_OTA_BASE) && \
+ defined(COMPANION_OTA_BASE_STABLE) && defined(COMPANION_OTA_BASE_DEV)
+ #define DMC_COMPANION_OTA 1
+#endif
+
+// SNMP needs the 0neblock/SNMP_Agent library in the env's lib_deps.
+#if defined(DMC_COMPANION_WIFI) && __has_include()
+ #define DMC_COMPANION_SNMP 1
+#endif
+
+#define CNET_PREFS_FILE "/cnet.json"
+
+// Same budget as the companion filter: remote CLI over the mesh is the tightest.
+#define CNET_REPLY_SIZE 158
+
+#define CNET_SNMP_COMMUNITY_MAX 33 // incl. the terminator
+
+class CompanionNetPrefs : public ConfigSerializer {
+public:
+ bool snmp_on = false;
+ char snmp_community[CNET_SNMP_COMMUNITY_MAX] = "public";
+ uint8_t ota_channel = 0; // OTA_CHANNEL_NATIVE / _STABLE / _DEV (helpers/OtaManifest.h)
+
+ void reset();
+ void sanitize();
+
+protected:
+ void structure() override {
+ def("snmp", snmp_on);
+ def("snmp_comm", snmp_community, sizeof(snmp_community));
+ def("ota_ch", ota_channel);
+ }
+};
+
+class DataStore;
+bool loadCompanionNet(CompanionNetPrefs& prefs, DataStore& store);
+bool saveCompanionNet(CompanionNetPrefs& prefs, DataStore& store);
+
+// MAC address replies; true when the command was one of them. Every platform.
+bool companionMacCommand(const char* command, char* reply);
+
+#ifdef DMC_COMPANION_OTA
+// One OTA run against base. dry_run = `ota check` (plain HTTP manifest, report
+// only); otherwise a TLS-verified manifest fetch and, when an update applies, the
+// flash and a reboot. Returns true when an installable update exists (check) or
+// false with the reason in reply (update returns only on failure).
+bool companionOtaRun(const char* base, bool dry_run, char* reply);
+#endif
+
+#ifdef DMC_COMPANION_SNMP
+class MyMesh;
+// Main loop. link_up: Wi-Fi associated with an address.
+void companionSnmpLoop(MyMesh& mesh, CompanionNetPrefs& prefs, bool link_up);
+// `get snmp.status`.
+bool companionSnmpRunning();
+// The community changed: applies to the next request.
+void companionSnmpSetCommunity(const char* community);
+#endif
diff --git a/examples/companion_radio/CompanionOta.cpp b/examples/companion_radio/CompanionOta.cpp
new file mode 100644
index 00000000000..3f1c0fd738f
--- /dev/null
+++ b/examples/companion_radio/CompanionOta.cpp
@@ -0,0 +1,235 @@
+// DMC companion pull OTA from the DutchMeshCore OTA server (`ota check` /
+// `ota update`). The decisions are in helpers/OtaManifest.h (native tests); this
+// file does the network and flash side.
+//
+// The device reads /.json, the manifest the server
+// builds from this repo's dmc-companion-* releases:
+// {"file":"https://.../companion/fw//.bin","version":"v1.18.0","hash":"abc1234","partSig":"..."}
+// `ota check` fetches it over plain HTTP (version info only, no TLS heap). An
+// update fetches it again over HTTPS and streams the image into the inactive app
+// slot, both verified against the roots in CompanionOtaCerts.h, then reboots.
+// The flow follows the DMC observer's otaFromManifest (dmc-observer-dev).
+
+#include "CompanionNet.h"
+
+#ifdef DMC_COMPANION_OTA
+
+#include "MyMesh.h"
+#include "CompanionOtaCerts.h"
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#define OTA_MANIFEST_MAX 1024 // a manifest is ~250 bytes
+
+// Signature of the partition table flashed on this device, in the format of
+// scripts/partition_signature.py (which build.sh runs to produce the manifest's partSig).
+static void devicePartitionSignature(char* out, size_t out_sz) {
+ OtaPartEntry e[24];
+ int n = 0;
+ esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_ANY, ESP_PARTITION_SUBTYPE_ANY, nullptr);
+ while (it != nullptr && n < (int)(sizeof(e) / sizeof(e[0]))) {
+ const esp_partition_t* p = esp_partition_get(it);
+ e[n].type = (uint8_t)p->type;
+ e[n].subtype = (uint8_t)p->subtype;
+ e[n].offset = p->address;
+ e[n].size = p->size;
+ n++;
+ it = esp_partition_next(it);
+ }
+ esp_partition_iterator_release(it); // safe on NULL (loop exhausted)
+ ota_partsig_format(e, n, out, out_sz);
+}
+
+// Fetch the manifest body into buf. tls: verified HTTPS, else plain HTTP.
+static bool fetchManifest(const char* base, bool tls, char* buf, size_t buf_sz, char* reply) {
+ char murl[192];
+ if (tls) {
+ int n = snprintf(murl, sizeof(murl), "%s/%s.json", base, COMPANION_OTA_VARIANT);
+ if (n <= 0 || (size_t)n >= sizeof(murl) || !ota_url_is_https(murl)) {
+ strcpy(reply, "Error: OTA manifest URL");
+ return false;
+ }
+ } else if (!ota_manifest_url(base, COMPANION_OTA_VARIANT, murl, sizeof(murl))) {
+ strcpy(reply, "Error: OTA manifest URL");
+ return false;
+ }
+
+ WiFiClient plain;
+ WiFiClientSecure secure;
+ HTTPClient http;
+ http.setTimeout(15000);
+ http.setUserAgent("MeshCore-DMC-Companion-OTA/1.0");
+ // HTTP/1.0: a CDN answers 1.1 with a chunked body, which is not read raw here.
+ http.useHTTP10(true);
+ bool begun;
+ if (tls) {
+ secure.setCACert(COMPANION_OTA_ROOT_CERTS);
+ secure.setTimeout(15);
+ begun = http.begin(secure, murl);
+ } else {
+ begun = http.begin(plain, murl);
+ }
+ if (!begun) {
+ strcpy(reply, "Error: OTA manifest connect failed");
+ return false;
+ }
+ int code = http.GET();
+ if (code != HTTP_CODE_OK) {
+ if (code == 404) snprintf(reply, CNET_REPLY_SIZE, "Error: no published build for %s", COMPANION_OTA_VARIANT);
+ else if (code < 0) snprintf(reply, CNET_REPLY_SIZE, "Error: OTA manifest %s", http.errorToString(code).c_str());
+ else snprintf(reply, CNET_REPLY_SIZE, "Error: OTA manifest HTTP %d", code);
+ http.end();
+ return false;
+ }
+
+ WiFiClient* stream = http.getStreamPtr();
+ size_t len = 0;
+ uint32_t deadline = millis() + 15000;
+ while (len + 1 < buf_sz && (stream->connected() || stream->available()) && (int32_t)(millis() - deadline) < 0) {
+ int avail = stream->available();
+ if (avail <= 0) { delay(5); continue; }
+ size_t want = buf_sz - 1 - len;
+ if ((size_t)avail < want) want = (size_t)avail;
+ len += stream->readBytes(buf + len, want);
+ }
+ buf[len] = 0;
+ bool overflow = (len + 1 >= buf_sz) && stream->available() > 0;
+ http.end();
+ if (len == 0 || overflow) {
+ strcpy(reply, overflow ? "Error: OTA manifest too large" : "Error: OTA manifest empty");
+ return false;
+ }
+ return true;
+}
+
+static bool otaRunImpl(const char* base, bool dry_run, char* reply) {
+ if (WiFi.status() != WL_CONNECTED) {
+ strcpy(reply, "Error: Wi-Fi not connected");
+ return false;
+ }
+
+ char body[OTA_MANIFEST_MAX];
+ if (!fetchManifest(base, !dry_run, body, sizeof(body), reply)) return false;
+
+ char url[256], version[32], hash[24], partsig[256];
+ ota_json_string(body, "file", url, sizeof(url));
+ ota_json_string(body, "version", version, sizeof(version));
+ ota_json_string(body, "hash", hash, sizeof(hash));
+ ota_json_string(body, "partSig", partsig, sizeof(partsig));
+ if (!url[0]) {
+ strcpy(reply, "Error: OTA manifest has no file");
+ return false;
+ }
+ // The manifest may only send the device to this product's firmware path, over TLS.
+ if (!ota_firmware_url_allowed(url, base) || !ota_url_is_https(url)) {
+ strcpy(reply, "Error: OTA manifest file URL not allowed");
+ return false;
+ }
+
+ char avail[64];
+ if (version[0]) snprintf(avail, sizeof(avail), "%s (%s)", version, hash);
+ else snprintf(avail, sizeof(avail), "%s", hash);
+
+ OtaVerdict verdict = ota_compare(FIRMWARE_VERSION, version, hash);
+ if (verdict == OTA_VERDICT_CURRENT) {
+ snprintf(reply, CNET_REPLY_SIZE, "> up to date: %s", avail);
+ return false;
+ }
+ if (verdict == OTA_VERDICT_OLDER) {
+ snprintf(reply, CNET_REPLY_SIZE, "> running %s, newer than published %s", FIRMWARE_VERSION, avail);
+ return false;
+ }
+
+ // OTA writes the app slot only: an image built for another partition layout,
+ // or a build without a second app slot, needs a USB flash. A manifest without
+ // partSig skips the layout guard.
+ if (partsig[0]) {
+ char own_sig[256];
+ devicePartitionSignature(own_sig, sizeof(own_sig));
+ if (strcmp(own_sig, partsig) != 0) {
+ snprintf(reply, CNET_REPLY_SIZE, "> update %s needs a USB flash (partition layout changed)", avail);
+ return false;
+ }
+ }
+ if (esp_ota_get_next_update_partition(nullptr) == nullptr) {
+ snprintf(reply, CNET_REPLY_SIZE, "> update %s needs a USB flash (single app slot)", avail);
+ return false;
+ }
+
+ const char* kind = (verdict == OTA_VERDICT_REBUILD) ? " (same version, other build)" : "";
+ if (dry_run) {
+ snprintf(reply, CNET_REPLY_SIZE, "> update available: %s%s", avail, kind);
+ return true;
+ }
+
+ // --- flash ---------------------------------------------------------------
+ Serial.printf("OTA: %s -> %s, downloading %s\n", FIRMWARE_VERSION, avail, url);
+ board.setInhibitSleep(true); // stay awake through the flash
+
+ WiFiClientSecure client;
+ client.setCACert(COMPANION_OTA_ROOT_CERTS);
+ client.setTimeout(20);
+
+ // Non-capturing lambdas + a file-static decile, so the global httpUpdate never
+ // holds a dangling reference after this function returns.
+ static int progress_decile;
+ progress_decile = -1;
+ httpUpdate.onProgress([](int cur, int total) {
+ if (total <= 0) return;
+ int d = (int)((int64_t)cur * 10 / total);
+ if (d != progress_decile) { progress_decile = d; Serial.printf("OTA: %d%%\n", d * 10); }
+ });
+ httpUpdate.onEnd([]() { Serial.println("OTA: write complete, rebooting"); });
+ httpUpdate.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS);
+ httpUpdate.rebootOnUpdate(true);
+ t_httpUpdate_return ret = httpUpdate.update(client, url);
+
+ // Only reached on failure: a good image reboots inside update().
+ board.setInhibitSleep(false);
+ if (ret == HTTP_UPDATE_NO_UPDATES) {
+ snprintf(reply, CNET_REPLY_SIZE, "Error: OTA server sent no image for %s", avail);
+ } else {
+ snprintf(reply, CNET_REPLY_SIZE, "Error: OTA failed (%d): %s", (int)ret, httpUpdate.getLastErrorString().c_str());
+ }
+ return false;
+}
+
+// The TLS handshake and the update need far more stack than the loop task has,
+// especially when reached through the mesh receive path. Run the work in its own
+// 24 KB task and wait for it; the stack is freed when it ends.
+struct OtaTaskArgs {
+ const char* base;
+ bool dry_run;
+ char* reply;
+ volatile bool result;
+ volatile bool done;
+};
+
+static void otaTask(void* param) {
+ OtaTaskArgs* a = static_cast(param);
+ a->result = otaRunImpl(a->base, a->dry_run, a->reply);
+ a->done = true;
+ vTaskDelete(nullptr);
+}
+
+bool companionOtaRun(const char* base, bool dry_run, char* reply) {
+ reply[0] = 0;
+ OtaTaskArgs args = { base, dry_run, reply, false, false };
+ // No core pinning: the ESP32-C3 has only one.
+ if (xTaskCreate(otaTask, "ota", 24576, &args, 5, nullptr) != pdPASS) {
+ strcpy(reply, "Error: OTA task could not start (low memory)");
+ return false;
+ }
+ while (!args.done) delay(20); // delay() yields to the OTA task
+ return args.result;
+}
+
+#endif // DMC_COMPANION_OTA
diff --git a/examples/companion_radio/CompanionOtaCerts.h b/examples/companion_radio/CompanionOtaCerts.h
new file mode 100644
index 00000000000..cf36555899d
--- /dev/null
+++ b/examples/companion_radio/CompanionOtaCerts.h
@@ -0,0 +1,248 @@
+#pragma once
+
+// Generated by scripts/gen_companion_ota_certs.py from the Mozilla root store
+// (certifi 2025.11.12). Do not edit by hand.
+//
+// Roots the companion trusts for the OTA download from ota.dutchmeshcore.nl
+// (Cloudflare: Google Trust Services, Let's Encrypt, SSL.com).
+
+static const char COMPANION_OTA_ROOT_CERTS[] =
+ // GTS Root R1
+ "-----BEGIN CERTIFICATE-----\n"
+ "MIIFVzCCAz+gAwIBAgINAgPlk28xsBNJiGuiFzANBgkqhkiG9w0BAQwFADBHMQsw\n"
+ "CQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEU\n"
+ "MBIGA1UEAxMLR1RTIFJvb3QgUjEwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAw\n"
+ "MDAwWjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZp\n"
+ "Y2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjEwggIiMA0GCSqGSIb3DQEBAQUA\n"
+ "A4ICDwAwggIKAoICAQC2EQKLHuOhd5s73L+UPreVp0A8of2C+X0yBoJx9vaMf/vo\n"
+ "27xqLpeXo4xL+Sv2sfnOhB2x+cWX3u+58qPpvBKJXqeqUqv4IyfLpLGcY9vXmX7w\n"
+ "Cl7raKb0xlpHDU0QM+NOsROjyBhsS+z8CZDfnWQpJSMHobTSPS5g4M/SCYe7zUjw\n"
+ "TcLCeoiKu7rPWRnWr4+wB7CeMfGCwcDfLqZtbBkOtdh+JhpFAz2weaSUKK0Pfybl\n"
+ "qAj+lug8aJRT7oM6iCsVlgmy4HqMLnXWnOunVmSPlk9orj2XwoSPwLxAwAtcvfaH\n"
+ "szVsrBhQf4TgTM2S0yDpM7xSma8ytSmzJSq0SPly4cpk9+aCEI3oncKKiPo4Zor8\n"
+ "Y/kB+Xj9e1x3+naH+uzfsQ55lVe0vSbv1gHR6xYKu44LtcXFilWr06zqkUspzBmk\n"
+ "MiVOKvFlRNACzqrOSbTqn3yDsEB750Orp2yjj32JgfpMpf/VjsPOS+C12LOORc92\n"
+ "wO1AK/1TD7Cn1TsNsYqiA94xrcx36m97PtbfkSIS5r762DL8EGMUUXLeXdYWk70p\n"
+ "aDPvOmbsB4om3xPXV2V4J95eSRQAogB/mqghtqmxlbCluQ0WEdrHbEg8QOB+DVrN\n"
+ "VjzRlwW5y0vtOUucxD/SVRNuJLDWcfr0wbrM7Rv1/oFB2ACYPTrIrnqYNxgFlQID\n"
+ "AQABo0IwQDAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4E\n"
+ "FgQU5K8rJnEaK0gnhS9SZizv8IkTcT4wDQYJKoZIhvcNAQEMBQADggIBAJ+qQibb\n"
+ "C5u+/x6Wki4+omVKapi6Ist9wTrYggoGxval3sBOh2Z5ofmmWJyq+bXmYOfg6LEe\n"
+ "QkEzCzc9zolwFcq1JKjPa7XSQCGYzyI0zzvFIoTgxQ6KfF2I5DUkzps+GlQebtuy\n"
+ "h6f88/qBVRRiClmpIgUxPoLW7ttXNLwzldMXG+gnoot7TiYaelpkttGsN/H9oPM4\n"
+ "7HLwEXWdyzRSjeZ2axfG34arJ45JK3VmgRAhpuo+9K4l/3wV3s6MJT/KYnAK9y8J\n"
+ "ZgfIPxz88NtFMN9iiMG1D53Dn0reWVlHxYciNuaCp+0KueIHoI17eko8cdLiA6Ef\n"
+ "MgfdG+RCzgwARWGAtQsgWSl4vflVy2PFPEz0tv/bal8xa5meLMFrUKTX5hgUvYU/\n"
+ "Z6tGn6D/Qqc6f1zLXbBwHSs09dR2CQzreExZBfMzQsNhFRAbd03OIozUhfJFfbdT\n"
+ "6u9AWpQKXCBfTkBdYiJ23//OYb2MI3jSNwLgjt7RETeJ9r/tSQdirpLsQBqvFAnZ\n"
+ "0E6yove+7u7Y/9waLd64NnHi/Hm3lCXRSHNboTXns5lndcEZOitHTtNCjv0xyBZm\n"
+ "2tIMPNuzjsmhDYAPexZ3FL//2wmUspO8IFgV6dtxQ/PeEMMA3KgqlbbC1j+Qa3bb\n"
+ "bP6MvPJwNQzcmRk13NfIRmPVNnGuV/u3gm3c\n"
+ "-----END CERTIFICATE-----\n"
+ // GTS Root R2
+ "-----BEGIN CERTIFICATE-----\n"
+ "MIIFVzCCAz+gAwIBAgINAgPlrsWNBCUaqxElqjANBgkqhkiG9w0BAQwFADBHMQsw\n"
+ "CQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEU\n"
+ "MBIGA1UEAxMLR1RTIFJvb3QgUjIwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAw\n"
+ "MDAwWjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZp\n"
+ "Y2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjIwggIiMA0GCSqGSIb3DQEBAQUA\n"
+ "A4ICDwAwggIKAoICAQDO3v2m++zsFDQ8BwZabFn3GTXd98GdVarTzTukk3LvCvpt\n"
+ "nfbwhYBboUhSnznFt+4orO/LdmgUud+tAWyZH8QiHZ/+cnfgLFuv5AS/T3KgGjSY\n"
+ "6Dlo7JUle3ah5mm5hRm9iYz+re026nO8/4Piy33B0s5Ks40FnotJk9/BW9BuXvAu\n"
+ "MC6C/Pq8tBcKSOWIm8Wba96wyrQD8Nr0kLhlZPdcTK3ofmZemde4wj7I0BOdre7k\n"
+ "RXuJVfeKH2JShBKzwkCX44ofR5GmdFrS+LFjKBC4swm4VndAoiaYecb+3yXuPuWg\n"
+ "f9RhD1FLPD+M2uFwdNjCaKH5wQzpoeJ/u1U8dgbuak7MkogwTZq9TwtImoS1mKPV\n"
+ "+3PBV2HdKFZ1E66HjucMUQkQdYhMvI35ezzUIkgfKtzra7tEscszcTJGr61K8Yzo\n"
+ "dDqs5xoic4DSMPclQsciOzsSrZYuxsN2B6ogtzVJV+mSSeh2FnIxZyuWfoqjx5RW\n"
+ "Ir9qS34BIbIjMt/kmkRtWVtd9QCgHJvGeJeNkP+byKq0rxFROV7Z+2et1VsRnTKa\n"
+ "G73VululycslaVNVJ1zgyjbLiGH7HrfQy+4W+9OmTN6SpdTi3/UGVN4unUu0kzCq\n"
+ "gc7dGtxRcw1PcOnlthYhGXmy5okLdWTK1au8CcEYof/UVKGFPP0UJAOyh9OktwID\n"
+ "AQABo0IwQDAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4E\n"
+ "FgQUu//KjiOfT5nK2+JopqUVJxce2Q4wDQYJKoZIhvcNAQEMBQADggIBAB/Kzt3H\n"
+ "vqGf2SdMC9wXmBFqiN495nFWcrKeGk6c1SuYJF2ba3uwM4IJvd8lRuqYnrYb/oM8\n"
+ "0mJhwQTtzuDFycgTE1XnqGOtjHsB/ncw4c5omwX4Eu55MaBBRTUoCnGkJE+M3DyC\n"
+ "B19m3H0Q/gxhswWV7uGugQ+o+MePTagjAiZrHYNSVc61LwDKgEDg4XSsYPWHgJ2u\n"
+ "NmSRXbBoGOqKYcl3qJfEycel/FVL8/B/uWU9J2jQzGv6U53hkRrJXRqWbTKH7QMg\n"
+ "yALOWr7Z6v2yTcQvG99fevX4i8buMTolUVVnjWQye+mew4K6Ki3pHrTgSAai/Gev\n"
+ "HyICc/sgCq+dVEuhzf9gR7A/Xe8bVr2XIZYtCtFenTgCR2y59PYjJbigapordwj6\n"
+ "xLEokCZYCDzifqrXPW+6MYgKBesntaFJ7qBFVHvmJ2WZICGoo7z7GJa7Um8M7YNR\n"
+ "TOlZ4iBgxcJlkoKM8xAfDoqXvneCbT+PHV28SSe9zE8P4c52hgQjxcCMElv924Sg\n"
+ "JPFI/2R80L5cFtHvma3AH/vLrrw4IgYmZNralw4/KBVEqE8AyvCazM90arQ+POuV\n"
+ "7LXTWtiBmelDGDfrs7vRWGJB82bSj6p4lVQgw1oudCvV0b4YacCs1aTPObpRhANl\n"
+ "6WLAYv7YTVWW4tAR+kg0Eeye7QUd5MjWHYbL\n"
+ "-----END CERTIFICATE-----\n"
+ // GTS Root R3
+ "-----BEGIN CERTIFICATE-----\n"
+ "MIICCTCCAY6gAwIBAgINAgPluILrIPglJ209ZjAKBggqhkjOPQQDAzBHMQswCQYD\n"
+ "VQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIG\n"
+ "A1UEAxMLR1RTIFJvb3QgUjMwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAwMDAw\n"
+ "WjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2Vz\n"
+ "IExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjMwdjAQBgcqhkjOPQIBBgUrgQQAIgNi\n"
+ "AAQfTzOHMymKoYTey8chWEGJ6ladK0uFxh1MJ7x/JlFyb+Kf1qPKzEUURout736G\n"
+ "jOyxfi//qXGdGIRFBEFVbivqJn+7kAHjSxm65FSWRQmx1WyRRK2EE46ajA2ADDL2\n"
+ "4CejQjBAMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQW\n"
+ "BBTB8Sa6oC2uhYHP0/EqEr24Cmf9vDAKBggqhkjOPQQDAwNpADBmAjEA9uEglRR7\n"
+ "VKOQFhG/hMjqb2sXnh5GmCCbn9MN2azTL818+FsuVbu/3ZL3pAzcMeGiAjEA/Jdm\n"
+ "ZuVDFhOD3cffL74UOO0BzrEXGhF16b0DjyZ+hOXJYKaV11RZt+cRLInUue4X\n"
+ "-----END CERTIFICATE-----\n"
+ // GTS Root R4
+ "-----BEGIN CERTIFICATE-----\n"
+ "MIICCTCCAY6gAwIBAgINAgPlwGjvYxqccpBQUjAKBggqhkjOPQQDAzBHMQswCQYD\n"
+ "VQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIG\n"
+ "A1UEAxMLR1RTIFJvb3QgUjQwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAwMDAw\n"
+ "WjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2Vz\n"
+ "IExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjQwdjAQBgcqhkjOPQIBBgUrgQQAIgNi\n"
+ "AATzdHOnaItgrkO4NcWBMHtLSZ37wWHO5t5GvWvVYRg1rkDdc/eJkTBa6zzuhXyi\n"
+ "QHY7qca4R9gq55KRanPpsXI5nymfopjTX15YhmUPoYRlBtHci8nHc8iMai/lxKvR\n"
+ "HYqjQjBAMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQW\n"
+ "BBSATNbrdP9JNqPV2Py1PsVq8JQdjDAKBggqhkjOPQQDAwNpADBmAjEA6ED/g94D\n"
+ "9J+uHXqnLrmvT/aDHQ4thQEd0dlq7A/Cr8deVl5c1RxYIigL9zC2L7F8AjEA8GE8\n"
+ "p/SgguMh1YQdc4acLa/KNJvxn7kjNuK8YAOdgLOaVsjh4rsUecrNIdSUtUlD\n"
+ "-----END CERTIFICATE-----\n"
+ // ISRG Root X1
+ "-----BEGIN CERTIFICATE-----\n"
+ "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\n"
+ "-----END CERTIFICATE-----\n"
+ // ISRG Root X2
+ "-----BEGIN CERTIFICATE-----\n"
+ "MIICGzCCAaGgAwIBAgIQQdKd0XLq7qeAwSxs6S+HUjAKBggqhkjOPQQDAzBPMQsw\n"
+ "CQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFyY2gg\n"
+ "R3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBYMjAeFw0yMDA5MDQwMDAwMDBaFw00\n"
+ "MDA5MTcxNjAwMDBaME8xCzAJBgNVBAYTAlVTMSkwJwYDVQQKEyBJbnRlcm5ldCBT\n"
+ "ZWN1cml0eSBSZXNlYXJjaCBHcm91cDEVMBMGA1UEAxMMSVNSRyBSb290IFgyMHYw\n"
+ "EAYHKoZIzj0CAQYFK4EEACIDYgAEzZvVn4CDCuwJSvMWSj5cz3es3mcFDR0HttwW\n"
+ "+1qLFNvicWDEukWVEYmO6gbf9yoWHKS5xcUy4APgHoIYOIvXRdgKam7mAHf7AlF9\n"
+ "ItgKbppbd9/w+kHsOdx1ymgHDB/qo0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0T\n"
+ "AQH/BAUwAwEB/zAdBgNVHQ4EFgQUfEKWrt5LSDv6kviejM9ti6lyN5UwCgYIKoZI\n"
+ "zj0EAwMDaAAwZQIwe3lORlCEwkSHRhtFcP9Ymd70/aTSVaYgLXTWNLxBo1BfASdW\n"
+ "tL4ndQavEi51mI38AjEAi/V3bNTIZargCyzuFJ0nN6T5U6VR5CmD1/iQMVtCnwr1\n"
+ "/q4AaOeMSQ+2b1tbFfLn\n"
+ "-----END CERTIFICATE-----\n"
+ // SSL.com TLS RSA Root CA 2022
+ "-----BEGIN CERTIFICATE-----\n"
+ "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\n"
+ "98OwoX6EyneSMSy4kLGCenROmxMmtNVQZlR4rmA=\n"
+ "-----END CERTIFICATE-----\n"
+ // SSL.com TLS ECC Root CA 2022
+ "-----BEGIN CERTIFICATE-----\n"
+ "MIICOjCCAcCgAwIBAgIQFAP1q/s3ixdAW+JDsqXRxDAKBggqhkjOPQQDAzBOMQsw\n"
+ "CQYDVQQGEwJVUzEYMBYGA1UECgwPU1NMIENvcnBvcmF0aW9uMSUwIwYDVQQDDBxT\n"
+ "U0wuY29tIFRMUyBFQ0MgUm9vdCBDQSAyMDIyMB4XDTIyMDgyNTE2MzM0OFoXDTQ2\n"
+ "MDgxOTE2MzM0N1owTjELMAkGA1UEBhMCVVMxGDAWBgNVBAoMD1NTTCBDb3Jwb3Jh\n"
+ "dGlvbjElMCMGA1UEAwwcU1NMLmNvbSBUTFMgRUNDIFJvb3QgQ0EgMjAyMjB2MBAG\n"
+ "ByqGSM49AgEGBSuBBAAiA2IABEUpNXP6wrgjzhR9qLFNoFs27iosU8NgCTWyJGYm\n"
+ "acCzldZdkkAZDsalE3D07xJRKF3nzL35PIXBz5SQySvOkkJYWWf9lCcQZIxPBLFN\n"
+ "SeR7T5v15wj4A4j3p8OSSxlUgaNjMGEwDwYDVR0TAQH/BAUwAwEB/zAfBgNVHSME\n"
+ "GDAWgBSJjy+j6CugFFR781a4Jl9nOAuc0DAdBgNVHQ4EFgQUiY8vo+groBRUe/NW\n"
+ "uCZfZzgLnNAwDgYDVR0PAQH/BAQDAgGGMAoGCCqGSM49BAMDA2gAMGUCMFXjIlbp\n"
+ "15IkWE8elDIPDAI2wv2sdDJO4fscgIijzPvX6yv/N33w7deedWo1dlJF4AIxAMeN\n"
+ "b0Igj762TVntd00pxCAgRWSGOlDGxK0tk/UYfXLtqc/ErFc2KAhl3zx5Zn6g6g==\n"
+ "-----END CERTIFICATE-----\n"
+ // SSL.com Root Certification Authority RSA
+ "-----BEGIN CERTIFICATE-----\n"
+ "MIIF3TCCA8WgAwIBAgIIeyyb0xaAMpkwDQYJKoZIhvcNAQELBQAwfDELMAkGA1UE\n"
+ "BhMCVVMxDjAMBgNVBAgMBVRleGFzMRAwDgYDVQQHDAdIb3VzdG9uMRgwFgYDVQQK\n"
+ "DA9TU0wgQ29ycG9yYXRpb24xMTAvBgNVBAMMKFNTTC5jb20gUm9vdCBDZXJ0aWZp\n"
+ "Y2F0aW9uIEF1dGhvcml0eSBSU0EwHhcNMTYwMjEyMTczOTM5WhcNNDEwMjEyMTcz\n"
+ "OTM5WjB8MQswCQYDVQQGEwJVUzEOMAwGA1UECAwFVGV4YXMxEDAOBgNVBAcMB0hv\n"
+ "dXN0b24xGDAWBgNVBAoMD1NTTCBDb3Jwb3JhdGlvbjExMC8GA1UEAwwoU1NMLmNv\n"
+ "bSBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5IFJTQTCCAiIwDQYJKoZIhvcN\n"
+ "AQEBBQADggIPADCCAgoCggIBAPkP3aMrfcvQKv7sZ4Wm5y4bunfh4/WvpOz6Sl2R\n"
+ "xFdHaxh3a3by/ZPkPQ/CFp4LZsNWlJ4Xg4XOVu/yFv0AYvUiCVToZRdOQbngT0aX\n"
+ "qhvIuG5iXmmxX9sqAn78bMrzQdjt0Oj8P2FI7bADFB0QDksZ4LtO7IZl/zbzXmcC\n"
+ "C52GVWH9ejjt/uIZALdvoVBidXQ8oPrIJZK0bnoix/geoeOy3ZExqysdBP+lSgQ3\n"
+ "6YWkMyv94tZVNHwZpEpox7Ko07fKoZOI68GXvIz5HdkihCR0xwQ9aqkpk8zruFvh\n"
+ "/l8lqjRYyMEjVJ0bmBHDOJx+PYZspQ9AhnwC9FwCTyjLrnGfDzrIM/4RJTXq/LrF\n"
+ "YD3ZfBjVsqnTdXgDciLKOsMf7yzlLqn6niy2UUb9rwPW6mBo6oUWNmuF6R7As93E\n"
+ "JNyAKoFBbZQ+yODJgUEAnl6/f8UImKIYLEJAs/lvOCdLToD0PYFH4Ih86hzOtXVc\n"
+ "US4cK38acijnALXRdMbX5J+tB5O2UzU1/Dfkw/ZdFr4hc96SCvigY2q8lpJqPvi8\n"
+ "ZVWb3vUNiSYE/CUapiVpy8JtynziWV+XrOvvLsi81xtZPCvM8hnIk2snYxnP/Okm\n"
+ "+Mpxm3+T/jRnhE6Z6/yzeAkzcLpmpnbtG3PrGqUNxCITIJRWCk4sbE6x/c+cCbqi\n"
+ "M+2HAgMBAAGjYzBhMB0GA1UdDgQWBBTdBAkHovV6fVJTEpKV7jiAJQ2mWTAPBgNV\n"
+ "HRMBAf8EBTADAQH/MB8GA1UdIwQYMBaAFN0ECQei9Xp9UlMSkpXuOIAlDaZZMA4G\n"
+ "A1UdDwEB/wQEAwIBhjANBgkqhkiG9w0BAQsFAAOCAgEAIBgRlCn7Jp0cHh5wYfGV\n"
+ "cpNxJK1ok1iOMq8bs3AD/CUrdIWQPXhq9LmLpZc7tRiRux6n+UBbkflVma8eEdBc\n"
+ "Hadm47GUBwwyOabqG7B52B2ccETjit3E+ZUfijhDPwGFpUenPUayvOUiaPd7nNgs\n"
+ "PgohyC0zrL/FgZkxdMF1ccW+sfAjRfSda/wZY52jvATGGAslu1OJD7OAUN5F7kR/\n"
+ "q5R4ZJjT9ijdh9hwZXT7DrkT66cPYakylszeu+1jTBi7qUD3oFRuIIhxdRjqerQ0\n"
+ "cuAjJ3dctpDqhiVAq+8zD8ufgr6iIPv2tS0a5sKFsXQP+8hlAqRSAUfdSSLBv9jr\n"
+ "a6x+3uxjMxW3IwiPxg+NQVrdjsW5j+VFP3jbutIbQLH+cU0/4IGiul607BXgk90I\n"
+ "H37hVZkLId6Tngr75qNJvTYw/ud3sqB1l7UtgYgXZSD32pAAn8lSzDLKNXz1PQ/Y\n"
+ "K9f1JmzJBjSWFupwWRoyeXkLtoh/D1JIPb9s2KJELtFOt3JY04kTlf5Eq/jXixtu\n"
+ "nLwsoFvVagCvXzfh1foQC5ichucmj87w7G6KVwuA406ywKBjYZC6VWg3dGq2ktuf\n"
+ "oYYitmUnDuy2n0Jg5GfCtdpBC8TTi2EbvPofkSvXRAdeuims2cXp71NIWuuA8ShY\n"
+ "Ic2wBlX7Jz9TkHCpBB5XJ7k=\n"
+ "-----END CERTIFICATE-----\n"
+ // SSL.com Root Certification Authority ECC
+ "-----BEGIN CERTIFICATE-----\n"
+ "MIICjTCCAhSgAwIBAgIIdebfy8FoW6gwCgYIKoZIzj0EAwIwfDELMAkGA1UEBhMC\n"
+ "VVMxDjAMBgNVBAgMBVRleGFzMRAwDgYDVQQHDAdIb3VzdG9uMRgwFgYDVQQKDA9T\n"
+ "U0wgQ29ycG9yYXRpb24xMTAvBgNVBAMMKFNTTC5jb20gUm9vdCBDZXJ0aWZpY2F0\n"
+ "aW9uIEF1dGhvcml0eSBFQ0MwHhcNMTYwMjEyMTgxNDAzWhcNNDEwMjEyMTgxNDAz\n"
+ "WjB8MQswCQYDVQQGEwJVUzEOMAwGA1UECAwFVGV4YXMxEDAOBgNVBAcMB0hvdXN0\n"
+ "b24xGDAWBgNVBAoMD1NTTCBDb3Jwb3JhdGlvbjExMC8GA1UEAwwoU1NMLmNvbSBS\n"
+ "b290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5IEVDQzB2MBAGByqGSM49AgEGBSuB\n"
+ "BAAiA2IABEVuqVDEpiM2nl8ojRfLliJkP9x6jh3MCLOicSS6jkm5BBtHllirLZXI\n"
+ "7Z4INcgn64mMU1jrYor+8FsPazFSY0E7ic3s7LaNGdM0B9y7xgZ/wkWV7Mt/qCPg\n"
+ "CemB+vNH06NjMGEwHQYDVR0OBBYEFILRhXMw5zUE044CkvvlpNHEIejNMA8GA1Ud\n"
+ "EwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAUgtGFczDnNQTTjgKS++Wk0cQh6M0wDgYD\n"
+ "VR0PAQH/BAQDAgGGMAoGCCqGSM49BAMCA2cAMGQCMG/n61kRpGDPYbCWe+0F+S8T\n"
+ "kdzt5fxQaxFGRrMcIQBiu77D5+jNB5n5DQtdcj7EqgIwH7y6C+IwJPt8bYBVCpk+\n"
+ "gA0z5Wajs6O7pdWLjwkspl1+4vAHCGht0nxpbl/f5Wpl\n"
+ "-----END CERTIFICATE-----\n";
diff --git a/examples/companion_radio/CompanionSNMP.cpp b/examples/companion_radio/CompanionSNMP.cpp
new file mode 100644
index 00000000000..d830c635434
--- /dev/null
+++ b/examples/companion_radio/CompanionSNMP.cpp
@@ -0,0 +1,207 @@
+// DMC companion SNMP agent: read-only SNMPv2c (GET / GETNEXT / GETBULK) on UDP 161.
+// Ported from meshcomod (Elektr0Vodka/meshcomod CompanionSNMP.cpp).
+//
+// Off by default: until `set snmp on` the agent is never allocated, so a build
+// that leaves it off costs flash but no RAM. When on, it runs in the main loop
+// whenever Wi-Fi holds an address and stops while the link is down.
+//
+// The OID tree follows the DMC observer firmware (dmc-observer-dev, MQTT_SNMP.md)
+// so one poller template reads both: shared numbers keep the observer's meaning,
+// type and unit, and an OID the companion has no data for is not registered, so it
+// reads noSuchObject instead of a 0 that looks real. Companion-only values use .9
+// and up, the same numbers as meshcomod's companion.
+
+#include "CompanionNet.h"
+
+#ifdef DMC_COMPANION_SNMP
+
+#include "MyMesh.h"
+#include
+#include
+#include
+#include
+#include
+#include
+
+#ifdef ENABLE_WIFI_INTERFACE
+ #include
+ extern SerialWifiInterface wifi_interface;
+#endif
+
+// Temporary, unregistered enterprise number, the same one the observer firmware uses.
+#define MESHCORE_OID_BASE ".1.3.6.1.4.1.99999"
+
+// Same "no value" marker as the observer for a board without an MCU temperature sensor.
+#define SNMP_TEMP_UNAVAILABLE (-127)
+
+extern SimpleMeshTables tables;
+
+static MyMesh* s_mesh = nullptr;
+
+// Agent and socket are created on first start and kept, so toggling never leaks.
+static WiFiUDP* s_udp = nullptr;
+static SNMPAgent* s_agent = nullptr;
+static bool s_running = false;
+
+// Values the agent serves from a copy instead of reading them per request.
+// Hardware (battery ADC, die temperature) is read every 30 s: some battery reads
+// block for 10 ms and neither value moves quickly. Counter32 handlers need a
+// variable to point at, so those totals are copied every 2 s. All of it happens in
+// companionSnmpLoop(), on the main loop with the mesh, never inside a request.
+static int s_battery_mv = 0;
+static int s_mcu_temp_c = SNMP_TEMP_UNAVAILABLE;
+static int s_reset_reason = 0;
+static char s_reset_reason_text[48] = "";
+static uint32_t s_flood_dups = 0;
+static uint32_t s_direct_dups = 0;
+static uint32_t s_rx_air_secs = 0;
+static uint32_t s_cf_malformed = 0;
+static uint32_t s_cf_age = 0;
+static uint32_t s_cf_path = 0;
+static uint32_t s_cf_sender = 0;
+static uint32_t s_cf_text = 0;
+static uint32_t s_last_stats_ms = 0;
+static uint32_t s_last_health_ms = 0;
+static bool s_health_read = false;
+
+static void readHealth() {
+ s_battery_mv = (int)board.getBattMilliVolts();
+ const float t = board.getMCUTemperature();
+ s_mcu_temp_c = isnan(t) ? SNMP_TEMP_UNAVAILABLE : (int)lroundf(t);
+}
+
+static void copyCounters() {
+ s_flood_dups = tables.getNumFloodDups();
+ s_direct_dups = tables.getNumDirectDups();
+ s_rx_air_secs = (uint32_t)(s_mesh->getReceiveAirTime() / 1000);
+ const Counters& c = s_mesh->dmcFilterCounters();
+ s_cf_malformed = c.malformed;
+ s_cf_age = c.age;
+ s_cf_path = c.path;
+ s_cf_sender = c.sender;
+ s_cf_text = c.text;
+}
+
+static void registerHandlers(SNMPAgent* a) {
+ // System (.1.x.0)
+ a->addDynamicIntegerHandler(MESHCORE_OID_BASE ".1.1.0", []() -> int { return (int)(millis() / 1000); });
+ a->addDynamicReadOnlyStringHandler(MESHCORE_OID_BASE ".1.2.0", []() -> const std::string { return FIRMWARE_VERSION; });
+ a->addDynamicReadOnlyStringHandler(MESHCORE_OID_BASE ".1.3.0", []() -> const std::string {
+ return std::string(s_mesh->getNodePrefs()->node_name);
+ });
+ a->addIntegerHandler(MESHCORE_OID_BASE ".1.4.0", &s_battery_mv);
+ a->addIntegerHandler(MESHCORE_OID_BASE ".1.5.0", &s_mcu_temp_c);
+ a->addIntegerHandler(MESHCORE_OID_BASE ".1.6.0", &s_reset_reason);
+ a->addDynamicReadOnlyStringHandler(MESHCORE_OID_BASE ".1.7.0", []() -> const std::string {
+ return std::string(s_reset_reason_text);
+ });
+
+ // Radio (.2.x.0). .2.16 (TX duty cycle) is left out: this firmware does not track it.
+ a->addDynamicIntegerHandler(MESHCORE_OID_BASE ".2.1.0", []() -> int { return (int)radio_driver.getPacketsRecv(); });
+ a->addDynamicIntegerHandler(MESHCORE_OID_BASE ".2.2.0", []() -> int { return (int)radio_driver.getPacketsSent(); });
+ a->addDynamicIntegerHandler(MESHCORE_OID_BASE ".2.3.0", []() -> int { return (int)radio_driver.getPacketsRecvErrors(); });
+ a->addDynamicIntegerHandler(MESHCORE_OID_BASE ".2.4.0", []() -> int { return radio_driver.getNoiseFloor(); });
+ a->addDynamicIntegerHandler(MESHCORE_OID_BASE ".2.5.0", []() -> int { return (int)radio_driver.getLastRSSI(); });
+ a->addDynamicIntegerHandler(MESHCORE_OID_BASE ".2.6.0", []() -> int { return (int)(radio_driver.getLastSNR() * 4); });
+ a->addDynamicIntegerHandler(MESHCORE_OID_BASE ".2.7.0", []() -> int { return (int)s_mesh->getNumSentFlood(); });
+ a->addDynamicIntegerHandler(MESHCORE_OID_BASE ".2.8.0", []() -> int { return (int)s_mesh->getNumSentDirect(); });
+ a->addDynamicIntegerHandler(MESHCORE_OID_BASE ".2.9.0", []() -> int { return (int)s_mesh->getNumRecvFlood(); });
+ a->addDynamicIntegerHandler(MESHCORE_OID_BASE ".2.10.0", []() -> int { return (int)s_mesh->getNumRecvDirect(); });
+ a->addDynamicIntegerHandler(MESHCORE_OID_BASE ".2.11.0", []() -> int { return (int)(s_mesh->getTotalAirTime() / 1000); });
+ a->addDynamicIntegerHandler(MESHCORE_OID_BASE ".2.12.0", []() -> int { return s_mesh->dmcTxQueueLen(); });
+ a->addCounter32Handler(MESHCORE_OID_BASE ".2.13.0", &s_flood_dups);
+ a->addCounter32Handler(MESHCORE_OID_BASE ".2.14.0", &s_direct_dups);
+ a->addCounter32Handler(MESHCORE_OID_BASE ".2.15.0", &s_rx_air_secs);
+ // Bits 1, 2 and 4 as on the observer. Bit 8 (radio watchdog) does not exist here and stays 0.
+ a->addDynamicIntegerHandler(MESHCORE_OID_BASE ".2.17.0", []() -> int { return (int)s_mesh->dmcErrFlags(); });
+
+ // MQTT (.3.x.0): no bridge and no NTP on the companion, so nothing is registered.
+
+ // Memory (.4.x.0). heap_caps reports 0 for PSRAM on boards without it.
+ a->addDynamicIntegerHandler(MESHCORE_OID_BASE ".4.1.0", []() -> int { return (int)ESP.getFreeHeap(); });
+ a->addDynamicIntegerHandler(MESHCORE_OID_BASE ".4.2.0", []() -> int { return (int)ESP.getMaxAllocHeap(); });
+ a->addDynamicIntegerHandler(MESHCORE_OID_BASE ".4.3.0", []() -> int { return (int)heap_caps_get_free_size(MALLOC_CAP_INTERNAL); });
+ a->addDynamicIntegerHandler(MESHCORE_OID_BASE ".4.4.0", []() -> int { return (int)heap_caps_get_free_size(MALLOC_CAP_SPIRAM); });
+ a->addDynamicIntegerHandler(MESHCORE_OID_BASE ".4.5.0", []() -> int { return (int)ESP.getMinFreeHeap(); });
+
+ // Network (.5.x.0)
+ a->addDynamicIntegerHandler(MESHCORE_OID_BASE ".5.1.0", []() -> int {
+ return WiFi.status() == WL_CONNECTED ? (int)WiFi.RSSI() : -127;
+ });
+
+ // .6 duty gate, .7 packet filter and .8 neighbours are repeater features: not registered.
+
+ // Companion (.9.x.0), companion only. .9.2 (WebSocket clients) is meshcomod-only.
+#ifdef ENABLE_WIFI_INTERFACE
+ a->addDynamicIntegerHandler(MESHCORE_OID_BASE ".9.1.0", []() -> int { return wifi_interface.isConnected() ? 1 : 0; });
+#endif
+ a->addDynamicIntegerHandler(MESHCORE_OID_BASE ".9.3.0", []() -> int { return s_mesh->getNumContacts(); });
+ a->addDynamicIntegerHandler(MESHCORE_OID_BASE ".9.4.0", []() -> int { return s_mesh->dmcNumChannels(); });
+
+ // Receive filter (.10.x.0), companion only. Not the repeater's .7: these count messages
+ // kept from the user (dropped as junk or hidden by taste), not packets kept off the air.
+ a->addDynamicIntegerHandler(MESHCORE_OID_BASE ".10.1.0", []() -> int { return s_mesh->dmcFilterEnabled() ? 1 : 0; });
+ a->addCounter32Handler(MESHCORE_OID_BASE ".10.2.0", &s_cf_malformed);
+ a->addCounter32Handler(MESHCORE_OID_BASE ".10.3.0", &s_cf_age);
+ a->addCounter32Handler(MESHCORE_OID_BASE ".10.4.0", &s_cf_path);
+ a->addCounter32Handler(MESHCORE_OID_BASE ".10.5.0", &s_cf_sender);
+ a->addCounter32Handler(MESHCORE_OID_BASE ".10.6.0", &s_cf_text);
+}
+
+static void startAgent(const char* community) {
+ if (!s_agent) {
+ const uint32_t reason = board.getResetReason();
+ s_reset_reason = (int)reason;
+ StrHelper::strncpy(s_reset_reason_text, board.getResetReasonString(reason), sizeof(s_reset_reason_text));
+
+ s_udp = new WiFiUDP();
+ s_agent = new SNMPAgent(community);
+ registerHandlers(s_agent);
+ s_agent->sortHandlers();
+ s_agent->setUDP(s_udp); // registers the socket and binds UDP 161
+ } else {
+ s_agent->setReadWriteCommunity(community);
+ s_agent->restartUDP(); // the old socket died with the link
+ }
+ s_running = true;
+ Serial.printf("[snmp] agent up on udp/161 (free=%u)\n", (unsigned)ESP.getFreeHeap());
+}
+
+static void stopAgent() {
+ s_agent->stop();
+ s_running = false;
+ Serial.println("[snmp] agent stopped");
+}
+
+void companionSnmpLoop(MyMesh& mesh, CompanionNetPrefs& prefs, bool link_up) {
+ s_mesh = &mesh;
+ const bool want = prefs.snmp_on && link_up;
+ if (want && !s_running) {
+ startAgent(prefs.snmp_community);
+ } else if (!want && s_running) {
+ stopAgent();
+ }
+ if (!s_running) return;
+
+ const uint32_t now = millis();
+ if (!s_health_read || now - s_last_health_ms >= 30000) {
+ s_health_read = true;
+ s_last_health_ms = now;
+ readHealth();
+ }
+ if (s_last_stats_ms == 0 || now - s_last_stats_ms >= 2000) {
+ s_last_stats_ms = now;
+ copyCounters();
+ }
+ s_agent->loop();
+}
+
+bool companionSnmpRunning() {
+ return s_running;
+}
+
+void companionSnmpSetCommunity(const char* community) {
+ if (s_agent) s_agent->setReadWriteCommunity(community);
+}
+
+#endif // DMC_COMPANION_SNMP
diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp
index 2bd3d51d57f..335046627e1 100644
--- a/examples/companion_radio/MyMesh.cpp
+++ b/examples/companion_radio/MyMesh.cpp
@@ -982,6 +982,7 @@ void MyMesh::begin() {
importMeshcomodSettings(_prefs, *_store); // DMC: keep settings when upgrading from meshcomod
_store->loadPrefs(_prefs);
loadCompanionFilter(_cfilter, *_store); // DMC companion filter
+ loadCompanionNet(_cnet, *_store); // DMC companion SNMP / OTA settings
sensors.node_lat = _prefs.node_lat;
sensors.node_lon = _prefs.node_lon;
@@ -2214,6 +2215,7 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, char*
}
if (dmcFilterCommand(command, reply)) return true; // DMC companion filter
+ if (dmcNetCommand(command, sender_timestamp, reply)) return true; // DMC companion SNMP / OTA / MAC
return false; // not handled
}
diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h
index a425f647d30..08ff360a860 100644
--- a/examples/companion_radio/MyMesh.h
+++ b/examples/companion_radio/MyMesh.h
@@ -23,6 +23,7 @@
#endif
#include "CompanionFilter.h"
+#include "CompanionNet.h"
#include "MeshcomodPrefs.h"
#include "DataStore.h"
#include "NodePrefs.h"
@@ -150,6 +151,10 @@ class MyMesh : public BaseChatMesh, public DataStoreHost {
uint8_t _cfilter_hidden[MAX_HASH_SIZE];
bool _cfilter_hidden_set = false;
+ // DMC companion network extras: SNMP, OTA, MAC (CompanionNet.cpp)
+ bool dmcNetCommand(const char* command, uint32_t sender_timestamp, char* reply);
+ CompanionNetPrefs _cnet;
+
void sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis);
void sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, uint32_t delay_millis=0) override;
void sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pkt, uint32_t delay_millis=0) override;
@@ -225,6 +230,14 @@ class MyMesh : public BaseChatMesh, public DataStoreHost {
// To check if there is pending work
bool hasPendingWork() const;
+ // DMC companion network extras (CompanionNet.cpp, CompanionSNMP.cpp)
+ void dmcNetLoop();
+ int dmcNumChannels();
+ int dmcTxQueueLen() const { return _mgr->getOutboundTotal(); }
+ uint16_t dmcErrFlags() const { return _err_flags; }
+ const Counters& dmcFilterCounters() const { return _cfilter.counters(); }
+ bool dmcFilterEnabled() const { return _cfilter.isEnabled(); }
+
private:
void writeOKFrame();
void writeErrFrame(uint8_t err_code);
diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp
index 839d4958336..5faefb76b09 100644
--- a/examples/companion_radio/main.cpp
+++ b/examples/companion_radio/main.cpp
@@ -291,6 +291,7 @@ void setup() {
void loop() {
the_mesh.loop();
+ the_mesh.dmcNetLoop(); // DMC companion SNMP / OTA
interface_manager.loop();
sensors.loop();
#ifdef DISPLAY_CLASS
diff --git a/scripts/gen_companion_ota_certs.py b/scripts/gen_companion_ota_certs.py
new file mode 100644
index 00000000000..2d108cb89af
--- /dev/null
+++ b/scripts/gen_companion_ota_certs.py
@@ -0,0 +1,67 @@
+#!/usr/bin/env python3
+"""Regenerate examples/companion_radio/CompanionOtaCerts.h.
+
+The DMC companion verifies the OTA server's TLS certificate against this short
+list of roots instead of a full CA bundle (a few KB of flash instead of ~60 KB).
+ota.dutchmeshcore.nl sits behind Cloudflare, whose edge certificates come from
+Google Trust Services, Let's Encrypt or SSL.com, so the roots of those three are
+enough. Add a root here if Cloudflare starts using another CA.
+
+Roots are taken from the Mozilla store as shipped by certifi:
+ pip install certifi && python3 scripts/gen_companion_ota_certs.py
+"""
+import re
+import sys
+from pathlib import Path
+
+import certifi
+
+ROOTS = [
+ "GTS Root R1",
+ "GTS Root R2",
+ "GTS Root R3",
+ "GTS Root R4",
+ "ISRG Root X1",
+ "ISRG Root X2",
+ "SSL.com TLS RSA Root CA 2022",
+ "SSL.com TLS ECC Root CA 2022",
+ "SSL.com Root Certification Authority RSA",
+ "SSL.com Root Certification Authority ECC",
+]
+
+OUT = Path(__file__).resolve().parent.parent / "examples" / "companion_radio" / "CompanionOtaCerts.h"
+
+
+def main() -> int:
+ text = Path(certifi.where()).read_text(encoding="utf-8")
+ blocks = {}
+ for m in re.finditer(r'# Label: "([^"]+)"\n(?:#[^\n]*\n)*(-----BEGIN CERTIFICATE-----\n.*?-----END CERTIFICATE-----)', text, re.S):
+ blocks[m.group(1)] = m.group(2)
+ missing = [r for r in ROOTS if r not in blocks]
+ if missing:
+ print("not in the certifi store: " + ", ".join(missing), file=sys.stderr)
+ return 1
+
+ lines = [
+ "#pragma once",
+ "",
+ "// Generated by scripts/gen_companion_ota_certs.py from the Mozilla root store",
+ "// (certifi " + certifi.__version__ + "). Do not edit by hand.",
+ "//",
+ "// Roots the companion trusts for the OTA download from ota.dutchmeshcore.nl",
+ "// (Cloudflare: Google Trust Services, Let's Encrypt, SSL.com).",
+ "",
+ "static const char COMPANION_OTA_ROOT_CERTS[] =",
+ ]
+ for name in ROOTS:
+ lines.append(" // " + name)
+ for pem_line in blocks[name].splitlines():
+ lines.append(' "' + pem_line + '\\n"')
+ lines[-1] += ";"
+ OUT.write_text("\n".join(lines) + "\n", encoding="utf-8", newline="\n")
+ print("wrote " + str(OUT))
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/partition_signature.py b/scripts/partition_signature.py
new file mode 100644
index 00000000000..326b9e51497
--- /dev/null
+++ b/scripts/partition_signature.py
@@ -0,0 +1,44 @@
+#!/usr/bin/env python3
+"""Canonical signature of an ESP32 partition table (from a partitions.bin).
+
+Used to decide OTA partition compatibility: the observer firmware computes the
+same signature at runtime from its *flashed* partition table (via esp_partition),
+and `ota update` refuses only when the target build's signature differs from the
+running device's — i.e. a real partition-table change, not a blanket flag.
+
+The signature MUST be computed identically here and in firmware
+(src/helpers/ESP32Board.cpp). Definition:
+
+ for each partition-table entry: (type, subtype, offset, size)
+ sort by offset ascending
+ format each as "%x:%x:%x:%x" (lowercase hex, no 0x, no padding)
+ join with ","
+
+partitions.bin layout: 32-byte records, each starting with magic 0xAA 0x50; the
+trailing MD5 record (magic 0xEB 0xEB) and 0xFF padding are ignored.
+
+Usage: partition_signature.py # prints the signature
+"""
+import struct
+import sys
+
+
+def signature(bin_path: str) -> str:
+ data = open(bin_path, "rb").read()
+ entries = []
+ for i in range(0, len(data) - 31, 32):
+ rec = data[i:i + 32]
+ if rec[0:2] != b"\xaa\x50": # not a partition entry (MD5 record / padding)
+ continue
+ ptype, subtype = rec[2], rec[3]
+ offset, size = struct.unpack("", file=sys.stderr)
+ sys.exit(2)
+ print(signature(sys.argv[1]))
diff --git a/src/helpers/OtaManifest.h b/src/helpers/OtaManifest.h
new file mode 100644
index 00000000000..cf5de422bcf
--- /dev/null
+++ b/src/helpers/OtaManifest.h
@@ -0,0 +1,272 @@
+#pragma once
+#include
+#include
+#include
+
+// Manifest pull OTA (`ota check` / `ota update`): the pure decisions, kept free of
+// network and flash so they run in the native tests. The DMC companion does the
+// I/O in examples/companion_radio/CompanionOta.cpp. Ported from meshcomod
+// (Elektr0Vodka/meshcomod src/helpers/OtaManifest.h); keep the shared parts in sync.
+//
+// The device reads /.json, served by the DutchMeshCore OTA server in
+// the same shape as for the DMC observer builds:
+// {"file":"","version":"v1.18.0","hash":"abc1234","partSig":"..."}
+// build.sh bakes the bases and the variant (the PlatformIO env name) in.
+
+struct OtaVersion {
+ int part[4]; // "v1.18.0.1" -> 1, 18, 0, 1; missing parts stay 0
+ int count; // number of parts present
+ char hash[16]; // token after the last '-', or "" when there is no '-'
+};
+
+// Parse "v1.18.0.1[-...]-". The numeric version ends at the first '-'.
+// Returns false when the text does not start with a version.
+static inline bool ota_version_parse(const char* s, OtaVersion* out) {
+ memset(out, 0, sizeof(*out));
+ if (s == nullptr) return false;
+ const char* p = s;
+ if (*p == 'v' || *p == 'V') p++;
+ while (out->count < 4) {
+ if (*p < '0' || *p > '9') return false;
+ int n = 0;
+ while (*p >= '0' && *p <= '9') n = n * 10 + (*p++ - '0');
+ out->part[out->count++] = n;
+ if (*p != '.') break;
+ p++;
+ }
+ if (*p != '\0' && *p != '-') return false;
+ const char* dash = strrchr(s, '-');
+ if (dash != nullptr) {
+ strncpy(out->hash, dash + 1, sizeof(out->hash) - 1);
+ }
+ return true;
+}
+
+enum OtaVerdict : uint8_t {
+ OTA_VERDICT_CURRENT = 0, // the published build is the one running
+ OTA_VERDICT_NEWER, // the published build has a higher version
+ OTA_VERDICT_OLDER, // the running build is newer than the published one
+ OTA_VERDICT_REBUILD, // same version, different commit
+};
+
+// Shared-prefix compare absorbs the 7 vs 8 character git abbreviation.
+static inline bool ota_hash_equal(const char* a, const char* b) {
+ size_t la = strlen(a), lb = strlen(b);
+ size_t m = la < lb ? la : lb;
+ return m >= 7 && strncmp(a, b, m) == 0;
+}
+
+// Compare the running FIRMWARE_VERSION with a manifest's version and hash.
+static inline OtaVerdict ota_compare(const char* running, const char* avail_version, const char* avail_hash) {
+ OtaVersion own, avail;
+ bool own_ok = ota_version_parse(running, &own);
+ bool avail_ok = ota_version_parse(avail_version, &avail);
+ if (!own_ok || !avail_ok) {
+ // No usable version on one side: only the commit can tell them apart.
+ char own_hash[16] = {0};
+ const char* dash = running ? strrchr(running, '-') : nullptr;
+ if (dash) strncpy(own_hash, dash + 1, sizeof(own_hash) - 1);
+ return ota_hash_equal(own_hash, avail_hash ? avail_hash : "") ? OTA_VERDICT_CURRENT : OTA_VERDICT_REBUILD;
+ }
+ for (int i = 0; i < 4; i++) {
+ if (avail.part[i] > own.part[i]) return OTA_VERDICT_NEWER;
+ if (avail.part[i] < own.part[i]) return OTA_VERDICT_OLDER;
+ }
+ if (own.hash[0] && avail_hash && avail_hash[0] && !ota_hash_equal(own.hash, avail_hash)) {
+ return OTA_VERDICT_REBUILD;
+ }
+ return OTA_VERDICT_CURRENT;
+}
+
+// One partition-table entry, as esp_partition reports it.
+struct OtaPartEntry {
+ uint8_t type, subtype;
+ uint32_t offset, size;
+};
+
+// Canonical partition-table signature. MUST match scripts/partition_signature.py:
+// each entry "type:subtype:offset:size" in lowercase hex, sorted by offset, joined
+// by ','. Sorts `e` in place.
+static inline void ota_partsig_format(OtaPartEntry* e, int n, char* out, size_t out_sz) {
+ for (int i = 1; i < n; i++) {
+ OtaPartEntry k = e[i];
+ int j = i - 1;
+ while (j >= 0 && e[j].offset > k.offset) { e[j + 1] = e[j]; j--; }
+ e[j + 1] = k;
+ }
+ if (out_sz == 0) return;
+ out[0] = 0;
+ size_t pos = 0;
+ for (int i = 0; i < n; i++) {
+ int w = snprintf(out + pos, out_sz - pos, "%s%x:%x:%x:%x", i ? "," : "", e[i].type, e[i].subtype,
+ (unsigned)e[i].offset, (unsigned)e[i].size);
+ if (w < 0 || (size_t)w >= out_sz - pos) { // truncated: keep what fits, stop
+ out[out_sz - 1] = 0;
+ return;
+ }
+ pos += (size_t)w;
+ }
+}
+
+// Text after "http://" or "https://", or nullptr for any other scheme.
+static inline const char* ota_url_after_scheme(const char* url) {
+ if (url == nullptr) return nullptr;
+ if (strncmp(url, "https://", 8) == 0) return url + 8;
+ if (strncmp(url, "http://", 7) == 0) return url + 7;
+ return nullptr;
+}
+
+// Manifest URL for `ota check` / `ota update`, always over plain HTTP: it only
+// carries version info, and skipping TLS keeps the check cheap on RAM-tight
+// builds. The image itself is fetched from the manifest's `file`.
+static inline bool ota_manifest_url(const char* base, const char* variant, char* out, size_t out_sz) {
+ const char* rest = ota_url_after_scheme(base);
+ if (rest == nullptr) return false;
+ int n = snprintf(out, out_sz, "http://%s/%s.json", rest, variant);
+ return n > 0 && (size_t)n < out_sz;
+}
+
+// A manifest may only point at images under the server's firmware path for this
+// product: base ".../meshcomod/v" -> ".../meshcomod/fw/". Either scheme is accepted.
+static inline bool ota_firmware_url_allowed(const char* url, const char* base) {
+ const char* u = ota_url_after_scheme(url);
+ const char* b = ota_url_after_scheme(base);
+ if (u == nullptr || b == nullptr) return false;
+ size_t blen = strlen(b);
+ if (blen < 2 || strcmp(b + blen - 2, "/v") != 0) return false;
+ size_t root = blen - 1; // keep the '/' before "v"
+ if (strncmp(u, b, root) != 0 || strncmp(u + root, "fw/", 3) != 0) return false;
+ return strstr(u, "..") == nullptr;
+}
+
+// The image download is only ever done over TLS.
+static inline bool ota_url_is_https(const char* url) {
+ return url != nullptr && strncmp(url, "https://", 8) == 0;
+}
+
+// Skip a JSON string. p is at the opening quote; returns the character after the
+// closing quote, or nullptr when the string never ends.
+static inline const char* ota_json_skip_string(const char* p) {
+ for (p++; *p; p++) {
+ if (*p == '\\') {
+ if (*++p == '\0') return nullptr;
+ } else if (*p == '"') {
+ return p + 1;
+ }
+ }
+ return nullptr;
+}
+
+static inline const char* ota_json_skip_ws(const char* p) {
+ while (*p == ' ' || *p == '\t' || *p == '\r' || *p == '\n') p++;
+ return p;
+}
+
+// Read the string value of a top-level key of a flat JSON object (the manifest),
+// so the companion needs no JSON library. Handles the escapes a JSON encoder
+// emits for URLs and plain text; \u escapes are accepted for ASCII only. Returns
+// false (out = "") when the key is missing, not a string, malformed, or does not
+// fit in out.
+static inline bool ota_json_string(const char* json, const char* key, char* out, size_t out_sz) {
+ if (out_sz) out[0] = 0;
+ if (json == nullptr || key == nullptr || out_sz == 0) return false;
+ const size_t klen = strlen(key);
+ int depth = 0;
+ bool expect_key = false;
+ const char* p = json;
+ while (*p) {
+ char c = *p;
+ if (c == '{' || c == '[') {
+ depth++;
+ expect_key = (c == '{' && depth == 1);
+ p++;
+ } else if (c == '}' || c == ']') {
+ depth--;
+ p++;
+ } else if (c == ',') {
+ expect_key = (depth == 1);
+ p++;
+ } else if (c == '"') {
+ const char* end = ota_json_skip_string(p);
+ if (end == nullptr) return false;
+ if (depth != 1 || !expect_key) { // a value, or a key of a nested object
+ p = end;
+ continue;
+ }
+ expect_key = false;
+ bool match = (size_t)(end - p - 2) == klen && strncmp(p + 1, key, klen) == 0;
+ p = ota_json_skip_ws(end);
+ if (*p != ':') return false;
+ p = ota_json_skip_ws(p + 1);
+ if (!match) continue;
+ if (*p != '"') return false;
+ size_t n = 0;
+ for (p++; *p != '"'; p++) {
+ if (*p == '\0') { out[0] = 0; return false; }
+ char ch = *p;
+ if (ch == '\\') {
+ p++;
+ switch (*p) {
+ case '"': case '\\': case '/': ch = *p; break;
+ case 'b': ch = '\b'; break;
+ case 'f': ch = '\f'; break;
+ case 'n': ch = '\n'; break;
+ case 'r': ch = '\r'; break;
+ case 't': ch = '\t'; break;
+ case 'u': {
+ unsigned v = 0;
+ for (int i = 1; i <= 4; i++) {
+ char h = p[i];
+ v <<= 4;
+ if (h >= '0' && h <= '9') v |= (unsigned)(h - '0');
+ else if (h >= 'a' && h <= 'f') v |= (unsigned)(h - 'a' + 10);
+ else if (h >= 'A' && h <= 'F') v |= (unsigned)(h - 'A' + 10);
+ else { out[0] = 0; return false; }
+ }
+ if (v == 0 || v > 0x7f) { out[0] = 0; return false; }
+ ch = (char)v;
+ p += 4;
+ break;
+ }
+ default: out[0] = 0; return false;
+ }
+ }
+ if (n + 1 >= out_sz) { out[0] = 0; return false; }
+ out[n++] = ch;
+ }
+ out[n] = 0;
+ return true;
+ } else {
+ p++;
+ }
+ }
+ return false;
+}
+
+// `ota branch`: which server channel the device pulls from. NATIVE is the one the
+// build was made for (dev for a DMC_CHANNEL=dev build, else stable).
+enum : uint8_t {
+ OTA_CHANNEL_NATIVE = 0,
+ OTA_CHANNEL_STABLE = 1,
+ OTA_CHANNEL_DEV = 2,
+};
+
+static inline bool ota_channel_parse(const char* name, uint8_t* out) {
+ if (name == nullptr) return false;
+ if (strcmp(name, "stable") == 0) { *out = OTA_CHANNEL_STABLE; return true; }
+ if (strcmp(name, "dev") == 0) { *out = OTA_CHANNEL_DEV; return true; }
+ if (strcmp(name, "default") == 0) { *out = OTA_CHANNEL_NATIVE; return true; }
+ return false;
+}
+
+static inline const char* ota_channel_base(uint8_t sel, const char* native, const char* stable, const char* dev) {
+ if (sel == OTA_CHANNEL_STABLE) return stable;
+ if (sel == OTA_CHANNEL_DEV) return dev;
+ return native;
+}
+
+static inline const char* ota_channel_name(const char* base, const char* stable, const char* dev) {
+ if (strcmp(base, stable) == 0) return "stable";
+ if (strcmp(base, dev) == 0) return "dev";
+ return "custom";
+}
diff --git a/test/test_ota_manifest/test_ota_manifest.cpp b/test/test_ota_manifest/test_ota_manifest.cpp
new file mode 100644
index 00000000000..e6bde2990eb
--- /dev/null
+++ b/test/test_ota_manifest/test_ota_manifest.cpp
@@ -0,0 +1,247 @@
+#include
+
+#include
+#include
+
+// Manifest pull OTA decisions (version compare, partition signature, URL rules).
+// Pure string logic, so it runs on `native`; the network and flash side lives in
+// examples/companion_radio/CompanionOta.cpp.
+#include "helpers/OtaManifest.h"
+
+static const char* BASE = "https://ota.dutchmeshcore.nl/meshcomod/v";
+
+// --- version parsing --------------------------------------------------------
+
+TEST(OtaVersion, ParsesBuildScriptString) {
+ OtaVersion v;
+ ASSERT_TRUE(ota_version_parse("v1.18.0.1-ab12cd3", &v));
+ EXPECT_EQ(v.count, 4);
+ EXPECT_EQ(v.part[0], 1);
+ EXPECT_EQ(v.part[1], 18);
+ EXPECT_EQ(v.part[2], 0);
+ EXPECT_EQ(v.part[3], 1);
+ EXPECT_STREQ(v.hash, "ab12cd3");
+}
+
+TEST(OtaVersion, PlainVersionHasNoHash) {
+ OtaVersion v;
+ ASSERT_TRUE(ota_version_parse("v1.18.0.1", &v));
+ EXPECT_EQ(v.count, 4);
+ EXPECT_STREQ(v.hash, "");
+}
+
+TEST(OtaVersion, HashIsTheLastDashToken) {
+ OtaVersion v;
+ ASSERT_TRUE(ota_version_parse("v1.17.0.4-DMC-EV-abc1234", &v));
+ EXPECT_EQ(v.part[3], 4);
+ EXPECT_STREQ(v.hash, "abc1234");
+}
+
+TEST(OtaVersion, RejectsNonVersionPrefix) {
+ OtaVersion v;
+ EXPECT_FALSE(ota_version_parse("meshcomod-r1.14.1.0-repeater-tcp-abc1234", &v));
+ EXPECT_FALSE(ota_version_parse("", &v));
+ EXPECT_FALSE(ota_version_parse(nullptr, &v));
+ EXPECT_FALSE(ota_version_parse("v1..2", &v));
+}
+
+// --- compare ---------------------------------------------------------------
+
+TEST(OtaCompare, NewerBuildNumber) {
+ EXPECT_EQ(ota_compare("v1.18.0.1-aaaaaaa", "v1.18.0.2", "bbbbbbb"), OTA_VERDICT_NEWER);
+}
+
+TEST(OtaCompare, NewerBaseVersion) {
+ EXPECT_EQ(ota_compare("v1.18.0.9-aaaaaaa", "v1.19.0.0", "bbbbbbb"), OTA_VERDICT_NEWER);
+}
+
+TEST(OtaCompare, OlderIsNotAnUpdate) {
+ EXPECT_EQ(ota_compare("v1.18.0.3-aaaaaaa", "v1.18.0.2", "bbbbbbb"), OTA_VERDICT_OLDER);
+ EXPECT_EQ(ota_compare("v1.19.0.0-aaaaaaa", "v1.18.9.9", "bbbbbbb"), OTA_VERDICT_OLDER);
+}
+
+TEST(OtaCompare, SameVersionSameHashIsCurrent) {
+ EXPECT_EQ(ota_compare("v1.18.0.1-ab12cd3", "v1.18.0.1", "ab12cd3"), OTA_VERDICT_CURRENT);
+}
+
+TEST(OtaCompare, HashPrefixAbsorbsAbbreviationLength) {
+ EXPECT_EQ(ota_compare("v1.18.0.1-ab12cd3", "v1.18.0.1", "ab12cd34"), OTA_VERDICT_CURRENT);
+}
+
+TEST(OtaCompare, SameVersionOtherHashIsRebuild) {
+ EXPECT_EQ(ota_compare("v1.18.0.1-ab12cd3", "v1.18.0.1", "ffffff0"), OTA_VERDICT_REBUILD);
+}
+
+TEST(OtaCompare, SameVersionWithoutRunningHashIsCurrent) {
+ // A local build without build.sh embeds the bare MyMesh.h version.
+ EXPECT_EQ(ota_compare("v1.18.0.1", "v1.18.0.1", "ab12cd3"), OTA_VERDICT_CURRENT);
+}
+
+TEST(OtaCompare, MissingFourthPartCountsAsZero) {
+ EXPECT_EQ(ota_compare("v1.18.0-aaaaaaa", "v1.18.0.0", "aaaaaaa"), OTA_VERDICT_CURRENT);
+ EXPECT_EQ(ota_compare("v1.18.0-aaaaaaa", "v1.18.0.1", "bbbbbbb"), OTA_VERDICT_NEWER);
+}
+
+TEST(OtaCompare, UnparseableFallsBackToHash) {
+ EXPECT_EQ(ota_compare("custom-ab12cd3", "", "ab12cd3"), OTA_VERDICT_CURRENT);
+ EXPECT_EQ(ota_compare("custom-ab12cd3", "", "ffffff0"), OTA_VERDICT_REBUILD);
+}
+
+// --- partition signature ---------------------------------------------------
+
+TEST(OtaPartSig, MatchesScriptFormatSortedByOffset) {
+ // Same definition as scripts/partition_signature.py: "%x:%x:%x:%x" joined by ','.
+ OtaPartEntry e[] = {
+ {0x00, 0x10, 0x10000, 0x330000},
+ {0x01, 0x02, 0x9000, 0x5000},
+ {0x01, 0x00, 0xe000, 0x2000},
+ };
+ char out[128];
+ ota_partsig_format(e, 3, out, sizeof(out));
+ EXPECT_STREQ(out, "1:2:9000:5000,1:0:e000:2000,0:10:10000:330000");
+}
+
+TEST(OtaPartSig, TruncatesWithoutOverflow) {
+ OtaPartEntry e[] = {{0x01, 0x02, 0x9000, 0x5000}, {0x00, 0x10, 0x10000, 0x330000}};
+ char out[8];
+ ota_partsig_format(e, 2, out, sizeof(out));
+ EXPECT_LT(strlen(out), sizeof(out));
+}
+
+// --- URLs -------------------------------------------------------------------
+
+TEST(OtaUrl, ManifestCheckUsesPlainHttp) {
+ char url[160];
+ ASSERT_TRUE(ota_manifest_url(BASE, "heltec_v4_companion_radio_usb_tcp", url, sizeof(url)));
+ EXPECT_STREQ(url, "http://ota.dutchmeshcore.nl/meshcomod/v/heltec_v4_companion_radio_usb_tcp.json");
+}
+
+TEST(OtaUrl, ManifestUrlTooLongFails) {
+ char url[16];
+ EXPECT_FALSE(ota_manifest_url(BASE, "heltec_v4_companion_radio_usb_tcp", url, sizeof(url)));
+}
+
+TEST(OtaUrl, FirmwareUnderSiblingFwPathIsAllowed) {
+ EXPECT_TRUE(ota_firmware_url_allowed(
+ "https://ota.dutchmeshcore.nl/meshcomod/fw/companion-v1.18.0.2/heltec_v4_companion_radio_usb_tcp-v1.18.0.2-abc1234.bin",
+ BASE));
+ EXPECT_TRUE(ota_firmware_url_allowed(
+ "http://ota.dutchmeshcore.nl/meshcomod/fw/companion-v1.18.0.2/x-v1.18.0.2-abc1234.bin", BASE));
+}
+
+TEST(OtaUrl, FirmwareElsewhereIsRefused) {
+ EXPECT_FALSE(ota_firmware_url_allowed("https://evil.example/meshcomod/fw/a.bin", BASE));
+ EXPECT_FALSE(ota_firmware_url_allowed("https://ota.dutchmeshcore.nl/mqtt/fw/a/b.bin", BASE));
+ EXPECT_FALSE(ota_firmware_url_allowed("https://ota.dutchmeshcore.nl/meshcomod/fw/../../x.bin", BASE));
+ EXPECT_FALSE(ota_firmware_url_allowed("https://ota.dutchmeshcore.nl/meshcomod/fwx/a.bin", BASE));
+ EXPECT_FALSE(ota_firmware_url_allowed("ftp://ota.dutchmeshcore.nl/meshcomod/fw/a.bin", BASE));
+ EXPECT_FALSE(ota_firmware_url_allowed(nullptr, BASE));
+}
+
+TEST(OtaUrl, BaseWithoutTrailingVAllowsNothing) {
+ EXPECT_FALSE(ota_firmware_url_allowed("https://ota.dutchmeshcore.nl/meshcomod/fw/a.bin",
+ "https://ota.dutchmeshcore.nl/meshcomod"));
+}
+
+// --- DMC version strings ----------------------------------------------------
+
+TEST(OtaCompare, DmcStableStringMatchesItsManifest) {
+ EXPECT_EQ(ota_compare("v1.18.0-dutchmeshcore.nl-ab12cd3", "v1.18.0", "ab12cd3"), OTA_VERDICT_CURRENT);
+}
+
+TEST(OtaCompare, DmcDevBuildOfAnotherCommitIsRebuild) {
+ EXPECT_EQ(ota_compare("v1.18.0-dev-dutchmeshcore.nl-ab12cd3", "v1.18.0", "ffffff0"), OTA_VERDICT_REBUILD);
+}
+
+TEST(OtaCompare, DmcNewerReleaseIsNewer) {
+ EXPECT_EQ(ota_compare("v1.18.0-dutchmeshcore.nl-ab12cd3", "v1.18.1", "ffffff0"), OTA_VERDICT_NEWER);
+}
+
+// --- manifest fields ----------------------------------------------------------
+
+static const char* MANIFEST =
+ "{\"file\": \"https:\/\/ota.dutchmeshcore.nl\/companion\/fw\/dmc-companion-v1.18.0\/a-v1.18.0-ab12cd3.bin\","
+ " \"version\":\"v1.18.0\",\n \"hash\" : \"ab12cd3\", \"partSig\": \"1:2:9000:5000,0:10:10000:330000\"}";
+
+TEST(OtaJson, ReadsStringFields) {
+ char out[160];
+ ASSERT_TRUE(ota_json_string(MANIFEST, "file", out, sizeof(out)));
+ EXPECT_STREQ(out, "https://ota.dutchmeshcore.nl/companion/fw/dmc-companion-v1.18.0/a-v1.18.0-ab12cd3.bin");
+ ASSERT_TRUE(ota_json_string(MANIFEST, "version", out, sizeof(out)));
+ EXPECT_STREQ(out, "v1.18.0");
+ ASSERT_TRUE(ota_json_string(MANIFEST, "hash", out, sizeof(out)));
+ EXPECT_STREQ(out, "ab12cd3");
+ ASSERT_TRUE(ota_json_string(MANIFEST, "partSig", out, sizeof(out)));
+ EXPECT_STREQ(out, "1:2:9000:5000,0:10:10000:330000");
+}
+
+TEST(OtaJson, MissingKeyIsEmpty) {
+ char out[16] = "x";
+ EXPECT_FALSE(ota_json_string("{\"file\":\"a\"}", "partSig", out, sizeof(out)));
+ EXPECT_STREQ(out, "");
+}
+
+TEST(OtaJson, KeyTextInsideAValueDoesNotMatch) {
+ char out[32];
+ ASSERT_TRUE(ota_json_string("{\"note\":\"\\\"hash\\\": \\\"bad\\\"\",\"hash\":\"good\"}", "hash", out, sizeof(out)));
+ EXPECT_STREQ(out, "good");
+}
+
+TEST(OtaJson, NonStringValueIsRefused) {
+ char out[16];
+ EXPECT_FALSE(ota_json_string("{\"hash\":123}", "hash", out, sizeof(out)));
+}
+
+TEST(OtaJson, TooLongValueIsRefused) {
+ char out[4];
+ EXPECT_FALSE(ota_json_string("{\"hash\":\"abcdef\"}", "hash", out, sizeof(out)));
+}
+
+TEST(OtaJson, UnterminatedStringIsRefused) {
+ char out[16];
+ EXPECT_FALSE(ota_json_string("{\"hash\":\"abc", "hash", out, sizeof(out)));
+}
+
+// --- image URL scheme ----------------------------------------------------------
+
+TEST(OtaUrl, ImageMustBeHttps) {
+ EXPECT_TRUE(ota_url_is_https("https://ota.dutchmeshcore.nl/companion/fw/t/a.bin"));
+ EXPECT_FALSE(ota_url_is_https("http://ota.dutchmeshcore.nl/companion/fw/t/a.bin"));
+ EXPECT_FALSE(ota_url_is_https(nullptr));
+}
+
+// --- channel ------------------------------------------------------------------
+
+static const char* NATIVE = "https://ota.dutchmeshcore.nl/companion/dev/v";
+static const char* STABLE = "https://ota.dutchmeshcore.nl/companion/v";
+static const char* DEV = "https://ota.dutchmeshcore.nl/companion/dev/v";
+
+TEST(OtaChannel, ParsesNames) {
+ uint8_t ch = 99;
+ ASSERT_TRUE(ota_channel_parse("stable", &ch));
+ EXPECT_EQ(ch, OTA_CHANNEL_STABLE);
+ ASSERT_TRUE(ota_channel_parse("dev", &ch));
+ EXPECT_EQ(ch, OTA_CHANNEL_DEV);
+ ASSERT_TRUE(ota_channel_parse("default", &ch));
+ EXPECT_EQ(ch, OTA_CHANNEL_NATIVE);
+ EXPECT_FALSE(ota_channel_parse("beta", &ch));
+ EXPECT_FALSE(ota_channel_parse(nullptr, &ch));
+}
+
+TEST(OtaChannel, ResolvesBase) {
+ EXPECT_STREQ(ota_channel_base(OTA_CHANNEL_NATIVE, NATIVE, STABLE, DEV), NATIVE);
+ EXPECT_STREQ(ota_channel_base(OTA_CHANNEL_STABLE, NATIVE, STABLE, DEV), STABLE);
+ EXPECT_STREQ(ota_channel_base(OTA_CHANNEL_DEV, NATIVE, STABLE, DEV), DEV);
+ EXPECT_STREQ(ota_channel_base(7, NATIVE, STABLE, DEV), NATIVE); // unknown: the build's own
+}
+
+TEST(OtaChannel, NamesTheBase) {
+ EXPECT_STREQ(ota_channel_name(STABLE, STABLE, DEV), "stable");
+ EXPECT_STREQ(ota_channel_name(DEV, STABLE, DEV), "dev");
+ EXPECT_STREQ(ota_channel_name("https://x/v", STABLE, DEV), "custom");
+}
+
+int main(int argc, char** argv) {
+ ::testing::InitGoogleTest(&argc, argv);
+ return RUN_ALL_TESTS();
+}
diff --git a/variants/heltec_rc32/platformio.ini b/variants/heltec_rc32/platformio.ini
index 2f07c021b18..0894c6399d2 100644
--- a/variants/heltec_rc32/platformio.ini
+++ b/variants/heltec_rc32/platformio.ini
@@ -193,6 +193,7 @@ lib_deps =
${Heltec_RC32.lib_deps}
densaugeo/base64 @ ~1.4.0
end2endzone/NonBlockingRTTTL@^1.3.0
+ 0neblock/SNMP_Agent @ 2.1.0
[env:heltec_rc32_without_display_sensor]
extends = Heltec_RC32
@@ -334,6 +335,7 @@ lib_deps =
${Heltec_RC32_with_display.lib_deps}
densaugeo/base64 @ ~1.4.0
end2endzone/NonBlockingRTTTL@^1.3.0
+ 0neblock/SNMP_Agent @ 2.1.0
[env:heltec_rc32_sensor]
extends = Heltec_RC32_with_display
diff --git a/variants/heltec_tracker_v2/platformio.ini b/variants/heltec_tracker_v2/platformio.ini
index 5fd9edabe7a..0fa3d46a556 100644
--- a/variants/heltec_tracker_v2/platformio.ini
+++ b/variants/heltec_tracker_v2/platformio.ini
@@ -202,6 +202,7 @@ build_src_filter = ${Heltec_tracker_v2.build_src_filter}
lib_deps =
${Heltec_tracker_v2.lib_deps}
densaugeo/base64 @ ~1.4.0
+ 0neblock/SNMP_Agent @ 2.1.0
[env:heltec_tracker_v2_sensor]
extends = Heltec_tracker_v2
diff --git a/variants/heltec_v2/platformio.ini b/variants/heltec_v2/platformio.ini
index 6435936ebdd..3eeef9f81ea 100644
--- a/variants/heltec_v2/platformio.ini
+++ b/variants/heltec_v2/platformio.ini
@@ -198,6 +198,7 @@ build_src_filter = ${Heltec_lora32_v2.build_src_filter}
lib_deps =
${Heltec_lora32_v2.lib_deps}
densaugeo/base64 @ ~1.4.0
+ 0neblock/SNMP_Agent @ 2.1.0
[env:Heltec_v2_kiss_modem]
extends = Heltec_lora32_v2
diff --git a/variants/heltec_v3/platformio.ini b/variants/heltec_v3/platformio.ini
index 06c028569e1..b94ccf25162 100644
--- a/variants/heltec_v3/platformio.ini
+++ b/variants/heltec_v3/platformio.ini
@@ -181,6 +181,7 @@ build_src_filter = ${Heltec_lora32_v3.build_src_filter}
lib_deps =
${Heltec_lora32_v3.lib_deps}
densaugeo/base64 @ ~1.4.0
+ 0neblock/SNMP_Agent @ 2.1.0
[env:Heltec_v3_companion_radio_wifi]
extends = Heltec_lora32_v3
@@ -207,6 +208,7 @@ build_src_filter = ${Heltec_lora32_v3.build_src_filter}
lib_deps =
${Heltec_lora32_v3.lib_deps}
densaugeo/base64 @ ~1.4.0
+ 0neblock/SNMP_Agent @ 2.1.0
[env:Heltec_v3_sensor]
extends = Heltec_lora32_v3
@@ -326,6 +328,7 @@ build_src_filter = ${Heltec_lora32_v3.build_src_filter}
lib_deps =
${Heltec_lora32_v3.lib_deps}
densaugeo/base64 @ ~1.4.0
+ 0neblock/SNMP_Agent @ 2.1.0
[env:Heltec_WSL3_companion_radio_usb]
extends = Heltec_lora32_v3
@@ -362,6 +365,7 @@ build_src_filter = ${Heltec_lora32_v3.build_src_filter}
lib_deps =
${Heltec_lora32_v3.lib_deps}
densaugeo/base64 @ ~1.4.0
+ 0neblock/SNMP_Agent @ 2.1.0
[env:Heltec_WSL3_sensor]
extends = Heltec_lora32_v3
diff --git a/variants/heltec_v4/platformio.ini b/variants/heltec_v4/platformio.ini
index 76e8e714e68..d6c1256d15d 100644
--- a/variants/heltec_v4/platformio.ini
+++ b/variants/heltec_v4/platformio.ini
@@ -224,6 +224,7 @@ build_src_filter = ${heltec_v4_oled.build_src_filter}
lib_deps =
${heltec_v4_oled.lib_deps}
densaugeo/base64 @ ~1.4.0
+ 0neblock/SNMP_Agent @ 2.1.0
[env:heltec_v4_companion_radio_wifi]
extends = heltec_v4_oled
@@ -250,6 +251,7 @@ build_src_filter = ${heltec_v4_oled.build_src_filter}
lib_deps =
${heltec_v4_oled.lib_deps}
densaugeo/base64 @ ~1.4.0
+ 0neblock/SNMP_Agent @ 2.1.0
[env:heltec_v4_sensor]
extends = heltec_v4_oled
@@ -417,6 +419,7 @@ build_src_filter = ${heltec_v4_tft.build_src_filter}
lib_deps =
${heltec_v4_tft.lib_deps}
densaugeo/base64 @ ~1.4.0
+ 0neblock/SNMP_Agent @ 2.1.0
[env:heltec_v4_tft_sensor]
extends = heltec_v4_tft
diff --git a/variants/heltec_v4_r8/platformio.ini b/variants/heltec_v4_r8/platformio.ini
index e22ca8f5418..d72a1868614 100644
--- a/variants/heltec_v4_r8/platformio.ini
+++ b/variants/heltec_v4_r8/platformio.ini
@@ -171,6 +171,7 @@ build_src_filter = ${heltec_v4_r8_oled.build_src_filter}
lib_deps =
${heltec_v4_r8_oled.lib_deps}
densaugeo/base64 @ ~1.4.0
+ 0neblock/SNMP_Agent @ 2.1.0
[env:heltec_v4_r8_companion_radio_wifi]
extends = heltec_v4_r8_oled
@@ -195,6 +196,7 @@ build_src_filter = ${heltec_v4_r8_oled.build_src_filter}
lib_deps =
${heltec_v4_r8_oled.lib_deps}
densaugeo/base64 @ ~1.4.0
+ 0neblock/SNMP_Agent @ 2.1.0
[env:heltec_v4_r8_sensor]
extends = heltec_v4_r8_oled
@@ -322,6 +324,7 @@ build_src_filter = ${heltec_v4_r8_tft.build_src_filter}
lib_deps =
${heltec_v4_r8_tft.lib_deps}
densaugeo/base64 @ ~1.4.0
+ 0neblock/SNMP_Agent @ 2.1.0
[env:heltec_v4_r8_tft_sensor]
extends = heltec_v4_r8_tft
diff --git a/variants/heltec_wireless_paper/platformio.ini b/variants/heltec_wireless_paper/platformio.ini
index 56f95b2f980..735fd8a6092 100644
--- a/variants/heltec_wireless_paper/platformio.ini
+++ b/variants/heltec_wireless_paper/platformio.ini
@@ -65,6 +65,7 @@ lib_deps =
${Heltec_Wireless_Paper_base.lib_deps}
densaugeo/base64 @ ~1.4.0
bakercp/CRC32 @ ^2.0.0
+ 0neblock/SNMP_Agent @ 2.1.0
[env:Heltec_Wireless_Paper_companion_radio_usb]
extends = Heltec_Wireless_Paper_base
diff --git a/variants/lilygo_tbeam_1w/platformio.ini b/variants/lilygo_tbeam_1w/platformio.ini
index ab9c9e67cdc..0ba509a97ef 100644
--- a/variants/lilygo_tbeam_1w/platformio.ini
+++ b/variants/lilygo_tbeam_1w/platformio.ini
@@ -172,6 +172,7 @@ build_src_filter = ${LilyGo_TBeam_1W.build_src_filter}
lib_deps =
${LilyGo_TBeam_1W.lib_deps}
densaugeo/base64 @ ~1.4.0
+ 0neblock/SNMP_Agent @ 2.1.0
; === LILYGO T-Beam 1W Repeater with ESPNow Bridge ===
[env:LilyGo_TBeam_1W_repeater_bridge_espnow]
diff --git a/variants/lilygo_tbeam_supreme_SX1262/platformio.ini b/variants/lilygo_tbeam_supreme_SX1262/platformio.ini
index af4a8eafa50..3634cdb267c 100644
--- a/variants/lilygo_tbeam_supreme_SX1262/platformio.ini
+++ b/variants/lilygo_tbeam_supreme_SX1262/platformio.ini
@@ -168,6 +168,7 @@ build_src_filter = ${T_Beam_S3_Supreme_SX1262.build_src_filter}
lib_deps =
${T_Beam_S3_Supreme_SX1262.lib_deps}
densaugeo/base64 @ ~1.4.0
+ 0neblock/SNMP_Agent @ 2.1.0
[env:T_Beam_S3_Supreme_SX1262_kiss_modem]
extends = T_Beam_S3_Supreme_SX1262
diff --git a/variants/lilygo_tlora_v2_1/platformio.ini b/variants/lilygo_tlora_v2_1/platformio.ini
index dc60b6f75e4..b3b0f3a6139 100644
--- a/variants/lilygo_tlora_v2_1/platformio.ini
+++ b/variants/lilygo_tlora_v2_1/platformio.ini
@@ -131,6 +131,8 @@ lib_deps =
extends = LilyGo_TLora_V2_1_1_6
build_flags =
${LilyGo_TLora_V2_1_1_6.build_flags}
+; 4 MB flash: no room for the TLS stack that OTA needs
+ -D DMC_COMPANION_NO_OTA
-I examples/companion_radio/ui-new
-D ENABLE_WIFI_INTERFACE
-D MAX_CONTACTS=160
@@ -148,6 +150,7 @@ build_src_filter = ${LilyGo_TLora_V2_1_1_6.build_src_filter}
lib_deps =
${LilyGo_TLora_V2_1_1_6.lib_deps}
densaugeo/base64 @ ~1.4.0
+ 0neblock/SNMP_Agent @ 2.1.0
;
; Repeater Bridges
diff --git a/variants/meshnology_w12/platformio.ini b/variants/meshnology_w12/platformio.ini
index ffab9d57c62..328668c7108 100644
--- a/variants/meshnology_w12/platformio.ini
+++ b/variants/meshnology_w12/platformio.ini
@@ -191,6 +191,7 @@ build_src_filter = ${meshnology_w12.build_src_filter}
lib_deps =
${meshnology_w12.lib_deps}
densaugeo/base64 @ ~1.4.0
+ 0neblock/SNMP_Agent @ 2.1.0
[env:meshnology_w12_sensor]
extends = meshnology_w12
diff --git a/variants/rak3112/platformio.ini b/variants/rak3112/platformio.ini
index 82f5fcc4d8f..a64c84418c9 100644
--- a/variants/rak3112/platformio.ini
+++ b/variants/rak3112/platformio.ini
@@ -171,6 +171,7 @@ build_src_filter = ${rak3112.build_src_filter}
lib_deps =
${rak3112.lib_deps}
densaugeo/base64 @ ~1.4.0
+ 0neblock/SNMP_Agent @ 2.1.0
[env:RAK_3112_companion_radio_wifi]
extends = rak3112
@@ -194,6 +195,7 @@ build_src_filter = ${rak3112.build_src_filter}
lib_deps =
${rak3112.lib_deps}
densaugeo/base64 @ ~1.4.0
+ 0neblock/SNMP_Agent @ 2.1.0
[env:RAK_3112_sensor]
extends = rak3112
diff --git a/variants/station_g2/platformio.ini b/variants/station_g2/platformio.ini
index c1ae765fede..012081329b3 100644
--- a/variants/station_g2/platformio.ini
+++ b/variants/station_g2/platformio.ini
@@ -241,6 +241,7 @@ build_src_filter = ${Station_G2.build_src_filter}
lib_deps =
${Station_G2.lib_deps}
densaugeo/base64 @ ~1.4.0
+ 0neblock/SNMP_Agent @ 2.1.0
[env:Station_G2_kiss_modem]
extends = Station_G2
diff --git a/variants/station_g3_esp32/platformio.ini b/variants/station_g3_esp32/platformio.ini
index 7006d939ee5..c813558a146 100644
--- a/variants/station_g3_esp32/platformio.ini
+++ b/variants/station_g3_esp32/platformio.ini
@@ -156,6 +156,7 @@ build_src_filter = ${Station_G3_ESP32.build_src_filter}
lib_deps =
${Station_G3_ESP32.lib_deps}
densaugeo/base64 @ ~1.4.0
+ 0neblock/SNMP_Agent @ 2.1.0
[env:Station_G3_ESP32_kiss_modem]
extends = Station_G3_ESP32
diff --git a/variants/thinknode_m2/platformio.ini b/variants/thinknode_m2/platformio.ini
index 862c7154331..6c0f34d9040 100644
--- a/variants/thinknode_m2/platformio.ini
+++ b/variants/thinknode_m2/platformio.ini
@@ -175,6 +175,8 @@ lib_deps =
extends = ThinkNode_M2
build_flags =
${ThinkNode_M2.build_flags}
+; 4 MB flash: no room for the TLS stack that OTA needs
+ -D DMC_COMPANION_NO_OTA
-I examples/companion_radio/ui-new
-D ENABLE_WIFI_INTERFACE
-D MAX_CONTACTS=350
@@ -194,6 +196,7 @@ lib_deps =
${ThinkNode_M2.lib_deps}
densaugeo/base64 @ ~1.4.0
end2endzone/NonBlockingRTTTL@^1.3.0
+ 0neblock/SNMP_Agent @ 2.1.0
[env:ThinkNode_M2_companion_radio_serial]
extends = ThinkNode_M2
diff --git a/variants/thinknode_m5/platformio.ini b/variants/thinknode_m5/platformio.ini
index a3f348b3374..4e4c0d99972 100644
--- a/variants/thinknode_m5/platformio.ini
+++ b/variants/thinknode_m5/platformio.ini
@@ -189,6 +189,8 @@ lib_deps =
extends = ThinkNode_M5
build_flags =
${ThinkNode_M5.build_flags}
+; 4 MB flash: no room for the TLS stack that OTA needs
+ -D DMC_COMPANION_NO_OTA
-I examples/companion_radio/ui-new
-D ENABLE_WIFI_INTERFACE
-D MAX_CONTACTS=350
@@ -208,6 +210,7 @@ lib_deps =
${ThinkNode_M5.lib_deps}
densaugeo/base64 @ ~1.4.0
end2endzone/NonBlockingRTTTL@^1.3.0
+ 0neblock/SNMP_Agent @ 2.1.0
[env:ThinkNode_M5_companion_radio_serial]
extends = ThinkNode_M5
diff --git a/variants/thinknode_m7/platformio.ini b/variants/thinknode_m7/platformio.ini
index 128b124417d..9933ee2d48d 100644
--- a/variants/thinknode_m7/platformio.ini
+++ b/variants/thinknode_m7/platformio.ini
@@ -113,6 +113,7 @@ lib_deps =
${ThinkNode_M7.lib_deps}
${ThinkNode_M7_ethernet.lib_deps}
densaugeo/base64 @ ~1.4.0
+ 0neblock/SNMP_Agent @ 2.1.0
[env:ThinkNode_M7_companion_radio_usb]
extends = ThinkNode_M7
@@ -156,6 +157,7 @@ build_src_filter = ${ThinkNode_M7.build_src_filter}
lib_deps =
${ThinkNode_M7.lib_deps}
densaugeo/base64 @ ~1.4.0
+ 0neblock/SNMP_Agent @ 2.1.0
[env:ThinkNode_M7_companion_radio_ethernet]
extends = ThinkNode_M7
diff --git a/variants/xiao_c3/platformio.ini b/variants/xiao_c3/platformio.ini
index 62e6fd21623..5111e8334f1 100644
--- a/variants/xiao_c3/platformio.ini
+++ b/variants/xiao_c3/platformio.ini
@@ -118,6 +118,8 @@ build_src_filter = ${Xiao_esp32_C3.build_src_filter}
+
build_flags =
${Xiao_esp32_C3.build_flags}
+; 4 MB flash: no room for the TLS stack that OTA needs
+ -D DMC_COMPANION_NO_OTA
-D ENABLE_WIFI_INTERFACE
-D MAX_CONTACTS=350
-D MAX_GROUP_CHANNELS=40
@@ -132,6 +134,7 @@ lib_deps =
${Xiao_esp32_C3.lib_deps}
${esp32_ota.lib_deps}
densaugeo/base64 @ ~1.4.0
+ 0neblock/SNMP_Agent @ 2.1.0
[env:Xiao_C3_kiss_modem]
extends = Xiao_esp32_C3
diff --git a/variants/xiao_s3/platformio.ini b/variants/xiao_s3/platformio.ini
index b8ae79733ca..c79a0987cf4 100644
--- a/variants/xiao_s3/platformio.ini
+++ b/variants/xiao_s3/platformio.ini
@@ -115,6 +115,7 @@ lib_deps =
${Xiao_S3.lib_deps}
densaugeo/base64 @ ~1.4.0
adafruit/Adafruit SSD1306 @ ^2.5.13
+ 0neblock/SNMP_Agent @ 2.1.0
[env:Xiao_S3_companion_radio_usb]
extends = Xiao_S3
diff --git a/variants/xiao_s3_wio/platformio.ini b/variants/xiao_s3_wio/platformio.ini
index 5a2ef106173..6b68dca5718 100644
--- a/variants/xiao_s3_wio/platformio.ini
+++ b/variants/xiao_s3_wio/platformio.ini
@@ -175,6 +175,7 @@ lib_deps =
${Xiao_S3_WIO.lib_deps}
densaugeo/base64 @ ~1.4.0
adafruit/Adafruit SSD1306 @ ^2.5.13
+ 0neblock/SNMP_Agent @ 2.1.0
[env:Xiao_S3_WIO_companion_radio_serial]
extends = Xiao_S3_WIO
@@ -223,6 +224,7 @@ build_flags =
lib_deps =
${Xiao_S3_WIO.lib_deps}
densaugeo/base64 @ ~1.4.0
+ 0neblock/SNMP_Agent @ 2.1.0
[env:Xiao_S3_WIO_sensor]
extends = Xiao_S3_WIO