From 98fe78a379ba8033e290eaaeef42287d42f7f544 Mon Sep 17 00:00:00 2001 From: Jared Dohrman Date: Thu, 9 Apr 2026 15:00:53 +1000 Subject: [PATCH 1/5] feat: add MQTT repeater support, web config panel, and EastMesh-specific firmware workflows --- .../setup-build-environment/action.yml | 25 +- ...yml => build-companion-wifi-firmwares.yml} | 10 +- .../workflows/build-repeater-firmwares.yml | 42 - ....yml => build-repeater-mqtt-firmwares.yml} | 10 +- RELEASE.md | 48 +- build.sh | 34 +- examples/simple_repeater/MyMesh.cpp | 229 +++ examples/simple_repeater/MyMesh.h | 12 +- examples/simple_repeater/UITask.cpp | 11 + examples/simple_room_server/MyMesh.cpp | 4 + examples/simple_room_server/MyMesh.h | 1 + examples/simple_sensor/SensorMesh.cpp | 4 + examples/simple_sensor/SensorMesh.h | 1 + pyproject.toml | 4 +- src/helpers/CommonCLI.cpp | 2 + src/helpers/CommonCLI.h | 1 + src/helpers/StatsFormatHelper.h | 20 + src/helpers/mqtt/JWTHelper.cpp | 105 ++ src/helpers/mqtt/JWTHelper.h | 18 + src/helpers/mqtt/MQTTCaCerts.h | 112 ++ src/helpers/mqtt/MQTTPrefs.cpp | 82 + src/helpers/mqtt/MQTTPrefs.h | 40 + src/helpers/mqtt/MQTTUplink.cpp | 1556 +++++++++++++++++ src/helpers/mqtt/MQTTUplink.h | 176 ++ src/helpers/mqtt/WebPanelCert.h | 25 + uv.lock | 2 +- variants/eastmesh_mqtt/platformio.ini | 941 ++++++++++ 27 files changed, 3412 insertions(+), 103 deletions(-) rename .github/workflows/{build-companion-firmwares.yml => build-companion-wifi-firmwares.yml} (74%) delete mode 100644 .github/workflows/build-repeater-firmwares.yml rename .github/workflows/{build-room-server-firmwares.yml => build-repeater-mqtt-firmwares.yml} (78%) create mode 100644 src/helpers/mqtt/JWTHelper.cpp create mode 100644 src/helpers/mqtt/JWTHelper.h create mode 100644 src/helpers/mqtt/MQTTCaCerts.h create mode 100644 src/helpers/mqtt/MQTTPrefs.cpp create mode 100644 src/helpers/mqtt/MQTTPrefs.h create mode 100644 src/helpers/mqtt/MQTTUplink.cpp create mode 100644 src/helpers/mqtt/MQTTUplink.h create mode 100644 src/helpers/mqtt/WebPanelCert.h create mode 100644 variants/eastmesh_mqtt/platformio.ini diff --git a/.github/actions/setup-build-environment/action.yml b/.github/actions/setup-build-environment/action.yml index 04990afa..03790ec7 100644 --- a/.github/actions/setup-build-environment/action.yml +++ b/.github/actions/setup-build-environment/action.yml @@ -26,17 +26,15 @@ runs: uv venv --allow-existing --seed echo "$GITHUB_WORKSPACE/.venv/bin" >> "$GITHUB_PATH" - # Recommended EastMesh tag format: - # room-server-eastmesh-v1.0.1 + # Active release tag formats: + # companion-wifi-v1.2.3 + # repeater-mqtt-eastmesh-v1.0.1 # with OFFICIAL_MESHCORE_VERSION=v1.2.3 configured as a GitHub variable. # # This exports: - # FIRMWARE_VERSION=v1.2.3 - # EASTMESH_VERSION=v1.0.1 - # RELEASE_VERSION=v1.2.3-eastmesh-v1.0.1 - # - # Legacy tags like "room-server-v1.2.3" and - # "room-server-v1.2.3-eastmesh-v1.0.1" remain supported. + # companion-wifi-v1.2.3 -> FIRMWARE_VERSION=v1.2.3, RELEASE_VERSION=v1.2.3 + # repeater-mqtt-eastmesh-v1.0.1 -> FIRMWARE_VERSION=v1.2.3, EASTMESH_VERSION=v1.0.1, + # RELEASE_VERSION=v1.2.3-eastmesh-v1.0.1 - name: Extract Version Info shell: bash run: | @@ -56,17 +54,14 @@ runs: EASTMESH_VERSION="" FIRMWARE_VERSION="$STATIC_OFFICIAL_VERSION" - if [[ "$GIT_TAG_NAME" =~ ^(companion|repeater|room-server)-eastmesh-(v[[:alnum:]._-]+)$ ]]; then + if [[ "$GIT_TAG_NAME" =~ ^repeater-mqtt-eastmesh-(v[[:alnum:]._-]+)$ ]]; then if [[ -z "$STATIC_OFFICIAL_VERSION" ]]; then echo "OFFICIAL_MESHCORE_VERSION must be set for EastMesh release tags" >&2 exit 1 fi - EASTMESH_VERSION="${BASH_REMATCH[2]}" - elif [[ "$GIT_TAG_NAME" =~ ^(companion|repeater|room-server)-(.+)-eastmesh-(v[[:alnum:]._-]+)$ ]]; then - FIRMWARE_VERSION="${BASH_REMATCH[2]}" - EASTMESH_VERSION="${BASH_REMATCH[3]}" - elif [[ "$GIT_TAG_NAME" =~ ^(companion|repeater|room-server)-(.+)$ ]]; then - FIRMWARE_VERSION="${BASH_REMATCH[2]}" + EASTMESH_VERSION="${BASH_REMATCH[1]}" + elif [[ "$GIT_TAG_NAME" =~ ^companion-wifi-(v[[:alnum:]._-]+)$ ]]; then + FIRMWARE_VERSION="${BASH_REMATCH[1]}" else echo "Unsupported release tag format: $GIT_TAG_NAME" >&2 exit 1 diff --git a/.github/workflows/build-companion-firmwares.yml b/.github/workflows/build-companion-wifi-firmwares.yml similarity index 74% rename from .github/workflows/build-companion-firmwares.yml rename to .github/workflows/build-companion-wifi-firmwares.yml index ef1a1822..293c1a51 100644 --- a/.github/workflows/build-companion-firmwares.yml +++ b/.github/workflows/build-companion-wifi-firmwares.yml @@ -1,4 +1,4 @@ -name: Build Companion Firmwares +name: Build Companion WiFi Firmwares permissions: contents: write @@ -7,7 +7,7 @@ on: workflow_dispatch: push: tags: - - 'companion-*' + - 'companion-wifi-v*' jobs: @@ -24,19 +24,19 @@ jobs: uses: ./.github/actions/setup-build-environment - name: Build Firmwares - run: /usr/bin/env bash build.sh build-companion-firmwares + run: /usr/bin/env bash build.sh build-companion-wifi-firmwares - name: Upload Workflow Artifacts uses: actions/upload-artifact@v7 with: - name: companion-firmwares + name: companion-wifi-firmwares path: out - name: Create Release uses: softprops/action-gh-release@v2 if: startsWith(github.ref, 'refs/tags/') with: - name: Companion Firmware ${{ env.RELEASE_VERSION }} + name: Companion WiFi Firmware ${{ env.RELEASE_VERSION }} body: "" draft: true files: out/* diff --git a/.github/workflows/build-repeater-firmwares.yml b/.github/workflows/build-repeater-firmwares.yml deleted file mode 100644 index 97a9434e..00000000 --- a/.github/workflows/build-repeater-firmwares.yml +++ /dev/null @@ -1,42 +0,0 @@ -name: Build Repeater Firmwares - -permissions: - contents: write - -on: - workflow_dispatch: - push: - tags: - - 'repeater-*' - -jobs: - - build: - runs-on: ubuntu-latest - env: - OFFICIAL_MESHCORE_VERSION: ${{ vars.OFFICIAL_MESHCORE_VERSION }} - steps: - - - name: Clone Repo - uses: actions/checkout@v6 - - - name: Setup Build Environment - uses: ./.github/actions/setup-build-environment - - - name: Build Firmwares - run: /usr/bin/env bash build.sh build-repeater-firmwares - - - name: Upload Workflow Artifacts - uses: actions/upload-artifact@v7 - with: - name: repeater-firmwares - path: out - - - name: Create Release - uses: softprops/action-gh-release@v2 - if: startsWith(github.ref, 'refs/tags/') - with: - name: Repeater Firmware ${{ env.RELEASE_VERSION }} - body: "" - draft: true - files: out/* diff --git a/.github/workflows/build-room-server-firmwares.yml b/.github/workflows/build-repeater-mqtt-firmwares.yml similarity index 78% rename from .github/workflows/build-room-server-firmwares.yml rename to .github/workflows/build-repeater-mqtt-firmwares.yml index 2f132a12..02ab9f92 100644 --- a/.github/workflows/build-room-server-firmwares.yml +++ b/.github/workflows/build-repeater-mqtt-firmwares.yml @@ -1,4 +1,4 @@ -name: Build Room Server Firmwares +name: Build Repeater MQTT Firmwares permissions: contents: write @@ -7,7 +7,7 @@ on: workflow_dispatch: push: tags: - - 'room-server-*' + - 'repeater-mqtt-eastmesh-*' jobs: @@ -24,19 +24,19 @@ jobs: uses: ./.github/actions/setup-build-environment - name: Build Firmwares - run: /usr/bin/env bash build.sh build-room-server-firmwares + run: /usr/bin/env bash build.sh build-repeater-mqtt-firmwares - name: Upload Workflow Artifacts uses: actions/upload-artifact@v7 with: - name: room-server-firmwares + name: repeater-mqtt-firmwares path: out - name: Create Release uses: softprops/action-gh-release@v2 if: startsWith(github.ref, 'refs/tags/') with: - name: Room Server Firmware ${{ env.RELEASE_VERSION }} + name: Repeater MQTT Firmware ${{ env.RELEASE_VERSION }} body: "" draft: true files: out/* diff --git a/RELEASE.md b/RELEASE.md index 2f793c38..1b37d0ad 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -16,17 +16,16 @@ This value becomes the base `FIRMWARE_VERSION` used by the release workflows. Push one or more of the following tag formats to trigger the matching firmware release workflow: -- `companion-eastmesh-v1.0.1` -- `repeater-eastmesh-v1.0.1` -- `room-server-eastmesh-v1.0.1` +- `companion-wifi-v1.2.3` +- `repeater-mqtt-eastmesh-v1.0.1` -Replace `v1.0.1` with the EastMesh release version you want to publish. +Use the upstream MeshCore version in `companion-wifi-v1.2.3`. +Use the EastMesh release version in `repeater-mqtt-eastmesh-v1.0.1`. Each tag triggers a separate workflow: -- `companion-eastmesh-*` builds companion firmware -- `repeater-eastmesh-*` builds repeater firmware -- `room-server-eastmesh-*` builds room server firmware +- `companion-wifi-v*` builds companion WiFi firmware +- `repeater-mqtt-eastmesh-*` builds repeater MQTT firmware You can push one, or more tags on the same commit, and they will all build separately. @@ -34,16 +33,18 @@ You can push one, or more tags on the same commit, and they will all build separ During the GitHub Actions build: -- `FIRMWARE_VERSION` comes from `OFFICIAL_MESHCORE_VERSION` -- `EASTMESH_VERSION` comes from the tag +- `companion-wifi` uses the version in the tag as `FIRMWARE_VERSION` +- `repeater-mqtt` uses `OFFICIAL_MESHCORE_VERSION` as `FIRMWARE_VERSION` +- `repeater-mqtt` uses the EastMesh version from the tag as `EASTMESH_VERSION` -The final firmware/artifact version string becomes: +The resulting version string depends on the workflow: -- `v1.2.3-eastmesh-v1.0.1-` +- `companion-wifi`: `v1.2.3-` +- `repeater-mqtt`: `v1.2.3-eastmesh-v1.0.1-` Example: -- tag: `room-server-eastmesh-v1.0.1` +- tag: `repeater-mqtt-eastmesh-v1.0.1` - repo variable: `OFFICIAL_MESHCORE_VERSION=v1.2.3` - resulting build version: `v1.2.3-eastmesh-v1.0.1-abcdef` @@ -59,23 +60,12 @@ Example: Example: ```bash -git tag companion-eastmesh-v1.0.1 -git tag repeater-eastmesh-v1.0.1 -git tag room-server-eastmesh-v1.0.1 -git push origin companion-eastmesh-v1.0.1 repeater-eastmesh-v1.0.1 room-server-eastmesh-v1.0.1 +git tag companion-wifi-v1.2.3 +git tag repeater-mqtt-eastmesh-v1.0.1 +git push origin companion-wifi-v1.2.3 repeater-mqtt-eastmesh-v1.0.1 ``` -## Legacy Tags +## Supported Tags -The workflows still accept older tag formats for compatibility: - -- `companion-v1.2.3` -- `repeater-v1.2.3` -- `room-server-v1.2.3` -- `room-server-v1.2.3-eastmesh-v1.0.1` - -However, the recommended EastMesh release format is: - -- `companion-eastmesh-v1.0.1` -- `repeater-eastmesh-v1.0.1` -- `room-server-eastmesh-v1.0.1` +- `companion-wifi-v1.2.3` +- `repeater-mqtt-eastmesh-v1.0.1` diff --git a/build.sh b/build.sh index aa0bcbe1..1a46bcfa 100755 --- a/build.sh +++ b/build.sh @@ -32,7 +32,9 @@ Commands: build-firmwares: Build all firmwares for all targets. build-matching-firmwares : Build all firmwares for build targets containing the string given for . build-companion-firmwares: Build all companion firmwares for all build targets. + build-companion-wifi-firmwares: Build all companion WiFi firmwares for all build targets. build-repeater-firmwares: Build all repeater firmwares for all build targets. + build-repeater-mqtt-firmwares: Build all repeater MQTT firmwares for all build targets. build-room-server-firmwares: Build all chat room server firmwares for all build targets. Examples: @@ -45,9 +47,15 @@ $ sh build.sh build-matching-firmwares Build all companion firmwares $ sh build.sh build-companion-firmwares +Build all companion WiFi firmwares +$ sh build.sh build-companion-wifi-firmwares + Build all repeater firmwares $ sh build.sh build-repeater-firmwares +Build all repeater MQTT firmwares +$ sh build.sh build-repeater-mqtt-firmwares + Build all chat room server firmwares $ sh build.sh build-room-server-firmwares @@ -155,20 +163,24 @@ build_firmware() { exit 1 fi - # set firmware version string + # set artifact version string # e.g: v1.2.3-eastmesh-v1.0.1-abcdef FIRMWARE_VERSION_BASE="${FIRMWARE_VERSION}" if [ -n "$EASTMESH_VERSION" ]; then FIRMWARE_VERSION_BASE="${FIRMWARE_VERSION_BASE}-eastmesh-${EASTMESH_VERSION}" fi FIRMWARE_VERSION_STRING="${FIRMWARE_VERSION_BASE}-${COMMIT_HASH}" + CLIENT_VERSION_STRING="eastmesh-${COMMIT_HASH}" + if [ -n "$EASTMESH_VERSION" ]; then + CLIENT_VERSION_STRING="eastmesh-${EASTMESH_VERSION}-${COMMIT_HASH}" + fi # craft filename # e.g: RAK_4631_Repeater-v1.0.0-SHA FIRMWARE_FILENAME="$1-${FIRMWARE_VERSION_STRING}" - # add firmware version info to end of existing platformio build flags in environment vars - export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DFIRMWARE_BUILD_DATE='\"${FIRMWARE_BUILD_DATE}\"' -DFIRMWARE_VERSION='\"${FIRMWARE_VERSION_STRING}\"'" + # add build metadata to end of existing platformio build flags in environment vars + export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DFIRMWARE_BUILD_DATE='\"${FIRMWARE_BUILD_DATE}\"' -DFIRMWARE_VERSION='\"${FIRMWARE_VERSION}\"' -DCLIENT_VERSION='\"${CLIENT_VERSION_STRING}\"'" # disable debug flags if requested disable_debug_flags @@ -255,6 +267,18 @@ build_companion_firmwares() { } +build_companion_wifi_firmwares() { + + build_all_firmwares_by_suffix "_companion_radio_wifi" + +} + +build_repeater_mqtt_firmwares() { + + build_all_firmwares_by_suffix "_repeater_mqtt" + +} + build_room_server_firmwares() { # # build specific room server firmwares @@ -298,8 +322,12 @@ elif [[ $1 == "build-firmwares" ]]; then build_firmwares elif [[ $1 == "build-companion-firmwares" ]]; then build_companion_firmwares +elif [[ $1 == "build-companion-wifi-firmwares" ]]; then + build_companion_wifi_firmwares elif [[ $1 == "build-repeater-firmwares" ]]; then build_repeater_firmwares +elif [[ $1 == "build-repeater-mqtt-firmwares" ]]; then + build_repeater_mqtt_firmwares elif [[ $1 == "build-room-server-firmwares" ]]; then build_room_server_firmwares fi diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 24e88949..e75920de 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -461,6 +461,9 @@ void MyMesh::logRx(mesh::Packet *pkt, int len, float score) { bridge.sendPacket(pkt); } #endif +#ifdef WITH_MQTT_UPLINK + mqtt.publishPacket(*pkt, false, (int)_radio->getLastRSSI(), _radio->getLastSNR()); +#endif if (_logging) { File f = openAppend(PACKET_LOG_FILE); @@ -487,6 +490,9 @@ void MyMesh::logTx(mesh::Packet *pkt, int len) { bridge.sendPacket(pkt); } #endif +#ifdef WITH_MQTT_UPLINK + mqtt.publishPacket(*pkt, true, (int)_radio->getLastRSSI(), _radio->getLastSNR()); +#endif if (_logging) { File f = openAppend(PACKET_LOG_FILE); @@ -840,6 +846,9 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc #if defined(WITH_ESPNOW_BRIDGE) , bridge(&_prefs, _mgr, &rtc) #endif +#if defined(WITH_MQTT_UPLINK) + , mqtt(rtc, self_id) +#endif { last_millis = 0; uptime_millis = 0; @@ -915,6 +924,12 @@ void MyMesh::begin(FILESYSTEM *fs) { bridge.begin(); } #endif +#if defined(WITH_MQTT_UPLINK) + board.setInhibitSleep(true); + mqtt.setNodeNameSource(_prefs.node_name); + mqtt.setWebCommandRunner(this); + mqtt.begin(_fs); +#endif radio_set_params(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); radio_set_tx_power(_prefs.tx_power_dbm); @@ -1079,6 +1094,10 @@ void MyMesh::formatPacketStatsReply(char *reply) { getNumRecvFlood(), getNumRecvDirect()); } +void MyMesh::formatMemoryReply(char *reply, size_t reply_size) { + StatsFormatHelper::formatMemoryStats(reply, reply_size); +} + void MyMesh::saveIdentity(const mesh::LocalIdentity &new_id) { #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) IdentityStore store(*_fs, ""); @@ -1285,11 +1304,201 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply sendNodeDiscoverReq(); strcpy(reply, "OK - Discover sent"); } +#ifdef WITH_MQTT_UPLINK + } else if (memcmp(command, "mqtt.owner ", 11) == 0) { + if (mqtt.setOwnerPublicKey(&command[11])) { + strcpy(reply, "OK"); + } else { + strcpy(reply, "Err - owner must be 64 hex chars"); + } + } else if (memcmp(command, "mqtt.email ", 11) == 0) { + if (mqtt.setOwnerEmail(&command[11])) { + strcpy(reply, "OK"); + } else { + strcpy(reply, "Err - bad mqtt.email"); + } + } else if (memcmp(command, "get mqtt.status", 15) == 0) { + mqtt.formatStatusReply(reply, 160); + } else if (strcmp(command, "get web.status") == 0 || strcmp(command, "get web") == 0) { + mqtt.formatWebStatusReply(reply, 160); + } else if (memcmp(command, "get wifi.status", 15) == 0) { + mqtt.formatWifiStatusReply(reply, 160); + } else if (memcmp(command, "get wifi.ssid", 13) == 0) { + sprintf(reply, "> %s", mqtt.getWifiSSID()[0] ? mqtt.getWifiSSID() : "-"); + } else if (memcmp(command, "get wifi.powersaving", 20) == 0) { + sprintf(reply, "> %s", mqtt.getWifiPowerSave()); + } else if (memcmp(command, "get mqtt.iata", 13) == 0) { + sprintf(reply, "> %s", mqtt.getIata()); + } else if (memcmp(command, "get mqtt.owner", 14) == 0) { + sprintf(reply, "> %s", mqtt.getOwnerPublicKey()[0] ? mqtt.getOwnerPublicKey() : "-"); + } else if (memcmp(command, "get mqtt.email", 14) == 0) { + sprintf(reply, "> %s", mqtt.getOwnerEmail()[0] ? mqtt.getOwnerEmail() : "-"); + } else if (memcmp(command, "get mqtt.packets", 16) == 0) { + sprintf(reply, "> %s", mqtt.isPacketsEnabled() ? "on" : "off"); + } else if (memcmp(command, "get mqtt.raw", 12) == 0) { + sprintf(reply, "> %s", mqtt.isRawEnabled() ? "on" : "off"); + } else if (memcmp(command, "get mqtt.tx", 11) == 0) { + sprintf(reply, "> %s", mqtt.isTxEnabled() ? "on" : "off"); + } else if (memcmp(command, "get mqtt.eastmesh-au", 20) == 0 || memcmp(command, "get mqtt.eastmesh.au", 20) == 0) { + sprintf(reply, "> %s", mqtt.isEndpointEnabled(0x01) ? "on" : "off"); + } else if (memcmp(command, "get mqtt.letsmesh-eu", 21) == 0 || memcmp(command, "get mqtt.letsmesh.eu", 21) == 0) { + sprintf(reply, "> %s", mqtt.isEndpointEnabled(0x02) ? "on" : "off"); + } else if (memcmp(command, "get mqtt.letsmesh-us", 21) == 0 || memcmp(command, "get mqtt.letsmesh.us", 21) == 0) { + sprintf(reply, "> %s", mqtt.isEndpointEnabled(0x04) ? "on" : "off"); + } else if (memcmp(command, "set mqtt.tx ", 12) == 0) { + mqtt.setTxEnabled(memcmp(&command[12], "on", 2) == 0); + strcpy(reply, "OK"); + } else if (memcmp(command, "set web ", 8) == 0) { + mqtt.setWebEnabled(memcmp(&command[8], "on", 2) == 0); + strcpy(reply, "OK"); + } else if (memcmp(command, "set.web ", 8) == 0) { + mqtt.setWebEnabled(memcmp(&command[8], "on", 2) == 0); + strcpy(reply, "OK"); + } else if (memcmp(command, "set wifi.ssid ", 14) == 0) { + if (mqtt.setWifiSSID(&command[14])) { + strcpy(reply, "OK"); + } else { + strcpy(reply, "Err - bad wifi.ssid"); + } + } else if (memcmp(command, "set wifi.pwd ", 13) == 0) { + if (mqtt.setWifiPassword(&command[13])) { + strcpy(reply, "OK"); + } else { + strcpy(reply, "Err - bad wifi.pwd"); + } + } else if (memcmp(command, "set wifi.powersaving ", 21) == 0) { + if (mqtt.setWifiPowerSave(&command[21])) { + strcpy(reply, "OK"); + } else { + strcpy(reply, "Err - use none|min|max"); + } + } else if (memcmp(command, "set mqtt.iata ", 14) == 0) { + if (mqtt.setIata(&command[14])) { + strcpy(reply, "OK"); + } else { + strcpy(reply, "Err - bad mqtt.iata"); + } + } else if (memcmp(command, "set mqtt.owner ", 15) == 0) { + if (mqtt.setOwnerPublicKey(&command[15])) { + strcpy(reply, "OK"); + } else { + strcpy(reply, "Err - owner must be 64 hex chars"); + } + } else if (memcmp(command, "set mqtt.email ", 15) == 0) { + if (mqtt.setOwnerEmail(&command[15])) { + strcpy(reply, "OK"); + } else { + strcpy(reply, "Err - bad mqtt.email"); + } + } else if (memcmp(command, "set mqtt.packets ", 17) == 0) { + mqtt.setPacketsEnabled(memcmp(&command[17], "on", 2) == 0); + strcpy(reply, "OK"); + } else if (memcmp(command, "set mqtt.raw ", 13) == 0) { + mqtt.setRawEnabled(memcmp(&command[13], "on", 2) == 0); + strcpy(reply, "OK"); + } else if (memcmp(command, "set mqtt.eastmesh-au ", 21) == 0 || memcmp(command, "set mqtt.eastmesh.au ", 21) == 0) { + mqtt.setEndpointEnabled(0x01, memcmp(&command[21], "on", 2) == 0); + strcpy(reply, "OK"); + } else if (memcmp(command, "set mqtt.letsmesh-eu ", 21) == 0 || memcmp(command, "set mqtt.letsmesh.eu ", 21) == 0) { + mqtt.setEndpointEnabled(0x02, memcmp(&command[21], "on", 2) == 0); + strcpy(reply, "OK"); + } else if (memcmp(command, "set mqtt.letsmesh-us ", 21) == 0 || memcmp(command, "set mqtt.letsmesh.us ", 21) == 0) { + mqtt.setEndpointEnabled(0x04, memcmp(&command[21], "on", 2) == 0); + strcpy(reply, "OK"); +#endif } else{ _cli.handleCommand(sender_timestamp, command, reply); // common CLI commands } } +void MyMesh::runWebCommand(const char* command, char* reply, size_t reply_size) { + if (reply_size == 0) { + return; + } + reply[0] = 0; + if (command == nullptr) { + strncpy(reply, "Err - empty command", reply_size - 1); + reply[reply_size - 1] = 0; + return; + } + + auto matches_exact = [command](const char* candidate) -> bool { + return strcmp(command, candidate) == 0; + }; + auto matches_prefix = [command](const char* candidate) -> bool { + size_t len = strlen(candidate); + return strncmp(command, candidate, len) == 0; + }; + + bool allowed = + matches_exact("get mqtt.status") || + matches_exact("get web.status") || + matches_exact("get web") || + matches_exact("advert") || + matches_exact("reboot") || + matches_exact("start ota") || + matches_exact("get wifi.status") || + matches_exact("get wifi.powersaving") || + matches_exact("memory") || + matches_exact("get mqtt.iata") || + matches_exact("get mqtt.owner") || + matches_exact("get mqtt.email") || + matches_exact("get mqtt.packets") || + matches_exact("get mqtt.raw") || + matches_exact("get mqtt.tx") || + matches_exact("get mqtt.eastmesh-au") || + matches_exact("get mqtt.eastmesh.au") || + matches_exact("get mqtt.letsmesh-eu") || + matches_exact("get mqtt.letsmesh.eu") || + matches_exact("get mqtt.letsmesh-us") || + matches_exact("get mqtt.letsmesh.us") || + matches_exact("get name") || + matches_exact("get lat") || + matches_exact("get lon") || + matches_exact("get advert.interval") || + matches_exact("get flood.advert.interval") || + matches_exact("get flood.max") || + matches_exact("get owner.info") || + matches_exact("get guest.password") || + matches_prefix("set wifi.ssid ") || + matches_prefix("set wifi.pwd ") || + matches_prefix("set wifi.powersaving ") || + matches_prefix("set mqtt.iata ") || + matches_prefix("set mqtt.owner ") || + matches_prefix("set mqtt.email ") || + matches_prefix("set mqtt.packets ") || + matches_prefix("set mqtt.raw ") || + matches_prefix("set mqtt.tx ") || + matches_prefix("set web ") || + matches_prefix("set.web ") || + matches_prefix("set mqtt.eastmesh-au ") || + matches_prefix("set mqtt.eastmesh.au ") || + matches_prefix("set mqtt.letsmesh-eu ") || + matches_prefix("set mqtt.letsmesh.eu ") || + matches_prefix("set mqtt.letsmesh-us ") || + matches_prefix("set mqtt.letsmesh.us ") || + matches_prefix("set name ") || + matches_prefix("set lat ") || + matches_prefix("set lon ") || + matches_prefix("set guest.password ") || + matches_prefix("set prv.key ") || + matches_prefix("set advert.interval ") || + matches_prefix("set flood.advert.interval ") || + matches_prefix("set flood.max ") || + matches_prefix("set owner.info "); + + if (!allowed) { + strncpy(reply, "Err - command not allowlisted for web access", reply_size - 1); + reply[reply_size - 1] = 0; + return; + } + + char command_buf[192]; + StrHelper::strncpy(command_buf, command, sizeof(command_buf)); + handleCommand(0, command_buf, reply); + reply[reply_size - 1] = 0; +} + void MyMesh::loop() { #ifdef WITH_BRIDGE bridge.loop(); @@ -1329,6 +1538,23 @@ void MyMesh::loop() { dirty_contacts_expiry = 0; } +#ifdef WITH_MQTT_UPLINK + MQTTStatusSnapshot mqtt_status{}; + mqtt_status.battery_mv = static_cast(board.getBattMilliVolts()); + mqtt_status.uptime_secs = static_cast(uptime_millis / 1000); + mqtt_status.error_flags = _err_flags; + mqtt_status.queue_len = static_cast(_mgr->getOutboundTotal()); + mqtt_status.noise_floor = static_cast(_radio->getNoiseFloor()); + mqtt_status.tx_air_secs = static_cast(getTotalAirTime() / 1000); + mqtt_status.rx_air_secs = static_cast(getReceiveAirTime() / 1000); + mqtt_status.recv_errors = radio_driver.getPacketsRecvErrors(); + mqtt_status.radio_freq = _prefs.freq; + mqtt_status.radio_bw = _prefs.bw; + mqtt_status.radio_sf = _prefs.sf; + mqtt_status.radio_cr = _prefs.cr; + mqtt.loop(mqtt_status); +#endif + // update uptime uint32_t now = millis(); uptime_millis += now - last_millis; @@ -1339,6 +1565,9 @@ void MyMesh::loop() { bool MyMesh::hasPendingWork() const { #if defined(WITH_BRIDGE) if (bridge.isRunning()) return true; // bridge needs WiFi radio, can't sleep +#endif +#if defined(WITH_MQTT_UPLINK) + if (mqtt.isActive()) return true; #endif return _mgr->getOutboundTotal() > 0; } diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 88729ea7..a1131934 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -23,6 +23,10 @@ #define WITH_BRIDGE #endif +#ifdef WITH_MQTT_UPLINK +#include +#endif + #include #include #include @@ -80,7 +84,7 @@ struct NeighbourInfo { #define PACKET_LOG_FILE "/packet_log" -class MyMesh : public mesh::Mesh, public CommonCLICallbacks { +class MyMesh : public mesh::Mesh, public CommonCLICallbacks, public MQTTWebCommandRunner { FILESYSTEM* _fs; uint32_t last_millis; uint64_t uptime_millis; @@ -117,6 +121,9 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { #elif defined(WITH_ESPNOW_BRIDGE) ESPNowBridge bridge; #endif +#ifdef WITH_MQTT_UPLINK + MQTTUplink mqtt; +#endif void putNeighbour(const mesh::Identity& id, uint32_t timestamp, float snr); uint8_t handleLoginReq(const mesh::Identity& sender, const uint8_t* secret, uint32_t sender_timestamp, const uint8_t* data, bool is_flood); @@ -208,12 +215,15 @@ public: void formatStatsReply(char *reply) override; void formatRadioStatsReply(char *reply) override; void formatPacketStatsReply(char *reply) override; + void formatMemoryReply(char *reply, size_t reply_size) override; mesh::LocalIdentity& getSelfId() override { return self_id; } void saveIdentity(const mesh::LocalIdentity& new_id) override; void clearStats() override; void handleCommand(uint32_t sender_timestamp, char* command, char* reply); + void runWebCommand(const char* command, char* reply, size_t reply_size) override; + const char* getWebAdminPassword() const override { return _prefs.password; } void loop(); #if defined(WITH_BRIDGE) diff --git a/examples/simple_repeater/UITask.cpp b/examples/simple_repeater/UITask.cpp index d096d14b..9570a76e 100644 --- a/examples/simple_repeater/UITask.cpp +++ b/examples/simple_repeater/UITask.cpp @@ -1,5 +1,6 @@ #include "UITask.h" #include +#include #include #define AUTO_OFF_MILLIS 20000 // 20 seconds @@ -77,6 +78,16 @@ void UITask::renderCurrScreen() { _display->setCursor(0, 30); sprintf(tmp, "BW: %03.2f CR: %d", _node_prefs->bw, _node_prefs->cr); _display->print(tmp); + + // WiFi IP + _display->setCursor(0, 40); + if (WiFi.status() == WL_CONNECTED) { + IPAddress ip = WiFi.localIP(); + snprintf(tmp, sizeof(tmp), "IP: %u.%u.%u.%u", ip[0], ip[1], ip[2], ip[3]); + } else { + snprintf(tmp, sizeof(tmp), "IP: -"); + } + _display->print(tmp); } } diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 7b943773..04ce49c9 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -763,6 +763,10 @@ void MyMesh::formatPacketStatsReply(char *reply) { getNumRecvFlood(), getNumRecvDirect()); } +void MyMesh::formatMemoryReply(char *reply, size_t reply_size) { + StatsFormatHelper::formatMemoryStats(reply, reply_size); +} + void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply) { while (*command == ' ') command++; // skip leading spaces diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index e888bfa5..5e85cce5 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -196,6 +196,7 @@ public: void formatStatsReply(char *reply) override; void formatRadioStatsReply(char *reply) override; void formatPacketStatsReply(char *reply) override; + void formatMemoryReply(char *reply, size_t reply_size) override; mesh::LocalIdentity& getSelfId() override { return self_id; } diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index 57d23a31..aea52dd1 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -833,6 +833,10 @@ void SensorMesh::formatPacketStatsReply(char *reply) { getNumRecvFlood(), getNumRecvDirect()); } +void SensorMesh::formatMemoryReply(char *reply, size_t reply_size) { + StatsFormatHelper::formatMemoryStats(reply, reply_size); +} + float SensorMesh::getTelemValue(uint8_t channel, uint8_t type) { auto buf = telemetry.getBuffer(); uint8_t size = telemetry.getSize(); diff --git a/examples/simple_sensor/SensorMesh.h b/examples/simple_sensor/SensorMesh.h index bb786b2a..4a0812a3 100644 --- a/examples/simple_sensor/SensorMesh.h +++ b/examples/simple_sensor/SensorMesh.h @@ -73,6 +73,7 @@ public: void formatStatsReply(char *reply) override; void formatRadioStatsReply(char *reply) override; void formatPacketStatsReply(char *reply) override; + void formatMemoryReply(char *reply, size_t reply_size) override; mesh::LocalIdentity& getSelfId() override { return self_id; } void saveIdentity(const mesh::LocalIdentity& new_id) override; void clearStats() override { } diff --git a/pyproject.toml b/pyproject.toml index 2583fc79..c9da11d4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] -name = "meshcore-tooling" +name = "meshcore-eastmesh" version = "0.1.0" -description = "Python tooling for MeshCore development workflows" +description = "Python tooling for EastMesh workflows on top of MeshCore" requires-python = ">=3.11" dependencies = [ "platformio>=6.1.18,<7", diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 47a2592b..94eb75eb 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -882,6 +882,8 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch _callbacks->formatRadioStatsReply(reply); } else if (sender_timestamp == 0 && memcmp(command, "stats-core", 10) == 0 && (command[10] == 0 || command[10] == ' ')) { _callbacks->formatStatsReply(reply); + } else if (sender_timestamp == 0 && memcmp(command, "memory", 6) == 0 && (command[6] == 0 || command[6] == ' ')) { + _callbacks->formatMemoryReply(reply, 160); } else { strcpy(reply, "Unknown command"); } diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 3a4332d1..5044610e 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -83,6 +83,7 @@ public: virtual void formatStatsReply(char *reply) = 0; virtual void formatRadioStatsReply(char *reply) = 0; virtual void formatPacketStatsReply(char *reply) = 0; + virtual void formatMemoryReply(char *reply, size_t reply_size) = 0; virtual mesh::LocalIdentity& getSelfId() = 0; virtual void saveIdentity(const mesh::LocalIdentity& new_id) = 0; virtual void clearStats() = 0; diff --git a/src/helpers/StatsFormatHelper.h b/src/helpers/StatsFormatHelper.h index bf619133..4b5e5921 100644 --- a/src/helpers/StatsFormatHelper.h +++ b/src/helpers/StatsFormatHelper.h @@ -1,6 +1,7 @@ #pragma once #include "Mesh.h" +#include class StatsFormatHelper { public: @@ -52,4 +53,23 @@ public: driver.getPacketsRecvErrors() ); } + + static void formatMemoryStats(char* reply, size_t reply_size) { + if (reply == nullptr || reply_size == 0) { + return; + } +#if defined(ESP32) + snprintf(reply, reply_size, + "{\"heap_free\":%u,\"heap_min\":%u,\"heap_max\":%u,\"psram_free\":%u,\"psram_min\":%u,\"psram_max\":%u}", + ESP.getFreeHeap(), + ESP.getMinFreeHeap(), + ESP.getMaxAllocHeap(), + ESP.getFreePsram(), + ESP.getMinFreePsram(), + ESP.getMaxAllocPsram() + ); +#else + snprintf(reply, reply_size, "{\"supported\":false}"); +#endif + } }; diff --git a/src/helpers/mqtt/JWTHelper.cpp b/src/helpers/mqtt/JWTHelper.cpp new file mode 100644 index 00000000..b56bb104 --- /dev/null +++ b/src/helpers/mqtt/JWTHelper.cpp @@ -0,0 +1,105 @@ +#include "JWTHelper.h" + +#ifdef WITH_MQTT_UPLINK + +#include +#include +#include +#include + +size_t JWTHelper::base64UrlEncode(const uint8_t* input, size_t input_len, char* output, size_t output_size) { + if (input == nullptr || output == nullptr || output_size < 2) { + return 0; + } + + size_t encoded_len = 0; + if (mbedtls_base64_encode(reinterpret_cast(output), output_size - 1, &encoded_len, input, + input_len) != 0) { + return 0; + } + + for (size_t i = 0; i < encoded_len; ++i) { + if (output[i] == '+') { + output[i] = '-'; + } else if (output[i] == '/') { + output[i] = '_'; + } + } + + while (encoded_len > 0 && output[encoded_len - 1] == '=') { + encoded_len--; + } + output[encoded_len] = 0; + return encoded_len; +} + +bool JWTHelper::createAuthToken(const mesh::LocalIdentity& identity, const char* audience, time_t issued_at, + time_t expires_at, char* token, size_t token_size, const char* owner, + const char* email) { + if (audience == nullptr || token == nullptr || token_size < 32 || issued_at <= 0 || expires_at <= issued_at) { + return false; + } + + static const char header_json[] = "{\"alg\":\"Ed25519\",\"typ\":\"JWT\"}"; + char header[96]; + size_t header_len = base64UrlEncode(reinterpret_cast(header_json), strlen(header_json), header, + sizeof(header)); + if (header_len == 0) { + return false; + } + + char public_key_hex[65]; + mesh::Utils::toHex(public_key_hex, identity.pub_key, PUB_KEY_SIZE); + for (size_t i = 0; public_key_hex[i] != 0; ++i) { + public_key_hex[i] = toupper(static_cast(public_key_hex[i])); + } + + char payload_json[384]; + int payload_json_len = snprintf(payload_json, sizeof(payload_json), + "{\"publicKey\":\"%s\",\"aud\":\"%s\",\"iat\":%lu,\"exp\":%lu", + public_key_hex, audience, static_cast(issued_at), + static_cast(expires_at)); + if (payload_json_len <= 0 || static_cast(payload_json_len) >= sizeof(payload_json)) { + return false; + } + if (owner != nullptr && owner[0] != 0) { + payload_json_len += snprintf(payload_json + payload_json_len, sizeof(payload_json) - payload_json_len, + ",\"owner\":\"%s\"", owner); + } + if (email != nullptr && email[0] != 0) { + payload_json_len += snprintf(payload_json + payload_json_len, sizeof(payload_json) - payload_json_len, + ",\"email\":\"%s\"", email); + } + if (payload_json_len <= 0 || static_cast(payload_json_len) + 2 >= sizeof(payload_json)) { + return false; + } + payload_json[payload_json_len++] = '}'; + payload_json[payload_json_len] = 0; + + char payload[384]; + size_t payload_len = base64UrlEncode(reinterpret_cast(payload_json), payload_json_len, payload, + sizeof(payload)); + if (payload_len == 0) { + return false; + } + + char signing_input[512]; + int signing_input_len = snprintf(signing_input, sizeof(signing_input), "%s.%s", header, payload); + if (signing_input_len <= 0 || static_cast(signing_input_len) >= sizeof(signing_input)) { + return false; + } + + uint8_t signature[64]; + identity.sign(signature, reinterpret_cast(signing_input), signing_input_len); + + char signature_hex[129]; + for (size_t i = 0; i < sizeof(signature); ++i) { + snprintf(&signature_hex[i * 2], 3, "%02X", signature[i]); + } + signature_hex[128] = 0; + + int total_len = snprintf(token, token_size, "%s.%s", signing_input, signature_hex); + return total_len > 0 && static_cast(total_len) < token_size; +} + +#endif diff --git a/src/helpers/mqtt/JWTHelper.h b/src/helpers/mqtt/JWTHelper.h new file mode 100644 index 00000000..cecfe1de --- /dev/null +++ b/src/helpers/mqtt/JWTHelper.h @@ -0,0 +1,18 @@ +#pragma once + +#ifdef WITH_MQTT_UPLINK + +#include +#include + +class JWTHelper { +public: + static bool createAuthToken(const mesh::LocalIdentity& identity, const char* audience, time_t issued_at, + time_t expires_at, char* token, size_t token_size, const char* owner = nullptr, + const char* email = nullptr); + +private: + static size_t base64UrlEncode(const uint8_t* input, size_t input_len, char* output, size_t output_size); +}; + +#endif diff --git a/src/helpers/mqtt/MQTTCaCerts.h b/src/helpers/mqtt/MQTTCaCerts.h new file mode 100644 index 00000000..336d6996 --- /dev/null +++ b/src/helpers/mqtt/MQTTCaCerts.h @@ -0,0 +1,112 @@ +#pragma once + +namespace mqtt_ca_certs { + +// EastMesh currently presents a Let's Encrypt E7-issued certificate. +static const char kEastmeshIsrgRootX1Pem[] = +R"PEM(-----BEGIN CERTIFICATE----- +MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw +TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh +cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4 +WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu +ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY +MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc +h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+ +0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U +A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW +T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH +B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC +B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv +KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn +OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn +jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw +qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI +rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV +HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq +hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL +ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ +3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK +NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5 +ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur +TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC +jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc +oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq +4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA +mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d +emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc= +-----END CERTIFICATE----- +)PEM"; + +// LetsMesh currently presents a certificate issued by Google Trust Services WE1. +static const char kLetsmeshWe1Pem[] = +R"PEM(-----BEGIN CERTIFICATE----- +MIICnzCCAiWgAwIBAgIQf/MZd5csIkp2FV0TttaF4zAKBggqhkjOPQQDAzBHMQsw +CQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEU +MBIGA1UEAxMLR1RTIFJvb3QgUjQwHhcNMjMxMjEzMDkwMDAwWhcNMjkwMjIwMTQw +MDAwWjA7MQswCQYDVQQGEwJVUzEeMBwGA1UEChMVR29vZ2xlIFRydXN0IFNlcnZp +Y2VzMQwwCgYDVQQDEwNXRTEwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARvzTr+ +Z1dHTCEDhUDCR127WEcPQMFcF4XGGTfn1XzthkubgdnXGhOlCgP4mMTG6J7/EFmP +LCaY9eYmJbsPAvpWo4H+MIH7MA4GA1UdDwEB/wQEAwIBhjAdBgNVHSUEFjAUBggr +BgEFBQcDAQYIKwYBBQUHAwIwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQU +kHeSNWfE/6jMqeZ72YB5e8yT+TgwHwYDVR0jBBgwFoAUgEzW63T/STaj1dj8tT7F +avCUHYwwNAYIKwYBBQUHAQEEKDAmMCQGCCsGAQUFBzAChhhodHRwOi8vaS5wa2ku +Z29vZy9yNC5jcnQwKwYDVR0fBCQwIjAgoB6gHIYaaHR0cDovL2MucGtpLmdvb2cv +ci9yNC5jcmwwEwYDVR0gBAwwCjAIBgZngQwBAgEwCgYIKoZIzj0EAwMDaAAwZQIx +AOcCq1HW90OVznX+0RGU1cxAQXomvtgM8zItPZCuFQ8jSBJSjz5keROv9aYsAm5V +sQIwJonMaAFi54mrfhfoFNZEfuNMSQ6/bIBiNLiyoX46FohQvKeIoJ99cx7sUkFN +7uJW +-----END CERTIFICATE----- +)PEM"; + +static const char kCombinedPem[] = +R"PEM(-----BEGIN CERTIFICATE----- +MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw +TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh +cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4 +WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu +ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY +MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc +h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+ +0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U +A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW +T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH +B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC +B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv +KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn +OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn +jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw +qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI +rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV +HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq +hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL +ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ +3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK +NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5 +ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur +TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC +jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc +oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq +4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA +mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d +emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIICnzCCAiWgAwIBAgIQf/MZd5csIkp2FV0TttaF4zAKBggqhkjOPQQDAzBHMQsw +CQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEU +MBIGA1UEAxMLR1RTIFJvb3QgUjQwHhcNMjMxMjEzMDkwMDAwWhcNMjkwMjIwMTQw +MDAwWjA7MQswCQYDVQQGEwJVUzEeMBwGA1UEChMVR29vZ2xlIFRydXN0IFNlcnZp +Y2VzMQwwCgYDVQQDEwNXRTEwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARvzTr+ +Z1dHTCEDhUDCR127WEcPQMFcF4XGGTfn1XzthkubgdnXGhOlCgP4mMTG6J7/EFmP +LCaY9eYmJbsPAvpWo4H+MIH7MA4GA1UdDwEB/wQEAwIBhjAdBgNVHSUEFjAUBggr +BgEFBQcDAQYIKwYBBQUHAwIwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQU +kHeSNWfE/6jMqeZ72YB5e8yT+TgwHwYDVR0jBBgwFoAUgEzW63T/STaj1dj8tT7F +avCUHYwwNAYIKwYBBQUHAQEEKDAmMCQGCCsGAQUFBzAChhhodHRwOi8vaS5wa2ku +Z29vZy9yNC5jcnQwKwYDVR0fBCQwIjAgoB6gHIYaaHR0cDovL2MucGtpLmdvb2cv +ci9yNC5jcmwwEwYDVR0gBAwwCjAIBgZngQwBAgEwCgYIKoZIzj0EAwMDaAAwZQIx +AOcCq1HW90OVznX+0RGU1cxAQXomvtgM8zItPZCuFQ8jSBJSjz5keROv9aYsAm5V +sQIwJonMaAFi54mrfhfoFNZEfuNMSQ6/bIBiNLiyoX46FohQvKeIoJ99cx7sUkFN +7uJW +-----END CERTIFICATE----- +)PEM"; + +} // namespace mqtt_ca_certs diff --git a/src/helpers/mqtt/MQTTPrefs.cpp b/src/helpers/mqtt/MQTTPrefs.cpp new file mode 100644 index 00000000..0104166d --- /dev/null +++ b/src/helpers/mqtt/MQTTPrefs.cpp @@ -0,0 +1,82 @@ +#include "MQTTPrefs.h" + +#ifdef WITH_MQTT_UPLINK + +#include +#include + +namespace { +constexpr uint32_t kFixedStatusIntervalMs = 300000; +} + +void MQTTPrefsStore::setDefaults(MQTTPrefs& prefs) { + memset(&prefs, 0, sizeof(prefs)); + prefs.magic = kMagic; + prefs.enabled_mask = 0x01; + prefs.packets_enabled = 1; + prefs.raw_enabled = 0; + prefs.status_enabled = 1; + prefs.tx_enabled = 0; + prefs.web_enabled = 1; + prefs.wifi_powersave = 0; + prefs.status_interval_ms = kFixedStatusIntervalMs; + StrHelper::strncpy(prefs.iata, MQTT_DEFAULT_IATA, sizeof(prefs.iata)); +#ifdef WIFI_SSID + StrHelper::strncpy(prefs.wifi_ssid, WIFI_SSID, sizeof(prefs.wifi_ssid)); +#endif +#ifdef WIFI_PWD + StrHelper::strncpy(prefs.wifi_pwd, WIFI_PWD, sizeof(prefs.wifi_pwd)); +#endif +} + +bool MQTTPrefsStore::load(FILESYSTEM* fs, MQTTPrefs& prefs) { + setDefaults(prefs); + if (fs == nullptr || !fs->exists(kFilename)) { + return false; + } + +#if defined(RP2040_PLATFORM) + File file = fs->open(kFilename, "r"); +#else + File file = fs->open(kFilename); +#endif + if (!file) { + return false; + } + + size_t bytes_to_read = min(static_cast(file.size()), sizeof(prefs)); + bool ok = bytes_to_read >= sizeof(prefs.magic) && + file.read(reinterpret_cast(&prefs), bytes_to_read) == bytes_to_read; + file.close(); + + if (!ok || prefs.magic != kMagic) { + return false; + } + if (prefs.wifi_powersave > 2) { + prefs.wifi_powersave = 0; + } + prefs.web_enabled = prefs.web_enabled ? 1 : 0; + prefs.status_interval_ms = kFixedStatusIntervalMs; + prefs.enabled_mask &= 0x07; + return true; +} + +bool MQTTPrefsStore::save(FILESYSTEM* fs, const MQTTPrefs& prefs) { + if (fs == nullptr) { + return false; + } + fs->remove(kFilename); +#if defined(RP2040_PLATFORM) + File file = fs->open(kFilename, "w"); +#else + File file = fs->open(kFilename, "w", true); +#endif + if (!file) { + return false; + } + bool ok = file.write(reinterpret_cast(&prefs), sizeof(prefs)) == sizeof(prefs); + file.close(); + return ok; +} + +#endif diff --git a/src/helpers/mqtt/MQTTPrefs.h b/src/helpers/mqtt/MQTTPrefs.h new file mode 100644 index 00000000..a8308733 --- /dev/null +++ b/src/helpers/mqtt/MQTTPrefs.h @@ -0,0 +1,40 @@ +#pragma once + +#ifdef WITH_MQTT_UPLINK + +#include +#include + +#ifndef MQTT_DEFAULT_IATA + #define MQTT_DEFAULT_IATA "MEL" +#endif + +struct MQTTPrefs { + uint32_t magic; + uint8_t enabled_mask; + uint8_t packets_enabled; + uint8_t raw_enabled; + uint8_t status_enabled; + uint8_t tx_enabled; + uint8_t web_enabled; + uint8_t wifi_powersave; + uint32_t status_interval_ms; + char iata[8]; + char wifi_ssid[33]; + char wifi_pwd[65]; + char owner_public_key[65]; + char owner_email[96]; +}; + +class MQTTPrefsStore { +public: + static void setDefaults(MQTTPrefs& prefs); + static bool load(FILESYSTEM* fs, MQTTPrefs& prefs); + static bool save(FILESYSTEM* fs, const MQTTPrefs& prefs); + +private: + static constexpr uint32_t kMagic = 0x4D515454; + static constexpr const char* kFilename = "/mqtt_prefs"; +}; + +#endif diff --git a/src/helpers/mqtt/MQTTUplink.cpp b/src/helpers/mqtt/MQTTUplink.cpp new file mode 100644 index 00000000..9ef874cc --- /dev/null +++ b/src/helpers/mqtt/MQTTUplink.cpp @@ -0,0 +1,1556 @@ +#include "MQTTUplink.h" +#include "MQTTCaCerts.h" +#include "WebPanelCert.h" + +#ifdef WITH_MQTT_UPLINK + +#if defined(ESP_PLATFORM) + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef FIRMWARE_VERSION + #define FIRMWARE_VERSION "v1.14.1" +#endif + +#ifndef FIRMWARE_BUILD_DATE + #define FIRMWARE_BUILD_DATE "20 Mar 2026" +#endif + +#ifndef CLIENT_VERSION + #define CLIENT_VERSION "eastmesh-repeater-mqtt" +#endif + +#ifndef MQTT_DEBUG + #define MQTT_DEBUG 0 +#endif + +#if MQTT_DEBUG + #define LOG_CAT(tag, fmt, ...) Serial.printf("[" tag "] " fmt "\n", ##__VA_ARGS__) + #define WIFI_LOG(fmt, ...) LOG_CAT("WIFI", fmt, ##__VA_ARGS__) + #define WEB_LOG(fmt, ...) LOG_CAT("WEB", fmt, ##__VA_ARGS__) + #define TIME_LOG(fmt, ...) LOG_CAT("TIME", fmt, ##__VA_ARGS__) + #define MQTT_LOG(fmt, ...) LOG_CAT("MQTT", fmt, ##__VA_ARGS__) +#else + #define LOG_CAT(...) do { } while (0) + #define WIFI_LOG(...) do { } while (0) + #define WEB_LOG(...) do { } while (0) + #define TIME_LOG(...) do { } while (0) + #define MQTT_LOG(...) do { } while (0) +#endif + +namespace { +constexpr unsigned long kWifiStartupDelayMillis = 750; +constexpr unsigned long kWifiRetryMillis = 15000; +constexpr unsigned long kWifiConnectTimeoutMillis = 45000; +constexpr unsigned long kBrokerRetryMillis = 10000; +constexpr time_t kTokenLifetimeSecs = 3600; +constexpr time_t kTokenRefreshSlackSecs = 300; +constexpr time_t kMinSaneEpoch = 1735689600; // 2025-01-01T00:00:00Z +constexpr size_t kWebServerStackSize = 8192; +constexpr size_t kWebPasswordBufferSize = 80; +constexpr size_t kWebCommandBufferSize = 192; +constexpr size_t kWebReplyBufferSize = 256; + +char* allocScratchBuffer(size_t size) { + void* ptr = heap_caps_malloc(size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (ptr == nullptr) { + ptr = heap_caps_malloc(size, MALLOC_CAP_8BIT); + } + return static_cast(ptr); +} + +void freeScratchBuffer(void* ptr) { + if (ptr != nullptr) { + heap_caps_free(ptr); + } +} + +const char kWebPanelHtml[] PROGMEM = R"HTML( + + + + + + Repeater Config + + + +
+ +
+

Repeater Config

+

Use the repeater admin password to unlock the command console. Accept the self-signed certificate warning in your browser first.

+
+ + +
+
+
+ +
+

Run CLI Command

+
+ + +
+

Only the allowlisted commands exposed by this panel will run here.

+
+
+ + + + + + +
+ + + +)HTML"; + +const char* getWifiStateLabel(const MQTTPrefs& prefs, bool wifi_started) { + if (prefs.wifi_ssid[0] == 0) { + return "off"; + } + wl_status_t status = WiFi.status(); + if (status == WL_CONNECTED) { + return "up"; + } + if (wifi_started) { + return "conn"; + } + return "down"; +} + +} + +const MQTTUplink::BrokerSpec MQTTUplink::kBrokerSpecs[3] = { + {"eastmesh-au", "eastmesh-au", "mqtt2.eastmesh.au", "wss://mqtt2.eastmesh.au:443/mqtt", kEastmeshBit}, + {"letsmesh-eu", "letsmesh-eu", "mqtt-eu-v1.letsmesh.net", "wss://mqtt-eu-v1.letsmesh.net:443/mqtt", + kLetsmeshEuBit}, + {"letsmesh-us", "letsmesh-us", "mqtt-us-v1.letsmesh.net", "wss://mqtt-us-v1.letsmesh.net:443/mqtt", + kLetsmeshUsBit}, +}; + +MQTTUplink::MQTTUplink(mesh::RTCClock& rtc, mesh::LocalIdentity& identity) + : _fs(nullptr), _rtc(&rtc), _identity(&identity), _running(false), _wifi_started(false), _sntp_started(false), + _have_time_sync(false), _wifi_sta_started_at(0), _last_wifi_attempt(0), _last_status_publish(0), _last_status{}, + _node_name(nullptr), + _web_runner(nullptr), _web_server(nullptr) { + memset(_device_id, 0, sizeof(_device_id)); + memset(_web_token, 0, sizeof(_web_token)); + _web_route_context.self = this; + MQTTPrefsStore::setDefaults(_prefs); + for (size_t i = 0; i < 3; ++i) { + memset(&_brokers[i], 0, sizeof(_brokers[i])); + _brokers[i].spec = &kBrokerSpecs[i]; + } + MQTT_LOG("uplink init"); +} + +bool MQTTUplink::savePrefs() { + return MQTTPrefsStore::save(_fs, _prefs); +} + +bool MQTTUplink::hasEnabledBroker() const { + return (_prefs.enabled_mask & 0x07) != 0; +} + +bool MQTTUplink::isActive() const { + return _running && hasEnabledBroker(); +} + +void MQTTUplink::reconnectWifi() { + WIFI_LOG("reset"); + stopWebServer(); + for (BrokerState& broker : _brokers) { + destroyBroker(broker); + } + if (_wifi_started) { + WiFi.disconnect(true, true); + WiFi.mode(WIFI_OFF); + } + _wifi_started = false; + _sntp_started = false; + _have_time_sync = false; + _wifi_sta_started_at = 0; + _last_wifi_attempt = 0; +} + +void MQTTUplink::makeSafeToken(const char* input, char* output, size_t output_size) { + if (output_size == 0) { + return; + } + size_t oi = 0; + for (size_t i = 0; input != nullptr && input[i] != 0 && oi + 1 < output_size; ++i) { + char c = input[i]; + if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '_') { + output[oi++] = c; + } else { + output[oi++] = '_'; + } + } + output[oi] = 0; +} + +void MQTTUplink::bytesToHexUpper(const uint8_t* src, size_t len, char* dst, size_t dst_size) { + if (dst_size == 0) { + return; + } + size_t di = 0; + for (size_t i = 0; i < len && di + 2 < dst_size; ++i) { + snprintf(&dst[di], dst_size - di, "%02X", src[i]); + di += 2; + } + dst[min(di, dst_size - 1)] = 0; +} + +void MQTTUplink::formatIsoTimestamp(time_t ts, char* dst, size_t dst_size) { + if (dst_size == 0) { + return; + } + struct tm tm_local; + localtime_r(&ts, &tm_local); + strftime(dst, dst_size, "%Y-%m-%dT%H:%M:%S", &tm_local); + size_t len = strlen(dst); + if (len + 8 < dst_size) { + memcpy(&dst[len], ".000000", 8); + } +} + +void MQTTUplink::escapeJsonString(const char* input, char* output, size_t output_size) { + if (output_size == 0) { + return; + } + + size_t oi = 0; + for (size_t i = 0; input != nullptr && input[i] != 0 && oi + 1 < output_size; ++i) { + char c = input[i]; + const char* escape = nullptr; + switch (c) { + case '\"': + escape = "\\\""; + break; + case '\\': + escape = "\\\\"; + break; + case '\n': + escape = "\\n"; + break; + case '\r': + escape = "\\r"; + break; + case '\t': + escape = "\\t"; + break; + default: + break; + } + + if (escape != nullptr) { + for (size_t j = 0; escape[j] != 0 && oi + 1 < output_size; ++j) { + output[oi++] = escape[j]; + } + continue; + } + + unsigned char uc = static_cast(c); + if (uc < 0x20) { + output[oi++] = '_'; + continue; + } + output[oi++] = c; + } + output[oi] = 0; +} + +wifi_ps_type_t MQTTUplink::toEspPowerSave(uint8_t mode) { + switch (mode) { + case 1: + return WIFI_PS_MIN_MODEM; + case 2: + return WIFI_PS_MAX_MODEM; + default: + return WIFI_PS_NONE; + } +} + +const char* MQTTUplink::getPowerSaveLabel(uint8_t mode) { + switch (mode) { + case 1: + return "min"; + case 2: + return "max"; + default: + return "none"; + } +} + +void MQTTUplink::refreshIdentityStrings() { + bytesToHexUpper(_identity->pub_key, PUB_KEY_SIZE, _device_id, sizeof(_device_id)); + for (size_t i = 0; i < 3; ++i) { + BrokerState& broker = _brokers[i]; + snprintf(broker.username, sizeof(broker.username), "v1_%s", _device_id); + snprintf(broker.client_id, sizeof(broker.client_id), "mqtt_%s-%.6s", broker.spec->key, _device_id); + snprintf(broker.status_topic, sizeof(broker.status_topic), "meshcore/%s/%s/status", _prefs.iata, _device_id); + snprintf(broker.packets_topic, sizeof(broker.packets_topic), "meshcore/%s/%s/packets", _prefs.iata, _device_id); + snprintf(broker.raw_topic, sizeof(broker.raw_topic), "meshcore/%s/%s/raw", _prefs.iata, _device_id); + } +} + +void MQTTUplink::refreshBrokerState(BrokerState& broker) { + char safe_name[40]; + makeSafeToken(board.getManufacturerName(), safe_name, sizeof(safe_name)); + char origin[80]; + const char* node_name = (_node_name != nullptr && _node_name[0] != 0) ? _node_name : _device_id; + escapeJsonString(node_name, origin, sizeof(origin)); + char client_version[96]; + snprintf(client_version, sizeof(client_version), "%s", CLIENT_VERSION); + char radio_info[48]; + snprintf(radio_info, sizeof(radio_info), "%.6f,%.1f,%u,%u", static_cast(_last_status.radio_freq), + static_cast(_last_status.radio_bw), _last_status.radio_sf, _last_status.radio_cr); + + char ts[32]; + formatIsoTimestamp(time(nullptr), ts, sizeof(ts)); + snprintf(broker.offline_payload, sizeof(broker.offline_payload), + "{\"status\":\"offline\",\"timestamp\":\"%s\",\"origin\":\"%s\",\"origin_id\":\"%s\",\"model\":\"%s\"," + "\"firmware_version\":\"%s\",\"radio\":\"%s\",\"client_version\":\"%s\"}", + ts, origin, _device_id, safe_name, FIRMWARE_VERSION, radio_info, client_version); +} + +bool MQTTUplink::refreshToken(BrokerState& broker) { + time_t now = time(nullptr); + if (now < kMinSaneEpoch) { + TIME_LOG("%s token skipped: clock not ready (%lu)", broker.spec->label, static_cast(now)); + return false; + } + + time_t expires_at = now + kTokenLifetimeSecs; + const char* owner = _prefs.owner_public_key[0] ? _prefs.owner_public_key : nullptr; + const char* email = _prefs.owner_email[0] ? _prefs.owner_email : nullptr; + if (!JWTHelper::createAuthToken(*_identity, broker.spec->host, now, expires_at, broker.token, sizeof(broker.token), + owner, email)) { + MQTT_LOG("%s token creation failed", broker.spec->label); + return false; + } + broker.token_expires_at = expires_at; + MQTT_LOG("%s token ready exp=%lu owner=%s email=%s", broker.spec->label, + static_cast(expires_at), owner != nullptr ? "yes" : "no", email != nullptr ? "yes" : "no"); + return true; +} + +void MQTTUplink::destroyBroker(BrokerState& broker) { + if (broker.client != nullptr) { + MQTT_LOG("%s destroy broker client", broker.spec->label); + esp_mqtt_client_stop(broker.client); + esp_mqtt_client_destroy(broker.client); + broker.client = nullptr; + } + broker.connected = false; +} + +void MQTTUplink::queuePublish(BrokerState& broker, const char* topic, const char* payload, bool retain) { + if (broker.client == nullptr || !broker.connected) { + return; + } + MQTT_LOG("%s publish topic=%s retain=%d bytes=%u", broker.spec->label, topic, retain ? 1 : 0, + static_cast(strlen(payload))); + esp_mqtt_client_enqueue(broker.client, topic, payload, 0, 1, retain, true); +} + +int MQTTUplink::buildStatusJson(char* buffer, size_t buffer_size, bool online) const { + char ts[32]; + formatIsoTimestamp(time(nullptr), ts, sizeof(ts)); + char model[48]; + makeSafeToken(board.getManufacturerName(), model, sizeof(model)); + char origin[80]; + const char* node_name = (_node_name != nullptr && _node_name[0] != 0) ? _node_name : _device_id; + escapeJsonString(node_name, origin, sizeof(origin)); + char client_version[96]; + snprintf(client_version, sizeof(client_version), "%s", CLIENT_VERSION); + char radio_info[48]; + snprintf(radio_info, sizeof(radio_info), "%.6f,%.1f,%u,%u", static_cast(_last_status.radio_freq), + static_cast(_last_status.radio_bw), _last_status.radio_sf, _last_status.radio_cr); + return snprintf(buffer, buffer_size, + "{\"status\":\"%s\",\"timestamp\":\"%s\",\"origin\":\"%s\",\"origin_id\":\"%s\"," + "\"model\":\"%s\",\"firmware_version\":\"%s\",\"radio\":\"%s\",\"client_version\":\"%s\"," + "\"stats\":{\"battery_mv\":%d,\"uptime_secs\":%lu,\"errors\":%u,\"queue_len\":%u," + "\"noise_floor\":%d,\"tx_air_secs\":%lu,\"rx_air_secs\":%lu,\"recv_errors\":%lu}}", + online ? "online" : "offline", ts, origin, _device_id, model, FIRMWARE_VERSION, radio_info, client_version, + _last_status.battery_mv, static_cast(_last_status.uptime_secs), _last_status.error_flags, + _last_status.queue_len, _last_status.noise_floor, static_cast(_last_status.tx_air_secs), + static_cast(_last_status.rx_air_secs), + static_cast(_last_status.recv_errors)); +} + +int MQTTUplink::buildPacketJson(char* buffer, size_t buffer_size, const mesh::Packet& packet, bool is_tx, int rssi, + float snr) const { + uint8_t raw[256]; + int raw_len = packet.writeTo(raw); + char* raw_hex = allocScratchBuffer(520); + if (raw_hex == nullptr) { + return -1; + } + bytesToHexUpper(raw, raw_len, raw_hex, 520); + + uint8_t packet_hash[MAX_HASH_SIZE]; + packet.calculatePacketHash(packet_hash); + char hash_hex[(MAX_HASH_SIZE * 2) + 1]; + bytesToHexUpper(packet_hash, MAX_HASH_SIZE, hash_hex, sizeof(hash_hex)); + + time_t now = time(nullptr); + char ts[32]; + formatIsoTimestamp(now, ts, sizeof(ts)); + struct tm tm_utc; + gmtime_r(&now, &tm_utc); + char time_only[16]; + char date_only[16]; + strftime(time_only, sizeof(time_only), "%H:%M:%S", &tm_utc); + strftime(date_only, sizeof(date_only), "%d/%m/%Y", &tm_utc); + char origin[80]; + const char* node_name = (_node_name != nullptr && _node_name[0] != 0) ? _node_name : _device_id; + escapeJsonString(node_name, origin, sizeof(origin)); + if (packet.isRouteDirect() && packet.path_len > 0) { + char path_info[128]; + snprintf(path_info, sizeof(path_info), "path_%dx%d_%db", (int)packet.getPathHashCount(), + (int)packet.getPathHashSize(), (int)packet.getPathByteLen()); + int len = snprintf(buffer, buffer_size, + "{\"origin\":\"%s\",\"origin_id\":\"%s\",\"timestamp\":\"%s\",\"type\":\"PACKET\"," + "\"direction\":\"%s\",\"time\":\"%s\",\"date\":\"%s\",\"len\":\"%d\",\"packet_type\":\"%u\"," + "\"route\":\"D\",\"payload_len\":\"%u\",\"raw\":\"%s\",\"SNR\":\"%.1f\",\"RSSI\":\"%d\"," + "\"hash\":\"%s\",\"path\":\"%s\"}", + origin, _device_id, ts, is_tx ? "tx" : "rx", time_only, date_only, raw_len, packet.getPayloadType(), + packet.payload_len, raw_hex, snr, rssi, hash_hex, path_info); + freeScratchBuffer(raw_hex); + return len; + } + + int len = snprintf(buffer, buffer_size, + "{\"origin\":\"%s\",\"origin_id\":\"%s\",\"timestamp\":\"%s\",\"type\":\"PACKET\"," + "\"direction\":\"%s\",\"time\":\"%s\",\"date\":\"%s\",\"len\":\"%d\",\"packet_type\":\"%u\"," + "\"route\":\"F\",\"payload_len\":\"%u\",\"raw\":\"%s\",\"SNR\":\"%.1f\",\"RSSI\":\"%d\"," + "\"hash\":\"%s\"}", + origin, _device_id, ts, is_tx ? "tx" : "rx", time_only, date_only, raw_len, packet.getPayloadType(), + packet.payload_len, raw_hex, snr, rssi, hash_hex); + freeScratchBuffer(raw_hex); + return len; +} + +int MQTTUplink::buildRawJson(char* buffer, size_t buffer_size, const mesh::Packet& packet, bool is_tx, int rssi, + float snr) const { + (void)is_tx; + (void)rssi; + (void)snr; + uint8_t raw[256]; + int raw_len = packet.writeTo(raw); + char* raw_hex = allocScratchBuffer(520); + if (raw_hex == nullptr) { + return -1; + } + bytesToHexUpper(raw, raw_len, raw_hex, 520); + char ts[32]; + formatIsoTimestamp(time(nullptr), ts, sizeof(ts)); + char origin[80]; + const char* node_name = (_node_name != nullptr && _node_name[0] != 0) ? _node_name : _device_id; + escapeJsonString(node_name, origin, sizeof(origin)); + + int len = snprintf(buffer, buffer_size, + "{\"origin\":\"%s\",\"origin_id\":\"%s\",\"timestamp\":\"%s\",\"type\":\"RAW\",\"data\":\"%s\"}", + origin, _device_id, ts, raw_hex); + freeScratchBuffer(raw_hex); + return len; +} + +void MQTTUplink::publishOnlineStatus(BrokerState& broker) { + char* payload = allocScratchBuffer(768); + if (payload == nullptr) { + return; + } + int len = buildStatusJson(payload, 768, true); + if (len > 0 && static_cast(len) < 768) { + queuePublish(broker, broker.status_topic, payload, true); + } + freeScratchBuffer(payload); +} + +void MQTTUplink::publishStatus(bool online) { + char* payload = allocScratchBuffer(768); + if (payload == nullptr) { + return; + } + int len = buildStatusJson(payload, 768, online); + if (len <= 0 || static_cast(len) >= 768) { + freeScratchBuffer(payload); + return; + } + for (BrokerState& broker : _brokers) { + if ((_prefs.enabled_mask & broker.spec->bit) != 0) { + queuePublish(broker, broker.status_topic, payload, true); + } + } + freeScratchBuffer(payload); +} + +void MQTTUplink::handleMqttEvent(void* handler_args, esp_event_base_t, int32_t event_id, void* event_data) { + auto* broker = static_cast(handler_args); + if (broker == nullptr) { + return; + } + auto* event = static_cast(event_data); + + switch (event_id) { + case MQTT_EVENT_CONNECTED: + broker->connected = true; + MQTT_LOG("%s connected", broker->spec->label); + break; + case MQTT_EVENT_DISCONNECTED: + MQTT_LOG("%s disconnected", broker->spec->label); + case MQTT_EVENT_ERROR: + broker->connected = false; + if (event_id == MQTT_EVENT_ERROR) { + if (event != nullptr && event->error_handle != nullptr) { + MQTT_LOG("%s error type=%d tls_esp=0x%x tls_stack=0x%x cert_flags=0x%x sock_errno=%d conn_refused=%d", + broker->spec->label, event->error_handle->error_type, event->error_handle->esp_tls_last_esp_err, + event->error_handle->esp_tls_stack_err, event->error_handle->esp_tls_cert_verify_flags, + event->error_handle->esp_transport_sock_errno, event->error_handle->connect_return_code); + } else { + MQTT_LOG("%s error event", broker->spec->label); + } + } + break; + case MQTT_EVENT_BEFORE_CONNECT: + MQTT_LOG("%s before connect", broker->spec->label); + break; + default: + break; + } +} + +esp_err_t MQTTUplink::handleWebIndex(httpd_req_t* req) { + auto* ctx = static_cast(req->user_ctx); + if (ctx == nullptr || ctx->self == nullptr) { + return httpd_resp_send_500(req); + } + httpd_resp_set_type(req, "text/html; charset=utf-8"); + httpd_resp_set_hdr(req, "Cache-Control", "no-store"); + return httpd_resp_send(req, kWebPanelHtml, HTTPD_RESP_USE_STRLEN); +} + +esp_err_t MQTTUplink::handleWebLogin(httpd_req_t* req) { + auto* ctx = static_cast(req->user_ctx); + if (ctx == nullptr || ctx->self == nullptr || ctx->self->_web_runner == nullptr) { + return httpd_resp_send_500(req); + } + + char* password = allocScratchBuffer(kWebPasswordBufferSize); + if (password == nullptr) { + return httpd_resp_send_500(req); + } + + if (!ctx->self->readRequestBody(req, password, kWebPasswordBufferSize)) { + freeScratchBuffer(password); + return httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Bad request"); + } + + if (strcmp(password, ctx->self->_web_runner->getWebAdminPassword()) != 0) { + freeScratchBuffer(password); + WEB_LOG("login denied"); + return httpd_resp_send_err(req, HTTPD_401_UNAUTHORIZED, "Bad password"); + } + + freeScratchBuffer(password); + ctx->self->refreshWebToken(); + WEB_LOG("login accepted"); + httpd_resp_set_type(req, "text/plain; charset=utf-8"); + httpd_resp_set_hdr(req, "Cache-Control", "no-store"); + return httpd_resp_sendstr(req, ctx->self->_web_token); +} + +esp_err_t MQTTUplink::handleWebCommand(httpd_req_t* req) { + auto* ctx = static_cast(req->user_ctx); + if (ctx == nullptr || ctx->self == nullptr || ctx->self->_web_runner == nullptr) { + return httpd_resp_send_500(req); + } + if (!ctx->self->isWebAuthorized(req)) { + return httpd_resp_send_err(req, HTTPD_401_UNAUTHORIZED, "Unauthorized"); + } + + char* command = allocScratchBuffer(kWebCommandBufferSize); + char* reply = allocScratchBuffer(kWebReplyBufferSize); + if (command == nullptr || reply == nullptr) { + freeScratchBuffer(command); + freeScratchBuffer(reply); + return httpd_resp_send_500(req); + } + + if (!ctx->self->readRequestBody(req, command, kWebCommandBufferSize)) { + freeScratchBuffer(command); + freeScratchBuffer(reply); + return httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Bad request"); + } + + memset(reply, 0, kWebReplyBufferSize); + ctx->self->_web_runner->runWebCommand(command, reply, kWebReplyBufferSize); + httpd_resp_set_type(req, "text/plain; charset=utf-8"); + httpd_resp_set_hdr(req, "Cache-Control", "no-store"); + esp_err_t rc = httpd_resp_send(req, reply[0] ? reply : "OK", HTTPD_RESP_USE_STRLEN); + freeScratchBuffer(command); + freeScratchBuffer(reply); + return rc; +} + +bool MQTTUplink::readRequestBody(httpd_req_t* req, char* buffer, size_t buffer_size) const { + if (req == nullptr || buffer == nullptr || buffer_size == 0 || req->content_len <= 0 || + req->content_len >= static_cast(buffer_size)) { + return false; + } + + int remaining = req->content_len; + int offset = 0; + while (remaining > 0) { + int read = httpd_req_recv(req, &buffer[offset], remaining); + if (read <= 0) { + return false; + } + offset += read; + remaining -= read; + } + buffer[offset] = 0; + return true; +} + +void MQTTUplink::refreshWebToken() { + uint8_t token[16]; + esp_fill_random(token, sizeof(token)); + bytesToHexUpper(token, sizeof(token), _web_token, sizeof(_web_token)); +} + +bool MQTTUplink::isWebAuthorized(httpd_req_t* req) const { + if (_web_token[0] == 0) { + return false; + } + char token[40]; + if (httpd_req_get_hdr_value_str(req, "X-Auth-Token", token, sizeof(token)) != ESP_OK) { + return false; + } + return strcmp(token, _web_token) == 0; +} + +bool MQTTUplink::startWebServer() { + if (_web_server != nullptr || _web_runner == nullptr || _prefs.web_enabled == 0) { + return _web_server != nullptr; + } + + httpd_ssl_config_t config = HTTPD_SSL_CONFIG_DEFAULT(); + config.httpd.max_open_sockets = 2; + config.httpd.max_uri_handlers = 3; + config.httpd.max_resp_headers = 4; + config.httpd.backlog_conn = 2; + config.httpd.recv_wait_timeout = 2; + config.httpd.send_wait_timeout = 2; + config.httpd.stack_size = kWebServerStackSize; + config.cacert_pem = reinterpret_cast(mqtt_web_panel_cert::kServerCertPem); + config.cacert_len = sizeof(mqtt_web_panel_cert::kServerCertPem); + config.prvtkey_pem = reinterpret_cast(mqtt_web_panel_cert::kServerKeyPem); + config.prvtkey_len = sizeof(mqtt_web_panel_cert::kServerKeyPem); + + esp_err_t rc = httpd_ssl_start(&_web_server, &config); + if (rc != ESP_OK) { + _web_server = nullptr; + WEB_LOG("server start failed rc=0x%x", static_cast(rc)); + return false; + } + + httpd_uri_t index_uri = {.uri = "/", .method = HTTP_GET, .handler = &MQTTUplink::handleWebIndex, .user_ctx = &_web_route_context}; + httpd_uri_t login_uri = {.uri = "/login", .method = HTTP_POST, .handler = &MQTTUplink::handleWebLogin, .user_ctx = &_web_route_context}; + httpd_uri_t command_uri = {.uri = "/api/command", .method = HTTP_POST, .handler = &MQTTUplink::handleWebCommand, .user_ctx = &_web_route_context}; + httpd_register_uri_handler(_web_server, &index_uri); + httpd_register_uri_handler(_web_server, &login_uri); + httpd_register_uri_handler(_web_server, &command_uri); + WEB_LOG("server started on https://%s/", WiFi.localIP().toString().c_str()); + return true; +} + +void MQTTUplink::stopWebServer() { + if (_web_server != nullptr) { + WEB_LOG("server stopped"); + httpd_ssl_stop(_web_server); + _web_server = nullptr; + } + _web_token[0] = 0; +} + +void MQTTUplink::ensureWebServer() { + if (_web_runner == nullptr || _prefs.web_enabled == 0 || !_wifi_started || WiFi.status() != WL_CONNECTED) { + stopWebServer(); + return; + } + startWebServer(); +} + +void MQTTUplink::ensureWifi() { + if (_prefs.wifi_ssid[0] == 0) { + WIFI_LOG("disabled: no ssid"); + stopWebServer(); + reconnectWifi(); + return; + } + + if (!hasEnabledBroker() && _web_runner == nullptr) { + WIFI_LOG("disabled: no mqtt endpoints and no web runner"); + stopWebServer(); + if (_wifi_started) { + WiFi.disconnect(true, true); + WiFi.mode(WIFI_OFF); + _wifi_started = false; + _sntp_started = false; + _have_time_sync = false; + _wifi_sta_started_at = 0; + } + return; + } + + if (WiFi.status() == WL_CONNECTED) { + return; + } + + unsigned long now_ms = millis(); + wl_status_t status = WiFi.status(); + if (_wifi_started) { + if (_last_wifi_attempt == 0) { + if (now_ms - _wifi_sta_started_at < kWifiStartupDelayMillis) { + return; + } + } else if (status == WL_IDLE_STATUS && now_ms - _last_wifi_attempt < kWifiConnectTimeoutMillis) { + return; + return; + } + if (now_ms - _last_wifi_attempt < kWifiRetryMillis) { + return; + } + } + + if (!_wifi_started) { + WiFi.mode(WIFI_STA); + WiFi.setSleep(toEspPowerSave(_prefs.wifi_powersave)); + _wifi_started = true; + _wifi_sta_started_at = now_ms; + _last_wifi_attempt = 0; + WIFI_LOG("sta start powersaving=%s settle_ms=%lu", getPowerSaveLabel(_prefs.wifi_powersave), + static_cast(kWifiStartupDelayMillis)); + return; + } else { + WIFI_LOG("retry status=%d", static_cast(status)); + } + _last_wifi_attempt = now_ms; + WIFI_LOG("begin ssid=%s", _prefs.wifi_ssid); + WiFi.begin(_prefs.wifi_ssid, _prefs.wifi_pwd); +} + +void MQTTUplink::updateTimeSync() { + bool prev_have_time_sync = _have_time_sync; + if (!_wifi_started || WiFi.status() != WL_CONNECTED) { + _have_time_sync = false; + if (prev_have_time_sync != _have_time_sync) { + TIME_LOG("sntp lost"); + } + return; + } + + if (!_sntp_started) { + configTzTime("UTC0", "au.pool.ntp.org", "time.google.com", "time.cloudflare.com"); + _sntp_started = true; + TIME_LOG("sntp start servers=au.pool.ntp.org,time.google.com,time.cloudflare.com"); + } + + sntp_sync_status_t sync_status = sntp_get_sync_status(); + time_t now = time(nullptr); + bool sane_time = now >= kMinSaneEpoch; + bool sync_ready = sync_status == SNTP_SYNC_STATUS_COMPLETED || sync_status == SNTP_SYNC_STATUS_IN_PROGRESS; + _have_time_sync = sane_time && (sync_ready || prev_have_time_sync); + if (prev_have_time_sync != _have_time_sync) { + TIME_LOG("sntp %s epoch=%lu status=%ld", _have_time_sync ? "ready" : "waiting", + static_cast(now), static_cast(sync_status)); + } +} + +void MQTTUplink::ensureBroker(BrokerState& broker) { + bool enabled = (_prefs.enabled_mask & broker.spec->bit) != 0; + if (!enabled) { + destroyBroker(broker); + return; + } + + if (!_have_time_sync || WiFi.status() != WL_CONNECTED) { + return; + } + + time_t now = time(nullptr); + if (broker.client != nullptr && broker.token_expires_at > 0 && now + kTokenRefreshSlackSecs >= broker.token_expires_at) { + destroyBroker(broker); + } + + if (broker.client != nullptr) { + return; + } + + unsigned long now_ms = millis(); + if (now_ms - broker.last_connect_attempt < kBrokerRetryMillis) { + return; + } + broker.last_connect_attempt = now_ms; + + if (!refreshToken(broker)) { + return; + } + + refreshBrokerState(broker); + MQTT_LOG("%s mqtt init host=%s port=%d path=%s client_id=%s heap_free=%u heap_max=%u", broker.spec->label, + broker.spec->host, 443, "/mqtt", broker.client_id, ESP.getFreeHeap(), ESP.getMaxAllocHeap()); + esp_mqtt_client_config_t cfg = {}; +#if ESP_IDF_VERSION_MAJOR >= 5 + cfg.broker.address.hostname = broker.spec->host; + cfg.broker.address.port = 443; + cfg.broker.address.transport = MQTT_TRANSPORT_OVER_WSS; + cfg.broker.address.path = "/mqtt"; + cfg.broker.verification.certificate = mqtt_ca_certs::kCombinedPem; + cfg.credentials.username = broker.username; + cfg.credentials.client_id = broker.client_id; + cfg.credentials.authentication.password = broker.token; + cfg.session.keepalive = 30; + cfg.session.last_will.topic = broker.status_topic; + cfg.session.last_will.msg = broker.offline_payload; + cfg.session.last_will.qos = 1; + cfg.session.last_will.retain = 1; + cfg.network.reconnect_timeout_ms = 10000; + cfg.network.timeout_ms = 10000; + cfg.network.disable_auto_reconnect = false; + cfg.buffer.size = 1024; + cfg.buffer.out_size = 1024; +#else + cfg.host = broker.spec->host; + cfg.port = 443; + cfg.username = broker.username; + cfg.password = broker.token; + cfg.client_id = broker.client_id; + cfg.keepalive = 30; + cfg.buffer_size = 1024; + cfg.out_buffer_size = 1024; + cfg.reconnect_timeout_ms = 10000; + cfg.network_timeout_ms = 10000; + cfg.disable_auto_reconnect = false; + cfg.transport = MQTT_TRANSPORT_OVER_WSS; + cfg.cert_pem = mqtt_ca_certs::kCombinedPem; + cfg.lwt_topic = broker.status_topic; + cfg.lwt_msg = broker.offline_payload; + cfg.lwt_qos = 1; + cfg.lwt_retain = 1; + cfg.path = "/mqtt"; +#endif + + broker.client = esp_mqtt_client_init(&cfg); + if (broker.client == nullptr) { + MQTT_LOG("%s mqtt init failed", broker.spec->label); + return; + } + + esp_mqtt_client_register_event(broker.client, MQTT_EVENT_ANY, &MQTTUplink::handleMqttEvent, &broker); + if (esp_mqtt_client_start(broker.client) != ESP_OK) { + MQTT_LOG("%s mqtt start failed", broker.spec->label); + destroyBroker(broker); + } else { + MQTT_LOG("%s mqtt start requested", broker.spec->label); + } +} + +void MQTTUplink::begin(FILESYSTEM* fs) { + _fs = fs; + MQTTPrefsStore::load(_fs, _prefs); + refreshIdentityStrings(); + refreshWebToken(); + _running = true; + _last_status_publish = millis(); + MQTT_LOG("begin iata=%s enabled_mask=0x%02X wifi_ssid=%s", _prefs.iata, _prefs.enabled_mask, _prefs.wifi_ssid); +} + +void MQTTUplink::end() { + MQTT_LOG("end"); + publishStatus(false); + stopWebServer(); + for (BrokerState& broker : _brokers) { + destroyBroker(broker); + } + if (_wifi_started) { + WiFi.disconnect(true, true); + WiFi.mode(WIFI_OFF); + } + _wifi_started = false; + _sntp_started = false; + _have_time_sync = false; + _wifi_sta_started_at = 0; + _last_wifi_attempt = 0; + _running = false; +} + +void MQTTUplink::loop(const MQTTStatusSnapshot& snapshot) { + if (!_running) { + return; + } + + _last_status = snapshot; + ensureWifi(); + updateTimeSync(); + ensureWebServer(); + + for (BrokerState& broker : _brokers) { + bool was_connected = broker.connected; + ensureBroker(broker); + if (!was_connected && broker.connected) { + publishOnlineStatus(broker); + } + } + + if (_prefs.status_enabled && hasEnabledBroker() && _have_time_sync && + millis() - _last_status_publish >= _prefs.status_interval_ms) { + publishStatus(true); + _last_status_publish = millis(); + } +} + +void MQTTUplink::publishPacket(const mesh::Packet& packet, bool is_tx, int rssi, float snr) { + if (!_running || !_have_time_sync || !hasEnabledBroker() || !_prefs.packets_enabled) { + return; + } + if (is_tx && !_prefs.tx_enabled) { + return; + } + MQTT_LOG("packet dir=%s type=%u payload_len=%u rssi=%d snr=%.1f", is_tx ? "tx" : "rx", packet.getPayloadType(), + packet.payload_len, rssi, snr); + + char* payload = allocScratchBuffer(1280); + if (payload == nullptr) { + return; + } + int len = buildPacketJson(payload, 1280, packet, is_tx, rssi, snr); + if (len <= 0 || static_cast(len) >= 1280) { + freeScratchBuffer(payload); + return; + } + + for (BrokerState& broker : _brokers) { + if ((_prefs.enabled_mask & broker.spec->bit) != 0) { + queuePublish(broker, broker.packets_topic, payload, false); + } + } + freeScratchBuffer(payload); + + if (!_prefs.raw_enabled) { + return; + } + + char* raw_payload = allocScratchBuffer(896); + if (raw_payload == nullptr) { + return; + } + len = buildRawJson(raw_payload, 896, packet, is_tx, rssi, snr); + if (len <= 0 || static_cast(len) >= 896) { + freeScratchBuffer(raw_payload); + return; + } + + for (BrokerState& broker : _brokers) { + if ((_prefs.enabled_mask & broker.spec->bit) != 0) { + queuePublish(broker, broker.raw_topic, raw_payload, false); + } + } + freeScratchBuffer(raw_payload); +} + +void MQTTUplink::formatStatusReply(char* reply, size_t reply_size) const { + auto broker_state = [this](const BrokerState& broker) -> const char* { + if ((_prefs.enabled_mask & broker.spec->bit) == 0) { + return "off"; + } + if (broker.connected) { + return "up"; + } + if (WiFi.status() != WL_CONNECTED || !_have_time_sync) { + return "wait"; + } + if (broker.client != nullptr) { + return "conn"; + } + return "retry"; + }; + + snprintf(reply, reply_size, "> wifi:%s ntp:%s iata:%s eastmesh-au:%s letsmesh-eu:%s letsmesh-us:%s tx:%s", + getWifiStateLabel(_prefs, _wifi_started), _have_time_sync ? "up" : "wait", _prefs.iata, + broker_state(_brokers[0]), broker_state(_brokers[1]), broker_state(_brokers[2]), + _prefs.tx_enabled ? "on" : "off"); +} + +void MQTTUplink::formatWebStatusReply(char* reply, size_t reply_size) const { + if (_web_runner == nullptr) { + snprintf(reply, reply_size, "> web:off"); + return; + } + + if (_prefs.web_enabled == 0) { + snprintf(reply, reply_size, "> web:off"); + return; + } + + if (_web_server == nullptr || !_wifi_started || WiFi.status() != WL_CONNECTED) { + snprintf(reply, reply_size, "> web:down"); + return; + } + + snprintf(reply, reply_size, "> web:up url:https://%s/ auth:%s", WiFi.localIP().toString().c_str(), + _web_token[0] ? "unlocked" : "locked"); +} + +bool MQTTUplink::setEndpointEnabled(uint8_t bit, bool enabled) { + if (enabled) { + _prefs.enabled_mask |= bit; + } else { + _prefs.enabled_mask &= ~bit; + } + savePrefs(); + return true; +} + +bool MQTTUplink::isEndpointEnabled(uint8_t bit) const { + return (_prefs.enabled_mask & bit) != 0; +} + +bool MQTTUplink::setPacketsEnabled(bool enabled) { + _prefs.packets_enabled = enabled ? 1 : 0; + return savePrefs(); +} + +bool MQTTUplink::setRawEnabled(bool enabled) { + _prefs.raw_enabled = enabled ? 1 : 0; + return savePrefs(); +} + +bool MQTTUplink::setStatusEnabled(bool enabled) { + _prefs.status_enabled = enabled ? 1 : 0; + return savePrefs(); +} + +bool MQTTUplink::setTxEnabled(bool enabled) { + _prefs.tx_enabled = enabled ? 1 : 0; + return savePrefs(); +} + +bool MQTTUplink::setWebEnabled(bool enabled) { + _prefs.web_enabled = enabled ? 1 : 0; + bool ok = savePrefs(); + if (_prefs.web_enabled == 0) { + stopWebServer(); + } else { + ensureWebServer(); + } + return ok; +} + +bool MQTTUplink::setIata(const char* iata) { + if (iata == nullptr || *iata == 0) { + return false; + } + + char cleaned[sizeof(_prefs.iata)]; + memset(cleaned, 0, sizeof(cleaned)); + makeSafeToken(iata, cleaned, sizeof(cleaned)); + for (size_t i = 0; cleaned[i] != 0; ++i) { + cleaned[i] = toupper(static_cast(cleaned[i])); + } + StrHelper::strncpy(_prefs.iata, cleaned, sizeof(_prefs.iata)); + refreshIdentityStrings(); + return savePrefs(); +} + +bool MQTTUplink::setWifiPowerSave(const char* mode) { + if (mode == nullptr) { + return false; + } + + uint8_t next_mode; + if (strcmp(mode, "none") == 0) { + next_mode = 0; + } else if (strcmp(mode, "min") == 0) { + next_mode = 1; + } else if (strcmp(mode, "max") == 0) { + next_mode = 2; + } else { + return false; + } + + _prefs.wifi_powersave = next_mode; + bool ok = savePrefs(); + if (_wifi_started) { + ok = WiFi.setSleep(toEspPowerSave(_prefs.wifi_powersave)) && ok; + } + return ok; +} + +const char* MQTTUplink::getWifiPowerSave() const { + return getPowerSaveLabel(_prefs.wifi_powersave); +} + +bool MQTTUplink::setWifiSSID(const char* ssid) { + if (ssid == nullptr) { + return false; + } + StrHelper::strncpy(_prefs.wifi_ssid, ssid, sizeof(_prefs.wifi_ssid)); + bool ok = savePrefs(); + reconnectWifi(); + return ok; +} + +bool MQTTUplink::setWifiPassword(const char* pwd) { + if (pwd == nullptr) { + return false; + } + StrHelper::strncpy(_prefs.wifi_pwd, pwd, sizeof(_prefs.wifi_pwd)); + bool ok = savePrefs(); + reconnectWifi(); + return ok; +} + +bool MQTTUplink::setOwnerPublicKey(const char* owner_public_key) { + if (owner_public_key == nullptr) { + return false; + } + + if (owner_public_key[0] == 0) { + _prefs.owner_public_key[0] = 0; + bool ok = savePrefs(); + reconnectWifi(); + return ok; + } + + if (strlen(owner_public_key) != 64) { + return false; + } + + for (size_t i = 0; i < 64; ++i) { + if (!isxdigit(static_cast(owner_public_key[i]))) { + return false; + } + _prefs.owner_public_key[i] = toupper(static_cast(owner_public_key[i])); + } + _prefs.owner_public_key[64] = 0; + bool ok = savePrefs(); + reconnectWifi(); + return ok; +} + +bool MQTTUplink::setOwnerEmail(const char* owner_email) { + if (owner_email == nullptr) { + return false; + } + StrHelper::strncpy(_prefs.owner_email, owner_email, sizeof(_prefs.owner_email)); + bool ok = savePrefs(); + reconnectWifi(); + return ok; +} + +void MQTTUplink::formatWifiStatusReply(char* reply, size_t reply_size) const { + const char* status = "disconnected"; + const char* state = "disconnected"; + wl_status_t wifi_status = WiFi.status(); + if (_prefs.wifi_ssid[0] == 0) { + status = "unconfigured"; + state = "unconfigured"; + } else if (wifi_status == WL_CONNECTED) { + status = "connected"; + state = "connected"; + } else if (_wifi_started) { + status = "connecting"; + } + + switch (wifi_status) { + case WL_IDLE_STATUS: + state = "idle"; + break; + case WL_NO_SSID_AVAIL: + state = "no_ssid"; + break; + case WL_SCAN_COMPLETED: + state = "scan_completed"; + break; + case WL_CONNECTED: + state = "connected"; + break; + case WL_CONNECT_FAILED: + state = "connect_failed"; + break; + case WL_CONNECTION_LOST: + state = "connection_lost"; + break; + case WL_DISCONNECTED: + state = "disconnected"; + break; + default: + state = "unknown"; + break; + } + + if (wifi_status == WL_CONNECTED) { + snprintf(reply, reply_size, "> ssid:%s status:%s code:%d state:%s ip:%s", _prefs.wifi_ssid, status, + static_cast(wifi_status), state, WiFi.localIP().toString().c_str()); + } else { + snprintf(reply, reply_size, "> ssid:%s status:%s code:%d state:%s", _prefs.wifi_ssid[0] ? _prefs.wifi_ssid : "-", + status, static_cast(wifi_status), state); + } +} + +#else + +MQTTUplink::MQTTUplink(mesh::RTCClock&, mesh::LocalIdentity&) + : _fs(nullptr), _rtc(nullptr), _identity(nullptr), _running(false), _wifi_started(false), _sntp_started(false), + _have_time_sync(false), _wifi_sta_started_at(0), _last_wifi_attempt(0), _last_status_publish(0), _last_status{}, + _node_name(nullptr), + _web_runner(nullptr) { +} + +bool MQTTUplink::savePrefs() { return false; } +void MQTTUplink::begin(FILESYSTEM*) {} +void MQTTUplink::end() {} +void MQTTUplink::loop(const MQTTStatusSnapshot&) {} +void MQTTUplink::publishPacket(const mesh::Packet&, bool, int, float) {} +void MQTTUplink::formatStatusReply(char* reply, size_t reply_size) const { snprintf(reply, reply_size, "> unsupported"); } +void MQTTUplink::formatWebStatusReply(char* reply, size_t reply_size) const { snprintf(reply, reply_size, "> unsupported"); } +bool MQTTUplink::setEndpointEnabled(uint8_t, bool) { return false; } +bool MQTTUplink::isEndpointEnabled(uint8_t) const { return false; } +bool MQTTUplink::setPacketsEnabled(bool) { return false; } +bool MQTTUplink::setRawEnabled(bool) { return false; } +bool MQTTUplink::setStatusEnabled(bool) { return false; } +bool MQTTUplink::setTxEnabled(bool) { return false; } +bool MQTTUplink::setWebEnabled(bool) { return false; } +bool MQTTUplink::setIata(const char*) { return false; } +bool MQTTUplink::setWifiPowerSave(const char*) { return false; } +const char* MQTTUplink::getWifiPowerSave() const { return "unsupported"; } +bool MQTTUplink::isActive() const { return false; } +bool MQTTUplink::setWifiSSID(const char*) { return false; } +bool MQTTUplink::setWifiPassword(const char*) { return false; } +bool MQTTUplink::setOwnerPublicKey(const char*) { return false; } +bool MQTTUplink::setOwnerEmail(const char*) { return false; } +void MQTTUplink::formatWifiStatusReply(char* reply, size_t reply_size) const { snprintf(reply, reply_size, "> unsupported"); } +void MQTTUplink::reconnectWifi() {} + +#endif + +#endif diff --git a/src/helpers/mqtt/MQTTUplink.h b/src/helpers/mqtt/MQTTUplink.h new file mode 100644 index 00000000..ee930f1f --- /dev/null +++ b/src/helpers/mqtt/MQTTUplink.h @@ -0,0 +1,176 @@ +#pragma once + +#ifdef WITH_MQTT_UPLINK + +#include +#include +#include + +#include "JWTHelper.h" +#include "MQTTPrefs.h" + +#if defined(ESP_PLATFORM) +#include +#include +#include +#endif + +class MQTTWebCommandRunner { +public: + virtual ~MQTTWebCommandRunner() = default; + virtual void runWebCommand(const char* command, char* reply, size_t reply_size) = 0; + virtual const char* getWebAdminPassword() const = 0; +}; + +struct MQTTStatusSnapshot { + int battery_mv; + uint32_t uptime_secs; + uint16_t error_flags; + uint16_t queue_len; + int noise_floor; + uint32_t tx_air_secs; + uint32_t rx_air_secs; + uint32_t recv_errors; + float radio_freq; + float radio_bw; + uint8_t radio_sf; + uint8_t radio_cr; +}; + +class MQTTUplink { +public: + explicit MQTTUplink(mesh::RTCClock& rtc, mesh::LocalIdentity& identity); + + void begin(FILESYSTEM* fs); + void end(); + void loop(const MQTTStatusSnapshot& snapshot); + void publishPacket(const mesh::Packet& packet, bool is_tx, int rssi, float snr); + + bool isRunning() const { return _running; } + bool isActive() const; + + void formatStatusReply(char* reply, size_t reply_size) const; + void formatWebStatusReply(char* reply, size_t reply_size) const; + + bool setEndpointEnabled(uint8_t bit, bool enabled); + bool isEndpointEnabled(uint8_t bit) const; + bool setPacketsEnabled(bool enabled); + bool isPacketsEnabled() const { return _prefs.packets_enabled != 0; } + bool setRawEnabled(bool enabled); + bool isRawEnabled() const { return _prefs.raw_enabled != 0; } + bool setStatusEnabled(bool enabled); + bool isStatusEnabled() const { return _prefs.status_enabled != 0; } + bool setTxEnabled(bool enabled); + bool isTxEnabled() const { return _prefs.tx_enabled != 0; } + bool setWebEnabled(bool enabled); + bool isWebEnabled() const { return _prefs.web_enabled != 0; } + bool setIata(const char* iata); + const char* getIata() const { return _prefs.iata; } + void setNodeNameSource(const char* node_name) { _node_name = node_name; } + void setWebCommandRunner(MQTTWebCommandRunner* runner) { _web_runner = runner; } + bool setWifiSSID(const char* ssid); + bool setWifiPassword(const char* pwd); + const char* getWifiSSID() const { return _prefs.wifi_ssid; } + bool setWifiPowerSave(const char* mode); + const char* getWifiPowerSave() const; + bool setOwnerPublicKey(const char* owner_public_key); + const char* getOwnerPublicKey() const { return _prefs.owner_public_key; } + bool setOwnerEmail(const char* owner_email); + const char* getOwnerEmail() const { return _prefs.owner_email; } + void formatWifiStatusReply(char* reply, size_t reply_size) const; + void reconnectWifi(); + +private: +#if defined(ESP_PLATFORM) + struct BrokerSpec { + const char* key; + const char* label; + const char* host; + const char* uri; + uint8_t bit; + }; + + struct BrokerState { + const BrokerSpec* spec; + esp_mqtt_client_handle_t client; + bool connected; + unsigned long last_connect_attempt; + time_t token_expires_at; + char username[70]; + char token[512]; + char client_id[48]; + char status_topic[128]; + char packets_topic[128]; + char raw_topic[128]; + char offline_payload[256]; + }; + + struct WebRouteContext { + MQTTUplink* self; + }; +#endif + + FILESYSTEM* _fs; + mesh::RTCClock* _rtc; + mesh::LocalIdentity* _identity; + MQTTPrefs _prefs; + bool _running; + bool _wifi_started; + bool _sntp_started; + bool _have_time_sync; + unsigned long _wifi_sta_started_at; + unsigned long _last_wifi_attempt; + unsigned long _last_status_publish; + MQTTStatusSnapshot _last_status; + char _device_id[65]; + const char* _node_name; + MQTTWebCommandRunner* _web_runner; + +#if defined(ESP_PLATFORM) + static constexpr uint8_t kEastmeshBit = 0x01; + static constexpr uint8_t kLetsmeshEuBit = 0x02; + static constexpr uint8_t kLetsmeshUsBit = 0x04; + static const BrokerSpec kBrokerSpecs[3]; + + BrokerState _brokers[3]; + httpd_handle_t _web_server; + char _web_token[33]; + WebRouteContext _web_route_context; + + static void handleMqttEvent(void* handler_args, esp_event_base_t base, int32_t event_id, void* event_data); + static esp_err_t handleWebIndex(httpd_req_t* req); + static esp_err_t handleWebLogin(httpd_req_t* req); + static esp_err_t handleWebCommand(httpd_req_t* req); + void ensureWebServer(); + void stopWebServer(); + bool startWebServer(); + bool isWebAuthorized(httpd_req_t* req) const; + bool readRequestBody(httpd_req_t* req, char* buffer, size_t buffer_size) const; + void refreshWebToken(); + void ensureWifi(); + void updateTimeSync(); + bool hasEnabledBroker() const; + void refreshIdentityStrings(); + void refreshBrokerState(BrokerState& broker); + void ensureBroker(BrokerState& broker); + void destroyBroker(BrokerState& broker); + bool refreshToken(BrokerState& broker); + void publishStatus(bool online); + void publishOnlineStatus(BrokerState& broker); + void queuePublish(BrokerState& broker, const char* topic, const char* payload, bool retain); + int buildStatusJson(char* buffer, size_t buffer_size, bool online) const; + int buildPacketJson(char* buffer, size_t buffer_size, const mesh::Packet& packet, bool is_tx, int rssi, + float snr) const; + int buildRawJson(char* buffer, size_t buffer_size, const mesh::Packet& packet, bool is_tx, int rssi, float snr) const; + static wifi_ps_type_t toEspPowerSave(uint8_t mode); + static const char* getPowerSaveLabel(uint8_t mode); + static void escapeJsonString(const char* input, char* output, size_t output_size); + static void makeSafeToken(const char* input, char* output, size_t output_size); + static void bytesToHexUpper(const uint8_t* src, size_t len, char* dst, size_t dst_size); + static void formatIsoTimestamp(time_t ts, char* dst, size_t dst_size); +#endif + + bool savePrefs(); +}; + +#endif diff --git a/src/helpers/mqtt/WebPanelCert.h b/src/helpers/mqtt/WebPanelCert.h new file mode 100644 index 00000000..991244e5 --- /dev/null +++ b/src/helpers/mqtt/WebPanelCert.h @@ -0,0 +1,25 @@ +#pragma once + +namespace mqtt_web_panel_cert { + +static const char kServerCertPem[] = + "-----BEGIN CERTIFICATE-----\n" + "MIIBiDCCAS2gAwIBAgIUYPagAbDjmNMHx+sqAC5qvOnlywIwCgYIKoZIzj0EAwIw\n" + "GTEXMBUGA1UEAwwObWVzaGNvcmUtbG9jYWwwHhcNMjYwNDA5MDE0MjAzWhcNMzYw\n" + "NDA2MDE0MjAzWjAZMRcwFQYDVQQDDA5tZXNoY29yZS1sb2NhbDBZMBMGByqGSM49\n" + "AgEGCCqGSM49AwEHA0IABJWLgQ/4sDJKykqR9a5brDLR9RikwoZ/6n1Od38rbfzV\n" + "ykP2LJIdrxU6zS1gzLk33klycB7AWFL1W7klStdiSpmjUzBRMB0GA1UdDgQWBBSk\n" + "dpP2r+UPriHT+hXLiyFdjkFgmTAfBgNVHSMEGDAWgBSkdpP2r+UPriHT+hXLiyFd\n" + "jkFgmTAPBgNVHRMBAf8EBTADAQH/MAoGCCqGSM49BAMCA0kAMEYCIQCWS5ZRbON4\n" + "71RrwcogEzGuQTNdkbtkRc+517nZ63ZOawIhAPwSy/0yMyhqAoOMpVnhIb+w995f\n" + "zH4nApL2Q2U0KI2l\n" + "-----END CERTIFICATE-----\n"; + +static const char kServerKeyPem[] = + "-----BEGIN EC PRIVATE KEY-----\n" + "MHcCAQEEIGX71nLSiA8qUJXuiZRNzaGizNbMqgOxALSPd/HzfzWAoAoGCCqGSM49\n" + "AwEHoUQDQgAElYuBD/iwMkrKSpH1rlusMtH1GKTChn/qfU53fytt/NXKQ/Yskh2v\n" + "FTrNLWDMuTfeSXJwHsBYUvVbuSVK12JKmQ==\n" + "-----END EC PRIVATE KEY-----\n"; + +} diff --git a/uv.lock b/uv.lock index f10d5431..6c54c29b 100644 --- a/uv.lock +++ b/uv.lock @@ -331,7 +331,7 @@ wheels = [ ] [[package]] -name = "meshcore-tooling" +name = "meshcore-eastmesh" version = "0.1.0" source = { virtual = "." } dependencies = [ diff --git a/variants/eastmesh_mqtt/platformio.ini b/variants/eastmesh_mqtt/platformio.ini new file mode 100644 index 00000000..5e689723 --- /dev/null +++ b/variants/eastmesh_mqtt/platformio.ini @@ -0,0 +1,941 @@ +[env:T_Beam_S3_Supreme_SX1262_repeater_mqtt] +extends = T_Beam_S3_Supreme_SX1262 +build_flags = + ${T_Beam_S3_Supreme_SX1262.build_flags} + -D ADVERT_NAME='"T-Beam S3 Supreme SX1262 Repeater"' + -D ADVERT_LAT=0 + -D ADVERT_LON=0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D WITH_MQTT_UPLINK=1 + -D MQTT_DEBUG=1 + -D WIFI_SSID='"myssid"' + -D WIFI_PWD='"mypwd"' + -D MQTT_DEFAULT_IATA='"MEL"' + -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=1 +build_src_filter = ${T_Beam_S3_Supreme_SX1262.build_src_filter} + +<../examples/simple_repeater> + + +lib_deps = + ${T_Beam_S3_Supreme_SX1262.lib_deps} + ${esp32_ota.lib_deps} + +[env:LilyGo_TLora_V2_1_1_6_repeater_mqtt] +extends = LilyGo_TLora_V2_1_1_6 +build_src_filter = ${LilyGo_TLora_V2_1_1_6.build_src_filter} + +<../examples/simple_repeater> + + +build_flags = + ${LilyGo_TLora_V2_1_1_6.build_flags} + -D ADVERT_NAME='"TLora-V2.1-1.6 Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D WITH_MQTT_UPLINK=1 + -D MQTT_DEBUG=1 + -D WIFI_SSID='"myssid"' + -D WIFI_PWD='"mypwd"' + -D MQTT_DEFAULT_IATA='"MEL"' + -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=1 +lib_deps = + ${LilyGo_TLora_V2_1_1_6.lib_deps} + ${esp32_ota.lib_deps} + +[env:LilyGo_TBeam_1W_repeater_mqtt] +extends = LilyGo_TBeam_1W +build_flags = + ${LilyGo_TBeam_1W.build_flags} + -D ADVERT_NAME='"T-Beam 1W Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D PERSISTANT_GPS=1 + -D ENV_SKIP_GPS_DETECT=1 + -D WITH_MQTT_UPLINK=1 + -D MQTT_DEBUG=1 + -D WIFI_SSID='"myssid"' + -D WIFI_PWD='"mypwd"' + -D MQTT_DEFAULT_IATA='"MEL"' + -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=1 +build_src_filter = ${LilyGo_TBeam_1W.build_src_filter} + +<../examples/simple_repeater> + + +lib_deps = + ${LilyGo_TBeam_1W.lib_deps} + ${esp32_ota.lib_deps} + +[env:Xiao_C3_repeater_mqtt] +extends = Xiao_esp32_C3 +build_src_filter = ${Xiao_esp32_C3.build_src_filter} + +<../examples/simple_repeater/*.cpp> + + +build_flags = + ${Xiao_esp32_C3.build_flags} + -D ADVERT_NAME='"Xiao C3 Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D WITH_MQTT_UPLINK=1 + -D MQTT_DEBUG=1 + -D WIFI_SSID='"myssid"' + -D WIFI_PWD='"mypwd"' + -D MQTT_DEFAULT_IATA='"MEL"' + -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=1 +lib_deps = + ${Xiao_esp32_C3.lib_deps} + ${esp32_ota.lib_deps} + bakercp/CRC32 @ ^2.0.0 + +[env:Ebyte_EoRa-S3_Repeater_mqtt] +extends = Ebyte_EoRa-S3 +build_flags = + ${Ebyte_EoRa-S3.build_flags} + -D DISPLAY_CLASS=SSD1306Display + -D ADVERT_NAME='"EORA_S3-1262 Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D WITH_MQTT_UPLINK=1 + -D MQTT_DEBUG=1 + -D WIFI_SSID='"myssid"' + -D WIFI_PWD='"mypwd"' + -D MQTT_DEFAULT_IATA='"MEL"' + -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=1 +build_src_filter = ${Ebyte_EoRa-S3.build_src_filter} + + + +<../examples/simple_repeater> + + +lib_deps = + ${Ebyte_EoRa-S3.lib_deps} + ${esp32_ota.lib_deps} + +[env:RAK_3112_repeater_mqtt] +extends = rak3112 +build_flags = + ${rak3112.build_flags} + -D ADVERT_NAME='"RAK3112 Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D WITH_MQTT_UPLINK=1 + -D MQTT_DEBUG=1 + -D WIFI_SSID='"myssid"' + -D WIFI_PWD='"mypwd"' + -D MQTT_DEFAULT_IATA='"MEL"' + -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=1 +build_src_filter = ${rak3112.build_src_filter} + +<../examples/simple_repeater> + + +lib_deps = + ${rak3112.lib_deps} + ${esp32_ota.lib_deps} + bakercp/CRC32 @ ^2.0.0 + +[env:Tbeam_SX1262_repeater_mqtt] +extends = LilyGo_TBeam_SX1262 +build_flags = + ${LilyGo_TBeam_SX1262.build_flags} + -D ADVERT_NAME='"Tbeam SX1262 Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D WITH_MQTT_UPLINK=1 + -D MQTT_DEBUG=1 + -D WIFI_SSID='"myssid"' + -D WIFI_PWD='"mypwd"' + -D MQTT_DEFAULT_IATA='"MEL"' + -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=1 +build_src_filter = ${LilyGo_TBeam_SX1262.build_src_filter} + +<../examples/simple_repeater> + + +lib_deps = + ${LilyGo_TBeam_SX1262.lib_deps} + ${esp32_ota.lib_deps} + +[env:Heltec_E290_repeater_mqtt] +extends = Heltec_E290_base +build_flags = + ${Heltec_E290_base.build_flags} + -D DISPLAY_CLASS=E290Display + -D ADVERT_NAME='"Heltec E290 Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D WITH_MQTT_UPLINK=1 + -D MQTT_DEBUG=1 + -D WIFI_SSID='"myssid"' + -D WIFI_PWD='"mypwd"' + -D MQTT_DEFAULT_IATA='"MEL"' + -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=1 +build_src_filter = ${Heltec_E290_base.build_src_filter} + + + +<../examples/simple_repeater> + + +lib_deps = + ${Heltec_E290_base.lib_deps} + ${esp32_ota.lib_deps} + bakercp/CRC32 @ ^2.0.0 + +[env:Tbeam_SX1276_repeater_mqtt] +extends = LilyGo_TBeam_SX1276 +build_flags = + ${LilyGo_TBeam_SX1276.build_flags} + -D ADVERT_NAME='"Tbeam Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D PERSISTANT_GPS=1 + -D WITH_MQTT_UPLINK=1 + -D MQTT_DEBUG=1 + -D WIFI_SSID='"myssid"' + -D WIFI_PWD='"mypwd"' + -D MQTT_DEFAULT_IATA='"MEL"' + -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=1 +build_src_filter = ${LilyGo_TBeam_SX1276.build_src_filter} + +<../examples/simple_repeater> + + +lib_deps = + ${LilyGo_TBeam_SX1276.lib_deps} + ${esp32_ota.lib_deps} + +[env:Heltec_E213_repeater_mqtt] +extends = Heltec_E213_base +build_flags = + ${Heltec_E213_base.build_flags} + -D DISPLAY_CLASS=E213Display + -D ADVERT_NAME='"Heltec E213 Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D WITH_MQTT_UPLINK=1 + -D MQTT_DEBUG=1 + -D WIFI_SSID='"myssid"' + -D WIFI_PWD='"mypwd"' + -D MQTT_DEFAULT_IATA='"MEL"' + -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=1 +build_src_filter = ${Heltec_E213_base.build_src_filter} + + + +<../examples/simple_repeater> + + +lib_deps = + ${Heltec_E213_base.lib_deps} + ${esp32_ota.lib_deps} + bakercp/CRC32 @ ^2.0.0 + +[env:Heltec_Wireless_Tracker_repeater_mqtt] +extends = Heltec_tracker_base +build_flags = + ${Heltec_tracker_base.build_flags} + -D DISPLAY_ROTATION=1 + -D DISPLAY_CLASS=ST7735Display + -D ADVERT_NAME='"Heltec Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D WITH_MQTT_UPLINK=1 + -D MQTT_DEBUG=1 + -D WIFI_SSID='"myssid"' + -D WIFI_PWD='"mypwd"' + -D MQTT_DEFAULT_IATA='"MEL"' + -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=1 +build_src_filter = ${Heltec_tracker_base.build_src_filter} + + + +<../examples/simple_repeater> + + +lib_deps = + ${Heltec_tracker_base.lib_deps} + ${esp32_ota.lib_deps} + +[env:M5Stack_Unit_C6L_repeater_mqtt] +extends = M5Stack_Unit_C6L +build_src_filter = ${M5Stack_Unit_C6L.build_src_filter} + +<../examples/simple_repeater/*.cpp> + + +build_flags = + ${M5Stack_Unit_C6L.build_flags} + -D ADVERT_NAME='"Unit C6L Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D WITH_MQTT_UPLINK=1 + -D MQTT_DEBUG=1 + -D WIFI_SSID='"myssid"' + -D WIFI_PWD='"mypwd"' + -D MQTT_DEFAULT_IATA='"MEL"' + -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=1 +lib_deps = + ${M5Stack_Unit_C6L.lib_deps} + +[env:LilyGo_TDeck_repeater_mqtt] +extends = LilyGo_TDeck +build_flags = + ${LilyGo_TDeck.build_flags} + -D ADVERT_NAME='"TDeck Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D WITH_MQTT_UPLINK=1 + -D MQTT_DEBUG=1 + -D WIFI_SSID='"myssid"' + -D WIFI_PWD='"mypwd"' + -D MQTT_DEFAULT_IATA='"MEL"' + -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=1 +build_src_filter = ${LilyGo_TDeck.build_src_filter} + +<../examples/simple_repeater> + + + + +lib_deps = + ${LilyGo_TDeck.lib_deps} + ${esp32_ota.lib_deps} + +[env:Station_G2_repeater_mqtt] +extends = Station_G2 +build_flags = + ${Station_G2.build_flags} + -D ADVERT_NAME='"Station G2 Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D WITH_MQTT_UPLINK=1 + -D MQTT_DEBUG=1 + -D WIFI_SSID='"myssid"' + -D WIFI_PWD='"mypwd"' + -D MQTT_DEFAULT_IATA='"MEL"' + -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=1 +build_src_filter = ${Station_G2.build_src_filter} + +<../examples/simple_repeater> + + +lib_deps = + ${Station_G2.lib_deps} + ${esp32_ota.lib_deps} + +[env:Station_G2_logging_repeater_mqtt] +extends = Station_G2 +build_flags = + ${Station_G2.build_flags} + -D ADVERT_NAME='"Station G2 Logging Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D MESH_PACKET_LOGGING=1 + -D SX126X_RX_BOOSTED_GAIN=1 + -D WITH_MQTT_UPLINK=1 + -D MQTT_DEBUG=1 + -D WIFI_SSID='"myssid"' + -D WIFI_PWD='"mypwd"' + -D MQTT_DEFAULT_IATA='"MEL"' + -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=1 +build_src_filter = ${Station_G2.build_src_filter} + +<../examples/simple_repeater> + + +lib_deps = + ${Station_G2.lib_deps} + ${esp32_ota.lib_deps} + +[env:LilyGo_T3S3_sx1262_repeater_mqtt] +extends = LilyGo_T3S3_sx1262 +build_flags = + ${LilyGo_T3S3_sx1262.build_flags} + -D DISPLAY_CLASS=SSD1306Display + -D ADVERT_NAME='"T3S3-1262 Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D WITH_MQTT_UPLINK=1 + -D MQTT_DEBUG=1 + -D WIFI_SSID='"myssid"' + -D WIFI_PWD='"mypwd"' + -D MQTT_DEFAULT_IATA='"MEL"' + -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=1 +build_src_filter = ${LilyGo_T3S3_sx1262.build_src_filter} + + + +<../examples/simple_repeater> + + +lib_deps = + ${LilyGo_T3S3_sx1262.lib_deps} + ${esp32_ota.lib_deps} + +[env:Meshadventurer_sx1262_repeater_mqtt] +extends = Meshadventurer +build_src_filter = ${Meshadventurer.build_src_filter} + +<../examples/simple_repeater> + + + + +build_flags = + ${Meshadventurer.build_flags} + -D RADIO_CLASS=CustomSX1262 + -D WRAPPER_CLASS=CustomSX1262Wrapper + -D LORA_TX_POWER=22 + -D ADVERT_NAME='"Meshadventurer Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D WITH_MQTT_UPLINK=1 + -D MQTT_DEBUG=1 + -D WIFI_SSID='"myssid"' + -D WIFI_PWD='"mypwd"' + -D MQTT_DEFAULT_IATA='"MEL"' + -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=1 +lib_deps = + ${Meshadventurer.lib_deps} + ${esp32_ota.lib_deps} + +[env:Meshadventurer_sx1268_repeater_mqtt] +extends = Meshadventurer +build_src_filter = ${Meshadventurer.build_src_filter} + +<../examples/simple_repeater> + + + + +build_flags = + ${Meshadventurer.build_flags} + -D RADIO_CLASS=CustomSX1268 + -D WRAPPER_CLASS=CustomSX1268Wrapper + -D LORA_TX_POWER=22 + -D ADVERT_NAME='"Meshadventurer Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D WITH_MQTT_UPLINK=1 + -D MQTT_DEBUG=1 + -D WIFI_SSID='"myssid"' + -D WIFI_PWD='"mypwd"' + -D MQTT_DEFAULT_IATA='"MEL"' + -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=1 +lib_deps = + ${Meshadventurer.lib_deps} + ${esp32_ota.lib_deps} + +[env:Heltec_Wireless_Paper_repeater_mqtt] +extends = Heltec_Wireless_Paper_base +build_flags = + ${Heltec_Wireless_Paper_base.build_flags} + -D DISPLAY_CLASS=E213Display + -D ADVERT_NAME='"Heltec WP Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D WITH_MQTT_UPLINK=1 + -D MQTT_DEBUG=1 + -D WIFI_SSID='"myssid"' + -D WIFI_PWD='"mypwd"' + -D MQTT_DEFAULT_IATA='"MEL"' + -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=1 +build_src_filter = ${Heltec_Wireless_Paper_base.build_src_filter} + + + +<../examples/simple_repeater> + + +lib_deps = + ${Heltec_Wireless_Paper_base.lib_deps} + ${esp32_ota.lib_deps} + bakercp/CRC32 @ ^2.0.0 + +[env:heltec_v4_repeater_mqtt] +extends = heltec_v4_oled +build_flags = + ${heltec_v4_oled.build_flags} + -D DISPLAY_CLASS=SSD1306Display + -D ADVERT_NAME='"Heltec MQTT Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D WITH_MQTT_UPLINK=1 + -D MQTT_DEBUG=1 + -D WIFI_SSID='"myssid"' + -D WIFI_PWD='"mypwd"' + -D MQTT_DEFAULT_IATA='"MEL"' + -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=1 +build_src_filter = ${heltec_v4_oled.build_src_filter} + + + + + +<../examples/simple_repeater> +lib_deps = + ${heltec_v4_oled.lib_deps} + ${esp32_ota.lib_deps} + bakercp/CRC32 @ ^2.0.0 + +[env:heltec_v4_tft_repeater_mqtt] +extends = heltec_v4_tft +build_flags = + ${heltec_v4_tft.build_flags} + -D DISPLAY_CLASS=ST7789LCDDisplay + -D ADVERT_NAME='"Heltec Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D WITH_MQTT_UPLINK=1 + -D MQTT_DEBUG=1 + -D WIFI_SSID='"myssid"' + -D WIFI_PWD='"mypwd"' + -D MQTT_DEFAULT_IATA='"MEL"' + -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=1 +build_src_filter = ${heltec_v4_tft.build_src_filter} + + + +<../examples/simple_repeater> + + +lib_deps = + ${heltec_v4_tft.lib_deps} + ${esp32_ota.lib_deps} + bakercp/CRC32 @ ^2.0.0 + +[env:heltec_tracker_v2_repeater_mqtt] +extends = Heltec_tracker_v2 +build_flags = + ${Heltec_tracker_v2.build_flags} + -D DISPLAY_CLASS=ST7735Display + -D ADVERT_NAME='"Heltec Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D WITH_MQTT_UPLINK=1 + -D MQTT_DEBUG=1 + -D WIFI_SSID='"myssid"' + -D WIFI_PWD='"mypwd"' + -D MQTT_DEFAULT_IATA='"MEL"' + -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=1 +build_src_filter = ${Heltec_tracker_v2.build_src_filter} + + + +<../examples/simple_repeater> + + +lib_deps = + ${Heltec_tracker_v2.lib_deps} + ${esp32_ota.lib_deps} + bakercp/CRC32 @ ^2.0.0 + +[env:Heltec_v3_repeater_mqtt] +extends = Heltec_lora32_v3 +build_flags = + ${Heltec_lora32_v3.build_flags} + -D DISPLAY_CLASS=SSD1306Display + -D ADVERT_NAME='"Heltec Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D WITH_MQTT_UPLINK=1 + -D MQTT_DEBUG=1 + -D WIFI_SSID='"myssid"' + -D WIFI_PWD='"mypwd"' + -D MQTT_DEFAULT_IATA='"MEL"' + -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=1 +build_src_filter = ${Heltec_lora32_v3.build_src_filter} + + + +<../examples/simple_repeater> + + +lib_deps = + ${Heltec_lora32_v3.lib_deps} + ${esp32_ota.lib_deps} + bakercp/CRC32 @ ^2.0.0 + +[env:Heltec_WSL3_repeater_mqtt] +extends = Heltec_lora32_v3 +build_flags = + ${Heltec_lora32_v3.build_flags} + -D ADVERT_NAME='"Heltec Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D WITH_MQTT_UPLINK=1 + -D MQTT_DEBUG=1 + -D WIFI_SSID='"myssid"' + -D WIFI_PWD='"mypwd"' + -D MQTT_DEFAULT_IATA='"MEL"' + -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=1 +build_src_filter = ${Heltec_lora32_v3.build_src_filter} + +<../examples/simple_repeater> + + +lib_deps = + ${Heltec_lora32_v3.lib_deps} + ${esp32_ota.lib_deps} + bakercp/CRC32 @ ^2.0.0 + +[env:LilyGo_T3S3_sx1276_repeater_mqtt] +extends = LilyGo_T3S3_sx1276 +build_flags = + ${LilyGo_T3S3_sx1276.build_flags} + -D DISPLAY_CLASS=SSD1306Display + -D ADVERT_NAME='"T3S3-1276 Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D WITH_MQTT_UPLINK=1 + -D MQTT_DEBUG=1 + -D WIFI_SSID='"myssid"' + -D WIFI_PWD='"mypwd"' + -D MQTT_DEFAULT_IATA='"MEL"' + -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=1 +build_src_filter = ${LilyGo_T3S3_sx1276.build_src_filter} + + + +<../examples/simple_repeater> + + +lib_deps = + ${LilyGo_T3S3_sx1276.lib_deps} + ${esp32_ota.lib_deps} + +[env:LilyGo_Tlora_C6_repeater_mqtt_] +extends = tlora_c6 +build_src_filter = ${tlora_c6.build_src_filter} + +<../examples/simple_repeater/*.cpp> + + +build_flags = + ${tlora_c6.build_flags} + -D ADVERT_NAME='"Tlora C6 Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D WITH_MQTT_UPLINK=1 + -D MQTT_DEBUG=1 + -D WIFI_SSID='"myssid"' + -D WIFI_PWD='"mypwd"' + -D MQTT_DEFAULT_IATA='"MEL"' + -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=1 +lib_deps = + ${tlora_c6.lib_deps} + +[env:Xiao_C6_repeater_mqtt_] +extends = Xiao_C6 +build_src_filter = ${Xiao_C6.build_src_filter} + +<../examples/simple_repeater/*.cpp> + + +build_flags = + ${Xiao_C6.build_flags} + -D ADVERT_NAME='"Xiao C6 Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D WITH_MQTT_UPLINK=1 + -D MQTT_DEBUG=1 + -D WIFI_SSID='"myssid"' + -D WIFI_PWD='"mypwd"' + -D MQTT_DEFAULT_IATA='"MEL"' + -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=1 +lib_deps = + ${Xiao_C6.lib_deps} + +[env:Meshimi_repeater_mqtt_] +extends = Meshimi +build_src_filter = ${Meshimi.build_src_filter} + +<../examples/simple_repeater/*.cpp> + + +build_flags = + ${Meshimi.build_flags} + -D ADVERT_NAME='"Meshimi Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D WITH_MQTT_UPLINK=1 + -D MQTT_DEBUG=1 + -D WIFI_SSID='"myssid"' + -D WIFI_PWD='"mypwd"' + -D MQTT_DEFAULT_IATA='"MEL"' + -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=1 +lib_deps = + ${Meshimi.lib_deps} + +[env:WHY2025_badge_repeater_mqtt_] +extends = WHY2025_badge +build_src_filter = ${WHY2025_badge.build_src_filter} + +<../examples/simple_repeater/*.cpp> + + +build_flags = + ${WHY2025_badge.build_flags} + -D ADVERT_NAME='"WHY2025 Badge Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D WITH_MQTT_UPLINK=1 + -D MQTT_DEBUG=1 + -D WIFI_SSID='"myssid"' + -D WIFI_PWD='"mypwd"' + -D MQTT_DEFAULT_IATA='"MEL"' + -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=1 +lib_deps = + ${WHY2025_badge.lib_deps} + +[env:Heltec_v2_repeater_mqtt] +extends = Heltec_lora32_v2 +build_flags = + ${Heltec_lora32_v2.build_flags} + -D DISPLAY_CLASS=SSD1306Display + -D ADVERT_NAME='"Heltec Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D WITH_MQTT_UPLINK=1 + -D MQTT_DEBUG=1 + -D WIFI_SSID='"myssid"' + -D WIFI_PWD='"mypwd"' + -D MQTT_DEFAULT_IATA='"MEL"' + -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=1 +build_src_filter = ${Heltec_lora32_v2.build_src_filter} + +<../examples/simple_repeater> + + + + + + +lib_deps = + ${Heltec_lora32_v2.lib_deps} + ${esp32_ota.lib_deps} + +[env:nibble_screen_connect_repeater_mqtt] +extends = nibble_screen_connect_base +build_flags = + ${nibble_screen_connect_base.build_flags} + -D DISPLAY_CLASS=SSD1306Display + -D ADVERT_NAME='"Nibble Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D WITH_MQTT_UPLINK=1 + -D MQTT_DEBUG=1 + -D WIFI_SSID='"myssid"' + -D WIFI_PWD='"mypwd"' + -D MQTT_DEFAULT_IATA='"MEL"' + -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=1 +build_src_filter = ${nibble_screen_connect_base.build_src_filter} + + + +<../examples/simple_repeater> + + +lib_deps = + ${nibble_screen_connect_base.lib_deps} + ${esp32_ota.lib_deps} + +[env:ThinkNode_M2_Repeater_mqtt] +extends = ThinkNode_M2 +build_src_filter = ${ThinkNode_M2.build_src_filter} + +<../examples/simple_repeater/*.cpp> + + +build_flags = + ${ThinkNode_M2.build_flags} + -D ADVERT_NAME='"Thinknode M2 Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=8 + -D WITH_MQTT_UPLINK=1 + -D MQTT_DEBUG=1 + -D WIFI_SSID='"myssid"' + -D WIFI_PWD='"mypwd"' + -D MQTT_DEFAULT_IATA='"MEL"' + -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=1 +lib_deps = + ${ThinkNode_M2.lib_deps} + ${esp32_ota.lib_deps} + +[env:ThinkNode_M5_Repeater_mqtt] +extends = ThinkNode_M5 +build_src_filter = ${ThinkNode_M5.build_src_filter} + +<../examples/simple_repeater/*.cpp> + + +build_flags = + ${ThinkNode_M5.build_flags} + -D ADVERT_NAME='"Thinknode M5 Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=8 + -D WITH_MQTT_UPLINK=1 + -D MQTT_DEBUG=1 + -D WIFI_SSID='"myssid"' + -D WIFI_PWD='"mypwd"' + -D MQTT_DEFAULT_IATA='"MEL"' + -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=1 +lib_deps = + ${ThinkNode_M5.lib_deps} + ${esp32_ota.lib_deps} + +[env:Heltec_T190_repeater_mqtt_] +extends = Heltec_T190_base +build_flags = + ${Heltec_T190_base.build_flags} + -D ADVERT_NAME='"Heltec T190 Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D WITH_MQTT_UPLINK=1 + -D MQTT_DEBUG=1 + -D WIFI_SSID='"myssid"' + -D WIFI_PWD='"mypwd"' + -D MQTT_DEFAULT_IATA='"MEL"' + -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=1 +build_src_filter = ${Heltec_T190_base.build_src_filter} + +<../examples/simple_repeater> + + +lib_deps = + ${Heltec_T190_base.lib_deps} + ${esp32_ota.lib_deps} + +[env:Tenstar_C3_sx1262_repeater_mqtt] +extends = Tenstar_esp32_C3 +build_src_filter = ${Tenstar_esp32_C3.build_src_filter} + +<../examples/simple_repeater/*.cpp> + + +build_flags = + ${Tenstar_esp32_C3.build_flags} + -D RADIO_CLASS=CustomSX1262 + -D WRAPPER_CLASS=CustomSX1262Wrapper + -D SX126X_RX_BOOSTED_GAIN=1 + -D LORA_TX_POWER=22 + -D ADVERT_NAME='"Tenstar C3 Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D WITH_MQTT_UPLINK=1 + -D MQTT_DEBUG=1 + -D WIFI_SSID='"myssid"' + -D WIFI_PWD='"mypwd"' + -D MQTT_DEFAULT_IATA='"MEL"' + -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=1 +lib_deps = + ${Tenstar_esp32_C3.lib_deps} + ${esp32_ota.lib_deps} + +[env:Tenstar_C3_sx1268_repeater_mqtt] +extends = Tenstar_esp32_C3 +build_src_filter = ${Tenstar_esp32_C3.build_src_filter} + +<../examples/simple_repeater/*.cpp> + + +build_flags = + ${Tenstar_esp32_C3.build_flags} + -D RADIO_CLASS=CustomSX1268 + -D WRAPPER_CLASS=CustomSX1268Wrapper + -D LORA_TX_POWER=22 + -D ADVERT_NAME='"Tenstar C3 Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D WITH_MQTT_UPLINK=1 + -D MQTT_DEBUG=1 + -D WIFI_SSID='"myssid"' + -D WIFI_PWD='"mypwd"' + -D MQTT_DEFAULT_IATA='"MEL"' + -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=1 +lib_deps = + ${Tenstar_esp32_C3.lib_deps} + ${esp32_ota.lib_deps} + +[env:Heltec_ct62_repeater_mqtt] +extends = Heltec_ct62 +build_flags = + ${Heltec_ct62.build_flags} + ;-D ARDUINO_USB_MODE=1 + ;-D ARDUINO_USB_CDC_ON_BOOT=1 + -D ADVERT_NAME='"HT-CT62 Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D WITH_MQTT_UPLINK=1 + -D MQTT_DEBUG=1 + -D WIFI_SSID='"myssid"' + -D WIFI_PWD='"mypwd"' + -D MQTT_DEFAULT_IATA='"MEL"' + -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=1 +build_src_filter = ${Heltec_ct62.build_src_filter} + +<../examples/simple_repeater> + + +lib_deps = + ${Heltec_ct62.lib_deps} + ${esp32_ota.lib_deps} + +[env:Xiao_S3_WIO_repeater_mqtt] +extends = Xiao_S3_WIO +build_src_filter = ${Xiao_S3_WIO.build_src_filter} + +<../examples/simple_repeater/*.cpp> + + +build_flags = + ${Xiao_S3_WIO.build_flags} + -D ADVERT_NAME='"XiaoS3 Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D WITH_MQTT_UPLINK=1 + -D MQTT_DEBUG=1 + -D WIFI_SSID='"myssid"' + -D WIFI_PWD='"mypwd"' + -D MQTT_DEFAULT_IATA='"MEL"' + -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=1 +lib_deps = + ${Xiao_S3_WIO.lib_deps} + ${esp32_ota.lib_deps} + +[env:Generic_E22_sx1262_repeater_mqtt] +extends = Generic_E22 +build_src_filter = ${Generic_E22.build_src_filter} + +<../examples/simple_repeater/*.cpp> + + +build_flags = + ${Generic_E22.build_flags} + -D RADIO_CLASS=CustomSX1262 + -D WRAPPER_CLASS=CustomSX1262Wrapper + -D LORA_TX_POWER=22 + -D ADVERT_NAME='"E22 Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D WITH_MQTT_UPLINK=1 + -D MQTT_DEBUG=1 + -D WIFI_SSID='"myssid"' + -D WIFI_PWD='"mypwd"' + -D MQTT_DEFAULT_IATA='"MEL"' + -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=1 +lib_deps = + ${Generic_E22.lib_deps} + ${esp32_ota.lib_deps} + +[env:Generic_E22_sx1268_repeater_mqtt] +extends = Generic_E22 +build_src_filter = ${Generic_E22.build_src_filter} + +<../examples/simple_repeater/*.cpp> + + +build_flags = + ${Generic_E22.build_flags} + -D RADIO_CLASS=CustomSX1268 + -D WRAPPER_CLASS=CustomSX1268Wrapper + -D LORA_TX_POWER=22 + -D ADVERT_NAME='"E22 Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D WITH_MQTT_UPLINK=1 + -D MQTT_DEBUG=1 + -D WIFI_SSID='"myssid"' + -D WIFI_PWD='"mypwd"' + -D MQTT_DEFAULT_IATA='"MEL"' + -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=1 +lib_deps = + ${Generic_E22.lib_deps} + ${esp32_ota.lib_deps} From e8377c2dfb91f80c9c6f7d978e3d56f2e5602b82 Mon Sep 17 00:00:00 2001 From: Jared Dohrman Date: Thu, 9 Apr 2026 15:04:22 +1000 Subject: [PATCH 2/5] chore: narrow CI and release workflows to EastMesh wifi and mqtt targets --- .github/workflows/pr-build-check.yml | 30 ++++++++++------------------ 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/.github/workflows/pr-build-check.yml b/.github/workflows/pr-build-check.yml index 54d9cbe3..b5261baf 100644 --- a/.github/workflows/pr-build-check.yml +++ b/.github/workflows/pr-build-check.yml @@ -27,26 +27,18 @@ jobs: fail-fast: false matrix: environment: - # ESP32-S3 (most common platform) - - Heltec_v3_companion_radio_ble - - Heltec_v3_repeater - - Heltec_v3_room_server - - heltec_v4_companion_radio_ble - - heltec_v4_repeater - - heltec_v4_room_server - - T_Beam_S3_Supreme_SX1262_companion_radio_ble - - T_Beam_S3_Supreme_SX1262_repeater - - T_Beam_S3_Supreme_SX1262_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 / core EastMesh targets + - Heltec_v3_companion_radio_wifi + - Heltec_v3_repeater_mqtt + - heltec_v4_companion_radio_wifi + - heltec_v4_repeater_mqtt + - T_Beam_S3_Supreme_SX1262_companion_radio_wifi + - T_Beam_S3_Supreme_SX1262_repeater_mqtt + # ESP32-C3 + - Xiao_C3_companion_radio_wifi + - Xiao_C3_repeater_mqtt # ESP32-C6 - - LilyGo_Tlora_C6_repeater_ + - LilyGo_Tlora_C6_repeater_mqtt_ steps: - name: Clone Repo From 238e19d9b7d3d44f205798e2b19600661db92b17 Mon Sep 17 00:00:00 2001 From: Jared Dohrman Date: Thu, 9 Apr 2026 15:14:06 +1000 Subject: [PATCH 3/5] fix: trim C3 mqtt firmware size and make web panel optional --- src/helpers/mqtt/MQTTUplink.cpp | 37 +++++++++++- src/helpers/mqtt/MQTTUplink.h | 15 ++++- variants/eastmesh_mqtt/platformio.ini | 81 ++++++++++++++------------- 3 files changed, 90 insertions(+), 43 deletions(-) diff --git a/src/helpers/mqtt/MQTTUplink.cpp b/src/helpers/mqtt/MQTTUplink.cpp index 9ef874cc..e0479427 100644 --- a/src/helpers/mqtt/MQTTUplink.cpp +++ b/src/helpers/mqtt/MQTTUplink.cpp @@ -1,6 +1,8 @@ #include "MQTTUplink.h" #include "MQTTCaCerts.h" -#include "WebPanelCert.h" +#if defined(WITH_WEB_PANEL) && WITH_WEB_PANEL + #include "WebPanelCert.h" +#endif #ifdef WITH_MQTT_UPLINK @@ -75,6 +77,7 @@ void freeScratchBuffer(void* ptr) { } } +#if WITH_WEB_PANEL const char kWebPanelHtml[] PROGMEM = R"HTML( @@ -418,6 +421,7 @@ const char kWebPanelHtml[] PROGMEM = R"HTML( )HTML"; +#endif const char* getWifiStateLabel(const MQTTPrefs& prefs, bool wifi_started) { if (prefs.wifi_ssid[0] == 0) { @@ -447,10 +451,16 @@ MQTTUplink::MQTTUplink(mesh::RTCClock& rtc, mesh::LocalIdentity& identity) : _fs(nullptr), _rtc(&rtc), _identity(&identity), _running(false), _wifi_started(false), _sntp_started(false), _have_time_sync(false), _wifi_sta_started_at(0), _last_wifi_attempt(0), _last_status_publish(0), _last_status{}, _node_name(nullptr), - _web_runner(nullptr), _web_server(nullptr) { + _web_runner(nullptr) +#if WITH_WEB_PANEL + , _web_server(nullptr) +#endif + { memset(_device_id, 0, sizeof(_device_id)); +#if WITH_WEB_PANEL memset(_web_token, 0, sizeof(_web_token)); _web_route_context.self = this; +#endif MQTTPrefsStore::setDefaults(_prefs); for (size_t i = 0; i < 3; ++i) { memset(&_brokers[i], 0, sizeof(_brokers[i])); @@ -837,6 +847,7 @@ void MQTTUplink::handleMqttEvent(void* handler_args, esp_event_base_t, int32_t e } } +#if WITH_WEB_PANEL esp_err_t MQTTUplink::handleWebIndex(httpd_req_t* req) { auto* ctx = static_cast(req->user_ctx); if (ctx == nullptr || ctx->self == nullptr) { @@ -846,7 +857,9 @@ esp_err_t MQTTUplink::handleWebIndex(httpd_req_t* req) { httpd_resp_set_hdr(req, "Cache-Control", "no-store"); return httpd_resp_send(req, kWebPanelHtml, HTTPD_RESP_USE_STRLEN); } +#endif +#if WITH_WEB_PANEL esp_err_t MQTTUplink::handleWebLogin(httpd_req_t* req) { auto* ctx = static_cast(req->user_ctx); if (ctx == nullptr || ctx->self == nullptr || ctx->self->_web_runner == nullptr) { @@ -876,7 +889,9 @@ esp_err_t MQTTUplink::handleWebLogin(httpd_req_t* req) { httpd_resp_set_hdr(req, "Cache-Control", "no-store"); return httpd_resp_sendstr(req, ctx->self->_web_token); } +#endif +#if WITH_WEB_PANEL esp_err_t MQTTUplink::handleWebCommand(httpd_req_t* req) { auto* ctx = static_cast(req->user_ctx); if (ctx == nullptr || ctx->self == nullptr || ctx->self->_web_runner == nullptr) { @@ -909,7 +924,9 @@ esp_err_t MQTTUplink::handleWebCommand(httpd_req_t* req) { freeScratchBuffer(reply); return rc; } +#endif +#if WITH_WEB_PANEL bool MQTTUplink::readRequestBody(httpd_req_t* req, char* buffer, size_t buffer_size) const { if (req == nullptr || buffer == nullptr || buffer_size == 0 || req->content_len <= 0 || req->content_len >= static_cast(buffer_size)) { @@ -998,6 +1015,10 @@ void MQTTUplink::ensureWebServer() { } startWebServer(); } +#else +void MQTTUplink::ensureWebServer() { } +void MQTTUplink::stopWebServer() { } +#endif void MQTTUplink::ensureWifi() { if (_prefs.wifi_ssid[0] == 0) { @@ -1178,7 +1199,9 @@ void MQTTUplink::begin(FILESYSTEM* fs) { _fs = fs; MQTTPrefsStore::load(_fs, _prefs); refreshIdentityStrings(); +#if WITH_WEB_PANEL refreshWebToken(); +#endif _running = true; _last_status_publish = millis(); MQTT_LOG("begin iata=%s enabled_mask=0x%02X wifi_ssid=%s", _prefs.iata, _prefs.enabled_mask, _prefs.wifi_ssid); @@ -1301,6 +1324,7 @@ void MQTTUplink::formatStatusReply(char* reply, size_t reply_size) const { } void MQTTUplink::formatWebStatusReply(char* reply, size_t reply_size) const { +#if WITH_WEB_PANEL if (_web_runner == nullptr) { snprintf(reply, reply_size, "> web:off"); return; @@ -1318,6 +1342,10 @@ void MQTTUplink::formatWebStatusReply(char* reply, size_t reply_size) const { snprintf(reply, reply_size, "> web:up url:https://%s/ auth:%s", WiFi.localIP().toString().c_str(), _web_token[0] ? "unlocked" : "locked"); +#else + (void)reply_size; + snprintf(reply, reply_size, "> web:unsupported"); +#endif } bool MQTTUplink::setEndpointEnabled(uint8_t bit, bool enabled) { @@ -1355,6 +1383,7 @@ bool MQTTUplink::setTxEnabled(bool enabled) { } bool MQTTUplink::setWebEnabled(bool enabled) { +#if WITH_WEB_PANEL _prefs.web_enabled = enabled ? 1 : 0; bool ok = savePrefs(); if (_prefs.web_enabled == 0) { @@ -1363,6 +1392,10 @@ bool MQTTUplink::setWebEnabled(bool enabled) { ensureWebServer(); } return ok; +#else + (void)enabled; + return false; +#endif } bool MQTTUplink::setIata(const char* iata) { diff --git a/src/helpers/mqtt/MQTTUplink.h b/src/helpers/mqtt/MQTTUplink.h index ee930f1f..2492d433 100644 --- a/src/helpers/mqtt/MQTTUplink.h +++ b/src/helpers/mqtt/MQTTUplink.h @@ -11,7 +11,12 @@ #if defined(ESP_PLATFORM) #include -#include +#if !defined(WITH_WEB_PANEL) + #define WITH_WEB_PANEL 1 +#endif +#if WITH_WEB_PANEL + #include +#endif #include #endif @@ -105,9 +110,11 @@ private: char offline_payload[256]; }; +#if WITH_WEB_PANEL struct WebRouteContext { MQTTUplink* self; }; +#endif #endif FILESYSTEM* _fs; @@ -133,20 +140,26 @@ private: static const BrokerSpec kBrokerSpecs[3]; BrokerState _brokers[3]; +#if WITH_WEB_PANEL httpd_handle_t _web_server; char _web_token[33]; WebRouteContext _web_route_context; +#endif static void handleMqttEvent(void* handler_args, esp_event_base_t base, int32_t event_id, void* event_data); +#if WITH_WEB_PANEL static esp_err_t handleWebIndex(httpd_req_t* req); static esp_err_t handleWebLogin(httpd_req_t* req); static esp_err_t handleWebCommand(httpd_req_t* req); +#endif void ensureWebServer(); void stopWebServer(); bool startWebServer(); +#if WITH_WEB_PANEL bool isWebAuthorized(httpd_req_t* req) const; bool readRequestBody(httpd_req_t* req, char* buffer, size_t buffer_size) const; void refreshWebToken(); +#endif void ensureWifi(); void updateTimeSync(); bool hasEnabledBroker() const; diff --git a/variants/eastmesh_mqtt/platformio.ini b/variants/eastmesh_mqtt/platformio.ini index 5e689723..ac425f1c 100644 --- a/variants/eastmesh_mqtt/platformio.ini +++ b/variants/eastmesh_mqtt/platformio.ini @@ -8,7 +8,7 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_UPLINK=1 - -D MQTT_DEBUG=1 + ; -D MQTT_DEBUG=1 -D WIFI_SSID='"myssid"' -D WIFI_PWD='"mypwd"' -D MQTT_DEFAULT_IATA='"MEL"' @@ -33,7 +33,7 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_UPLINK=1 - -D MQTT_DEBUG=1 + ; -D MQTT_DEBUG=1 -D WIFI_SSID='"myssid"' -D WIFI_PWD='"mypwd"' -D MQTT_DEFAULT_IATA='"MEL"' @@ -54,7 +54,7 @@ build_flags = -D PERSISTANT_GPS=1 -D ENV_SKIP_GPS_DETECT=1 -D WITH_MQTT_UPLINK=1 - -D MQTT_DEBUG=1 + ; -D MQTT_DEBUG=1 -D WIFI_SSID='"myssid"' -D WIFI_PWD='"mypwd"' -D MQTT_DEFAULT_IATA='"MEL"' @@ -79,7 +79,8 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_UPLINK=1 - -D MQTT_DEBUG=1 + -D WITH_WEB_PANEL=0 + ; -D MQTT_DEBUG=1 -D WIFI_SSID='"myssid"' -D WIFI_PWD='"mypwd"' -D MQTT_DEFAULT_IATA='"MEL"' @@ -100,7 +101,7 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_UPLINK=1 - -D MQTT_DEBUG=1 + ; -D MQTT_DEBUG=1 -D WIFI_SSID='"myssid"' -D WIFI_PWD='"mypwd"' -D MQTT_DEFAULT_IATA='"MEL"' @@ -123,7 +124,7 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_UPLINK=1 - -D MQTT_DEBUG=1 + ; -D MQTT_DEBUG=1 -D WIFI_SSID='"myssid"' -D WIFI_PWD='"mypwd"' -D MQTT_DEFAULT_IATA='"MEL"' @@ -146,7 +147,7 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_UPLINK=1 - -D MQTT_DEBUG=1 + ; -D MQTT_DEBUG=1 -D WIFI_SSID='"myssid"' -D WIFI_PWD='"mypwd"' -D MQTT_DEFAULT_IATA='"MEL"' @@ -169,7 +170,7 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_UPLINK=1 - -D MQTT_DEBUG=1 + ; -D MQTT_DEBUG=1 -D WIFI_SSID='"myssid"' -D WIFI_PWD='"mypwd"' -D MQTT_DEFAULT_IATA='"MEL"' @@ -194,7 +195,7 @@ build_flags = -D MAX_NEIGHBOURS=50 -D PERSISTANT_GPS=1 -D WITH_MQTT_UPLINK=1 - -D MQTT_DEBUG=1 + ; -D MQTT_DEBUG=1 -D WIFI_SSID='"myssid"' -D WIFI_PWD='"mypwd"' -D MQTT_DEFAULT_IATA='"MEL"' @@ -217,7 +218,7 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_UPLINK=1 - -D MQTT_DEBUG=1 + ; -D MQTT_DEBUG=1 -D WIFI_SSID='"myssid"' -D WIFI_PWD='"mypwd"' -D MQTT_DEFAULT_IATA='"MEL"' @@ -243,7 +244,7 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_UPLINK=1 - -D MQTT_DEBUG=1 + ; -D MQTT_DEBUG=1 -D WIFI_SSID='"myssid"' -D WIFI_PWD='"mypwd"' -D MQTT_DEFAULT_IATA='"MEL"' @@ -269,7 +270,7 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_UPLINK=1 - -D MQTT_DEBUG=1 + ; -D MQTT_DEBUG=1 -D WIFI_SSID='"myssid"' -D WIFI_PWD='"mypwd"' -D MQTT_DEFAULT_IATA='"MEL"' @@ -287,7 +288,7 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_UPLINK=1 - -D MQTT_DEBUG=1 + ; -D MQTT_DEBUG=1 -D WIFI_SSID='"myssid"' -D WIFI_PWD='"mypwd"' -D MQTT_DEFAULT_IATA='"MEL"' @@ -310,7 +311,7 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_UPLINK=1 - -D MQTT_DEBUG=1 + ; -D MQTT_DEBUG=1 -D WIFI_SSID='"myssid"' -D WIFI_PWD='"mypwd"' -D MQTT_DEFAULT_IATA='"MEL"' @@ -334,7 +335,7 @@ build_flags = -D MESH_PACKET_LOGGING=1 -D SX126X_RX_BOOSTED_GAIN=1 -D WITH_MQTT_UPLINK=1 - -D MQTT_DEBUG=1 + ; -D MQTT_DEBUG=1 -D WIFI_SSID='"myssid"' -D WIFI_PWD='"mypwd"' -D MQTT_DEFAULT_IATA='"MEL"' @@ -357,7 +358,7 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_UPLINK=1 - -D MQTT_DEBUG=1 + ; -D MQTT_DEBUG=1 -D WIFI_SSID='"myssid"' -D WIFI_PWD='"mypwd"' -D MQTT_DEFAULT_IATA='"MEL"' @@ -387,7 +388,7 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_UPLINK=1 - -D MQTT_DEBUG=1 + ; -D MQTT_DEBUG=1 -D WIFI_SSID='"myssid"' -D WIFI_PWD='"mypwd"' -D MQTT_DEFAULT_IATA='"MEL"' @@ -413,7 +414,7 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_UPLINK=1 - -D MQTT_DEBUG=1 + ; -D MQTT_DEBUG=1 -D WIFI_SSID='"myssid"' -D WIFI_PWD='"mypwd"' -D MQTT_DEFAULT_IATA='"MEL"' @@ -433,7 +434,7 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_UPLINK=1 - -D MQTT_DEBUG=1 + ; -D MQTT_DEBUG=1 -D WIFI_SSID='"myssid"' -D WIFI_PWD='"mypwd"' -D MQTT_DEFAULT_IATA='"MEL"' @@ -458,7 +459,7 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_UPLINK=1 - -D MQTT_DEBUG=1 + ; -D MQTT_DEBUG=1 -D WIFI_SSID='"myssid"' -D WIFI_PWD='"mypwd"' -D MQTT_DEFAULT_IATA='"MEL"' @@ -483,7 +484,7 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_UPLINK=1 - -D MQTT_DEBUG=1 + ; -D MQTT_DEBUG=1 -D WIFI_SSID='"myssid"' -D WIFI_PWD='"mypwd"' -D MQTT_DEFAULT_IATA='"MEL"' @@ -508,7 +509,7 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_UPLINK=1 - -D MQTT_DEBUG=1 + ; -D MQTT_DEBUG=1 -D WIFI_SSID='"myssid"' -D WIFI_PWD='"mypwd"' -D MQTT_DEFAULT_IATA='"MEL"' @@ -533,7 +534,7 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_UPLINK=1 - -D MQTT_DEBUG=1 + ; -D MQTT_DEBUG=1 -D WIFI_SSID='"myssid"' -D WIFI_PWD='"mypwd"' -D MQTT_DEFAULT_IATA='"MEL"' @@ -557,7 +558,7 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_UPLINK=1 - -D MQTT_DEBUG=1 + ; -D MQTT_DEBUG=1 -D WIFI_SSID='"myssid"' -D WIFI_PWD='"mypwd"' -D MQTT_DEFAULT_IATA='"MEL"' @@ -581,7 +582,7 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_UPLINK=1 - -D MQTT_DEBUG=1 + ; -D MQTT_DEBUG=1 -D WIFI_SSID='"myssid"' -D WIFI_PWD='"mypwd"' -D MQTT_DEFAULT_IATA='"MEL"' @@ -607,7 +608,7 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_UPLINK=1 - -D MQTT_DEBUG=1 + ; -D MQTT_DEBUG=1 -D WIFI_SSID='"myssid"' -D WIFI_PWD='"mypwd"' -D MQTT_DEFAULT_IATA='"MEL"' @@ -628,7 +629,7 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_UPLINK=1 - -D MQTT_DEBUG=1 + ; -D MQTT_DEBUG=1 -D WIFI_SSID='"myssid"' -D WIFI_PWD='"mypwd"' -D MQTT_DEFAULT_IATA='"MEL"' @@ -649,7 +650,7 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_UPLINK=1 - -D MQTT_DEBUG=1 + ; -D MQTT_DEBUG=1 -D WIFI_SSID='"myssid"' -D WIFI_PWD='"mypwd"' -D MQTT_DEFAULT_IATA='"MEL"' @@ -670,7 +671,7 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_UPLINK=1 - -D MQTT_DEBUG=1 + ; -D MQTT_DEBUG=1 -D WIFI_SSID='"myssid"' -D WIFI_PWD='"mypwd"' -D MQTT_DEFAULT_IATA='"MEL"' @@ -689,7 +690,7 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_UPLINK=1 - -D MQTT_DEBUG=1 + ; -D MQTT_DEBUG=1 -D WIFI_SSID='"myssid"' -D WIFI_PWD='"mypwd"' -D MQTT_DEFAULT_IATA='"MEL"' @@ -714,7 +715,7 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_UPLINK=1 - -D MQTT_DEBUG=1 + ; -D MQTT_DEBUG=1 -D WIFI_SSID='"myssid"' -D WIFI_PWD='"mypwd"' -D MQTT_DEFAULT_IATA='"MEL"' @@ -740,7 +741,7 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=8 -D WITH_MQTT_UPLINK=1 - -D MQTT_DEBUG=1 + ; -D MQTT_DEBUG=1 -D WIFI_SSID='"myssid"' -D WIFI_PWD='"mypwd"' -D MQTT_DEFAULT_IATA='"MEL"' @@ -762,7 +763,7 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=8 -D WITH_MQTT_UPLINK=1 - -D MQTT_DEBUG=1 + ; -D MQTT_DEBUG=1 -D WIFI_SSID='"myssid"' -D WIFI_PWD='"mypwd"' -D MQTT_DEFAULT_IATA='"MEL"' @@ -781,7 +782,7 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_UPLINK=1 - -D MQTT_DEBUG=1 + ; -D MQTT_DEBUG=1 -D WIFI_SSID='"myssid"' -D WIFI_PWD='"mypwd"' -D MQTT_DEFAULT_IATA='"MEL"' @@ -810,7 +811,7 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_UPLINK=1 - -D MQTT_DEBUG=1 + ; -D MQTT_DEBUG=1 -D WIFI_SSID='"myssid"' -D WIFI_PWD='"mypwd"' -D MQTT_DEFAULT_IATA='"MEL"' @@ -835,7 +836,7 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_UPLINK=1 - -D MQTT_DEBUG=1 + ; -D MQTT_DEBUG=1 -D WIFI_SSID='"myssid"' -D WIFI_PWD='"mypwd"' -D MQTT_DEFAULT_IATA='"MEL"' @@ -856,7 +857,7 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_UPLINK=1 - -D MQTT_DEBUG=1 + ; -D MQTT_DEBUG=1 -D WIFI_SSID='"myssid"' -D WIFI_PWD='"mypwd"' -D MQTT_DEFAULT_IATA='"MEL"' @@ -881,7 +882,7 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_UPLINK=1 - -D MQTT_DEBUG=1 + ; -D MQTT_DEBUG=1 -D WIFI_SSID='"myssid"' -D WIFI_PWD='"mypwd"' -D MQTT_DEFAULT_IATA='"MEL"' @@ -906,7 +907,7 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_UPLINK=1 - -D MQTT_DEBUG=1 + ; -D MQTT_DEBUG=1 -D WIFI_SSID='"myssid"' -D WIFI_PWD='"mypwd"' -D MQTT_DEFAULT_IATA='"MEL"' @@ -931,7 +932,7 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D WITH_MQTT_UPLINK=1 - -D MQTT_DEBUG=1 + ; -D MQTT_DEBUG=1 -D WIFI_SSID='"myssid"' -D WIFI_PWD='"mypwd"' -D MQTT_DEFAULT_IATA='"MEL"' From ca63ea923a091037a97cd8247078738c3852ec00 Mon Sep 17 00:00:00 2001 From: Jared Dohrman Date: Thu, 9 Apr 2026 15:19:46 +1000 Subject: [PATCH 4/5] fix: generate web panel certs per build and clean up mqtt env flags --- .gitignore | 1 + .../extra_scripts/generate_web_panel_cert.py | 77 +++++++++++++++++++ platformio.ini | 4 +- src/helpers/mqtt/WebPanelCert.h | 27 +++---- 4 files changed, 95 insertions(+), 14 deletions(-) create mode 100644 arch/esp32/extra_scripts/generate_web_panel_cert.py diff --git a/.gitignore b/.gitignore index a0ad5f6e..af22aa5d 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ compile_commands.json .venv/ venv/ platformio.local.ini +src/helpers/mqtt/WebPanelCert.h diff --git a/arch/esp32/extra_scripts/generate_web_panel_cert.py b/arch/esp32/extra_scripts/generate_web_panel_cert.py new file mode 100644 index 00000000..a1268f8b --- /dev/null +++ b/arch/esp32/extra_scripts/generate_web_panel_cert.py @@ -0,0 +1,77 @@ +Import("env") + +from pathlib import Path +import subprocess +import tempfile + + +def render_pem_array(name, pem_text): + lines = [] + for chunk in pem_text.splitlines(keepends=True): + escaped = chunk.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n") + lines.append(f' "{escaped}"') + joined = "\n".join(lines) if lines else ' ""' + return f"static const char {name}[] =\n{joined};\n" + + +project_dir = Path(env["PROJECT_DIR"]) +header_path = project_dir / "src/helpers/mqtt/WebPanelCert.h" + +if header_path.exists(): + existing = header_path.read_text() + if "AUTO-GENERATED BY generate_web_panel_cert.py" in existing and "kServerKeyPem" in existing: + Return() + +with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + key_path = tmp_path / "web-panel-key.pem" + cert_path = tmp_path / "web-panel-cert.pem" + + try: + subprocess.run( + ["openssl", "ecparam", "-name", "prime256v1", "-genkey", "-noout", "-out", str(key_path)], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + subprocess.run( + [ + "openssl", + "req", + "-new", + "-x509", + "-key", + str(key_path), + "-out", + str(cert_path), + "-days", + "3650", + "-subj", + "/CN=meshcore-local", + ], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + except FileNotFoundError as exc: + raise RuntimeError("openssl is required to generate the web panel certificate header") from exc + except subprocess.CalledProcessError as exc: + raise RuntimeError( + f"openssl failed while generating the web panel certificate header: {exc.stderr.strip()}" + ) from exc + + cert_pem = cert_path.read_text() + key_pem = key_path.read_text() + +header_path.parent.mkdir(parents=True, exist_ok=True) +header_path.write_text( + "#pragma once\n\n" + "// AUTO-GENERATED BY generate_web_panel_cert.py. DO NOT COMMIT.\n\n" + "namespace mqtt_web_panel_cert {\n\n" + f"{render_pem_array('kServerCertPem', cert_pem)}\n" + f"{render_pem_array('kServerKeyPem', key_pem)}" + "}\n", + encoding="utf-8", +) diff --git a/platformio.ini b/platformio.ini index 5f722e89..fcad6303 100644 --- a/platformio.ini +++ b/platformio.ini @@ -58,7 +58,9 @@ build_src_filter = extends = arduino_base platform = platformio/espressif32@6.11.0 monitor_filters = esp32_exception_decoder -extra_scripts = merge-bin.py +extra_scripts = + pre:arch/esp32/extra_scripts/generate_web_panel_cert.py + merge-bin.py build_flags = ${arduino_base.build_flags} -D ESP32_PLATFORM ; -D ESP32_CPU_FREQ=80 ; change it to your need diff --git a/src/helpers/mqtt/WebPanelCert.h b/src/helpers/mqtt/WebPanelCert.h index 991244e5..82478d25 100644 --- a/src/helpers/mqtt/WebPanelCert.h +++ b/src/helpers/mqtt/WebPanelCert.h @@ -1,25 +1,26 @@ #pragma once +// AUTO-GENERATED BY generate_web_panel_cert.py. DO NOT COMMIT. + namespace mqtt_web_panel_cert { static const char kServerCertPem[] = "-----BEGIN CERTIFICATE-----\n" - "MIIBiDCCAS2gAwIBAgIUYPagAbDjmNMHx+sqAC5qvOnlywIwCgYIKoZIzj0EAwIw\n" - "GTEXMBUGA1UEAwwObWVzaGNvcmUtbG9jYWwwHhcNMjYwNDA5MDE0MjAzWhcNMzYw\n" - "NDA2MDE0MjAzWjAZMRcwFQYDVQQDDA5tZXNoY29yZS1sb2NhbDBZMBMGByqGSM49\n" - "AgEGCCqGSM49AwEHA0IABJWLgQ/4sDJKykqR9a5brDLR9RikwoZ/6n1Od38rbfzV\n" - "ykP2LJIdrxU6zS1gzLk33klycB7AWFL1W7klStdiSpmjUzBRMB0GA1UdDgQWBBSk\n" - "dpP2r+UPriHT+hXLiyFdjkFgmTAfBgNVHSMEGDAWgBSkdpP2r+UPriHT+hXLiyFd\n" - "jkFgmTAPBgNVHRMBAf8EBTADAQH/MAoGCCqGSM49BAMCA0kAMEYCIQCWS5ZRbON4\n" - "71RrwcogEzGuQTNdkbtkRc+517nZ63ZOawIhAPwSy/0yMyhqAoOMpVnhIb+w995f\n" - "zH4nApL2Q2U0KI2l\n" + "MIIBiDCCAS2gAwIBAgIUFysjM9xosI98WhPpK1doyLOAkgYwCgYIKoZIzj0EAwIw\n" + "GTEXMBUGA1UEAwwObWVzaGNvcmUtbG9jYWwwHhcNMjYwNDA5MDUxODQ4WhcNMzYw\n" + "NDA2MDUxODQ4WjAZMRcwFQYDVQQDDA5tZXNoY29yZS1sb2NhbDBZMBMGByqGSM49\n" + "AgEGCCqGSM49AwEHA0IABBpVegwc/kZDjVtHXnl0iYbD2vdes7nLztlyRDylcfOD\n" + "GoR9hopoc9NernvGJLUFP/+PsjIsfEGmVrmPESnShsWjUzBRMB0GA1UdDgQWBBTi\n" + "oWH5V8BvTj9Kla8ki8SHb2vdSTAfBgNVHSMEGDAWgBTioWH5V8BvTj9Kla8ki8SH\n" + "b2vdSTAPBgNVHRMBAf8EBTADAQH/MAoGCCqGSM49BAMCA0kAMEYCIQDjNde/RApq\n" + "jx/FYar9+qMMxHeBMrSiOBozTFpcmFr7RgIhAMdVESr6P2urGsxzbYirwURj5H1F\n" + "8eej1APpWRRZvvP0\n" "-----END CERTIFICATE-----\n"; static const char kServerKeyPem[] = "-----BEGIN EC PRIVATE KEY-----\n" - "MHcCAQEEIGX71nLSiA8qUJXuiZRNzaGizNbMqgOxALSPd/HzfzWAoAoGCCqGSM49\n" - "AwEHoUQDQgAElYuBD/iwMkrKSpH1rlusMtH1GKTChn/qfU53fytt/NXKQ/Yskh2v\n" - "FTrNLWDMuTfeSXJwHsBYUvVbuSVK12JKmQ==\n" + "MHcCAQEEIPQxD+WYeE2tQja5AVrOgDUHAKJ7TYfELqU55Npb7mJ1oAoGCCqGSM49\n" + "AwEHoUQDQgAEGlV6DBz+RkONW0deeXSJhsPa916zucvO2XJEPKVx84MahH2Gimhz\n" + "016ue8YktQU//4+yMix8QaZWuY8RKdKGxQ==\n" "-----END EC PRIVATE KEY-----\n"; - } From 4b184a60d984968ff1c3ce773e9793c26d7e2be7 Mon Sep 17 00:00:00 2001 From: Jared Dohrman Date: Thu, 9 Apr 2026 15:25:52 +1000 Subject: [PATCH 5/5] fix: stop tracking web panel cert key and generate it under ignored artifacts --- .gitignore | 2 +- .../extra_scripts/generate_web_panel_cert.py | 2 +- src/helpers/mqtt/MQTTUplink.cpp | 2 +- src/helpers/mqtt/WebPanelCert.h | 26 ------------------- 4 files changed, 3 insertions(+), 29 deletions(-) delete mode 100644 src/helpers/mqtt/WebPanelCert.h diff --git a/.gitignore b/.gitignore index af22aa5d..924ffaf5 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,4 @@ compile_commands.json .venv/ venv/ platformio.local.ini -src/helpers/mqtt/WebPanelCert.h +src/helpers/mqtt/generated/ diff --git a/arch/esp32/extra_scripts/generate_web_panel_cert.py b/arch/esp32/extra_scripts/generate_web_panel_cert.py index a1268f8b..37b07577 100644 --- a/arch/esp32/extra_scripts/generate_web_panel_cert.py +++ b/arch/esp32/extra_scripts/generate_web_panel_cert.py @@ -15,7 +15,7 @@ def render_pem_array(name, pem_text): project_dir = Path(env["PROJECT_DIR"]) -header_path = project_dir / "src/helpers/mqtt/WebPanelCert.h" +header_path = project_dir / "src/helpers/mqtt/generated/WebPanelCert.h" if header_path.exists(): existing = header_path.read_text() diff --git a/src/helpers/mqtt/MQTTUplink.cpp b/src/helpers/mqtt/MQTTUplink.cpp index e0479427..fff119c8 100644 --- a/src/helpers/mqtt/MQTTUplink.cpp +++ b/src/helpers/mqtt/MQTTUplink.cpp @@ -1,7 +1,7 @@ #include "MQTTUplink.h" #include "MQTTCaCerts.h" #if defined(WITH_WEB_PANEL) && WITH_WEB_PANEL - #include "WebPanelCert.h" + #include "generated/WebPanelCert.h" #endif #ifdef WITH_MQTT_UPLINK diff --git a/src/helpers/mqtt/WebPanelCert.h b/src/helpers/mqtt/WebPanelCert.h deleted file mode 100644 index 82478d25..00000000 --- a/src/helpers/mqtt/WebPanelCert.h +++ /dev/null @@ -1,26 +0,0 @@ -#pragma once - -// AUTO-GENERATED BY generate_web_panel_cert.py. DO NOT COMMIT. - -namespace mqtt_web_panel_cert { - -static const char kServerCertPem[] = - "-----BEGIN CERTIFICATE-----\n" - "MIIBiDCCAS2gAwIBAgIUFysjM9xosI98WhPpK1doyLOAkgYwCgYIKoZIzj0EAwIw\n" - "GTEXMBUGA1UEAwwObWVzaGNvcmUtbG9jYWwwHhcNMjYwNDA5MDUxODQ4WhcNMzYw\n" - "NDA2MDUxODQ4WjAZMRcwFQYDVQQDDA5tZXNoY29yZS1sb2NhbDBZMBMGByqGSM49\n" - "AgEGCCqGSM49AwEHA0IABBpVegwc/kZDjVtHXnl0iYbD2vdes7nLztlyRDylcfOD\n" - "GoR9hopoc9NernvGJLUFP/+PsjIsfEGmVrmPESnShsWjUzBRMB0GA1UdDgQWBBTi\n" - "oWH5V8BvTj9Kla8ki8SHb2vdSTAfBgNVHSMEGDAWgBTioWH5V8BvTj9Kla8ki8SH\n" - "b2vdSTAPBgNVHRMBAf8EBTADAQH/MAoGCCqGSM49BAMCA0kAMEYCIQDjNde/RApq\n" - "jx/FYar9+qMMxHeBMrSiOBozTFpcmFr7RgIhAMdVESr6P2urGsxzbYirwURj5H1F\n" - "8eej1APpWRRZvvP0\n" - "-----END CERTIFICATE-----\n"; - -static const char kServerKeyPem[] = - "-----BEGIN EC PRIVATE KEY-----\n" - "MHcCAQEEIPQxD+WYeE2tQja5AVrOgDUHAKJ7TYfELqU55Npb7mJ1oAoGCCqGSM49\n" - "AwEHoUQDQgAEGlV6DBz+RkONW0deeXSJhsPa916zucvO2XJEPKVx84MahH2Gimhz\n" - "016ue8YktQU//4+yMix8QaZWuY8RKdKGxQ==\n" - "-----END EC PRIVATE KEY-----\n"; -}