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