Parcourir la source

feat: add MQTT repeater support, web config panel, and EastMesh-specific firmware workflows

Jared Dohrman il y a 4 mois
Parent
commit
98fe78a379

+ 10 - 15
.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

+ 5 - 5
.github/workflows/build-companion-firmwares.yml → .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/*

+ 0 - 42
.github/workflows/build-repeater-firmwares.yml

@@ -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/*

+ 5 - 5
.github/workflows/build-room-server-firmwares.yml → .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/*

+ 19 - 29
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-<commit>`
+- `companion-wifi`: `v1.2.3-<commit>`
+- `repeater-mqtt`: `v1.2.3-eastmesh-v1.0.1-<commit>`
 
 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`

+ 31 - 3
build.sh

@@ -32,7 +32,9 @@ Commands:
   build-firmwares: Build all firmwares for all targets.
   build-matching-firmwares <build-match-spec>: Build all firmwares for build targets containing the string given for <build-match-spec>.
   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-match-spec>
 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

+ 229 - 0
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<int>(board.getBattMilliVolts());
+  mqtt_status.uptime_secs = static_cast<uint32_t>(uptime_millis / 1000);
+  mqtt_status.error_flags = _err_flags;
+  mqtt_status.queue_len = static_cast<uint16_t>(_mgr->getOutboundTotal());
+  mqtt_status.noise_floor = static_cast<int>(_radio->getNoiseFloor());
+  mqtt_status.tx_air_secs = static_cast<uint32_t>(getTotalAirTime() / 1000);
+  mqtt_status.rx_air_secs = static_cast<uint32_t>(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;
 }

+ 11 - 1
examples/simple_repeater/MyMesh.h

@@ -23,6 +23,10 @@
 #define WITH_BRIDGE
 #endif
 
+#ifdef WITH_MQTT_UPLINK
+#include <helpers/mqtt/MQTTUplink.h>
+#endif
+
 #include <helpers/AdvertDataHelpers.h>
 #include <helpers/ArduinoHelpers.h>
 #include <helpers/ClientACL.h>
@@ -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)

+ 11 - 0
examples/simple_repeater/UITask.cpp

@@ -1,5 +1,6 @@
 #include "UITask.h"
 #include <Arduino.h>
+#include <WiFi.h>
 #include <helpers/CommonCLI.h>
 
 #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);
   }
 }
 

+ 4 - 0
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

+ 1 - 0
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; }
 

+ 4 - 0
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();

+ 1 - 0
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 { }

+ 2 - 2
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",

+ 2 - 0
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");
     }

+ 1 - 0
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;

+ 20 - 0
src/helpers/StatsFormatHelper.h

@@ -1,6 +1,7 @@
 #pragma once
 
 #include "Mesh.h"
+#include <Arduino.h>
 
 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
+  }
 };

+ 105 - 0
src/helpers/mqtt/JWTHelper.cpp

@@ -0,0 +1,105 @@
+#include "JWTHelper.h"
+
+#ifdef WITH_MQTT_UPLINK
+
+#include <ctype.h>
+#include <ed_25519.h>
+#include <mbedtls/base64.h>
+#include <string.h>
+
+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<unsigned char*>(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<const uint8_t*>(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<unsigned char>(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<unsigned long>(issued_at),
+                                  static_cast<unsigned long>(expires_at));
+  if (payload_json_len <= 0 || static_cast<size_t>(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<size_t>(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<const uint8_t*>(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<size_t>(signing_input_len) >= sizeof(signing_input)) {
+    return false;
+  }
+
+  uint8_t signature[64];
+  identity.sign(signature, reinterpret_cast<const uint8_t*>(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<size_t>(total_len) < token_size;
+}
+
+#endif

+ 18 - 0
src/helpers/mqtt/JWTHelper.h

@@ -0,0 +1,18 @@
+#pragma once
+
+#ifdef WITH_MQTT_UPLINK
+
+#include <Arduino.h>
+#include <Identity.h>
+
+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

+ 112 - 0
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

+ 82 - 0
src/helpers/mqtt/MQTTPrefs.cpp

@@ -0,0 +1,82 @@
+#include "MQTTPrefs.h"
+
+#ifdef WITH_MQTT_UPLINK
+
+#include <helpers/TxtDataHelpers.h>
+#include <string.h>
+
+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<size_t>(file.size()), sizeof(prefs));
+  bool ok = bytes_to_read >= sizeof(prefs.magic) &&
+            file.read(reinterpret_cast<uint8_t*>(&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<const uint8_t*>(&prefs), sizeof(prefs)) == sizeof(prefs);
+  file.close();
+  return ok;
+}
+
+#endif

+ 40 - 0
src/helpers/mqtt/MQTTPrefs.h

@@ -0,0 +1,40 @@
+#pragma once
+
+#ifdef WITH_MQTT_UPLINK
+
+#include <helpers/IdentityStore.h>
+#include <stdint.h>
+
+#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

+ 1556 - 0
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 <helpers/ESP32Board.h>
+#include <WiFi.h>
+#include <WiFiClientSecure.h>
+#include <esp_idf_version.h>
+#include <esp_heap_caps.h>
+#include <esp_system.h>
+#include <esp_sntp.h>
+#include <helpers/TxtDataHelpers.h>
+#include <ctype.h>
+#include <string.h>
+#include <time.h>
+
+#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<char*>(ptr);
+}
+
+void freeScratchBuffer(void* ptr) {
+  if (ptr != nullptr) {
+    heap_caps_free(ptr);
+  }
+}
+
+const char kWebPanelHtml[] PROGMEM = R"HTML(
+<!doctype html>
+<html>
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width,initial-scale=1">
+  <title>Repeater Config</title>
+  <style>
+    :root {
+      color-scheme: light;
+      --accent:#1f9d5c;
+      --accent-hover:#27b86d;
+      --background:#f2f7f3;
+      --text:#163222;
+      --text-muted:#5f7d69;
+      --border:#d6e6db;
+      --surface1:#ffffff;
+      --surface2:#f8fcf9;
+      --card-bg:#ffffff;
+      --input-bg:#ffffff;
+      --terminal-bg:#f8fcf9;
+      --terminal-border:#d6e6db;
+      --terminal-cmd:#1f9d5c;
+      --status-red:#c94a4a;
+      --button-text:#ffffff;
+      --button-secondary-text:#163222;
+    }
+    :root[data-theme="dark"] {
+      color-scheme: dark;
+      --accent:#1fb874;
+      --accent-hover:#2edc8f;
+      --background:#080b09;
+      --text:#c6e3d2;
+      --text-muted:#789a88;
+      --border:#1c2a22;
+      --surface1:#0d120f;
+      --surface2:#131914;
+      --card-bg:#0d120f;
+      --input-bg:#101612;
+      --terminal-bg:#080b09;
+      --terminal-border:#1c2a22;
+      --terminal-cmd:#8fd3ff;
+      --status-red:#d45a5a;
+      --button-text:#08110b;
+      --button-secondary-text:#c6e3d2;
+    }
+    html { min-height:100%; background:linear-gradient(180deg,var(--background),var(--surface2)); background-repeat:no-repeat; background-attachment:fixed; }
+    body { min-height:100vh; margin:0; font:16px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace; background:transparent; color:var(--text); transition:background .2s ease,color .2s ease; }
+    main { max-width:920px; margin:0 auto; padding:24px; }
+    .theme-fab { position:fixed; top:18px; right:18px; width:48px; height:48px; border-radius:999px; display:flex; align-items:center; justify-content:center; z-index:20; box-shadow:0 12px 28px rgba(0,0,0,.18); font-size:22px; line-height:1; }
+    .card { background:var(--card-bg); border:1px solid var(--border); border-radius:14px; padding:18px; margin-bottom:18px; }
+    h1,h2,h3 { margin:0 0 12px; font-size:18px; }
+    p { color:var(--text-muted); margin:8px 0 0; }
+    input, textarea, button, select { width:100%; box-sizing:border-box; border-radius:10px; border:1px solid var(--border); background:var(--input-bg); color:var(--text); padding:12px; font:inherit; }
+    textarea { min-height:100px; resize:vertical; }
+    button { width:auto; cursor:pointer; background:var(--accent); color:var(--button-text); border:none; font-weight:700; transition:background .2s ease,color .2s ease,border-color .2s ease; }
+    button:hover { background:var(--accent-hover); }
+    .row { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
+    .row3 { display:grid; grid-template-columns:1fr 1fr 1fr; gap:12px; }
+    .quick { display:flex; flex-wrap:wrap; gap:10px; }
+    .quick button, .iconbtn, .themebtn { background:var(--surface2); color:var(--button-secondary-text); border:1px solid var(--border); }
+    .quick button:hover, .iconbtn:hover, .themebtn:hover { background:var(--surface1); }
+    .stack { display:grid; gap:12px; }
+    .label { font-size:12px; color:var(--text-muted); margin-bottom:6px; display:block; }
+    .fieldline { display:grid; grid-template-columns:1fr auto; gap:8px; align-items:center; }
+    .iconbtn { width:44px; padding:12px 0; }
+    .themebtn { padding:10px 14px; }
+    #app { display:none; }
+    #status { white-space:pre-wrap; color:var(--text-muted); min-height:1.4em; }
+    .terminal { background:var(--terminal-bg); border:1px solid var(--terminal-border); border-radius:12px; padding:14px; min-height:180px; max-height:320px; overflow:auto; font:14px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace; }
+    .term-entry { margin:0 0 12px; }
+    .term-cmd { color:var(--terminal-cmd); }
+    .term-out { white-space:pre-wrap; color:var(--text); }
+    .term-out.err { color:var(--status-red); }
+  </style>
+</head>
+<body>
+  <main>
+    <button id="themeToggle" class="themebtn theme-fab" aria-label="Toggle theme" title="Toggle theme">☾</button>
+    <section class="card" id="login">
+      <h1>Repeater Config</h1>
+      <p>Use the repeater admin password to unlock the command console. Accept the self-signed certificate warning in your browser first.</p>
+      <div class="row" style="margin-top:14px">
+        <input id="password" type="password" placeholder="Admin password">
+        <button id="loginBtn">Unlock</button>
+      </div>
+      <div id="status"></div>
+    </section>
+
+    <section class="card" id="app">
+      <h2>Run CLI Command</h2>
+      <div class="row">
+        <input id="command" placeholder="get mqtt.status">
+        <button id="runBtn">Run</button>
+      </div>
+      <p>Only the allowlisted commands exposed by this panel will run here.</p>
+      <div id="reply" class="terminal"></div>
+    </section>
+
+    <section class="card" id="app2" style="display:none">
+      <h2>Quick Commands</h2>
+      <div class="stack">
+        <div>
+          <span class="label">WiFi</span>
+          <div class="quick">
+            <button data-cmd="get wifi.status">wifi.status</button>
+            <button data-cmd="get wifi.powersaving">wifi.powersaving</button>
+          </div>
+        </div>
+        <div>
+          <span class="label">MQTT</span>
+          <div class="quick">
+            <button data-cmd="get mqtt.status">mqtt.status</button>
+            <button data-cmd="get mqtt.iata">mqtt.iata</button>
+            <button data-cmd="get mqtt.owner">mqtt.owner</button>
+            <button data-cmd="get mqtt.email">mqtt.email</button>
+          </div>
+        </div>
+      </div>
+    </section>
+
+    <section class="card" id="app3" style="display:none">
+      <h2>Web Forms</h2>
+      <div class="stack">
+        <div class="row">
+          <div>
+            <label class="label" for="nodeName">Device Name</label>
+            <div class="fieldline">
+              <input id="nodeName" placeholder="MeshCore-HOWL">
+              <button class="iconbtn" data-load-cmd="get name" data-load-input="nodeName" title="Refresh device name">&#8635;</button>
+            </div>
+          </div>
+          <div style="align-self:end">
+            <button data-prefix="set name " data-input="nodeName">Save name</button>
+          </div>
+        </div>
+        <div class="row">
+          <div>
+            <label class="label" for="nodeLat">Latitude</label>
+            <div class="fieldline">
+              <input id="nodeLat" placeholder="-37.8136">
+              <button class="iconbtn" data-load-cmd="get lat" data-load-input="nodeLat" title="Refresh latitude">&#8635;</button>
+            </div>
+          </div>
+          <div>
+            <label class="label" for="nodeLon">Longitude</label>
+            <div class="fieldline">
+              <input id="nodeLon" placeholder="144.9631">
+              <button class="iconbtn" data-load-cmd="get lon" data-load-input="nodeLon" title="Refresh longitude">&#8635;</button>
+            </div>
+          </div>
+        </div>
+        <div class="quick">
+          <button data-prefix="set lat " data-input="nodeLat">Save latitude</button>
+          <button data-prefix="set lon " data-input="nodeLon">Save longitude</button>
+        </div>
+        <div class="row">
+          <div>
+            <label class="label" for="guestPassword">Guest Password</label>
+            <input id="guestPassword" type="password" placeholder="new guest password">
+          </div>
+        </div>
+        <div class="quick">
+          <button data-prefix="set guest.password " data-input="guestPassword">Save guest password</button>
+        </div>
+        <div>
+          <label class="label" for="privateKey">Private Key</label>
+          <input id="privateKey" placeholder="64-hex-char private key">
+          <p>Changing the private key requires a reboot to apply.</p>
+          <button data-prefix="set prv.key " data-input="privateKey">Save private key</button>
+        </div>
+        <div class="row3">
+          <div>
+            <label class="label" for="advertInterval">Advert Interval (minutes)</label>
+            <div class="fieldline">
+              <input id="advertInterval" placeholder="2">
+              <button class="iconbtn" data-load-cmd="get advert.interval" data-load-input="advertInterval" title="Refresh advert interval">&#8635;</button>
+            </div>
+          </div>
+          <div>
+            <label class="label" for="floodInterval">Flood Interval (hours)</label>
+            <div class="fieldline">
+              <input id="floodInterval" placeholder="12">
+              <button class="iconbtn" data-load-cmd="get flood.advert.interval" data-load-input="floodInterval" title="Refresh flood interval">&#8635;</button>
+            </div>
+          </div>
+          <div>
+            <label class="label" for="floodMax">Flood Max</label>
+            <div class="fieldline">
+              <input id="floodMax" placeholder="64">
+              <button class="iconbtn" data-load-cmd="get flood.max" data-load-input="floodMax" title="Refresh flood max">&#8635;</button>
+            </div>
+          </div>
+        </div>
+        <div class="quick">
+          <button data-prefix="set advert.interval " data-input="advertInterval">Save advert interval</button>
+          <button data-prefix="set flood.advert.interval " data-input="floodInterval">Save flood interval</button>
+          <button data-prefix="set flood.max " data-input="floodMax">Save flood max</button>
+        </div>
+        <div>
+          <label class="label" for="ownerInfo">Owner Info</label>
+          <div class="fieldline">
+            <textarea id="ownerInfo" placeholder="Free text shown in owner info"></textarea>
+            <button class="iconbtn" data-load-cmd="get owner.info" data-load-input="ownerInfo" data-load-format="multiline" title="Refresh owner info">&#8635;</button>
+          </div>
+          <button id="saveOwnerInfo">Save owner info</button>
+        </div>
+      </div>
+    </section>
+
+    <section class="card" id="app4" style="display:none">
+      <h2>Actions</h2>
+      <div class="quick">
+        <button id="advertBtn">Advert</button>
+        <button id="rebootBtn">Reboot</button>
+        <button id="otaBtn">Start OTA</button>
+        <button id="logoutBtn" class="themebtn">Logout</button>
+      </div>
+    </section>
+  </main>
+  <script>
+    let token = "";
+    const statusEl = document.getElementById("status");
+    const replyEl = document.getElementById("reply");
+    const themeToggleEl = document.getElementById("themeToggle");
+    const rootEl = document.documentElement;
+    function getPreferredTheme() {
+      const saved = localStorage.getItem("repeater-theme");
+      if (saved === "light" || saved === "dark") return saved;
+      return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
+    }
+    function applyTheme(theme) {
+      rootEl.dataset.theme = theme;
+      themeToggleEl.textContent = theme === "dark" ? "☀" : "☾";
+      themeToggleEl.title = theme === "dark" ? "Switch to light mode" : "Switch to dark mode";
+      themeToggleEl.setAttribute("aria-label", themeToggleEl.title);
+    }
+    function toggleTheme() {
+      const next = rootEl.dataset.theme === "dark" ? "light" : "dark";
+      localStorage.setItem("repeater-theme", next);
+      applyTheme(next);
+    }
+    applyTheme(getPreferredTheme());
+    themeToggleEl.onclick = toggleTheme;
+    function appendHistory(cmd, text, ok) {
+      const entry = document.createElement("div");
+      entry.className = "term-entry";
+      const cmdLine = document.createElement("div");
+      cmdLine.className = "term-cmd";
+      cmdLine.textContent = "> " + cmd;
+      const outLine = document.createElement("div");
+      outLine.className = "term-out" + (ok ? "" : " err");
+      outLine.textContent = text && text.length ? text : "OK";
+      entry.appendChild(cmdLine);
+      entry.appendChild(outLine);
+      replyEl.appendChild(entry);
+      replyEl.scrollTop = replyEl.scrollHeight;
+    }
+    function parseReplyValue(text) {
+      return (text || "").replace(/^>\s*/, "").trim();
+    }
+    function showAuthedUi(show) {
+      document.getElementById("login").style.display = show ? "none" : "block";
+      document.getElementById("app").style.display = show ? "block" : "none";
+      document.getElementById("app2").style.display = show ? "block" : "none";
+      document.getElementById("app3").style.display = show ? "block" : "none";
+      document.getElementById("app4").style.display = show ? "block" : "none";
+      if (!show) {
+        document.getElementById("password").value = "";
+        statusEl.textContent = "";
+      }
+    }
+    async function runCommand(cmd) {
+      if (!token) return { ok:false, text:"" };
+      document.getElementById("command").value = cmd;
+      const res = await fetch("/api/command", { method:"POST", headers:{ "X-Auth-Token": token }, body: cmd });
+      const text = await res.text();
+      appendHistory(cmd, text, res.ok);
+      return { ok:res.ok, text };
+    }
+    function runPrefixed(prefix, inputId) {
+      const value = document.getElementById(inputId).value;
+      runCommand(prefix + value);
+    }
+    async function loadField(cmd, inputId, format) {
+      const result = await runCommand(cmd);
+      if (!result.ok) return;
+      let value = parseReplyValue(result.text);
+      if (format === "multiline") {
+        value = value.replace(/\|/g, "\n");
+      }
+      document.getElementById(inputId).value = value;
+    }
+    document.getElementById("loginBtn").onclick = async () => {
+      const pwd = document.getElementById("password").value;
+      const res = await fetch("/login", { method:"POST", body: pwd });
+      const text = await res.text();
+      if (!res.ok) {
+        statusEl.textContent = text || "Access denied";
+        return;
+      }
+      token = text.trim();
+      showAuthedUi(true);
+      await loadField("get name", "nodeName");
+    };
+    document.getElementById("password").addEventListener("keydown", (event) => {
+      if (event.key === "Enter") {
+        event.preventDefault();
+        document.getElementById("loginBtn").click();
+      }
+    });
+    document.getElementById("runBtn").onclick = () => runCommand(document.getElementById("command").value);
+    document.querySelectorAll("[data-cmd]").forEach((btn) => btn.onclick = () => runCommand(btn.dataset.cmd));
+    document.querySelectorAll("[data-prefix]").forEach((btn) => btn.onclick = () => runPrefixed(btn.dataset.prefix, btn.dataset.input));
+    document.querySelectorAll("[data-load-cmd]").forEach((btn) => btn.onclick = () => loadField(btn.dataset.loadCmd, btn.dataset.loadInput, btn.dataset.loadFormat));
+    document.getElementById("saveOwnerInfo").onclick = () => {
+      const value = document.getElementById("ownerInfo").value.replace(/\n/g, "|");
+      runCommand("set owner.info " + value);
+    };
+    document.getElementById("rebootBtn").onclick = async () => {
+      if (confirm("Reboot the repeater now?")) {
+        await runCommand("reboot");
+      }
+    };
+    document.getElementById("advertBtn").onclick = async () => {
+      if (confirm("Send an advert now?")) {
+        await runCommand("advert");
+      }
+    };
+    document.getElementById("otaBtn").onclick = async () => {
+      if (confirm("Start OTA mode now?")) {
+        await runCommand("start ota");
+      }
+    };
+    document.getElementById("logoutBtn").onclick = () => {
+      token = "";
+      showAuthedUi(false);
+    };
+    showAuthedUi(false);
+  </script>
+</body>
+</html>
+)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<unsigned char>(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<double>(_last_status.radio_freq),
+           static_cast<double>(_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<unsigned long>(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<unsigned long>(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<unsigned>(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<double>(_last_status.radio_freq),
+           static_cast<double>(_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<unsigned long>(_last_status.uptime_secs), _last_status.error_flags,
+                  _last_status.queue_len, _last_status.noise_floor, static_cast<unsigned long>(_last_status.tx_air_secs),
+                  static_cast<unsigned long>(_last_status.rx_air_secs),
+                  static_cast<unsigned long>(_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<size_t>(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<size_t>(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<BrokerState*>(handler_args);
+  if (broker == nullptr) {
+    return;
+  }
+  auto* event = static_cast<esp_mqtt_event_handle_t>(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<WebRouteContext*>(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<WebRouteContext*>(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<WebRouteContext*>(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<int>(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<const uint8_t*>(mqtt_web_panel_cert::kServerCertPem);
+  config.cacert_len = sizeof(mqtt_web_panel_cert::kServerCertPem);
+  config.prvtkey_pem = reinterpret_cast<const uint8_t*>(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<unsigned>(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<unsigned long>(kWifiStartupDelayMillis));
+    return;
+  } else {
+    WIFI_LOG("retry status=%d", static_cast<int>(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<unsigned long>(now), static_cast<long>(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<size_t>(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<size_t>(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<unsigned char>(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<unsigned char>(owner_public_key[i]))) {
+      return false;
+    }
+    _prefs.owner_public_key[i] = toupper(static_cast<unsigned char>(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<int>(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<int>(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

+ 176 - 0
src/helpers/mqtt/MQTTUplink.h

@@ -0,0 +1,176 @@
+#pragma once
+
+#ifdef WITH_MQTT_UPLINK
+
+#include <Mesh.h>
+#include <helpers/CommonCLI.h>
+#include <target.h>
+
+#include "JWTHelper.h"
+#include "MQTTPrefs.h"
+
+#if defined(ESP_PLATFORM)
+#include <WiFi.h>
+#include <esp_https_server.h>
+#include <mqtt_client.h>
+#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

+ 25 - 0
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";
+
+}

+ 1 - 1
uv.lock

@@ -331,7 +331,7 @@ wheels = [
 ]
 
 [[package]]
-name = "meshcore-tooling"
+name = "meshcore-eastmesh"
 version = "0.1.0"
 source = { virtual = "." }
 dependencies = [

+ 941 - 0
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>
+  +<helpers/mqtt/*.cpp>
+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>
+  +<helpers/mqtt/*.cpp>
+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>
+  +<helpers/mqtt/*.cpp>
+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>
+  +<helpers/mqtt/*.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}
+  +<helpers/ui/SSD1306Display.cpp>
+  +<../examples/simple_repeater>
+  +<helpers/mqtt/*.cpp>
+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>
+  +<helpers/mqtt/*.cpp>
+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>
+  +<helpers/mqtt/*.cpp>
+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}
+  +<helpers/ui/E290Display.cpp>
+  +<../examples/simple_repeater>
+  +<helpers/mqtt/*.cpp>
+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>
+  +<helpers/mqtt/*.cpp>
+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}
+  +<helpers/ui/E213Display.cpp>
+  +<../examples/simple_repeater>
+  +<helpers/mqtt/*.cpp>
+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}
+  +<helpers/ui/ST7735Display.cpp>
+  +<../examples/simple_repeater>
+  +<helpers/mqtt/*.cpp>
+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>
+  +<helpers/mqtt/*.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>
+  +<helpers/ui/ST7789LCDDisplay.cpp>
+  +<helpers/mqtt/*.cpp>
+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>
+  +<helpers/mqtt/*.cpp>
+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>
+  +<helpers/mqtt/*.cpp>
+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}
+  +<helpers/ui/SSD1306Display.cpp>
+  +<../examples/simple_repeater>
+  +<helpers/mqtt/*.cpp>
+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>
+  +<helpers/ui/SSD1306Display.cpp>
+  +<helpers/mqtt/*.cpp>
+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>
+  +<helpers/ui/SSD1306Display.cpp>
+  +<helpers/mqtt/*.cpp>
+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}
+  +<helpers/ui/E213Display.cpp>
+  +<../examples/simple_repeater>
+  +<helpers/mqtt/*.cpp>
+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}
+  +<helpers/mqtt/*.cpp>
+  +<helpers/ui/SSD1306Display.cpp>
+  +<../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}
+  +<helpers/ui/ST7789LCDDisplay.cpp>
+  +<../examples/simple_repeater>
+  +<helpers/mqtt/*.cpp>
+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}
+  +<helpers/ui/ST7735Display.cpp>
+  +<../examples/simple_repeater>
+  +<helpers/mqtt/*.cpp>
+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}
+  +<helpers/ui/SSD1306Display.cpp>
+  +<../examples/simple_repeater>
+  +<helpers/mqtt/*.cpp>
+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>
+  +<helpers/mqtt/*.cpp>
+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}
+  +<helpers/ui/SSD1306Display.cpp>
+  +<../examples/simple_repeater>
+  +<helpers/mqtt/*.cpp>
+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>
+  +<helpers/mqtt/*.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>
+  +<helpers/mqtt/*.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>
+  +<helpers/mqtt/*.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>
+  +<helpers/mqtt/*.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>
+  +<helpers/ui/SSD1306Display.cpp>
+  +<helpers/ui/MomentaryButton.cpp>
+  +<helpers/mqtt/*.cpp>
+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}
+  +<helpers/ui/SSD1306Display.cpp>
+  +<../examples/simple_repeater>
+  +<helpers/mqtt/*.cpp>
+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>
+  +<helpers/mqtt/*.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>
+  +<helpers/mqtt/*.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>
+  +<helpers/mqtt/*.cpp>
+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>
+  +<helpers/mqtt/*.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>
+  +<helpers/mqtt/*.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>
+  +<helpers/mqtt/*.cpp>
+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>
+  +<helpers/mqtt/*.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>
+  +<helpers/mqtt/*.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>
+  +<helpers/mqtt/*.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}