diff --git a/.github/scripts/publish-device-releases.sh b/.github/scripts/publish-device-releases.sh new file mode 100644 index 0000000000..a4dfaffb8d --- /dev/null +++ b/.github/scripts/publish-device-releases.sh @@ -0,0 +1,168 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ ! -d out ]; then + echo "Expected build output directory 'out' to exist." + exit 1 +fi + +if ! command -v gh >/dev/null 2>&1; then + echo "GitHub CLI is required to publish device releases." + exit 1 +fi + +repo="${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" +version="${FIRMWARE_VERSION:-${GIT_TAG_VERSION:-dev}}" +commit_hash="$(git rev-parse --short HEAD)" +release_prefix="${RELEASE_PREFIX:-firmware}" +release_name_prefix="${RELEASE_NAME_PREFIX:-Firmware}" + +manifest_dir="$(mktemp -d)" +manifest_path="${manifest_dir}/firmware-manifest.json" +work_dir="$(mktemp -d)" + +cleanup() { + rm -rf "${manifest_dir}" "${work_dir}" +} +trap cleanup EXIT + +slugify() { + printf '%s' "$1" \ + | tr '[:upper:]' '[:lower:]' \ + | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//' +} + +asset_ext() { + local file="$1" + if [[ "$file" == *-merged.bin ]]; then + printf 'merged.bin' + return + fi + printf '%s' "${file##*.}" +} + +json_escape() { + local value="$1" + value="${value//\\/\\\\}" + value="${value//\"/\\\"}" + value="${value//$'\n'/\\n}" + value="${value//$'\r'/}" + printf '%s' "$value" +} + +mapfile -t outputs < <(find out -maxdepth 1 -type f | sort) +if [ "${#outputs[@]}" -eq 0 ]; then + echo "No firmware files found in out." + exit 1 +fi + +declare -A envs=() +version_suffix="-${version}-${commit_hash}" + +for file in "${outputs[@]}"; do + base="$(basename "$file")" + stem="${base%.*}" + if [[ "$base" == *-merged.bin ]]; then + stem="${base%-merged.bin}" + fi + env_name="${stem%${version_suffix}}" + if [ "$env_name" = "$stem" ]; then + echo "Skipping '${base}', it does not match expected version suffix '${version_suffix}'." + continue + fi + envs["$env_name"]=1 +done + +printf '{\n "version": "%s",\n "commit": "%s",\n "generated_at": "%s",\n "firmwares": [\n' \ + "$(json_escape "$version")" \ + "$(json_escape "$commit_hash")" \ + "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" > "$manifest_path" + +first_env=1 +for env_name in $(printf '%s\n' "${!envs[@]}" | sort); do + tag="latest-$(slugify "${release_prefix}-${env_name}")" + release_name="${release_name_prefix}: ${env_name}" + target_dir="${work_dir}/${env_name}" + mkdir -p "$target_dir" + + mapfile -t env_files < <(find out -maxdepth 1 -type f -name "${env_name}-${version}-${commit_hash}*" | sort) + if [ "${#env_files[@]}" -eq 0 ]; then + continue + fi + + upload_args=() + asset_json="" + first_asset=1 + for file in "${env_files[@]}"; do + ext="$(asset_ext "$(basename "$file")")" + asset_name="${env_name}.${ext}" + stable_file="${target_dir}/${asset_name}" + cp "$file" "$stable_file" + upload_args+=("${stable_file}") + + download_url="https://github.com/${repo}/releases/download/${tag}/${asset_name}" + if [ "$first_asset" -eq 0 ]; then + asset_json="${asset_json}," + fi + asset_json="${asset_json} + { + \"name\": \"$(json_escape "$asset_name")\", + \"type\": \"$(json_escape "$ext")\", + \"download_url\": \"$(json_escape "$download_url")\" + }" + first_asset=0 + done + + body_file="${target_dir}/release-body.md" + cat > "$body_file" </dev/null 2>&1; then + gh release edit "$tag" --repo "$repo" --title "$release_name" --notes-file "$body_file" --latest=false + else + gh release create "$tag" --repo "$repo" --title "$release_name" --notes-file "$body_file" --target "$GITHUB_SHA" --latest=false + fi + + gh release upload "$tag" "${upload_args[@]}" --repo "$repo" --clobber + + if [ "$first_env" -eq 0 ]; then + printf ',\n' >> "$manifest_path" + fi + printf ' {\n "id": "%s",\n "tag": "%s",\n "release_url": "%s",\n "assets": [%s\n ]\n }' \ + "$(json_escape "$env_name")" \ + "$(json_escape "$tag")" \ + "$(json_escape "https://github.com/${repo}/releases/tag/${tag}")" \ + "$asset_json" >> "$manifest_path" + first_env=0 +done + +printf '\n ]\n}\n' >> "$manifest_path" + +manifest_tag="latest-$(slugify "${release_prefix}-manifest")" +manifest_body="${manifest_dir}/manifest-body.md" +cat > "$manifest_body" </dev/null 2>&1; then + gh release edit "$manifest_tag" --repo "$repo" --title "${release_name_prefix} manifest" --notes-file "$manifest_body" --latest=false +else + gh release create "$manifest_tag" --repo "$repo" --title "${release_name_prefix} manifest" --notes-file "$manifest_body" --target "$GITHUB_SHA" --latest=false +fi + +gh release upload "$manifest_tag" "$manifest_path" --repo "$repo" --clobber diff --git a/.github/workflows/build-mqtt-firmwares.yml b/.github/workflows/build-mqtt-firmwares.yml new file mode 100644 index 0000000000..6a1594449b --- /dev/null +++ b/.github/workflows/build-mqtt-firmwares.yml @@ -0,0 +1,52 @@ +name: Build MQTT Firmwares + +permissions: + contents: write + +on: + workflow_dispatch: + push: + tags: + - 'mqtt-*' + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Clone Repo + uses: actions/checkout@v4 + + - name: Setup Build Environment + uses: ./.github/actions/setup-build-environment + + - name: Build MQTT Firmwares + env: + FIRMWARE_VERSION: ${{ env.GIT_TAG_VERSION }} + run: /usr/bin/env bash build.sh build-mqtt-firmwares + + - name: Upload Workflow Artifacts + uses: actions/upload-artifact@v4 + with: + name: mqtt-firmwares + path: out + + - name: Create Release + if: startsWith(github.ref, 'refs/tags/') + uses: softprops/action-gh-release@v2 + with: + name: MQTT Firmware ${{ env.GIT_TAG_VERSION }} + body: "" + draft: true + files: out/* + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Publish Device Releases + if: startsWith(github.ref, 'refs/tags/') + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + FIRMWARE_VERSION: ${{ env.GIT_TAG_VERSION }} + RELEASE_PREFIX: mqtt + RELEASE_NAME_PREFIX: MQTT Firmware + run: /usr/bin/env bash .github/scripts/publish-device-releases.sh diff --git a/.github/workflows/build-repeater-firmwares-mqtt.yml b/.github/workflows/build-repeater-firmwares-mqtt.yml new file mode 100644 index 0000000000..f9491786cd --- /dev/null +++ b/.github/workflows/build-repeater-firmwares-mqtt.yml @@ -0,0 +1,52 @@ +name: Build Repeater Firmwares MQTT + +permissions: + contents: write + +on: + workflow_dispatch: + push: + tags: + - 'repeater-*' + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Clone Repo + uses: actions/checkout@v4 + + - name: Setup Build Environment + uses: ./.github/actions/setup-build-environment + + - name: Build Repeater Firmwares MQTT + env: + FIRMWARE_VERSION: ${{ env.GIT_TAG_VERSION }} + run: /usr/bin/env bash build.sh build-repeater-firmwares-mqtt + + - name: Upload Workflow Artifacts + uses: actions/upload-artifact@v4 + with: + name: repeater-firmwares-mqtt + path: out + + - name: Create Release + if: startsWith(github.ref, 'refs/tags/') + uses: softprops/action-gh-release@v2 + with: + name: Repeater Firmware MQTT ${{ env.FIRMWARE_VERSION }} + body: "" + draft: true + files: out/* + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Publish Device Releases + if: startsWith(github.ref, 'refs/tags/') + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + FIRMWARE_VERSION: ${{ env.GIT_TAG_VERSION }} + RELEASE_PREFIX: repeater-mqtt + RELEASE_NAME_PREFIX: Repeater MQTT Firmware + run: /usr/bin/env bash .github/scripts/publish-device-releases.sh diff --git a/.github/workflows/build-room-server-firmwares-mqtt.yml b/.github/workflows/build-room-server-firmwares-mqtt.yml new file mode 100644 index 0000000000..690ecf0554 --- /dev/null +++ b/.github/workflows/build-room-server-firmwares-mqtt.yml @@ -0,0 +1,52 @@ +name: Build Room Server Firmwares MQTT + +permissions: + contents: write + +on: + workflow_dispatch: + push: + tags: + - 'room-server-*' + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Clone Repo + uses: actions/checkout@v4 + + - name: Setup Build Environment + uses: ./.github/actions/setup-build-environment + + - name: Build MQTT Firmwares + env: + FIRMWARE_VERSION: ${{ env.GIT_TAG_VERSION }} + run: /usr/bin/env bash build.sh build-room-server-firmwares-mqtt + + - name: Upload Workflow Artifacts + uses: actions/upload-artifact@v4 + with: + name: room-server-firmwares-mqtt + path: out + + - name: Create Release + if: startsWith(github.ref, 'refs/tags/') + uses: softprops/action-gh-release@v2 + with: + name: Room Server Firmware MQTT ${{ env.FIRMWARE_VERSION }} + body: "" + draft: true + files: out/* + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Publish Device Releases + if: startsWith(github.ref, 'refs/tags/') + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + FIRMWARE_VERSION: ${{ env.GIT_TAG_VERSION }} + RELEASE_PREFIX: room-server-mqtt + RELEASE_NAME_PREFIX: Room Server MQTT Firmware + run: /usr/bin/env bash .github/scripts/publish-device-releases.sh diff --git a/.github/workflows/pr-build-check.yml b/.github/workflows/pr-build-check.yml index 67468b5691..e0a6d4dc40 100644 --- a/.github/workflows/pr-build-check.yml +++ b/.github/workflows/pr-build-check.yml @@ -25,24 +25,26 @@ jobs: fail-fast: false matrix: environment: - # ESP32-S3 (most common platform) - - Heltec_v3_companion_radio_ble - - Heltec_v3_repeater - - Heltec_v3_room_server - # nRF52 - - RAK_4631_companion_radio_ble - - RAK_4631_repeater - - RAK_4631_room_server - # RP2040 - - PicoW_repeater - # STM32 - - wio-e5-mini_repeater + # ESP32-S3 (Heltec v3) + - Heltec_v3_repeater_observer_mqtt + - Heltec_v3_room_server_observer_mqtt + # ESP32-S3 (Heltec v4) + - heltec_v4_repeater_observer_mqtt + - heltec_v4_room_server_observer_mqtt + # ESP32-S3 (LilyGo T3S3) + - LilyGo_T3S3_sx1262_repeater_observer_mqtt + - LilyGo_T3S3_sx1262_room_server_observer_mqtt + # ESP32-S3 (T-Beam Supreme) + - T_Beam_S3_Supreme_SX1262_repeater_observer_mqtt + # ESP32 (T-Beam SX1262) + - Tbeam_SX1262_repeater_observer_mqtt + - Tbeam_SX1262_room_server_observer_mqtt + # ESP32 (T-Beam SX1276) + - Tbeam_SX1276_repeater_observer_mqtt + - Tbeam_SX1276_room_server_observer_mqtt # ESP32-C6 - - LilyGo_Tlora_C6_repeater_ - # LR1110 (nRF52) - - wio_wm1110_repeater - # SX1276 (ESP32) - - Tbeam_SX1276_repeater + - LilyGo_Tlora_C6_repeater_observer_mqtt + - LilyGo_Tlora_C6_room_server_observer_mqtt steps: - name: Clone Repo diff --git a/.gitignore b/.gitignore index a0ad5f6ea9..1bb0c050da 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ compile_commands.json .venv/ venv/ platformio.local.ini +.claude/settings.local.json diff --git a/build.sh b/build.sh index 313c4c47a0..baafafdaaa 100755 --- a/build.sh +++ b/build.sh @@ -14,6 +14,9 @@ Commands: build-companion-firmwares: Build all companion firmwares for all build targets. build-repeater-firmwares: Build all repeater firmwares for all build targets. build-room-server-firmwares: Build all chat room server firmwares for all build targets. + build-mqtt-firmwares: Build all MQTT-enabled firmwares. + build-repeater-firmwares-mqtt: Build all MQTT-enabled repeater firmwares. + build-room-server-firmwares-mqtt: Build all MQTT-enabled chat room server firmwares. Examples: Build firmware for the "RAK_4631_repeater" device target @@ -239,6 +242,18 @@ build_room_server_firmwares() { } +build_repeater_firmwares_mqtt() { + build_all_firmwares_by_suffix "_repeater_observer_mqtt" +} + +build_room_server_firmwares_mqtt() { + build_all_firmwares_by_suffix "_room_server_observer_mqtt" +} + +build_mqtt_firmwares() { + build_all_firmwares_by_suffix "_mqtt" +} + build_firmwares() { build_companion_firmwares build_repeater_firmwares @@ -275,4 +290,10 @@ elif [[ $1 == "build-repeater-firmwares" ]]; then build_repeater_firmwares elif [[ $1 == "build-room-server-firmwares" ]]; then build_room_server_firmwares +elif [[ $1 == "build-repeater-firmwares-mqtt" ]]; then + build_repeater_firmwares_mqtt +elif [[ $1 == "build-room-server-firmwares-mqtt" ]]; then + build_room_server_firmwares_mqtt +elif [[ $1 == "build-mqtt-firmwares" ]]; then + build_mqtt_firmwares fi diff --git a/docs/mqtt-setup/index.html b/docs/mqtt-setup/index.html new file mode 100644 index 0000000000..a18953054a --- /dev/null +++ b/docs/mqtt-setup/index.html @@ -0,0 +1,678 @@ + + + + + + MeshCore MQTT Setup + + + + + + +
+ +
+

📡 MeshCore MQTT Setup

+

Configure your MQTT slots and get ready-to-paste CLI commands for your node.

+
+ +
+ How to use this tool +
+
+

Steps

+

+ 1. Enter your IATA airport code (e.g. AMS for Amsterdam) — this identifies your location on the mesh map.
+ 2. Pick a preset for each slot you want to enable. Slots left on none are disabled.
+ 3. For MeshRank slots, paste your token from meshrank.net.
+ 4. Copy the generated commands and paste them into the MeshCore CLI (Remote Terminal or serial console). +

+
+
+

Default slots on DutchMeshCore firmware

+

+ The DutchMeshCore build pre-assigns slot 1 → dutchmeshcore-1 and slot 2 → dutchmeshcore-2 by default. + You only need to run set mqtt.iata <code> to start publishing — unless you want to change the slot assignments. +

+
+ +
+
+ + +

Global settings

+
+
+
+ + + 3-letter code for your nearest airport. Required for most presets. +
+
+
+ + +

MQTT Slots (1–6)

+
+ + +
+
+

Generated commands

+ +
+
+

Select a preset above to generate commands.

+
+
+ +
+ + + +
+ + + + diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index e8c1914bad..9c58c9d600 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -859,7 +859,7 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe // defaults memset(&_prefs, 0, sizeof(_prefs)); - _prefs.airtime_factor = 1.0; + _prefs.airtime_factor = 1.0; // one half strcpy(_prefs.node_name, "NONAME"); _prefs.freq = LORA_FREQ; _prefs.sf = LORA_SF; @@ -1827,7 +1827,7 @@ void MyMesh::handleCmdFrame(size_t len) { out_frame[i++] = STATS_TYPE_CORE; uint16_t battery_mv = board.getBattMilliVolts(); uint32_t uptime_secs = _ms->getMillis() / 1000; - uint8_t queue_len = (uint8_t)_mgr->getOutboundTotal(); + uint8_t queue_len = (uint8_t)_mgr->getOutboundCount(0xFFFFFFFF); memcpy(&out_frame[i], &battery_mv, 2); i += 2; memcpy(&out_frame[i], &uptime_secs, 4); i += 4; memcpy(&out_frame[i], &_err_flags, 2); i += 2; diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 666f79fc5c..9e21045505 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -1,5 +1,6 @@ #include "MyMesh.h" #include +#include // for qsort() /* ------------------------------ Config -------------------------------- */ @@ -144,6 +145,39 @@ uint8_t MyMesh::handleLoginReq(const mesh::Identity& sender, const uint8_t* secr return 13; // reply length } +// Comparison functions for qsort() - defined at file scope to avoid heap allocations +static int cmp_neighbours_newest_to_oldest(const void* a, const void* b) { + const NeighbourInfo* na = *(const NeighbourInfo**)a; + const NeighbourInfo* nb = *(const NeighbourInfo**)b; + if (nb->heard_timestamp > na->heard_timestamp) return 1; + if (nb->heard_timestamp < na->heard_timestamp) return -1; + return 0; +} + +static int cmp_neighbours_oldest_to_newest(const void* a, const void* b) { + const NeighbourInfo* na = *(const NeighbourInfo**)a; + const NeighbourInfo* nb = *(const NeighbourInfo**)b; + if (na->heard_timestamp > nb->heard_timestamp) return 1; + if (na->heard_timestamp < nb->heard_timestamp) return -1; + return 0; +} + +static int cmp_neighbours_strongest_to_weakest(const void* a, const void* b) { + const NeighbourInfo* na = *(const NeighbourInfo**)a; + const NeighbourInfo* nb = *(const NeighbourInfo**)b; + if (nb->snr > na->snr) return 1; + if (nb->snr < na->snr) return -1; + return 0; +} + +static int cmp_neighbours_weakest_to_strongest(const void* a, const void* b) { + const NeighbourInfo* na = *(const NeighbourInfo**)a; + const NeighbourInfo* nb = *(const NeighbourInfo**)b; + if (na->snr > nb->snr) return 1; + if (na->snr < nb->snr) return -1; + return 0; +} + uint8_t MyMesh::handleAnonRegionsReq(const mesh::Identity& sender, uint32_t sender_timestamp, const uint8_t* data) { if (anon_limiter.allow(rtc_clock.getCurrentTime())) { // request data has: {reply-path-len}{reply-path} @@ -219,7 +253,7 @@ int MyMesh::handleRequest(ClientInfo *sender, uint32_t sender_timestamp, uint8_t if (payload[0] == REQ_TYPE_GET_STATUS) { // guests can also access this now RepeaterStats stats; stats.batt_milli_volts = board.getBattMilliVolts(); - stats.curr_tx_queue_len = _mgr->getOutboundTotal(); + stats.curr_tx_queue_len = _mgr->getOutboundCount(0xFFFFFFFF); stats.noise_floor = (int16_t)_radio->getNoiseFloor(); stats.last_rssi = (int16_t)radio_driver.getLastRSSI(); stats.n_packets_recv = radio_driver.getPacketsRecv(); @@ -299,45 +333,50 @@ int MyMesh::handleRequest(ClientInfo *sender, uint32_t sender_timestamp, uint8_t MESH_DEBUG_PRINTLN("REQ_TYPE_GET_NEIGHBOURS invalid pubkey_prefix_length=%d clamping to %d", pubkey_prefix_length, PUB_KEY_SIZE); } - // create copy of neighbours list, skipping empty entries so we can sort it separately from main list + // Early exit if no neighbours to avoid unnecessary processing int16_t neighbours_count = 0; #if MAX_NEIGHBOURS NeighbourInfo* sorted_neighbours[MAX_NEIGHBOURS]; +#endif + for (int i = 0; i < MAX_NEIGHBOURS; i++) { + if (neighbours[i].heard_timestamp > 0) { + neighbours_count++; + } + } + + if (neighbours_count == 0) { + // No neighbours - return minimal response + memcpy(&reply_data[reply_offset], &neighbours_count, 2); reply_offset += 2; + uint16_t zero = 0; + memcpy(&reply_data[reply_offset], &zero, 2); reply_offset += 2; // results_count = 0 + return reply_offset; + } + + // create copy of neighbours list, skipping empty entries so we can sort it separately from main list + int16_t sorted_idx = 0; for (int i = 0; i < MAX_NEIGHBOURS; i++) { auto neighbour = &neighbours[i]; if (neighbour->heard_timestamp > 0) { - sorted_neighbours[neighbours_count] = neighbour; - neighbours_count++; + sorted_neighbours[sorted_idx++] = neighbour; } } - // sort neighbours based on order + // Sort neighbours based on order using qsort() - standard C library function + // qsort() doesn't allocate heap memory (uses stack-based recursion) and is O(n log n) + // This matches the pattern used elsewhere in the codebase (e.g., BaseChatMesh) if (order_by == 0) { // sort by newest to oldest - MESH_DEBUG_PRINTLN("REQ_TYPE_GET_NEIGHBOURS sorting newest to oldest"); - std::sort(sorted_neighbours, sorted_neighbours + neighbours_count, [](const NeighbourInfo* a, const NeighbourInfo* b) { - return a->heard_timestamp > b->heard_timestamp; // desc - }); + qsort(sorted_neighbours, neighbours_count, sizeof(NeighbourInfo*), cmp_neighbours_newest_to_oldest); } else if (order_by == 1) { // sort by oldest to newest - MESH_DEBUG_PRINTLN("REQ_TYPE_GET_NEIGHBOURS sorting oldest to newest"); - std::sort(sorted_neighbours, sorted_neighbours + neighbours_count, [](const NeighbourInfo* a, const NeighbourInfo* b) { - return a->heard_timestamp < b->heard_timestamp; // asc - }); + qsort(sorted_neighbours, neighbours_count, sizeof(NeighbourInfo*), cmp_neighbours_oldest_to_newest); } else if (order_by == 2) { // sort by strongest to weakest - MESH_DEBUG_PRINTLN("REQ_TYPE_GET_NEIGHBOURS sorting strongest to weakest"); - std::sort(sorted_neighbours, sorted_neighbours + neighbours_count, [](const NeighbourInfo* a, const NeighbourInfo* b) { - return a->snr > b->snr; // desc - }); + qsort(sorted_neighbours, neighbours_count, sizeof(NeighbourInfo*), cmp_neighbours_strongest_to_weakest); } else if (order_by == 3) { // sort by weakest to strongest - MESH_DEBUG_PRINTLN("REQ_TYPE_GET_NEIGHBOURS sorting weakest to strongest"); - std::sort(sorted_neighbours, sorted_neighbours + neighbours_count, [](const NeighbourInfo* a, const NeighbourInfo* b) { - return a->snr < b->snr; // asc - }); + qsort(sorted_neighbours, neighbours_count, sizeof(NeighbourInfo*), cmp_neighbours_weakest_to_strongest); } -#endif // build results buffer int results_count = 0; @@ -461,17 +500,30 @@ const char *MyMesh::getLogDateTime() { void MyMesh::logRxRaw(float snr, float rssi, const uint8_t raw[], int len) { #if MESH_PACKET_LOGGING - Serial.print(getLogDateTime()); - Serial.print(" RAW: "); - mesh::Utils::printHex(Serial, raw, len); - Serial.println(); + if (Serial.availableForWrite() > 0) { + Serial.print(getLogDateTime()); + Serial.print(" RAW: "); + mesh::Utils::printHex(Serial, raw, len); + Serial.println(); + } +#endif + +#ifdef WITH_BRIDGE + if (_prefs.bridge_enabled) { + // Store raw radio data for MQTT messages + if (bridge) bridge->storeRawRadioData(raw, len, snr, rssi); + } #endif } void MyMesh::logRx(mesh::Packet *pkt, int len, float score) { -#ifdef WITH_BRIDGE +#ifdef WITH_MQTT_BRIDGE + // MQTT bridge: always feed RX packets — bridge decides based on mqtt.rx setting + if (bridge) bridge->onPacketReceived(pkt); +#elif defined(WITH_BRIDGE) + // Non-MQTT bridge (ESP-NOW): use bridge.source setting if (_prefs.bridge_pkt_src == 1) { - bridge.sendPacket(pkt); + if (bridge) bridge->onPacketReceived(pkt); } #endif @@ -495,9 +547,13 @@ void MyMesh::logRx(mesh::Packet *pkt, int len, float score) { } void MyMesh::logTx(mesh::Packet *pkt, int len) { -#ifdef WITH_BRIDGE +#ifdef WITH_MQTT_BRIDGE + // MQTT bridge: always feed TX packets — bridge decides based on mqtt.tx setting + if (bridge) bridge->sendPacket(pkt); +#elif defined(WITH_BRIDGE) + // Non-MQTT bridge (ESP-NOW): use bridge.source setting if (_prefs.bridge_pkt_src == 0) { - bridge.sendPacket(pkt); + if (bridge) bridge->sendPacket(pkt); } #endif @@ -771,9 +827,7 @@ bool MyMesh::onPeerPathRecv(mesh::Packet *packet, int sender_idx, const uint8_t void MyMesh::onControlDataRecv(mesh::Packet* packet) { uint8_t type = packet->payload[0] & 0xF0; // just test upper 4 bits - if (type == CTL_TYPE_NODE_DISCOVER_REQ && packet->payload_len >= 6 - && !_prefs.disable_fwd && discover_limiter.allow(rtc_clock.getCurrentTime()) - ) { + if (type == CTL_TYPE_NODE_DISCOVER_REQ && packet->payload_len >= 6 && discover_limiter.allow(rtc_clock.getCurrentTime())) { int i = 1; uint8_t filter = packet->payload[i++]; uint32_t tag; @@ -851,9 +905,10 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc anon_limiter(4, 180) // max 4 every 3 minutes #if defined(WITH_RS232_BRIDGE) , bridge(&_prefs, WITH_RS232_BRIDGE, _mgr, &rtc) -#endif -#if defined(WITH_ESPNOW_BRIDGE) +#elif defined(WITH_ESPNOW_BRIDGE) , bridge(&_prefs, _mgr, &rtc) +#elif defined(WITH_MQTT_BRIDGE) + , bridge(nullptr) #endif { last_millis = 0; @@ -870,7 +925,7 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc // defaults memset(&_prefs, 0, sizeof(_prefs)); - _prefs.airtime_factor = 1.0; + _prefs.airtime_factor = 1.0; // one half _prefs.rx_delay_base = 0.0f; // turn off by default, was 10.0; _prefs.tx_delay_factor = 0.5f; // was 0.25f _prefs.direct_tx_delay_factor = 0.3f; // was 0.2 @@ -887,21 +942,52 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc _prefs.flood_advert_interval = 12; // 12 hours _prefs.flood_max = 64; _prefs.interference_threshold = 0; // disabled +#ifdef WITH_MQTT_BRIDGE + _prefs.agc_reset_interval = 7; // 28 seconds (secs/4) — prevents AGC drift on long-running observers +#endif + _prefs.radio_watchdog_minutes = 5; // 5 minutes default // bridge defaults _prefs.bridge_enabled = 1; // enabled _prefs.bridge_delay = 500; // milliseconds - _prefs.bridge_pkt_src = 0; // logTx + _prefs.bridge_pkt_src = 1; // logRx (RX packets) _prefs.bridge_baud = 115200; // baud rate _prefs.bridge_channel = 1; // channel 1 StrHelper::strncpy(_prefs.bridge_secret, "LVSITANOS", sizeof(_prefs.bridge_secret)); + // SNMP defaults + _prefs.snmp_enabled = 0; + StrHelper::strncpy(_prefs.snmp_community, "public", sizeof(_prefs.snmp_community)); + // GPS defaults _prefs.gps_enabled = 0; _prefs.gps_interval = 0; _prefs.advert_loc_policy = ADVERT_LOC_PREFS; + // MQTT defaults + StrHelper::strncpy(_prefs.mqtt_origin, "MeshCore-Repeater", sizeof(_prefs.mqtt_origin)); + StrHelper::strncpy(_prefs.mqtt_iata, "SEA", sizeof(_prefs.mqtt_iata)); + _prefs.mqtt_status_enabled = 1; // enabled + _prefs.mqtt_packets_enabled = 1; // enabled + _prefs.mqtt_raw_enabled = 0; // disabled + _prefs.mqtt_tx_enabled = 2; // advert: own adverts only (matches MQTTPrefs default) + _prefs.mqtt_rx_enabled = 1; // RX packets enabled by default + _prefs.mqtt_status_interval = 300000; // 5 minutes + + // WiFi defaults + StrHelper::strncpy(_prefs.wifi_ssid, "ssid_here", sizeof(_prefs.wifi_ssid)); + StrHelper::strncpy(_prefs.wifi_password, "password_here", sizeof(_prefs.wifi_password)); + + // Timezone defaults (Pacific Time with DST support) + StrHelper::strncpy(_prefs.timezone_string, "America/Los_Angeles", sizeof(_prefs.timezone_string)); + _prefs.timezone_offset = -8; // fallback + + // MQTT slot presets (dutchmeshcore collectors enabled by default) + StrHelper::strncpy(_prefs.mqtt_slot_preset[0], "dutchmeshcore-1", sizeof(_prefs.mqtt_slot_preset[0])); + StrHelper::strncpy(_prefs.mqtt_slot_preset[1], "dutchmeshcore-2", sizeof(_prefs.mqtt_slot_preset[1])); + StrHelper::strncpy(_prefs.mqtt_slot_preset[2], "none", sizeof(_prefs.mqtt_slot_preset[2])); + _prefs.adc_multiplier = 0.0f; // 0.0f means use default board multiplier #if defined(USE_SX1262) || defined(USE_SX1268) @@ -923,6 +1009,11 @@ void MyMesh::begin(FILESYSTEM *fs) { _fs = fs; // load persisted prefs _cli.loadPrefs(_fs); + + // Set MQTT origin to actual device name (not build-time ADVERT_NAME) + StrHelper::strncpy(_prefs.mqtt_origin, _prefs.node_name, sizeof(_prefs.mqtt_origin)); + MESH_DEBUG_PRINTLN("MQTT origin set to device name: %s", _prefs.mqtt_origin); + acl.load(_fs, self_id); // TODO: key_store.begin(); region_map.load(_fs); @@ -949,7 +1040,41 @@ void MyMesh::begin(FILESYSTEM *fs) { #if defined(WITH_BRIDGE) if (_prefs.bridge_enabled) { - bridge.begin(); +#ifdef WITH_MQTT_BRIDGE + // Defer construction to avoid static init crashes on ESP32 classic + bridge = new MQTTBridge(&_prefs, _mgr, getRTCClock(), &self_id); +#endif + if (bridge) { + // Set device public key for MQTT topics + char device_id[65]; + mesh::LocalIdentity self_id = getSelfId(); + mesh::Utils::toHex(device_id, self_id.pub_key, PUB_KEY_SIZE); + MESH_DEBUG_PRINTLN("Setting device ID: %s", device_id); + bridge->setDeviceID(device_id); + + // Set firmware version + bridge->setFirmwareVersion(getFirmwareVer()); + + // Set board model + bridge->setBoardModel(_cli.getBoard()->getManufacturerName()); + + // Set build date + bridge->setBuildDate(getBuildDate()); + +#ifdef WITH_MQTT_BRIDGE + // Set stats sources for automatic stats collection + bridge->setStatsSources(this, _radio, _cli.getBoard(), _ms); +#ifdef WITH_SNMP + if (_prefs.snmp_enabled) { + _snmp_agent.setNodeName(_prefs.node_name); + _snmp_agent.setFirmwareVersion(getFirmwareVer()); + bridge->setSNMPAgent(&_snmp_agent); + } +#endif +#endif + + bridge->begin(); + } } #endif @@ -963,8 +1088,6 @@ void MyMesh::begin(FILESYSTEM *fs) { updateAdvertTimer(); updateFloodAdvertTimer(); - board.setAdcMultiplier(_prefs.adc_multiplier); - #if ENV_INCLUDE_GPS == 1 applyGpsPrefs(); #endif @@ -1019,7 +1142,7 @@ void MyMesh::sendSelfAdvertisement(int delay_millis, bool flood) { void MyMesh::updateAdvertTimer() { if (_prefs.advert_interval > 0) { // schedule local advert timer - next_local_advert = futureMillis(((uint32_t)_prefs.advert_interval) * 2 * 60 * 1000); + next_local_advert = futureMillis((int)((uint32_t)_prefs.advert_interval * 2 * 60 * 1000)); } else { next_local_advert = 0; // stop the timer } @@ -1141,6 +1264,10 @@ void MyMesh::formatRadioStatsReply(char *reply) { StatsFormatHelper::formatRadioStats(reply, _radio, radio_driver, getTotalAirTime(), getReceiveAirTime()); } +void MyMesh::formatRadioDiagReply(char *reply) { + StatsFormatHelper::formatRadioDiag(reply, _radio, radio_driver, *_ms, _err_flags, hasOutbound()); +} + void MyMesh::formatPacketStatsReply(char *reply) { StatsFormatHelper::formatPacketStats(reply, radio_driver, getNumSentFlood(), getNumSentDirect(), getNumRecvFlood(), getNumRecvDirect()); @@ -1257,12 +1384,14 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply } void MyMesh::loop() { + // Check radio FIRST to ensure we don't miss incoming packets + // MQTT processing runs in a separate FreeRTOS task on Core 0, so we don't call bridge.loop() here + mesh::Mesh::loop(); + #ifdef WITH_BRIDGE - bridge.loop(); + // bridge.loop() is now handled by FreeRTOS task on Core 0 - no need to call it here #endif - mesh::Mesh::loop(); - if (next_flood_advert && millisHasNowPassed(next_flood_advert)) { mesh::Packet *pkt = createSelfAdvert(); uint32_t delay_millis = 0; @@ -1299,12 +1428,31 @@ void MyMesh::loop() { uint32_t now = millis(); uptime_millis += now - last_millis; last_millis = now; + +#ifdef WITH_SNMP + // Push radio stats to SNMP agent every 2 seconds + if (_snmp_agent.isRunning()) { + static unsigned long last_snmp_stats = 0; + if (now - last_snmp_stats >= 2000) { + last_snmp_stats = now; + _snmp_agent.updateRadioStats( + radio_driver.getPacketsRecv(), radio_driver.getPacketsSent(), + radio_driver.getPacketsRecvErrors(), + (int16_t)_radio->getNoiseFloor(), + (int16_t)radio_driver.getLastRSSI(), + (int16_t)(radio_driver.getLastSNR() * 4), + getNumSentFlood(), getNumSentDirect(), + getNumRecvFlood(), getNumRecvDirect(), + getTotalAirTime() / 1000, uptime_millis / 1000); + } + } +#endif } // To check if there is pending work bool MyMesh::hasPendingWork() const { #if defined(WITH_BRIDGE) - if (bridge.isRunning()) return true; // bridge needs WiFi radio, can't sleep + if (bridge && bridge->isRunning()) return true; // bridge needs WiFi radio, can't sleep #endif - return _mgr->getOutboundTotal() > 0; + return _mgr->getOutboundCount(0xFFFFFFFF) > 0; } diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 8ed0317e69..4d3e6d4e36 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -3,6 +3,7 @@ #include #include #include +#include #include #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) @@ -23,6 +24,15 @@ #define WITH_BRIDGE #endif +#ifdef WITH_MQTT_BRIDGE +#include "helpers/bridges/MQTTBridge.h" +#define WITH_BRIDGE +#endif + +#ifdef WITH_SNMP +#include "helpers/SNMPAgent.h" +#endif + #include #include #include @@ -35,9 +45,6 @@ #include #include "RateLimiter.h" -#ifdef WITH_BRIDGE -extern AbstractBridge* bridge; -#endif struct RepeaterStats { uint16_t batt_milli_volts; @@ -117,6 +124,11 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { RS232Bridge bridge; #elif defined(WITH_ESPNOW_BRIDGE) ESPNowBridge bridge; +#elif defined(WITH_MQTT_BRIDGE) + MQTTBridge* bridge; +#endif +#ifdef WITH_SNMP + MeshSNMPAgent _snmp_agent; #endif void putNeighbour(const mesh::Identity& id, uint32_t timestamp, float snr); @@ -153,6 +165,9 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { int getAGCResetInterval() const override { return ((int)_prefs.agc_reset_interval) * 4000; // milliseconds } + uint32_t getRadioWatchdogMillis() const override { + return ((uint32_t)_prefs.radio_watchdog_minutes) * 60000UL; + } uint8_t getExtraAckTransmitCount() const override { return _prefs.multi_acks; } @@ -213,6 +228,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { void removeNeighbor(const uint8_t* pubkey, int key_len) override; void formatStatsReply(char *reply) override; void formatRadioStatsReply(char *reply) override; + void formatRadioDiagReply(char *reply) override; void formatPacketStatsReply(char *reply) override; void startRegionsLoad() override; bool saveRegions() override; @@ -228,21 +244,58 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { #if defined(WITH_BRIDGE) void setBridgeState(bool enable) override { - if (enable == bridge.isRunning()) return; + if (!bridge) { +#ifdef WITH_MQTT_BRIDGE + bridge = new MQTTBridge(&_prefs, _mgr, getRTCClock(), &self_id); +#endif + if (!bridge) return; + } + if (enable == bridge->isRunning()) return; if (enable) { - bridge.begin(); + // Set device metadata before starting bridge (same as in begin()) + char device_id[65]; + mesh::LocalIdentity self_id = getSelfId(); + mesh::Utils::toHex(device_id, self_id.pub_key, PUB_KEY_SIZE); + bridge->setDeviceID(device_id); + bridge->setFirmwareVersion(getFirmwareVer()); + bridge->setBoardModel(_cli.getBoard()->getManufacturerName()); + bridge->setBuildDate(getBuildDate()); +#ifdef WITH_MQTT_BRIDGE + bridge->setStatsSources(this, _radio, _cli.getBoard(), _ms); +#endif + bridge->begin(); } - else + else { - bridge.end(); + bridge->end(); } } void restartBridge() override { - if (!bridge.isRunning()) return; - bridge.end(); - bridge.begin(); + if (!bridge || !bridge->isRunning()) return; + bridge->end(); + // Set device metadata before restarting bridge (same as in begin()) + char device_id[65]; + mesh::LocalIdentity self_id = getSelfId(); + mesh::Utils::toHex(device_id, self_id.pub_key, PUB_KEY_SIZE); + bridge->setDeviceID(device_id); + bridge->setFirmwareVersion(getFirmwareVer()); + bridge->setBoardModel(_cli.getBoard()->getManufacturerName()); + bridge->setBuildDate(getBuildDate()); +#ifdef WITH_MQTT_BRIDGE + bridge->setStatsSources(this, _radio, _cli.getBoard(), _ms); +#endif + bridge->begin(); + } + + void restartBridgeSlot(int slot) override { + if (!bridge || !bridge->isRunning()) return; + bridge->setSlotPreset(slot, _prefs.mqtt_slot_preset[slot]); + } + + int getQueueSize() override { + return bridge ? bridge->getQueueSize() : 0; } #endif diff --git a/examples/simple_repeater/UITask.cpp b/examples/simple_repeater/UITask.cpp index acb4632581..3545c95d38 100644 --- a/examples/simple_repeater/UITask.cpp +++ b/examples/simple_repeater/UITask.cpp @@ -6,6 +6,10 @@ #define USER_BTN_PRESSED LOW #endif +#ifdef WITH_MQTT_BRIDGE +#include +#endif + #define AUTO_OFF_MILLIS 20000 // 20 seconds #define BOOT_SCREEN_MILLIS 4000 // 4 seconds @@ -81,6 +85,17 @@ void UITask::renderCurrScreen() { _display->setCursor(0, 30); sprintf(tmp, "BW: %03.2f CR: %d", _node_prefs->bw, _node_prefs->cr); _display->print(tmp); + +#ifdef WITH_MQTT_BRIDGE + // Display IP address for MQTT bridge devices + if (WiFi.status() == WL_CONNECTED) { + IPAddress ip = WiFi.localIP(); + _display->setCursor(0, 40); + _display->setColor(DisplayDriver::LIGHT); + snprintf(tmp, sizeof(tmp), "IP: %d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]); + _display->print(tmp); + } +#endif } } diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 145fb0fd9f..3497b8e3ef 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -140,7 +140,7 @@ int MyMesh::handleRequest(ClientInfo *sender, uint32_t sender_timestamp, uint8_t if (payload[0] == REQ_TYPE_GET_STATUS) { ServerStats stats; stats.batt_milli_volts = board.getBattMilliVolts(); - stats.curr_tx_queue_len = _mgr->getOutboundTotal(); + stats.curr_tx_queue_len = _mgr->getOutboundCount(0xFFFFFFFF); stats.noise_floor = (int16_t)_radio->getNoiseFloor(); stats.last_rssi = (int16_t)radio_driver.getLastRSSI(); stats.n_packets_recv = radio_driver.getPacketsRecv(); @@ -201,14 +201,28 @@ int MyMesh::handleRequest(ClientInfo *sender, uint32_t sender_timestamp, uint8_t void MyMesh::logRxRaw(float snr, float rssi, const uint8_t raw[], int len) { #if MESH_PACKET_LOGGING - Serial.print(getLogDateTime()); - Serial.print(" RAW: "); - mesh::Utils::printHex(Serial, raw, len); - Serial.println(); + if (Serial.availableForWrite() > 0) { + Serial.print(getLogDateTime()); + Serial.print(" RAW: "); + mesh::Utils::printHex(Serial, raw, len); + Serial.println(); + } +#endif + +#ifdef WITH_MQTT_BRIDGE + if (_prefs.bridge_enabled) { + // Store raw radio data for MQTT messages (same as repeater) + if (bridge) bridge->storeRawRadioData(raw, len, snr, rssi); + } #endif } void MyMesh::logRx(mesh::Packet *pkt, int len, float score) { +#ifdef WITH_MQTT_BRIDGE + // MQTT bridge: always feed RX packets — bridge decides based on mqtt.rx setting + if (_prefs.bridge_enabled && bridge) bridge->onPacketReceived(pkt); +#endif + if (_logging) { File f = openAppend(PACKET_LOG_FILE); if (f) { @@ -228,6 +242,11 @@ void MyMesh::logRx(mesh::Packet *pkt, int len, float score) { } } void MyMesh::logTx(mesh::Packet *pkt, int len) { +#ifdef WITH_MQTT_BRIDGE + // MQTT bridge: always feed TX packets — bridge decides based on mqtt.tx setting + if (_prefs.bridge_enabled && bridge) bridge->sendPacket(pkt); +#endif + if (_logging) { File f = openAppend(PACKET_LOG_FILE); if (f) { @@ -615,6 +634,9 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc region_map(key_store), temp_map(key_store), _cli(board, rtc, sensors, region_map, acl, &_prefs, this), telemetry(MAX_PACKET_PAYLOAD - 4) +#ifdef WITH_MQTT_BRIDGE + , bridge(nullptr) +#endif { last_millis = 0; uptime_millis = 0; @@ -626,7 +648,7 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc // defaults memset(&_prefs, 0, sizeof(_prefs)); - _prefs.airtime_factor = 1.0; + _prefs.airtime_factor = 1.0; // one half _prefs.rx_delay_base = 0.0f; // off by default, was 10.0 _prefs.tx_delay_factor = 0.5f; // was 0.25f; _prefs.direct_tx_delay_factor = 0.2f; // was zero @@ -653,6 +675,36 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc _prefs.gps_interval = 0; _prefs.advert_loc_policy = ADVERT_LOC_PREFS; + // bridge defaults (same as repeater) + _prefs.bridge_enabled = 1; // enabled + _prefs.bridge_delay = 500; // milliseconds + _prefs.bridge_pkt_src = 1; // logRx (RX packets) + _prefs.bridge_baud = 115200; // baud rate + _prefs.bridge_channel = 1; // channel 1 + + // MQTT defaults (same as repeater) + StrHelper::strncpy(_prefs.mqtt_origin, "MeshCore-RoomServer", sizeof(_prefs.mqtt_origin)); + StrHelper::strncpy(_prefs.mqtt_iata, "SEA", sizeof(_prefs.mqtt_iata)); + _prefs.mqtt_status_enabled = 1; // enabled + _prefs.mqtt_packets_enabled = 1; // enabled + _prefs.mqtt_raw_enabled = 0; // disabled + _prefs.mqtt_tx_enabled = 2; // advert: own adverts only (matches MQTTPrefs default) + _prefs.mqtt_rx_enabled = 1; // RX packets enabled by default + _prefs.mqtt_status_interval = 300000; // 5 minutes + + // WiFi defaults (same as repeater) + StrHelper::strncpy(_prefs.wifi_ssid, "ssid_here", sizeof(_prefs.wifi_ssid)); + StrHelper::strncpy(_prefs.wifi_password, "password_here", sizeof(_prefs.wifi_password)); + + // Timezone defaults (same as repeater - Pacific Time with DST support) + StrHelper::strncpy(_prefs.timezone_string, "America/Los_Angeles", sizeof(_prefs.timezone_string)); + _prefs.timezone_offset = -8; // fallback + + // MQTT slot presets (analyzer-us and analyzer-eu enabled by default) + StrHelper::strncpy(_prefs.mqtt_slot_preset[0], "analyzer-us", sizeof(_prefs.mqtt_slot_preset[0])); + StrHelper::strncpy(_prefs.mqtt_slot_preset[1], "analyzer-eu", sizeof(_prefs.mqtt_slot_preset[1])); + StrHelper::strncpy(_prefs.mqtt_slot_preset[2], "none", sizeof(_prefs.mqtt_slot_preset[2])); + next_post_idx = 0; next_client_idx = 0; next_push = 0; @@ -702,6 +754,38 @@ void MyMesh::begin(FILESYSTEM *fs) { #if ENV_INCLUDE_GPS == 1 applyGpsPrefs(); #endif +#ifdef WITH_MQTT_BRIDGE + // Set MQTT origin to actual device name (not build-time ADVERT_NAME) - same as repeater + StrHelper::strncpy(_prefs.mqtt_origin, _prefs.node_name, sizeof(_prefs.mqtt_origin)); + MESH_DEBUG_PRINTLN("MQTT origin set to device name: %s", _prefs.mqtt_origin); + + if (_prefs.bridge_enabled) { + // Defer construction to avoid static init crashes on ESP32 classic + bridge = new MQTTBridge(&_prefs, _mgr, getRTCClock(), &self_id); + if (bridge) { + // Set device public key for MQTT topics + char device_id[65]; + mesh::LocalIdentity self_id = getSelfId(); + mesh::Utils::toHex(device_id, self_id.pub_key, PUB_KEY_SIZE); + MESH_DEBUG_PRINTLN("Setting device ID: %s", device_id); + bridge->setDeviceID(device_id); + + // Set firmware version + bridge->setFirmwareVersion(getFirmwareVer()); + + // Set board model + bridge->setBoardModel(_cli.getBoard()->getManufacturerName()); + + // Set build date + bridge->setBuildDate(getBuildDate()); + + // Set stats sources for automatic stats collection + bridge->setStatsSources(this, _radio, _cli.getBoard(), _ms); + + bridge->begin(); + } + } +#endif } void MyMesh::sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis, uint8_t path_hash_size) { @@ -766,7 +850,7 @@ void MyMesh::sendSelfAdvertisement(int delay_millis, bool flood) { void MyMesh::updateAdvertTimer() { if (_prefs.advert_interval > 0) { // schedule local advert timer - next_local_advert = futureMillis((uint32_t)_prefs.advert_interval * 2 * 60 * 1000); + next_local_advert = futureMillis((int)((uint32_t)_prefs.advert_interval * 2 * 60 * 1000)); } else { next_local_advert = 0; // stop the timer } @@ -938,7 +1022,12 @@ bool MyMesh::saveFilter(ClientInfo* client) { } void MyMesh::loop() { + // Check radio FIRST to ensure we don't miss incoming packets + // MQTT processing can take time, so we prioritize radio reception mesh::Mesh::loop(); +#ifdef WITH_MQTT_BRIDGE + // bridge.loop() is now handled by FreeRTOS task on Core 0 - no need to call it here +#endif if (millisHasNowPassed(next_push) && acl.getNumClients() > 0) { // check for ACK timeouts diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index 1b35ae95a1..74e57e808a 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -24,6 +24,11 @@ #include #include +#ifdef WITH_MQTT_BRIDGE +#include "helpers/bridges/MQTTBridge.h" +#define WITH_BRIDGE +#endif + /* ------------------------------ Config -------------------------------- */ #ifndef FIRMWARE_BUILD_DATE @@ -117,6 +122,9 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { uint8_t pending_sf; uint8_t pending_cr; int matching_peer_indexes[MAX_CLIENTS]; +#ifdef WITH_MQTT_BRIDGE + MQTTBridge* bridge; +#endif void addPost(ClientInfo* client, const char* postData); void pushPostToClient(ClientInfo* client, PostInfo& post); @@ -222,4 +230,59 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { void clearStats() override; void handleCommand(uint32_t sender_timestamp, char* command, char* reply); void loop(); + +#if defined(WITH_BRIDGE) + void setBridgeState(bool enable) override { + if (!bridge) { +#ifdef WITH_MQTT_BRIDGE + bridge = new MQTTBridge(&_prefs, _mgr, getRTCClock(), &self_id); +#endif + if (!bridge) return; + } + if (enable == bridge->isRunning()) return; + if (enable) + { + char device_id[65]; + mesh::LocalIdentity self_id = getSelfId(); + mesh::Utils::toHex(device_id, self_id.pub_key, PUB_KEY_SIZE); + bridge->setDeviceID(device_id); + bridge->setFirmwareVersion(getFirmwareVer()); + bridge->setBoardModel(_cli.getBoard()->getManufacturerName()); + bridge->setBuildDate(getBuildDate()); +#ifdef WITH_MQTT_BRIDGE + bridge->setStatsSources(this, _radio, _cli.getBoard(), _ms); +#endif + bridge->begin(); + } + else + { + bridge->end(); + } + } + + void restartBridge() override { + if (!bridge || !bridge->isRunning()) return; + bridge->end(); + char device_id[65]; + mesh::LocalIdentity self_id = getSelfId(); + mesh::Utils::toHex(device_id, self_id.pub_key, PUB_KEY_SIZE); + bridge->setDeviceID(device_id); + bridge->setFirmwareVersion(getFirmwareVer()); + bridge->setBoardModel(_cli.getBoard()->getManufacturerName()); + bridge->setBuildDate(getBuildDate()); +#ifdef WITH_MQTT_BRIDGE + bridge->setStatsSources(this, _radio, _cli.getBoard(), _ms); +#endif + bridge->begin(); + } + + void restartBridgeSlot(int slot) override { + if (!bridge || !bridge->isRunning()) return; + bridge->setSlotPreset(slot, _prefs.mqtt_slot_preset[slot]); + } + + int getQueueSize() override { + return bridge ? bridge->getQueueSize() : 0; + } +#endif }; diff --git a/examples/simple_room_server/UITask.cpp b/examples/simple_room_server/UITask.cpp index 42bc14d4a5..76d06275a4 100644 --- a/examples/simple_room_server/UITask.cpp +++ b/examples/simple_room_server/UITask.cpp @@ -6,6 +6,10 @@ #define USER_BTN_PRESSED LOW #endif +#ifdef WITH_MQTT_BRIDGE +#include +#endif + #define AUTO_OFF_MILLIS 20000 // 20 seconds #define BOOT_SCREEN_MILLIS 4000 // 4 seconds @@ -81,6 +85,17 @@ void UITask::renderCurrScreen() { _display->setCursor(0, 30); sprintf(tmp, "BW: %03.2f CR: %d", _node_prefs->bw, _node_prefs->cr); _display->print(tmp); + +#ifdef WITH_MQTT_BRIDGE + // Display IP address for MQTT bridge devices + if (WiFi.status() == WL_CONNECTED) { + IPAddress ip = WiFi.localIP(); + _display->setCursor(0, 40); + _display->setColor(DisplayDriver::LIGHT); + snprintf(tmp, sizeof(tmp), "IP: %d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]); + _display->print(tmp); + } +#endif } } diff --git a/examples/simple_secure_chat/main.cpp b/examples/simple_secure_chat/main.cpp index ab14d3933f..c1ed710abf 100644 --- a/examples/simple_secure_chat/main.cpp +++ b/examples/simple_secure_chat/main.cpp @@ -281,7 +281,7 @@ class MyMesh : public BaseChatMesh, ContactVisitor { { // defaults memset(&_prefs, 0, sizeof(_prefs)); - _prefs.airtime_factor = 1.0; + _prefs.airtime_factor = 2.0; // one third strcpy(_prefs.node_name, "NONAME"); _prefs.freq = LORA_FREQ; _prefs.tx_power_dbm = LORA_TX_POWER; diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index b8fe1e579c..808669d87e 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -708,7 +708,7 @@ SensorMesh::SensorMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::Millise // defaults memset(&_prefs, 0, sizeof(_prefs)); - _prefs.airtime_factor = 1.0; + _prefs.airtime_factor = 1.0; // one half _prefs.rx_delay_base = 0.0f; // turn off by default, was 10.0; _prefs.tx_delay_factor = 0.5f; // was 0.25f _prefs.direct_tx_delay_factor = 0.2f; // was zero @@ -828,7 +828,7 @@ void SensorMesh::sendSelfAdvertisement(int delay_millis, bool flood) { void SensorMesh::updateAdvertTimer() { if (_prefs.advert_interval > 0) { // schedule local advert timer - next_local_advert = futureMillis( ((uint32_t)_prefs.advert_interval) * 2 * 60 * 1000); + next_local_advert = futureMillis((int)((uint32_t)_prefs.advert_interval * 2 * 60 * 1000)); } else { next_local_advert = 0; // stop the timer } diff --git a/lib/PsychicMqttClient/.piopm b/lib/PsychicMqttClient/.piopm new file mode 100644 index 0000000000..8a14ccfe83 --- /dev/null +++ b/lib/PsychicMqttClient/.piopm @@ -0,0 +1 @@ +{"type": "library", "name": "PsychicMqttClient", "version": "0.2.4", "spec": {"owner": "elims", "id": 17628, "name": "PsychicMqttClient", "requirements": null, "uri": null}} \ No newline at end of file diff --git a/lib/PsychicMqttClient/CHANGELOG.md b/lib/PsychicMqttClient/CHANGELOG.md new file mode 100644 index 0000000000..f61cbc65eb --- /dev/null +++ b/lib/PsychicMqttClient/CHANGELOG.md @@ -0,0 +1,66 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [0.2.4] - Fixes + +### Fixed + +- Binary payloads truncated by strcpy and switched to memcpy [18](https://github.com/theelims/PsychicMqttClient/pull/18) +- Fixed broken link to Adafruit cert repository + +### Changed + +- Replaced `mqtt.eclipseprojects.io` with `broker.hivemq.com` +- Replaced debug tag with 🐙 + +## [0.2.3] - Various Fixes and Certificate Validation + +### Fixed + +- Issue [16](https://github.com/theelims/PsychicMqttClient/issues/16) was fixed. + +### Added + +- PR [17](https://github.com/theelims/PsychicMqttClient/pull/17) adds functionality to authenticate via client key + certificate. + +## [0.2.2] - Various Fixes + +### Fixed + +- Issue [14](https://github.com/theelims/PsychicMqttClient/issues/14) was fixed. +- Issue [13](https://github.com/theelims/PsychicMqttClient/issues/13) was fixed. + +### Added + +- The function `forceStop()` was added to address issue [12](https://github.com/theelims/PsychicMqttClient/issues/12). + +## [0.2.1] - Fixing Wildcard Behavior + +### Changed + +- Issue [6](https://github.com/theelims/PsychicMqttClient/issues/6) was fixed. + +### Added + +- Example for wildcards in topics. + +## [0.2.0] - Compatible with Arduino 3 / ESP-IDF 5 + +### Added + +- CI workflow +- ESP-IDF 5 compatibility + +### Changed + +- updated library.json + +## [0.1.1] - Stability Fixes + +### Fixed + +- Fixed subscribe error when not connected +- Freezing of event-loop when disconnect() is called while the MQTT server is not connected + +## [0.1.0] - Initial Release diff --git a/lib/PsychicMqttClient/LICENSE b/lib/PsychicMqttClient/LICENSE new file mode 100644 index 0000000000..b058dbe9ab --- /dev/null +++ b/lib/PsychicMqttClient/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 elims + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/lib/PsychicMqttClient/README.md b/lib/PsychicMqttClient/README.md new file mode 100644 index 0000000000..bf9f1fe1d4 --- /dev/null +++ b/lib/PsychicMqttClient/README.md @@ -0,0 +1,198 @@ +# PsychicMqttClient + +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![Continuous Integration](https://github.com/theelims/PsychicMqttClient/actions/workflows/ci.yml/badge.svg)](https://github.com/theelims/PsychicMqttClient/actions/workflows/ci.yml) +[![PlatformIO Registry](https://badges.registry.platformio.org/packages/elims/library/PsychicMqttClient.svg)](https://registry.platformio.org/libraries/elims/PsychicMqttClient) + +Fully featured async MQTT 3.1.1 client for ESP32 with support for SSL/TLS and MQTT over WS. Uses the ESP-IDF MQTT client library under the hood and adds a powerful but easy to use API on top of it. Supports MQTT over TCP, SSL with mbedtls, MQTT over Websocket and MQTT over Websocket Secure. + +There are countless popular MQTT client libraries available for Arduino and ESP32. Like [AsyncMqttClient](https://github.com/marvinroger/async-mqtt-client) by Marvin Roger, [pubsubclient](https://github.com/knolleary/pubsubclient) by knolleary and [arduino-mqtt](https://github.com/256dpi/arduino-mqtt) by 256dpi. They are widely used, but all have their unique limitations. Like not supporting all QoS levels, limited message size and none of them has a practical support for SSL/TLS. Also MQTT over websocket is missing in all of them. + +The API is very similar to [AsyncMqttClient](https://github.com/marvinroger/async-mqtt-client) for the ESP32 by Marvin Roger, so that this library can be used almost as a drop-in replacement. Only minor adjustments are necessary. + +## Features + +- Supports MQTT 3.1.1 with QoS 0, QoS 1 and QoS 2 +- Compatible with all ESP32 variants (ESP32, ESP32-S2, ESP32-S3, ESP32-C3, ESP32-C6, ...) +- Supports Arduino 2 (ESP-IDF 4) and Arduino 3 (ESP-IDF 5) +- Supports MQTT over TCP and MQTT over websocket +- Full support for SSL/TSL encryption - for both MQTT over TCP and MQTT over WS +- No limitation in buffer size for transmit and receive messages. Multipart messages are reassembled. +- Fully asynchronous and non-blocking +- No dependencies to other libraries +- Flexible and powerful event-based API + - `onTopic()` event which calls a callback every time a message on a specific subscribed topic is received +- Handles reconnects automatically +- Automatically embeds a X509 Root CA Bundle into the binary on platformio + +> [!IMPORTANT] +> This library does not compile with the Arduino IDE. It requires the build system of [platformio](https://platformio.org/) or [pioarduino](https://github.com/pioarduino/platform-espressif32). + +## Usage + +MQTT has never been easier to use. Instantiate the MQTT client, set the server URI, and subscribe to a topic with the `onTopic()` event handler. Easy to use with a lambda function where you can work with the received payload. Connect the MQTT client to the server and you're set. Publish works as expected from any other MQTT client. + +```cpp +#include + +PsychicMqttClient mqttClient; + +void setup() +{ + // Setup Serial, WiFi, ... + + mqttClient.setServer("mqtt://mqtt.eclipseprojects.io"); + + mqttClient.onTopic("your/topic", 0, [&](const char *topic, const char *payload, int retain, int qos, bool dup) + { + Serial.printf("Received Topic: %s\r\n", topic); + Serial.printf("Received Payload: %s\r\n", payload); }); + + mqttClient.connect(); +} + +void loop() +{ + // Your logic + mqttClient.publish("your/topic", 0, 0, "Hello World!"); +} +``` + +The client will handle all the connection details on its own. It will attempt to reconnect automatically, so you don't need to take care of this. + +> [!IMPORTANT] +> No blocking code inside event handler functions. These must return fast. Especially no `delay()`, hardware functions or any other blocking code. Store the results and notify your main loop to take actions. If applicable use FreeRTOS toolings like queues or semaphores to protect against race conditions. + +## SSL/TLS Encryption + +Using SSL/TLS encryption can be a little bit tedious, but with this MQTT client this is exceptionally easy. You can include a single PEM certificate in the code, or create a bundle of certificates with a platformio script. These are embedded into the binary and offer you universal SSL/TLS support for a wide range of servers without headaches. + +### Single CA Root Certificate + +If you only need to connect to one specific MQTT server, which will never change, directly embedding the certificate into the source file is the easiest method. + +You can get the root certificate directly from the browser. Open the domain of your MQTT server and verify the SSL encryption by pressing on the icon left to the URL. On Chrome press on `Connection is Secure` > `Certificate is Valid` to open the certificate dialog. (Works similar on all other browsers.) + +![Chrome Screenshot](docs/resources/Browser_certificate.PNG) + +In the Certificate Viewer select the Root Certificate at the top of the tree and press `Export` or any other way to download it. Open the downloaded file and copy the content into your source file. Format the string that it looks like shown below. Add the quotes and `\n` line breaks to keep the block format. + +```cpp +const char *eclipse_root_ca = "-----BEGIN CERTIFICATE-----\n" + "MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw\n" + "TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh\n" + "cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4\n" + "WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu\n" + "ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY\n" + "MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc\n" + "h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+\n" + "0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U\n" + "A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW\n" + "T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH\n" + "B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC\n" + "B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv\n" + "KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn\n" + "OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn\n" + "jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw\n" + "qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI\n" + "rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV\n" + "HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq\n" + "hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL\n" + "ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ\n" + "3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK\n" + "NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5\n" + "ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur\n" + "TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC\n" + "jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc\n" + "oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq\n" + "4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA\n" + "mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d\n" + "emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=\n" + "-----END CERTIFICATE-----\n"; +``` + +When setting the server URI just add the reference to the root CA certificate: + +```cpp +mqttClient.setCACert(eclipse_root_ca); +``` + +That's it. Your MQTT connection is encrypted now. + +### X509 CA Root Certificate Bundles + +If you require universal connectivity to more then one server with different root certificate authorities you can use the python script in the `/scripts` folder. It will either download a standard set of the most popular root CA's or use a set of certificates in \*.PEM or \*.DEM file format located in the folder `/ssl_certs`. For the download either the Mozilla collection at [https://curl.se/ca/cacert.pem](https://curl.se/ca/cacert.pem) is used or a collection curated by [Adafruit](https://github.com/adafruit/certificates/) specifically adjusted for the constraints of embedded systems. + +Copy the script from the library folder into your platformio project folder `./scripts` so that it can be found. In the `platformio.ini` add the following lines + +```ini +extra_scripts = pre:scripts/generate_cert_bundle.py +; Source for SSL Cert Store can bei either downloaded from Mozilla with 'mozilla' ('https://curl.se/ca/cacert.pem') +; or from a curated Adafruit repository with 'adafruit' (https://raw.githubusercontent.com/adafruit/certificates/main/data/roots.pem) +; or complied from a 'folder' full of *.pem / *.dem files stored in the ./ssl_certs folder +board_ssl_cert_source = adafruit +board_build.embed_files = src/certs/x509_crt_bundle.bin +``` + +and configure `board_ssl_cert_source` to your needs. If you use your own collection copy the \*.PEM / \*.DEM certificate files to `./ssl_certs`. The platformio build system will automatically compile the certificates into a binary file and embed them into the final binary. This can be later accessed in your code by + +```cpp +extern const uint8_t rootca_crt_bundle_start[] asm("_binary_src_certs_x509_crt_bundle_bin_start"); +extern const uint8_t rootca_crt_bundle_end[] asm("_binary_src_certs_x509_crt_bundle_bin_end"); +``` + +To include the bundle for the MQTT client simply call + +```cpp +mqttClient.setCACertBundle(rootca_crt_bundle_start, rootca_crt_bundle_end - rootca_crt_bundle_start); +``` + +when configuring the MQTT client instance before connecting. + +#### Together with WiFiClientSecure + +If you use WiFiClientSecure in your application, it can be configured to use the X509 certificate bundle as well. In that case you should use WiFiClientSecure to instantiate the certificate bundle before calling `attachArduinoCACertBundle()`. + +```cpp +WiFiClientSecure client; +#if ESP_ARDUINO_VERSION_MAJOR == 3 +client.setCACertBundle(rootca_crt_bundle_start, rootca_crt_bundle_end - rootca_crt_bundle_start); +#else +client.setCACertBundle(rootca_crt_bundle_start); +#endif +// ... +mqttClient.attachArduinoCACertBundle(); +``` + +Otherwise the bundle will be overwritten by the MQTT client with unwanted side effects. + +> [!IMPORTANT] +> Currently there is a bug in mbedtls which prevents the proper certificate validation for all certificates signed by `Let's encrypt`. For this reason working directly with the ISRG Root X1 CA certificate or the bundle downloaded from Mozilla might result in SSL failing. You need to include the DST Root CA X3 certificate as well. Currently this is only done by the Adafruit repository. You can [read](https://github.com/adafruit/certificates/pull/1) here and [here](https://github.com/espressif/arduino-esp32/issues/8626) about the details. + +## Advanced Usage + +Check the [documentation](/documentation.md) or the commented header file and the `FullyFeatured` example for a complete list of all event handlers and configuration options. You can even get access to the ESP-IDF MQTT Clients' configuration object, should you need parameters not broken out to the API. + +## License + +MIT License + +Copyright (c) 2025 elims + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/lib/PsychicMqttClient/docs/resources/Browser_certificate.PNG b/lib/PsychicMqttClient/docs/resources/Browser_certificate.PNG new file mode 100644 index 0000000000..bdf2326d6b Binary files /dev/null and b/lib/PsychicMqttClient/docs/resources/Browser_certificate.PNG differ diff --git a/lib/PsychicMqttClient/examples/FullyFeatured/main.cpp b/lib/PsychicMqttClient/examples/FullyFeatured/main.cpp new file mode 100644 index 0000000000..23c61e9cd4 --- /dev/null +++ b/lib/PsychicMqttClient/examples/FullyFeatured/main.cpp @@ -0,0 +1,243 @@ +/** + * PsychicMqttClient + * + * Fully Featured Example for a minimal MQTT client using the PsychicMqttClient library. + * + * Please change the ssid and pass to your actual WiFi credentials. + * + * This example uses the public MQTT broker broker.hivemq.com, + * registers a onTopic callback function subscribed to a unique topic + * and publishes a message to that topic. The echoed message payload will be + * printed to the serial monitor. + * + */ + +#include +#include +#include + +const char ssid[] = "ssid"; // your network SSID (name) +const char pass[] = "pass"; // your network password + +/** + * Load the root certificate bundle embedded by the PIO build process + */ +extern const uint8_t rootca_crt_bundle_start[] asm("_binary_src_certs_x509_crt_bundle_bin_start"); +extern const uint8_t rootca_crt_bundle_end[] asm("_binary_src_certs_x509_crt_bundle_bin_end"); + +const char loremIpsum[] = "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, " + "sed diam nonumy eirmod tempor invidunt ut labore et dolor" + "e magna aliquyam erat, sed diam voluptua.At vero eos et a" + "ccusam et justo duo dolores et ea rebum.Stet clita kasd g" + "ubergren, no sea takimata sanctus est Lorem ipsum dolor s" + "it amet.Lorem ipsum dolor sit amet, consetetur sadipscing" + " elitr, sed diam nonumy eirmod tempor invidunt ut labore " + "et dolore magna aliquyam erat, sed diam voluptua.At vero " + "eos et accusam et justo duo dolores et ea rebum.Stet clit" + "a kasd gubergren, no sea takimata sanctus est Lorem ipsum" + " dolor sit amet.Lorem ipsum dolor sit amet, consetetur sa" + "dipscing elitr, sed diam nonumy eirmod tempor invidunt ut" + " labore et dolore magna aliquyam erat, sed diam voluptua." + "At vero eos et accusam et justo duo dolores et ea rebum.S" + "tet clita kasd gubergren, no sea takimata sanctus est Lor" + "em ipsum dolor sit amet.Duis autem vel eum iriure dolor i" + "n hendrerit in vulputate velit esse molestie consequat, v" + "el illum dolore eu feugiat nulla facilisis at vero eros e" + "t accumsan et iusto odio dignissim qui blandit praesent l" + "uptatum zzril delenit augue duis dolore te feugait nulla " + "facilisi. Lorem ipsum dolor sit amet, consectetuer adipis" + "cing elit, sed diam nonummy nibh euismod tincidunt ut lao" + "reet dolore magna aliquam erat volutpat.Ut wisi enim ad m" + "inim veniam, quis nostrud exerci tation ullamcorper susci" + "pit lobortis nisl ut aliquip ex ea commodo consequat.Duis" + " autem vel eum iriure dolor in hendrerit in vulputate vel" + "it esse molestie consequat, vel illum dolore eu feugiat n" + "ulla facilisis at vero eros et accumsan et iusto odio dig" + "nissim qui blandit praesent luptatum zzril delenit augue " + "duis dolore te feugait nulla facilisi.Nam liber tempor cu" + "m soluta nobis eleifend option congue nihil imperdiet dom" + "ing id quod mazim placerat facer possim assum.Lorem ipsum" + " dolor sit amet, consectetuer adipiscing elit, sed diam n" + "onummy nibh euismod tincidunt ut laoreet dolore magna ali" + "quam erat volutpat.Ut wisi enim ad minim veniam, quis nos" + "trud exerci tation ullamcorper suscipit lobortis nisl ut " + "aliquip ex ea commodo consequat.Duis autem vel eum iriure" + " dolor in hendrerit in vulputate velit esse molestie cons" + "equat, vel illum dolore eu feugiat nulla facilisis.At ver" + "o eos et accusam et justo duo dolores et ea rebum.Stet cl" + "ita kasd gubergren, no sea takimata sanctus est Lorem ips" + "um dolor sit amet.Lorem ipsum dolor sit amet, consetetur " + "sadipscing elitr, sed diam nonumy eirmod tempor invidunt " + "ut labore et dolore magna aliquyam erat, sed diam voluptu" + "a.(End of Lorem Ipsum)"; + +/** + * Create a PsychicMqttClient object + */ +PsychicMqttClient mqttClient; + +void onMqttConnect(bool sessionPresent) +{ + Serial.println("Connected to MQTT."); + Serial.printf("Session present: %d\r\n", sessionPresent); + + int packetIdSub = mqttClient.subscribe("test/lol", 2); + Serial.printf("Subscribing at QoS 2, packetId: %d\r\n", packetIdSub); + + mqttClient.publish("test/lol", 0, true, "test 1"); + Serial.println("Publishing at QoS 0"); + + int packetIdPub1 = mqttClient.publish("test/lol", 1, true, "test 2"); + Serial.printf("Publishing at QoS 1, packetId: %d\r\n", packetIdPub1); + + int packetIdPub2 = mqttClient.publish("test/lol", 2, true, "test 3"); + Serial.printf("Publishing at QoS 2, packetId: %d\r\n", packetIdPub2); +} + +void onMqttDisconnect(bool sessionPresent) +{ + Serial.println("Disconnected from MQTT."); +} + +void onMqttSubscribe(uint16_t packetId) +{ + Serial.println("Subscribe acknowledged."); + Serial.printf(" packetId: %d\r\n", packetId); +} + +void onMqttUnsubscribe(uint16_t packetId) +{ + Serial.println("Unsubscribe acknowledged."); + Serial.printf(" packetId: %d\r\n", packetId); +} + +void onMqttMessage(char *topic, char *payload, int retain, int qos, bool dup) +{ + Serial.println("Message received."); + Serial.printf(" topic: %s\r\n", topic); + Serial.printf(" qos: %d\r\n", qos); + Serial.printf(" dup: %d\r\n", dup); + Serial.printf(" retain: %d\r\n", retain); +} + +void onMqttPublish(uint16_t packetId) +{ + Serial.println("Publish acknowledged."); + Serial.printf(" packetId: %d\r\n", packetId); +} + +void onLongTopic(const char *topic, const char *payload, int retain, int qos, bool dup) +{ + Serial.printf("Received Topic: %s\r\n", topic); + Serial.printf("Received Payload: %s\r\n", payload); +} + +void setup() +{ + Serial.begin(115200); + + /** + * Connect to the WiFi network with the given ssid and pass. + */ + WiFi.begin(ssid, pass); + + Serial.printf("Connecting to WiFi %s .", ssid); + while (WiFi.status() != WL_CONNECTED) + { + Serial.print("."); + delay(500); + } + + Serial.printf("\r\nConnected, IP address: %s \r\n", WiFi.localIP().toString().c_str()); + + /** + * Connect to the open MQTT broker broker.hivemq.com with SSL/TLS enryption. + */ + mqttClient.setServer("mqtts://broker.hivemq.com"); + +#if ESP_ARDUINO_VERSION_MAJOR == 3 + mqttClient.setCACertBundle(rootca_crt_bundle_start, rootca_crt_bundle_end - rootca_crt_bundle_start); +#else + mqttClient.setCACertBundle(rootca_crt_bundle_start); +#endif + + mqttClient.setBufferSize(1024); + + /** + * Lambda callback function for onTopic Event Handler + * + * Subscribes to the topic "{MAC-Address}/simple" with QoS 0. + * The lambda callback function will be called when a message is received. + */ + String simpleTopic = String(ESP.getEfuseMac()) + "/simple"; + String longTopic = String(ESP.getEfuseMac()) + "/long"; + + /** + * Register the callback functions for the MQTT client + */ + mqttClient.onConnect(onMqttConnect); + mqttClient.onDisconnect(onMqttDisconnect); + mqttClient.onSubscribe(onMqttSubscribe); + mqttClient.onUnsubscribe(onMqttUnsubscribe); + mqttClient.onMessage(onMqttMessage); + mqttClient.onPublish(onMqttPublish); + + /** + * Using a lambda callback function is a very convenient way to handle the + * received message. The function will be called when a message is received. + * + * The parameters are: + * - topic: The topic of the received message + * - payload: The payload of the received message + * - retain: The retain flag of the received message + * - qos: The qos of the received message + * - dup: The duplicate flag of the received message + * + * It is important not to do any heavy calculations, hardware access, delays or + * blocking code in the callback function. + */ + mqttClient.onTopic(simpleTopic.c_str(), 0, [&](const char *topic, const char *payload, int retain, int qos, bool dup) + { + Serial.printf("Received Topic: %s\r\n", topic); + Serial.printf("Received Payload: %s\r\n", payload); }); + + /** + * Using a dedicated callback function is a good way to handle the received message. + */ + mqttClient.onTopic(longTopic.c_str(), 0, onLongTopic); + + /** + * Connect to the MQTT broker + */ + mqttClient.connect(); + + /** + * Wait blocking until the connection is established + */ + while (!mqttClient.connected()) + { + delay(500); + } + + /** + * Publish a message to the topic "{MAC-Address}/simple" with QoS 0 and retain flag 0. + * + * You can only publish messages after the connection is established. + */ + mqttClient.publish(simpleTopic.c_str(), 0, 0, "Hello World!"); + + delay(5000); + Serial.println("Unsubscribing from topic test/lol"); + mqttClient.unsubscribe("test/lol"); + + delay(5000); + Serial.println("Publishing lorem ipsum..."); + mqttClient.publish(longTopic.c_str(), 0, 0, loremIpsum); +} + +void loop() +{ + /** + * Nothing to do here, the onTopic callback function will be called when a message is received. + */ +} diff --git a/lib/PsychicMqttClient/examples/SSL_CA_Bundle/main.cpp b/lib/PsychicMqttClient/examples/SSL_CA_Bundle/main.cpp new file mode 100644 index 0000000000..8da213a99f --- /dev/null +++ b/lib/PsychicMqttClient/examples/SSL_CA_Bundle/main.cpp @@ -0,0 +1,115 @@ +/** + * PsychicMqttClient + * + * Simple Example for a minimal MQTT client using the PsychicMqttClient library. + * + * Please change the ssid and pass to your actual WiFi credentials. + * + * This example uses the public MQTT broker broker.hivemq.com, + * registers a onTopic callback function subscribed to a unique topic + * and publishes a message to that topic. The echoed message payload will be + * printed to the serial monitor. + * + */ + +#include +#include +#include + +const char ssid[] = "ssid"; // your network SSID (name) +const char pass[] = "pass"; // your network password + +/** + * Load the root certificate bundle embedded by the PIO build process + */ +extern const uint8_t rootca_crt_bundle_start[] asm("_binary_src_certs_x509_crt_bundle_bin_start"); +extern const uint8_t rootca_crt_bundle_end[] asm("_binary_src_certs_x509_crt_bundle_bin_end"); + +/** + * Create a PsychicMqttClient object + */ +PsychicMqttClient mqttClient; + +void setup() +{ + Serial.begin(115200); + + /** + * Connect to the WiFi network with the given ssid and pass. + */ + WiFi.begin(ssid, pass); + + Serial.printf("Connecting to WiFi %s .", ssid); + while (WiFi.status() != WL_CONNECTED) + { + Serial.print("."); + delay(500); + } + + Serial.printf("\r\nConnected, IP address: %s \r\n", WiFi.localIP().toString().c_str()); + + /** + * Connect to the open MQTT broker broker.hivemq.com with SSL/TLS enryption. + */ + mqttClient.setServer("mqtts://broker.hivemq.com"); + +#if ESP_ARDUINO_VERSION_MAJOR == 3 + mqttClient.setCACertBundle(rootca_crt_bundle_start, rootca_crt_bundle_end - rootca_crt_bundle_start); +#else + mqttClient.setCACertBundle(rootca_crt_bundle_start); +#endif + + /** + * Lambda callback function for onTopic Event Handler + * + * Subscribes to the topic "{MAC-Address}/simple" with QoS 0. + * The lambda callback function will be called when a message is received. + */ + String topic = String(ESP.getEfuseMac()) + "/simple"; + + mqttClient.onTopic(topic.c_str(), 0, [&](const char *topic, const char *payload, int retain, int qos, bool dup) + { + /** + * Using a lambda callback function is a very convenient way to handle the + * received message. The function will be called when a message is received. + * + * The parameters are: + * - topic: The topic of the received message + * - payload: The payload of the received message + * - retain: The retain flag of the received message + * - qos: The qos of the received message + * - dup: The duplicate flag of the received message + * + * It is important not to do any heavy calculations, hardware access, delays or + * blocking code in the callback function. + */ + Serial.printf("Received Topic: %s\r\n", topic); + Serial.printf("Received Payload: %s\r\n", payload); }); + + /** + * Connect to the MQTT broker + */ + mqttClient.connect(); + + /** + * Wait blocking until the connection is established + */ + while (!mqttClient.connected()) + { + delay(500); + } + + /** + * Publish a message to the topic "{MAC-Address}/simple" with QoS 0 and retain flag 0. + * + * You can only publish messages after the connection is established. + */ + mqttClient.publish(topic.c_str(), 0, 0, "Hello World!"); +} + +void loop() +{ + /** + * Nothing to do here, the onTopic callback function will be called when a message is received. + */ +} diff --git a/lib/PsychicMqttClient/examples/SSL_CA_Bundle_WiFiClientSecure/main.cpp b/lib/PsychicMqttClient/examples/SSL_CA_Bundle_WiFiClientSecure/main.cpp new file mode 100644 index 0000000000..ada2be907a --- /dev/null +++ b/lib/PsychicMqttClient/examples/SSL_CA_Bundle_WiFiClientSecure/main.cpp @@ -0,0 +1,164 @@ +/** + * PsychicMqttClient + * + * Simple Example for a minimal MQTT client using the PsychicMqttClient library. + * + * Please change the ssid and pass to your actual WiFi credentials. + * + * This example uses the public MQTT broker broker.hivemq.com, + * registers a onTopic callback function subscribed to a unique topic + * and publishes a message to that topic. The echoed message payload will be + * printed to the serial monitor. + * + */ + +#include +#include +#include +#include + +const char ssid[] = "ssid"; // your network SSID (name) +const char pass[] = "pass"; // your network password + +/** + * Load the root certificate bundle embedded by the build process + */ +extern const uint8_t rootca_crt_bundle_start[] asm("_binary_src_certs_x509_crt_bundle_bin_start"); +extern const uint8_t rootca_crt_bundle_end[] asm("_binary_src_certs_x509_crt_bundle_bin_end"); + +/** + * Create a PsychicMqttClient object + */ +PsychicMqttClient mqttClient; + +/** + * Create a WiFiClientSecure object + */ +WiFiClientSecure client; +const char *server = "www.howsmyssl.com"; + +void fetchData() +{ + Serial.println("\nStarting connection to server..."); + if (!client.connect(server, 443)) + Serial.println("Connection failed!"); + else + { + Serial.println("Connected to server!"); + // Make a HTTP request: + client.println("GET https://www.howsmyssl.com/a/check HTTP/1.0"); + client.println("Host: www.howsmyssl.com"); + client.println("Connection: close"); + client.println(); + + while (client.connected()) + { + String line = client.readStringUntil('\n'); + if (line == "\r") + { + Serial.println("headers received"); + break; + } + } + // if there are incoming bytes available + // from the server, read them and print them: + while (client.available()) + { + char c = client.read(); + Serial.write(c); + } + + client.stop(); + Serial.println("\nStopping connection to server..."); + } +} + +void setup() +{ + Serial.begin(115200); + + /** + * Connect to the WiFi network with the given ssid and pass. + */ + WiFi.begin(ssid, pass); + + Serial.printf("Connecting to WiFi %s .", ssid); + while (WiFi.status() != WL_CONNECTED) + { + Serial.print("."); + delay(500); + } + + Serial.printf("\r\nConnected, IP address: %s \r\n", WiFi.localIP().toString().c_str()); + + /** + * Attach the root certificate bundle to the WiFiClientSecure object + * first and fetch some data from a server. + */ +#if ESP_ARDUINO_VERSION_MAJOR == 3 + client.setCACertBundle(rootca_crt_bundle_start, rootca_crt_bundle_end - rootca_crt_bundle_start); +#else + client.setCACertBundle(rootca_crt_bundle_start); +#endif + fetchData(); + + /** + * Connect to the open MQTT broker broker.hivemq.com with SSL/TLS enryption. + */ + mqttClient.setServer("mqtts://broker.hivemq.com"); + mqttClient.attachArduinoCACertBundle(); + + /** + * Lambda callback function for onTopic Event Handler + * + * Subscribes to the topic "{MAC-Address}/simple" with QoS 0. + * The lambda callback function will be called when a message is received. + */ + String topic = String(ESP.getEfuseMac()) + "/simple"; + + mqttClient.onTopic(topic.c_str(), 0, [&](const char *topic, const char *payload, int retain, int qos, bool dup) + { + /** + * Using a lambda callback function is a very convenient way to handle the + * received message. The function will be called when a message is received. + * + * The parameters are: + * - topic: The topic of the received message + * - payload: The payload of the received message + * - retain: The retain flag of the received message + * - qos: The qos of the received message + * - dup: The duplicate flag of the received message + * + * It is important not to do any heavy calculations, hardware access, delays or + * blocking code in the callback function. + */ + Serial.printf("Received Topic: %s\r\n", topic); + Serial.printf("Received Payload: %s\r\n", payload); }); + + /** + * Connect to the MQTT broker + */ + mqttClient.connect(); + + /** + * Wait blocking until the connection is established + */ + while (!mqttClient.connected()) + { + delay(500); + } + + /** + * Publish a message to the topic "{MAC-Address}/simple" with QoS 0 and retain flag 0. + * + * You can only publish messages after the connection is established. + */ + mqttClient.publish(topic.c_str(), 0, 0, "Hello World!"); +} + +void loop() +{ + /** + * Nothing to do here, the onTopic callback function will be called when a message is received. + */ +} diff --git a/lib/PsychicMqttClient/examples/SSL_CA_Cert/main.cpp b/lib/PsychicMqttClient/examples/SSL_CA_Cert/main.cpp new file mode 100644 index 0000000000..3ecbda24f4 --- /dev/null +++ b/lib/PsychicMqttClient/examples/SSL_CA_Cert/main.cpp @@ -0,0 +1,139 @@ +/** + * PsychicMqttClient + * + * Simple Example for a minimal MQTT client using the PsychicMqttClient library. + * + * Please change the ssid and pass to your actual WiFi credentials. + * + * This example uses the public MQTT broker broker.hivemq.com, + * registers a onTopic callback function subscribed to a unique topic + * and publishes a message to that topic. The echoed message payload will be + * printed to the serial monitor. + * + */ + +#include +#include +#include + +const char ssid[] = "ssid"; // your network SSID (name) +const char pass[] = "pass"; // your network password + +/** + * ISRG Root X1 Certificate which is used by the public MQTT broker broker.hivemq.com + */ +const char *eclipse_root_ca = "-----BEGIN CERTIFICATE-----\n" + "MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw\n" + "TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh\n" + "cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4\n" + "WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu\n" + "ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY\n" + "MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc\n" + "h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+\n" + "0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U\n" + "A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW\n" + "T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH\n" + "B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC\n" + "B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv\n" + "KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn\n" + "OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn\n" + "jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw\n" + "qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI\n" + "rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV\n" + "HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq\n" + "hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL\n" + "ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ\n" + "3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK\n" + "NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5\n" + "ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur\n" + "TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC\n" + "jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc\n" + "oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq\n" + "4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA\n" + "mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d\n" + "emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=\n" + "-----END CERTIFICATE-----\n"; + +/** + * Create a PsychicMqttClient object + */ +PsychicMqttClient mqttClient; + +void setup() +{ + Serial.begin(115200); + + /** + * Connect to the WiFi network with the given ssid and pass. + */ + WiFi.begin(ssid, pass); + + Serial.printf("Connecting to WiFi %s .", ssid); + while (WiFi.status() != WL_CONNECTED) + { + Serial.print("."); + delay(500); + } + + Serial.printf("\r\nConnected, IP address: %s \r\n", WiFi.localIP().toString().c_str()); + + /** + * Connect to the open MQTT broker broker.hivemq.com with SSL/TLS enryption. + */ + mqttClient.setServer("mqtts://broker.hivemq.com"); + mqttClient.setCACert(eclipse_root_ca); + + /** + * Lambda callback function for onTopic Event Handler + * + * Subscribes to the topic "{MAC-Address}/simple" with QoS 0. + * The lambda callback function will be called when a message is received. + */ + String topic = String(ESP.getEfuseMac()) + "/simple"; + + mqttClient.onTopic(topic.c_str(), 0, [&](const char *topic, const char *payload, int retain, int qos, bool dup) + { + /** + * Using a lambda callback function is a very convenient way to handle the + * received message. The function will be called when a message is received. + * + * The parameters are: + * - topic: The topic of the received message + * - payload: The payload of the received message + * - retain: The retain flag of the received message + * - qos: The qos of the received message + * - dup: The duplicate flag of the received message + * + * It is important not to do any heavy calculations, hardware access, delays or + * blocking code in the callback function. + */ + Serial.printf("Received Topic: %s\r\n", topic); + Serial.printf("Received Payload: %s\r\n", payload); }); + + /** + * Connect to the MQTT broker + */ + mqttClient.connect(); + + /** + * Wait blocking until the connection is established + */ + while (!mqttClient.connected()) + { + delay(500); + } + + /** + * Publish a message to the topic "{MAC-Address}/simple" with QoS 0 and retain flag 0. + * + * You can only publish messages after the connection is established. + */ + mqttClient.publish(topic.c_str(), 0, 0, "Hello World!"); +} + +void loop() +{ + /** + * Nothing to do here, the onTopic callback function will be called when a message is received. + */ +} diff --git a/lib/PsychicMqttClient/examples/Simple/main.cpp b/lib/PsychicMqttClient/examples/Simple/main.cpp new file mode 100644 index 0000000000..7515ab26b5 --- /dev/null +++ b/lib/PsychicMqttClient/examples/Simple/main.cpp @@ -0,0 +1,103 @@ +/** + * PsychicMqttClient + * + * Simple Example for a minimal MQTT client using the PsychicMqttClient library. + * + * Please change the ssid and pass to your actual WiFi credentials. + * + * This example uses the public MQTT broker broker.hivemq.com, + * registers a onTopic callback function subscribed to a unique topic + * and publishes a message to that topic. The echoed message payload will be + * printed to the serial monitor. + * + */ + +#include +#include +#include + +const char ssid[] = "ssid"; // your network SSID (name) +const char pass[] = "pass"; // your network password + +/** + * Create a PsychicMqttClient object + */ +PsychicMqttClient mqttClient; + +void setup() +{ + Serial.begin(115200); + + /** + * Connect to the WiFi network with the given ssid and pass. + */ + WiFi.begin(ssid, pass); + + Serial.printf("Connecting to WiFi %s .", ssid); + while (WiFi.status() != WL_CONNECTED) + { + Serial.print("."); + delay(500); + } + + Serial.printf("\r\nConnected, IP address: %s \r\n", WiFi.localIP().toString().c_str()); + + /** + * Connect to the open MQTT broker broker.hivemq.com. + */ + mqttClient.setServer("mqtt://broker.hivemq.com"); + + /** + * Lambda callback function for onTopic Event Handler + * + * Subscribes to the topic "{MAC-Address}/simple" with QoS 0. + * The lambda callback function will be called when a message is received. + */ + String topic = String(ESP.getEfuseMac()) + "/simple"; + + mqttClient.onTopic(topic.c_str(), 0, [&](const char *topic, const char *payload, int retain, int qos, bool dup) + { + /** + * Using a lambda callback function is a very convenient way to handle the + * received message. The function will be called when a message is received. + * + * The parameters are: + * - topic: The topic of the received message + * - payload: The payload of the received message + * - retain: The retain flag of the received message + * - qos: The qos of the received message + * - dup: The duplicate flag of the received message + * + * It is important not to do any heavy calculations, hardware access, delays or + * blocking code in the callback function. + */ + Serial.printf("Received Topic: %s\r\n", topic); + Serial.printf("Received Payload: %s\r\n", payload); }); + + /** + * Connect to the MQTT broker + */ + mqttClient.connect(); + + /** + * Wait blocking until the connection is established + */ + while (!mqttClient.connected()) + { + delay(500); + } + + /** + * Publish a message to the topic "{MAC-Address}/simple" with QoS 0 and retain flag 0. + * + * You can only publish messages after the connection is established. + */ + mqttClient.publish(topic.c_str(), 0, 0, "Hello World!"); +} + +void loop() +{ + /** + * Nothing to do here, the onTopic callback function will be called when a message is received. + */ +} diff --git a/lib/PsychicMqttClient/examples/Simple_WS/main.cpp b/lib/PsychicMqttClient/examples/Simple_WS/main.cpp new file mode 100644 index 0000000000..2a4f66dd5d --- /dev/null +++ b/lib/PsychicMqttClient/examples/Simple_WS/main.cpp @@ -0,0 +1,103 @@ +/** + * PsychicMqttClient + * + * Simple Example for a minimal MQTT client using the PsychicMqttClient library. + * + * Please change the ssid and pass to your actual WiFi credentials. + * + * This example uses the public MQTT broker broker.hivemq.com, + * registers a onTopic callback function subscribed to a unique topic + * and publishes a message to that topic. The echoed message payload will be + * printed to the serial monitor. + * + */ + +#include +#include +#include + +const char ssid[] = "ssid"; // your network SSID (name) +const char pass[] = "pass"; // your network password + +/** + * Create a PsychicMqttClient object + */ +PsychicMqttClient mqttClient; + +void setup() +{ + Serial.begin(115200); + + /** + * Connect to the WiFi network with the given ssid and pass. + */ + WiFi.begin(ssid, pass); + + Serial.printf("Connecting to WiFi %s .", ssid); + while (WiFi.status() != WL_CONNECTED) + { + Serial.print("."); + delay(500); + } + + Serial.printf("\r\nConnected, IP address: %s \r\n", WiFi.localIP().toString().c_str()); + + /** + * Connect to the open MQTT broker broker.hivemq.com over websocket. + */ + mqttClient.setServer("ws://broker.hivemq.com:80/mqtt"); + + /** + * Lambda callback function for onTopic Event Handler + * + * Subscribes to the topic "{MAC-Address}/simple" with QoS 0. + * The lambda callback function will be called when a message is received. + */ + String topic = String(ESP.getEfuseMac()) + "/simple"; + + mqttClient.onTopic(topic.c_str(), 0, [&](const char *topic, const char *payload, int retain, int qos, bool dup) + { + /** + * Using a lambda callback function is a very convenient way to handle the + * received message. The function will be called when a message is received. + * + * The parameters are: + * - topic: The topic of the received message + * - payload: The payload of the received message + * - retain: The retain flag of the received message + * - qos: The qos of the received message + * - dup: The duplicate flag of the received message + * + * It is important not to do any heavy calculations, hardware access, delays or + * blocking code in the callback function. + */ + Serial.printf("Received Topic: %s\r\n", topic); + Serial.printf("Received Payload: %s\r\n", payload); }); + + /** + * Connect to the MQTT broker + */ + mqttClient.connect(); + + /** + * Wait blocking until the connection is established + */ + while (!mqttClient.connected()) + { + delay(500); + } + + /** + * Publish a message to the topic "{MAC-Address}/simple" with QoS 0 and retain flag 0. + * + * You can only publish messages after the connection is established. + */ + mqttClient.publish(topic.c_str(), 0, 0, "Hello World!"); +} + +void loop() +{ + /** + * Nothing to do here, the onTopic callback function will be called when a message is received. + */ +} diff --git a/lib/PsychicMqttClient/examples/Wildcards/main.cpp b/lib/PsychicMqttClient/examples/Wildcards/main.cpp new file mode 100644 index 0000000000..f32ac4d665 --- /dev/null +++ b/lib/PsychicMqttClient/examples/Wildcards/main.cpp @@ -0,0 +1,144 @@ +/** + * PsychicMqttClient + * + * Simple Example for a minimal MQTT client using the PsychicMqttClient library. + * + * Please change the ssid and pass to your actual WiFi credentials. + * + * This example uses the public MQTT broker broker.hivemq.com, + * registers a onTopic callback function subscribed to a unique topic + * and publishes a message to that topic. The echoed message payload will be + * printed to the serial monitor. + * + */ + +#include +#include +#include + +#define RECEIVE_DELAY 5000 + +const char ssid[] = "ssid"; // your network SSID (name) +const char pass[] = "pass"; // your network password + +PsychicMqttClient mqttClient; + +/* + Setup a number of topics with and without wildcards. +*/ +const String TestTopicA = String(ESP.getEfuseMac()) + "/test/A/0000/topic"; +const String TestTopicAWildcard = String(ESP.getEfuseMac()) + "/test/A/+/topic"; +const String TestTopicAReference = String(ESP.getEfuseMac()) + "/test/A/1111/topic"; +const String TestTopicALong = String(ESP.getEfuseMac()) + "/test/A/0000/topic/long"; + +const String TestTopicB = String(ESP.getEfuseMac()) + "/test/B/topic/0000"; +const String TestTopicBWildcard = String(ESP.getEfuseMac()) + "/test/B/topic/#"; +const String TestTopicBReference = String(ESP.getEfuseMac()) + "/test/B/topic/1111"; +const String TestTopicBLong = String(ESP.getEfuseMac()) + "/test/B/topic/0000/long"; + +const String TestTopicC = String(ESP.getEfuseMac()) + "/test/C/topic/0000"; +const String TestTopicCWildcard = String(ESP.getEfuseMac()) + "/test/C/topic/+"; +const String TestTopicCReference = String(ESP.getEfuseMac()) + "/test/C/topic/1111"; +const String TestTopicCLong = String(ESP.getEfuseMac()) + "/test/C/topic/0000/long"; + +const String TestAllTopics = String(ESP.getEfuseMac()) + "/test/#"; +const String TestTopic = String(ESP.getEfuseMac()) + "/test/topic"; + +void setup() +{ + Serial.begin(115200); + + WiFi.begin(ssid, pass); + + Serial.printf("Connecting to WiFi %s .", ssid); + while (WiFi.status() != WL_CONNECTED) + { + Serial.print("."); + delay(500); + } + + Serial.printf("\r\nConnected, IP address: %s \r\n", WiFi.localIP().toString().c_str()); + + mqttClient.setServer("mqtt://broker.hivemq.com"); + + Serial.printf("Subscribing to %s\r\n", TestTopicA.c_str()); + mqttClient.onTopic(TestTopicA.c_str(), 0, [&](const char *topic, const char *payload, int retain, int qos, bool dup) + { Serial.printf("Received %s: %s\r\n", TestTopicA.c_str(), topic); }); + + Serial.printf("Subscribing to %s\r\n", TestTopicAWildcard.c_str()); + mqttClient.onTopic(TestTopicAWildcard.c_str(), 0, [&](const char *topic, const char *payload, int retain, int qos, bool dup) + { Serial.printf("Received %s: %s\r\n", TestTopicAWildcard.c_str(), topic); }); + + Serial.printf("Subscribing to %s\r\n", TestTopicB.c_str()); + mqttClient.onTopic(TestTopicB.c_str(), 0, [&](const char *topic, const char *payload, int retain, int qos, bool dup) + { Serial.printf("Received %s: %s\r\n", TestTopicB.c_str(), topic); }); + + Serial.printf("Subscribing to %s\r\n", TestTopicBWildcard.c_str()); + mqttClient.onTopic(TestTopicBWildcard.c_str(), 0, [&](const char *topic, const char *payload, int retain, int qos, bool dup) + { Serial.printf("Received %s: %s\r\n", TestTopicBWildcard.c_str(), topic); }); + + Serial.printf("Subscribing to %s\r\n", TestTopicC.c_str()); + mqttClient.onTopic(TestTopicC.c_str(), 0, [&](const char *topic, const char *payload, int retain, int qos, bool dup) + { Serial.printf("Received %s: %s\r\n", TestTopicC.c_str(), topic); }); + + Serial.printf("Subscribing to %s\r\n", TestTopicCWildcard.c_str()); + mqttClient.onTopic(TestTopicCWildcard.c_str(), 0, [&](const char *topic, const char *payload, int retain, int qos, bool dup) + { Serial.printf("Received %s: %s\r\n", TestTopicCWildcard.c_str(), topic); }); + + Serial.printf("Subscribing to %s\r\n", TestAllTopics.c_str()); + mqttClient.onTopic(TestAllTopics.c_str(), 0, [&](const char *topic, const char *payload, int retain, int qos, bool dup) + { Serial.printf("Received %s: %s\r\n", TestAllTopics.c_str(), topic); }); + + mqttClient.connect(); + + while (!mqttClient.connected()) + { + delay(500); + } + + delay(RECEIVE_DELAY); +} + +void loop() +{ + Serial.println("Testing Wildcards:"); + + Serial.printf("\r\nPublishing to %s\r\n", TestTopicA.c_str()); + mqttClient.publish(TestTopicA.c_str(), 0, 0, "test"); + delay(RECEIVE_DELAY); + + Serial.printf("\r\nPublishing to %s\r\n", TestTopicAReference.c_str()); + mqttClient.publish(TestTopicAReference.c_str(), 0, 0, "test"); + delay(RECEIVE_DELAY); + + Serial.printf("\r\nPublishing to %s\r\n", TestTopicALong.c_str()); + mqttClient.publish(TestTopicALong.c_str(), 0, 0, "test"); + delay(RECEIVE_DELAY); + + Serial.printf("\r\nPublishing to %s\r\n", TestTopicB.c_str()); + mqttClient.publish(TestTopicB.c_str(), 0, 0, "test"); + delay(RECEIVE_DELAY); + + Serial.printf("\r\nPublishing to %s\r\n", TestTopicBReference.c_str()); + mqttClient.publish(TestTopicBReference.c_str(), 0, 0, "test"); + delay(RECEIVE_DELAY); + + Serial.printf("\r\nPublishing to %s\r\n", TestTopicBLong.c_str()); + mqttClient.publish(TestTopicBLong.c_str(), 0, 0, "test"); + delay(RECEIVE_DELAY); + + Serial.printf("\r\nPublishing to %s\r\n", TestTopicC.c_str()); + mqttClient.publish(TestTopicC.c_str(), 0, 0, "test"); + delay(RECEIVE_DELAY); + + Serial.printf("\r\nPublishing to %s\r\n", TestTopicCReference.c_str()); + mqttClient.publish(TestTopicCReference.c_str(), 0, 0, "test"); + delay(RECEIVE_DELAY); + + Serial.printf("\r\nPublishing to %s\r\n", TestTopicCLong.c_str()); + mqttClient.publish(TestTopicCLong.c_str(), 0, 0, "test"); + delay(RECEIVE_DELAY); + + Serial.println("\r\nTest done."); + vTaskDelete(NULL); +} diff --git a/lib/PsychicMqttClient/library.json b/lib/PsychicMqttClient/library.json new file mode 100644 index 0000000000..e078f99e1b --- /dev/null +++ b/lib/PsychicMqttClient/library.json @@ -0,0 +1,32 @@ +{ + "name": "PsychicMqttClient", + "version": "0.2.4-meshcore.1", + "description": "Fully featured async MQTT client for ESP32 with support for SSL/TLS and MQTT over WS. Uses the ESP-IDF MQTT client library under the hood and adds a powerful and easy to use API on top of it. (MeshCore: fixed memory leak in connect())", + "keywords": "iot, home, automation, async, mqtt, client, esp32, mqttclient, mqtt-client", + "repository": { + "type": "git", + "url": "https://github.com/theelims/PsychicMqttClient.git" + }, + "authors": { + "name": "elims", + "email": "elims@gmx.net", + "maintainer": true + }, + "license": "MIT", + "frameworks": "arduino", + "platforms": "espressif32", + "headers": "PsychicMQTTClient.h", + "export": { + "include": [ + "docs", + "examples", + "scripts", + "src", + "library.json", + "CHANGELOG.md", + "LICENSE", + "platformio.ini", + "README.md" + ] + } +} diff --git a/lib/PsychicMqttClient/library.properties b/lib/PsychicMqttClient/library.properties new file mode 100644 index 0000000000..0e826c47c2 --- /dev/null +++ b/lib/PsychicMqttClient/library.properties @@ -0,0 +1,10 @@ +name=PsychicMqttClient +version=0.2.4-meshcore.1 +author=elims +maintainer=elims +sentence=Fully featured async MQTT client for ESP32 with support for SSL/TLS and MQTT over WS. +paragraph=Uses the ESP-IDF MQTT client library under the hood and adds a powerful and easy to use API on top of it. +category=Communication +url=https://github.com/theelims/PsychicMqttClient +architectures=esp32 +license=MIT diff --git a/lib/PsychicMqttClient/platformio.ini b/lib/PsychicMqttClient/platformio.ini new file mode 100644 index 0000000000..1dcb657dcd --- /dev/null +++ b/lib/PsychicMqttClient/platformio.ini @@ -0,0 +1,42 @@ +[env] +framework = arduino +build_flags = + -Wall -Wextra + -D CONFIG_ARDUHAL_LOG_COLORS + -D CORE_DEBUG_LEVEL=ARDUHAL_LOG_LEVEL_VERBOSE +lib_deps = +upload_protocol = esptool +monitor_speed = 115200 +monitor_filters = esp32_exception_decoder, log2file + +extra_scripts = pre:scripts/generate_cert_bundle.py +; Source for SSL Cert Store can bei either downloaded from Mozilla with 'mozilla' ('https://curl.se/ca/cacert.pem') +; or from a curated Adafruit repository with 'adafruit' (https://raw.githubusercontent.com/adafruit/certificates/main/data/roots.pem) +; or complied from a 'folder' full of *.pem / *.dem files stored in the ./ssl_certs folder +board_ssl_cert_source = adafruit +board_build.embed_files = src/certs/x509_crt_bundle.bin + +[platformio] +lib_dir = . +; src_dir = examples/FullyFeatured +; src_dir = examples/Simple +; src_dir = examples/Simple_WS +src_dir = examples/SSL_CA_Bundle +; src_dir = examples/SSL_CA_Bundle_WiFiClientSecure +; src_dir = examples/SSL_CA_Cert +; src_dir = examples/Wildcards + +[env:arduino-2] +platform = espressif32@6.10.0 +board = esp32dev + +[env:arduino-3] +platform = espressif32 +platform_packages= + platformio/framework-arduinoespressif32 @ https://github.com/espressif/arduino-esp32.git#3.0.4 + platformio/framework-arduinoespressif32-libs @ https://github.com/espressif/arduino-esp32/releases/download/3.0.4/esp32-arduino-libs-3.0.4.zip +board = esp32dev + +[env:pioarduino-3] +platform = https://github.com/pioarduino/platform-espressif32/releases/download/54.03.21/platform-espressif32.zip +board = esp32-s3-devkitc-1 diff --git a/lib/PsychicMqttClient/scripts/generate_cert_bundle.py b/lib/PsychicMqttClient/scripts/generate_cert_bundle.py new file mode 100644 index 0000000000..878ddf4900 --- /dev/null +++ b/lib/PsychicMqttClient/scripts/generate_cert_bundle.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python +# +# modified ESP32 x509 certificate bundle generation utility to run with platformio +# +# Converts PEM and DER certificates to a custom bundle format which stores just the +# subject name and public key to reduce space +# +# The bundle will have the format: number of certificates; crt 1 subject name length; crt 1 public key length; +# crt 1 subject name; crt 1 public key; crt 2... +# +# SPDX-FileCopyrightText: 2018-2022 Espressif Systems (Shanghai) CO LTD +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import with_statement + +from pathlib import Path +import os +import struct +import sys +import requests +from io import open + +Import("env") + +try: + from cryptography import x509 + from cryptography.hazmat.backends import default_backend + from cryptography.hazmat.primitives import serialization +except ImportError: + env.Execute("$PYTHONEXE -m pip install cryptography") + + +ca_bundle_bin_file = 'x509_crt_bundle.bin' +mozilla_cacert_url = 'https://curl.se/ca/cacert.pem' +adafruit_filtered_cacert_url = 'https://raw.githubusercontent.com/adafruit/certificates/main/data/roots-filtered.pem' +adafruit_full_cacert_url = 'https://raw.githubusercontent.com/adafruit/certificates/main/data/roots-full.pem' +certs_dir = Path("./ssl_certs") +binary_dir = Path("./src/certs") + +quiet = False + +def download_cacert_file(source): + if source == "mozilla": + response = requests.get(mozilla_cacert_url) + elif source == "adafruit": + response = requests.get(adafruit_filtered_cacert_url) + elif source == "adafruit-full": + response = requests.get(adafruit_full_cacert_url) + else: + raise InputError('Invalid certificate source') + + if response.status_code == 200: + + # Ensure the directory exists, create it if necessary + os.makedirs(certs_dir, exist_ok=True) + + # Generate the full path to the output file + output_file = os.path.join(certs_dir, "cacert.pem") + + # Write the certificate bundle to the output file with utf-8 encoding + with open(output_file, "w", encoding="utf-8") as f: + f.write(response.text) + + status('Certificate bundle downloaded to: %s' % output_file) + else: + status('Failed to fetch the certificate bundle.') + +def status(msg): + """ Print status message to stderr """ + if not quiet: + critical(msg) + + +def critical(msg): + """ Print critical message to stderr """ + sys.stderr.write('SSL Cert Store: ') + sys.stderr.write(msg) + sys.stderr.write('\n') + + +class CertificateBundle: + def __init__(self): + self.certificates = [] + self.compressed_crts = [] + + if os.path.isfile(ca_bundle_bin_file): + os.remove(ca_bundle_bin_file) + + def add_from_path(self, crts_path): + + found = False + for file_path in os.listdir(crts_path): + found |= self.add_from_file(os.path.join(crts_path, file_path)) + + if found is False: + raise InputError('No valid x509 certificates found in %s' % crts_path) + + def add_from_file(self, file_path): + try: + if file_path.endswith('.pem'): + status('Parsing certificates from %s' % file_path) + with open(file_path, 'r', encoding='utf-8') as f: + crt_str = f.read() + self.add_from_pem(crt_str) + return True + + elif file_path.endswith('.der'): + status('Parsing certificates from %s' % file_path) + with open(file_path, 'rb') as f: + crt_str = f.read() + self.add_from_der(crt_str) + return True + + except ValueError: + critical('Invalid certificate in %s' % file_path) + raise InputError('Invalid certificate') + + return False + + def add_from_pem(self, crt_str): + """ A single PEM file may have multiple certificates """ + + crt = '' + count = 0 + start = False + + for strg in crt_str.splitlines(True): + if strg == '-----BEGIN CERTIFICATE-----\n' and start is False: + crt = '' + start = True + elif strg == '-----END CERTIFICATE-----\n' and start is True: + crt += strg + '\n' + start = False + self.certificates.append(x509.load_pem_x509_certificate(crt.encode(), default_backend())) + count += 1 + if start is True: + crt += strg + + if count == 0: + raise InputError('No certificate found') + + status('Successfully added %d certificates' % count) + + def add_from_der(self, crt_str): + self.certificates.append(x509.load_der_x509_certificate(crt_str, default_backend())) + status('Successfully added 1 certificate') + + def create_bundle(self): + # Sort certificates in order to do binary search when looking up certificates + self.certificates = sorted(self.certificates, key=lambda cert: cert.subject.public_bytes(default_backend())) + + bundle = struct.pack('>H', len(self.certificates)) + + for crt in self.certificates: + """ Read the public key as DER format """ + pub_key = crt.public_key() + pub_key_der = pub_key.public_bytes(serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo) + + """ Read the subject name as DER format """ + sub_name_der = crt.subject.public_bytes(default_backend()) + + name_len = len(sub_name_der) + key_len = len(pub_key_der) + len_data = struct.pack('>HH', name_len, key_len) + + bundle += len_data + bundle += sub_name_der + bundle += pub_key_der + + return bundle + +class InputError(RuntimeError): + def __init__(self, e): + super(InputError, self).__init__(e) + + +def main(): + + bundle = CertificateBundle() + + try: + cert_source = env.GetProjectOption("board_ssl_cert_source") + + if (cert_source == "mozilla" or cert_source == "adafruit"): + download_cacert_file(cert_source) + bundle.add_from_file(os.path.join(certs_dir, "cacert.pem")) + elif (cert_source == "folder"): + bundle.add_from_path(certs_dir) + except ValueError: + critical('Invalid configuration option: use \'board_ssl_cert_source\' parameter in platformio.ini' ) + raise InputError('Invalid certificate') + + status('Successfully added %d certificates in total' % len(bundle.certificates)) + + crt_bundle = bundle.create_bundle() + + # Ensure the directory exists, create it if necessary + os.makedirs(binary_dir, exist_ok=True) + + output_file = os.path.join(binary_dir, ca_bundle_bin_file) + + with open(output_file, 'wb') as f: + f.write(crt_bundle) + + status('Successfully created %s' % output_file) + + +try: + main() +except InputError as e: + print(e) + sys.exit(2) diff --git a/lib/PsychicMqttClient/src/PsychicMqttClient.cpp b/lib/PsychicMqttClient/src/PsychicMqttClient.cpp new file mode 100644 index 0000000000..1f94ad82bc --- /dev/null +++ b/lib/PsychicMqttClient/src/PsychicMqttClient.cpp @@ -0,0 +1,802 @@ +#include "PsychicMqttClient.h" + +#include + +static const char *TAG = "🐙"; + +static void log_error_if_nonzero(const char *message, int error_code) +{ + if (error_code != 0) + { + ESP_LOGE(TAG, "Last error %s: 0x%x", message, error_code); + } +} + +PsychicMqttClient::PsychicMqttClient() : _mqtt_cfg() +{ + memset(&_mqtt_cfg, 0, sizeof(_mqtt_cfg)); + _topic[0] = '\0'; +} + +PsychicMqttClient::~PsychicMqttClient() +{ + disconnect(); + if (_client != nullptr) + { + esp_mqtt_client_destroy(_client); + _client = nullptr; + } + + if (_buffer != nullptr) + { + free(_buffer); + _buffer = nullptr; + _buffer_capacity = 0; + } + // Topic storage is inline; nothing to free. + // Subscription entries have inline topic storage; nothing to free. +} + +PsychicMqttClient &PsychicMqttClient::setKeepAlive(int keepAlive) +{ +#if ESP_IDF_VERSION_MAJOR == 5 + _mqtt_cfg.session.keepalive = keepAlive; +#else + _mqtt_cfg.keepalive = keepAlive; +#endif + _config_dirty = true; + return *this; +} + +PsychicMqttClient &PsychicMqttClient::setAutoReconnect(bool reconnect) +{ +#if ESP_IDF_VERSION_MAJOR == 5 + _mqtt_cfg.network.disable_auto_reconnect = !reconnect; +#else + _mqtt_cfg.disable_auto_reconnect = !reconnect; +#endif + _config_dirty = true; + return *this; +} + +PsychicMqttClient &PsychicMqttClient::setClientId(const char *clientId) +{ +#if ESP_IDF_VERSION_MAJOR == 5 + _mqtt_cfg.credentials.client_id = clientId; +#else + _mqtt_cfg.client_id = clientId; +#endif + _config_dirty = true; + return *this; +} + +PsychicMqttClient &PsychicMqttClient::setCleanSession(bool cleanSession) +{ +#if ESP_IDF_VERSION_MAJOR == 5 + _mqtt_cfg.session.disable_clean_session = !cleanSession; +#else + _mqtt_cfg.disable_clean_session = !cleanSession; +#endif + _config_dirty = true; + return *this; +} + +PsychicMqttClient &PsychicMqttClient::setBufferSize(int bufferSize) +{ +#if ESP_IDF_VERSION_MAJOR == 5 + _mqtt_cfg.buffer.size = bufferSize; +#else + _mqtt_cfg.buffer_size = bufferSize; +#endif + _config_dirty = true; + return *this; +} + +PsychicMqttClient &PsychicMqttClient::setTaskStackAndPriority(int stackSize, int priority) +{ +#if ESP_IDF_VERSION_MAJOR == 5 + _mqtt_cfg.task.stack_size = stackSize; + _mqtt_cfg.task.priority = priority; +#else + _mqtt_cfg.task_stack = stackSize; + _mqtt_cfg.task_prio = priority; +#endif + _config_dirty = true; + return *this; +} + +PsychicMqttClient &PsychicMqttClient::setCACert(const char *rootCA, size_t rootCALen) +{ +#if ESP_IDF_VERSION_MAJOR == 5 + _mqtt_cfg.broker.verification.certificate = rootCA; + _mqtt_cfg.broker.verification.certificate_len = rootCALen; +#else + _mqtt_cfg.cert_pem = rootCA; + _mqtt_cfg.cert_len = rootCALen; +#endif + _config_dirty = true; + return *this; +} + +PsychicMqttClient &PsychicMqttClient::setCACertBundle(const uint8_t *bundle, size_t bundleLen) +{ +#if ESP_IDF_VERSION_MAJOR == 5 + if (bundle != nullptr) + { + esp_crt_bundle_set(bundle, bundleLen); + _mqtt_cfg.broker.verification.crt_bundle_attach = esp_crt_bundle_attach; + } + else + { + esp_crt_bundle_detach(NULL); + _mqtt_cfg.broker.verification.crt_bundle_attach = NULL; + } +#else + if (bundle != nullptr) + { + arduino_esp_crt_bundle_set(bundle); + _mqtt_cfg.crt_bundle_attach = arduino_esp_crt_bundle_attach; + } + else + { + arduino_esp_crt_bundle_detach(NULL); + _mqtt_cfg.crt_bundle_attach = NULL; + } +#endif + _config_dirty = true; + return *this; +} + +PsychicMqttClient &PsychicMqttClient::attachArduinoCACertBundle(bool attach) +{ +#if ESP_IDF_VERSION_MAJOR == 5 + if (attach) + _mqtt_cfg.broker.verification.crt_bundle_attach = esp_crt_bundle_attach; + else + _mqtt_cfg.broker.verification.crt_bundle_attach = NULL; +#else + if (attach) + _mqtt_cfg.crt_bundle_attach = arduino_esp_crt_bundle_attach; + else + _mqtt_cfg.crt_bundle_attach = NULL; +#endif + _config_dirty = true; + return *this; +} + +PsychicMqttClient &PsychicMqttClient::setCredentials(const char *username, const char *password) +{ +#if ESP_IDF_VERSION_MAJOR == 5 + _mqtt_cfg.credentials.username = username; + if (password != nullptr) + _mqtt_cfg.credentials.authentication.password = password; +#else + _mqtt_cfg.username = username; + if (password != nullptr) + _mqtt_cfg.password = password; +#endif + _config_dirty = true; + return *this; +} + +PsychicMqttClient &PsychicMqttClient::setClientCertificate(const char *clientCert, const char *clientKey, + size_t clientCertLen, size_t clientKeyLen) +{ +#if ESP_IDF_VERSION_MAJOR == 5 + _mqtt_cfg.credentials.authentication.certificate = clientCert; + _mqtt_cfg.credentials.authentication.key = clientKey; + _mqtt_cfg.credentials.authentication.certificate_len = clientCertLen; + _mqtt_cfg.credentials.authentication.key_len = clientKeyLen; +#else + _mqtt_cfg.client_cert_pem = clientCert; + _mqtt_cfg.client_key_pem = clientKey; + _mqtt_cfg.client_cert_len = clientCertLen; + _mqtt_cfg.client_key_len = clientKeyLen; +#endif + _config_dirty = true; + return *this; +} + +PsychicMqttClient &PsychicMqttClient::setWill(const char *topic, uint8_t qos, bool retain, const char *payload, int length) +{ +#if ESP_IDF_VERSION_MAJOR == 5 + _mqtt_cfg.session.last_will.topic = topic; + _mqtt_cfg.session.last_will.qos = qos; + _mqtt_cfg.session.last_will.retain = retain; + _mqtt_cfg.session.last_will.msg_len = length; + _mqtt_cfg.session.last_will.msg = payload; +#else + _mqtt_cfg.lwt_topic = topic; + _mqtt_cfg.lwt_qos = qos; + _mqtt_cfg.lwt_retain = retain; + _mqtt_cfg.lwt_msg_len = length; + _mqtt_cfg.lwt_msg = payload; +#endif + _config_dirty = true; + return *this; +} + +PsychicMqttClient &PsychicMqttClient::setServer(const char *uri) +{ +#if ESP_IDF_VERSION_MAJOR == 5 + _mqtt_cfg.broker.address.uri = uri; +#else + _mqtt_cfg.uri = uri; +#endif + _config_dirty = true; + return *this; +} + +PsychicMqttClient &PsychicMqttClient::onConnect(OnConnectUserCallback callback) +{ + if (_onConnectUserCallbackCount < PSYCHIC_MAX_CONNECT_CB) + { + _onConnectUserCallbacks[_onConnectUserCallbackCount++] = std::move(callback); + } + else + { + ESP_LOGE(TAG, "onConnect callback list full (max=%d)", PSYCHIC_MAX_CONNECT_CB); + } + return *this; +} + +PsychicMqttClient &PsychicMqttClient::onDisconnect(OnDisconnectUserCallback callback) +{ + if (_onDisconnectUserCallbackCount < PSYCHIC_MAX_DISCONNECT_CB) + { + _onDisconnectUserCallbacks[_onDisconnectUserCallbackCount++] = std::move(callback); + } + else + { + ESP_LOGE(TAG, "onDisconnect callback list full (max=%d)", PSYCHIC_MAX_DISCONNECT_CB); + } + return *this; +} + +PsychicMqttClient &PsychicMqttClient::onSubscribe(OnSubscribeUserCallback callback) +{ + if (_onSubscribeUserCallbackCount < PSYCHIC_MAX_SUBSCRIBE_CB) + { + _onSubscribeUserCallbacks[_onSubscribeUserCallbackCount++] = std::move(callback); + } + else + { + ESP_LOGE(TAG, "onSubscribe callback list full (max=%d)", PSYCHIC_MAX_SUBSCRIBE_CB); + } + return *this; +} + +PsychicMqttClient &PsychicMqttClient::onUnsubscribe(OnUnsubscribeUserCallback callback) +{ + if (_onUnsubscribeUserCallbackCount < PSYCHIC_MAX_UNSUBSCRIBE_CB) + { + _onUnsubscribeUserCallbacks[_onUnsubscribeUserCallbackCount++] = std::move(callback); + } + else + { + ESP_LOGE(TAG, "onUnsubscribe callback list full (max=%d)", PSYCHIC_MAX_UNSUBSCRIBE_CB); + } + return *this; +} + +PsychicMqttClient &PsychicMqttClient::onMessage(OnMessageUserCallback callback) +{ + if (_onMessageUserCallbackCount < PSYCHIC_MAX_MESSAGE_CB) + { + OnMessageUserCallback_t &slot = _onMessageUserCallbacks[_onMessageUserCallbackCount++]; + slot.topic[0] = '\0'; + slot.qos = 0; + slot.callback = std::move(callback); + slot.has_topic = false; + slot.used = true; + } + else + { + ESP_LOGE(TAG, "onMessage callback list full (max=%d)", PSYCHIC_MAX_MESSAGE_CB); + } + return *this; +} + +PsychicMqttClient &PsychicMqttClient::onTopic(const char *topic, int qos, OnMessageUserCallback callback) +{ + if (_onMessageUserCallbackCount >= PSYCHIC_MAX_MESSAGE_CB) + { + ESP_LOGE(TAG, "onTopic subscription list full (max=%d)", PSYCHIC_MAX_MESSAGE_CB); + return *this; + } + OnMessageUserCallback_t &slot = _onMessageUserCallbacks[_onMessageUserCallbackCount++]; + size_t tlen = strnlen(topic, PSYCHIC_MAX_TOPIC_LEN - 1); + memcpy(slot.topic, topic, tlen); + slot.topic[tlen] = '\0'; + slot.qos = qos; + slot.callback = std::move(callback); + slot.has_topic = true; + slot.used = true; + + if (_connected) + subscribe(slot.topic, qos); + return *this; +} + +PsychicMqttClient &PsychicMqttClient::onPublish(OnPublishUserCallback callback) +{ + if (_onPublishUserCallbackCount < PSYCHIC_MAX_PUBLISH_CB) + { + _onPublishUserCallbacks[_onPublishUserCallbackCount++] = std::move(callback); + } + else + { + ESP_LOGE(TAG, "onPublish callback list full (max=%d)", PSYCHIC_MAX_PUBLISH_CB); + } + return *this; +} + +PsychicMqttClient &PsychicMqttClient::onError(OnErrorUserCallback callback) +{ + if (_onErrorUserCallbackCount < PSYCHIC_MAX_ERROR_CB) + { + _onErrorUserCallbacks[_onErrorUserCallbackCount++] = std::move(callback); + } + else + { + ESP_LOGE(TAG, "onError callback list full (max=%d)", PSYCHIC_MAX_ERROR_CB); + } + return *this; +} + +bool PsychicMqttClient::connected() +{ + return _connected; +} + +void PsychicMqttClient::connect() +{ +#if ESP_IDF_VERSION_MAJOR == 5 + if (_mqtt_cfg.broker.address.uri == nullptr) + { + ESP_LOGE(TAG, "MQTT URI not set."); + return; + } + int desired_buffer = _mqtt_cfg.buffer.size > 0 ? _mqtt_cfg.buffer.size : 1024; +#else + if (_mqtt_cfg.uri == nullptr) + { + ESP_LOGE(TAG, "MQTT URI not set."); + return; + } + int desired_buffer = _mqtt_cfg.buffer_size > 0 ? _mqtt_cfg.buffer_size : 1024; +#endif + + // Lazily size the reassembly buffer once for the client's lifetime. + // Messages larger than this will be rejected rather than triggering + // per-message heap allocation. + if (_buffer == nullptr) + { + _buffer_capacity = (size_t)desired_buffer; + _buffer = (char *)malloc(_buffer_capacity + 1); + if (_buffer == nullptr) + { + ESP_LOGE(TAG, "Failed to allocate reassembly buffer (%u bytes)", (unsigned)_buffer_capacity); + _buffer_capacity = 0; + } + } + + if (_client == nullptr) + { + _client = esp_mqtt_client_init(&_mqtt_cfg); + // Register event handler only once when client is first created + // to avoid memory leak from repeated registrations + esp_mqtt_client_register_event(_client, MQTT_EVENT_ANY, _onMqttEventStatic, this); + _config_dirty = false; + } + else + { + if (_config_dirty) + { + esp_err_t cfg_result = esp_mqtt_set_config(_client, &_mqtt_cfg); + ESP_ERROR_CHECK_WITHOUT_ABORT(cfg_result); + if (cfg_result == ESP_OK) + { + _config_dirty = false; + ESP_LOGD(TAG, "connect(): applied mqtt config update"); + } + else + { + ESP_LOGW(TAG, "connect(): failed to apply mqtt config, will retry"); + } + } + else + { + ESP_LOGD(TAG, "connect(): mqtt config unchanged, skipping set_config"); + } + } + + ESP_ERROR_CHECK_WITHOUT_ABORT(esp_mqtt_client_start(_client)); + ESP_LOGI(TAG, "MQTT client started."); +} + +void PsychicMqttClient::reconnect() +{ + if (_client == nullptr) + { + ESP_LOGW(TAG, "MQTT client not initialized, cannot reconnect."); + return; + } + if (_config_dirty) + { + // Apply config only when mutating setters changed _mqtt_cfg. + esp_err_t cfg_result = esp_mqtt_set_config(_client, &_mqtt_cfg); + ESP_ERROR_CHECK_WITHOUT_ABORT(cfg_result); + if (cfg_result == ESP_OK) + { + _config_dirty = false; + ESP_LOGD(TAG, "reconnect(): applied mqtt config update"); + } + else + { + ESP_LOGW(TAG, "reconnect(): failed to apply mqtt config, reconnecting with previous config"); + } + } + else + { + ESP_LOGD(TAG, "reconnect(): mqtt config unchanged, skipping set_config"); + } + ESP_ERROR_CHECK_WITHOUT_ABORT(esp_mqtt_client_reconnect(_client)); + ESP_LOGI(TAG, "MQTT client reconnect requested."); +} + +void PsychicMqttClient::disconnect() +{ + if (_client == nullptr) + { + ESP_LOGW(TAG, "MQTT client not started."); + return; + } + + if (_connected) + { + ESP_LOGI(TAG, "Disconnecting MQTT client."); + _stopMqttClient = false; + esp_mqtt_client_disconnect(_client); + + // Wait for all disconnect events to be processed + while (!_stopMqttClient) + { + vTaskDelay(10 / portTICK_PERIOD_MS); + } + } + + esp_mqtt_client_stop(_client); + ESP_LOGI(TAG, "MQTT client stopped."); +} + +void PsychicMqttClient::forceStop() +{ + if (_client == nullptr) + { + ESP_LOGW(TAG, "MQTT client not started."); + return; + } + + if (_connected) + { + ESP_LOGI(TAG, "Forced stop MQTT client."); + } + ESP_ERROR_CHECK_WITHOUT_ABORT(esp_mqtt_client_stop(_client)); + _connected = false; + ESP_LOGI(TAG, "MQTT client forcefully stopped."); +} + +int PsychicMqttClient::subscribe(const char *topic, int qos) +{ + if (_connected) + { + ESP_LOGI(TAG, "Subscribing to topic %s with QoS %d", topic, qos); + return esp_mqtt_client_subscribe(_client, topic, qos); + } + else + { + ESP_LOGW(TAG, "MQTT client not connected. Dropping subscription to topic %s with QoS %d.", topic, qos); + return -1; + } +} + +int PsychicMqttClient::unsubscribe(const char *topic) +{ + ESP_LOGI(TAG, "Unsubscribing from topic %s", topic); + return esp_mqtt_client_unsubscribe(_client, topic); +} + +int PsychicMqttClient::publish(const char *topic, int qos, bool retain, const char *payload, int length, bool async) +{ + // drop message if not connected and QoS is 0 + if (!connected() && qos == 0) + { + ESP_LOGW(TAG, "MQTT client not connected. Dropping message with QoS = 0."); + return -1; + } + + if (async) + { + ESP_LOGV(TAG, "Enqueuing message to topic %s with QoS %d", topic, qos); + // Hotfix: restore legacy outbox behavior for QoS0 async publishes. + // This avoids false-failure semantics from enqueue(store=false) on some + // connected paths where packet topics stop flowing. + bool store_in_outbox = true; + return esp_mqtt_client_enqueue(_client, topic, payload, length, qos, retain, store_in_outbox); + } + else + { + ESP_LOGV(TAG, "Publishing message to topic %s with QoS %d", topic, qos); + return esp_mqtt_client_publish(_client, topic, payload, length, qos, retain); + } +} + +const char *PsychicMqttClient::getClientId() +{ +#if ESP_IDF_VERSION_MAJOR == 5 + return _mqtt_cfg.credentials.client_id; +#else + return _mqtt_cfg.client_id; +#endif +} + +esp_mqtt_client_config_t *PsychicMqttClient::getMqttConfig() +{ + return &_mqtt_cfg; +} + +void PsychicMqttClient::_onMqttEventStatic(void *handler_args, esp_event_base_t base, int32_t event_id, void *event_data) +{ + // Since this is a static function, we need to cast the first argument (void*) back to the class instance type + PsychicMqttClient *instance = (PsychicMqttClient *)handler_args; + instance->_onMqttEvent(base, event_id, event_data); +} + +void PsychicMqttClient::_onMqttEvent(esp_event_base_t base, int32_t event_id, void *event_data) +{ + ESP_LOGV(TAG, "Event dispatched from event loop base=%s, event_id=%d", base, event_id); + esp_mqtt_event_handle_t event = (esp_mqtt_event_handle_t)event_data; + switch (event_id) + { + case MQTT_EVENT_CONNECTED: + _connected = true; + _onConnect(event); + break; + case MQTT_EVENT_DISCONNECTED: + _connected = false; + _onDisconnect(event); + break; + case MQTT_EVENT_SUBSCRIBED: + _onSubscribe(event); + break; + case MQTT_EVENT_UNSUBSCRIBED: + _onUnsubscribe(event); + break; + case MQTT_EVENT_PUBLISHED: + _onPublish(event); + break; + case MQTT_EVENT_DATA: + _onMessage(event); + break; + case MQTT_EVENT_ERROR: + _connected = false; + _onError(event); + break; + default: + ESP_LOGI(TAG, "Other event id:%d", event->event_id); + break; + } +} + +void PsychicMqttClient::_onConnect(esp_mqtt_event_handle_t &event) +{ + ESP_LOGI(TAG, "MQTT_EVENT_CONNECTED"); + + // Resubscribe to all registered topics. + for (uint8_t i = 0; i < _onMessageUserCallbackCount; ++i) + { + OnMessageUserCallback_t &sub = _onMessageUserCallbacks[i]; + if (sub.used && sub.has_topic) + subscribe(sub.topic, sub.qos); + } + + for (uint8_t i = 0; i < _onConnectUserCallbackCount; ++i) + { + _onConnectUserCallbacks[i](event->session_present); + } +} + +void PsychicMqttClient::_onDisconnect(esp_mqtt_event_handle_t &event) +{ + ESP_LOGI(TAG, "MQTT_EVENT_DISCONNECTED"); + for (uint8_t i = 0; i < _onDisconnectUserCallbackCount; ++i) + { + _onDisconnectUserCallbacks[i](event->session_present); + } + _stopMqttClient = true; +} + +void PsychicMqttClient::_onSubscribe(esp_mqtt_event_handle_t &event) +{ + ESP_LOGI(TAG, "MQTT_EVENT_SUBSCRIBED, msg_id=%d", event->msg_id); + for (uint8_t i = 0; i < _onSubscribeUserCallbackCount; ++i) + { + _onSubscribeUserCallbacks[i](event->msg_id); + } +} + +void PsychicMqttClient::_onUnsubscribe(esp_mqtt_event_handle_t &event) +{ + ESP_LOGI(TAG, "MQTT_EVENT_UNSUBSCRIBED, msg_id=%d", event->msg_id); + for (uint8_t i = 0; i < _onUnsubscribeUserCallbackCount; ++i) + { + _onUnsubscribeUserCallbacks[i](event->msg_id); + } +} + +void PsychicMqttClient::_onMessage(esp_mqtt_event_handle_t &event) +{ + // Single-message path: payload fits in the event. No heap use. + if (event->total_data_len == event->data_len) + { + ESP_LOGV(TAG, "MQTT_EVENT_DATA_SINGLE"); + char payload[event->data_len + 1]; + memcpy(payload, event->data, event->data_len); + payload[event->data_len] = '\0'; + + size_t tlen = (size_t)event->topic_len; + if (tlen >= sizeof(_topic)) tlen = sizeof(_topic) - 1; + char topic[sizeof(_topic)]; + memcpy(topic, event->topic, tlen); + topic[tlen] = '\0'; + + for (uint8_t i = 0; i < _onMessageUserCallbackCount; ++i) + { + OnMessageUserCallback_t &cb = _onMessageUserCallbacks[i]; + if (!cb.used) continue; + if (!cb.has_topic || _isTopicMatch(topic, cb.topic)) + { + cb.callback(topic, payload, event->retain, event->qos, event->dup); + } + } + return; + } + + // Multipart first chunk: remember the topic, begin reassembly in _buffer. + if (event->current_data_offset == 0) + { + ESP_LOGV(TAG, "MQTT_EVENT_DATA_MULTIPART_FIRST"); + if (_buffer == nullptr) + { + ESP_LOGE(TAG, "multipart message but no reassembly buffer allocated"); + return; + } + if ((size_t)event->total_data_len > _buffer_capacity) + { + ESP_LOGE(TAG, "multipart message size %d exceeds reassembly buffer %u", event->total_data_len, (unsigned)_buffer_capacity); + return; + } + memcpy(_buffer, event->data, event->data_len); + + size_t tlen = (size_t)event->topic_len; + if (tlen >= sizeof(_topic)) tlen = sizeof(_topic) - 1; + memcpy(_topic, event->topic, tlen); + _topic[tlen] = '\0'; + return; + } + + // Multipart final chunk: finalize and dispatch. + if (event->current_data_offset + event->data_len == event->total_data_len) + { + ESP_LOGV(TAG, "MQTT_EVENT_DATA_MULTIPART_LAST"); + if (_buffer == nullptr) return; + if ((size_t)(event->current_data_offset + event->data_len) > _buffer_capacity) return; + memcpy(_buffer + event->current_data_offset, event->data, event->data_len); + _buffer[event->total_data_len] = '\0'; + + for (uint8_t i = 0; i < _onMessageUserCallbackCount; ++i) + { + OnMessageUserCallback_t &cb = _onMessageUserCallbacks[i]; + if (!cb.used) continue; + if (!cb.has_topic || _isTopicMatch(_topic, cb.topic)) + { + cb.callback(_topic, _buffer, event->retain, event->qos, event->dup); + } + } + return; + } + + // Multipart middle chunk: copy into _buffer at the correct offset. + if (_buffer == nullptr) return; + if ((size_t)(event->current_data_offset + event->data_len) > _buffer_capacity) return; + memcpy(_buffer + event->current_data_offset, event->data, event->data_len); + ESP_LOGV(TAG, "MQTT_EVENT_DATA_MULTIPART"); +} + +void PsychicMqttClient::_onPublish(esp_mqtt_event_handle_t &event) +{ + ESP_LOGI(TAG, "MQTT_EVENT_PUBLISHED, msg_id=%d", event->msg_id); + for (uint8_t i = 0; i < _onPublishUserCallbackCount; ++i) + { + _onPublishUserCallbacks[i](event->msg_id); + } +} + +void PsychicMqttClient::_onError(esp_mqtt_event_handle_t &event) +{ + ESP_LOGI(TAG, "MQTT_EVENT_ERROR"); + if (event->error_handle->error_type == MQTT_ERROR_TYPE_TCP_TRANSPORT) + { + log_error_if_nonzero("reported from esp-tls", event->error_handle->esp_tls_last_esp_err); + log_error_if_nonzero("reported from tls stack", event->error_handle->esp_tls_stack_err); + log_error_if_nonzero("captured as transport's socket errno", event->error_handle->esp_transport_sock_errno); + ESP_LOGI(TAG, "Last errno string (%s)", strerror(event->error_handle->esp_transport_sock_errno)); + + for (uint8_t i = 0; i < _onErrorUserCallbackCount; ++i) + { + _onErrorUserCallbacks[i](*event->error_handle); + } + } +} + +// Zero-allocation MQTT topic/subscription match. Supports '+' single-level +// wildcard and trailing '#' multi-level wildcard per MQTT 3.1.1 spec. +bool PsychicMqttClient::_isTopicMatch(const char *topic, const char *subscription) +{ + if (topic == nullptr || subscription == nullptr) return false; + + const char *t = topic; + const char *s = subscription; + + for (;;) + { + // '#' at the start of a level matches everything remaining. + if (s[0] == '#' && s[1] == '\0') + return true; + + // Find end of the current level in both strings. + const char *t_end = strchr(t, '/'); + const char *s_end = strchr(s, '/'); + size_t t_len = t_end ? (size_t)(t_end - t) : strlen(t); + size_t s_len = s_end ? (size_t)(s_end - s) : strlen(s); + + bool level_matches; + if (s_len == 1 && s[0] == '+') + { + // '+' matches exactly one level (any content, including empty). + level_matches = true; + } + else + { + level_matches = (t_len == s_len) && (memcmp(t, s, t_len) == 0); + } + if (!level_matches) + return false; + + // Advance past this level. + bool t_done = (t_end == nullptr); + bool s_done = (s_end == nullptr); + + if (t_done && s_done) + return true; + + if (s_done) + { + // Subscription ended but topic has more levels → no match, + // unless the subscription ended with '+' and topic also has no + // more levels (already handled above). + return false; + } + + if (t_done) + { + // Topic ended but subscription has more levels. Only matches if + // the remainder is exactly '#'. + return (s_end[1] == '#' && s_end[2] == '\0'); + } + + t = t_end + 1; + s = s_end + 1; + } +} diff --git a/lib/PsychicMqttClient/src/PsychicMqttClient.h b/lib/PsychicMqttClient/src/PsychicMqttClient.h new file mode 100644 index 0000000000..80961d22a8 --- /dev/null +++ b/lib/PsychicMqttClient/src/PsychicMqttClient.h @@ -0,0 +1,452 @@ +#pragma once +/** + * PsychicMqttClient + * + * Fully featured async MQTT 3.1.1 client for ESP32 with support for SSL/TLS + * and MQTT over WS. Uses the ESP-IDF MQTT client library under the hood and + * adds a powerful but easy to use API on top of it. Supports MQTT over TCP, + * SSL with mbedtls, MQTT over Websocket and MQTT over Websocket Secure. + * https://github.com/theelims/PsychicMqttClient + * + * MIT License + * + * Copyright (c) 2024 elims + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +#include + +#include "Arduino.h" +#include "mqtt_client.h" +#include "esp_crt_bundle.h" + +#define PSYCHIC_MQTT_CLIENT_VERSION_STR "0.2.2" +#define PSYCHIC_MQTT_CLIENT_VERSION_MAJOR 0 +#define PSYCHIC_MQTT_CLIENT_VERSION_MINOR 2 +#define PSYCHIC_MQTT_CLIENT_VERSION_PATCH 2 + +#ifndef ARDUINO_ARCH_ESP32 +#error "This library only supports boards with an ESP32 processor." +#endif + +// user callbacks +typedef std::function OnConnectUserCallback; +typedef std::function OnDisconnectUserCallback; +typedef std::function OnSubscribeUserCallback; +typedef std::function OnUnsubscribeUserCallback; +typedef std::function OnMessageUserCallback; +typedef std::function OnPublishUserCallback; +typedef std::function OnErrorUserCallback; + +// Fixed caps sized for MeshCore's usage patterns. All callback and subscription +// storage is inline in the client object — zero dynamic allocation on register. +#ifndef PSYCHIC_MAX_CONNECT_CB +#define PSYCHIC_MAX_CONNECT_CB 4 +#endif +#ifndef PSYCHIC_MAX_DISCONNECT_CB +#define PSYCHIC_MAX_DISCONNECT_CB 4 +#endif +#ifndef PSYCHIC_MAX_SUBSCRIBE_CB +#define PSYCHIC_MAX_SUBSCRIBE_CB 2 +#endif +#ifndef PSYCHIC_MAX_UNSUBSCRIBE_CB +#define PSYCHIC_MAX_UNSUBSCRIBE_CB 2 +#endif +#ifndef PSYCHIC_MAX_MESSAGE_CB +#define PSYCHIC_MAX_MESSAGE_CB 4 +#endif +#ifndef PSYCHIC_MAX_PUBLISH_CB +#define PSYCHIC_MAX_PUBLISH_CB 2 +#endif +#ifndef PSYCHIC_MAX_ERROR_CB +#define PSYCHIC_MAX_ERROR_CB 4 +#endif +#ifndef PSYCHIC_MAX_TOPIC_LEN +#define PSYCHIC_MAX_TOPIC_LEN 128 +#endif + +typedef struct +{ + char topic[PSYCHIC_MAX_TOPIC_LEN]; + int qos; + OnMessageUserCallback callback; + bool has_topic; // false = match all topics (onMessage) + bool used; +} OnMessageUserCallback_t; + +/** + * @class PsychicMqttClient + * @brief A class that wraps the ESP-IDF MQTT client and provides a more user friendly interface. + * The API is very similar to AsyncMqttClient for the ESP32 by Marvin Roger, so that this library can + * be used as a almost drop-in replacement. + */ +class PsychicMqttClient +{ +public: + /** + * @brief Constructs a new instance of the PsychicMqttClient class. + */ + PsychicMqttClient(); + + /** + * @brief Destroys the instance of the PsychicMqttClient class. + */ + ~PsychicMqttClient(); + + /** + * @brief Sets the keep alive interval in seconds for the MQTT connection. + * + * @param keepAlive The keep alive interval in seconds. Defaults to 120. + * @return A reference to the PsychicMqttClient instance. + */ + PsychicMqttClient &setKeepAlive(int keepAlive = 120); + + /** + * @brief Sets the auto reconnect flag for the MQTT connection. + * + * @param reconnect The auto reconnect flag. Defaults to true. + * @return A reference to the PsychicMqttClient instance. + */ + PsychicMqttClient &setAutoReconnect(bool reconnect = true); + + /** + * @brief Sets the client ID for the MQTT connection. + * + * @param clientId The client ID. Defaults to ESP32_%CHIPID% where + * %CHIPID% are the last 3 bytes of MAC address in hex format. + * @return A reference to the PsychicMqttClient instance. + */ + PsychicMqttClient &setClientId(const char *clientId); + + /** + * @brief Sets the clean session flag for the MQTT connection. + * + * @param cleanSession The clean session flag. Defaults to true. + * @return A reference to the PsychicMqttClient instance. + */ + PsychicMqttClient &setCleanSession(bool cleanSession = true); + + /** + * @brief Sets the size for the MQTT send/receive buffer. If messages exceed + * the buffer size, the message will be split into multiple chunks. Received m + * essages will be assembled into the original message. + * + * @param bufferSize The buffer size in bytes. Defaults to 1024. + * @return A reference to the PsychicMqttClient instance. + */ + PsychicMqttClient &setBufferSize(int bufferSize = 1024); + + /** + * @brief Sets the task stack size and priority for the MQTT client task. + * + * @param stackSize The task stack size in bytes. Defaults to 6144. + * @param prio The task priority. Defaults to 5. + * @return A reference to the PsychicMqttClient instance. + */ + PsychicMqttClient &setTaskStackAndPriority(int stackSize, int prio); + + /** + * @brief Sets the CA root certificate for the MQTT server. + * + * @param cert The certificate in PEM or DER format. + * @param certLen optional length of the certificate shouldn't cert be null-terminated. + * @return A reference to the PsychicMqttClient instance. + */ + PsychicMqttClient &setCACert(const char *rootCA, size_t rootCALen = 0); + + /** + * @brief Sets a CA root certificate bundle for the MQTT server. Use this + * method if you have multiple CA root certificates and this is the only place + * using SSL/TLS. Otherwise use attachArduinoCACertBundle() to attach an existing + * certificate bundle. + * + * @param bundle The certificate bundle in PEM or DER format. + * @return A reference to the PsychicMqttClient instance. + */ + PsychicMqttClient &setCACertBundle(const uint8_t *bundle, size_t bundleLen = 0); + + /** + * @brief Attaches an existing CA root certificate bundle for the MQTT server. Like if you + * already use WiFiClientSecure and want to use the same CA root certificate bundle for MQTT. + * + * @param attach Whether to attach or detach the CA root certificate bundle. Defaults to true. + * @return A reference to the PsychicMqttClient instance. + */ + PsychicMqttClient &attachArduinoCACertBundle(bool attach = true); + + /** + * @brief Sets the credentials for the MQTT connection. + * + * @param username The username for the MQTT connection. Defaults to nullptr. + * @param password The password for the MQTT connection. Defaults to nullptr. + * @return A reference to the PsychicMqttClient instance. + */ + PsychicMqttClient &setCredentials(const char *username, const char *password = nullptr); + + /** + * @brief Authenticate via mutual X.509 certificate. + * + * @param clientCert The X.509 certificate issued from the same CA as the server cert. + * @param clientKey The private key for the X.509 certificate. + * @param clientCertLen Optional length of the client certificate. If the certificate is not null-terminated. + * @param clientKeyLen Optional length of the client key. If the key is not null-terminated. + * @return A reference to the PsychicMqttClient instance. + */ + PsychicMqttClient &setClientCertificate(const char *clientCert, const char *clientKey, size_t clientCertLen = 0, + size_t clientKeyLen = 0); + + /** + * @brief Sets the last will and testament for the MQTT connection. + * + * @param topic The topic for the last will and testament. + * @param qos The QoS level for the last will and testament. + * @param retain The retain flag for the last will and testament. + * @param payload The payload for the last will and testament. Defaults to nullptr. + * @param length The length of the payload for the last will and testament. Defaults to 0. + * @return A reference to the PsychicMqttClient instance. + */ + PsychicMqttClient &setWill(const char *topic, uint8_t qos, bool retain, const char *payload = nullptr, int length = 0); + + /** + * @brief Sets the MQTT server URI. Supports mqtt://, mqtts:// and ws://, wss:// as + * transport protocols. Fully supports SSL/TLS. + * + * Example: mqtt://162.168.10.1 + * mqtt://mqtt.eclipseprojects.io + * mqtt://mqtt.eclipseprojects.io:1884 + * mqtt://username:password@mqtt.eclipseprojects.io:1884 + * mqtts://mqtt.eclipseprojects.io + * mqtts://mqtt.eclipseprojects.io:8884 + * ws://mqtt.eclipseprojects.io:80/mqtt + * wss://mqtt.eclipseprojects.io:443/mqtt + * + * @param uri The MQTT server URI. + * @return A reference to the PsychicMqttClient instance. + */ + PsychicMqttClient &setServer(const char *uri); + + /** + * @brief Registers a callback function to be called when the MQTT client is connected. + * + * @param callback The callback function with the signature void(bool sessionPresent) to be registered. + * @return A reference to the PsychicMqttClient instance. + */ + PsychicMqttClient &onConnect(OnConnectUserCallback callback); + + /** + * @brief Registers a callback function to be called when the MQTT client is disconnected. + * + * @param callback The callback function with the signature void(bool sessionPresent) to + * be registered. + * @return A reference to the PsychicMqttClient instance. + */ + PsychicMqttClient &onDisconnect(OnDisconnectUserCallback callback); + + /** + * @brief Registers a callback function to be called when a topic is subscribed. + * + * @param callback The callback function with the signature void(int msgId) to be registered. + * @return A reference to the PsychicMqttClient instance. + */ + PsychicMqttClient &onSubscribe(OnSubscribeUserCallback callback); + + /** + * @brief Registers a callback function to be called when a topic is unsubscribed. + * + * @param callback The callback function with the signature void(int msgId) to be registered. + * @return A reference to the PsychicMqttClient instance. + */ + PsychicMqttClient &onUnsubscribe(OnUnsubscribeUserCallback callback); + + /** + * @brief Registers a callback function to be called when a message is received. + * Multipart messages will be reassembled into the original message. + * + * @param callback The callback function with the signature void(char *topic, + * char *payload, int msgId, int retain, int qos, bool dup) to be registered. + * @return A reference to the PsychicMqttClient instance. + */ + PsychicMqttClient &onMessage(OnMessageUserCallback callback); + + /** + * @brief Registers a callback function to be called when a message is + * received on a specific topic. Multipart messages will be + * reassembled into the original message. Fully supports MQTT Wildcards. + * Will automatically subscribe to all topics once the client is connected. + * + * @param topic The topic to listen for. MQTT Wildcards are fully supported. + * @param qos The QoS level to listen for. + * @param callback The callback function with the signature void(char *topic, + * char *payload, int msgId, int retain, int qos, bool dup) to be registered. + * @return A reference to the PsychicMqttClient instance. + */ + PsychicMqttClient &onTopic(const char *topic, int qos, OnMessageUserCallback callback); + + /** + * @brief Registers a callback function to be called when a message is published. + * + * @param callback The callback function with the signature void(int msgId) to be registered. + * @return A reference to the PsychicMqttClient instance. + */ + PsychicMqttClient &onPublish(OnPublishUserCallback callback); + + /** + * @brief Registers a callback function to be called when an error occurs. + * + * @param callback The callback function with the signature void(esp_mqtt_error_codes_t error) to be registered. + * @return A reference to the PsychicMqttClient instance. + */ + PsychicMqttClient &onError(OnErrorUserCallback callback); + + /** + * @brief Checks if the MQTT client is connected. + * + * @return True if the client is connected, false otherwise. + */ + bool connected(); + + /** + * @brief Connects the MQTT client to the server. + * + * @note All parameters must be set before calling this method. + */ + void connect(); + + /** + * @brief Reconnects a previously started MQTT client. + * + * Uses esp_mqtt_client_reconnect() which is the correct API for + * re-initiating a connection on an already-started client, especially + * when auto-reconnect is disabled. Updates config before reconnecting + * so credential changes (e.g., refreshed JWT tokens) take effect. + */ + void reconnect(); + + /** + * @brief Disconnects the MQTT client from the server. + * This call might be blocking until the client is stopped cleanly + */ + void disconnect(); + + /** + * @brief Forcefully stops the MQTT client and disconnects from the server. + * This does not trigger the onDisconnect callbacks. + */ + void forceStop(); + + /** + * @brief Subscribes to a topic. Server must be connected + * for a subscription to succeed. + * + * @param topic The topic to subscribe to. + * @param qos The QoS level for the subscription. + * @return Message ID on success, -1 on failure. + */ + int subscribe(const char *topic, int qos); + + /** + * @brief Unsubscribes from a topic. Server must be connected + * for an unsubscription to succeed. + * + * @param topic The topic to unsubscribe from. + * @return Message ID on success, -1 on failure. + */ + int unsubscribe(const char *topic); + + /** + * @brief Publishes a message to a topic. + * + * @param topic The topic to publish to. + * @param qos The QoS level (0-2) for the message. + * @param retain The retain flag for the message. + * @param payload The payload for the message. Defaults to nullptr. + * @param length The length of the payload. Defaults to 0. + * @param async Whether to enqueue the message for asynchronous publishing. + * Defaults to true. False means blocking until the message is published. + * @return Message ID on success, -1 on failure. + */ + int publish(const char *topic, int qos, bool retain, const char *payload = nullptr, int length = 0, bool async = true); + + /** + * @brief Gets the client ID of the MQTT client. + * + * @return The client ID. + */ + const char *getClientId(); + + /** + * @brief Returns the ESP-IDF MQTT client config object of the MQTT client in case + * lower level access is needed. + * + * @return The config object. + */ + esp_mqtt_client_config_t *getMqttConfig(); + +private: + esp_mqtt_client_handle_t _client = nullptr; + esp_mqtt_client_config_t _mqtt_cfg; + esp_mqtt_error_codes_t _lastError; + bool _connected = false; + bool _stopMqttClient = false; + bool _config_dirty = true; + + // Multipart message reassembly. _buffer is lazily allocated at connect() time + // to match the configured buffer size, then reused for the client's lifetime. + // _topic is inline storage, never heap-allocated. + char *_buffer = nullptr; + size_t _buffer_capacity = 0; + char _topic[PSYCHIC_MAX_TOPIC_LEN]; + + static void _onMqttEventStatic(void *handler_args, esp_event_base_t base, int32_t event_id, void *event_data); + void _onMqttEvent(esp_event_base_t base, int32_t event_id, void *event_data); + bool _isTopicMatch(const char *topic, const char *subscription); + + // Fixed-size callback storage. Each slot is "used" once registered; we never + // unregister so we only ever append. Iteration uses the count, not sizeof. + OnConnectUserCallback _onConnectUserCallbacks[PSYCHIC_MAX_CONNECT_CB]; + uint8_t _onConnectUserCallbackCount = 0; + + OnDisconnectUserCallback _onDisconnectUserCallbacks[PSYCHIC_MAX_DISCONNECT_CB]; + uint8_t _onDisconnectUserCallbackCount = 0; + + OnSubscribeUserCallback _onSubscribeUserCallbacks[PSYCHIC_MAX_SUBSCRIBE_CB]; + uint8_t _onSubscribeUserCallbackCount = 0; + + OnUnsubscribeUserCallback _onUnsubscribeUserCallbacks[PSYCHIC_MAX_UNSUBSCRIBE_CB]; + uint8_t _onUnsubscribeUserCallbackCount = 0; + + OnMessageUserCallback_t _onMessageUserCallbacks[PSYCHIC_MAX_MESSAGE_CB]; + uint8_t _onMessageUserCallbackCount = 0; + + OnPublishUserCallback _onPublishUserCallbacks[PSYCHIC_MAX_PUBLISH_CB]; + uint8_t _onPublishUserCallbackCount = 0; + + OnErrorUserCallback _onErrorUserCallbacks[PSYCHIC_MAX_ERROR_CB]; + uint8_t _onErrorUserCallbackCount = 0; + + void _onBeforeConnect(esp_mqtt_event_handle_t &event_data, esp_mqtt_client_handle_t &client); + void _onConnect(esp_mqtt_event_handle_t &event_data); + void _onDisconnect(esp_mqtt_event_handle_t &event_data); + void _onSubscribe(esp_mqtt_event_handle_t &event_data); + void _onUnsubscribe(esp_mqtt_event_handle_t &event_data); + void _onMessage(esp_mqtt_event_handle_t &event_data); + void _onPublish(esp_mqtt_event_handle_t &event_data); + void _onError(esp_mqtt_event_handle_t &event_data); +}; diff --git a/lib/PsychicMqttClient/src/certs/x509_crt_bundle.bin b/lib/PsychicMqttClient/src/certs/x509_crt_bundle.bin new file mode 100644 index 0000000000..7eadfe440d Binary files /dev/null and b/lib/PsychicMqttClient/src/certs/x509_crt_bundle.bin differ diff --git a/platformio.ini b/platformio.ini index 864e5e1ffe..00bb1ab941 100644 --- a/platformio.ini +++ b/platformio.ini @@ -92,6 +92,7 @@ build_flags = ${arduino_base.build_flags} lib_deps = ${arduino_base.lib_deps} https://github.com/oltaco/CustomLFS @ 0.2.1 +lib_ignore = PsychicMqttClient ; ----------------- RP2040 --------------------- [rp2040_base] @@ -101,6 +102,7 @@ board_build.core = earlephilhower platform = https://github.com/maxgerhardt/platform-raspberrypi.git build_flags = ${arduino_base.build_flags} -D RP2040_PLATFORM +lib_ignore = PsychicMqttClient ; ----------------- STM32 ---------------------- @@ -117,6 +119,7 @@ build_src_filter = ${arduino_base.build_src_filter} lib_deps = ${arduino_base.lib_deps} file://arch/stm32/Adafruit_LittleFS_stm32 adafruit/Adafruit BusIO @ 1.17.2 +lib_ignore = PsychicMqttClient [sensor_base] build_flags = diff --git a/readme_ev.md b/readme_ev.md new file mode 100644 index 0000000000..4834c995cd --- /dev/null +++ b/readme_ev.md @@ -0,0 +1,64 @@ +# DutchMeshCore Observer Firmware — Changes vs Main + +> **⚠ HIGHLY EXPERIMENTAL** — This branch is based on `mqtt-bridge-implementation-flex-lwt`, which is itself an active development branch not yet merged into main. The MQTT observer feature, and the extended device support added here, should be considered experimental. Expect bugs, breaking changes, and incomplete testing across hardware. Use in production at your own risk. + +This branch customises the default MQTT configuration for the [dutchmeshcore.nl](https://dutchmeshcore.nl) observer network. Observers only need to configure WiFi credentials and their IATA code after flashing. + +## Changes + +### 1. Default MQTT broker presets replaced (`src/helpers/MQTTPresets.h`) + +The first two built-in preset slots previously pointed to the LetsMesh Analyzer (US and EU). They have been replaced with the DutchMeshCore collectors: + +| Slot | Old preset | New preset | +|------|-----------|------------| +| 1 | `analyzer-us` — `wss://mqtt-us-v1.letsmesh.net:443/mqtt` | `dutchmeshcore-1` — `wss://collector1.dutchmeshcore.nl:443` | +| 2 | `analyzer-eu` — `wss://mqtt-eu-v1.letsmesh.net:443/mqtt` | `dutchmeshcore-2` — `wss://collector2.dutchmeshcore.nl:443` | + +Both use JWT authentication (Ed25519 device identity) and the ISRG Root X1 (Let's Encrypt) CA certificate. + +**Why slots 1 and 2 specifically:** non-PSRAM devices are limited to 2 active MQTT connections at runtime. By placing the DutchMeshCore collectors in the first two slots they are guaranteed to be active on all supported hardware, including lower-cost boards without PSRAM. + +### 2. Default TX mode changed to `on` (`examples/simple_repeater/MyMesh.cpp`, `src/helpers/CommonCLI.cpp`) + +`mqtt_tx_enabled` default changed from `2` (own adverts only) to `1` (all TX packets). + +| Setting | Old default | New default | +|---------|------------|-------------| +| `mqtt.tx` | `advert` | `on` | +| `mqtt.rx` | `on` | `on` (unchanged) | +| `bridge.enabled` | `on` | `on` (unchanged) | + +**Why:** observers should forward all traffic — both received (RX) and transmitted (TX) packets — to give the collectors a complete view of mesh activity. + +### 3. MQTT observer envs added to all eligible ESP32 devices + +The original upstream branch had MQTT observer builds for 10 devices. This branch extends that to 30 devices by adding `*_observer_mqtt` environments to every remaining ESP32 and ESP32-C6 based variant. NRF52, RP2040, and STM32 boards were intentionally excluded — they have no WiFi and cannot connect to an MQTT broker. + +All new observer envs follow the same pattern as the existing V3/V4 reference builds: + +| Feature | Value | +|---------|-------| +| `MQTT_DEBUG` | enabled | +| `MQTT_MEMORY_DEBUG` | enabled (repeater envs) | +| `MQTT_WIFI_TX_POWER` | `WIFI_POWER_11dBm` — conservative TX power to avoid LoRa interference | +| `WITH_SNMP` | enabled (repeater envs) | +| `board_ssl_cert_source` | `adafruit` | +| WiFi credential block | commented-out `# -D WIFI_SSID/WIFI_PWD` lines in build_flags | + +New variants added (20 devices, both repeater and room-server observer envs where applicable): + +`ebyte_eora_s3`, `generic-e22` (sx1262 + sx1268), `heltec_ct62`, `heltec_tracker`, `heltec_tracker_v2`, `heltec_v2`, `heltec_wireless_paper`, `lilygo_t3s3_sx1276`, `lilygo_tbeam_1w`, `lilygo_tdeck`, `lilygo_tlora_c6`, `lilygo_tlora_v2_1`, `m5stack_unit_c6l`, `meshadventurer`, `nibble_screen_connect`, `tenstar_c3`, `thinknode_m2`, `thinknode_m5`, `xiao_c3`, `xiao_c6` + +> **These additional device builds are highly experimental and untested.** They follow the same pattern as the existing observer envs but have not been verified on real hardware. ESP32-C6 and ESP32-C3 variants (`lilygo_tlora_c6`, `m5stack_unit_c6l`, `xiao_c6`, `tenstar_c3`, `xiao_c3`) carry extra risk as the C6/C3 platform support is itself marked experimental upstream. + +## What observers need to configure after flashing + +1. `set wifi.ssid ` +2. `set wifi.password ` +3. `set mqtt.iata ` +Only when updating a repeater/roomserver that's already set up with mqtt you do step 4&5: +4. `set mqtt1.preset dutchmeshcore-1` +5. `set mqtt2.preset dutchmeshcore-2` + +Everything else is pre-configured by this firmware. diff --git a/scripts/fix_observer_envs.py b/scripts/fix_observer_envs.py new file mode 100644 index 0000000000..bffbe54512 --- /dev/null +++ b/scripts/fix_observer_envs.py @@ -0,0 +1,187 @@ +""" +Fix new _observer_mqtt envs to match the V3/V4 reference pattern. +Run from the repo root or anywhere; uses absolute paths. +""" +import re, os + +VARIANTS_DIR = r"C:\MeshCore-MQTT\variants" + +VARIANTS = [ + "ebyte_eora_s3", + "generic-e22", + "heltec_ct62", + "heltec_tracker", + "heltec_tracker_v2", + "heltec_v2", + "heltec_wireless_paper", + "lilygo_t3s3_sx1276", + "lilygo_tbeam_1w", + "lilygo_tdeck", + "lilygo_tlora_c6", + "lilygo_tlora_v2_1", + "m5stack_unit_c6l", + "meshadventurer", + "nibble_screen_connect", + "tenstar_c3", + "thinknode_m2", + "thinknode_m5", + "xiao_c3", + "xiao_c6", +] + +WIFI_CRED_BLOCK = ( + "# -D WIFI_SSID='\"ssid\"'\n" + "# -D WIFI_PWD='\"password\"'\n" + "# -D MQTT_SERVER='\"your-mqtt-broker.com\"'\n" + "# -D MQTT_PORT=1883\n" + "# -D MQTT_USERNAME='\"your-username\"'\n" + "# -D MQTT_PASSWORD='\"your-password\"'" +) + + +def process_section(name: str, body: str) -> str: + """Apply all fixes to a single env section body.""" + if not name.endswith("_observer_mqtt"): + return body + + is_room = "room_server" in name + + # 1. Fix cert source + body = body.replace( + "board_ssl_cert_source = adafruit-full", + "board_ssl_cert_source = adafruit", + ) + + if not is_room: + # Repeater: fix debug block (commented MQTT_DEBUG + MESH lines) + # Pattern A: MQTT_DEBUG + MESH lines already there + body = re.sub( + r"; -D MQTT_DEBUG=1\n; -D MESH_PACKET_LOGGING=1\n; -D MESH_DEBUG=1", + ( + " -D MQTT_DEBUG=1\n" + " -D MQTT_MEMORY_DEBUG=1\n" + "; Keep default observer profile less verbose to reduce runtime contention.\n" + "; -D MESH_PACKET_LOGGING=1\n" + "; -D MESH_DEBUG=1" + ), + body, + ) + + # Insert MQTT_WIFI_TX_POWER + WITH_SNMP + WiFi block + if " -D ESP32_CPU_FREQ=160\n" in body: + body = body.replace( + " -D ESP32_CPU_FREQ=160\n", + ( + " -D ESP32_CPU_FREQ=160\n" + " -D MQTT_WIFI_TX_POWER=WIFI_POWER_11dBm\n" + " -D WITH_SNMP=1\n" + + WIFI_CRED_BLOCK + "\n" + ), + 1, + ) + elif " -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y\n" in body: + body = body.replace( + " -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y\n", + ( + " -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y\n" + " -D MQTT_WIFI_TX_POWER=WIFI_POWER_11dBm\n" + " -D WITH_SNMP=1\n" + + WIFI_CRED_BLOCK + "\n" + ), + 1, + ) + + # Add SNMPAgent right after JWTHelper in build_src_filter + body = body.replace( + " +\n", + " +\n +\n", + 1, + ) + + # Add SNMP_Agent lib before paulstoffregen/Time + body = body.replace( + " paulstoffregen/Time@1.6.1", + " 0neblock/SNMP_Agent\n paulstoffregen/Time@1.6.1", + 1, + ) + + else: + # Room server: fix MQTT_DEBUG and optionally add MESH debug lines + + # Case: no MESH debug lines — MQTT_DEBUG immediately before CONFIG_MBEDTLS + body = re.sub( + r"; -D MQTT_DEBUG=1\n -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y", + ( + " -D MQTT_DEBUG=1\n" + "; -D MESH_PACKET_LOGGING=1\n" + "; -D MESH_DEBUG=1\n" + " -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y" + ), + body, + ) + + # Case: MESH debug lines already there but MQTT_DEBUG still commented + body = re.sub( + r"; -D MQTT_DEBUG=1\n; -D MESH_PACKET_LOGGING=1\n; -D MESH_DEBUG=1", + ( + " -D MQTT_DEBUG=1\n" + "; -D MESH_PACKET_LOGGING=1\n" + "; -D MESH_DEBUG=1" + ), + body, + ) + + # Insert MQTT_WIFI_TX_POWER + if " -D ESP32_CPU_FREQ=160\n" in body: + body = body.replace( + " -D ESP32_CPU_FREQ=160\n", + " -D ESP32_CPU_FREQ=160\n -D MQTT_WIFI_TX_POWER=WIFI_POWER_11dBm\n", + 1, + ) + elif " -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y\n" in body: + body = body.replace( + " -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y\n", + " -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y\n -D MQTT_WIFI_TX_POWER=WIFI_POWER_11dBm\n", + 1, + ) + + return body + + +def process_file(filepath: str): + with open(filepath, "r", newline="") as f: + original = f.read() + + # Split file into sections by [env:...] boundaries, preserving headers + # Pattern: split just before [env: keeping the delimiter + parts = re.split(r"(?=^\[env:)", original, flags=re.MULTILINE) + + result_parts = [] + for part in parts: + m = re.match(r"^\[env:([^\]]+)\]", part) + if m: + env_name = m.group(1) + new_part = process_section(env_name, part) + result_parts.append(new_part) + else: + result_parts.append(part) + + new_content = "".join(result_parts) + + if new_content != original: + with open(filepath, "w", newline="") as f: + f.write(new_content) + print(f" UPDATED: {os.path.basename(os.path.dirname(filepath))}") + else: + print(f" no change: {os.path.basename(os.path.dirname(filepath))}") + + +print("Fixing observer MQTT envs...") +for variant in VARIANTS: + fp = os.path.join(VARIANTS_DIR, variant, "platformio.ini") + if os.path.exists(fp): + process_file(fp) + else: + print(f" NOT FOUND: {fp}") + +print("Done.") diff --git a/scripts/generate_cert_bundle.py b/scripts/generate_cert_bundle.py new file mode 100644 index 0000000000..1584cc686e --- /dev/null +++ b/scripts/generate_cert_bundle.py @@ -0,0 +1,231 @@ +#!/usr/bin/env python +# +# modified ESP32 x509 certificate bundle generation utility to run with platformio +# +# Converts PEM and DER certificates to a custom bundle format which stores just the +# subject name and public key to reduce space +# +# The bundle will have the format: number of certificates; crt 1 subject name length; crt 1 public key length; +# crt 1 subject name; crt 1 public key; crt 2... +# +# SPDX-FileCopyrightText: 2018-2022 Espressif Systems (Shanghai) CO LTD +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import with_statement + +from pathlib import Path +import os +import struct +import sys +import requests +from io import open + +Import("env") + +try: + from cryptography import x509 + from cryptography.hazmat.backends import default_backend + from cryptography.hazmat.primitives import serialization +except ImportError: + env.Execute("$PYTHONEXE -m pip install cryptography") + from cryptography import x509 + from cryptography.hazmat.backends import default_backend + from cryptography.hazmat.primitives import serialization + + +ca_bundle_bin_file = 'x509_crt_bundle.bin' +mozilla_cacert_url = 'https://curl.se/ca/cacert.pem' +adafruit_filtered_cacert_url = 'https://raw.githubusercontent.com/adafruit/certificates/main/data/roots-filtered.pem' +adafruit_full_cacert_url = 'https://raw.githubusercontent.com/adafruit/certificates/main/data/roots-full.pem' +certs_dir = Path("./ssl_certs") +binary_dir = Path("./src/certs") + +quiet = False + +def download_cacert_file(source): + if source == "mozilla": + response = requests.get(mozilla_cacert_url) + elif source == "adafruit": + response = requests.get(adafruit_filtered_cacert_url) + elif source == "adafruit-full": + response = requests.get(adafruit_full_cacert_url) + else: + raise InputError('Invalid certificate source') + + if response.status_code == 200: + + # Ensure the directory exists, create it if necessary + os.makedirs(certs_dir, exist_ok=True) + + # Generate the full path to the output file + output_file = os.path.join(certs_dir, "cacert.pem") + + # Write the certificate bundle to the output file with utf-8 encoding + with open(output_file, "w", encoding="utf-8") as f: + f.write(response.text) + + status('Certificate bundle downloaded to: %s' % output_file) + else: + status('Failed to fetch the certificate bundle.') + +def status(msg): + """ Print status message to stderr """ + if not quiet: + critical(msg) + + +def critical(msg): + """ Print critical message to stderr """ + sys.stderr.write('SSL Cert Store: ') + sys.stderr.write(msg) + sys.stderr.write('\n') + + +class CertificateBundle: + def __init__(self): + self.certificates = [] + self.compressed_crts = [] + + if os.path.isfile(ca_bundle_bin_file): + os.remove(ca_bundle_bin_file) + + def add_from_path(self, crts_path): + + found = False + for file_path in os.listdir(crts_path): + found |= self.add_from_file(os.path.join(crts_path, file_path)) + + if found is False: + raise InputError('No valid x509 certificates found in %s' % crts_path) + + def add_from_file(self, file_path): + try: + if file_path.endswith('.pem'): + status('Parsing certificates from %s' % file_path) + with open(file_path, 'r', encoding='utf-8') as f: + crt_str = f.read() + self.add_from_pem(crt_str) + return True + + elif file_path.endswith('.der'): + status('Parsing certificates from %s' % file_path) + with open(file_path, 'rb') as f: + crt_str = f.read() + self.add_from_der(crt_str) + return True + + except ValueError: + critical('Invalid certificate in %s' % file_path) + raise InputError('Invalid certificate') + + return False + + def add_from_pem(self, crt_str): + """ A single PEM file may have multiple certificates """ + + crt = '' + count = 0 + start = False + + for strg in crt_str.splitlines(True): + if strg == '-----BEGIN CERTIFICATE-----\n' and start is False: + crt = '' + start = True + elif strg == '-----END CERTIFICATE-----\n' and start is True: + crt += strg + '\n' + start = False + self.certificates.append(x509.load_pem_x509_certificate(crt.encode(), default_backend())) + count += 1 + if start is True: + crt += strg + + if count == 0: + raise InputError('No certificate found') + + status('Successfully added %d certificates' % count) + + def add_from_der(self, crt_str): + self.certificates.append(x509.load_der_x509_certificate(crt_str, default_backend())) + status('Successfully added 1 certificate') + + def create_bundle(self): + # Sort certificates in order to do binary search when looking up certificates + self.certificates = sorted(self.certificates, key=lambda cert: cert.subject.public_bytes(default_backend())) + + bundle = struct.pack('>H', len(self.certificates)) + + for crt in self.certificates: + """ Read the public key as DER format """ + pub_key = crt.public_key() + pub_key_der = pub_key.public_bytes(serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo) + + """ Read the subject name as DER format """ + sub_name_der = crt.subject.public_bytes(default_backend()) + + name_len = len(sub_name_der) + key_len = len(pub_key_der) + len_data = struct.pack('>HH', name_len, key_len) + + bundle += len_data + bundle += sub_name_der + bundle += pub_key_der + + return bundle + +class InputError(RuntimeError): + def __init__(self, e): + super(InputError, self).__init__(e) + + +def main(): + + bundle = CertificateBundle() + + try: + cert_source = env.GetProjectOption("board_ssl_cert_source") + + if (cert_source == "mozilla" or cert_source == "adafruit" or cert_source == "adafruit-full"): + download_cacert_file(cert_source) + bundle.add_from_file(os.path.join(certs_dir, "cacert.pem")) + elif (cert_source == "folder"): + bundle.add_from_path(certs_dir) + except ValueError: + critical('Invalid configuration option: use \'board_ssl_cert_source\' parameter in platformio.ini' ) + raise InputError('Invalid certificate') + + # Also include any extra .pem/.der files in the certs directory (excluding + # the downloaded cacert.pem bundle). This allows adding root CAs that have + # been removed from upstream trust stores but are still needed by ESP-IDF's + # cert bundle verification (e.g. GlobalSign Root CA R1 for GTS cross-signs). + if os.path.isdir(certs_dir): + for file_name in sorted(os.listdir(certs_dir)): + if file_name == 'cacert.pem': + continue + file_path = os.path.join(certs_dir, file_name) + if os.path.isfile(file_path) and (file_name.endswith('.pem') or file_name.endswith('.der')): + try: + bundle.add_from_file(file_path) + status('Added extra certificate from %s' % file_name) + except Exception as e: + critical('Skipping %s: %s' % (file_name, str(e))) + + status('Successfully added %d certificates in total' % len(bundle.certificates)) + + crt_bundle = bundle.create_bundle() + + # Ensure the directory exists, create it if necessary + os.makedirs(binary_dir, exist_ok=True) + + output_file = os.path.join(binary_dir, ca_bundle_bin_file) + + with open(output_file, 'wb') as f: + f.write(crt_bundle) + + status('Successfully created %s' % output_file) + + +try: + main() +except InputError as e: + print(e) + sys.exit(2) diff --git a/src/Dispatcher.cpp b/src/Dispatcher.cpp index 9d7a11131d..edb1ee9ada 100644 --- a/src/Dispatcher.cpp +++ b/src/Dispatcher.cpp @@ -8,9 +8,7 @@ namespace mesh { -#define MAX_RX_DELAY_MILLIS 32000 // 32 seconds -#define MIN_TX_BUDGET_RESERVE_MS 100 // min budget (ms) required before allowing next TX -#define MIN_TX_BUDGET_AIRTIME_DIV 2 // require at least 1/N of estimated airtime as budget before TX +#define MAX_RX_DELAY_MILLIS 32000 // 32 seconds #ifndef NOISE_FLOOR_CALIB_INTERVAL #define NOISE_FLOOR_CALIB_INTERVAL 2000 // 2 seconds @@ -22,34 +20,12 @@ void Dispatcher::begin() { _err_flags = 0; radio_nonrx_start = _ms->getMillis(); - duty_cycle_window_ms = getDutyCycleWindowMs(); - float duty_cycle = 1.0f / (1.0f + getAirtimeBudgetFactor()); - tx_budget_ms = (unsigned long)(duty_cycle_window_ms * duty_cycle); - last_budget_update = _ms->getMillis(); - _radio->begin(); prev_isrecv_mode = _radio->isInRecvMode(); } float Dispatcher::getAirtimeBudgetFactor() const { - return 1.0; -} - -void Dispatcher::updateTxBudget() { - unsigned long now = _ms->getMillis(); - unsigned long elapsed = now - last_budget_update; - - float duty_cycle = 1.0f / (1.0f + getAirtimeBudgetFactor()); - unsigned long max_budget = (unsigned long)(getDutyCycleWindowMs() * duty_cycle); - unsigned long refill = (unsigned long)(elapsed * duty_cycle); - - if (refill > 0) { - tx_budget_ms += refill; - if (tx_budget_ms > max_budget) { - tx_budget_ms = max_budget; - } - last_budget_update = now; - } + return 2.0; // default, 33.3% (1/3rd) } int Dispatcher::calcRxDelay(float score, uint32_t air_time) const { @@ -63,6 +39,10 @@ uint32_t Dispatcher::getCADFailMaxDuration() const { return 4000; // 4 seconds } +uint32_t Dispatcher::getRadioWatchdogMillis() const { + return RADIO_WATCHDOG_MS; +} + void Dispatcher::loop() { if (millisHasNowPassed(next_floor_calib_time)) { _radio->triggerNoiseFloorCalibrate(getInterferenceThreshold()); @@ -82,29 +62,43 @@ void Dispatcher::loop() { _err_flags |= ERR_EVENT_STARTRX_TIMEOUT; } + // Radio watchdog: detect radio stuck in RX mode but not receiving any packets. + // Use a composite "last radio activity" timestamp: the most recent of any valid RX, + // any ISR event (even CRC errors), or any successful TX. This prevents false firings + // in quiet mesh environments where packets may be more than RADIO_WATCHDOG_MS apart, + // while still catching a truly stuck radio (PSRAM starvation → missed ISR → no activity). + { + const uint32_t watchdog_ms = getRadioWatchdogMillis(); + if (watchdog_ms > 0) { + unsigned long last_recv = _radio->getLastRecvMillis(); + unsigned long last_irq = _radio->getLastRadioInterruptMillis(); + unsigned long last_active = (last_recv > last_irq ? last_recv : last_irq); + if (last_radio_active_ms > last_active) last_active = last_radio_active_ms; + if (is_recv && last_active > 0) { + unsigned long silent_ms = _ms->getMillis() - last_active; + unsigned long since_recovery = _ms->getMillis() - last_watchdog_recovery; + if (silent_ms > watchdog_ms && since_recovery > watchdog_ms) { + _err_flags |= ERR_EVENT_RADIO_WATCHDOG; + MESH_DEBUG_PRINTLN("Radio watchdog: silent %lu ms, state=%d, recovering", silent_ms, _radio->getRadioState()); + _radio->idle(); + _radio->startRecv(); + last_watchdog_recovery = _ms->getMillis(); + } + } + } + } + if (outbound) { // waiting for outbound send to be completed if (_radio->isSendComplete()) { long t = _ms->getMillis() - outbound_start; - total_air_time += t; + total_air_time += t; // keep track of how much air time we are using //Serial.print(" airtime="); Serial.println(t); - updateTxBudget(); - - if (t > tx_budget_ms) { - tx_budget_ms = 0; - } else { - tx_budget_ms -= t; - } - - if (tx_budget_ms < MIN_TX_BUDGET_RESERVE_MS) { - float duty_cycle = 1.0f / (1.0f + getAirtimeBudgetFactor()); - unsigned long needed = MIN_TX_BUDGET_RESERVE_MS - tx_budget_ms; - next_tx_time = futureMillis((unsigned long)(needed / duty_cycle)); - } else { - next_tx_time = _ms->getMillis(); - } + // will need radio silence up to next_tx_time + next_tx_time = futureMillis(t * getAirtimeBudgetFactor()); _radio->onSendFinished(); + last_radio_active_ms = _ms->getMillis(); // TX success → radio is alive logTx(outbound, 2 + outbound->getPathByteLen() + outbound->payload_len); if (outbound->isRouteFlood()) { n_sent_flood++; @@ -272,20 +266,9 @@ void Dispatcher::processRecvPacket(Packet* pkt) { } void Dispatcher::checkSend() { - if (_mgr->getOutboundCount(_ms->getMillis()) == 0) return; - - updateTxBudget(); - - uint32_t est_airtime = _radio->getEstAirtimeFor(MAX_TRANS_UNIT); - if (tx_budget_ms < est_airtime / MIN_TX_BUDGET_AIRTIME_DIV) { - float duty_cycle = 1.0f / (1.0f + getAirtimeBudgetFactor()); - unsigned long needed = est_airtime / MIN_TX_BUDGET_AIRTIME_DIV - tx_budget_ms; - next_tx_time = futureMillis((unsigned long)(needed / duty_cycle)); - return; - } - - if (!millisHasNowPassed(next_tx_time)) return; - if (_radio->isReceiving()) { + if (_mgr->getOutboundCount(_ms->getMillis()) == 0) return; // nothing waiting to send + if (!millisHasNowPassed(next_tx_time)) return; // still in 'radio silence' phase (from airtime budget setting) + if (_radio->isReceiving()) { // LBT - check if radio is currently mid-receive, or if channel activity if (cad_busy_start == 0) { cad_busy_start = _ms->getMillis(); // record when CAD busy state started } diff --git a/src/Dispatcher.h b/src/Dispatcher.h index 2a99d0682b..0373c1b265 100644 --- a/src/Dispatcher.h +++ b/src/Dispatcher.h @@ -61,12 +61,19 @@ class Radio { */ virtual void loop() { } + virtual void idle() { } + virtual void startRecv() { } + virtual int getNoiseFloor() const { return 0; } virtual void triggerNoiseFloorCalibrate(int threshold) { } virtual void resetAGC() { } + virtual uint8_t getRadioState() const { return 0; } + virtual unsigned long getLastRecvMillis() const { return 0; } + virtual unsigned long getLastRadioInterruptMillis() const { return 0; } + virtual bool isInRecvMode() const = 0; /** @@ -76,6 +83,11 @@ class Radio { virtual float getLastRSSI() const { return 0; } virtual float getLastSNR() const { return 0; } + + /** + * \returns number of receive errors (e.g. CRC failures) since last reset; 0 if not tracked. + */ + virtual uint32_t getPacketsRecvErrors() const { return 0; } }; /** @@ -90,7 +102,6 @@ class PacketManager { virtual void queueOutbound(Packet* packet, uint8_t priority, uint32_t scheduled_for) = 0; virtual Packet* getNextOutbound(uint32_t now) = 0; // by priority virtual int getOutboundCount(uint32_t now) const = 0; - virtual int getOutboundTotal() const = 0; virtual int getFreeCount() const = 0; virtual Packet* getOutboundByIdx(int i) = 0; virtual Packet* removeOutboundByIdx(int i) = 0; @@ -108,6 +119,11 @@ typedef uint32_t DispatcherAction; #define ERR_EVENT_FULL (1 << 0) #define ERR_EVENT_CAD_TIMEOUT (1 << 1) #define ERR_EVENT_STARTRX_TIMEOUT (1 << 2) +#define ERR_EVENT_RADIO_WATCHDOG (1 << 3) + +#ifndef RADIO_WATCHDOG_MS + #define RADIO_WATCHDOG_MS 300000 // 5 minutes +#endif /** * \brief The low-level task that manages detecting incoming Packets, and the queueing @@ -116,6 +132,8 @@ typedef uint32_t DispatcherAction; class Dispatcher { Packet* outbound; // current outbound packet unsigned long outbound_expiry, outbound_start, total_air_time, rx_air_time; + unsigned long last_watchdog_recovery; + unsigned long last_radio_active_ms; // updated on any TX or RX event; used by watchdog unsigned long next_tx_time; unsigned long cad_busy_start; unsigned long radio_nonrx_start; @@ -123,12 +141,8 @@ class Dispatcher { bool prev_isrecv_mode; uint32_t n_sent_flood, n_sent_direct; uint32_t n_recv_flood, n_recv_direct; - unsigned long tx_budget_ms; - unsigned long last_budget_update; - unsigned long duty_cycle_window_ms; void processRecvPacket(Packet* pkt); - void updateTxBudget(); protected: PacketManager* _mgr; @@ -141,15 +155,14 @@ class Dispatcher { { outbound = NULL; total_air_time = rx_air_time = 0; - next_tx_time = ms.getMillis(); + next_tx_time = 0; cad_busy_start = 0; next_floor_calib_time = next_agc_reset_time = 0; _err_flags = 0; radio_nonrx_start = 0; prev_isrecv_mode = true; - tx_budget_ms = 0; - last_budget_update = 0; - duty_cycle_window_ms = 3600000; + last_watchdog_recovery = 0; + last_radio_active_ms = 0; } virtual DispatcherAction onRecvPacket(Packet* pkt) = 0; @@ -167,7 +180,7 @@ class Dispatcher { virtual uint32_t getCADFailMaxDuration() const; virtual int getInterferenceThreshold() const { return 0; } // disabled by default virtual int getAGCResetInterval() const { return 0; } // disabled by default - virtual unsigned long getDutyCycleWindowMs() const { return 3600000; } + virtual uint32_t getRadioWatchdogMillis() const; public: void begin(); @@ -177,13 +190,14 @@ class Dispatcher { void releasePacket(Packet* packet); void sendPacket(Packet* packet, uint8_t priority, uint32_t delay_millis=0); - unsigned long getTotalAirTime() const { return total_air_time; } + unsigned long getTotalAirTime() const { return total_air_time; } // in milliseconds unsigned long getReceiveAirTime() const {return rx_air_time; } - unsigned long getRemainingTxBudget() const { return tx_budget_ms; } uint32_t getNumSentFlood() const { return n_sent_flood; } uint32_t getNumSentDirect() const { return n_sent_direct; } uint32_t getNumRecvFlood() const { return n_recv_flood; } uint32_t getNumRecvDirect() const { return n_recv_direct; } + uint16_t getErrFlags() const { return _err_flags; } // Get error flags + bool hasOutbound() const { return outbound != NULL; } void resetStats() { n_sent_flood = n_sent_direct = n_recv_flood = n_recv_direct = 0; _err_flags = 0; diff --git a/src/MeshCore.h b/src/MeshCore.h index 2db1d4c3ec..f78440af8f 100644 --- a/src/MeshCore.h +++ b/src/MeshCore.h @@ -23,15 +23,15 @@ #if MESH_DEBUG && ARDUINO #include - #define MESH_DEBUG_PRINT(F, ...) Serial.printf("DEBUG: " F, ##__VA_ARGS__) - #define MESH_DEBUG_PRINTLN(F, ...) Serial.printf("DEBUG: " F "\n", ##__VA_ARGS__) + #define MESH_DEBUG_PRINT(F, ...) do { if (Serial.availableForWrite() > 0) { Serial.printf("DEBUG: " F, ##__VA_ARGS__); } } while(0) + #define MESH_DEBUG_PRINTLN(F, ...) do { if (Serial.availableForWrite() > 0) { Serial.printf("DEBUG: " F "\n", ##__VA_ARGS__); } } while(0) #else #define MESH_DEBUG_PRINT(...) {} #define MESH_DEBUG_PRINTLN(...) {} #endif #if BRIDGE_DEBUG && ARDUINO -#define BRIDGE_DEBUG_PRINTLN(F, ...) Serial.printf("%s BRIDGE: " F, getLogDateTime(), ##__VA_ARGS__) +#define BRIDGE_DEBUG_PRINTLN(F, ...) do { if (Serial.availableForWrite() > 0) { Serial.printf("%s BRIDGE: " F, getLogDateTime(), ##__VA_ARGS__); } } while(0) #else #define BRIDGE_DEBUG_PRINTLN(...) {} #endif diff --git a/src/certs/x509_crt_bundle.bin b/src/certs/x509_crt_bundle.bin new file mode 100644 index 0000000000..90556e7793 Binary files /dev/null and b/src/certs/x509_crt_bundle.bin differ diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index b71afc72e2..d48fbd6f8b 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -2,12 +2,34 @@ #include "CommonCLI.h" #include "TxtDataHelpers.h" #include "AdvertDataHelpers.h" -#include "TxtDataHelpers.h" #include #ifndef BRIDGE_MAX_BAUD #define BRIDGE_MAX_BAUD 115200 #endif +#ifdef ESP_PLATFORM +#include +#include +#include +#include +#endif +#ifdef WITH_MQTT_BRIDGE +#include "bridges/MQTTBridge.h" + +// Helper function to calculate total size of MQTT fields for file format compatibility +// Uses NodePrefs struct to get accurate field sizes +static size_t getMQTTFieldsSize(const NodePrefs* prefs) { + return sizeof(prefs->mqtt_origin) + sizeof(prefs->mqtt_iata) + + sizeof(prefs->mqtt_status_enabled) + sizeof(prefs->mqtt_packets_enabled) + + sizeof(prefs->mqtt_raw_enabled) + sizeof(prefs->mqtt_tx_enabled) + + sizeof(prefs->mqtt_status_interval) + sizeof(prefs->wifi_ssid) + + sizeof(prefs->wifi_password) + sizeof(prefs->timezone_string) + + sizeof(prefs->timezone_offset) + sizeof(prefs->mqtt_slot_preset) + + sizeof(prefs->mqtt_slot_host) + sizeof(prefs->mqtt_slot_port) + + sizeof(prefs->mqtt_slot_username) + sizeof(prefs->mqtt_slot_password) + + sizeof(prefs->mqtt_owner_public_key) + sizeof(prefs->mqtt_email); +} +#endif // Believe it or not, this std C function is busted on some platforms! static uint32_t _atoi(const char* sp) { @@ -19,22 +41,158 @@ static uint32_t _atoi(const char* sp) { return n; } +#ifdef WITH_MQTT_BRIDGE +static int getMQTTPresetNameCount() { + // Include virtual presets accepted by CLI parser. + return MQTT_PRESET_COUNT + 2; // built-ins + custom + none +} + +static const char* getMQTTPresetNameByIndex(int index) { + if (index < MQTT_PRESET_COUNT) return MQTT_PRESETS[index].name; + if (index == MQTT_PRESET_COUNT) return MQTT_PRESET_CUSTOM; + if (index == MQTT_PRESET_COUNT + 1) return MQTT_PRESET_NONE; + return nullptr; +} + +static void formatMQTTPresetListReply(char* reply, size_t reply_size, int start) { + if (!reply || reply_size == 0) return; + reply[0] = '\0'; + + const int total = getMQTTPresetNameCount(); + if (start < 0 || start >= total) { + snprintf(reply, reply_size, "Error: preset list start must be 0-%d", total - 1); + return; + } + + // Keep room for continuation marker and null terminator. + const size_t reserve_for_next = 18; + size_t used = 0; + bool wrote_any = false; + + int index = start; + while (index < total) { + const char* name = getMQTTPresetNameByIndex(index); + if (!name) break; + size_t name_len = strlen(name); + size_t room = reply_size - used; + if (room <= reserve_for_next) break; + size_t needed = name_len + (wrote_any ? 1 : 0); // comma separator + if (needed >= room - reserve_for_next) break; + if (wrote_any) { + reply[used++] = ','; + } + memcpy(reply + used, name, name_len); + used += name_len; + reply[used] = '\0'; + wrote_any = true; + index++; + } + + if (!wrote_any) { + strcpy(reply, "Error: list page too small"); + return; + } + + if (index < total) { + snprintf(reply + used, reply_size - used, "... next:%d", index); + } +} +#endif + static bool isValidName(const char *n) { while (*n) { - if (*n == '[' || *n == ']' || *n == '\\' || *n == ':' || *n == ',' || *n == '?' || *n == '*') return false; + if (*n == '[' || *n == ']' || *n == '/' || *n == '\\' || *n == ':' || *n == ',' || *n == '?' || *n == '*') return false; n++; } return true; } +#ifdef ESP_PLATFORM +// Optional embedded CA bundle symbols produced by board_build.embed_files. +// Weak linkage keeps non-bundle builds linkable. +extern const uint8_t rootca_crt_bundle_start[] asm("_binary_src_certs_x509_crt_bundle_bin_start") __attribute__((weak)); +extern const uint8_t rootca_crt_bundle_end[] asm("_binary_src_certs_x509_crt_bundle_bin_end") __attribute__((weak)); + +static bool parseTlsBundleTarget(const char* input, char* host_out, size_t host_out_size, uint16_t* port_out) { + if (!input || !host_out || host_out_size == 0 || !port_out) return false; + + while (*input == ' ') input++; + if (*input == '\0') return false; + + const char* start = input; + const char* scheme = strstr(input, "://"); + if (scheme) start = scheme + 3; + + const char* end = start; + while (*end && *end != '/' && *end != '?' && *end != '#') end++; + if (end <= start) return false; + + uint16_t port = 443; + const char* host_start = start; + const char* host_end = end; + + if (*host_start == '[') { + const char* close = (const char*)memchr(host_start, ']', host_end - host_start); + if (!close) return false; + if ((close + 1) < host_end && *(close + 1) == ':') { + int p = atoi(close + 2); + if (p <= 0 || p > 65535) return false; + port = (uint16_t)p; + } + host_start++; + host_end = close; + } else { + const char* colon = (const char*)memchr(host_start, ':', host_end - host_start); + if (colon) { + int p = atoi(colon + 1); + if (p <= 0 || p > 65535) return false; + port = (uint16_t)p; + host_end = colon; + } + } + + size_t host_len = (size_t)(host_end - host_start); + if (host_len == 0 || host_len >= host_out_size) return false; + memcpy(host_out, host_start, host_len); + host_out[host_len] = '\0'; + *port_out = port; + return true; +} +#endif + void CommonCLI::loadPrefs(FILESYSTEM* fs) { + bool is_fresh_install = false; + bool is_upgrade = false; + if (fs->exists("/com_prefs")) { loadPrefsInt(fs, "/com_prefs"); // new filename } else if (fs->exists("/node_prefs")) { loadPrefsInt(fs, "/node_prefs"); + is_upgrade = true; // Migrating from old filename savePrefs(fs); // save to new filename fs->remove("/node_prefs"); // remove old + } else { + // File doesn't exist - set default bridge settings for fresh installs + is_fresh_install = true; + _prefs->bridge_pkt_src = 1; // Default to RX (logRx) for new installs } +#ifdef WITH_MQTT_BRIDGE + // Load MQTT preferences from separate file + loadMQTTPrefs(fs); + // Sync MQTT prefs to NodePrefs so existing code (like MQTTBridge) can access them + syncMQTTPrefsToNodePrefs(); + + // For MQTT bridge, migrate bridge.source to RX (logRx) only on fresh installs or upgrades + // so legacy "tx" is not the default. mqtt.rx / mqtt.tx are separate (fresh default: advert for TX) + if ((is_fresh_install || is_upgrade) && _prefs->bridge_pkt_src == 0) { + MESH_DEBUG_PRINTLN("MQTT Bridge: Migrating bridge.source from tx to rx (MQTT bridge default)"); + _prefs->bridge_pkt_src = 1; // Set to RX (logRx) + savePrefs(fs); // Save the updated preference + } + // mqtt_rx_enabled: new field appended to end of MQTTPrefs. On upgrade from older firmware, + // the shorter /mqtt_prefs file won't contain it, so it keeps the default value (1 = on) + // set by setMQTTPrefsDefaults(). No explicit migration needed. +#endif } void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { @@ -61,7 +219,7 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { file.read((uint8_t *)&_prefs->tx_delay_factor, sizeof(_prefs->tx_delay_factor)); // 84 file.read((uint8_t *)&_prefs->guest_password[0], sizeof(_prefs->guest_password)); // 88 file.read((uint8_t *)&_prefs->direct_tx_delay_factor, sizeof(_prefs->direct_tx_delay_factor)); // 104 - file.read(pad, 4); // 108 : 4 bytes unused + file.read(pad, 4); // 108 file.read((uint8_t *)&_prefs->sf, sizeof(_prefs->sf)); // 112 file.read((uint8_t *)&_prefs->cr, sizeof(_prefs->cr)); // 113 file.read((uint8_t *)&_prefs->allow_read_only, sizeof(_prefs->allow_read_only)); // 114 @@ -88,8 +246,39 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { file.read((uint8_t *)&_prefs->discovery_mod_timestamp, sizeof(_prefs->discovery_mod_timestamp)); // 162 file.read((uint8_t *)&_prefs->adc_multiplier, sizeof(_prefs->adc_multiplier)); // 166 file.read((uint8_t *)_prefs->owner_info, sizeof(_prefs->owner_info)); // 170 + // MQTT settings - skip reading from main prefs file (now stored separately) + // For backward compatibility, we'll skip these bytes if they exist in old files + // The actual MQTT prefs will be loaded from /mqtt_prefs in loadMQTTPrefs() + // Skip MQTT fields for file format compatibility (whether MQTT bridge is enabled or not) +#ifdef WITH_MQTT_BRIDGE + size_t mqtt_fields_size = getMQTTFieldsSize(_prefs); +#else + // If MQTT bridge not enabled, still skip these fields for file format compatibility + size_t mqtt_fields_size = + sizeof(_prefs->mqtt_origin) + sizeof(_prefs->mqtt_iata) + + sizeof(_prefs->mqtt_status_enabled) + sizeof(_prefs->mqtt_packets_enabled) + + sizeof(_prefs->mqtt_raw_enabled) + sizeof(_prefs->mqtt_tx_enabled) + + sizeof(_prefs->mqtt_status_interval) + sizeof(_prefs->wifi_ssid) + + sizeof(_prefs->wifi_password) + sizeof(_prefs->timezone_string) + + sizeof(_prefs->timezone_offset) + sizeof(_prefs->mqtt_slot_preset) + + sizeof(_prefs->mqtt_slot_host) + sizeof(_prefs->mqtt_slot_port) + + sizeof(_prefs->mqtt_slot_username) + sizeof(_prefs->mqtt_slot_password) + + sizeof(_prefs->mqtt_owner_public_key) + sizeof(_prefs->mqtt_email); +#endif + uint8_t skip_buffer[512]; // Large enough buffer + size_t remaining = mqtt_fields_size; + while (remaining > 0) { + size_t to_read = remaining > sizeof(skip_buffer) ? sizeof(skip_buffer) : remaining; + file.read(skip_buffer, to_read); + remaining -= to_read; + } file.read((uint8_t *)&_prefs->rx_boosted_gain, sizeof(_prefs->rx_boosted_gain)); // 290 - // next: 291 + file.read((uint8_t *)&_prefs->snmp_enabled, sizeof(_prefs->snmp_enabled)); // 291 + file.read((uint8_t *)&_prefs->snmp_community, sizeof(_prefs->snmp_community)); // 292 + if (file.available() >= (int)sizeof(_prefs->radio_watchdog_minutes)) { + file.read((uint8_t *)&_prefs->radio_watchdog_minutes, sizeof(_prefs->radio_watchdog_minutes)); // 316 + } + // next: 317 // sanitise bad pref values _prefs->rx_delay_base = constrain(_prefs->rx_delay_base, 0, 20.0f); @@ -104,6 +293,7 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { _prefs->multi_acks = constrain(_prefs->multi_acks, 0, 1); _prefs->adc_multiplier = constrain(_prefs->adc_multiplier, 0.0f, 10.0f); _prefs->path_hash_mode = constrain(_prefs->path_hash_mode, 0, 2); // NOTE: mode 3 reserved for future + _prefs->loop_detect = constrain(_prefs->loop_detect, 0, 3); // LOOP_DETECT_OFF..LOOP_DETECT_STRICT // sanitise bad bridge pref values _prefs->bridge_enabled = constrain(_prefs->bridge_enabled, 0, 1); @@ -117,8 +307,12 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { _prefs->gps_enabled = constrain(_prefs->gps_enabled, 0, 1); _prefs->advert_loc_policy = constrain(_prefs->advert_loc_policy, 0, 2); - // sanitise settings _prefs->rx_boosted_gain = constrain(_prefs->rx_boosted_gain, 0, 1); // boolean + _prefs->snmp_enabled = constrain(_prefs->snmp_enabled, 0, 1); + _prefs->snmp_community[sizeof(_prefs->snmp_community) - 1] = '\0'; // ensure null terminated + if (_prefs->radio_watchdog_minutes > 120) { + _prefs->radio_watchdog_minutes = 5; + } file.close(); } @@ -152,7 +346,7 @@ void CommonCLI::savePrefs(FILESYSTEM* fs) { file.write((uint8_t *)&_prefs->tx_delay_factor, sizeof(_prefs->tx_delay_factor)); // 84 file.write((uint8_t *)&_prefs->guest_password[0], sizeof(_prefs->guest_password)); // 88 file.write((uint8_t *)&_prefs->direct_tx_delay_factor, sizeof(_prefs->direct_tx_delay_factor)); // 104 - file.write(pad, 4); // 108 : 4 byte unused + file.write(pad, 4); // 108 file.write((uint8_t *)&_prefs->sf, sizeof(_prefs->sf)); // 112 file.write((uint8_t *)&_prefs->cr, sizeof(_prefs->cr)); // 113 file.write((uint8_t *)&_prefs->allow_read_only, sizeof(_prefs->allow_read_only)); // 114 @@ -179,19 +373,296 @@ void CommonCLI::savePrefs(FILESYSTEM* fs) { file.write((uint8_t *)&_prefs->discovery_mod_timestamp, sizeof(_prefs->discovery_mod_timestamp)); // 162 file.write((uint8_t *)&_prefs->adc_multiplier, sizeof(_prefs->adc_multiplier)); // 166 file.write((uint8_t *)_prefs->owner_info, sizeof(_prefs->owner_info)); // 170 + // MQTT settings - no longer saved here (stored in separate /mqtt_prefs file) + // Write zeros/padding to maintain file format compatibility +#ifdef WITH_MQTT_BRIDGE + size_t mqtt_fields_size = getMQTTFieldsSize(_prefs); +#else + // If MQTT bridge not enabled, still write zeros for file format compatibility + size_t mqtt_fields_size = + sizeof(_prefs->mqtt_origin) + sizeof(_prefs->mqtt_iata) + + sizeof(_prefs->mqtt_status_enabled) + sizeof(_prefs->mqtt_packets_enabled) + + sizeof(_prefs->mqtt_raw_enabled) + sizeof(_prefs->mqtt_tx_enabled) + + sizeof(_prefs->mqtt_status_interval) + sizeof(_prefs->wifi_ssid) + + sizeof(_prefs->wifi_password) + sizeof(_prefs->timezone_string) + + sizeof(_prefs->timezone_offset) + sizeof(_prefs->mqtt_slot_preset) + + sizeof(_prefs->mqtt_slot_host) + sizeof(_prefs->mqtt_slot_port) + + sizeof(_prefs->mqtt_slot_username) + sizeof(_prefs->mqtt_slot_password) + + sizeof(_prefs->mqtt_owner_public_key) + sizeof(_prefs->mqtt_email); +#endif + memset(pad, 0, sizeof(pad)); + size_t remaining = mqtt_fields_size; + while (remaining > 0) { + size_t to_write = remaining > sizeof(pad) ? sizeof(pad) : remaining; + file.write(pad, to_write); + remaining -= to_write; + } file.write((uint8_t *)&_prefs->rx_boosted_gain, sizeof(_prefs->rx_boosted_gain)); // 290 - // next: 291 + file.write((uint8_t *)&_prefs->snmp_enabled, sizeof(_prefs->snmp_enabled)); // 291 + file.write((uint8_t *)&_prefs->snmp_community, sizeof(_prefs->snmp_community)); // 292 + file.write((uint8_t *)&_prefs->radio_watchdog_minutes, sizeof(_prefs->radio_watchdog_minutes)); // 316 + // next: 317 file.close(); } +#ifdef WITH_MQTT_BRIDGE + // Save MQTT preferences to separate file + syncNodePrefsToMQTTPrefs(); // Sync any changes from NodePrefs to MQTTPrefs + saveMQTTPrefs(fs); +#endif +} + +#ifdef WITH_MQTT_BRIDGE +// Set default values for MQTT preferences (used when file doesn't exist or is corrupted) +static void setMQTTPrefsDefaults(MQTTPrefs* prefs) { + memset(prefs, 0, sizeof(MQTTPrefs)); + // Set sensible defaults matching MQTTBridge expectations + prefs->mqtt_status_enabled = 1; // enabled by default + prefs->mqtt_packets_enabled = 1; // enabled by default + prefs->mqtt_raw_enabled = 0; // disabled by default + prefs->mqtt_tx_enabled = 2; // advert: own adverts only, by default + prefs->mqtt_rx_enabled = 1; // RX packets enabled by default + prefs->mqtt_status_interval = 300000; // 5 minutes default + // Slot presets: dutchmeshcore collectors enabled by default, rest = none + strncpy(prefs->mqtt_slot_preset[0], "dutchmeshcore-1", sizeof(prefs->mqtt_slot_preset[0]) - 1); + prefs->mqtt_slot_preset[0][sizeof(prefs->mqtt_slot_preset[0]) - 1] = '\0'; + strncpy(prefs->mqtt_slot_preset[1], "dutchmeshcore-2", sizeof(prefs->mqtt_slot_preset[1]) - 1); + prefs->mqtt_slot_preset[1][sizeof(prefs->mqtt_slot_preset[1]) - 1] = '\0'; + for (int i = 2; i < MAX_MQTT_SLOTS; i++) { + strncpy(prefs->mqtt_slot_preset[i], "none", sizeof(prefs->mqtt_slot_preset[i]) - 1); + prefs->mqtt_slot_preset[i][sizeof(prefs->mqtt_slot_preset[i]) - 1] = '\0'; + } + prefs->wifi_power_save = 1; // Default to none (0=min, 1=none, 2=max) + // String fields are already zero-initialized by memset } +void CommonCLI::loadMQTTPrefs(FILESYSTEM* fs) { + // Initialize with defaults first + setMQTTPrefsDefaults(&_mqtt_prefs); + + bool file_existed = fs->exists("/mqtt_prefs"); + if (file_existed) { + // Load from separate MQTT prefs file +#if defined(RP2040_PLATFORM) + File file = fs->open("/mqtt_prefs", "r"); +#else + File file = fs->open("/mqtt_prefs"); +#endif + if (file) { + size_t file_size = file.size(); + + // Detect old (pre-slot) format by file size. + // Old MQTTPrefs was ~472 bytes (no slot fields). New is ~1464 bytes. + // If the file is smaller than the new struct but close to OldMQTTPrefs size, + // read it with the old layout and migrate. + if (file_size > 0 && file_size <= sizeof(OldMQTTPrefs)) { + OldMQTTPrefs old_prefs; + memset(&old_prefs, 0, sizeof(old_prefs)); + size_t bytes_read = file.read((uint8_t *)&old_prefs, file_size < sizeof(old_prefs) ? file_size : sizeof(old_prefs)); + file.close(); + + if (bytes_read > 0) { + MESH_DEBUG_PRINTLN("MQTT: Migrating old-format prefs to slot-based layout"); + + // Copy common fields (identical layout at start of both structs) + memcpy(_mqtt_prefs.mqtt_origin, old_prefs.mqtt_origin, sizeof(_mqtt_prefs.mqtt_origin)); + memcpy(_mqtt_prefs.mqtt_iata, old_prefs.mqtt_iata, sizeof(_mqtt_prefs.mqtt_iata)); + _mqtt_prefs.mqtt_status_enabled = old_prefs.mqtt_status_enabled; + _mqtt_prefs.mqtt_packets_enabled = old_prefs.mqtt_packets_enabled; + _mqtt_prefs.mqtt_raw_enabled = old_prefs.mqtt_raw_enabled; + _mqtt_prefs.mqtt_tx_enabled = old_prefs.mqtt_tx_enabled; + _mqtt_prefs.mqtt_status_interval = old_prefs.mqtt_status_interval; + memcpy(_mqtt_prefs.wifi_ssid, old_prefs.wifi_ssid, sizeof(_mqtt_prefs.wifi_ssid)); + memcpy(_mqtt_prefs.wifi_password, old_prefs.wifi_password, sizeof(_mqtt_prefs.wifi_password)); + _mqtt_prefs.wifi_power_save = old_prefs.wifi_power_save; + memcpy(_mqtt_prefs.timezone_string, old_prefs.timezone_string, sizeof(_mqtt_prefs.timezone_string)); + _mqtt_prefs.timezone_offset = old_prefs.timezone_offset; + + // Migrate shared auth fields + memcpy(_mqtt_prefs.mqtt_owner_public_key, old_prefs.mqtt_owner_public_key, sizeof(_mqtt_prefs.mqtt_owner_public_key)); + memcpy(_mqtt_prefs.mqtt_email, old_prefs.mqtt_email, sizeof(_mqtt_prefs.mqtt_email)); + + // Migrate analyzer presets to slots + if (old_prefs.mqtt_analyzer_us_enabled == 1) { + strncpy(_mqtt_prefs.mqtt_slot_preset[0], "analyzer-us", sizeof(_mqtt_prefs.mqtt_slot_preset[0]) - 1); + } else { + strncpy(_mqtt_prefs.mqtt_slot_preset[0], "none", sizeof(_mqtt_prefs.mqtt_slot_preset[0]) - 1); + } + if (old_prefs.mqtt_analyzer_eu_enabled == 1) { + strncpy(_mqtt_prefs.mqtt_slot_preset[1], "analyzer-eu", sizeof(_mqtt_prefs.mqtt_slot_preset[1]) - 1); + } else { + strncpy(_mqtt_prefs.mqtt_slot_preset[1], "none", sizeof(_mqtt_prefs.mqtt_slot_preset[1]) - 1); + } + + // Migrate custom server to slot 3 + if (old_prefs.mqtt_server[0] != '\0' && old_prefs.mqtt_port > 0) { + strncpy(_mqtt_prefs.mqtt_slot_preset[2], "custom", sizeof(_mqtt_prefs.mqtt_slot_preset[2]) - 1); + strncpy(_mqtt_prefs.mqtt_slot_host[2], old_prefs.mqtt_server, sizeof(_mqtt_prefs.mqtt_slot_host[2]) - 1); + _mqtt_prefs.mqtt_slot_port[2] = old_prefs.mqtt_port; + strncpy(_mqtt_prefs.mqtt_slot_username[2], old_prefs.mqtt_username, sizeof(_mqtt_prefs.mqtt_slot_username[2]) - 1); + strncpy(_mqtt_prefs.mqtt_slot_password[2], old_prefs.mqtt_password, sizeof(_mqtt_prefs.mqtt_slot_password[2]) - 1); + } else { + strncpy(_mqtt_prefs.mqtt_slot_preset[2], "none", sizeof(_mqtt_prefs.mqtt_slot_preset[2]) - 1); + } + + // Save migrated prefs in new format + saveMQTTPrefs(fs); + } + } else if (file_size > 0 && file_size <= sizeof(ThreeSlotMQTTPrefs)) { + // 3-slot format → 6-slot migration + // Array sizes changed from [3] to [6], shifting all field offsets. + // Read into old layout struct and field-copy to new layout. + ThreeSlotMQTTPrefs old3; + memset(&old3, 0, sizeof(old3)); + size_t bytes_to_read = file_size < sizeof(old3) ? file_size : sizeof(old3); + size_t bytes_read = file.read((uint8_t *)&old3, bytes_to_read); + file.close(); + + if (bytes_read > 0) { + MESH_DEBUG_PRINTLN("MQTT: Migrating 3-slot prefs to 6-slot layout"); + + // Copy non-slot fields (identical layout) + memcpy(_mqtt_prefs.mqtt_origin, old3.mqtt_origin, sizeof(_mqtt_prefs.mqtt_origin)); + memcpy(_mqtt_prefs.mqtt_iata, old3.mqtt_iata, sizeof(_mqtt_prefs.mqtt_iata)); + _mqtt_prefs.mqtt_status_enabled = old3.mqtt_status_enabled; + _mqtt_prefs.mqtt_packets_enabled = old3.mqtt_packets_enabled; + _mqtt_prefs.mqtt_raw_enabled = old3.mqtt_raw_enabled; + _mqtt_prefs.mqtt_tx_enabled = old3.mqtt_tx_enabled; + _mqtt_prefs.mqtt_status_interval = old3.mqtt_status_interval; + memcpy(_mqtt_prefs.wifi_ssid, old3.wifi_ssid, sizeof(_mqtt_prefs.wifi_ssid)); + memcpy(_mqtt_prefs.wifi_password, old3.wifi_password, sizeof(_mqtt_prefs.wifi_password)); + _mqtt_prefs.wifi_power_save = old3.wifi_power_save; + memcpy(_mqtt_prefs.timezone_string, old3.timezone_string, sizeof(_mqtt_prefs.timezone_string)); + _mqtt_prefs.timezone_offset = old3.timezone_offset; + + // Copy slot fields for indices 0-2 from old layout + for (int i = 0; i < 3; i++) { + memcpy(_mqtt_prefs.mqtt_slot_preset[i], old3.mqtt_slot_preset[i], sizeof(_mqtt_prefs.mqtt_slot_preset[i])); + memcpy(_mqtt_prefs.mqtt_slot_host[i], old3.mqtt_slot_host[i], sizeof(_mqtt_prefs.mqtt_slot_host[i])); + _mqtt_prefs.mqtt_slot_port[i] = old3.mqtt_slot_port[i]; + memcpy(_mqtt_prefs.mqtt_slot_username[i], old3.mqtt_slot_username[i], sizeof(_mqtt_prefs.mqtt_slot_username[i])); + memcpy(_mqtt_prefs.mqtt_slot_password[i], old3.mqtt_slot_password[i], sizeof(_mqtt_prefs.mqtt_slot_password[i])); + memcpy(_mqtt_prefs.mqtt_slot_token[i], old3.mqtt_slot_token[i], sizeof(_mqtt_prefs.mqtt_slot_token[i])); + memcpy(_mqtt_prefs.mqtt_slot_topic[i], old3.mqtt_slot_topic[i], sizeof(_mqtt_prefs.mqtt_slot_topic[i])); + } + // Slots 3-5 keep defaults ("none") from setMQTTPrefsDefaults() + + // Copy shared auth fields + memcpy(_mqtt_prefs.mqtt_owner_public_key, old3.mqtt_owner_public_key, sizeof(_mqtt_prefs.mqtt_owner_public_key)); + memcpy(_mqtt_prefs.mqtt_email, old3.mqtt_email, sizeof(_mqtt_prefs.mqtt_email)); + + // Save migrated prefs in new 6-slot format + saveMQTTPrefs(fs); + } + } else if (file_size > 0) { + // 6-slot format: read directly + size_t bytes_to_read = file_size < sizeof(_mqtt_prefs) ? file_size : sizeof(_mqtt_prefs); + size_t bytes_read = file.read((uint8_t *)&_mqtt_prefs, bytes_to_read); + file.close(); + if (bytes_read != bytes_to_read) { + setMQTTPrefsDefaults(&_mqtt_prefs); + } + } else { + file.close(); + setMQTTPrefsDefaults(&_mqtt_prefs); + } + } + } else { + // No /mqtt_prefs file — defaults already set + // (Legacy /com_prefs migration removed: the old offset-based approach was fragile + // and the pre-MQTT firmware never wrote MQTT fields to /com_prefs anyway.) + } +} + +void CommonCLI::saveMQTTPrefs(FILESYSTEM* fs) { +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + fs->remove("/mqtt_prefs"); + File file = fs->open("/mqtt_prefs", FILE_O_WRITE); +#elif defined(RP2040_PLATFORM) + File file = fs->open("/mqtt_prefs", "w"); +#else + File file = fs->open("/mqtt_prefs", "w", true); +#endif + if (file) { + file.write((uint8_t *)&_mqtt_prefs, sizeof(_mqtt_prefs)); + file.close(); + } +} + +void CommonCLI::syncMQTTPrefsToNodePrefs() { + // Copy MQTT prefs to NodePrefs so existing code can access them + // Use StrHelper::strncpy to ensure proper null termination + StrHelper::strncpy(_prefs->mqtt_origin, _mqtt_prefs.mqtt_origin, sizeof(_prefs->mqtt_origin)); + StrHelper::strncpy(_prefs->mqtt_iata, _mqtt_prefs.mqtt_iata, sizeof(_prefs->mqtt_iata)); + _prefs->mqtt_status_enabled = _mqtt_prefs.mqtt_status_enabled; + _prefs->mqtt_packets_enabled = _mqtt_prefs.mqtt_packets_enabled; + _prefs->mqtt_raw_enabled = _mqtt_prefs.mqtt_raw_enabled; + _prefs->mqtt_tx_enabled = _mqtt_prefs.mqtt_tx_enabled; + _prefs->mqtt_rx_enabled = _mqtt_prefs.mqtt_rx_enabled; + _prefs->mqtt_status_interval = _mqtt_prefs.mqtt_status_interval; + StrHelper::strncpy(_prefs->wifi_ssid, _mqtt_prefs.wifi_ssid, sizeof(_prefs->wifi_ssid)); + StrHelper::strncpy(_prefs->wifi_password, _mqtt_prefs.wifi_password, sizeof(_prefs->wifi_password)); + _prefs->wifi_power_save = _mqtt_prefs.wifi_power_save; + StrHelper::strncpy(_prefs->timezone_string, _mqtt_prefs.timezone_string, sizeof(_prefs->timezone_string)); + _prefs->timezone_offset = _mqtt_prefs.timezone_offset; + // Slot-based fields + for (int i = 0; i < MAX_MQTT_SLOTS; i++) { + StrHelper::strncpy(_prefs->mqtt_slot_preset[i], _mqtt_prefs.mqtt_slot_preset[i], sizeof(_prefs->mqtt_slot_preset[i])); + StrHelper::strncpy(_prefs->mqtt_slot_host[i], _mqtt_prefs.mqtt_slot_host[i], sizeof(_prefs->mqtt_slot_host[i])); + _prefs->mqtt_slot_port[i] = _mqtt_prefs.mqtt_slot_port[i]; + StrHelper::strncpy(_prefs->mqtt_slot_username[i], _mqtt_prefs.mqtt_slot_username[i], sizeof(_prefs->mqtt_slot_username[i])); + StrHelper::strncpy(_prefs->mqtt_slot_password[i], _mqtt_prefs.mqtt_slot_password[i], sizeof(_prefs->mqtt_slot_password[i])); + StrHelper::strncpy(_prefs->mqtt_slot_token[i], _mqtt_prefs.mqtt_slot_token[i], sizeof(_prefs->mqtt_slot_token[i])); + StrHelper::strncpy(_prefs->mqtt_slot_topic[i], _mqtt_prefs.mqtt_slot_topic[i], sizeof(_prefs->mqtt_slot_topic[i])); + StrHelper::strncpy(_prefs->mqtt_slot_audience[i], _mqtt_prefs.mqtt_slot_audience[i], sizeof(_prefs->mqtt_slot_audience[i])); + } + StrHelper::strncpy(_prefs->mqtt_owner_public_key, _mqtt_prefs.mqtt_owner_public_key, sizeof(_prefs->mqtt_owner_public_key)); + StrHelper::strncpy(_prefs->mqtt_email, _mqtt_prefs.mqtt_email, sizeof(_prefs->mqtt_email)); +} + +void CommonCLI::syncNodePrefsToMQTTPrefs() { + // Copy NodePrefs to MQTT prefs (used when saving after changes via CLI) + // Use StrHelper::strncpy to ensure proper null termination + StrHelper::strncpy(_mqtt_prefs.mqtt_origin, _prefs->mqtt_origin, sizeof(_mqtt_prefs.mqtt_origin)); + StrHelper::strncpy(_mqtt_prefs.mqtt_iata, _prefs->mqtt_iata, sizeof(_mqtt_prefs.mqtt_iata)); + _mqtt_prefs.mqtt_status_enabled = _prefs->mqtt_status_enabled; + _mqtt_prefs.mqtt_packets_enabled = _prefs->mqtt_packets_enabled; + _mqtt_prefs.mqtt_raw_enabled = _prefs->mqtt_raw_enabled; + _mqtt_prefs.mqtt_tx_enabled = _prefs->mqtt_tx_enabled; + _mqtt_prefs.mqtt_rx_enabled = _prefs->mqtt_rx_enabled; + _mqtt_prefs.mqtt_status_interval = _prefs->mqtt_status_interval; + StrHelper::strncpy(_mqtt_prefs.wifi_ssid, _prefs->wifi_ssid, sizeof(_mqtt_prefs.wifi_ssid)); + StrHelper::strncpy(_mqtt_prefs.wifi_password, _prefs->wifi_password, sizeof(_mqtt_prefs.wifi_password)); + _mqtt_prefs.wifi_power_save = _prefs->wifi_power_save; + StrHelper::strncpy(_mqtt_prefs.timezone_string, _prefs->timezone_string, sizeof(_mqtt_prefs.timezone_string)); + _mqtt_prefs.timezone_offset = _prefs->timezone_offset; + // Slot-based fields + for (int i = 0; i < MAX_MQTT_SLOTS; i++) { + StrHelper::strncpy(_mqtt_prefs.mqtt_slot_preset[i], _prefs->mqtt_slot_preset[i], sizeof(_mqtt_prefs.mqtt_slot_preset[i])); + StrHelper::strncpy(_mqtt_prefs.mqtt_slot_host[i], _prefs->mqtt_slot_host[i], sizeof(_mqtt_prefs.mqtt_slot_host[i])); + _mqtt_prefs.mqtt_slot_port[i] = _prefs->mqtt_slot_port[i]; + StrHelper::strncpy(_mqtt_prefs.mqtt_slot_username[i], _prefs->mqtt_slot_username[i], sizeof(_mqtt_prefs.mqtt_slot_username[i])); + StrHelper::strncpy(_mqtt_prefs.mqtt_slot_password[i], _prefs->mqtt_slot_password[i], sizeof(_mqtt_prefs.mqtt_slot_password[i])); + StrHelper::strncpy(_mqtt_prefs.mqtt_slot_token[i], _prefs->mqtt_slot_token[i], sizeof(_mqtt_prefs.mqtt_slot_token[i])); + StrHelper::strncpy(_mqtt_prefs.mqtt_slot_topic[i], _prefs->mqtt_slot_topic[i], sizeof(_mqtt_prefs.mqtt_slot_topic[i])); + StrHelper::strncpy(_mqtt_prefs.mqtt_slot_audience[i], _prefs->mqtt_slot_audience[i], sizeof(_mqtt_prefs.mqtt_slot_audience[i])); + } + StrHelper::strncpy(_mqtt_prefs.mqtt_owner_public_key, _prefs->mqtt_owner_public_key, sizeof(_mqtt_prefs.mqtt_owner_public_key)); + StrHelper::strncpy(_mqtt_prefs.mqtt_email, _prefs->mqtt_email, sizeof(_mqtt_prefs.mqtt_email)); +} +#endif + #define MIN_LOCAL_ADVERT_INTERVAL 60 void CommonCLI::savePrefs() { + uint8_t old_advert_interval = _prefs->advert_interval; if (_prefs->advert_interval * 2 < MIN_LOCAL_ADVERT_INTERVAL) { _prefs->advert_interval = 0; // turn it off, now that device has been manually configured } + // If advert_interval was changed, update the timer to reflect the change + if (old_advert_interval != _prefs->advert_interval) { + _callbacks->updateAdvertTimer(); + } _callbacks->savePrefs(); } @@ -235,6 +706,57 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re } else { strcpy(reply, "ERR: clock cannot go backwards"); } + } else if (memcmp(command, "memory", 6) == 0) { +#ifdef ESP_PLATFORM + sprintf(reply, "Free: %d, Min: %d, Max: %d, Queue: %d, IntFree: %d, IntMax: %d, PSRAM: %d/%d", + ESP.getFreeHeap(), ESP.getMinFreeHeap(), ESP.getMaxAllocHeap(), + _callbacks->getQueueSize(), + (int)heap_caps_get_free_size(MALLOC_CAP_INTERNAL), + (int)heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL), + (int)heap_caps_get_free_size(MALLOC_CAP_SPIRAM), + (int)heap_caps_get_total_size(MALLOC_CAP_SPIRAM)); +#else + sprintf(reply, "Queue: %d", _callbacks->getQueueSize()); +#endif + } else if (memcmp(command, "tls.bundletest ", 15) == 0) { +#ifdef ESP_PLATFORM + if (WiFi.status() != WL_CONNECTED) { + strcpy(reply, "ERR: WiFi not connected"); + } else { + size_t bundle_len = 0; + if (rootca_crt_bundle_start != nullptr && + rootca_crt_bundle_end != nullptr && + rootca_crt_bundle_end > rootca_crt_bundle_start) { + bundle_len = static_cast(rootca_crt_bundle_end - rootca_crt_bundle_start); + } + if (bundle_len == 0) { + strcpy(reply, "ERR: no embedded cert bundle"); + } else { + char host[96]; + uint16_t port = 443; + if (!parseTlsBundleTarget(command + 15, host, sizeof(host), &port)) { + strcpy(reply, "ERR: usage tls.bundletest "); + } else { + WiFiClientSecure client; +#if ESP_ARDUINO_VERSION_MAJOR >= 3 + client.setCACertBundle(rootca_crt_bundle_start, bundle_len); +#else + client.setCACertBundle(rootca_crt_bundle_start); +#endif + client.setTimeout(8000); + bool ok = client.connect(host, port); + if (ok) { + client.stop(); + snprintf(reply, 160, "OK: TLS bundle verified %s:%u", host, (unsigned)port); + } else { + snprintf(reply, 160, "ERR: TLS bundle failed %s:%u", host, (unsigned)port); + } + } + } + } +#else + strcpy(reply, "ERR: unsupported on this platform"); +#endif } else if (memcmp(command, "start ota", 9) == 0) { if (!_board->startOTAUpdate(_prefs->node_name, reply)) { strcpy(reply, "Error"); @@ -286,8 +808,7 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re // change admin password StrHelper::strncpy(_prefs->password, &command[9], sizeof(_prefs->password)); savePrefs(); - sprintf(reply, "password now: "); - StrHelper::strncpy(&reply[14], _prefs->password, 160-15); // echo back just to let admin know for sure!! + sprintf(reply, "password now: %s", _prefs->password); // echo back just to let admin know for sure!! } else if (memcmp(command, "clear stats", 11) == 0) { _callbacks->clearStats(); strcpy(reply, "(OK - stats reset)"); @@ -455,6 +976,8 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re strcpy(reply, " EOF"); } else if (sender_timestamp == 0 && memcmp(command, "stats-packets", 13) == 0 && (command[13] == 0 || command[13] == ' ')) { _callbacks->formatPacketStatsReply(reply); + } else if (sender_timestamp == 0 && memcmp(command, "stats-radio-diag", 16) == 0 && (command[16] == 0 || command[16] == ' ')) { + _callbacks->formatRadioDiagReply(reply); } else if (sender_timestamp == 0 && memcmp(command, "stats-radio", 11) == 0 && (command[11] == 0 || command[11] == ' ')) { _callbacks->formatRadioStatsReply(reply); } else if (sender_timestamp == 0 && memcmp(command, "stats-core", 10) == 0 && (command[10] == 0 || command[10] == ' ')) { @@ -490,6 +1013,30 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep _prefs->agc_reset_interval = atoi(&config[19]) / 4; savePrefs(); sprintf(reply, "OK - interval rounded to %d", ((uint32_t) _prefs->agc_reset_interval) * 4); + } else if (memcmp(config, "radio.watchdog ", 15) == 0) { + const char* val = &config[15]; + if (*val == 0) { + strcpy(reply, "Error: missing radio.watchdog minutes"); + return; + } + for (const char* sp = val; *sp; sp++) { + if (*sp < '0' || *sp > '9') { + strcpy(reply, "Error: radio.watchdog must be an integer 0-120"); + return; + } + } + int mins = atoi(val); + if (mins > 120) { + strcpy(reply, "Error: radio.watchdog must be 0-120 minutes"); + } else { + _prefs->radio_watchdog_minutes = (uint8_t)mins; + savePrefs(); + if (mins == 0) { + strcpy(reply, "OK - radio watchdog disabled"); + } else { + sprintf(reply, "OK - radio watchdog %d min", mins); + } + } } else if (memcmp(config, "multi.acks ", 11) == 0) { _prefs->multi_acks = atoi(&config[11]); savePrefs(); @@ -656,6 +1203,14 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep savePrefs(); strcpy(reply, "OK"); } + } else if (memcmp(config, "snmp.community ", 15) == 0) { + StrHelper::strncpy(_prefs->snmp_community, &config[15], sizeof(_prefs->snmp_community)); + savePrefs(); + strcpy(reply, "OK - restart to apply"); + } else if (memcmp(config, "snmp ", 5) == 0) { + _prefs->snmp_enabled = memcmp(&config[5], "on", 2) == 0; + savePrefs(); + strcpy(reply, "OK - restart to apply"); } else if (memcmp(config, "tx ", 3) == 0) { _prefs->tx_power_dbm = atoi(&config[3]); savePrefs(); @@ -682,6 +1237,15 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep } } else if (memcmp(config, "bridge.source ", 14) == 0) { _prefs->bridge_pkt_src = memcmp(&config[14], "rx", 2) == 0; +#ifdef WITH_MQTT_BRIDGE + if (_prefs->bridge_pkt_src == 1) { + _prefs->mqtt_rx_enabled = 1; + _prefs->mqtt_tx_enabled = 0; + } else { + _prefs->mqtt_rx_enabled = 0; + _prefs->mqtt_tx_enabled = 1; + } +#endif savePrefs(); strcpy(reply, "OK"); #endif @@ -713,6 +1277,255 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep _callbacks->restartBridge(); savePrefs(); strcpy(reply, "OK"); +#endif +#ifdef WITH_MQTT_BRIDGE + } else if (memcmp(config, "mqtt.origin ", 12) == 0) { + StrHelper::strncpy(_prefs->mqtt_origin, &config[12], sizeof(_prefs->mqtt_origin)); + savePrefs(); + strcpy(reply, "OK"); + } else if (memcmp(config, "mqtt.iata ", 10) == 0) { + StrHelper::strncpy(_prefs->mqtt_iata, &config[10], sizeof(_prefs->mqtt_iata)); + for (int i = 0; _prefs->mqtt_iata[i]; i++) { + _prefs->mqtt_iata[i] = toupper(_prefs->mqtt_iata[i]); + } + savePrefs(); + _callbacks->restartBridge(); + strcpy(reply, "OK"); + } else if (memcmp(config, "mqtt.status ", 12) == 0) { + _prefs->mqtt_status_enabled = memcmp(&config[12], "on", 2) == 0; + savePrefs(); + strcpy(reply, "OK"); + } else if (memcmp(config, "mqtt.packets ", 13) == 0) { + _prefs->mqtt_packets_enabled = memcmp(&config[13], "on", 2) == 0; + savePrefs(); + strcpy(reply, "OK"); + } else if (memcmp(config, "mqtt.raw ", 9) == 0) { + _prefs->mqtt_raw_enabled = memcmp(&config[9], "on", 2) == 0; + savePrefs(); + strcpy(reply, "OK"); + } else if (memcmp(config, "mqtt.tx ", 8) == 0) { + if (memcmp(&config[8], "advert", 6) == 0) { + _prefs->mqtt_tx_enabled = 2; + } else { + _prefs->mqtt_tx_enabled = memcmp(&config[8], "on", 2) == 0 ? 1 : 0; + } + savePrefs(); + strcpy(reply, "OK"); + } else if (memcmp(config, "mqtt.rx ", 8) == 0) { + _prefs->mqtt_rx_enabled = memcmp(&config[8], "on", 2) == 0 ? 1 : 0; + savePrefs(); + strcpy(reply, "OK"); + } else if (memcmp(config, "mqtt.interval ", 14) == 0) { + uint32_t minutes = _atoi(&config[14]); + if (minutes >= 1 && minutes <= 60) { + _prefs->mqtt_status_interval = minutes * 60000; + savePrefs(); + _callbacks->restartBridge(); + sprintf(reply, "OK - interval set to %u minutes (%lu ms), bridge restarted", minutes, (unsigned long)_prefs->mqtt_status_interval); + } else { + strcpy(reply, "Error: interval must be between 1-60 minutes"); + } + } else if (memcmp(config, "wifi.ssid ", 10) == 0) { + StrHelper::strncpy(_prefs->wifi_ssid, &config[10], sizeof(_prefs->wifi_ssid)); + savePrefs(); + strcpy(reply, "OK"); + } else if (memcmp(config, "wifi.pwd ", 9) == 0) { + StrHelper::strncpy(_prefs->wifi_password, &config[9], sizeof(_prefs->wifi_password)); + savePrefs(); + strcpy(reply, "OK"); + } else if (memcmp(config, "wifi.powersave ", 15) == 0) { + const char* value = &config[15]; + uint8_t ps_value; + bool valid = false; + if (memcmp(value, "min", 3) == 0 && (value[3] == 0 || value[3] == ' ')) { + ps_value = 0; + valid = true; + } else if (memcmp(value, "none", 4) == 0 && (value[4] == 0 || value[4] == ' ')) { + ps_value = 1; + valid = true; + } else if (memcmp(value, "max", 3) == 0 && (value[3] == 0 || value[3] == ' ')) { + ps_value = 2; + valid = true; + } + if (!valid) { + strcpy(reply, "Error: must be none, min, or max"); + } else { + _prefs->wifi_power_save = ps_value; + savePrefs(); +#ifdef ESP_PLATFORM + if (WiFi.status() == WL_CONNECTED) { + wifi_ps_type_t ps_mode = (ps_value == 1) ? WIFI_PS_NONE : + (ps_value == 2) ? WIFI_PS_MAX_MODEM : WIFI_PS_MIN_MODEM; + esp_err_t ps_result = esp_wifi_set_ps(ps_mode); + if (ps_result == ESP_OK) { + const char* ps_name = (ps_value == 1) ? "none" : (ps_value == 2) ? "max" : "min"; + sprintf(reply, "OK - power save set to %s", ps_name); + } else { + sprintf(reply, "OK - saved, but failed to apply: %d", ps_result); + } + } else { + const char* ps_name = (ps_value == 1) ? "none" : (ps_value == 2) ? "max" : "min"; + sprintf(reply, "OK - saved as %s (will apply on next WiFi connection)", ps_name); + } +#else + const char* ps_name = (ps_value == 1) ? "none" : (ps_value == 2) ? "max" : "min"; + sprintf(reply, "OK - saved as %s", ps_name); +#endif + } + } else if (memcmp(config, "timezone ", 9) == 0) { + StrHelper::strncpy(_prefs->timezone_string, &config[9], sizeof(_prefs->timezone_string)); + savePrefs(); + strcpy(reply, "OK"); + } else if (memcmp(config, "timezone.offset ", 16) == 0) { + int8_t offset = _atoi(&config[16]); + if (offset >= -12 && offset <= 14) { + _prefs->timezone_offset = offset; + savePrefs(); + strcpy(reply, "OK"); + } else { + strcpy(reply, "Error: timezone offset must be between -12 and +14"); + } + } else if (config[0] == 'm' && config[1] == 'q' && config[2] == 't' && config[3] == 't' && + config[4] >= '1' && config[4] <= ('0' + MAX_MQTT_SLOTS) && config[5] == '.') { + // Slot-based commands: set mqtt1.preset , set mqtt1.server , etc. + int slot = config[4] - '1'; // 0-5 + const char* subcmd = &config[6]; + if (memcmp(subcmd, "preset ", 7) == 0) { + const char* preset_name = &subcmd[7]; + // Validate preset name + if (findMQTTPreset(preset_name) != nullptr || + strcmp(preset_name, MQTT_PRESET_CUSTOM) == 0 || + strcmp(preset_name, MQTT_PRESET_NONE) == 0) { + // Reject duplicate presets (except "none" and "custom") + int dup_slot = -1; + if (findMQTTPreset(preset_name) != nullptr) { + for (int s = 0; s < MAX_MQTT_SLOTS; s++) { + if (s != slot && strcmp(_prefs->mqtt_slot_preset[s], preset_name) == 0) { + dup_slot = s; + break; + } + } + } + if (dup_slot >= 0) { + sprintf(reply, "Error: preset '%s' is already assigned to slot %d", preset_name, dup_slot + 1); + } else { + StrHelper::strncpy(_prefs->mqtt_slot_preset[slot], preset_name, sizeof(_prefs->mqtt_slot_preset[slot])); + savePrefs(); + _callbacks->restartBridgeSlot(slot); + // Check if the slot has everything it needs to connect + const MQTTPresetDef* p = findMQTTPreset(preset_name); + if (p && p->topic_style == MQTT_TOPIC_MESHRANK && _prefs->mqtt_slot_token[slot][0] == '\0') { + sprintf(reply, "OK - slot %d preset: %s (run 'set mqtt%d.token ' to connect)", slot + 1, preset_name, slot + 1); + } else if (p && p->topic_style == MQTT_TOPIC_MESHCORE && + (strlen(_prefs->mqtt_iata) == 0 || strcmp(_prefs->mqtt_iata, "XXX") == 0)) { + sprintf(reply, "OK - slot %d preset: %s (run 'set mqtt.iata ' to publish)", slot + 1, preset_name); + } else { + sprintf(reply, "OK - slot %d preset: %s", slot + 1, preset_name); + } + } + } else { + strcpy(reply, "Error: unknown preset. Use 'get mqtt.presets'"); + } + } else if (memcmp(subcmd, "server ", 7) == 0) { + StrHelper::strncpy(_prefs->mqtt_slot_host[slot], &subcmd[7], sizeof(_prefs->mqtt_slot_host[slot])); + savePrefs(); + strcpy(reply, "OK"); + } else if (memcmp(subcmd, "port ", 5) == 0) { + int port = atoi(&subcmd[5]); + if (port > 0 && port <= 65535) { + _prefs->mqtt_slot_port[slot] = port; + savePrefs(); + strcpy(reply, "OK"); + } else { + strcpy(reply, "Error: port must be between 1 and 65535"); + } + } else if (memcmp(subcmd, "username ", 9) == 0) { + StrHelper::strncpy(_prefs->mqtt_slot_username[slot], &subcmd[9], sizeof(_prefs->mqtt_slot_username[slot])); + savePrefs(); + strcpy(reply, "OK"); + } else if (memcmp(subcmd, "password ", 9) == 0) { + StrHelper::strncpy(_prefs->mqtt_slot_password[slot], &subcmd[9], sizeof(_prefs->mqtt_slot_password[slot])); + savePrefs(); + strcpy(reply, "OK"); + } else if (memcmp(subcmd, "token ", 6) == 0) { + StrHelper::strncpy(_prefs->mqtt_slot_token[slot], &subcmd[6], sizeof(_prefs->mqtt_slot_token[slot])); + savePrefs(); + _callbacks->restartBridgeSlot(slot); + sprintf(reply, "OK - slot %d token set", slot + 1); + } else if (memcmp(subcmd, "topic ", 6) == 0) { + if (strcmp(_prefs->mqtt_slot_preset[slot], "custom") != 0) { + sprintf(reply, "Error: topic template only applies to custom preset slots"); + } else { + StrHelper::strncpy(_prefs->mqtt_slot_topic[slot], &subcmd[6], sizeof(_prefs->mqtt_slot_topic[slot])); + savePrefs(); + _callbacks->restartBridgeSlot(slot); + sprintf(reply, "OK - slot %d topic: %s", slot + 1, _prefs->mqtt_slot_topic[slot]); + } + } else if (memcmp(subcmd, "audience ", 9) == 0) { + StrHelper::strncpy(_prefs->mqtt_slot_audience[slot], &subcmd[9], sizeof(_prefs->mqtt_slot_audience[slot])); + savePrefs(); + _callbacks->restartBridgeSlot(slot); + if (_prefs->mqtt_slot_audience[slot][0] != '\0') { + sprintf(reply, "OK - slot %d JWT audience: %s", slot + 1, _prefs->mqtt_slot_audience[slot]); + } else { + sprintf(reply, "OK - slot %d JWT audience cleared (using username/password auth)", slot + 1); + } + } else if (memcmp(subcmd, "audience", 8) == 0 && subcmd[8] == '\0') { + // "set mqttN.audience" with no value — clear the audience + _prefs->mqtt_slot_audience[slot][0] = '\0'; + savePrefs(); + _callbacks->restartBridgeSlot(slot); + sprintf(reply, "OK - slot %d JWT audience cleared (using username/password auth)", slot + 1); + } else { + sprintf(reply, "unknown config: %s", config); + } + } else if (memcmp(config, "mqtt.analyzer.us ", 17) == 0) { + const int slot = 0; + if (memcmp(&config[17], "on", 2) == 0) { + StrHelper::strncpy(_prefs->mqtt_slot_preset[slot], "analyzer-us", sizeof(_prefs->mqtt_slot_preset[slot])); + } else { + StrHelper::strncpy(_prefs->mqtt_slot_preset[slot], MQTT_PRESET_NONE, sizeof(_prefs->mqtt_slot_preset[slot])); + } + savePrefs(); + _callbacks->restartBridgeSlot(slot); + strcpy(reply, "OK"); + } else if (memcmp(config, "mqtt.analyzer.eu ", 17) == 0) { + const int slot = 1; + if (memcmp(&config[17], "on", 2) == 0) { + StrHelper::strncpy(_prefs->mqtt_slot_preset[slot], "analyzer-eu", sizeof(_prefs->mqtt_slot_preset[slot])); + } else { + StrHelper::strncpy(_prefs->mqtt_slot_preset[slot], MQTT_PRESET_NONE, sizeof(_prefs->mqtt_slot_preset[slot])); + } + savePrefs(); + _callbacks->restartBridgeSlot(slot); + strcpy(reply, "OK"); + } else if (memcmp(config, "mqtt.owner ", 11) == 0) { + const char* owner_key = &config[11]; + int key_len = strlen(owner_key); + if (key_len == 64) { + bool valid_key = true; + for (int i = 0; i < key_len; i++) { + if (!((owner_key[i] >= '0' && owner_key[i] <= '9') || + (owner_key[i] >= 'A' && owner_key[i] <= 'F') || + (owner_key[i] >= 'a' && owner_key[i] <= 'f'))) { + valid_key = false; + break; + } + } + if (valid_key) { + StrHelper::strncpy(_prefs->mqtt_owner_public_key, owner_key, sizeof(_prefs->mqtt_owner_public_key)); + savePrefs(); + strcpy(reply, "OK"); + } else { + strcpy(reply, "Error: invalid hex characters in public key"); + } + } else { + strcpy(reply, "Error: public key must be 64 hex characters (32 bytes)"); + } + } else if (memcmp(config, "mqtt.email ", 11) == 0) { + StrHelper::strncpy(_prefs->mqtt_email, &config[11], sizeof(_prefs->mqtt_email)); + savePrefs(); + strcpy(reply, "OK"); #endif } else if (memcmp(config, "adc.multiplier ", 15) == 0) { _prefs->adc_multiplier = atof(&config[15]); @@ -728,8 +1541,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep strcpy(reply, "Error: unsupported by this board"); }; } else { - strcpy(reply, "unknown config: "); - StrHelper::strncpy(&reply[16], config, 160-17); + sprintf(reply, "unknown config: %s", config); } } @@ -746,6 +1558,8 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep sprintf(reply, "> %d", (uint32_t) _prefs->interference_threshold); } else if (memcmp(config, "agc.reset.interval", 18) == 0) { sprintf(reply, "> %d", ((uint32_t) _prefs->agc_reset_interval) * 4); + } else if (memcmp(config, "radio.watchdog", 14) == 0) { + sprintf(reply, "> %d", (uint32_t)_prefs->radio_watchdog_minutes); } else if (memcmp(config, "multi.acks", 10) == 0) { sprintf(reply, "> %d", (uint32_t) _prefs->multi_acks); } else if (memcmp(config, "allow.read.only", 15) == 0) { @@ -787,11 +1601,10 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep } else if (memcmp(config, "direct.txdelay", 14) == 0) { sprintf(reply, "> %s", StrHelper::ftoa(_prefs->direct_tx_delay_factor)); } else if (memcmp(config, "owner.info", 10) == 0) { - auto start = reply; *reply++ = '>'; *reply++ = ' '; const char* sp = _prefs->owner_info; - while (*sp && reply - start < 159) { + while (*sp) { *reply++ = (*sp == '\n') ? '|' : *sp; // translate newline back to orig '|' sp++; } @@ -808,6 +1621,10 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep } else { strcpy(reply, "> strict"); } + } else if (memcmp(config, "snmp.community", 14) == 0) { + sprintf(reply, "> %s", _prefs->snmp_community); + } else if (memcmp(config, "snmp", 4) == 0 && (config[4] == '\0' || config[4] == '\n' || config[4] == '\r')) { + strcpy(reply, _prefs->snmp_enabled ? "> on" : "> off"); } else if (memcmp(config, "tx", 2) == 0 && (config[2] == 0 || config[2] == ' ')) { sprintf(reply, "> %d", (int32_t) _prefs->tx_power_dbm); } else if (memcmp(config, "freq", 4) == 0) { @@ -844,6 +1661,165 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep sprintf(reply, "> %d", (uint32_t)_prefs->bridge_channel); } else if (memcmp(config, "bridge.secret", 13) == 0) { sprintf(reply, "> %s", _prefs->bridge_secret); +#endif +#ifdef WITH_MQTT_BRIDGE + } else if (memcmp(config, "mqtt.origin", 11) == 0) { + sprintf(reply, "> %s", _prefs->mqtt_origin); + } else if (memcmp(config, "mqtt.iata", 9) == 0) { + sprintf(reply, "> %s", _prefs->mqtt_iata); + } else if (memcmp(config, "mqtt.presets", 12) == 0 && (config[12] == '\0' || config[12] == ' ')) { + int start = 0; + if (config[12] == ' ') { + const char* start_arg = &config[13]; + if (*start_arg == '\0') { + strcpy(reply, "Error: usage get mqtt.presets [start]"); + return; + } + for (const char* sp = start_arg; *sp; sp++) { + if (*sp < '0' || *sp > '9') { + strcpy(reply, "Error: usage get mqtt.presets [start]"); + return; + } + } + start = (int)_atoi(start_arg); + } + formatMQTTPresetListReply(reply, 160, start); + } else if (memcmp(config, "mqtt.status", 11) == 0) { + MQTTBridge::formatMqttStatusReply(reply, 160, _prefs); + } else if (memcmp(config, "mqtt.packets", 12) == 0) { + sprintf(reply, "> %s", _prefs->mqtt_packets_enabled ? "on" : "off"); + } else if (memcmp(config, "mqtt.raw", 8) == 0) { + sprintf(reply, "> %s", _prefs->mqtt_raw_enabled ? "on" : "off"); + } else if (memcmp(config, "mqtt.tx", 7) == 0) { + const char* tx_str = _prefs->mqtt_tx_enabled == 2 ? "advert" : (_prefs->mqtt_tx_enabled ? "on" : "off"); + sprintf(reply, "> %s", tx_str); + } else if (memcmp(config, "mqtt.rx", 7) == 0) { + sprintf(reply, "> %s", _prefs->mqtt_rx_enabled ? "on" : "off"); + } else if (memcmp(config, "mqtt.interval", 13) == 0) { + uint32_t minutes = (_prefs->mqtt_status_interval + 29999) / 60000; + sprintf(reply, "> %u minutes (%lu ms)", minutes, (unsigned long)_prefs->mqtt_status_interval); + } else if (config[0] == 'm' && config[1] == 'q' && config[2] == 't' && config[3] == 't' && + config[4] >= '1' && config[4] <= ('0' + MAX_MQTT_SLOTS) && config[5] == '.') { + // Slot-based commands: get mqtt1.preset, get mqtt1.server, etc. + int slot = config[4] - '1'; // 0-5 + const char* subcmd = &config[6]; + if (memcmp(subcmd, "preset", 6) == 0) { + sprintf(reply, "> %s", _prefs->mqtt_slot_preset[slot]); + } else if (memcmp(subcmd, "server", 6) == 0) { + sprintf(reply, "> %s", _prefs->mqtt_slot_host[slot]); + } else if (memcmp(subcmd, "port", 4) == 0) { + sprintf(reply, "> %d", _prefs->mqtt_slot_port[slot]); + } else if (memcmp(subcmd, "username", 8) == 0) { + sprintf(reply, "> %s", _prefs->mqtt_slot_username[slot]); + } else if (memcmp(subcmd, "password", 8) == 0) { + sprintf(reply, "> %s", _prefs->mqtt_slot_password[slot]); + } else if (memcmp(subcmd, "token", 5) == 0) { + if (_prefs->mqtt_slot_token[slot][0] != '\0') { + sprintf(reply, "> %s", _prefs->mqtt_slot_token[slot]); + } else { + strcpy(reply, "> (not set)"); + } + } else if (memcmp(subcmd, "topic", 5) == 0) { + if (_prefs->mqtt_slot_topic[slot][0] != '\0') { + sprintf(reply, "> %s", _prefs->mqtt_slot_topic[slot]); + } else { + strcpy(reply, "> (default: meshcore/{iata}/{device}/{type})"); + } + } else if (memcmp(subcmd, "audience", 8) == 0) { + if (_prefs->mqtt_slot_audience[slot][0] != '\0') { + sprintf(reply, "> %s", _prefs->mqtt_slot_audience[slot]); + } else { + strcpy(reply, "> (not set — custom slots use username/password auth)"); + } + } else if (memcmp(subcmd, "diag", 4) == 0) { + MQTTBridge::formatSlotDiagReply(reply, 160, slot); + } else { + sprintf(reply, "??: %s", config); + } + } else if (memcmp(config, "wifi.ssid", 9) == 0) { + sprintf(reply, "> %s", _prefs->wifi_ssid); + } else if (memcmp(config, "wifi.pwd", 8) == 0) { + sprintf(reply, "> %s", _prefs->wifi_password); + } else if (memcmp(config, "wifi.status", 11) == 0) { + wl_status_t status = WiFi.status(); + const char* status_str; + switch (status) { + case WL_CONNECTED: status_str = "connected"; break; + case WL_NO_SSID_AVAIL: status_str = "no_ssid"; break; + case WL_CONNECT_FAILED: status_str = "connect_failed"; break; + case WL_CONNECTION_LOST: status_str = "connection_lost"; break; + case WL_DISCONNECTED: status_str = "disconnected"; break; + case 255: status_str = "not_started"; break; + default: status_str = "unknown"; break; + } + if (status == WL_CONNECTED) { + sprintf(reply, "> %s, IP: %s, RSSI: %d dBm", status_str, WiFi.localIP().toString().c_str(), WiFi.RSSI()); +#ifdef WITH_MQTT_BRIDGE + unsigned long connect_at = MQTTBridge::getWifiConnectedAtMillis(); + if (connect_at != 0) { + unsigned long uptime_ms = millis() - connect_at; + unsigned long uptime_sec = uptime_ms / 1000; + unsigned long d = uptime_sec / 86400; + unsigned long h = (uptime_sec % 86400) / 3600; + unsigned long m = (uptime_sec % 3600) / 60; + unsigned long s = uptime_sec % 60; + size_t len = strlen(reply); + const size_t reply_remaining = 128; + if (d > 0) { + snprintf(reply + len, reply_remaining, ", uptime: %lud %luh %lum %lus", d, h, m, s); + } else if (h > 0) { + snprintf(reply + len, reply_remaining, ", uptime: %luh %lum %lus", h, m, s); + } else if (m > 0) { + snprintf(reply + len, reply_remaining, ", uptime: %lum %lus", m, s); + } else { + snprintf(reply + len, reply_remaining, ", uptime: %lus", s); + } + } +#endif + } else { +#ifdef WITH_MQTT_BRIDGE + uint8_t reason = MQTTBridge::getLastWifiDisconnectReason(); + if (reason != 0) { + const char* desc = MQTTBridge::wifiReasonStr(reason); + if (desc) { + sprintf(reply, "> %s: %s (reason: %d)", status_str, desc, reason); + } else { + sprintf(reply, "> %s: reason %d", status_str, reason); + } + } else { + sprintf(reply, "> %s (code: %d)", status_str, status); + } +#else + sprintf(reply, "> %s (code: %d)", status_str, status); +#endif + } + } else if (memcmp(config, "wifi.powersave", 14) == 0) { + uint8_t ps = _prefs->wifi_power_save; + const char* ps_name = (ps == 1) ? "none" : (ps == 2) ? "max" : "min"; + sprintf(reply, "> %s", ps_name); + } else if (memcmp(config, "timezone", 8) == 0) { + sprintf(reply, "> %s", _prefs->timezone_string); + } else if (memcmp(config, "timezone.offset", 15) == 0) { + sprintf(reply, "> %d", _prefs->timezone_offset); + } else if (memcmp(config, "mqtt.analyzer.us", 17) == 0) { + sprintf(reply, "> %s", strcmp(_prefs->mqtt_slot_preset[0], "analyzer-us") == 0 ? "on" : "off"); + } else if (memcmp(config, "mqtt.analyzer.eu", 17) == 0) { + sprintf(reply, "> %s", strcmp(_prefs->mqtt_slot_preset[1], "analyzer-eu") == 0 ? "on" : "off"); + } else if (sender_timestamp == 0 && memcmp(config, "mqtt.owner", 10) == 0) { + if (_prefs->mqtt_owner_public_key[0] != '\0') { + sprintf(reply, "> %s", _prefs->mqtt_owner_public_key); + } else { + strcpy(reply, "> (not set)"); + } + } else if (sender_timestamp == 0 && memcmp(config, "mqtt.email", 10) == 0) { + if (_prefs->mqtt_email[0] != '\0') { + sprintf(reply, "> %s", _prefs->mqtt_email); + } else { + strcpy(reply, "> (not set)"); + } + } else if (memcmp(config, "mqtt.config.valid", 17) == 0) { + bool valid = MQTTBridge::isConfigValid(_prefs); + sprintf(reply, "> %s", valid ? "valid" : "invalid"); #endif } else if (memcmp(config, "bootloader.ver", 14) == 0) { #ifdef NRF52_PLATFORM diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index ffdc7c6536..aaa7a77e0b 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -4,9 +4,10 @@ #include #include #include +#include // For MAX_MQTT_SLOTS (used in NodePrefs struct layout) #include -#if defined(WITH_RS232_BRIDGE) || defined(WITH_ESPNOW_BRIDGE) +#if defined(WITH_RS232_BRIDGE) || defined(WITH_ESPNOW_BRIDGE) || defined(WITH_MQTT_BRIDGE) #define WITH_BRIDGE #endif @@ -28,6 +29,7 @@ struct NodePrefs { // persisted to file int8_t tx_power_dbm; uint8_t disable_fwd; uint8_t advert_interval; // minutes / 2 + uint8_t rx_boosted_gain; // power settings (file offset 79) uint8_t flood_advert_interval; // hours float rx_delay_base; float tx_delay_factor; @@ -42,10 +44,11 @@ struct NodePrefs { // persisted to file uint8_t flood_max; uint8_t interference_threshold; uint8_t agc_reset_interval; // secs / 4 + uint8_t path_hash_mode; // which path mode to use when sending // Bridge settings uint8_t bridge_enabled; // boolean uint16_t bridge_delay; // milliseconds (default 500 ms) - uint8_t bridge_pkt_src; // 0 = logTx, 1 = logRx (default logTx) + uint8_t bridge_pkt_src; // 0 = logTx, 1 = logRx (default logRx) uint32_t bridge_baud; // 9600, 19200, 38400, 57600, 115200 (default 115200) uint8_t bridge_channel; // 1-14 (ESP-NOW only) char bridge_secret[16]; // for XOR encryption of bridge packets (ESP-NOW only) @@ -58,11 +61,162 @@ struct NodePrefs { // persisted to file uint32_t discovery_mod_timestamp; float adc_multiplier; char owner_info[120]; - uint8_t rx_boosted_gain; // power settings - uint8_t path_hash_mode; // which path mode to use when sending + // MQTT settings (stored separately in /mqtt_prefs, but kept here for backward compatibility) + char mqtt_origin[32]; // Device name for MQTT topics + char mqtt_iata[8]; // IATA code for MQTT topics + uint8_t mqtt_status_enabled; // Enable status messages + uint8_t mqtt_packets_enabled; // Enable packet messages + uint8_t mqtt_raw_enabled; // Enable raw messages + uint8_t mqtt_tx_enabled; // TX packet uplinking: 0=off, 1=all, 2=advert (self-originated only) + uint32_t mqtt_status_interval; // Status publish interval (ms) + uint8_t mqtt_rx_enabled; // Enable RX packet uplinking (default: on) + + // WiFi settings + char wifi_ssid[32]; // WiFi SSID + char wifi_password[64]; // WiFi password + uint8_t wifi_power_save; // WiFi power save mode: 0=min, 1=none, 2=max (default: 1=none) + + // Timezone settings + char timezone_string[32]; // Timezone string (e.g., "America/Los_Angeles") + int8_t timezone_offset; // Timezone offset in hours (-12 to +14) - fallback + + // MQTT slot presets (up to MAX_MQTT_SLOTS, each can be a preset name or "custom"/"none") + char mqtt_slot_preset[MAX_MQTT_SLOTS][24]; // e.g. "analyzer-us", "meshmapper", "custom", "none" + + // Per-slot custom broker settings (only used when slot preset is "custom") + char mqtt_slot_host[MAX_MQTT_SLOTS][64]; + uint16_t mqtt_slot_port[MAX_MQTT_SLOTS]; + char mqtt_slot_username[MAX_MQTT_SLOTS][32]; + char mqtt_slot_password[MAX_MQTT_SLOTS][64]; + + // Shared MQTT authentication + char mqtt_owner_public_key[65]; // Owner public key (hex string, same length as repeater public key) + char mqtt_email[64]; // Owner email address for matching nodes with owners + + // Per-slot extended fields + char mqtt_slot_token[MAX_MQTT_SLOTS][48]; // Per-slot token (e.g., MeshRank account token) + char mqtt_slot_topic[MAX_MQTT_SLOTS][96]; // Per-slot custom topic template (custom preset only) + char mqtt_slot_audience[MAX_MQTT_SLOTS][64]; // JWT audience (non-empty enables JWT auth for custom slots) + uint8_t loop_detect; + + // SNMP settings (optional, only used when WITH_SNMP is defined) + uint8_t snmp_enabled; // boolean: 0=off, 1=on + char snmp_community[24]; // community string (default "public") + uint8_t radio_watchdog_minutes; // 0=disabled, 1-120 minutes }; +#ifdef WITH_MQTT_BRIDGE +// Old MQTT preferences layout (pre-slot firmware) — used only for migration detection +struct OldMQTTPrefs { + char mqtt_origin[32]; + char mqtt_iata[8]; + uint8_t mqtt_status_enabled; + uint8_t mqtt_packets_enabled; + uint8_t mqtt_raw_enabled; + uint8_t mqtt_tx_enabled; + uint32_t mqtt_status_interval; + char wifi_ssid[32]; + char wifi_password[64]; + uint8_t wifi_power_save; + char timezone_string[32]; + int8_t timezone_offset; + char mqtt_server[64]; + uint16_t mqtt_port; + char mqtt_username[32]; + char mqtt_password[64]; + uint8_t mqtt_analyzer_us_enabled; + uint8_t mqtt_analyzer_eu_enabled; + char mqtt_owner_public_key[65]; + char mqtt_email[64]; +}; + +// MQTT preferences stored in separate file to avoid conflicts with upstream NodePrefs changes +struct MQTTPrefs { + // MQTT settings + char mqtt_origin[32]; // Device name for MQTT topics + char mqtt_iata[8]; // IATA code for MQTT topics + uint8_t mqtt_status_enabled; // Enable status messages + uint8_t mqtt_packets_enabled; // Enable packet messages + uint8_t mqtt_raw_enabled; // Enable raw messages + uint8_t mqtt_tx_enabled; // Enable TX packet uplinking + uint32_t mqtt_status_interval; // Status publish interval (ms) + + // WiFi settings + char wifi_ssid[32]; // WiFi SSID + char wifi_password[64]; // WiFi password + uint8_t wifi_power_save; // WiFi power save mode: 0=min, 1=none, 2=max (default: 1=none) + + // Timezone settings + char timezone_string[32]; // Timezone string (e.g., "America/Los_Angeles") + int8_t timezone_offset; // Timezone offset in hours (-12 to +14) - fallback + + // Slot presets (up to MAX_MQTT_SLOTS) + char mqtt_slot_preset[MAX_MQTT_SLOTS][24]; // e.g. "analyzer-us", "meshmapper", "custom", "none" + + // Per-slot custom broker settings (only used when preset is "custom") + char mqtt_slot_host[MAX_MQTT_SLOTS][64]; + uint16_t mqtt_slot_port[MAX_MQTT_SLOTS]; + char mqtt_slot_username[MAX_MQTT_SLOTS][32]; + char mqtt_slot_password[MAX_MQTT_SLOTS][64]; + + // Shared authentication + char mqtt_owner_public_key[65]; // Owner public key (hex string) + char mqtt_email[64]; // Owner email address + + // --- Legacy fields (vestigial, kept for binary compatibility) --- + // Migration now uses OldMQTTPrefs/ThreeSlotMQTTPrefs structs. These fields are unused + // but must remain to preserve byte offsets for devices that already saved a new-format /mqtt_prefs file. + uint8_t _legacy_analyzer_us_enabled; + uint8_t _legacy_analyzer_eu_enabled; + char _legacy_mqtt_server[64]; + uint16_t _legacy_mqtt_port; + char _legacy_mqtt_username[32]; + char _legacy_mqtt_password[64]; + + // --- New fields (appended at end for migration safety) --- + char mqtt_slot_token[MAX_MQTT_SLOTS][48]; // Per-slot token (e.g., MeshRank account token) + char mqtt_slot_topic[MAX_MQTT_SLOTS][96]; // Per-slot custom topic template (custom preset only) + char mqtt_slot_audience[MAX_MQTT_SLOTS][64]; // JWT audience (non-empty enables JWT auth for custom slots) + + // --- Appended fields (added after initial 6-slot migration) --- + uint8_t mqtt_rx_enabled; // Enable RX packet uplinking (default: on) +}; + +// 3-slot MQTTPrefs layout — used for migrating from 3-slot to 6-slot format. +// Changing array sizes from [3] to [6] shifts all field offsets, so raw file.read() +// into the new struct would corrupt data. This struct preserves the old binary layout. +struct ThreeSlotMQTTPrefs { + char mqtt_origin[32]; + char mqtt_iata[8]; + uint8_t mqtt_status_enabled; + uint8_t mqtt_packets_enabled; + uint8_t mqtt_raw_enabled; + uint8_t mqtt_tx_enabled; + uint32_t mqtt_status_interval; + char wifi_ssid[32]; + char wifi_password[64]; + uint8_t wifi_power_save; + char timezone_string[32]; + int8_t timezone_offset; + char mqtt_slot_preset[3][24]; + char mqtt_slot_host[3][64]; + uint16_t mqtt_slot_port[3]; + char mqtt_slot_username[3][32]; + char mqtt_slot_password[3][64]; + char mqtt_owner_public_key[65]; + char mqtt_email[64]; + uint8_t _legacy_analyzer_us_enabled; + uint8_t _legacy_analyzer_eu_enabled; + char _legacy_mqtt_server[64]; + uint16_t _legacy_mqtt_port; + char _legacy_mqtt_username[32]; + char _legacy_mqtt_password[64]; + char mqtt_slot_token[3][48]; + char mqtt_slot_topic[3][96]; +}; +#endif + class CommonCLICallbacks { public: virtual void savePrefs() = 0; @@ -83,6 +237,7 @@ class CommonCLICallbacks { }; virtual void formatStatsReply(char *reply) = 0; virtual void formatRadioStatsReply(char *reply) = 0; + virtual void formatRadioDiagReply(char *reply) { strcpy(reply, "Not supported"); } virtual void formatPacketStatsReply(char *reply) = 0; virtual mesh::LocalIdentity& getSelfId() = 0; virtual void saveIdentity(const mesh::LocalIdentity& new_id) = 0; @@ -107,6 +262,15 @@ class CommonCLICallbacks { // no op by default }; + virtual void restartBridgeSlot(int slot) { + // Default: fall back to full restart + restartBridge(); + }; + + virtual int getQueueSize() { + return 0; // no op by default + }; + virtual void setRxBoostedGain(bool enable) { // no op by default }; @@ -121,10 +285,19 @@ class CommonCLI { RegionMap* _region_map; ClientACL* _acl; char tmp[PRV_KEY_SIZE*2 + 4]; +#ifdef WITH_MQTT_BRIDGE + MQTTPrefs _mqtt_prefs; +#endif mesh::RTCClock* getRTCClock() { return _rtc; } void savePrefs(); void loadPrefsInt(FILESYSTEM* _fs, const char* filename); +#ifdef WITH_MQTT_BRIDGE + void loadMQTTPrefs(FILESYSTEM* fs); + void saveMQTTPrefs(FILESYSTEM* fs); + void syncMQTTPrefsToNodePrefs(); + void syncNodePrefsToMQTTPrefs(); +#endif void handleRegionCmd(char* command, char* reply); void handleGetCmd(uint32_t sender_timestamp, char* command, char* reply); @@ -137,5 +310,6 @@ class CommonCLI { void loadPrefs(FILESYSTEM* _fs); void savePrefs(FILESYSTEM* _fs); void handleCommand(uint32_t sender_timestamp, char* command, char* reply); + mesh::MainBoard* getBoard() { return _board; } uint8_t buildAdvertData(uint8_t node_type, uint8_t* app_data); }; diff --git a/src/helpers/JWTHelper.cpp b/src/helpers/JWTHelper.cpp new file mode 100644 index 0000000000..a7732b10fe --- /dev/null +++ b/src/helpers/JWTHelper.cpp @@ -0,0 +1,201 @@ +#ifdef WITH_MQTT_BRIDGE +#include "JWTHelper.h" +#include +#include +#include +#include "ed_25519.h" +#include "mbedtls/base64.h" + +// Base64 URL encoding table (without padding) +static const char base64url_chars[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + +bool JWTHelper::createAuthToken( + const mesh::LocalIdentity& identity, + const char* audience, + unsigned long issuedAt, + unsigned long expiresIn, + char* token, + size_t tokenSize, + const char* owner, + const char* client, + const char* email +) { + if (!audience || !token || tokenSize == 0) { + return false; + } + + // Use current time if not specified + if (issuedAt == 0) { + issuedAt = time(nullptr); + } + + // Create header + char header[256]; + size_t headerLen = createHeader(header, sizeof(header)); + if (headerLen == 0) { + return false; + } + + // Get public key as UPPERCASE HEX string + char publicKeyHex[65]; + mesh::Utils::toHex(publicKeyHex, identity.pub_key, PUB_KEY_SIZE); + for (int i = 0; publicKeyHex[i]; i++) { + publicKeyHex[i] = toupper(publicKeyHex[i]); + } + + // Create payload + char payload[512]; + size_t payloadLen = createPayload(publicKeyHex, audience, issuedAt, expiresIn, payload, sizeof(payload), owner, client, email); + if (payloadLen == 0) { + return false; + } + + // Create signing input: header.payload + char signingInput[768]; + size_t signingInputLen = headerLen + 1 + payloadLen; + if (signingInputLen >= sizeof(signingInput)) { + return false; + } + + memcpy(signingInput, header, headerLen); + signingInput[headerLen] = '.'; + memcpy(signingInput + headerLen + 1, payload, payloadLen); + + // Sign the data using direct Ed25519 signing + uint8_t signature[64]; + mesh::LocalIdentity identity_copy = identity; + + uint8_t export_buffer[96]; + size_t exported_size = identity_copy.writeTo(export_buffer, sizeof(export_buffer)); + + if (exported_size != 96) { + return false; + } + + uint8_t* private_key = export_buffer; + uint8_t* public_key = export_buffer + 64; + + ed25519_sign(signature, (const unsigned char*)signingInput, signingInputLen, public_key, private_key); + + // Verify the signature locally + int verify_result = ed25519_verify(signature, (const unsigned char*)signingInput, signingInputLen, public_key); + if (verify_result != 1) { + if (Serial.availableForWrite() > 0) Serial.println("JWTHelper: Signature verification failed!"); + return false; + } + + // Convert signature to hex + char signatureHex[129]; + for (int i = 0; i < 64; i++) { + sprintf(signatureHex + (i * 2), "%02X", signature[i]); + } + signatureHex[128] = '\0'; + + // Create final token: header.payload.signatureHex (MeshCore Decoder format) + size_t sigHexLen = strlen(signatureHex); + size_t totalLen = headerLen + 1 + payloadLen + 1 + sigHexLen; + if (totalLen >= tokenSize) { + return false; + } + + memcpy(token, header, headerLen); + token[headerLen] = '.'; + memcpy(token + headerLen + 1, payload, payloadLen); + token[headerLen + 1 + payloadLen] = '.'; + memcpy(token + headerLen + 1 + payloadLen + 1, signatureHex, sigHexLen); + token[totalLen] = '\0'; + + return true; +} + +size_t JWTHelper::base64UrlEncode(const uint8_t* input, size_t inputLen, char* output, size_t outputSize) { + if (!input || !output || outputSize == 0) { + return 0; + } + + size_t outlen = 0; + int ret = mbedtls_base64_encode((unsigned char*)output, outputSize - 1, &outlen, input, inputLen); + + if (ret != 0) { + return 0; + } + + // Convert to base64 URL format in-place (replace + with -, / with _, remove padding =) + for (size_t i = 0; i < outlen; i++) { + if (output[i] == '+') { + output[i] = '-'; + } else if (output[i] == '/') { + output[i] = '_'; + } + } + + // Remove padding '=' characters + while (outlen > 0 && output[outlen-1] == '=') { + outlen--; + } + output[outlen] = '\0'; + return outlen; +} + +size_t JWTHelper::createHeader(char* output, size_t outputSize) { + // Create JWT header: {"alg":"Ed25519","typ":"JWT"} + DynamicJsonDocument doc(256); + doc["alg"] = "Ed25519"; + doc["typ"] = "JWT"; + + char jsonBuffer[256]; + size_t len = serializeJson(doc, jsonBuffer, sizeof(jsonBuffer)); + if (len == 0 || len >= sizeof(jsonBuffer)) { + return 0; + } + + return base64UrlEncode((uint8_t*)jsonBuffer, len, output, outputSize); +} + +size_t JWTHelper::createPayload( + const char* publicKey, + const char* audience, + unsigned long issuedAt, + unsigned long expiresIn, + char* output, + size_t outputSize, + const char* owner, + const char* client, + const char* email +) { + // Create JWT payload + DynamicJsonDocument doc(512); + doc["publicKey"] = publicKey; + doc["aud"] = audience; + doc["iat"] = issuedAt; + + if (expiresIn > 0) { + doc["exp"] = issuedAt + expiresIn; + } + + // Add optional owner field if provided + if (owner && strlen(owner) > 0) { + doc["owner"] = owner; + } + + // Add optional client field if provided + if (client && strlen(client) > 0) { + doc["client"] = client; + } + + // Add optional email field if provided + if (email && strlen(email) > 0) { + doc["email"] = email; + } + + char jsonBuffer[512]; + size_t len = serializeJson(doc, jsonBuffer, sizeof(jsonBuffer)); + if (len == 0 || len >= sizeof(jsonBuffer)) { + return 0; + } + + return base64UrlEncode((uint8_t*)jsonBuffer, len, output, outputSize); +} + + +#endif diff --git a/src/helpers/JWTHelper.h b/src/helpers/JWTHelper.h new file mode 100644 index 0000000000..a84889d891 --- /dev/null +++ b/src/helpers/JWTHelper.h @@ -0,0 +1,87 @@ +#pragma once + +#include "MeshCore.h" +#include "Identity.h" + +/** + * JWT Helper for creating authentication tokens + * + * This class provides functionality to create JWT-style authentication tokens + * signed with Ed25519 private keys for MQTT authentication. + */ +class JWTHelper { +public: + /** + * Create an authentication token for MQTT authentication + * + * @param identity LocalIdentity instance for signing + * @param audience Audience string (e.g., "mqtt-us-v1.letsmesh.net") + * @param issuedAt Unix timestamp (0 for current time) + * @param expiresIn Expiration time in seconds (0 for no expiration) + * @param token Buffer to store the resulting token + * @param tokenSize Size of the token buffer + * @param owner Optional owner public key in hex format (nullptr if not set) + * @param client Optional client string (nullptr if not set) + * @param email Optional email address (nullptr if not set) + * @return true if token was created successfully + */ + static bool createAuthToken( + const mesh::LocalIdentity& identity, + const char* audience, + unsigned long issuedAt = 0, + unsigned long expiresIn = 0, + char* token = nullptr, + size_t tokenSize = 0, + const char* owner = nullptr, + const char* client = nullptr, + const char* email = nullptr + ); + +private: + /** + * Base64 URL encode data + * + * @param input Input data + * @param inputLen Length of input data + * @param output Output buffer + * @param outputSize Size of output buffer + * @return Length of encoded data, or 0 on error + */ + static size_t base64UrlEncode(const uint8_t* input, size_t inputLen, char* output, size_t outputSize); + + /** + * Create JWT header + * + * @param output Output buffer + * @param outputSize Size of output buffer + * @return Length of header, or 0 on error + */ + static size_t createHeader(char* output, size_t outputSize); + + /** + * Create JWT payload + * + * @param publicKey Public key in hex format + * @param audience Audience string + * @param issuedAt Issued at timestamp + * @param expiresIn Expiration time in seconds (0 for no expiration) + * @param output Output buffer + * @param outputSize Size of output buffer + * @param owner Optional owner public key in hex format (nullptr if not set) + * @param client Optional client string (nullptr if not set) + * @param email Optional email address (nullptr if not set) + * @return Length of payload, or 0 on error + */ + static size_t createPayload( + const char* publicKey, + const char* audience, + unsigned long issuedAt, + unsigned long expiresIn, + char* output, + size_t outputSize, + const char* owner = nullptr, + const char* client = nullptr, + const char* email = nullptr + ); + +}; diff --git a/src/helpers/MQTTMessageBuilder.cpp b/src/helpers/MQTTMessageBuilder.cpp new file mode 100644 index 0000000000..9781399d3c --- /dev/null +++ b/src/helpers/MQTTMessageBuilder.cpp @@ -0,0 +1,435 @@ +#ifdef WITH_MQTT_BRIDGE +#include "MQTTMessageBuilder.h" +#include +#include +#include +#include "MeshCore.h" + +int MQTTMessageBuilder::buildStatusMessage( + JsonDocument& doc, + const char* origin, + const char* origin_id, + const char* model, + const char* firmware_version, + const char* radio, + const char* client_version, + const char* status, + const char* timestamp, + char* buffer, + size_t buffer_size, + int battery_mv, + int uptime_secs, + int errors, + int queue_len, + int noise_floor, + int tx_air_secs, + int rx_air_secs, + int recv_errors, + int internal_heap +) { + // doc is provided by the caller (heap-allocated DynamicJsonDocument in MQTTBridge), + // keeping this 768-byte scratch space off the MQTT task stack. + doc.clear(); + JsonObject root = doc.to(); + + root["status"] = status; + root["timestamp"] = timestamp; + root["origin"] = origin; + root["origin_id"] = origin_id; + root["model"] = model; + root["firmware_version"] = firmware_version; + root["radio"] = radio; + root["client_version"] = client_version; + + // Add stats object if any stats are provided + if (battery_mv >= 0 || uptime_secs >= 0 || errors >= 0 || queue_len >= 0 || + noise_floor > -999 || tx_air_secs >= 0 || rx_air_secs >= 0 || recv_errors >= 0 || + internal_heap >= 0) { + JsonObject stats = root.createNestedObject("stats"); + + if (battery_mv >= 0) { + stats["battery_mv"] = battery_mv; + } + if (uptime_secs >= 0) { + stats["uptime_secs"] = uptime_secs; + } + if (errors >= 0) { + stats["errors"] = errors; + } + if (queue_len >= 0) { + stats["queue_len"] = queue_len; + } + if (noise_floor > -999) { + stats["noise_floor"] = noise_floor; + } + if (tx_air_secs >= 0) { + stats["tx_air_secs"] = tx_air_secs; + } + if (rx_air_secs >= 0) { + stats["rx_air_secs"] = rx_air_secs; + } + if (recv_errors >= 0) { + stats["recv_errors"] = recv_errors; + } + if (internal_heap >= 0) { + stats["internal_heap"] = internal_heap; + } + } + + size_t len = serializeJson(root, buffer, buffer_size); + return (len > 0 && len < buffer_size) ? len : 0; +} + +int MQTTMessageBuilder::buildPacketMessage( + JsonDocument& doc, + const char* origin, + const char* origin_id, + const char* timestamp, + const char* direction, + const char* time, + const char* date, + int len, + int packet_type, + const char* route, + int payload_len, + const char* raw, + float snr, + int rssi, + const char* hash, + const char* path, + char* buffer, + size_t buffer_size +) { + // doc is provided by the caller (heap-allocated DynamicJsonDocument in MQTTBridge), + // keeping this 2048-byte scratch space off the MQTT task stack. + doc.clear(); + JsonObject root = doc.to(); + + // Format numeric values as strings to avoid String object allocations + char len_str[16]; + char packet_type_str[16]; + char payload_len_str[16]; + char snr_str[16]; + char rssi_str[16]; + + snprintf(len_str, sizeof(len_str), "%d", len); + snprintf(packet_type_str, sizeof(packet_type_str), "%d", packet_type); + snprintf(payload_len_str, sizeof(payload_len_str), "%d", payload_len); + snprintf(snr_str, sizeof(snr_str), "%.1f", snr); + snprintf(rssi_str, sizeof(rssi_str), "%d", rssi); + + root["timestamp"] = timestamp; + root["hash"] = hash; + root["origin"] = origin; + root["type"] = "PACKET"; + root["direction"] = direction; + root["time"] = time; + root["date"] = date; + root["len"] = len_str; + root["packet_type"] = packet_type_str; + root["route"] = route; + root["payload_len"] = payload_len_str; + root["raw"] = raw; + root["origin_id"] = origin_id; + // SNR and RSSI are only meaningful for RX packets (received from radio) + if (strcmp(direction, "rx") == 0) { + root["SNR"] = snr_str; + root["RSSI"] = rssi_str; + } + + if (path && strlen(path) > 0) { + root["path"] = path; + } + + size_t json_len = serializeJson(root, buffer, buffer_size); + return (json_len > 0 && json_len < buffer_size) ? json_len : 0; +} + +int MQTTMessageBuilder::buildRawMessage( + const char* origin, + const char* origin_id, + const char* timestamp, + const char* raw, + char* buffer, + size_t buffer_size +) { + // Use StaticJsonDocument to avoid heap fragmentation (fixed-size stack allocation) + StaticJsonDocument<512> doc; + JsonObject root = doc.to(); + + root["origin"] = origin; + root["origin_id"] = origin_id; + root["timestamp"] = timestamp; + root["type"] = "RAW"; + root["data"] = raw; + + size_t len = serializeJson(root, buffer, buffer_size); + return (len > 0 && len < buffer_size) ? len : 0; +} + +int MQTTMessageBuilder::buildPacketJSON( + JsonDocument& doc, + mesh::Packet* packet, + bool is_tx, + const char* origin, + const char* origin_id, + Timezone* timezone, + char* buffer, + size_t buffer_size +) { + if (!packet) return 0; + + // Get current device time (should be UTC since system timezone is set to UTC) + time_t now = time(nullptr); + + // Convert to local time using timezone library (for timestamp field only) + time_t local_time = timezone ? timezone->toLocal(now) : now; + struct tm* local_timeinfo = localtime(&local_time); + + // Format timestamp in ISO 8601 format (LOCAL TIME) + char timestamp[32]; + if (local_timeinfo) { + strftime(timestamp, sizeof(timestamp), "%Y-%m-%dT%H:%M:%S.000000", local_timeinfo); + } else { + strcpy(timestamp, "2024-01-01T12:00:00.000000"); + } + + // Get UTC time (since system timezone is UTC, time() returns UTC) + struct tm* utc_timeinfo = gmtime(&now); + + // Format time and date (ALWAYS UTC) + char time_str[16]; + char date_str[16]; + if (utc_timeinfo) { + strftime(time_str, sizeof(time_str), "%H:%M:%S", utc_timeinfo); + strftime(date_str, sizeof(date_str), "%d/%m/%Y", utc_timeinfo); + } else { + strcpy(time_str, "12:00:00"); + strcpy(date_str, "01/01/2024"); + } + + // Convert packet to hex + // MAX_TRANS_UNIT is 255 bytes, hex = 510 chars, but allow for larger with headers + char raw_hex[1024]; + packetToHex(packet, raw_hex, sizeof(raw_hex)); + + // Get packet characteristics + int packet_type = packet->getPayloadType(); + const char* route_str = getRouteTypeString(packet->isRouteDirect() ? 1 : 0); + + // Create proper packet hash using MeshCore's calculatePacketHash method + char hash_str[17]; + uint8_t packet_hash[MAX_HASH_SIZE]; + packet->calculatePacketHash(packet_hash); + bytesToHex(packet_hash, MAX_HASH_SIZE, hash_str, sizeof(hash_str)); + + // Build path string for direct packets (multibyte-path: show hash count, hash size, byte length) + char path_str[128] = ""; + if (packet->isRouteDirect() && packet->path_len > 0) { + snprintf(path_str, sizeof(path_str), "path_%dx%d_%db", + (int)packet->getPathHashCount(), (int)packet->getPathHashSize(), (int)packet->getPathByteLen()); + } + + return buildPacketMessage( + doc, + origin, origin_id, timestamp, + is_tx ? "tx" : "rx", + time_str, date_str, + packet->getRawLength(), + packet_type, route_str, + packet->payload_len, + raw_hex, + 12.5f, // SNR - using reasonable default + -65, // RSSI - using reasonable default + hash_str, + packet->isRouteDirect() ? path_str : nullptr, + buffer, buffer_size + ); +} + +int MQTTMessageBuilder::buildPacketJSONFromRaw( + JsonDocument& doc, + const uint8_t* raw_data, + int raw_len, + mesh::Packet* packet, + bool is_tx, + const char* origin, + const char* origin_id, + float snr, + float rssi, + Timezone* timezone, + char* buffer, + size_t buffer_size +) { + if (!packet || !raw_data || raw_len <= 0) return 0; + + // Get current device time (should be UTC since system timezone is set to UTC) + time_t now = time(nullptr); + + // Convert to local time using timezone library (for timestamp field only) + time_t local_time = timezone ? timezone->toLocal(now) : now; + struct tm* local_timeinfo = localtime(&local_time); + + // Format timestamp in ISO 8601 format (LOCAL TIME) + char timestamp[32]; + if (local_timeinfo) { + strftime(timestamp, sizeof(timestamp), "%Y-%m-%dT%H:%M:%S.000000", local_timeinfo); + } else { + strcpy(timestamp, "2024-01-01T12:00:00.000000"); + } + + // Get UTC time (since system timezone is UTC, time() returns UTC) + struct tm* utc_timeinfo = gmtime(&now); + + // Format time and date (ALWAYS UTC) + char time_str[16]; + char date_str[16]; + if (utc_timeinfo) { + strftime(time_str, sizeof(time_str), "%H:%M:%S", utc_timeinfo); + strftime(date_str, sizeof(date_str), "%d/%m/%Y", utc_timeinfo); + } else { + strcpy(time_str, "12:00:00"); + strcpy(date_str, "01/01/2024"); + } + + // Convert raw radio data to hex (this includes radio headers) + // MAX_TRANS_UNIT is 255 bytes, hex = 510 chars, but allow for larger with headers + char raw_hex[1024]; + bytesToHex(raw_data, raw_len, raw_hex, sizeof(raw_hex)); + + // Get packet characteristics from the parsed packet + int packet_type = packet->getPayloadType(); + const char* route_str = getRouteTypeString(packet->isRouteDirect() ? 1 : 0); + + // Create proper packet hash using MeshCore's calculatePacketHash method + char hash_str[17]; + uint8_t packet_hash[MAX_HASH_SIZE]; + packet->calculatePacketHash(packet_hash); + bytesToHex(packet_hash, MAX_HASH_SIZE, hash_str, sizeof(hash_str)); + + // Build path string for direct packets (multibyte-path: show hash count, hash size, byte length) + char path_str[128] = ""; + if (packet->isRouteDirect() && packet->path_len > 0) { + snprintf(path_str, sizeof(path_str), "path_%dx%d_%db", + (int)packet->getPathHashCount(), (int)packet->getPathHashSize(), (int)packet->getPathByteLen()); + } + + return buildPacketMessage( + doc, + origin, origin_id, timestamp, + is_tx ? "tx" : "rx", + time_str, date_str, + raw_len, // Use actual raw radio data length + packet_type, route_str, + packet->payload_len, + raw_hex, + snr, // Use actual SNR from radio + rssi, // Use actual RSSI from radio + hash_str, + packet->isRouteDirect() ? path_str : nullptr, + buffer, buffer_size + ); +} + +int MQTTMessageBuilder::buildRawJSON( + mesh::Packet* packet, + const char* origin, + const char* origin_id, + Timezone* timezone, + char* buffer, + size_t buffer_size +) { + if (!packet) return 0; + + // Get current device time + time_t now = time(nullptr); + + // Convert to local time using timezone library + time_t local_time = timezone ? timezone->toLocal(now) : now; + struct tm* timeinfo = localtime(&local_time); + + // Format timestamp in ISO 8601 format + char timestamp[32]; + if (timeinfo) { + strftime(timestamp, sizeof(timestamp), "%Y-%m-%dT%H:%M:%S.000000", timeinfo); + } else { + strcpy(timestamp, "2024-01-01T12:00:00.000000"); + } + + // Convert packet to hex + // MAX_TRANS_UNIT is 255, so max hex size is 510 chars + null = 511 bytes + char raw_hex[1024]; + packetToHex(packet, raw_hex, sizeof(raw_hex)); + + return buildRawMessage(origin, origin_id, timestamp, raw_hex, buffer, buffer_size); +} + +const char* MQTTMessageBuilder::getPacketTypeString(int packet_type) { + switch (packet_type) { + case 0: return "0"; // REQ + case 1: return "1"; // RESPONSE + case 2: return "2"; // TXT_MSG + case 3: return "3"; // ACK + case 4: return "4"; // ADVERT + case 5: return "5"; // GRP_TXT + case 6: return "6"; // GRP_DATA + case 7: return "7"; // ANON_REQ + case 8: return "8"; // PATH + case 9: return "9"; // TRACE + case 10: return "10"; // MULTIPART + case 11: return "11"; // Type11 + case 12: return "12"; // Type12 + case 13: return "13"; // Type13 + case 14: return "14"; // Type14 + case 15: return "15"; // RAW_CUSTOM + default: return "0"; + } +} + +const char* MQTTMessageBuilder::getRouteTypeString(int route_type) { + switch (route_type) { + case 0: return "F"; // FLOOD + case 1: return "D"; // DIRECT + case 2: return "T"; // TRANSPORT_DIRECT + default: return "U"; // UNKNOWN + } +} + +void MQTTMessageBuilder::formatTimestamp(unsigned long timestamp, char* buffer, size_t buffer_size) { + // Simplified timestamp formatting - in real implementation would use proper time + snprintf(buffer, buffer_size, "2024-01-01T12:00:00.000000"); +} + +void MQTTMessageBuilder::formatTime(unsigned long timestamp, char* buffer, size_t buffer_size) { + // Simplified time formatting + snprintf(buffer, buffer_size, "12:00:00"); +} + +void MQTTMessageBuilder::formatDate(unsigned long timestamp, char* buffer, size_t buffer_size) { + // Simplified date formatting + snprintf(buffer, buffer_size, "01/01/2024"); +} + +void MQTTMessageBuilder::bytesToHex(const uint8_t* data, size_t len, char* hex, size_t hex_size) { + if (hex_size < len * 2 + 1) return; + + for (size_t i = 0; i < len; i++) { + snprintf(hex + i * 2, 3, "%02X", data[i]); + } + hex[len * 2] = '\0'; +} + +void MQTTMessageBuilder::packetToHex(mesh::Packet* packet, char* hex, size_t hex_size) { + // Serialize full on-air/wire format using Packet::writeTo() + // This includes header, transport codes (if present), path_len, path, and payload + uint8_t raw_buf[512]; + uint8_t raw_len = packet->writeTo(raw_buf); + if (raw_len == 0 || raw_len > sizeof(raw_buf)) return; + + // Check if hex buffer is large enough (2 hex chars per byte + null terminator) + if (hex_size < (size_t)raw_len * 2 + 1) return; + + // Convert serialized packet to hex + bytesToHex(raw_buf, raw_len, hex, hex_size); +} +#endif diff --git a/src/helpers/MQTTMessageBuilder.h b/src/helpers/MQTTMessageBuilder.h new file mode 100644 index 0000000000..4930bbaa7b --- /dev/null +++ b/src/helpers/MQTTMessageBuilder.h @@ -0,0 +1,221 @@ +#pragma once + +#include "MeshCore.h" +#include +#include +#include + +/** + * @brief Utility class for building MQTT JSON messages + * + * This class handles the formatting of mesh packets and device status + * into JSON messages for MQTT publishing according to the MeshCore + * packet capture specification. + */ +class MQTTMessageBuilder { +private: + static const int JSON_BUFFER_SIZE = 1024; + +public: + /** + * Build status message JSON + * + * @param origin Device name + * @param origin_id Device public key (hex string) + * @param model Device model + * @param firmware_version Firmware version + * @param radio Radio information + * @param client_version Client version + * @param status Connection status ("online" or "offline") + * @param timestamp ISO 8601 timestamp + * @param buffer Output buffer for JSON string + * @param buffer_size Size of output buffer + * @param battery_mv Battery voltage in millivolts (optional, -1 to omit) + * @param uptime_secs Uptime in seconds (optional, -1 to omit) + * @param errors Error flags (optional, -1 to omit) + * @param queue_len Queue length (optional, -1 to omit) + * @param noise_floor Noise floor in dBm (optional, -999 to omit) + * @param tx_air_secs TX air time in seconds (optional, -1 to omit) + * @param rx_air_secs RX air time in seconds (optional, -1 to omit) + * @param recv_errors Radio receive/CRC errors (optional, -1 to omit) + * @param internal_heap Internal heap free bytes (optional, -1 to omit) + * @return Length of JSON string, or 0 on error + */ + static int buildStatusMessage( + JsonDocument& doc, + const char* origin, + const char* origin_id, + const char* model, + const char* firmware_version, + const char* radio, + const char* client_version, + const char* status, + const char* timestamp, + char* buffer, + size_t buffer_size, + int battery_mv = -1, + int uptime_secs = -1, + int errors = -1, + int queue_len = -1, + int noise_floor = -999, + int tx_air_secs = -1, + int rx_air_secs = -1, + int recv_errors = -1, + int internal_heap = -1 + ); + + /** + * Build packet message JSON + * + * @param origin Device name + * @param origin_id Device public key (hex string) + * @param timestamp ISO 8601 timestamp + * @param direction Packet direction ("rx" or "tx") + * @param time Time in HH:MM:SS format + * @param date Date in DD/MM/YYYY format + * @param len Total packet length + * @param packet_type Packet type code + * @param route Routing type + * @param payload_len Payload length + * @param raw Raw packet data (hex string) + * @param snr Signal-to-noise ratio + * @param rssi Received signal strength + * @param hash Packet hash + * @param path Routing path (for direct packets) + * @param buffer Output buffer for JSON string + * @param buffer_size Size of output buffer + * @return Length of JSON string, or 0 on error + */ + static int buildPacketMessage( + JsonDocument& doc, + const char* origin, + const char* origin_id, + const char* timestamp, + const char* direction, + const char* time, + const char* date, + int len, + int packet_type, + const char* route, + int payload_len, + const char* raw, + float snr, + int rssi, + const char* hash, + const char* path, + char* buffer, + size_t buffer_size + ); + + /** + * Build raw message JSON + * + * @param origin Device name + * @param origin_id Device public key (hex string) + * @param timestamp ISO 8601 timestamp + * @param raw Raw packet data (hex string) + * @param buffer Output buffer for JSON string + * @param buffer_size Size of output buffer + * @return Length of JSON string, or 0 on error + */ + static int buildRawMessage( + const char* origin, + const char* origin_id, + const char* timestamp, + const char* raw, + char* buffer, + size_t buffer_size + ); + + /** + * Convert packet to JSON message + * + * @param packet Mesh packet + * @param is_tx Whether packet was transmitted (true) or received (false) + * @param origin Device name + * @param origin_id Device public key (hex string) + * @param buffer Output buffer for JSON string + * @param buffer_size Size of output buffer + * @return Length of JSON string, or 0 on error + */ + static int buildPacketJSON( + JsonDocument& doc, + mesh::Packet* packet, + bool is_tx, + const char* origin, + const char* origin_id, + Timezone* timezone, + char* buffer, + size_t buffer_size + ); + + static int buildPacketJSONFromRaw( + JsonDocument& doc, + const uint8_t* raw_data, + int raw_len, + mesh::Packet* packet, + bool is_tx, + const char* origin, + const char* origin_id, + float snr, + float rssi, + Timezone* timezone, + char* buffer, + size_t buffer_size + ); + + /** + * Convert packet to raw JSON message + * + * @param packet Mesh packet + * @param origin Device name + * @param origin_id Device public key (hex string) + * @param buffer Output buffer for JSON string + * @param buffer_size Size of output buffer + * @return Length of JSON string, or 0 on error + */ + static int buildRawJSON( + mesh::Packet* packet, + const char* origin, + const char* origin_id, + Timezone* timezone, + char* buffer, + size_t buffer_size + ); + +private: + /** + * Convert packet type to string + */ + static const char* getPacketTypeString(int packet_type); + + /** + * Convert route type to string + */ + static const char* getRouteTypeString(int route_type); + + /** + * Format timestamp to ISO 8601 format + */ + static void formatTimestamp(unsigned long timestamp, char* buffer, size_t buffer_size); + + /** + * Format time to HH:MM:SS format + */ + static void formatTime(unsigned long timestamp, char* buffer, size_t buffer_size); + + /** + * Format date to DD/MM/YYYY format + */ + static void formatDate(unsigned long timestamp, char* buffer, size_t buffer_size); + + /** + * Convert bytes to hex string (uppercase) + */ + static void bytesToHex(const uint8_t* data, size_t len, char* hex, size_t hex_size); + + /** + * Convert packet to hex string + */ + static void packetToHex(mesh::Packet* packet, char* hex, size_t hex_size); +}; diff --git a/src/helpers/MQTTPresets.h b/src/helpers/MQTTPresets.h new file mode 100644 index 0000000000..2c97abe825 --- /dev/null +++ b/src/helpers/MQTTPresets.h @@ -0,0 +1,138 @@ +#pragma once + +// Maximum number of configurable MQTT connection slots (available to all builds for struct layout). +// Used in NodePrefs/MQTTPrefs for persistent storage — do NOT change without migration. +static const int MAX_MQTT_SLOTS = 6; + +// Runtime slot array size: fewer slots on non-PSRAM boards to save ~1.2KB of heap. +// Non-PSRAM boards are limited to 2 active connections (_max_active_slots), so 3 runtime +// slots (2 active + 1 spare for reconfiguration) is sufficient. +#if defined(BOARD_HAS_PSRAM) +static const int RUNTIME_MQTT_SLOTS = 6; +#else +static const int RUNTIME_MQTT_SLOTS = 3; +#endif + +#ifdef WITH_MQTT_BRIDGE + +enum MQTTAuthType : uint8_t { + MQTT_AUTH_NONE, // No authentication + MQTT_AUTH_USERPASS, // Username/password + MQTT_AUTH_JWT // Ed25519-signed JWT (device identity) +}; + +enum MQTTTopicStyle : uint8_t { + MQTT_TOPIC_MESHCORE, // meshcore/{iata}/{device_id}/{status|packets|raw} + MQTT_TOPIC_MESHRANK, // meshrank/uplink/{token}/{device_id}/packets (packets only) +}; + +struct MQTTPresetDef { + const char* name; // Preset identifier: "analyzer-us", "analyzer-eu", "meshmapper", "meshrank", "waev", ... + const char* server_url; // Full URL including scheme: "wss://host:port/path" or "mqtts://host:port" + const char* jwt_audience; // JWT audience field (only for MQTT_AUTH_JWT, nullptr otherwise) + const char* ca_cert; // PEM CA certificate (nullptr to skip cert pinning) + MQTTAuthType auth_type; + MQTTTopicStyle topic_style; + unsigned long token_lifetime; // JWT token lifetime in seconds (0 = use default 86400) + bool allow_retain; // Whether the broker allows the MQTT retain flag + uint16_t keepalive; // MQTT keepalive in seconds (0 = library default 120s) + const char* userpass_username; // MQTT_AUTH_USERPASS: broker username; else nullptr + const char* userpass_password; // MQTT_AUTH_USERPASS: broker password; else nullptr +}; + +// Google Trust Services - GTS Root R4 (used by LetsMesh Analyzer) +static const char GTS_ROOT_R4[] PROGMEM = + "-----BEGIN CERTIFICATE-----\n" + "MIIDejCCAmKgAwIBAgIQf+UwvzMTQ77dghYQST2KGzANBgkqhkiG9w0BAQsFADBX\n" + "MQswCQYDVQQGEwJCRTEZMBcGA1UEChMQR2xvYmFsU2lnbiBudi1zYTEQMA4GA1UE\n" + "CxMHUm9vdCBDQTEbMBkGA1UEAxMSR2xvYmFsU2lnbiBSb290IENBMB4XDTIzMTEx\n" + "NTAzNDMyMVoXDTI4MDEyODAwMDA0MlowRzELMAkGA1UEBhMCVVMxIjAgBgNVBAoT\n" + "GUdvb2dsZSBUcnVzdCBTZXJ2aWNlcyBMTEMxFDASBgNVBAMTC0dUUyBSb290IFI0\n" + "MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE83Rzp2iLYK5DuDXFgTB7S0md+8Fhzube\n" + "Rr1r1WEYNa5A3XP3iZEwWus87oV8okB2O6nGuEfYKueSkWpz6bFyOZ8pn6KY019e\n" + "WIZlD6GEZQbR3IvJx3PIjGov5cSr0R2Ko4H/MIH8MA4GA1UdDwEB/wQEAwIBhjAd\n" + "BgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwDwYDVR0TAQH/BAUwAwEB/zAd\n" + "BgNVHQ4EFgQUgEzW63T/STaj1dj8tT7FavCUHYwwHwYDVR0jBBgwFoAUYHtmGkUN\n" + "l8qJUC99BM00qP/8/UswNgYIKwYBBQUHAQEEKjAoMCYGCCsGAQUFBzAChhpodHRw\n" + "Oi8vaS5wa2kuZ29vZy9nc3IxLmNydDAtBgNVHR8EJjAkMCKgIKAehhxodHRwOi8v\n" + "Yy5wa2kuZ29vZy9yL2dzcjEuY3JsMBMGA1UdIAQMMAowCAYGZ4EMAQIBMA0GCSqG\n" + "SIb3DQEBCwUAA4IBAQAYQrsPBtYDh5bjP2OBDwmkoWhIDDkic574y04tfzHpn+cJ\n" + "odI2D4SseesQ6bDrarZ7C30ddLibZatoKiws3UL9xnELz4ct92vID24FfVbiI1hY\n" + "+SW6FoVHkNeWIP0GCbaM4C6uVdF5dTUsMVs/ZbzNnIdCp5Gxmx5ejvEau8otR/Cs\n" + "kGN+hr/W5GvT1tMBjgWKZ1i4//emhA1JG1BbPzoLJQvyEotc03lXjTaCzv8mEbep\n" + "8RqZ7a2CPsgRbuvTPBwcOMBBmuFeU88+FSBX6+7iP0il8b4Z0QFqIwwMHfs/L6K1\n" + "vepuoxtGzi4CZ68zJpiq1UvSqTbFJjtbD4seiMHl\n" + "-----END CERTIFICATE-----\n"; + +// ISRG Root X1 (used by MeshMapper - Let's Encrypt root CA) +static const char ISRG_ROOT_X1[] PROGMEM = + "-----BEGIN CERTIFICATE-----\n" + "MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw\n" + "TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh\n" + "cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4\n" + "WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu\n" + "ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY\n" + "MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc\n" + "h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+\n" + "0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U\n" + "A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW\n" + "T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH\n" + "B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC\n" + "B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv\n" + "KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn\n" + "OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn\n" + "jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw\n" + "qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI\n" + "rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV\n" + "HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq\n" + "hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL\n" + "ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ\n" + "3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK\n" + "NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5\n" + "ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur\n" + "TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC\n" + "jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc\n" + "oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq\n" + "4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA\n" + "mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d\n" + "emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=\n" + "-----END CERTIFICATE-----\n"; + +// Number of built-in presets +static const int MQTT_PRESET_COUNT = 15; + +// Built-in preset definitions (stored in flash) +static const MQTTPresetDef MQTT_PRESETS[MQTT_PRESET_COUNT] = { + { "dutchmeshcore-1", "wss://collector1.dutchmeshcore.nl:443", "collector1.dutchmeshcore.nl", ISRG_ROOT_X1, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr }, + { "dutchmeshcore-2", "wss://collector2.dutchmeshcore.nl:443", "collector2.dutchmeshcore.nl", ISRG_ROOT_X1, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr }, + { "analyzer-us", "wss://mqtt-us-v1.letsmesh.net:443/mqtt", "mqtt-us-v1.letsmesh.net", GTS_ROOT_R4, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr }, + { "analyzer-eu", "wss://mqtt-eu-v1.letsmesh.net:443/mqtt", "mqtt-eu-v1.letsmesh.net", GTS_ROOT_R4, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr }, + { "meshmapper", "wss://mqtt.meshmapper.cc:443/mqtt", "mqtt.meshmapper.cc", ISRG_ROOT_X1, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr }, + { "meshrank", "mqtts://meshrank.net:8883", nullptr, ISRG_ROOT_X1, MQTT_AUTH_NONE, MQTT_TOPIC_MESHRANK, 0, false, 0, nullptr, nullptr }, + { "waev", "wss://mqtt.waev.app:443/mqtt", "mqtt.waev.app", GTS_ROOT_R4, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 3300, false, 55, nullptr, nullptr }, + { "meshomatic", "wss://us-east.meshomatic.net:443/mqtt", "us-east.meshomatic.net", ISRG_ROOT_X1, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr }, + { "cascadiamesh", "wss://mqtt-v1.cascadiamesh.org:443/mqtt", "mqtt-v1.cascadiamesh.org", ISRG_ROOT_X1, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr }, + { "tennmesh", "mqtt://mqtt.tennmesh.com:1883", nullptr, nullptr, MQTT_AUTH_USERPASS, MQTT_TOPIC_MESHCORE, 0, true, 55, "mqttfeed", "tc2live" }, + { "nashmesh", "mqtt://mqtt.nashme.sh:1883", nullptr, nullptr, MQTT_AUTH_USERPASS, MQTT_TOPIC_MESHCORE, 0, true, 55, "meshdev", "large4cats" }, + { "chimesh", "wss://mqtt.chimesh.org:443", "mqtt.chimesh.org", ISRG_ROOT_X1, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr }, + { "meshat.se", "mqtts://mqtt.meshat.se:8883", nullptr, ISRG_ROOT_X1, MQTT_AUTH_USERPASS, MQTT_TOPIC_MESHCORE, 0, true, 55, "msh", "msh" }, + { "eastidahomesh","wss://broker.eastidahomesh.net:443", nullptr, ISRG_ROOT_X1, MQTT_AUTH_NONE, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr }, + { "coloradomesh", "mqtt://mqtt.meshcore.coloradomesh.org:8883","mqtt.meshcore.coloradomesh.org", nullptr, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr }, +}; + +// Find a preset by name, returns nullptr if not found +static const MQTTPresetDef* findMQTTPreset(const char* name) { + if (!name || name[0] == '\0') return nullptr; + for (int i = 0; i < MQTT_PRESET_COUNT; i++) { + if (strcmp(name, MQTT_PRESETS[i].name) == 0) { + return &MQTT_PRESETS[i]; + } + } + return nullptr; +} + +// Slot preset name constants +static const char MQTT_PRESET_NONE[] = "none"; +static const char MQTT_PRESET_CUSTOM[] = "custom"; + +#endif diff --git a/src/helpers/SNMPAgent.cpp b/src/helpers/SNMPAgent.cpp new file mode 100644 index 0000000000..a70716f5bb --- /dev/null +++ b/src/helpers/SNMPAgent.cpp @@ -0,0 +1,123 @@ +#ifdef WITH_SNMP + +#include "SNMPAgent.h" +#include + +#define SNMP_PORT 161 + +MeshSNMPAgent::MeshSNMPAgent() + : _snmp("public"), + _running(false), + _uptime_secs(0), + _packets_recv(0), _packets_sent(0), _recv_errors(0), + _noise_floor(0), _last_rssi(0), _last_snr(0), + _sent_flood(0), _sent_direct(0), _recv_flood(0), _recv_direct(0), + _total_air_time_secs(0), + _mqtt_connected_slots(0), _mqtt_queue_depth(0), _mqtt_skipped_publishes(0), + _free_heap(0), _max_alloc(0), _internal_free(0), _psram_free(0), + _wifi_rssi(0) +{ + _firmware_version[0] = '\0'; + _node_name[0] = '\0'; +} + +void MeshSNMPAgent::begin(const char* community) { + if (_running) return; + + _snmp = SNMPAgent(community); + _snmp.setUDP(&_udp); + _snmp.begin(); + + // System group (.1.x.0) + _snmp.addIntegerHandler(MESHCORE_OID_BASE ".1.1.0", &_uptime_secs); + _snmp.addReadOnlyStaticStringHandler(MESHCORE_OID_BASE ".1.2.0", _firmware_version, sizeof(_firmware_version)); + _snmp.addReadOnlyStaticStringHandler(MESHCORE_OID_BASE ".1.3.0", _node_name, sizeof(_node_name)); + + // Radio group (.2.x.0) + _snmp.addIntegerHandler(MESHCORE_OID_BASE ".2.1.0", &_packets_recv); + _snmp.addIntegerHandler(MESHCORE_OID_BASE ".2.2.0", &_packets_sent); + _snmp.addIntegerHandler(MESHCORE_OID_BASE ".2.3.0", &_recv_errors); + _snmp.addIntegerHandler(MESHCORE_OID_BASE ".2.4.0", &_noise_floor); + _snmp.addIntegerHandler(MESHCORE_OID_BASE ".2.5.0", &_last_rssi); + _snmp.addIntegerHandler(MESHCORE_OID_BASE ".2.6.0", &_last_snr); + _snmp.addIntegerHandler(MESHCORE_OID_BASE ".2.7.0", &_sent_flood); + _snmp.addIntegerHandler(MESHCORE_OID_BASE ".2.8.0", &_sent_direct); + _snmp.addIntegerHandler(MESHCORE_OID_BASE ".2.9.0", &_recv_flood); + _snmp.addIntegerHandler(MESHCORE_OID_BASE ".2.10.0", &_recv_direct); + _snmp.addIntegerHandler(MESHCORE_OID_BASE ".2.11.0", &_total_air_time_secs); + + // MQTT group (.3.x.0) + _snmp.addIntegerHandler(MESHCORE_OID_BASE ".3.1.0", &_mqtt_connected_slots); + _snmp.addIntegerHandler(MESHCORE_OID_BASE ".3.2.0", &_mqtt_queue_depth); + _snmp.addIntegerHandler(MESHCORE_OID_BASE ".3.3.0", &_mqtt_skipped_publishes); + + // Memory group (.4.x.0) + _snmp.addIntegerHandler(MESHCORE_OID_BASE ".4.1.0", &_free_heap); + _snmp.addIntegerHandler(MESHCORE_OID_BASE ".4.2.0", &_max_alloc); + _snmp.addIntegerHandler(MESHCORE_OID_BASE ".4.3.0", &_internal_free); + _snmp.addIntegerHandler(MESHCORE_OID_BASE ".4.4.0", &_psram_free); + + // Network group (.5.x.0) + _snmp.addIntegerHandler(MESHCORE_OID_BASE ".5.1.0", &_wifi_rssi); + + _snmp.sortHandlers(); + _running = true; +} + +void MeshSNMPAgent::loop() { + if (!_running) return; + + // Update memory and network stats locally (we're on Core 0 with WiFi) + _free_heap = (int)ESP.getFreeHeap(); + _max_alloc = (int)ESP.getMaxAllocHeap(); + _internal_free = (int)heap_caps_get_free_size(MALLOC_CAP_INTERNAL); +#ifdef BOARD_HAS_PSRAM + _psram_free = (int)heap_caps_get_free_size(MALLOC_CAP_SPIRAM); +#else + _psram_free = 0; +#endif + + if (WiFi.isConnected()) { + _wifi_rssi = (int)WiFi.RSSI(); + } + + _snmp.loop(); +} + +void MeshSNMPAgent::updateRadioStats( + uint32_t packets_recv, uint32_t packets_sent, uint32_t recv_errors, + int16_t noise_floor, int16_t last_rssi, int16_t last_snr, + uint32_t sent_flood, uint32_t sent_direct, + uint32_t recv_flood, uint32_t recv_direct, + uint32_t total_air_time_secs, uint32_t uptime_secs) { + _packets_recv = (int)packets_recv; + _packets_sent = (int)packets_sent; + _recv_errors = (int)recv_errors; + _noise_floor = (int)noise_floor; + _last_rssi = (int)last_rssi; + _last_snr = (int)last_snr; + _sent_flood = (int)sent_flood; + _sent_direct = (int)sent_direct; + _recv_flood = (int)recv_flood; + _recv_direct = (int)recv_direct; + _total_air_time_secs = (int)total_air_time_secs; + _uptime_secs = (int)uptime_secs; +} + +void MeshSNMPAgent::updateMQTTStats(int connected_slots, int queue_depth, int skipped_publishes) { + _mqtt_connected_slots = connected_slots; + _mqtt_queue_depth = queue_depth; + _mqtt_skipped_publishes = skipped_publishes; +} + +void MeshSNMPAgent::setNodeName(const char* name) { + strncpy(_node_name, name, sizeof(_node_name) - 1); + _node_name[sizeof(_node_name) - 1] = '\0'; +} + +void MeshSNMPAgent::setFirmwareVersion(const char* version) { + strncpy(_firmware_version, version, sizeof(_firmware_version) - 1); + _firmware_version[sizeof(_firmware_version) - 1] = '\0'; +} + +#endif // WITH_SNMP diff --git a/src/helpers/SNMPAgent.h b/src/helpers/SNMPAgent.h new file mode 100644 index 0000000000..c1bfe0f5be --- /dev/null +++ b/src/helpers/SNMPAgent.h @@ -0,0 +1,79 @@ +#pragma once + +#ifdef WITH_SNMP + +#include +#include +#include + +// Temporary private enterprise OID base — replace with registered PEN when available. +// All MeshCore OIDs live under this subtree. +#define MESHCORE_OID_BASE ".1.3.6.1.4.1.99999" + +// OID layout: +// .1.x.0 = system (uptime, version, node name) +// .2.x.0 = radio (packets, RSSI, SNR, noise floor, air time) +// .3.x.0 = mqtt (connected slots, queue depth, skipped publishes) +// .4.x.0 = memory (free heap, max alloc, internal free, PSRAM free) +// .5.x.0 = network (WiFi RSSI) + +class MeshSNMPAgent { +public: + MeshSNMPAgent(); + void begin(const char* community); + void loop(); + + // Called from the mesh task (Core 1) to push fresh stats into SNMP-visible variables. + // Copies are atomic for 32-bit aligned ints on ESP32, so no mutex needed. + void updateRadioStats(uint32_t packets_recv, uint32_t packets_sent, uint32_t recv_errors, + int16_t noise_floor, int16_t last_rssi, int16_t last_snr, + uint32_t sent_flood, uint32_t sent_direct, + uint32_t recv_flood, uint32_t recv_direct, + uint32_t total_air_time_secs, uint32_t uptime_secs); + + void updateMQTTStats(int connected_slots, int queue_depth, int skipped_publishes); + + void setNodeName(const char* name); + void setFirmwareVersion(const char* version); + + bool isRunning() const { return _running; } + +private: + WiFiUDP _udp; + SNMPAgent _snmp; + bool _running; + + // System OIDs + int _uptime_secs; + char _firmware_version[32]; + char _node_name[32]; + + // Radio OIDs + int _packets_recv; + int _packets_sent; + int _recv_errors; + int _noise_floor; + int _last_rssi; + int _last_snr; + int _sent_flood; + int _sent_direct; + int _recv_flood; + int _recv_direct; + int _total_air_time_secs; + + // MQTT OIDs + int _mqtt_connected_slots; + int _mqtt_queue_depth; + int _mqtt_skipped_publishes; + + // Memory OIDs (updated in loop() since we're on Core 0 with WiFi) + int _free_heap; + int _max_alloc; + int _internal_free; + int _psram_free; + + // Network OIDs + int _wifi_rssi; +}; + +#endif // WITH_SNMP diff --git a/src/helpers/StaticPoolPacketManager.cpp b/src/helpers/StaticPoolPacketManager.cpp index b8926df0cc..67d6397930 100644 --- a/src/helpers/StaticPoolPacketManager.cpp +++ b/src/helpers/StaticPoolPacketManager.cpp @@ -9,8 +9,6 @@ PacketQueue::PacketQueue(int max_entries) { } int PacketQueue::countBefore(uint32_t now) const { - if (now == 0xFFFFFFFF) return _num; // sentinel: count all entries regardless of schedule - int n = 0; for (int j = 0; j < _num; j++) { if ((int32_t)(_schedule_table[j] - now) > 0) continue; // scheduled for future... ignore for now @@ -99,10 +97,6 @@ int StaticPoolPacketManager::getOutboundCount(uint32_t now) const { return send_queue.countBefore(now); } -int StaticPoolPacketManager::getOutboundTotal() const { - return send_queue.count(); -} - int StaticPoolPacketManager::getFreeCount() const { return unused.count(); } diff --git a/src/helpers/StaticPoolPacketManager.h b/src/helpers/StaticPoolPacketManager.h index 59715b4e01..52c299dbc4 100644 --- a/src/helpers/StaticPoolPacketManager.h +++ b/src/helpers/StaticPoolPacketManager.h @@ -29,7 +29,6 @@ class StaticPoolPacketManager : public mesh::PacketManager { void queueOutbound(mesh::Packet* packet, uint8_t priority, uint32_t scheduled_for) override; mesh::Packet* getNextOutbound(uint32_t now) override; int getOutboundCount(uint32_t now) const override; - int getOutboundTotal() const override; int getFreeCount() const override; mesh::Packet* getOutboundByIdx(int i) override; mesh::Packet* removeOutboundByIdx(int i) override; diff --git a/src/helpers/StatsFormatHelper.h b/src/helpers/StatsFormatHelper.h index bf619133e9..7cce0552a5 100644 --- a/src/helpers/StatsFormatHelper.h +++ b/src/helpers/StatsFormatHelper.h @@ -14,7 +14,7 @@ class StatsFormatHelper { board.getBattMilliVolts(), ms.getMillis() / 1000, err_flags, - mgr->getOutboundTotal() + mgr->getOutboundCount(0xFFFFFFFF) ); } @@ -34,6 +34,24 @@ class StatsFormatHelper { ); } + template + static void formatRadioDiag(char* reply, + mesh::Radio* radio, + RadioDriverType& driver, + mesh::MillisecondClock& ms, + uint16_t err_flags, + bool has_outbound) { + uint8_t st = radio->getRadioState(); + const char* state_name = (st & 16) ? "INT_READY" : (st == 0 ? "IDLE" : (st == 1 ? "RX" : (st == 3 ? "TX_WAIT" : "?"))); + unsigned long last_rx = radio->getLastRecvMillis(); + unsigned long ago_secs = (last_rx > 0) ? (ms.getMillis() - last_rx) / 1000 : 0; + sprintf(reply, + "state=%d(%s), recv=%u, sent=%u, errors=%u, err_flags=%u, outbound=%s, last_rx=%lus ago", + st, state_name, + driver.getPacketsRecv(), driver.getPacketsSent(), driver.getPacketsRecvErrors(), + err_flags, has_outbound ? "yes" : "no", ago_secs); + } + template static void formatPacketStats(char* reply, RadioDriverType& driver, diff --git a/src/helpers/bridges/MQTTBridge.cpp b/src/helpers/bridges/MQTTBridge.cpp new file mode 100644 index 0000000000..512ba49104 --- /dev/null +++ b/src/helpers/bridges/MQTTBridge.cpp @@ -0,0 +1,3083 @@ +#include "MQTTBridge.h" +#include "../MQTTMessageBuilder.h" +#include +#include +#include + +#ifdef WITH_SNMP +#include "../SNMPAgent.h" +#endif + +#ifdef ESP_PLATFORM +#include +#include +#include +#include +#include +#include +#include +#endif + +// Helper function to strip quotes from strings (both single and double quotes) +static void stripQuotes(char* str, size_t max_len) { + if (!str || max_len == 0) return; + + size_t len = strlen(str); + if (len == 0) return; + + // Remove leading quote (single or double) + if (str[0] == '"' || str[0] == '\'') { + memmove(str, str + 1, len); + len--; + } + + // Remove trailing quote (single or double) + if (len > 0 && (str[len-1] == '"' || str[len-1] == '\'')) { + str[len-1] = '\0'; + } +} + +// Helper function to check if WiFi credentials are valid +static bool isWiFiConfigValid(const NodePrefs* prefs) { + // Check if WiFi SSID is configured (not empty) + if (strlen(prefs->wifi_ssid) == 0) { + return false; + } + + // WiFi password can be empty for open networks, so we don't check it + + return true; +} + +#ifdef WITH_MQTT_BRIDGE + +bool MQTTBridge::isConfigValid(const NodePrefs* prefs) { + if (!prefs || !isWiFiConfigValid(prefs)) return false; + for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { + const char* preset_name = prefs->mqtt_slot_preset[i]; + if (preset_name[0] == '\0' || strcmp(preset_name, MQTT_PRESET_NONE) == 0) continue; + if (strcmp(preset_name, MQTT_PRESET_CUSTOM) == 0) { + if (prefs->mqtt_slot_host[i][0] != '\0' && prefs->mqtt_slot_port[i] != 0) return true; + } else if (findMQTTPreset(preset_name) != nullptr) { + return true; + } + } + return false; +} + +// Optional embedded CA bundle symbols produced by board_build.embed_files. +// Weak linkage keeps non-bundle builds linkable and allows runtime fallback. +extern const uint8_t rootca_crt_bundle_start[] asm("_binary_src_certs_x509_crt_bundle_bin_start") __attribute__((weak)); +extern const uint8_t rootca_crt_bundle_end[] asm("_binary_src_certs_x509_crt_bundle_bin_end") __attribute__((weak)); + +// Track whether the global cert bundle has been loaded into s_crt_bundle. +// Loading must happen exactly once to avoid a use-after-free race when multiple +// TLS slots are set up in sequence (each connect() launches an async task). +static bool s_ca_bundle_loaded = false; + +// PSRAM-aware allocation: prefer PSRAM on ESP32 when BOARD_HAS_PSRAM, fallback to internal heap or malloc. +// Use psram_free() for any pointer returned by psram_malloc(). +static void* psram_malloc(size_t size) { + if (size == 0) return nullptr; +#if defined(ESP_PLATFORM) && defined(BOARD_HAS_PSRAM) + void* p = heap_caps_malloc(size, MALLOC_CAP_SPIRAM); + if (p != nullptr) return p; + p = heap_caps_malloc(size, MALLOC_CAP_INTERNAL); + return p; +#else + return malloc(size); +#endif +} + +static void* psram_calloc(size_t n, size_t size) { + if (n == 0 || size == 0) return nullptr; +#if defined(ESP_PLATFORM) && defined(BOARD_HAS_PSRAM) + void* p = heap_caps_calloc(n, size, MALLOC_CAP_SPIRAM); + if (p != nullptr) return p; + return heap_caps_calloc(n, size, MALLOC_CAP_INTERNAL); +#else + return calloc(n, size); +#endif +} + +static void psram_free(void* ptr) { + if (ptr == nullptr) return; +#if defined(ESP_PLATFORM) + heap_caps_free(ptr); +#else + free(ptr); +#endif +} + +// Time (millis()) when WiFi was last seen connected; 0 when disconnected. Used for get wifi.status uptime. +static unsigned long s_wifi_connected_at = 0; + +// Last WiFi disconnect reason (from ESP-IDF event). Used for get wifi.status diagnostics. +static uint8_t s_wifi_disconnect_reason = 0; +static unsigned long s_wifi_disconnect_time = 0; + +#ifdef MQTT_MEMORY_DEBUG +// #region agent log +static void agentLogHeap(const char* location, const char* message, const char* hypothesisId, + size_t free_h, size_t max_alloc, unsigned long internal_free, unsigned long spiram_free) { + char buf[320]; + snprintf(buf, sizeof(buf), + "{\"sessionId\":\"debug-session\",\"location\":\"%s\",\"message\":\"%s\",\"hypothesisId\":\"%s\"," + "\"data\":{\"free\":%u,\"max_alloc\":%u,\"internal_free\":%lu,\"spiram_free\":%lu},\"timestamp\":%lu}", + location, message, hypothesisId, (unsigned)free_h, (unsigned)max_alloc, internal_free, spiram_free, + (unsigned long)millis()); + Serial.println(buf); +} +// #endregion +#endif + +// Singleton for formatMqttStatusReply (set in begin(), cleared in end()) +static MQTTBridge* s_mqtt_bridge_instance = nullptr; + +unsigned long MQTTBridge::getWifiConnectedAtMillis() { + return s_wifi_connected_at; +} + +void MQTTBridge::formatMqttStatusReply(char* buf, size_t bufsize, const NodePrefs* prefs) { + if (buf == nullptr || bufsize == 0) return; + const char* msgs = (prefs->mqtt_status_enabled) ? "on" : "off"; + if (s_mqtt_bridge_instance == nullptr || !s_mqtt_bridge_instance->_initialized) { + snprintf(buf, bufsize, "> msgs: %s (bridge not running)", msgs); + return; + } + MQTTBridge* b = s_mqtt_bridge_instance; + + // Build per-slot status strings (compact format to fit 160-byte reply buffer) + // Only show configured slots, skip "none" slots + int q = 0; +#ifdef ESP_PLATFORM + if (b->_packet_queue_handle != nullptr) { + q = (int)uxQueueMessagesWaiting(b->_packet_queue_handle); + } +#else + q = b->_queue_count; +#endif + + int pos = snprintf(buf, bufsize, "> msgs: %s", msgs); + for (int i = 0; i < RUNTIME_MQTT_SLOTS && pos < (int)bufsize - 1; i++) { + const MQTTSlot& slot = b->_slots[i]; + const char* name = nullptr; + const char* state = nullptr; + + if (!slot.enabled && slot.preset) { + name = slot.preset->name; + state = "inactive"; + } else if (!slot.enabled) { + continue; // Skip unconfigured slots + } else if (!b->isSlotReady(i)) { + name = slot.preset ? slot.preset->name : "custom"; + state = "wait"; + } else if (slot.connected) { + name = slot.preset ? slot.preset->name : "custom"; + state = "ok"; + } else if (slot.circuit_breaker_tripped) { + name = slot.preset ? slot.preset->name : "custom"; + state = "fail"; + } else { + name = slot.preset ? slot.preset->name : "custom"; + state = "disc"; + } + pos += snprintf(buf + pos, bufsize - pos, ", %d: %s (%s)", i + 1, name, state); + } + snprintf(buf + pos, bufsize - pos, ", q:%d", q); +} + +uint8_t MQTTBridge::getLastWifiDisconnectReason() { return s_wifi_disconnect_reason; } +unsigned long MQTTBridge::getLastWifiDisconnectTime() { return s_wifi_disconnect_time; } + +const char* MQTTBridge::wifiReasonStr(uint8_t reason) { + switch (reason) { + case 2: return "auth expired"; + case 4: return "assoc timeout"; + case 8: return "AP disconnected"; + case 15: return "4-way handshake timeout"; + case 18: return "group cipher mismatch"; + case 40: return "cipher suite rejected"; + case 49: return "invalid PMKID"; + case 61: return "AP BSS management"; + case 88: return "AP BSS management"; + case 168: return "AP band-steering kick"; + case 34: return "AP state mismatch (class 3 frame)"; + case 39: return "SSID not found"; + case 63: return "SA query timeout (PMF)"; + case 200: return "signal lost"; + case 201: return "security mismatch"; + case 202: return "auth mode rejected"; + case 204: return "handshake timeout"; + default: return nullptr; + } +} + +const char* MQTTBridge::tlsErrorStr(int32_t err) { + switch (err) { + case 0x8001: return "DNS failed"; + case 0x8002: return "socket error"; + case 0x8004: return "connect refused"; + case 0x8006: return "TLS timeout"; + case 0x8008: return "connection timeout"; + case 0x800B: return "cert verify failed"; + case 0x8010: return "mbedTLS error"; + case 0x801A: return "TLS handshake failed"; + default: return nullptr; + } +} + +void MQTTBridge::formatSlotDiagReply(char* buf, size_t bufsize, int slot_index) { + if (!buf || bufsize == 0) return; + if (!s_mqtt_bridge_instance || !s_mqtt_bridge_instance->_initialized) { + snprintf(buf, bufsize, "> mqtt%d: bridge not running", slot_index + 1); + return; + } + if (slot_index < 0 || slot_index >= RUNTIME_MQTT_SLOTS) { + snprintf(buf, bufsize, "> invalid slot"); + return; + } + + MQTTBridge* b = s_mqtt_bridge_instance; + const MQTTSlot& slot = b->_slots[slot_index]; + + // Determine state string + const char* state; + if (!slot.enabled && !slot.preset && slot.host[0] == '\0') { + snprintf(buf, bufsize, "> mqtt%d: not configured", slot_index + 1); + return; + } else if (!slot.enabled) { + state = "inactive"; + } else if (!slot.client) { + state = "no client"; + } else if (slot.connected) { + state = "ok"; + } else if (slot.circuit_breaker_tripped) { + state = "fail"; + } else { + state = "disc"; + } + + int pos = snprintf(buf, bufsize, "> mqtt%d: %s", slot_index + 1, state); + if (slot.disconnect_count > 0) { + pos += snprintf(buf + pos, bufsize - pos, ", dc:%lu", (unsigned long)slot.disconnect_count); + if (slot.first_disconnect_time > 0) { + unsigned long first_disc_age_sec = (millis() - slot.first_disconnect_time) / 1000; + pos += snprintf(buf + pos, bufsize - pos, ", first_disc:%lus", first_disc_age_sec); + } + } + + // If connected with no errors, we're done + if (slot.connected && slot.last_error_time == 0) { + snprintf(buf + pos, bufsize - pos, ", no errors"); + return; + } + + // Show last error if we have one + if (slot.last_error_time > 0) { + // TLS error with human-friendly description + if (slot.last_tls_err != 0) { + const char* desc = tlsErrorStr(slot.last_tls_err); + if (desc) { + pos += snprintf(buf + pos, bufsize - pos, ", %s (0x%04X)", desc, (unsigned)slot.last_tls_err); + } else { + pos += snprintf(buf + pos, bufsize - pos, ", tls:0x%04X", (unsigned)slot.last_tls_err); + } + } + // mbedTLS stack error (shown as negative hex per convention) + if (slot.last_tls_stack_err != 0) { + pos += snprintf(buf + pos, bufsize - pos, ", mbedtls:-0x%04X", (unsigned)(-slot.last_tls_stack_err)); + } + // Socket errno + if (slot.last_sock_errno != 0) { + pos += snprintf(buf + pos, bufsize - pos, ", sock:%d", slot.last_sock_errno); + } + // Time ago + unsigned long ago_sec = (millis() - slot.last_error_time) / 1000; + if (ago_sec < 60) { + snprintf(buf + pos, bufsize - pos, ", %lus ago", ago_sec); + } else if (ago_sec < 3600) { + snprintf(buf + pos, bufsize - pos, ", %lum ago", ago_sec / 60); + } else { + snprintf(buf + pos, bufsize - pos, ", %luh ago", ago_sec / 3600); + } + } else if (!slot.connected) { + snprintf(buf + pos, bufsize - pos, ", no error info"); + } +} + +// --------------------------------------------------------------------------- +// Constructor +// --------------------------------------------------------------------------- +MQTTBridge::MQTTBridge(NodePrefs *prefs, mesh::PacketManager *mgr, mesh::RTCClock *rtc, mesh::LocalIdentity *identity) + : BridgeBase(prefs, mgr, rtc), + _queue_count(0), + _last_status_publish(0), _last_status_retry(0), _status_interval(300000), + _ntp_client(_ntp_udp, "pool.ntp.org", 0, 60000), _last_ntp_sync(0), _ntp_synced(false), _ntp_sync_pending(false), _slots_setup_done(false), _max_active_slots(RUNTIME_MQTT_SLOTS), + // Default to UTC; setRules() will be called from syncTimeWithNTP when a + // non-UTC timezone string is configured. Timezone has no default ctor, + // so we must pass rules here. + _timezone_storage(TimeChangeRule{"UTC", Last, Sun, Mar, 0, 0}, TimeChangeRule{"UTC", Last, Sun, Mar, 0, 0}), + _timezone(&_timezone_storage), + _last_raw_len(0), _last_snr(0), _last_rssi(0), _last_raw_timestamp(0), + _identity(identity), + _cached_has_connected_slots(false), + _last_memory_check(0), _skipped_publishes(0), + _last_no_broker_log(0), _queue_disconnected_since(0), + _last_config_warning(0), + _dispatcher(nullptr), _radio(nullptr), _board(nullptr), _ms(nullptr), +#ifdef WITH_SNMP + _snmp_agent(nullptr), +#endif + _last_wifi_check(0), _last_wifi_status(WL_DISCONNECTED), _wifi_status_initialized(false), + _wifi_disconnected_time(0), _last_wifi_reconnect_attempt(0), _wifi_reconnect_backoff_attempt(0), + _last_slot_reconnect_ms(0) +#ifdef ESP_PLATFORM + , _packet_queue_handle(nullptr), _mqtt_task_handle(nullptr), + _mqtt_task_stack(nullptr), _packet_queue_storage(nullptr) +#else + , _queue_head(0), _queue_tail(0) +#endif +{ + // Initialize default values + strncpy(_origin, "MeshCore-Repeater", sizeof(_origin) - 1); + strncpy(_iata, "XXX", sizeof(_iata) - 1); + strncpy(_device_id, "DEVICE_ID_PLACEHOLDER", sizeof(_device_id) - 1); + strncpy(_firmware_version, "unknown", sizeof(_firmware_version) - 1); + strncpy(_board_model, "unknown", sizeof(_board_model) - 1); + strncpy(_build_date, "unknown", sizeof(_build_date) - 1); + _status_enabled = true; + _packets_enabled = true; + _raw_enabled = false; + _rx_enabled = true; + _tx_mode = 0; + + // Initialize all slots to empty/disabled state + for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { + memset(&_slots[i], 0, sizeof(MQTTSlot)); + _slots[i].enabled = false; + _slots[i].client = nullptr; + _slots[i].preset = nullptr; + // auth_token[0] == '\0' after memset above — no valid token + _slots[i].connected = false; + _slots[i].initial_connect_done = false; + _slots[i].token_expires_at = 0; + _slots[i].last_token_renewal = 0; + _slots[i].reconnect_backoff = 0; + _slots[i].max_backoff_failures = 0; + _slots[i].circuit_breaker_tripped = false; + _slots[i].last_reconnect_attempt = 0; + _slots[i].last_log_time = 0; + _slots[i].port = 1883; + _slot_reconfigure_pending[i] = false; + } + + // Initialize JWT username + _jwt_username[0] = '\0'; + + // Initialize packet queue (FreeRTOS queue will be created in begin()) + #ifdef ESP_PLATFORM + // Queue and mutex will be created in begin() + #else + // Initialize circular buffer for non-ESP32 platforms + memset(_packet_queue, 0, sizeof(_packet_queue)); +#if defined(BOARD_HAS_PSRAM) + for (int i = 0; i < MAX_QUEUE_SIZE; i++) { + _packet_queue[i].has_raw_data = false; + } +#endif + #endif + + // On PSRAM boards, allocate raw radio buffer and JSON char buffers in PSRAM to preserve + // internal heap. On non-PSRAM boards these are inline arrays in the class object — + // no separate allocation needed. + #if defined(BOARD_HAS_PSRAM) + _last_raw_data = (uint8_t*)psram_malloc(LAST_RAW_DATA_SIZE); + _publish_json_buffer = (char*)psram_malloc(PUBLISH_JSON_BUFFER_SIZE); + _status_json_buffer = (char*)psram_malloc(STATUS_JSON_BUFFER_SIZE); + #else + memset(_last_raw_data, 0, sizeof(_last_raw_data)); + #endif + // JSON document scratch space is now a StaticJsonDocument inline class member — + // no heap allocation needed; reused via doc.clear() on every publish. +} + +// --------------------------------------------------------------------------- +// begin() +// --------------------------------------------------------------------------- +void MQTTBridge::begin() { + MQTT_DEBUG_PRINTLN("Initializing MQTT Bridge..."); + + // PSRAM diagnostic - helps debug memory fragmentation on boards with external RAM + #ifdef BOARD_HAS_PSRAM + { + bool psram_available = psramFound(); + size_t psram_size = 0; + size_t psram_free = 0; + if (psram_available) { + psram_size = ESP.getPsramSize(); + psram_free = ESP.getFreePsram(); + } + MQTT_DEBUG_PRINTLN("PSRAM: found=%s, size=%u, free=%u", + psram_available ? "YES" : "NO", psram_size, psram_free); + if (!psram_available) { + MQTT_DEBUG_PRINTLN("PSRAM: board has PSRAM flag but psramFound()=false. " + "Trying explicit psramInit()..."); + bool init_result = psramInit(); + MQTT_DEBUG_PRINTLN("PSRAM: psramInit() returned %s", init_result ? "true" : "false"); + if (init_result) { + psram_size = ESP.getPsramSize(); + psram_free = ESP.getFreePsram(); + MQTT_DEBUG_PRINTLN("PSRAM: after init - size=%u, free=%u", psram_size, psram_free); + } + } + // Log internal heap for comparison + MQTT_DEBUG_PRINTLN("PSRAM: internal_free=%u, internal_max_alloc=%u", + heap_caps_get_free_size(MALLOC_CAP_INTERNAL), + heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL)); + } + #else + MQTT_DEBUG_PRINTLN("PSRAM: not configured for this board (no BOARD_HAS_PSRAM)"); + #endif + + // Limit active slots based on available memory. + // Each WSS/TLS connection needs ~40KB for mbedTLS buffers. + // Without PSRAM, even 3 concurrent connections would exhaust internal heap. + // With PSRAM, cap at 5 for safety (6 configurable but 5 active max). + #if defined(ESP_PLATFORM) && defined(BOARD_HAS_PSRAM) + _max_active_slots = psramFound() ? 5 : 2; + #else + _max_active_slots = 2; + #endif + MQTT_DEBUG_PRINTLN("Max active slots: %d", _max_active_slots); + + // Check if WiFi credentials are configured first + if (!isWiFiConfigValid(_prefs)) { + MQTT_DEBUG_PRINTLN("MQTT Bridge initialization skipped - WiFi credentials not configured"); + return; + } + + // Update origin and IATA from preferences + strncpy(_origin, _prefs->mqtt_origin, sizeof(_origin) - 1); + _origin[sizeof(_origin) - 1] = '\0'; + strncpy(_iata, _prefs->mqtt_iata, sizeof(_iata) - 1); + _iata[sizeof(_iata) - 1] = '\0'; + + // Strip quotes from origin and IATA if present + stripQuotes(_origin, sizeof(_origin)); + stripQuotes(_iata, sizeof(_iata)); + + // Convert IATA code to uppercase (IATA codes are conventionally uppercase) + for (int i = 0; _iata[i]; i++) { + _iata[i] = toupper(_iata[i]); + } + + // Update enabled flags from preferences + _status_enabled = _prefs->mqtt_status_enabled; + _packets_enabled = _prefs->mqtt_packets_enabled; + _raw_enabled = _prefs->mqtt_raw_enabled; + _rx_enabled = _prefs->mqtt_rx_enabled; + _tx_mode = _prefs->mqtt_tx_enabled; // 0=off, 1=all, 2=advert + // Set status interval to 5 minutes (300000 ms), or use preference if set and valid + if (_prefs->mqtt_status_interval >= 1000 && _prefs->mqtt_status_interval <= 3600000) { + _status_interval = _prefs->mqtt_status_interval; + } else { + // Invalid or uninitialized value - fix it in preferences and use default + _prefs->mqtt_status_interval = 300000; // Fix the preference value + _status_interval = 300000; // 5 minutes default + } + + // Check for configuration mismatch: bridge.source=tx but mqtt.tx=off + checkConfigurationMismatch(); + + MQTT_DEBUG_PRINTLN("Config: Origin=%s, IATA=%s, Device=%s", _origin, _iata, _device_id); + + // Apply slot presets from preferences + for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { + const char* preset_name = _prefs->mqtt_slot_preset[i]; + if (preset_name[0] != '\0' && strcmp(preset_name, MQTT_PRESET_NONE) != 0) { + if (strcmp(preset_name, MQTT_PRESET_CUSTOM) == 0) { + // Custom broker: copy host/port/username/password from prefs + _slots[i].preset = nullptr; + strncpy(_slots[i].host, _prefs->mqtt_slot_host[i], sizeof(_slots[i].host) - 1); + _slots[i].host[sizeof(_slots[i].host) - 1] = '\0'; + if (strlen(_slots[i].host) == 0) { + MQTT_DEBUG_PRINTLN("MQTT%d: custom preset has no server configured, disabling", i + 1); + _slots[i].enabled = false; + continue; + } + _slots[i].enabled = true; + _slots[i].port = _prefs->mqtt_slot_port[i]; + strncpy(_slots[i].username, _prefs->mqtt_slot_username[i], sizeof(_slots[i].username) - 1); + _slots[i].username[sizeof(_slots[i].username) - 1] = '\0'; + strncpy(_slots[i].password, _prefs->mqtt_slot_password[i], sizeof(_slots[i].password) - 1); + _slots[i].password[sizeof(_slots[i].password) - 1] = '\0'; + strncpy(_slots[i].audience, _prefs->mqtt_slot_audience[i], sizeof(_slots[i].audience) - 1); + _slots[i].audience[sizeof(_slots[i].audience) - 1] = '\0'; + } else { + const MQTTPresetDef* preset = findMQTTPreset(preset_name); + if (preset) { + _slots[i].enabled = true; + _slots[i].preset = preset; + } else { + MQTT_DEBUG_PRINTLN("MQTT%d: unknown preset '%s', disabling", i + 1, preset_name); + _slots[i].enabled = false; + } + } + } + } + + // Log slot configuration + for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { + if (_slots[i].enabled) { + if (_slots[i].preset) { + MQTT_DEBUG_PRINTLN("MQTT%d: preset=%s", i + 1, _slots[i].preset->name); + } else { + MQTT_DEBUG_PRINTLN("MQTT%d: custom=%s:%d", i + 1, _slots[i].host, _slots[i].port); + } + } else { + MQTT_DEBUG_PRINTLN("MQTT%d: none", i + 1); + } + } + + #ifdef ESP_PLATFORM + // Create FreeRTOS queue; use PSRAM storage when available + #ifdef BOARD_HAS_PSRAM + _packet_queue_storage = (uint8_t*)psram_malloc(MAX_QUEUE_SIZE * sizeof(QueuedPacket)); + if (_packet_queue_storage != nullptr) { + _packet_queue_handle = xQueueCreateStatic(MAX_QUEUE_SIZE, sizeof(QueuedPacket), _packet_queue_storage, &_packet_queue_struct); + } else { + _packet_queue_handle = nullptr; + } + #else + // Non-PSRAM: use inline class-member storage with static queue creation. + // Eliminates a separate heap allocation, reducing startup fragmentation. + _packet_queue_storage = _packet_queue_inline; + _packet_queue_handle = xQueueCreateStatic(MAX_QUEUE_SIZE, sizeof(QueuedPacket), + _packet_queue_storage, &_packet_queue_struct); + #endif + if (_packet_queue_handle == nullptr) { + _packet_queue_handle = xQueueCreate(MAX_QUEUE_SIZE, sizeof(QueuedPacket)); + } + if (_packet_queue_handle == nullptr) { + MQTT_DEBUG_PRINTLN("Failed to create packet queue!"); + #if defined(BOARD_HAS_PSRAM) + psram_free(_packet_queue_storage); + #endif + _packet_queue_storage = nullptr; + return; + } + + // Create FreeRTOS task for MQTT/WiFi processing on Core 0 + #ifndef MQTT_TASK_CORE + #define MQTT_TASK_CORE 0 + #endif + #ifndef MQTT_TASK_STACK_SIZE + #define MQTT_TASK_STACK_SIZE 8192 + #endif + #ifndef MQTT_TASK_PRIORITY + #define MQTT_TASK_PRIORITY 1 + #endif + + // Task stack: use dynamic allocation (internal RAM). PSRAM stack was disabled because it + // causes resets on some boards (e.g. Heltec V4) when the task runs from PSRAM stack. + _mqtt_task_stack = nullptr; + _mqtt_task_handle = nullptr; + BaseType_t create_result = xTaskCreatePinnedToCore( + mqttTask, + "MQTTBridge", + MQTT_TASK_STACK_SIZE, + this, + MQTT_TASK_PRIORITY, + &_mqtt_task_handle, + MQTT_TASK_CORE + ); + if (create_result != pdPASS) _mqtt_task_handle = nullptr; + if (_mqtt_task_handle == nullptr) { + MQTT_DEBUG_PRINTLN("Failed to create MQTT task!"); + psram_free(_mqtt_task_stack); + _mqtt_task_stack = nullptr; + vQueueDelete(_packet_queue_handle); + _packet_queue_handle = nullptr; + #if defined(BOARD_HAS_PSRAM) + psram_free(_packet_queue_storage); + #endif + _packet_queue_storage = nullptr; + return; + } + + MQTT_DEBUG_PRINTLN("MQTT task created on Core %d", MQTT_TASK_CORE); + #else + // Non-ESP32: Initialize WiFi directly (no task) + WiFi.mode(WIFI_STA); + WiFi.setAutoReconnect(true); + WiFi.begin(_prefs->wifi_ssid, _prefs->wifi_password); + + // NOTE: Slot setup deferred until after NTP sync in loop() + #endif + + // Allocate persistent MQTT client objects once. They live for the bridge's + // lifetime so reconfigure/reconnect paths reuse the same mbedTLS context + // instead of churning ~40 KB of internal heap per cycle. + initSlotClients(); + + _initialized = true; + s_mqtt_bridge_instance = this; + MQTT_DEBUG_PRINTLN("MQTT Bridge initialized"); +} + +// --------------------------------------------------------------------------- +// end() +// --------------------------------------------------------------------------- +void MQTTBridge::end() { + MQTT_DEBUG_PRINTLN("Stopping MQTT Bridge..."); + s_mqtt_bridge_instance = nullptr; + + #ifdef ESP_PLATFORM + // Delete FreeRTOS task first (it will clean up WiFi/MQTT connections) + if (_mqtt_task_handle != nullptr) { + vTaskDelete(_mqtt_task_handle); + _mqtt_task_handle = nullptr; + } + // Free PSRAM task stack + psram_free(_mqtt_task_stack); + _mqtt_task_stack = nullptr; + + // Clean up queued packets from FreeRTOS queue + // Packets are value-copied in the queue, so no external pointers to clean up. + if (_packet_queue_handle != nullptr) { + QueuedPacket queued; + while (xQueueReceive(_packet_queue_handle, &queued, 0) == pdTRUE) { + _queue_count--; + } + vQueueDelete(_packet_queue_handle); + _packet_queue_handle = nullptr; + } + #if defined(BOARD_HAS_PSRAM) + psram_free(_packet_queue_storage); + #endif + _packet_queue_storage = nullptr; + + #else + // Clean up queued packet references + // Packets are value-copied in the queue, so no external pointers to clean up. + for (int i = 0; i < _queue_count; i++) { + int index = (_queue_head + i) % MAX_QUEUE_SIZE; + memset(&_packet_queue[index], 0, sizeof(QueuedPacket)); + } + + _queue_count = 0; + _queue_head = 0; + _queue_tail = 0; + memset(_packet_queue, 0, sizeof(_packet_queue)); + #endif + + // Disconnect and delete persistent MQTT clients. teardownSlot() intentionally + // only disconnects; destruction happens here so the mbedTLS contexts survive + // the reconfigure/reconnect hot path. + for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { + teardownSlot(i); + } + destroySlotClients(); + + // Timezone is inline class storage (_timezone_storage) since Phase 3 of + // the MQTT memory-defrag work — nothing to delete. _timezone always + // points at &_timezone_storage and stays valid for the bridge lifetime. + + // Free PSRAM-backed buffers (non-PSRAM builds use inline class arrays — no free needed) + #if defined(BOARD_HAS_PSRAM) + psram_free(_last_raw_data); _last_raw_data = nullptr; + psram_free(_publish_json_buffer); _publish_json_buffer = nullptr; + psram_free(_status_json_buffer); _status_json_buffer = nullptr; + #endif + // JSON documents are now StaticJsonDocument inline members — no heap allocation to free. + + _initialized = false; + _slots_setup_done = false; // Reset so deferred setup runs again on next begin() + MQTT_DEBUG_PRINTLN("MQTT Bridge stopped"); +} + +// --------------------------------------------------------------------------- +// FreeRTOS task entry point +// --------------------------------------------------------------------------- +#ifdef ESP_PLATFORM +void MQTTBridge::mqttTask(void* parameter) { + MQTTBridge* bridge = static_cast(parameter); + if (bridge) { + bridge->mqttTaskLoop(); + } + // Task should never return, but if it does, delete itself + vTaskDelete(nullptr); +} + +void MQTTBridge::initializeWiFiInTask() { + MQTT_DEBUG_PRINTLN("Initializing WiFi in MQTT task..."); + + // Initialize WiFi + WiFi.mode(WIFI_STA); + + // Enable automatic reconnection - ESP32 will handle reconnection automatically + WiFi.setAutoReconnect(true); + + // Set up WiFi event handlers for better diagnostics and immediate disconnection detection + WiFi.onEvent([this](WiFiEvent_t event, WiFiEventInfo_t info) { + switch(event) { + case ARDUINO_EVENT_WIFI_STA_GOT_IP: + MQTT_DEBUG_PRINTLN("WiFi connected: %s", IPAddress(info.got_ip.ip_info.ip.addr).toString().c_str()); + // Set flag to trigger NTP sync from loop() instead of doing it here + if (!_ntp_synced && !_ntp_sync_pending) { + _ntp_sync_pending = true; + } + break; + case ARDUINO_EVENT_WIFI_STA_DISCONNECTED: + s_wifi_disconnect_reason = info.wifi_sta_disconnected.reason; + s_wifi_disconnect_time = millis(); + MQTT_DEBUG_PRINTLN("WiFi disconnected: reason %d", s_wifi_disconnect_reason); + break; + default: + break; + } + }); + + WiFi.begin(_prefs->wifi_ssid, _prefs->wifi_password); + + // NOTE: Slot setup is deferred until after NTP sync in mqttTaskLoop(). + // JWT-auth slots need valid timestamps for token creation, and connecting + // before NTP sync just wastes heap on TLS handshakes that will be rejected. + + MQTT_DEBUG_PRINTLN("WiFi initialization started in task"); +} + +// --------------------------------------------------------------------------- +// mqttTaskLoop() - main loop running on Core 0 +// --------------------------------------------------------------------------- +void MQTTBridge::mqttTaskLoop() { + // Initialize WiFi first + initializeWiFiInTask(); + + // Wait a bit for WiFi to start connecting + vTaskDelay(pdMS_TO_TICKS(1000)); + + // Main task loop + #ifdef MQTT_MEMORY_DEBUG + static unsigned long last_agent_log = 0; + #endif + while (true) { + #ifdef MQTT_MEMORY_DEBUG + // #region agent log + unsigned long now_loop = millis(); + if (now_loop - last_agent_log >= 60000) { + last_agent_log = now_loop; + size_t free_h = ESP.getFreeHeap(); + size_t max_alloc = ESP.getMaxAllocHeap(); + unsigned long internal_f = heap_caps_get_free_size(MALLOC_CAP_INTERNAL); + unsigned long spiram_f = 0; + #ifdef BOARD_HAS_PSRAM + spiram_f = heap_caps_get_free_size(MALLOC_CAP_SPIRAM); + #endif + agentLogHeap("MQTTBridge.cpp:mqttTaskLoop", "mqtt_loop_60s", "H5", free_h, max_alloc, internal_f, spiram_f); + } + // #endregion + #endif + + unsigned long now = millis(); + bool wifi_just_connected = handleWiFiConnection(now); + if (wifi_just_connected) { + // WiFi recovered — reset last_reconnect_attempt for disconnected slots so they + // retry immediately rather than waiting up to 5 min for backoff timers to expire. + for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { + if (_slots[i].enabled && _slots[i].initial_connect_done && !_slots[i].connected) { + _slots[i].last_reconnect_attempt = 0; + } + } + } + + // Check for pending NTP sync (triggered from WiFi event handler) + if (_ntp_sync_pending && WiFi.status() == WL_CONNECTED) { + _ntp_sync_pending = false; + syncTimeWithNTP(); + } + + // Retry NTP every 30s if initial sync failed (slots can't start without valid time) + if (!_ntp_synced && WiFi.status() == WL_CONNECTED) { + static unsigned long last_ntp_retry = 0; + if (now - last_ntp_retry >= 30000) { + last_ntp_retry = now; + syncTimeWithNTP(); + } + } + + // Deferred slot setup: wait until NTP is synced so JWT tokens get valid timestamps. + // This avoids wasted TLS handshakes that get rejected due to bad token times. + if (_ntp_synced && !_slots_setup_done) { + _slots_setup_done = true; + + // Redirect mbedTLS allocations to PSRAM to save ~40KB internal heap per TLS connection. + // This is critical when running 3 concurrent WSS connections. + #if defined(BOARD_HAS_PSRAM) + mbedtls_platform_set_calloc_free(psram_calloc, psram_free); + MQTT_DEBUG_PRINTLN("mbedTLS allocator redirected to PSRAM"); + #endif + + MQTT_DEBUG_PRINTLN("NTP synced, setting up MQTT slots (max %d active)...", _max_active_slots); + int active_count = 0; + for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { + if (_slots[i].enabled) { + if (active_count >= _max_active_slots) { + MQTT_DEBUG_PRINTLN("MQTT%d skipped: max active slots (%d) reached (no PSRAM)", i + 1, _max_active_slots); + _slots[i].enabled = false; // Disable so other loops skip it + continue; + } + char reason[80]; + if (!isSlotReady(i, reason, sizeof(reason))) { + MQTT_DEBUG_PRINTLN("MQTT%d not ready — run '%s' to connect", i + 1, reason); + continue; + } + setupSlot(i); + active_count++; + // Stagger connections: 5s between slots to avoid simultaneous TLS handshakes + // which compete for ~40KB internal heap each + if (i < RUNTIME_MQTT_SLOTS - 1) { + vTaskDelay(pdMS_TO_TICKS(5000)); + } + } + } + } + + // Process pending slot reconfigures (queued from CLI on Core 1) + for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { + if (_slot_reconfigure_pending[i]) { + _slot_reconfigure_pending[i] = false; + MQTT_DEBUG_PRINTLN("Applying deferred reconfigure for MQTT%d (preset: %s)", i + 1, _prefs->mqtt_slot_preset[i]); + applySlotPreset(i, _prefs->mqtt_slot_preset[i]); + } + } + + // Maintain slot connections (token renewal, reconnect with backoff) + maintainSlotConnections(); + + // Process packet queue + processPacketQueue(); + +#ifdef WITH_SNMP + // SNMP agent loop — process incoming UDP requests + if (_snmp_agent) { + if (!_snmp_agent->isRunning() && WiFi.isConnected() && _prefs->snmp_enabled) { + _snmp_agent->begin(_prefs->snmp_community); + MQTT_DEBUG_PRINTLN("SNMP agent started on port 161 (community: %s)", _prefs->snmp_community); + } + if (_snmp_agent->isRunning()) { + // Update MQTT stats from this core + int connected = 0; + for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { + if (_slots[i].enabled && _slots[i].connected) connected++; + } + _snmp_agent->updateMQTTStats(connected, _queue_count, _skipped_publishes); + _snmp_agent->loop(); + } + } +#endif + + // Periodic configuration check (throttled to avoid spam) + checkConfigurationMismatch(); + + // Periodic NTP refresh (every hour) — lightweight, non-blocking. + // Uses async SNTP instead of the heavy syncTimeWithNTP() which blocks Core 0 + // for up to 20+ seconds with DNS lookups, UDP sockets, and retry loops. + if (WiFi.status() == WL_CONNECTED && now - _last_ntp_sync > 3600000) { + refreshNTP(); + } + + // Publish status updates (handle millis() overflow correctly) + if (_status_enabled) { + bool has_destinations = _cached_has_connected_slots; + + // Early exit if no destinations - skip all the expensive logic below + if (!has_destinations) { + if (_last_status_retry != 0) { + _last_status_retry = 0; + } + } else { + bool should_publish = false; + + // First, check if we need to respect retry interval (prevents spam when publish keeps failing) + if (_last_status_retry != 0) { + unsigned long retry_elapsed = (now >= _last_status_retry) ? + (now - _last_status_retry) : + (ULONG_MAX - _last_status_retry + now + 1); + if (retry_elapsed < STATUS_RETRY_INTERVAL) { + should_publish = false; + } else { + should_publish = true; + } + } else { + if (_last_status_publish == 0) { + should_publish = true; + } else { + unsigned long elapsed = (now >= _last_status_publish) ? + (now - _last_status_publish) : + (ULONG_MAX - _last_status_publish + now + 1); + should_publish = (elapsed >= _status_interval); + } + } + + if (should_publish) { + if (_last_status_publish != 0) { + unsigned long elapsed = (now >= _last_status_publish) ? + (now - _last_status_publish) : + (ULONG_MAX - _last_status_publish + now + 1); + MQTT_DEBUG_PRINTLN("Status publish timer expired (elapsed: %lu ms, interval: %lu ms)", elapsed, _status_interval); + } else { + MQTT_DEBUG_PRINTLN("Status publish attempt (first publish or retry)"); + } + + _last_status_retry = now; + if (publishStatus()) { + _last_status_publish = now; + _last_status_retry = 0; + MQTT_DEBUG_PRINTLN("Status published successfully, next publish in %lu ms", _status_interval); + } else { + MQTT_DEBUG_PRINTLN("Status publish failed, will retry in %lu ms", STATUS_RETRY_INTERVAL); + } + } + } + } + + // Update cached connection status periodically (every 5 seconds) + // This ensures cache stays accurate even if callbacks miss updates + static unsigned long last_slot_status_update = 0; + if (now - last_slot_status_update > 5000) { + updateCachedConnectionStatus(); + last_slot_status_update = now; + } + + // Adaptive delay: 5 ms when packets are queued, 50 ms when idle. + // The previous "status approaching" check (widening to 5 ms for 10 s before each status + // publish) caused 2 000 unnecessary wakeups per interval; the 50 ms idle tick catches + // the status deadline with at most 50 ms of extra latency, which is irrelevant at a + // 5-minute interval. + vTaskDelay(pdMS_TO_TICKS(_queue_count > 0 ? 5 : 50)); + } +} +#endif + +// --------------------------------------------------------------------------- +// Slot management +// --------------------------------------------------------------------------- + +// Allocate one PsychicMqttClient per slot and register its persistent callbacks. +// Called exactly once per bridge lifetime from begin(); the objects live until +// destroySlotClients(). Reconfiguring a slot (preset change, JWT renewal, +// reconnect) reuses the same client — no delete/new cycles, so the mbedTLS +// context and its ~40 KB of internal-heap buffers are allocated once instead +// of every reconfigure. +void MQTTBridge::initSlotClients() { + for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { + MQTTSlot& slot = _slots[i]; + if (slot.client != nullptr) continue; + + slot.client = new PsychicMqttClient(); + slot.client->setAutoReconnect(false); // we handle reconnect with our own backoff + + const int index = i; // capture a fresh copy so lambdas refer to the right slot + slot.client->onConnect([this, index](bool sessionPresent) { + MQTT_DEBUG_PRINTLN("MQTT%d connected", index + 1); + _slots[index].connected = true; + _slots[index].reconnect_backoff = 0; + _slots[index].max_backoff_failures = 0; + _slots[index].circuit_breaker_tripped = false; + _slots[index].last_tls_err = 0; + _slots[index].last_tls_stack_err = 0; + _slots[index].last_sock_errno = 0; + _slots[index].last_error_time = 0; + updateCachedConnectionStatus(); + publishStatusToSlot(index); + }); + slot.client->onDisconnect([this, index](bool sessionPresent) { + MQTT_DEBUG_PRINTLN("MQTT%d disconnected", index + 1); + _slots[index].disconnect_count++; + if (_slots[index].first_disconnect_time == 0) { + _slots[index].first_disconnect_time = millis(); + } + _slots[index].connected = false; + updateCachedConnectionStatus(); + }); + slot.client->onError([this, index](esp_mqtt_error_codes error) { + _slots[index].last_tls_err = error.esp_tls_last_esp_err; + _slots[index].last_tls_stack_err = error.esp_tls_stack_err; + _slots[index].last_sock_errno = error.esp_transport_sock_errno; + _slots[index].last_error_time = millis(); + if (error.esp_tls_last_esp_err != 0 || error.esp_tls_stack_err != 0 || error.esp_transport_sock_errno != 0) { + MQTT_DEBUG_PRINTLN("MQTT%d error: tls=%d, tls_stack=%d, sock=%d, type=%d", + index + 1, error.esp_tls_last_esp_err, error.esp_tls_stack_err, + error.esp_transport_sock_errno, error.error_type); + } else { + MQTT_DEBUG_PRINTLN("MQTT%d error: type=%d", index + 1, error.error_type); + } + }); + } +} + +void MQTTBridge::destroySlotClients() { + for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { + MQTTSlot& slot = _slots[i]; + if (slot.client == nullptr) continue; + + if (slot.client->connected()) { + slot.client->disconnect(); + } + #ifdef ESP_PLATFORM + vTaskDelay(pdMS_TO_TICKS(50)); + #else + delay(50); + #endif + delete slot.client; + slot.client = nullptr; + } +} + +void MQTTBridge::setupSlot(int index) { + if (index < 0 || index >= RUNTIME_MQTT_SLOTS) return; + MQTTSlot& slot = _slots[index]; + + if (!slot.enabled) { + teardownSlot(index); + return; + } + + // Persistent client is expected to have been allocated by initSlotClients(). + // If it hasn't, we can't proceed — bail loudly rather than silently leaking. + if (slot.client == nullptr) { + MQTT_DEBUG_PRINTLN("MQTT%d: setupSlot before initSlotClients() — skipping", index + 1); + return; + } + + // Reconfigure path: if we're re-applying (e.g. after a preset change), stop + // the existing connection cleanly first. The client object (and its mbedTLS + // context) is reused; setCredentials / setServer below overwrite the config + // fields in place before connect() restarts the ESP-IDF client. + if (slot.initial_connect_done) { + if (slot.client->connected()) { + slot.client->disconnect(); + } + // Clear TLS verification fields so a stale CA-bundle attach or cert + // pointer from a prior preset doesn't override the new one. + esp_mqtt_client_config_t* cfg = slot.client->getMqttConfig(); + #if ESP_IDF_VERSION_MAJOR == 5 + cfg->broker.verification.certificate = nullptr; + cfg->broker.verification.certificate_len = 0; + cfg->broker.verification.crt_bundle_attach = nullptr; + cfg->credentials.username = nullptr; + cfg->credentials.authentication.password = nullptr; + #else + cfg->cert_pem = nullptr; + cfg->cert_len = 0; + cfg->crt_bundle_attach = nullptr; + cfg->username = nullptr; + cfg->password = nullptr; + #endif + slot.auth_token[0] = '\0'; + slot.connected = false; + slot.token_expires_at = 0; + slot.last_token_renewal = 0; + slot.reconnect_backoff = 0; + slot.max_backoff_failures = 0; + slot.circuit_breaker_tripped = false; + slot.last_reconnect_attempt = 0; + } + + bool uses_jwt = (slot.preset && slot.preset->auth_type == MQTT_AUTH_JWT) || slot.audience[0] != '\0'; + optimizeMqttClientConfig(slot.client, uses_jwt); // sets keepalive (45s PSRAM, 75s non-PSRAM) + #ifndef MQTT_FORCE_KEEPALIVE_45 + #if defined(BOARD_HAS_PSRAM) + if (slot.preset && slot.preset->keepalive > 0) { + slot.client->setKeepAlive(slot.preset->keepalive); // preset overrides default + } + #else + // Non-PSRAM: keep the longer 75s default to reduce TLS churn. + // Preset keepalive (55s) is more aggressive than needed behind Cloudflare. + #endif + #endif + + if (slot.preset) { + // Preset-based slot + slot.client->setServer(slot.preset->server_url); + if (slot.preset->ca_cert) { + slot.client->setCACert(slot.preset->ca_cert); + } + + // Try to create token and connect (will succeed only if NTP synced) + if (slot.preset->auth_type == MQTT_AUTH_JWT) { + createSlotAuthToken(index); + if (slot.auth_token[0] != '\0') { + slot.client->setCredentials(_jwt_username, slot.auth_token); + } + } else if (slot.preset->auth_type == MQTT_AUTH_USERPASS && + slot.preset->userpass_username && slot.preset->userpass_password) { + slot.client->setCredentials(slot.preset->userpass_username, slot.preset->userpass_password); + } + } else { + // Custom broker slot — build persistent URI + // If host already has a scheme (mqtt://, mqtts://, ws://, wss://), preserve the full URI + // (including optional path/query) and only inject :port when the authority has no explicit port. + // Otherwise, infer protocol from port number. + bool has_scheme = (strncmp(slot.host, "mqtt://", 7) == 0 || + strncmp(slot.host, "mqtts://", 8) == 0 || + strncmp(slot.host, "ws://", 5) == 0 || + strncmp(slot.host, "wss://", 6) == 0); + if (has_scheme) { + const char* authority = strstr(slot.host, "://"); + authority = authority ? authority + 3 : slot.host; + const char* path = strchr(authority, '/'); + const char* authority_end = path ? path : slot.host + strlen(slot.host); + bool has_explicit_port = false; + + // Detect host:port in URI authority (IPv6 literals in [addr]:port are supported). + if (authority < authority_end) { + if (*authority == '[') { + const char* close = (const char*)memchr(authority, ']', authority_end - authority); + if (close && (close + 1) < authority_end && *(close + 1) == ':') { + has_explicit_port = true; + } + } else { + const char* colon = (const char*)memchr(authority, ':', authority_end - authority); + if (colon != nullptr) { + has_explicit_port = true; + } + } + } + + if (has_explicit_port || slot.port == 0) { + snprintf(slot.broker_uri, sizeof(slot.broker_uri), "%s", slot.host); + } else { + const size_t authority_len = (size_t)(authority_end - slot.host); + snprintf(slot.broker_uri, sizeof(slot.broker_uri), "%.*s:%u%s", + (int)authority_len, + slot.host, + (unsigned)slot.port, + path ? path : ""); + } + } else { + const char* proto = "mqtt"; + if (slot.port == 8883) { + proto = "mqtts"; + } else if (slot.port == 443) { + proto = "wss"; + } + snprintf(slot.broker_uri, sizeof(slot.broker_uri), "%s://%s:%d", proto, slot.host, slot.port); + } + slot.client->setServer(slot.broker_uri); + MQTT_DEBUG_PRINTLN("MQTT%d custom broker URI: %s (host='%s', port=%u)", + index + 1, slot.broker_uri, slot.host, (unsigned)slot.port); + + // Custom TLS/WSS slots need a CA bundle for server verification. + // The bundle is loaded into the global s_crt_bundle exactly once to avoid + // a use-after-free race: connect() launches an async FreeRTOS task, and + // calling setCACertBundle() again from a later slot would free the global + // crts array while a prior slot's TLS handshake may still be reading it. + bool needs_tls = (strncmp(slot.broker_uri, "mqtts://", 8) == 0 || + strncmp(slot.broker_uri, "wss://", 6) == 0); + if (needs_tls) { + if (!s_ca_bundle_loaded) { + size_t bundle_len = 0; + if (rootca_crt_bundle_start != nullptr && + rootca_crt_bundle_end != nullptr && + rootca_crt_bundle_end > rootca_crt_bundle_start) { + bundle_len = static_cast(rootca_crt_bundle_end - rootca_crt_bundle_start); + } + + if (bundle_len > 0) { + MQTT_DEBUG_PRINTLN("MQTT global CA bundle init: embedded bundle (%u bytes)", + (unsigned)bundle_len); + // Load the bundle into the global s_crt_bundle via the first client. + // This is a one-time operation; subsequent clients reuse via attachArduinoCACertBundle. + slot.client->setCACertBundle(rootca_crt_bundle_start, bundle_len); + s_ca_bundle_loaded = true; + } else { + MQTT_DEBUG_PRINTLN("MQTT%d TLS: no embedded cert bundle available", index + 1); + } + } else { + // Global bundle already loaded — just attach the callback for this client. + slot.client->attachArduinoCACertBundle(true); + } + MQTT_DEBUG_PRINTLN("MQTT%d TLS verify: CA bundle %s", index + 1, + s_ca_bundle_loaded ? "active" : "unavailable"); + } else { + MQTT_DEBUG_PRINTLN("MQTT%d custom broker uses non-TLS transport", index + 1); + } + + // Custom slot authentication: JWT if audience is set, else username/password + if (slot.audience[0] != '\0') { + // JWT auth for custom slot — create initial token (buffer is always inline) + createSlotAuthToken(index); + if (slot.auth_token[0] != '\0') { + slot.client->setCredentials(_jwt_username, slot.auth_token); + } + MQTT_DEBUG_PRINTLN("MQTT%d custom broker using JWT auth (audience: %s)", index + 1, slot.audience); + } else if (strlen(slot.username) > 0) { + slot.client->setCredentials(slot.username, slot.password); + } + } + + slot.client->connect(); + slot.initial_connect_done = true; +} + +// Disconnect the slot's MQTT client and clear per-connection state, but leave +// the client object alive so a subsequent setupSlot() can reuse its mbedTLS +// context. This is called both on reconfigure (preset change) and at shutdown; +// destruction of the underlying client happens once in destroySlotClients(). +void MQTTBridge::teardownSlot(int index) { + if (index < 0 || index >= RUNTIME_MQTT_SLOTS) return; + MQTTSlot& slot = _slots[index]; + + if (slot.client && slot.client->connected()) { + slot.client->disconnect(); + #ifdef ESP_PLATFORM + vTaskDelay(pdMS_TO_TICKS(50)); + #else + delay(50); + #endif + } + + slot.auth_token[0] = '\0'; + slot.connected = false; + slot.initial_connect_done = false; + slot.broker_uri[0] = '\0'; + slot.token_expires_at = 0; + slot.last_token_renewal = 0; + slot.reconnect_backoff = 0; + slot.max_backoff_failures = 0; + slot.circuit_breaker_tripped = false; + slot.last_reconnect_attempt = 0; + slot.last_log_time = 0; + slot.last_deferred_log_ms = 0; +} + +void MQTTBridge::maintainSlotConnections() { + if (!_identity) return; + + // Check WiFi status first + if (WiFi.status() != WL_CONNECTED) return; + + unsigned long now_millis = millis(); + unsigned long current_time = time(nullptr); + bool time_synced = (current_time >= 1000000000); // After year 2001 + + // JWT tokens require valid timestamps + unsigned long clock_sec = current_time; + bool clock_looks_set = (clock_sec >= 1735689600); // 2025-01-01 00:00:00 UTC + bool can_do_jwt = _ntp_synced || clock_looks_set; + + // Count connected slots to inform reconnect decisions + int connected_count = 0; + for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { + if (_slots[i].enabled && _slots[i].connected) connected_count++; + } + + // Only allow one reconnect attempt per maintenance cycle to avoid + // multiple simultaneous TLS handshakes blocking the network stack. + // Time-based guard: block reconnects if any slot reconnected within the last 15 s, + // ensuring the previous TLS handshake (and its Core-0-expensive completion events) + // finish before the next slot begins its own handshake. + const unsigned long RECONNECT_GUARD_MS = 15000UL; + bool reconnect_attempted_this_cycle = (now_millis - _last_slot_reconnect_ms < RECONNECT_GUARD_MS); + // Only allow one full teardown+setup per cycle to limit heap fragmentation + // when multiple slots fail simultaneously + bool teardown_attempted_this_cycle = false; + + for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { + if (!_slots[i].enabled || !_slots[i].client) continue; + + // JWT slots need time sync before we can manage tokens + bool slot_jwt = (_slots[i].preset && _slots[i].preset->auth_type == MQTT_AUTH_JWT) || + (!_slots[i].preset && _slots[i].audience[0] != '\0'); + if (slot_jwt && !can_do_jwt) { + continue; + } + + maintainSlotConnection(i, now_millis, current_time, time_synced, reconnect_attempted_this_cycle, teardown_attempted_this_cycle); + } +} + +void MQTTBridge::maintainSlotConnection(int index, unsigned long now_millis, unsigned long current_time, bool time_synced, bool& reconnect_attempted, bool& teardown_attempted) { + MQTTSlot& slot = _slots[index]; + + if (slot.connected) { + slot.reconnect_backoff = 0; + slot.max_backoff_failures = 0; + } + + // JWT token renewal (for preset JWT slots and custom slots with audience set) + bool slot_uses_jwt = (slot.preset && slot.preset->auth_type == MQTT_AUTH_JWT) || + (!slot.preset && slot.audience[0] != '\0'); + if (slot_uses_jwt) { + bool token_needs_renewal = false; + if (!time_synced) { + token_needs_renewal = (slot.token_expires_at == 0); + } else { + const unsigned long RENEWAL_BUFFER = 60; + token_needs_renewal = (slot.token_expires_at == 0) || + !(slot.token_expires_at >= 1000000000) || + (current_time >= slot.token_expires_at) || + (current_time >= (slot.token_expires_at - RENEWAL_BUFFER)); + } + + // Throttle renewal attempts to once per minute + const unsigned long RENEWAL_THROTTLE_MS = 60000; + bool can_attempt_renewal = (now_millis - slot.last_token_renewal) >= RENEWAL_THROTTLE_MS; + + if (token_needs_renewal && can_attempt_renewal) { + slot.last_token_renewal = now_millis; + + unsigned long old_token_expires_at = slot.token_expires_at; + + if (createSlotAuthToken(index)) { + MQTT_DEBUG_PRINTLN("MQTT%d token renewed", index + 1); + + const unsigned long DISCONNECT_THRESHOLD = 60; + bool old_token_expired_or_imminent = !time_synced || + (old_token_expires_at == 0) || + (current_time >= old_token_expires_at) || + (time_synced && old_token_expires_at >= 1000000000 && + current_time >= (old_token_expires_at - DISCONNECT_THRESHOLD)); + + if (old_token_expired_or_imminent || !slot.client->connected()) { + // Disconnect + reconnect with fresh credentials, reusing existing client + // to avoid internal heap leak/fragmentation from destroy/create cycles + MQTT_DEBUG_PRINTLN("MQTT%d token renewal: reconnecting with fresh credentials", index + 1); + if (slot.client->connected()) { + slot.client->disconnect(); // stops the client internally + } + slot.client->setCredentials(_jwt_username, slot.auth_token); + slot.client->connect(); // restart stopped client; reconnect() fails silently on a stopped client + reconnect_attempted = true; + _last_slot_reconnect_ms = now_millis; + MQTT_DEBUG_PRINTLN("MQTT%d int_heap=%d at token renewal reconnect", index + 1, + (int)heap_caps_get_free_size(MALLOC_CAP_INTERNAL)); + MQTT_DEBUG_PRINTLN(" radio_state=%d, last_rx=%lums ago", + _radio ? _radio->getRadioState() : -1, + (_radio && _radio->getLastRecvMillis() > 0) ? (_ms->getMillis() - _radio->getLastRecvMillis()) : 0); + } else { + // Token renewed but old one still valid — just update credentials for next reconnect + slot.client->setCredentials(_jwt_username, slot.auth_token); + } + } else { + MQTT_DEBUG_PRINTLN("MQTT%d token renewal failed", index + 1); + slot.token_expires_at = 0; + } + return; // Token renewal handled connect; skip backoff logic below + } + } + + // Phase 4 (MQTT memory-defrag): the MIN_TLS_HEAP preflight was a workaround + // for the fragmentation caused by per-reconnect mbedTLS allocations. With + // persistent clients (Phase 1), the mbedTLS context is allocated once at + // startup and the preflight is no longer necessary. + + // Periodic probe for circuit-breaker-tripped slots (recovery from transient outages) + // Attempts a single reconnect every 30 minutes to see if the server has come back + if (slot.circuit_breaker_tripped && !reconnect_attempted) { + static const unsigned long CIRCUIT_BREAKER_PROBE_INTERVAL_MS = 1800000UL; // 30 minutes + unsigned long probe_elapsed = (now_millis >= slot.last_reconnect_attempt) ? + (now_millis - slot.last_reconnect_attempt) : + (ULONG_MAX - slot.last_reconnect_attempt + now_millis + 1); + if (probe_elapsed >= CIRCUIT_BREAKER_PROBE_INTERVAL_MS) { + slot.last_reconnect_attempt = now_millis; + reconnect_attempted = true; + _last_slot_reconnect_ms = now_millis; + MQTT_DEBUG_PRINTLN("MQTT%d circuit breaker probe (attempting single reconnect after %lu ms, int_heap=%d)", index + 1, probe_elapsed, + (int)heap_caps_get_free_size(MALLOC_CAP_INTERNAL)); + MQTT_DEBUG_PRINTLN(" radio_state=%d, last_rx=%lums ago", + _radio ? _radio->getRadioState() : -1, + (_radio && _radio->getLastRecvMillis() > 0) ? (_ms->getMillis() - _radio->getLastRecvMillis()) : 0); + if (slot_uses_jwt) { + // Regenerate or refresh token, then reconnect the persistent client. + // The client object and its mbedTLS context are always live post + // initSlotClients(), so no full setup is ever needed here. + if (createSlotAuthToken(index)) { + slot.client->setCredentials(_jwt_username, slot.auth_token); + MQTT_DEBUG_PRINTLN("MQTT%d circuit breaker probe (fresh token)", index + 1); + } + slot.client->reconnect(); + } else { + slot.client->reconnect(); + } + // If the connect callback fires and sets slot.connected = true, + // it will clear circuit_breaker_tripped via the onConnect handler + } + } + + // Reconnect with exponential backoff (for disconnected slots that already have valid config) + // Only one reconnect per maintenance cycle to prevent TLS handshakes from blocking other slots + if (!slot.connected && slot.initial_connect_done && !slot.circuit_breaker_tripped && !reconnect_attempted) { + static const unsigned long SLOT_BACKOFF_MS[] = { 10000, 30000, 60000, 120000, 300000 }; + static const uint8_t MAX_FAILURES_AT_MAX_BACKOFF = 3; // ~15 min at max backoff before giving up + unsigned long reconnect_elapsed = (now_millis >= slot.last_reconnect_attempt) ? + (now_millis - slot.last_reconnect_attempt) : + (ULONG_MAX - slot.last_reconnect_attempt + now_millis + 1); + unsigned int idx = (slot.reconnect_backoff < 5) ? slot.reconnect_backoff : 4; + unsigned long delay_ms = SLOT_BACKOFF_MS[idx] + (index * 3000UL); // stagger by slot index + if (reconnect_elapsed >= delay_ms) { + slot.last_reconnect_attempt = now_millis; + if (slot.reconnect_backoff < 5) { + slot.reconnect_backoff++; + } else { + slot.max_backoff_failures++; + if (slot.max_backoff_failures >= MAX_FAILURES_AT_MAX_BACKOFF) { + slot.circuit_breaker_tripped = true; + MQTT_DEBUG_PRINTLN("MQTT%d circuit breaker tripped after %d failures at max backoff - stopping reconnect attempts. Reconfigure slot to retry.", index + 1, slot.max_backoff_failures); + return; + } + } + MQTT_DEBUG_PRINTLN("MQTT%d reconnecting (backoff level %d, failures at max: %d, int_heap=%d)", index + 1, slot.reconnect_backoff, slot.max_backoff_failures, + (int)heap_caps_get_free_size(MALLOC_CAP_INTERNAL)); + MQTT_DEBUG_PRINTLN(" radio_state=%d, last_rx=%lums ago", + _radio ? _radio->getRadioState() : -1, + (_radio && _radio->getLastRecvMillis() > 0) ? (_ms->getMillis() - _radio->getLastRecvMillis()) : 0); + reconnect_attempted = true; + _last_slot_reconnect_ms = now_millis; + if (slot_uses_jwt) { + // Always lightweight reconnect on the persistent client. A stale/expired + // token is handled by regenerating it in place and updating credentials + // — no teardown is needed because the client and its mbedTLS context + // persist for the bridge lifetime. + if (createSlotAuthToken(index)) { + slot.client->setCredentials(_jwt_username, slot.auth_token); + MQTT_DEBUG_PRINTLN("MQTT%d reconnect (fresh token, backoff %d)", index + 1, slot.reconnect_backoff); + } else { + MQTT_DEBUG_PRINTLN("MQTT%d reconnect (token refresh failed, backoff %d)", index + 1, slot.reconnect_backoff); + } + slot.client->reconnect(); + } else { + // Non-JWT slots — lightweight reconnect on existing client. + MQTT_DEBUG_PRINTLN("MQTT%d reconnect (non-JWT, backoff %d)", index + 1, slot.reconnect_backoff); + slot.client->reconnect(); + } + } + } +} + +bool MQTTBridge::createSlotAuthToken(int index) { + if (index < 0 || index >= RUNTIME_MQTT_SLOTS) return false; + MQTTSlot& slot = _slots[index]; + if (!_identity) return false; + + // Determine JWT audience: preset takes priority, then custom slot audience field + const char* audience = nullptr; + unsigned long base_lifetime = 86400; // default 24h + if (slot.preset && slot.preset->auth_type == MQTT_AUTH_JWT) { + audience = slot.preset->jwt_audience; + if (slot.preset->token_lifetime > 0) base_lifetime = slot.preset->token_lifetime; + } else if (slot.audience[0] != '\0') { + audience = slot.audience; + } + if (!audience || audience[0] == '\0') return false; + + // Ensure JWT username is set + if (_jwt_username[0] == '\0') { + char public_key_hex[65]; + mesh::Utils::toHex(public_key_hex, _identity->pub_key, PUB_KEY_SIZE); + snprintf(_jwt_username, sizeof(_jwt_username), "v1_%s", public_key_hex); + } + + // Prepare owner key + const char* owner_key = nullptr; + char owner_key_uppercase[65]; + if (_prefs->mqtt_owner_public_key[0] != '\0') { + strncpy(owner_key_uppercase, _prefs->mqtt_owner_public_key, sizeof(owner_key_uppercase) - 1); + owner_key_uppercase[sizeof(owner_key_uppercase) - 1] = '\0'; + for (int i = 0; owner_key_uppercase[i]; i++) { + owner_key_uppercase[i] = toupper(owner_key_uppercase[i]); + } + owner_key = owner_key_uppercase; + } + + char client_version[64]; + getClientVersion(client_version, sizeof(client_version)); + const char* email = (_prefs->mqtt_email[0] != '\0') ? _prefs->mqtt_email : nullptr; + + unsigned long current_time = time(nullptr); + // Stagger token expiry per slot to avoid simultaneous renewal/reconnect + // Use 5% of lifetime per slot, capped at 300s, so short-lived tokens aren't over-reduced + unsigned long stagger = index * min((unsigned long)300, base_lifetime / 20); + unsigned long expires_in = base_lifetime - stagger; + bool time_synced = (current_time >= 1000000000); + + if (JWTHelper::createAuthToken( + *_identity, audience, + 0, expires_in, slot.auth_token, AUTH_TOKEN_SIZE, + owner_key, client_version, email)) { + slot.token_expires_at = time_synced ? (current_time + expires_in) : 0; + return true; + } + + slot.token_expires_at = 0; + return false; +} + +bool MQTTBridge::publishToSlot(int index, const char* topic, const char* payload, bool retained, uint8_t qos) { + if (index < 0 || index >= RUNTIME_MQTT_SLOTS) return false; + MQTTSlot& slot = _slots[index]; + if (!slot.client || !slot.connected) { + unsigned long now = millis(); + if (now - slot.last_log_time > SLOT_LOG_INTERVAL) { + slot.last_log_time = now; + MQTT_DEBUG_PRINTLN("MQTT%d not connected - skipping publish", index + 1); + } + return false; + } + + // QoS 0 for the high-rate packet/raw publish paths: no PUBACK, no outbox store, + // no per-message heap alloc — critical for non-PSRAM fragmentation. QoS 1 is used + // only for low-rate retained status messages where delivery matters. + // + // esp_mqtt_client_enqueue return convention: QoS 0 returns msg_id == 0 on success + // (no tracking since there's no PUBACK); QoS 1/2 return a positive msg_id. Negative + // values (-1 generic failure, -2 outbox full) are the only actual failures. + int result = slot.client->publish(topic, qos, retained, payload, strlen(payload), true); + if (result < 0) { + // QoS0 packet/raw publishes are best-effort and may be retried from the + // bridge queue; avoid logging transient first-attempt failures here. + if (qos > 0) { + static unsigned long last_fail_log = 0; + unsigned long now = millis(); + if (now - last_fail_log > 60000) { + MQTT_DEBUG_PRINTLN("MQTT%d publish failed (result=%d qos=%u)", index + 1, result, (unsigned)qos); + last_fail_log = now; + } + } + return false; + } + return true; +} + +bool MQTTBridge::publishToAllSlots(const char* topic, const char* payload, bool retained, uint8_t qos) { + bool published = false; + for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { + if (_slots[i].enabled && _slots[i].client && _slots[i].connected) { + if (publishToSlot(i, topic, payload, retained, qos)) { + published = true; + } + } + } + return published; +} + +// --------------------------------------------------------------------------- +// Topic building - resolves the correct topic for a given slot and message type. +// Presets use hardcoded topic logic; custom slots support user-defined templates. +// --------------------------------------------------------------------------- +bool MQTTBridge::substituteTopicTemplate(const char* tmpl, MQTTMessageType type, int slot_index, char* buf, size_t buf_size) { + const char* type_str = (type == MSG_STATUS) ? "status" : (type == MSG_PACKETS) ? "packets" : "raw"; + const char* token = _prefs->mqtt_slot_token[slot_index]; + + size_t out = 0; + const char* p = tmpl; + while (*p && out < buf_size - 1) { + if (*p == '{') { + if (strncmp(p, "{iata}", 6) == 0) { + size_t len = strlen(_iata); + if (out + len >= buf_size) return false; + memcpy(buf + out, _iata, len); + out += len; + p += 6; + } else if (strncmp(p, "{device}", 8) == 0) { + size_t len = strlen(_device_id); + if (out + len >= buf_size) return false; + memcpy(buf + out, _device_id, len); + out += len; + p += 8; + } else if (strncmp(p, "{token}", 7) == 0) { + size_t len = strlen(token); + if (out + len >= buf_size) return false; + memcpy(buf + out, token, len); + out += len; + p += 7; + } else if (strncmp(p, "{type}", 6) == 0) { + size_t len = strlen(type_str); + if (out + len >= buf_size) return false; + memcpy(buf + out, type_str, len); + out += len; + p += 6; + } else { + buf[out++] = *p++; + } + } else { + buf[out++] = *p++; + } + } + buf[out] = '\0'; + return out > 0; +} + +bool MQTTBridge::buildTopicForSlot(int index, MQTTMessageType type, char* topic_buf, size_t buf_size) { + if (index < 0 || index >= RUNTIME_MQTT_SLOTS) return false; + const MQTTSlot& slot = _slots[index]; + + // Preset slots: use hardcoded topic logic + if (slot.preset) { + if (slot.preset->topic_style == MQTT_TOPIC_MESHRANK) { + // MeshRank: packets only, uses per-slot token in topic path + if (type != MSG_PACKETS) return false; + const char* token = _prefs->mqtt_slot_token[index]; + if (!token || token[0] == '\0') return false; + snprintf(topic_buf, buf_size, "meshrank/uplink/%s/%s/packets", token, _device_id); + return true; + } + // MQTT_TOPIC_MESHCORE (default for all other presets) + if (!isIATAValid()) return false; + const char* type_str = (type == MSG_STATUS) ? "status" : (type == MSG_PACKETS) ? "packets" : "raw"; + snprintf(topic_buf, buf_size, "meshcore/%s/%s/%s", _iata, _device_id, type_str); + return true; + } + + // Custom slots: use topic template if set, otherwise default meshcore format + if (_prefs->mqtt_slot_topic[index][0] != '\0') { + return substituteTopicTemplate(_prefs->mqtt_slot_topic[index], type, index, topic_buf, buf_size); + } + // Default: meshcore format + if (!isIATAValid()) return false; + const char* type_str = (type == MSG_STATUS) ? "status" : (type == MSG_PACKETS) ? "packets" : "raw"; + snprintf(topic_buf, buf_size, "meshcore/%s/%s/%s", _iata, _device_id, type_str); + return true; +} + +void MQTTBridge::publishStatusToSlot(int index) { + if (index < 0 || index >= RUNTIME_MQTT_SLOTS) return; + MQTTSlot& slot = _slots[index]; + if (!slot.client || !slot.connected) return; + + // Build per-slot topic (handles IATA check for meshcore, token check for meshrank) + char status_topic[128]; + if (!buildTopicForSlot(index, MSG_STATUS, status_topic, sizeof(status_topic))) { + return; // Slot doesn't support status (e.g., meshrank) or missing required config + } + + // Reuse pre-allocated buffer to avoid heap alloc/free churn under memory pressure. + // _status_json_buffer and _last_raw_data are both Core 0-owned; no mutex needed. + char fallback_status_buffer[STATUS_JSON_BUFFER_SIZE]; + char* json_buffer = (_status_json_buffer != nullptr) ? _status_json_buffer : fallback_status_buffer; + + char origin_id[65]; + char timestamp[32]; + char radio_info[64]; + + // Get current timestamp in ISO 8601 format + struct tm timeinfo; + if (getLocalTime(&timeinfo)) { + strftime(timestamp, sizeof(timestamp), "%Y-%m-%dT%H:%M:%S.000000", &timeinfo); + } else { + strcpy(timestamp, "2024-01-01T12:00:00.000000"); + } + + snprintf(radio_info, sizeof(radio_info), "%.6f,%.1f,%d,%d", + _prefs->freq, _prefs->bw, _prefs->sf, _prefs->cr); + + strncpy(origin_id, _device_id, sizeof(origin_id) - 1); + origin_id[sizeof(origin_id) - 1] = '\0'; + + char client_version[64]; + getClientVersion(client_version, sizeof(client_version)); + + // Collect stats on-demand if sources are available + int battery_mv = -1; + int uptime_secs = -1; + int errors = -1; + int noise_floor = -999; + int tx_air_secs = -1; + int rx_air_secs = -1; + int recv_errors = -1; + + if (_board) battery_mv = _board->getBattMilliVolts(); + if (_ms) uptime_secs = _ms->getMillis() / 1000; + if (_dispatcher) { + errors = _dispatcher->getErrFlags(); + tx_air_secs = _dispatcher->getTotalAirTime() / 1000; + rx_air_secs = _dispatcher->getReceiveAirTime() / 1000; + } + if (_radio) { + noise_floor = (int16_t)_radio->getNoiseFloor(); + recv_errors = (int)_radio->getPacketsRecvErrors(); + } + + // Internal heap free (for diagnosing repeater hangs from internal heap exhaustion) + int internal_heap_free = (int)heap_caps_get_free_size(MALLOC_CAP_INTERNAL); + + int len = MQTTMessageBuilder::buildStatusMessage( + _status_json_doc, + _origin, origin_id, _board_model, _firmware_version, radio_info, + client_version, "online", timestamp, json_buffer, STATUS_JSON_BUFFER_SIZE, + battery_mv, uptime_secs, errors, _queue_count, noise_floor, + tx_air_secs, rx_air_secs, recv_errors, internal_heap_free + ); + + if (len > 0) { + int result = slot.client->publish(status_topic, 1, true, json_buffer, strlen(json_buffer)); + if (result <= 0) { + MQTT_DEBUG_PRINTLN("MQTT%d status publish failed", index + 1); + } + } +} + +void MQTTBridge::updateCachedConnectionStatus() { + bool any_connected = false; + for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { + if (_slots[i].enabled && _slots[i].connected) { + any_connected = true; + break; + } + } + _cached_has_connected_slots = any_connected; +} + +bool MQTTBridge::isAnySlotConnected() { + for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { + if (_slots[i].enabled && _slots[i].connected) { + return true; + } + } + return false; +} + +void MQTTBridge::setSlotPreset(int slot_index, const char* preset_name) { + if (slot_index < 0 || slot_index >= RUNTIME_MQTT_SLOTS) return; + + // On ESP32, teardown/setup involves TLS and must run on the MQTT task (Core 0). + // Set a flag so the MQTT task picks it up on its next loop iteration. + #ifdef ESP_PLATFORM + if (_mqtt_task_handle != nullptr) { + _slot_reconfigure_pending[slot_index] = true; + MQTT_DEBUG_PRINTLN("MQTT%d reconfigure queued (preset: %s)", slot_index + 1, preset_name); + return; + } + #endif + + // Non-ESP32 or bridge not yet started: apply directly + applySlotPreset(slot_index, preset_name); +} + +void MQTTBridge::applySlotPreset(int slot_index, const char* preset_name) { + if (slot_index < 0 || slot_index >= RUNTIME_MQTT_SLOTS) return; + MQTTSlot& slot = _slots[slot_index]; + + teardownSlot(slot_index); + + if (strcmp(preset_name, MQTT_PRESET_NONE) == 0 || preset_name[0] == '\0') { + slot.enabled = false; + slot.preset = nullptr; + return; + } + + if (strcmp(preset_name, MQTT_PRESET_CUSTOM) == 0) { + slot.enabled = true; + slot.preset = nullptr; + // Custom broker settings should already be set via setSlotCustomBroker + if (_initialized && strlen(slot.host) > 0 && slot.port > 0) { + setupSlot(slot_index); + } + return; + } + + const MQTTPresetDef* preset = findMQTTPreset(preset_name); + if (preset) { + slot.enabled = true; + slot.preset = preset; + if (_initialized) { + char reason[80]; + if (!isSlotReady(slot_index, reason, sizeof(reason))) { + MQTT_DEBUG_PRINTLN("MQTT%d (%s) not ready — run '%s' to connect", slot_index + 1, preset_name, reason); + return; + } + setupSlot(slot_index); + } + } +} + +void MQTTBridge::setSlotCustomBroker(int slot_index, const char* host, uint16_t port, + const char* username, const char* password) { + if (slot_index < 0 || slot_index >= RUNTIME_MQTT_SLOTS) return; + MQTTSlot& slot = _slots[slot_index]; + + strncpy(slot.host, host ? host : "", sizeof(slot.host) - 1); + slot.host[sizeof(slot.host) - 1] = '\0'; + slot.port = port; + strncpy(slot.username, username ? username : "", sizeof(slot.username) - 1); + slot.username[sizeof(slot.username) - 1] = '\0'; + strncpy(slot.password, password ? password : "", sizeof(slot.password) - 1); + slot.password[sizeof(slot.password) - 1] = '\0'; +} + +// --------------------------------------------------------------------------- +// WiFi connection handling +// --------------------------------------------------------------------------- + +void MQTTBridge::checkConfigurationMismatch() { + // Warn if packets are enabled but both rx and tx are off — nothing will be published + if (_prefs->mqtt_packets_enabled && !_prefs->mqtt_rx_enabled && _prefs->mqtt_tx_enabled == 0) { + unsigned long now = millis(); + if (_last_config_warning == 0 || (now - _last_config_warning > CONFIG_WARNING_INTERVAL)) { + MQTT_DEBUG_PRINTLN("MQTT: Both mqtt.rx and mqtt.tx are off — no packets will be published. Run 'set mqtt.rx on' or 'set mqtt.tx on' to fix."); + _last_config_warning = now; + } + } else { + _last_config_warning = 0; + } +} + +bool MQTTBridge::handleWiFiConnection(unsigned long now) { + wl_status_t current_wifi_status = WiFi.status(); + bool transitioned_to_connected = false; + + if (current_wifi_status == WL_CONNECTED && s_wifi_connected_at == 0) { + s_wifi_connected_at = now; + } + if (!_wifi_status_initialized) { + _last_wifi_status = current_wifi_status; + _wifi_status_initialized = true; + if (current_wifi_status != WL_CONNECTED) { + _wifi_disconnected_time = now; + } + } + if (now - _last_wifi_check <= 10000) { + return false; + } + _last_wifi_check = now; + + if (current_wifi_status == WL_CONNECTED) { + if (_last_wifi_status != WL_CONNECTED) { + transitioned_to_connected = true; + _wifi_disconnected_time = 0; + s_wifi_connected_at = now; + _wifi_reconnect_backoff_attempt = 0; + #ifdef ESP_PLATFORM + wifi_ps_type_t ps_mode; + uint8_t ps_pref = _prefs->wifi_power_save; + if (ps_pref == 1) { + ps_mode = WIFI_PS_NONE; + } else if (ps_pref == 2) { + ps_mode = WIFI_PS_MAX_MODEM; + } else { + ps_mode = WIFI_PS_NONE; // default: no power save; eliminates DTIM wake latency on mains-powered bridges + } + esp_wifi_set_ps(ps_mode); + #ifdef MQTT_WIFI_TX_POWER + WiFi.setTxPower(MQTT_WIFI_TX_POWER); + #else + WiFi.setTxPower(WIFI_POWER_11dBm); + #endif + #endif + } + if (s_wifi_connected_at == 0) { + s_wifi_connected_at = now; + } + _last_wifi_status = WL_CONNECTED; + } else { + if (_last_wifi_status == WL_CONNECTED) { + _wifi_disconnected_time = now; + s_wifi_connected_at = 0; + // Disconnect all slot clients when WiFi drops + for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { + if (_slots[i].client && _slots[i].connected) { + _slots[i].client->disconnect(); + } + } + } else if (_wifi_disconnected_time > 0) { + unsigned long disconnected_duration = now - _wifi_disconnected_time; + static const unsigned long WIFI_BACKOFF_MS[] = { 15000, 30000, 60000, 120000, 300000 }; + unsigned int idx = (_wifi_reconnect_backoff_attempt < 5) ? _wifi_reconnect_backoff_attempt : 4; + unsigned long delay_ms = WIFI_BACKOFF_MS[idx]; + unsigned long elapsed_since_attempt = (now >= _last_wifi_reconnect_attempt) + ? (now - _last_wifi_reconnect_attempt) + : (ULONG_MAX - _last_wifi_reconnect_attempt + now + 1); + if (disconnected_duration >= delay_ms && elapsed_since_attempt >= delay_ms) { + _last_wifi_reconnect_attempt = now; + if (_wifi_reconnect_backoff_attempt < 5) { + _wifi_reconnect_backoff_attempt++; + } + WiFi.disconnect(); + WiFi.begin(_prefs->wifi_ssid, _prefs->wifi_password); + } + } + _last_wifi_status = current_wifi_status; + } + return transitioned_to_connected; +} + +bool MQTTBridge::isReady() const { + return _initialized && isWiFiConfigValid(_prefs); +} + +bool MQTTBridge::isIATAValid() const { + if (strlen(_iata) == 0 || strcmp(_iata, "XXX") == 0) { + return false; + } + return true; +} + +bool MQTTBridge::isSlotReady(int index, char* reason_buf, size_t reason_size) const { + if (index < 0 || index >= RUNTIME_MQTT_SLOTS) return false; + const MQTTSlot& slot = _slots[index]; + + if (!slot.enabled) return true; // disabled slots are "ready" (nothing to do) + + if (slot.preset) { + if (slot.preset->topic_style == MQTT_TOPIC_MESHRANK) { + if (_prefs->mqtt_slot_token[index][0] == '\0') { + if (reason_buf) snprintf(reason_buf, reason_size, "set mqtt%d.token ", index + 1); + return false; + } + } else if (slot.preset->topic_style == MQTT_TOPIC_MESHCORE) { + if (!isIATAValid()) { + if (reason_buf) snprintf(reason_buf, reason_size, "set mqtt.iata "); + return false; + } + } + } else { + // Custom slot without a topic template uses meshcore format, needs IATA + if (_prefs->mqtt_slot_topic[index][0] == '\0' && !isIATAValid()) { + if (reason_buf) snprintf(reason_buf, reason_size, "set mqtt.iata or set mqtt%d.topic