From 6ed76fb6b96b220ee07da9e0dd6c583d6c33cb7c Mon Sep 17 00:00:00 2001 From: Liam Cottle Date: Sun, 26 Apr 2026 00:24:40 +1200 Subject: [PATCH 01/12] add FUNDING.yml --- .github/FUNDING.yml | 1 + 1 file changed, 1 insertion(+) create mode 100644 .github/FUNDING.yml diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000000..262a9ee4be --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: meshcore-dev From f7fa9d0b82c031809aa4341d57e8f1a4f21bbb32 Mon Sep 17 00:00:00 2001 From: uncle lit <43320854+LitBomb@users.noreply.github.com> Date: Sun, 26 Apr 2026 18:35:02 -0700 Subject: [PATCH 02/12] Removed links to outdated resources and links Removed links to outdated resources and links --- docs/faq.md | 8 -------- 1 file changed, 8 deletions(-) diff --git a/docs/faq.md b/docs/faq.md index c04eb6aa22..36f9b8b1a1 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -111,7 +111,6 @@ Anyone is able to build anything they like on top of MeshCore without paying any - MeshCore Firmware on GitHub: [https://github.com/meshcore-dev/MeshCore](https://github.com/meshcore-dev/MeshCore) - MeshCore Companion Web App: [https://app.meshcore.nz](https://app.meshcore.nz) - MeshCore Map: [https://map.meshcore.io](https://map.meshcore.io) -- Andy Kirby's [MeshCore Intro Video](https://www.youtube.com/watch?v=t1qne8uJBAc) - Liam Cottle's [MeshCore Technical Presentation](https://www.youtube.com/watch?v=OwmkVkZQTf4) You need LoRa hardware devices to run MeshCore firmware as clients or server (repeater and room server). @@ -404,9 +403,6 @@ Another way to download map tiles is to use this Python script to get the tiles There is also a modified script that adds additional error handling and parallel downloads: -UK map tiles are available separately from Andy Kirby on his discord server: - - ### 4.8. Q: Where do the map tiles go? Once you have the tiles downloaded, copy the `\tiles` folder to the root of your T-Deck's SD card. @@ -563,10 +559,6 @@ pio run -e RAK_4631_Repeater ``` then you'll find `firmware.zip` in `.pio/build/RAK_4631_Repeater` -Andy also has a video on how to build using VS Code: -*How to build and flash Meshcore repeater firmware | Heltec V3* - *(Link referenced in the Discord post)* - ### 5.10. Q: Are there other MeshCore related open source projects? **A:** [Liam Cottle](https://liamcottle.net)'s MeshCore web client and MeshCore Javascript library are open source under MIT license. From a97dd1e5324bed8647b4a832731706989df4f848 Mon Sep 17 00:00:00 2001 From: Keith Tweed Date: Sun, 26 Apr 2026 19:51:33 -0600 Subject: [PATCH 03/12] Update script link in FAQ 4.7 --- docs/faq.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/faq.md b/docs/faq.md index 36f9b8b1a1..ddf6960e32 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -401,7 +401,7 @@ Another way to download map tiles is to use this Python script to get the tiles There is also a modified script that adds additional error handling and parallel downloads: - + ### 4.8. Q: Where do the map tiles go? Once you have the tiles downloaded, copy the `\tiles` folder to the root of your T-Deck's SD card. From 8572a34957af7253ca959702aab6d1a8eb5b18c7 Mon Sep 17 00:00:00 2001 From: OhYou-0 Date: Fri, 24 Apr 2026 10:16:19 -0700 Subject: [PATCH 04/12] Add LilyGo T-ETH Elite board support --- variants/lilygo_teth_elite/TETHEliteBoard.h | 10 +++ variants/lilygo_teth_elite/platformio.ini | 99 +++++++++++++++++++++ variants/lilygo_teth_elite/target.cpp | 43 +++++++++ variants/lilygo_teth_elite/target.h | 20 +++++ 4 files changed, 172 insertions(+) create mode 100644 variants/lilygo_teth_elite/TETHEliteBoard.h create mode 100644 variants/lilygo_teth_elite/platformio.ini create mode 100644 variants/lilygo_teth_elite/target.cpp create mode 100644 variants/lilygo_teth_elite/target.h diff --git a/variants/lilygo_teth_elite/TETHEliteBoard.h b/variants/lilygo_teth_elite/TETHEliteBoard.h new file mode 100644 index 0000000000..15eb9533ef --- /dev/null +++ b/variants/lilygo_teth_elite/TETHEliteBoard.h @@ -0,0 +1,10 @@ +#pragma once + +#include + +class TETHEliteBoard : public ESP32Board { +public: + const char* getManufacturerName() const override { + return "LilyGO T-ETH Elite"; + } +}; diff --git a/variants/lilygo_teth_elite/platformio.ini b/variants/lilygo_teth_elite/platformio.ini new file mode 100644 index 0000000000..97728f8b4c --- /dev/null +++ b/variants/lilygo_teth_elite/platformio.ini @@ -0,0 +1,99 @@ +[LilyGo_TETH_Elite_sx1262] +extends = esp32_base +board = esp32s3box +board_build.partitions = default_16MB.csv +board_upload.flash_size = 16MB +build_flags = + ${esp32_base.build_flags} + -I variants/lilygo_teth_elite + -D BOARD_HAS_PSRAM + -D LILYGO_TETH_ELITE + -D LILYGO_T_ETH_ELITE_ESP32S3 + -D ARDUINO_USB_CDC_ON_BOOT=1 + -D P_LORA_DIO_1=8 + -D P_LORA_NSS=40 + -D P_LORA_RESET=46 + -D P_LORA_BUSY=16 + -D P_LORA_SCLK=10 + -D P_LORA_MISO=9 + -D P_LORA_MOSI=11 + -D P_LORA_TX_LED=38 + -D SX126X_DIO2_AS_RF_SWITCH=true + -D SX126X_DIO3_TCXO_VOLTAGE=1.8 + -D SX126X_CURRENT_LIMIT=140 + -D USE_SX1262 + -D RADIO_CLASS=CustomSX1262 + -D WRAPPER_CLASS=CustomSX1262Wrapper + -D LORA_TX_POWER=8 + -D SX126X_RX_BOOSTED_GAIN=1 +build_src_filter = ${esp32_base.build_src_filter} + +<../variants/lilygo_teth_elite> +lib_deps = + ${esp32_base.lib_deps} + +[env:LilyGo_TETH_Elite_sx1262_repeater] +extends = LilyGo_TETH_Elite_sx1262 +build_flags = + ${LilyGo_TETH_Elite_sx1262.build_flags} + -D ADVERT_NAME='"T-ETH Elite 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 MESH_DEBUG=1 +build_src_filter = ${LilyGo_TETH_Elite_sx1262.build_src_filter} + +<../examples/simple_repeater> +lib_deps = + ${LilyGo_TETH_Elite_sx1262.lib_deps} + ${esp32_ota.lib_deps} + +[env:LilyGo_TETH_Elite_sx1262_room_server] +extends = LilyGo_TETH_Elite_sx1262 +build_flags = + ${LilyGo_TETH_Elite_sx1262.build_flags} + -D ADVERT_NAME='"T-ETH Elite Room"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D ROOM_PASSWORD='"hello"' +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${LilyGo_TETH_Elite_sx1262.build_src_filter} + +<../examples/simple_room_server> +lib_deps = + ${LilyGo_TETH_Elite_sx1262.lib_deps} + ${esp32_ota.lib_deps} + +[env:LilyGo_TETH_Elite_sx1262_companion_radio_usb] +extends = LilyGo_TETH_Elite_sx1262 +build_flags = + ${LilyGo_TETH_Elite_sx1262.build_flags} + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D OFFLINE_QUEUE_SIZE=256 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${LilyGo_TETH_Elite_sx1262.build_src_filter} + +<../examples/companion_radio/*.cpp> +lib_deps = + ${LilyGo_TETH_Elite_sx1262.lib_deps} + densaugeo/base64 @ ~1.4.0 + +[env:LilyGo_TETH_Elite_sx1262_companion_radio_ble] +extends = LilyGo_TETH_Elite_sx1262 +build_flags = + ${LilyGo_TETH_Elite_sx1262.build_flags} + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D BLE_PIN_CODE=123456 + -D BLE_DEBUG_LOGGING=1 + -D OFFLINE_QUEUE_SIZE=256 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${LilyGo_TETH_Elite_sx1262.build_src_filter} + + + +<../examples/companion_radio/*.cpp> +lib_deps = + ${LilyGo_TETH_Elite_sx1262.lib_deps} + densaugeo/base64 @ ~1.4.0 diff --git a/variants/lilygo_teth_elite/target.cpp b/variants/lilygo_teth_elite/target.cpp new file mode 100644 index 0000000000..4dc377d620 --- /dev/null +++ b/variants/lilygo_teth_elite/target.cpp @@ -0,0 +1,43 @@ +#include +#include "target.h" + +TETHEliteBoard board; + +static SPIClass spi(HSPI); +RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, spi); +WRAPPER_CLASS radio_driver(radio, board); + +ESP32RTCClock fallback_clock; +AutoDiscoverRTCClock rtc_clock(fallback_clock); +SensorManager sensors; + +#ifndef LORA_CR + #define LORA_CR 5 +#endif + +bool radio_init() { + fallback_clock.begin(); + rtc_clock.begin(Wire); + + return radio.std_init(&spi); +} + +uint32_t radio_get_rng_seed() { + return radio.random(0x7FFFFFFF); +} + +void radio_set_params(float freq, float bw, uint8_t sf, uint8_t cr) { + radio.setFrequency(freq); + radio.setSpreadingFactor(sf); + radio.setBandwidth(bw); + radio.setCodingRate(cr); +} + +void radio_set_tx_power(int8_t dbm) { + radio.setOutputPower(dbm); +} + +mesh::LocalIdentity radio_new_identity() { + RadioNoiseListener rng(radio); + return mesh::LocalIdentity(&rng); +} diff --git a/variants/lilygo_teth_elite/target.h b/variants/lilygo_teth_elite/target.h new file mode 100644 index 0000000000..a842186cf6 --- /dev/null +++ b/variants/lilygo_teth_elite/target.h @@ -0,0 +1,20 @@ +#pragma once + +#define RADIOLIB_STATIC_ONLY 1 +#include +#include +#include +#include +#include +#include "TETHEliteBoard.h" + +extern TETHEliteBoard board; +extern WRAPPER_CLASS radio_driver; +extern AutoDiscoverRTCClock rtc_clock; +extern SensorManager sensors; + +bool radio_init(); +uint32_t radio_get_rng_seed(); +void radio_set_params(float freq, float bw, uint8_t sf, uint8_t cr); +void radio_set_tx_power(int8_t dbm); +mesh::LocalIdentity radio_new_identity(); From da9455f1448ec5d74f0b2cb1550f3e9be2ce6d65 Mon Sep 17 00:00:00 2001 From: AI7NC <77077873+AI7NC@users.noreply.github.com> Date: Tue, 12 May 2026 12:42:33 -0700 Subject: [PATCH 05/12] Update cli_commands.md to include 'ver' Include the 'ver' command for retrieving the firmware version --- docs/cli_commands.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 3255fe20c5..dff55cd3c1 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -405,6 +405,11 @@ This document provides an overview of CLI commands that can be sent to MeshCore --- +#### View this node's firmware version +**Usage:** `ver` + +--- + #### View this node's configured role **Usage:** `get role` From 8cc5299c70c4e7e81f73bfb61890ea6c82f4feb6 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Mon, 11 May 2026 17:24:09 -0600 Subject: [PATCH 06/12] fix(mesh): widen TRACE offset to uint16 to avoid narrowing --- src/Mesh.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Mesh.cpp b/src/Mesh.cpp index 7252974a92..87ad61af2e 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -50,7 +50,9 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { uint8_t path_sz = flags & 0x03; // NEW v1.11+: lower 2 bits is path hash size uint8_t len = pkt->payload_len - i; - uint8_t offset = pkt->path_len << path_sz; + // path_len*entry_size can exceed 255 (path_len up to 63, entry_size up to 8); + // a uint8_t offset would wrap and steer the isHashMatch() read to the wrong place. + uint16_t offset = (uint16_t)pkt->path_len << path_sz; if (offset >= len) { // TRACE has reached end of given path onTraceRecv(pkt, trace_tag, auth_code, flags, pkt->path, &pkt->payload[i], len); } else if (self_id.isHashMatch(&pkt->payload[i + offset], 1 << path_sz) && allowPacketForward(pkt) && !_tables->hasSeen(pkt)) { From 71b5015579a2a508f9a9b8019ad7399ef1c5c0b5 Mon Sep 17 00:00:00 2001 From: Rastislav Vysoky Date: Fri, 15 May 2026 16:42:29 +0200 Subject: [PATCH 07/12] Change MeshCore intro video link to The Comms Channel's MC intro playlist --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0ef096db1a..868ecf67b0 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ MeshCore provides the ability to create wireless mesh networks, similar to Mesht ## 🚀 How to Get Started -- Watch the [MeshCore Intro Video](https://www.youtube.com/watch?v=t1qne8uJBAc) by Andy Kirby. +- Watch the [MeshCore QuickStart Playlist](https://www.youtube.com/watch?v=iaFltojJrAc&list=PLshzThxhw4O4WU_iZo3NmNZOv6KMrUuF9) by The Comms Channel - Watch the [MeshCore Technical Presentation](https://www.youtube.com/watch?v=OwmkVkZQTf4) by Liam Cottle. - Read through our [Frequently Asked Questions](./docs/faq.md) and [Documentation](https://docs.meshcore.io). - Flash the MeshCore firmware on a supported device. From 5902f79d1353d13ba97913f2c73d396be56975aa Mon Sep 17 00:00:00 2001 From: zfouts Date: Mon, 25 May 2026 16:34:46 -0500 Subject: [PATCH 08/12] Add WiFi provisioning, web admin UI, MQTT publisher, and chat decoder for Xiao S3 WIO New helpers under src/helpers/esp32/: - WifiProvisioning.{h,cpp}: AP-first captive portal (SSID MeshCore-Setup-XXXX, password meshcore123), NVS-persisted STA credentials in namespace mc-wifi, 3-attempt STA fallback to AP, USER_BTN long-press wipe, defensive WiFi.disconnect + setAutoReconnect to recover from first-attempt auth fails seen on some routers. - WifiAdminUI.{h,cpp}: AsyncWebServer admin UI with WebSocket packet feed at /ws, chat panel for decoded GRP_TXT messages, console (CLI over /api/cmd), status/stats/radio/packets/neighbours/radio-config endpoints, /channels page (manage group PSKs), /blacklist page (wildcard name patterns to drop ADVERTs from), inline editors for TX power / coding rate / path hash mode. Shared /style.css with dark-mode CSS variables; sub-pages link to it. - WifiCliBridge.{h,cpp}: line-based TCP CLI server on port 5050, routes CR-terminated lines through handleCommand. - MqttPublisher.{h,cpp}: PubSubClient-based publisher with 16-slot async queue (drops oldest under burst), NVS broker config in namespace mc-mqtt, publishes RX (logRxRaw) and TX (logTx) packets to /rx and /tx as JSON with rssi/snr/raw-hex. Role wiring: - examples/companion_radio/main.cpp: WIFI_PROVISIONING gate replaces the baked WIFI_SSID/WIFI_PWD WiFi.begin() path; SerialWifiInterface still runs on TCP 5000 once STA is up. Optional WIFI_SSID/WIFI_PWD become bootstrap defaults seeded into NVS on first boot. - examples/simple_repeater/main.cpp: full wiring of provisioning, admin UI, TCP CLI bridge, and MQTT publisher under WIFI_PROVISIONING + MQTT_PUBLISHER. _RepeaterMeshInfo adapter delegates listChannels/addChannel/listBlocked/ addBlocked/removeBlocked/formatRadioConfig to MyMesh. - examples/simple_repeater/MyMesh.{h,cpp}: chat decoder (Public channel pre-configured with the well-known PSK; user channels added via /channels and stored in NVS namespace mc-chans). NVS-persisted forwarding blacklist in namespace mc-block with glob patterns (* and ?) matched against advertised names; hits drop the ADVERT in allowPacketForward and skip neighbour-table insert in onAdvertRecv. logRxRaw and logTx hook calls to wifiAdmin* and mqtt* extern functions. - examples/simple_room_server/main.cpp: WIFI_PROVISIONING brings up provisioning, admin UI, and TCP CLI bridge (no MQTT, no chat decode). Infrastructure: - src/helpers/ESP32Board.cpp: move legacy OTA AsyncWebServer from port 80 to 8306 so it doesn't collide with provisioning's port 80 server when both are running. - variants/xiao_s3_wio/platformio.ini: override LoRa region defaults from EU (869.618 MHz, SF8) to USA/Canada (910.525 MHz, SF7) at the Xiao_S3_WIO base via build_unflags. Modify Xiao_S3_WIO_companion_radio_wifi to use WIFI_PROVISIONING instead of compile-time SSID/PWD. New envs Xiao_S3_WIO_repeater_wifi (with MQTT_PUBLISHER + densaugeo/base64 + PubSubClient deps) and Xiao_S3_WIO_room_server_wifi. Hardware-validated on a Seeed XIAO ESP32-S3 + Wio-SX1262: provisioning, STA reconnect, web admin, chat decoding from Public channel, blacklist, and live editing of TX power / coding rate / path hash mode all work. MQTT publishing and full Companion/Room Server boots are code-compiled but not yet hardware-tested. --- examples/companion_radio/main.cpp | 27 +- examples/simple_repeater/MyMesh.cpp | 283 +++++++++++ examples/simple_repeater/MyMesh.h | 32 ++ examples/simple_repeater/main.cpp | 89 ++++ examples/simple_room_server/main.cpp | 54 ++ src/helpers/ESP32Board.cpp | 4 +- src/helpers/esp32/MqttPublisher.cpp | 278 ++++++++++ src/helpers/esp32/MqttPublisher.h | 85 ++++ src/helpers/esp32/WifiAdminUI.cpp | 672 +++++++++++++++++++++++++ src/helpers/esp32/WifiAdminUI.h | 67 +++ src/helpers/esp32/WifiCliBridge.cpp | 58 +++ src/helpers/esp32/WifiCliBridge.h | 31 ++ src/helpers/esp32/WifiProvisioning.cpp | 294 +++++++++++ src/helpers/esp32/WifiProvisioning.h | 82 +++ variants/xiao_s3_wio/platformio.ini | 55 +- 15 files changed, 2105 insertions(+), 6 deletions(-) create mode 100644 src/helpers/esp32/MqttPublisher.cpp create mode 100644 src/helpers/esp32/MqttPublisher.h create mode 100644 src/helpers/esp32/WifiAdminUI.cpp create mode 100644 src/helpers/esp32/WifiAdminUI.h create mode 100644 src/helpers/esp32/WifiCliBridge.cpp create mode 100644 src/helpers/esp32/WifiCliBridge.h create mode 100644 src/helpers/esp32/WifiProvisioning.cpp create mode 100644 src/helpers/esp32/WifiProvisioning.h diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 31923543fd..002500b636 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -35,12 +35,28 @@ static uint32_t _atoi(const char* sp) { #endif #ifdef ESP32 - #ifdef WIFI_SSID + #if defined(WIFI_PROVISIONING) || defined(WIFI_SSID) #include SerialWifiInterface serial_interface; #ifndef TCP_PORT #define TCP_PORT 5000 #endif + #ifdef WIFI_PROVISIONING + #include + static WifiProvisioning::Config _wifi_cfg = []() { + WifiProvisioning::Config c; + c.ap_password = "meshcore123"; + #ifdef PIN_USER_BTN + c.user_btn_pin = PIN_USER_BTN; + #endif + #ifdef WIFI_SSID + c.bootstrap_ssid = WIFI_SSID; + c.bootstrap_password = WIFI_PWD; + #endif + return c; + }(); + WifiProvisioning wifi_provisioning(_wifi_cfg); + #endif #elif defined(BLE_PIN_CODE) #include SerialBLEInterface serial_interface; @@ -199,7 +215,11 @@ void setup() { #endif ); -#ifdef WIFI_SSID +#if defined(WIFI_PROVISIONING) + board.setInhibitSleep(true); // prevent sleep when WiFi is active + wifi_provisioning.begin(); + serial_interface.begin(TCP_PORT); +#elif defined(WIFI_SSID) board.setInhibitSleep(true); // prevent sleep when WiFi is active WiFi.setAutoReconnect(true); @@ -247,6 +267,9 @@ void loop() { sensors.loop(); #ifdef DISPLAY_CLASS ui_task.loop(); +#endif +#ifdef WIFI_PROVISIONING + wifi_provisioning.loop(); #endif rtc_clock.tick(); diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 1f68c6f2a0..9c62934745 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -1,6 +1,14 @@ #include "MyMesh.h" #include +#if defined(ESP_PLATFORM) && defined(WIFI_PROVISIONING) + #include + #include + // Well-known PSK for MeshCore's default "Public" group channel. + // Sourced from companion_radio/MyMesh.cpp. + #define REPEATER_PUBLIC_GROUP_PSK "izOH6cXN6mrJ5e26oRXNcg==" +#endif + /* ------------------------------ Config -------------------------------- */ #ifndef LORA_FREQ @@ -428,6 +436,25 @@ void MyMesh::sendFloodReply(mesh::Packet* packet, unsigned long delay_millis, ui bool MyMesh::allowPacketForward(const mesh::Packet *packet) { if (_prefs.disable_fwd) return false; + +#if defined(ESP_PLATFORM) && defined(WIFI_PROVISIONING) + // Drop ADVERTs from blacklisted node names. + if (_num_block_patterns > 0 && packet->getPayloadType() == PAYLOAD_TYPE_ADVERT) { + const int app_off = PUB_KEY_SIZE + 4 + SIGNATURE_SIZE; + if (packet->payload_len > app_off) { + AdvertDataParser ap(&packet->payload[app_off], packet->payload_len - app_off); + if (ap.isValid() && ap.hasName()) { + int hit = _matchesBlockPattern(ap.getName()); + if (hit >= 0) { + _block_hits[hit]++; + MESH_DEBUG_PRINTLN("blacklist: dropping ADVERT from '%s' (pattern '%s')", ap.getName(), _block_patterns[hit]); + return false; + } + } + } + } +#endif + if (packet->isRouteFlood() && packet->getPathHashCount() >= _prefs.flood_max) return false; if (packet->isRouteFlood() && recv_pkt_region == NULL) { MESH_DEBUG_PRINTLN("allowPacketForward: unknown transport code, or wildcard not allowed for FLOOD packet"); @@ -466,6 +493,14 @@ void MyMesh::logRxRaw(float snr, float rssi, const uint8_t raw[], int len) { mesh::Utils::printHex(Serial, raw, len); Serial.println(); #endif +#if defined(ESP_PLATFORM) && defined(WIFI_PROVISIONING) + extern void wifiAdminPushRxPacket(float snr, float rssi, const uint8_t* raw, int len); + wifiAdminPushRxPacket(snr, rssi, raw, len); +#endif +#if defined(ESP_PLATFORM) && defined(MQTT_PUBLISHER) + extern void mqttPublishRawPacket(float snr, float rssi, const uint8_t* raw, int len); + mqttPublishRawPacket(snr, rssi, raw, len); +#endif } void MyMesh::logRx(mesh::Packet *pkt, int len, float score) { @@ -501,6 +536,23 @@ void MyMesh::logTx(mesh::Packet *pkt, int len) { } #endif +#if (defined(ESP_PLATFORM) && (defined(MQTT_PUBLISHER) || defined(WIFI_PROVISIONING))) + { + uint8_t buf[256]; + uint8_t wire_len = pkt->writeTo(buf); + if (wire_len > 0) { + #if defined(WIFI_PROVISIONING) + extern void wifiAdminPushTxPacket(const uint8_t* raw, int len); + wifiAdminPushTxPacket(buf, wire_len); + #endif + #if defined(MQTT_PUBLISHER) + extern void mqttPublishTxPacket(const uint8_t* raw, int len); + mqttPublishTxPacket(buf, wire_len); + #endif + } + } +#endif + if (_logging) { File f = openAppend(PACKET_LOG_FILE); if (f) { @@ -632,6 +684,16 @@ static bool isShare(const mesh::Packet *packet) { void MyMesh::onAdvertRecv(mesh::Packet *packet, const mesh::Identity &id, uint32_t timestamp, const uint8_t *app_data, size_t app_data_len) { +#if defined(ESP_PLATFORM) && defined(WIFI_PROVISIONING) + // If this advert matches the blacklist, skip neighbour-table insertion entirely. + // allowPacketForward will also drop the forwarding side; this prevents stats churn. + if (_num_block_patterns > 0) { + AdvertDataParser ap(app_data, app_data_len); + if (ap.isValid() && ap.hasName() && _matchesBlockPattern(ap.getName()) >= 0) { + return; + } + } +#endif mesh::Mesh::onAdvertRecv(packet, id, timestamp, app_data, app_data_len); // chain to super impl // if this a zero hop advert (and not via 'Share'), add it to neighbours @@ -918,6 +980,221 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc memset(default_scope.key, 0, sizeof(default_scope.key)); } +#if defined(ESP_PLATFORM) && defined(WIFI_PROVISIONING) +namespace { + // Stash the base64 PSK alongside the in-memory channel so we can re-serialize + // user-added channels back to NVS without round-tripping bytes through base64. + // Kept as a parallel static array to avoid changing GroupChannel's footprint. + char _chat_chan_psks[8][48]; // 8 == MAX_CHAT_CHANS + bool _chat_chan_persistent[8] = {false}; // true for user-added (NVS-stored) +} + +bool MyMesh::addChatChannel(const char* name, const char* psk_base64) { + if (_num_chat_chans >= MAX_CHAT_CHANS) return false; + // Reject duplicate names so the table stays sane after repeated saves. + for (int i = 0; i < _num_chat_chans; i++) { + if (strcmp(_chat_chan_names[i], name) == 0) return false; + } + mesh::GroupChannel& ch = _chat_chans[_num_chat_chans]; + memset(ch.secret, 0, sizeof(ch.secret)); + int len = decode_base64((unsigned char*)psk_base64, strlen(psk_base64), ch.secret); + if (len != 16 && len != 32) return false; + mesh::Utils::sha256(ch.hash, sizeof(ch.hash), ch.secret, len); + strncpy(_chat_chan_names[_num_chat_chans], name, sizeof(_chat_chan_names[0]) - 1); + _chat_chan_names[_num_chat_chans][sizeof(_chat_chan_names[0]) - 1] = 0; + strncpy(_chat_chan_psks[_num_chat_chans], psk_base64, sizeof(_chat_chan_psks[0]) - 1); + _chat_chan_psks[_num_chat_chans][sizeof(_chat_chan_psks[0]) - 1] = 0; + _chat_chan_persistent[_num_chat_chans] = false; + _num_chat_chans++; + return true; +} + +void MyMesh::loadPersistentChatChannels() { + Preferences p; + p.begin("mc-chans", true); + String blob = p.getString("list", ""); + p.end(); + // Format: "name1|psk1\nname2|psk2\n..." + int start = 0; + while (start < (int)blob.length() && _num_chat_chans < MAX_CHAT_CHANS) { + int nl = blob.indexOf('\n', start); + if (nl < 0) nl = blob.length(); + int bar = blob.indexOf('|', start); + if (bar > start && bar < nl) { + String name = blob.substring(start, bar); + String psk = blob.substring(bar + 1, nl); + if (addChatChannel(name.c_str(), psk.c_str())) { + _chat_chan_persistent[_num_chat_chans - 1] = true; + } + } + start = nl + 1; + } +} + +bool MyMesh::addPersistentChatChannel(const char* name, const char* psk_base64) { + if (!addChatChannel(name, psk_base64)) return false; + _chat_chan_persistent[_num_chat_chans - 1] = true; + // Re-serialize all persistent channels. + String blob; + for (int i = 0; i < _num_chat_chans; i++) { + if (!_chat_chan_persistent[i]) continue; + blob += _chat_chan_names[i]; blob += '|'; blob += _chat_chan_psks[i]; blob += '\n'; + } + Preferences p; + p.begin("mc-chans", false); + p.putString("list", blob); + p.end(); + return true; +} + +namespace { +// Simple glob matcher: '*' matches any run of chars, '?' matches any single char. +bool _wildMatch(const char* pat, const char* s) { + if (!*pat) return !*s; + if (*pat == '*') { + while (*pat == '*') pat++; + if (!*pat) return true; + while (*s) { + if (_wildMatch(pat, s)) return true; + s++; + } + return false; + } + if (!*s) return false; + if (*pat == '?' || *pat == *s) return _wildMatch(pat + 1, s + 1); + return false; +} +} + +int MyMesh::_matchesBlockPattern(const char* name) { + if (!name || !*name) return -1; + for (int i = 0; i < _num_block_patterns; i++) { + if (_wildMatch(_block_patterns[i], name)) return i; + } + return -1; +} + +bool MyMesh::addBlockPattern(const char* pattern) { + if (!pattern || !*pattern) return false; + if (_num_block_patterns >= MAX_BLOCK_PATTERNS) return false; + for (int i = 0; i < _num_block_patterns; i++) { + if (strcmp(_block_patterns[i], pattern) == 0) return false; // duplicate + } + strncpy(_block_patterns[_num_block_patterns], pattern, sizeof(_block_patterns[0]) - 1); + _block_patterns[_num_block_patterns][sizeof(_block_patterns[0]) - 1] = 0; + _block_hits[_num_block_patterns] = 0; + _num_block_patterns++; + // Re-serialize to NVS. + String blob; + for (int i = 0; i < _num_block_patterns; i++) { blob += _block_patterns[i]; blob += '\n'; } + Preferences p; p.begin("mc-block", false); p.putString("list", blob); p.end(); + return true; +} + +bool MyMesh::removeBlockPattern(const char* pattern) { + if (!pattern) return false; + for (int i = 0; i < _num_block_patterns; i++) { + if (strcmp(_block_patterns[i], pattern) == 0) { + for (int j = i; j < _num_block_patterns - 1; j++) { + memcpy(_block_patterns[j], _block_patterns[j + 1], sizeof(_block_patterns[0])); + _block_hits[j] = _block_hits[j + 1]; + } + _num_block_patterns--; + String blob; + for (int k = 0; k < _num_block_patterns; k++) { blob += _block_patterns[k]; blob += '\n'; } + Preferences p; p.begin("mc-block", false); p.putString("list", blob); p.end(); + return true; + } + } + return false; +} + +void MyMesh::loadBlockPatterns() { + Preferences p; p.begin("mc-block", true); + String blob = p.getString("list", ""); + p.end(); + int start = 0; + while (start < (int)blob.length() && _num_block_patterns < MAX_BLOCK_PATTERNS) { + int nl = blob.indexOf('\n', start); + if (nl < 0) nl = blob.length(); + if (nl > start) { + String pat = blob.substring(start, nl); + strncpy(_block_patterns[_num_block_patterns], pat.c_str(), sizeof(_block_patterns[0]) - 1); + _block_patterns[_num_block_patterns][sizeof(_block_patterns[0]) - 1] = 0; + _block_hits[_num_block_patterns] = 0; + _num_block_patterns++; + } + start = nl + 1; + } +} + +void MyMesh::listBlockPatterns(char* out, size_t out_size) { + char* dp = out; size_t remain = out_size; + for (int i = 0; i < _num_block_patterns && remain > 16; i++) { + int n = snprintf(dp, remain, "%s|%u\n", _block_patterns[i], (unsigned)_block_hits[i]); + if (n <= 0 || (size_t)n >= remain) break; + dp += n; remain -= n; + } + *dp = 0; +} + +void MyMesh::listChatChannels(char* out, size_t out_size) { + char* dp = out; + size_t remain = out_size; + for (int i = 0; i < _num_chat_chans && remain > 16; i++) { + int n = snprintf(dp, remain, "%s|%02x|%c\n", + _chat_chan_names[i], + (unsigned)_chat_chans[i].hash[0], + _chat_chan_persistent[i] ? 'p' : 'b'); + if (n <= 0 || (size_t)n >= remain) break; + dp += n; remain -= n; + } + *dp = 0; +} + +int MyMesh::searchChannelsByHash(const uint8_t* hash, mesh::GroupChannel channels[], int max_matches) { + int n = 0; + for (int i = 0; i < _num_chat_chans && n < max_matches; i++) { + if (memcmp(_chat_chans[i].hash, hash, PATH_HASH_SIZE) == 0) { + channels[n++] = _chat_chans[i]; + } + } + return n; +} + +void MyMesh::onGroupDataRecv(mesh::Packet* packet, uint8_t type, const mesh::GroupChannel& channel, uint8_t* data, size_t len) { + if (type != PAYLOAD_TYPE_GRP_TXT || len < 5) return; + uint8_t txt_type = data[4] >> 2; + if (txt_type != 0) return; // only plain text for now + + uint32_t timestamp; + memcpy(×tamp, data, 4); + data[len] = 0; // null-terminate (Mesh.cpp's decrypt buffer has the slack) + const char* body = (const char*)&data[5]; + + // Resolve channel name by comparing secret back to our table. + const char* chan_name = "?"; + for (int i = 0; i < _num_chat_chans; i++) { + if (memcmp(_chat_chans[i].secret, channel.secret, sizeof(channel.secret)) == 0) { + chan_name = _chat_chan_names[i]; break; + } + } + + // Split "Sender: text" if present. + const char* sep = strstr(body, ": "); + char sender[24]; sender[0] = 0; + const char* text = body; + if (sep && (sep - body) < (int)sizeof(sender)) { + size_t n = sep - body; + memcpy(sender, body, n); sender[n] = 0; + text = sep + 2; + } + + extern void wifiAdminPushChat(const char* channel, const char* sender, const char* text, uint32_t timestamp); + wifiAdminPushChat(chan_name, sender, text, timestamp); +} +#endif + void MyMesh::begin(FILESYSTEM *fs) { mesh::Mesh::begin(); _fs = fs; @@ -968,6 +1245,12 @@ void MyMesh::begin(FILESYSTEM *fs) { #if ENV_INCLUDE_GPS == 1 applyGpsPrefs(); #endif + +#if defined(ESP_PLATFORM) && defined(WIFI_PROVISIONING) + addChatChannel("Public", REPEATER_PUBLIC_GROUP_PSK); + loadPersistentChatChannels(); + loadBlockPatterns(); +#endif } void MyMesh::sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis, uint8_t path_hash_size) { diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 8ed0317e69..44104e47d0 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -165,6 +165,25 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { bool filterRecvFloodPacket(mesh::Packet* pkt) override; +#if defined(ESP_PLATFORM) && defined(WIFI_PROVISIONING) + // Decoded-chat support: store group-channel PSKs so the repeater can decrypt + // public/hashtag-channel GRP_TXT frames it observes. + static constexpr int MAX_CHAT_CHANS = 8; + int _num_chat_chans = 0; + mesh::GroupChannel _chat_chans[MAX_CHAT_CHANS]; + char _chat_chan_names[MAX_CHAT_CHANS][24]; + int searchChannelsByHash(const uint8_t* hash, mesh::GroupChannel channels[], int max_matches) override; + void onGroupDataRecv(mesh::Packet* packet, uint8_t type, const mesh::GroupChannel& channel, uint8_t* data, size_t len) override; + + // Name-pattern blacklist: drop ADVERTs whose advertised name matches any pattern. + // Patterns are simple globs ('*' and '?'). Persisted in NVS namespace mc-block. + static constexpr int MAX_BLOCK_PATTERNS = 8; + char _block_patterns[MAX_BLOCK_PATTERNS][32]; + uint32_t _block_hits[MAX_BLOCK_PATTERNS] = {0}; + int _num_block_patterns = 0; + int _matchesBlockPattern(const char* name); // returns pattern idx or -1 +#endif + void onAnonDataRecv(mesh::Packet* packet, const uint8_t* secret, const mesh::Identity& sender, uint8_t* data, size_t len) override; int searchPeersByHash(const uint8_t* hash) override; void getPeerSharedSecret(uint8_t* dest_secret, int peer_idx) override; @@ -188,6 +207,19 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { return &_prefs; } +#if defined(ESP_PLATFORM) && defined(WIFI_PROVISIONING) + bool addChatChannel(const char* name, const char* psk_base64); + bool addPersistentChatChannel(const char* name, const char* psk_base64); + void listChatChannels(char* out, size_t out_size); + void loadPersistentChatChannels(); + + // Name-pattern blacklist API (user-facing). + bool addBlockPattern(const char* pattern); + bool removeBlockPattern(const char* pattern); + void listBlockPatterns(char* out, size_t out_size); // "pat|hits\n" lines + void loadBlockPatterns(); +#endif + void savePrefs() override { _cli.savePrefs(_fs); } diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index 297337ab5c..81dadcc671 100644 --- a/examples/simple_repeater/main.cpp +++ b/examples/simple_repeater/main.cpp @@ -13,6 +13,66 @@ SimpleMeshTables tables; MyMesh the_mesh(board, radio_driver, *new ArduinoMillis(), fast_rng, rtc_clock, tables); +#if defined(ESP32) && defined(WIFI_PROVISIONING) + #include + #include + #include + #ifdef MQTT_PUBLISHER + #include + #endif + static WifiProvisioning::Config _wifi_cfg = []() { + WifiProvisioning::Config c; + c.ap_password = "meshcore123"; + #ifdef PIN_USER_BTN + c.user_btn_pin = PIN_USER_BTN; + #endif + #ifdef WIFI_SSID + c.bootstrap_ssid = WIFI_SSID; + c.bootstrap_password = WIFI_PWD; + #endif + return c; + }(); + WifiProvisioning wifi_provisioning(_wifi_cfg); + + class _RepeaterMeshInfo : public MeshInfoProvider { + public: + const char* nodeName() override { return the_mesh.getNodeName(); } + const char* role() override { return the_mesh.getRole(); } + const char* firmwareVer() override { return the_mesh.getFirmwareVer(); } + void formatNeighbours(char* out) override { the_mesh.formatNeighborsReply(out); } + void formatStats(char* out) override { the_mesh.formatStatsReply(out); } + void formatRadioStats(char* out) override { the_mesh.formatRadioStatsReply(out); } + void formatPacketStats(char* out) override { the_mesh.formatPacketStatsReply(out); } + void listChannels(char* out) override { the_mesh.listChatChannels(out, 512); } + bool addChannel(const char* name, const char* psk_base64) override { + return the_mesh.addPersistentChatChannel(name, psk_base64); + } + void listBlocked(char* out) override { the_mesh.listBlockPatterns(out, 512); } + bool addBlocked(const char* pattern) override { return the_mesh.addBlockPattern(pattern); } + bool removeBlocked(const char* pattern) override { return the_mesh.removeBlockPattern(pattern); } + void formatRadioConfig(char* out) override { + NodePrefs* p = the_mesh.getNodePrefs(); + sprintf(out, + "{\"freq\":%.3f,\"bw\":%.1f,\"sf\":%u,\"cr\":%u,\"tx_power\":%d," + "\"path_hash_mode\":%u,\"path_hash_bytes\":%u,\"rx_boosted\":%u}", + p->freq, p->bw, (unsigned)p->sf, (unsigned)p->cr, (int)p->tx_power_dbm, + (unsigned)p->path_hash_mode, (unsigned)(p->path_hash_mode + 1), + (unsigned)p->rx_boosted_gain); + } + }; + static _RepeaterMeshInfo _mesh_info; + static WifiAdminUI* admin_ui = nullptr; + + static void _cli_adapter(const char* cmd, char* reply) { + char buf[256]; + strncpy(buf, cmd, sizeof(buf) - 1); + buf[sizeof(buf) - 1] = 0; + the_mesh.handleCommand(0, buf, reply); + } + static WifiCliBridge cli_bridge(5050, _cli_adapter); + // Same adapter wired into the web admin UI's /api/cmd route. +#endif + void halt() { while (1) ; } @@ -99,6 +159,26 @@ void setup() { ui_task.begin(the_mesh.getNodePrefs(), FIRMWARE_BUILD_DATE, FIRMWARE_VERSION); #endif +#if defined(ESP32) && defined(WIFI_PROVISIONING) + board.setInhibitSleep(true); // WiFi dies when board sleeps + wifi_provisioning.begin(); + if (wifi_provisioning.inStaMode() && wifi_provisioning.webServer()) { + admin_ui = new WifiAdminUI(wifi_provisioning.webServer(), &_mesh_info, _cli_adapter); + admin_ui->begin(); + cli_bridge.begin(); + #ifdef MQTT_PUBLISHER + mqttPublisher().attachWebRoutes(wifi_provisioning.webServer()); + mqttPublisher().begin(); + #endif + } + #ifdef MQTT_PUBLISHER + else if (wifi_provisioning.webServer()) { + // AP-mode setup: still let user configure MQTT for after they connect. + mqttPublisher().attachWebRoutes(wifi_provisioning.webServer()); + } + #endif +#endif + // send out initial zero hop Advertisement to the mesh #if ENABLE_ADVERT_ON_BOOT == 1 the_mesh.sendSelfAdvertisement(16000, false); @@ -153,6 +233,15 @@ void loop() { sensors.loop(); #ifdef DISPLAY_CLASS ui_task.loop(); +#endif +#if defined(ESP32) && defined(WIFI_PROVISIONING) + wifi_provisioning.loop(); + if (wifi_provisioning.inStaMode()) { + cli_bridge.loop(); + #ifdef MQTT_PUBLISHER + mqttPublisher().loop(); + #endif + } #endif rtc_clock.tick(); diff --git a/examples/simple_room_server/main.cpp b/examples/simple_room_server/main.cpp index a3798b2175..7834f765da 100644 --- a/examples/simple_room_server/main.cpp +++ b/examples/simple_room_server/main.cpp @@ -12,6 +12,46 @@ StdRNG fast_rng; SimpleMeshTables tables; MyMesh the_mesh(board, radio_driver, *new ArduinoMillis(), fast_rng, rtc_clock, tables); +#if defined(ESP32) && defined(WIFI_PROVISIONING) + #include + #include + #include + static WifiProvisioning::Config _wifi_cfg = []() { + WifiProvisioning::Config c; + c.ap_password = "meshcore123"; + #ifdef PIN_USER_BTN + c.user_btn_pin = PIN_USER_BTN; + #endif + #ifdef WIFI_SSID + c.bootstrap_ssid = WIFI_SSID; + c.bootstrap_password = WIFI_PWD; + #endif + return c; + }(); + WifiProvisioning wifi_provisioning(_wifi_cfg); + + class _RoomMeshInfo : public MeshInfoProvider { + public: + const char* nodeName() override { return the_mesh.getNodeName(); } + const char* role() override { return the_mesh.getRole(); } + const char* firmwareVer() override { return the_mesh.getFirmwareVer(); } + void formatNeighbours(char* out) override { the_mesh.formatNeighborsReply(out); } + void formatStats(char* out) override { the_mesh.formatStatsReply(out); } + void formatRadioStats(char* out) override { the_mesh.formatRadioStatsReply(out); } + void formatPacketStats(char* out) override { the_mesh.formatPacketStatsReply(out); } + }; + static _RoomMeshInfo _mesh_info; + static WifiAdminUI* admin_ui = nullptr; + + static void _cli_adapter(const char* cmd, char* reply) { + char buf[256]; + strncpy(buf, cmd, sizeof(buf) - 1); + buf[sizeof(buf) - 1] = 0; + the_mesh.handleCommand(0, buf, reply); + } + static WifiCliBridge cli_bridge(5050, _cli_adapter); +#endif + void halt() { while (1) ; } @@ -76,6 +116,16 @@ void setup() { ui_task.begin(the_mesh.getNodePrefs(), FIRMWARE_BUILD_DATE, FIRMWARE_VERSION); #endif +#if defined(ESP32) && defined(WIFI_PROVISIONING) + board.setInhibitSleep(true); // WiFi dies when board sleeps + wifi_provisioning.begin(); + if (wifi_provisioning.inStaMode() && wifi_provisioning.webServer()) { + admin_ui = new WifiAdminUI(wifi_provisioning.webServer(), &_mesh_info); + admin_ui->begin(); + cli_bridge.begin(); + } +#endif + // send out initial zero hop Advertisement to the mesh #if ENABLE_ADVERT_ON_BOOT == 1 the_mesh.sendSelfAdvertisement(16000, false); @@ -113,6 +163,10 @@ void loop() { sensors.loop(); #ifdef DISPLAY_CLASS ui_task.loop(); +#endif +#if defined(ESP32) && defined(WIFI_PROVISIONING) + wifi_provisioning.loop(); + if (wifi_provisioning.inStaMode()) cli_bridge.loop(); #endif rtc_clock.tick(); } diff --git a/src/helpers/ESP32Board.cpp b/src/helpers/ESP32Board.cpp index e0ca1d0eeb..3656e2ee2a 100644 --- a/src/helpers/ESP32Board.cpp +++ b/src/helpers/ESP32Board.cpp @@ -14,7 +14,7 @@ bool ESP32Board::startOTAUpdate(const char* id, char reply[]) { inhibit_sleep = true; // prevent sleep during OTA WiFi.softAP("MeshCore-OTA", NULL); - sprintf(reply, "Started: http://%s/update", WiFi.softAPIP().toString().c_str()); + sprintf(reply, "Started: http://%s:8306/update", WiFi.softAPIP().toString().c_str()); MESH_DEBUG_PRINTLN("startOTAUpdate: %s", reply); static char id_buf[60]; @@ -22,7 +22,7 @@ bool ESP32Board::startOTAUpdate(const char* id, char reply[]) { static char home_buf[90]; sprintf(home_buf, "

Hi! I am a MeshCore Repeater. ID: %s

", id); - AsyncWebServer* server = new AsyncWebServer(80); + AsyncWebServer* server = new AsyncWebServer(8306); server->on("/", HTTP_GET, [](AsyncWebServerRequest *request) { request->send(200, "text/html", home_buf); diff --git a/src/helpers/esp32/MqttPublisher.cpp b/src/helpers/esp32/MqttPublisher.cpp new file mode 100644 index 0000000000..6779026548 --- /dev/null +++ b/src/helpers/esp32/MqttPublisher.cpp @@ -0,0 +1,278 @@ +#if defined(ESP_PLATFORM) && defined(MQTT_PUBLISHER) + +#include "MqttPublisher.h" +#include + +#ifdef WIFI_DEBUG_LOGGING + #define MQTT_LOG(fmt, ...) Serial.printf("[mqtt] " fmt "\n", ##__VA_ARGS__) +#else + #define MQTT_LOG(fmt, ...) do {} while (0) +#endif + +namespace { +const char SETUP_HTML[] PROGMEM = R"HTML( + +MeshCore MQTT +
+
+

MQTT publisher

+
forwards every RX and TX (repeated) mesh packet as JSON
+ +
+

Broker

+ Published to <topic>/rx and <topic>/tx. Leave host blank to disable. +
+
+
+
+
+ + + + +
+
+ Saving does not reconnect immediately — reboot from the home page to apply. +
+

Status

+)HTML"; +} // namespace + +MqttPublisher& mqttPublisher() { + static MqttPublisher _inst; + return _inst; +} + +void mqttPublishRawPacket(float snr, float rssi, const uint8_t* raw, int len) { + mqttPublisher().enqueueRawPacket(snr, rssi, raw, len); +} + +void mqttPublishTxPacket(const uint8_t* raw, int len) { + mqttPublisher().enqueueTxPacket(raw, len); +} + +MqttPublisher::MqttPublisher() : _client(_net) {} + +String MqttPublisher::_defaultTopic() { + // Base topic; per-direction suffix ("/rx" or "/tx") is appended at publish time. + uint8_t mac[6]; + WiFi.macAddress(mac); + char buf[40]; + snprintf(buf, sizeof(buf), "meshcore/%02x%02x%02x%02x%02x%02x", + mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + return String(buf); +} + +void MqttPublisher::_loadConfig() { + Preferences p; + p.begin("mc-mqtt", true); + _host = p.getString("host", ""); + _port = p.getUShort("port", 1883); + _user = p.getString("user", ""); + _pwd = p.getString("pwd", ""); + _topic = p.getString("topic", _defaultTopic()); + p.end(); + if (_client_id.length() == 0) { + uint8_t mac[6]; + WiFi.macAddress(mac); + char buf[24]; + snprintf(buf, sizeof(buf), "mc-%02x%02x%02x%02x%02x%02x", + mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + _client_id = buf; + } +} + +void MqttPublisher::_saveConfig(const String& host, uint16_t port, const String& user, + const String& pwd, const String& topic) { + Preferences p; + p.begin("mc-mqtt", false); + p.putString("host", host); + p.putUShort("port", port); + p.putString("user", user); + p.putString("pwd", pwd); + p.putString("topic", topic.length() ? topic : _defaultTopic()); + p.end(); +} + +void MqttPublisher::begin() { + _loadConfig(); + if (!isConfigured()) { + MQTT_LOG("not configured, idle"); + return; + } + _client.setServer(_host.c_str(), _port); + _client.setBufferSize(1024); + MQTT_LOG("configured: %s:%u topic=%s", _host.c_str(), _port, _topic.c_str()); +} + +bool MqttPublisher::_ensureConnected() { + if (_client.connected()) return true; + if (!isConfigured()) return false; + if (millis() - _last_attempt_ms < _retry_backoff_ms) return false; + _last_attempt_ms = millis(); + + bool ok; + if (_user.length() > 0) { + ok = _client.connect(_client_id.c_str(), _user.c_str(), _pwd.c_str()); + } else { + ok = _client.connect(_client_id.c_str()); + } + if (ok) { + MQTT_LOG("connected to %s:%u as %s", _host.c_str(), _port, _client_id.c_str()); + _retry_backoff_ms = 2000; + } else { + MQTT_LOG("connect failed, state=%d", _client.state()); + _retry_backoff_ms = min(_retry_backoff_ms * 2, 60000); + } + return ok; +} + +void MqttPublisher::enqueueRawPacket(float snr, float rssi, const uint8_t* raw, int len) { + if (!isConfigured()) return; + if (len < 0) return; + if ((size_t)len > sizeof(Entry::data)) len = sizeof(Entry::data); + + uint8_t next = (_q_head + 1) % QUEUE_LEN; + if (next == _q_tail) { + _q_tail = (_q_tail + 1) % QUEUE_LEN; + _dropped++; + } + Entry& e = _queue[_q_head]; + e.dir = Dir::RX; + e.ts = millis(); + e.snr = snr; + e.rssi = rssi; + e.len = (uint16_t)len; + memcpy(e.data, raw, len); + _q_head = next; +} + +void MqttPublisher::enqueueTxPacket(const uint8_t* raw, int len) { + if (!isConfigured()) return; + if (len < 0) return; + if ((size_t)len > sizeof(Entry::data)) len = sizeof(Entry::data); + + uint8_t next = (_q_head + 1) % QUEUE_LEN; + if (next == _q_tail) { + _q_tail = (_q_tail + 1) % QUEUE_LEN; + _dropped++; + } + Entry& e = _queue[_q_head]; + e.dir = Dir::TX; + e.ts = millis(); + e.snr = 0.0f; + e.rssi = 0; + e.len = (uint16_t)len; + memcpy(e.data, raw, len); + _q_head = next; +} + +void MqttPublisher::_publishOne() { + if (_q_head == _q_tail) return; + Entry& e = _queue[_q_tail]; + + char hex[2 * sizeof(Entry::data) + 1]; + static const char H[] = "0123456789abcdef"; + for (uint16_t i = 0; i < e.len; i++) { + hex[2 * i] = H[(e.data[i] >> 4) & 0xF]; + hex[2 * i + 1] = H[e.data[i] & 0xF]; + } + hex[2 * e.len] = 0; + + char payload[2 * sizeof(Entry::data) + 128]; + const bool is_rx = (e.dir == Dir::RX); + int n; + if (is_rx) { + n = snprintf(payload, sizeof(payload), + "{\"dir\":\"rx\",\"ts\":%u,\"rssi\":%d,\"snr\":%.2f,\"len\":%u,\"raw\":\"%s\"}", + (unsigned)e.ts, (int)e.rssi, e.snr, (unsigned)e.len, hex); + } else { + n = snprintf(payload, sizeof(payload), + "{\"dir\":\"tx\",\"ts\":%u,\"len\":%u,\"raw\":\"%s\"}", + (unsigned)e.ts, (unsigned)e.len, hex); + } + if (n <= 0 || (size_t)n >= sizeof(payload)) { + _publish_errs++; + _q_tail = (_q_tail + 1) % QUEUE_LEN; + return; + } + + String full_topic = _topic + (is_rx ? "/rx" : "/tx"); + if (_client.publish(full_topic.c_str(), payload)) { + _published++; + _q_tail = (_q_tail + 1) % QUEUE_LEN; + } else { + _publish_errs++; + // leave entry in queue; will retry on next loop tick after reconnect + } +} + +void MqttPublisher::loop() { + if (!isConfigured()) return; + if (WiFi.status() != WL_CONNECTED) return; + if (!_ensureConnected()) return; + _client.loop(); + // Drain a few per tick to keep up under bursts without hogging the loop. + for (int i = 0; i < 4 && _client.connected(); i++) { + if (_q_head == _q_tail) break; + _publishOne(); + } +} + +void MqttPublisher::attachWebRoutes(AsyncWebServer* server) { + if (!server) return; + + server->on("/mqtt-setup", HTTP_GET, [](AsyncWebServerRequest* req) { + req->send_P(200, "text/html", SETUP_HTML); + }); + + server->on("/mqtt-status", HTTP_GET, [this](AsyncWebServerRequest* req) { + String out = "{"; + out += "\"host\":\""; out += _host; out += "\","; + out += "\"port\":"; out += String(_port); out += ","; + out += "\"user\":\""; out += _user; out += "\","; + out += "\"topic\":\""; out += _topic; out += "\","; + out += "\"connected\":"; out += (_client.connected() ? "true" : "false"); out += ","; + out += "\"state\":"; out += String(_client.state()); out += ","; + out += "\"published\":"; out += String(_published); out += ","; + out += "\"dropped\":"; out += String(_dropped); out += ","; + out += "\"errors\":"; out += String(_publish_errs); out += ","; + out += "\"queue_depth\":"; out += String((uint8_t)((_q_head + QUEUE_LEN - _q_tail) % QUEUE_LEN)); + out += "}"; + req->send(200, "application/json", out); + }); + + server->on("/mqtt-save", HTTP_POST, [this](AsyncWebServerRequest* req) { + String host = req->hasParam("host", true) ? req->getParam("host", true)->value() : String(); + String puser = req->hasParam("user", true) ? req->getParam("user", true)->value() : String(); + String ppwd = req->hasParam("pwd", true) ? req->getParam("pwd", true)->value() : String(); + String topic = req->hasParam("topic", true) ? req->getParam("topic", true)->value() : String(); + uint16_t port = 1883; + if (req->hasParam("port", true)) { + long v = req->getParam("port", true)->value().toInt(); + if (v > 0 && v < 65536) port = (uint16_t)v; + } + _saveConfig(host, port, puser, ppwd, topic); + req->send(200, "text/plain", "ok"); + }); +} + +#endif // ESP_PLATFORM && MQTT_PUBLISHER diff --git a/src/helpers/esp32/MqttPublisher.h b/src/helpers/esp32/MqttPublisher.h new file mode 100644 index 0000000000..6d67933fe6 --- /dev/null +++ b/src/helpers/esp32/MqttPublisher.h @@ -0,0 +1,85 @@ +#pragma once + +#if defined(ESP_PLATFORM) && defined(MQTT_PUBLISHER) + +#include +#include +#include +#include + +// Publishes raw received mesh packets to an MQTT broker as JSON. +// Configuration is stored in NVS (Preferences namespace 'mc-mqtt') and +// editable via the /mqtt-setup web routes this module registers. +// +// Packets are enqueued from any context (e.g. radio rx callback) and drained +// from loop() so the MQTT publish call never blocks the mesh path. + +class MqttPublisher { +public: + MqttPublisher(); + + // Load config from NVS and start. Must be called after WiFi is in STA mode. + void begin(); + + // Drive reconnects and drain the publish queue. Call from main loop. + void loop(); + + // Producer side: safe to call from logRxRaw / logTx. Drops oldest if queue is full. + void enqueueRawPacket(float snr, float rssi, const uint8_t* raw, int len); + void enqueueTxPacket(const uint8_t* raw, int len); + + // Register /mqtt-setup (GET form, POST save) and /mqtt-status (JSON) on the + // given web server. Reads/writes NVS; does NOT auto-reconnect on save — + // user-triggered reboot picks up the new config. + void attachWebRoutes(AsyncWebServer* server); + + bool isConnected() { return _client.connected(); } + bool isConfigured() const { return _host.length() > 0; } + const String& host() const { return _host; } + uint16_t port() const { return _port; } + const String& topic() const { return _topic; } + +private: + void _loadConfig(); + void _saveConfig(const String& host, uint16_t port, const String& user, + const String& pwd, const String& topic); + bool _ensureConnected(); + void _publishOne(); + static String _defaultTopic(); + + WiFiClient _net; + PubSubClient _client; + String _host; + uint16_t _port = 1883; + String _user; + String _pwd; + String _topic; + String _client_id; + uint32_t _last_attempt_ms = 0; + uint32_t _retry_backoff_ms = 2000; + + enum class Dir : uint8_t { RX = 0, TX = 1 }; + struct Entry { + Dir dir; + uint32_t ts; + float snr; // valid for RX only + float rssi; // valid for RX only + uint16_t len; + uint8_t data[256]; + }; + static constexpr size_t QUEUE_LEN = 16; + Entry _queue[QUEUE_LEN]; + volatile uint8_t _q_head = 0; + volatile uint8_t _q_tail = 0; + volatile uint32_t _dropped = 0; + uint32_t _published = 0; + uint32_t _publish_errs = 0; +}; + +// Singleton accessor + free functions for use from logRxRaw / logTx without +// pulling in the full header at every hook site. +MqttPublisher& mqttPublisher(); +void mqttPublishRawPacket(float snr, float rssi, const uint8_t* raw, int len); +void mqttPublishTxPacket(const uint8_t* raw, int len); + +#endif // ESP_PLATFORM && MQTT_PUBLISHER diff --git a/src/helpers/esp32/WifiAdminUI.cpp b/src/helpers/esp32/WifiAdminUI.cpp new file mode 100644 index 0000000000..50556c8824 --- /dev/null +++ b/src/helpers/esp32/WifiAdminUI.cpp @@ -0,0 +1,672 @@ +#ifdef ESP_PLATFORM + +#include "WifiAdminUI.h" +#include +#include + +namespace { + +// Singleton pointer for the free-function packet push helpers. +WifiAdminUI* g_admin_ui = nullptr; + +// Shared CSS served at /style.css so admin sub-pages (MQTT setup, Channels) can +// link to it instead of duplicating styles. +const char SHARED_CSS[] PROGMEM = R"CSS( +:root{--bg:#f6f7fb;--card:#fff;--ink:#111;--mute:#666;--line:#e2e5ec;--accent:#1a73e8;--warn:#ef6c00;--danger:#c62828;--good:#2e7d32;--mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace} +@media (prefers-color-scheme:dark){:root{--bg:#0e1116;--card:#161a22;--ink:#e6e8ee;--mute:#8b93a3;--line:#252b36}} +*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.4 system-ui,sans-serif} +.wrap{max-width:780px;margin:0 auto;padding:.8em} +header.top{display:flex;flex-wrap:wrap;align-items:baseline;gap:.6em;justify-content:space-between;padding:.4em .2em .6em} +header.top h1{font-size:1.25em;margin:0} +header.top .meta{color:var(--mute);font-size:.85em} +.btns{display:flex;flex-wrap:wrap;gap:.4em} +button,a.btn{font:inherit;padding:.4em .8em;border-radius:5px;border:1px solid var(--line);background:var(--card);color:var(--ink);cursor:pointer;text-decoration:none;display:inline-block} +button:hover,a.btn:hover{border-color:var(--accent)} +button.primary{background:var(--accent);color:#fff;border-color:var(--accent)} +button.warn{background:var(--warn);color:#fff;border:0} +button.danger{background:var(--danger);color:#fff;border:0} +.card{background:var(--card);border:1px solid var(--line);border-radius:6px;padding:.8em;margin-top:.6em} +.card h2{margin:0 0 .4em;font-size:.95em} +label{display:block;margin:.6em 0 .2em;font-size:.9em;color:var(--mute)} +input,select{font:inherit;width:100%;padding:.5em;box-sizing:border-box;border:1px solid var(--line);background:var(--bg);color:var(--ink);border-radius:4px} +.row{display:flex;gap:.5em}.row .grow{flex:1}.row .port{flex:0 0 6em} +.msg{padding:.5em .6em;border-radius:4px;margin-top:.8em;font-size:.9em} +.msg.ok{background:rgba(46,125,50,.12);color:var(--good)} +.msg.err{background:rgba(198,40,40,.12);color:var(--danger)} +small{color:var(--mute)} +table{width:100%;border-collapse:collapse;font:13px var(--mono)} +th,td{text-align:left;padding:.35em .4em;border-bottom:1px solid var(--line)} +th{color:var(--mute);font-weight:normal;font-size:.85em} +.tag{display:inline-block;padding:.1em .45em;border-radius:3px;font-size:.75em;border:1px solid var(--line);color:var(--mute)} +.tag.persistent{border-color:var(--accent);color:var(--accent)} +)CSS"; + +const char BLACKLIST_HTML[] PROGMEM = R"HTML( + +MeshCore Blacklist +
+
+

Forwarding blacklist

+
drop ADVERTs from nodes whose name matches a pattern
+ +
+

Active patterns

+
PatternHits
+ +
+

Add pattern

+
+ + +
+
+ Patterns are matched against the advertised node name. ADVERT packets from matches are dropped (not forwarded, not added to neighbours). Direct messages still pass through since we can't see the sender of an encrypted DM. Capacity: 8 patterns. +
+)HTML"; + +const char CHANNELS_HTML[] PROGMEM = R"HTML( + +MeshCore Channels +
+
+

Group channels

+
configure additional channels so the repeater can decrypt their messages
+ +
+

Configured channels

+
NameHashSource
+ +
+

Add channel

+
+ + + +
+
+ Channels persist in NVS and reload on boot. Capacity: 8 total incl. built-in Public. +
+)HTML"; + +const char ADMIN_HTML[] PROGMEM = R"HTML( + + +MeshCore Admin +
+ +
+

MeshCore

+
· fw ? · up ?
+
+ + + + + + +
+
+ +
+

Network

+

Radio config

+

Mesh

+

Radio stats

+

Packets

+
+ +
+ +
+
+

Chat

+
+ + +
+
+
waiting for chat messages on known channels (Public)…
+
+
+

Packet feed

+
+ + +
+
+
(connecting…)
+
+
+ +
+

Console

+
+ +
+ + +
+
+ + + + + + + + +
+
idage (s)snr
+
+
+ +
+ +
+
websocket connecting…
+
rx 0 / tx 0
+
+ +
+)HTML"; + +} // namespace + +WifiAdminUI& _adminInstance() { + static WifiAdminUI* dummy = nullptr; (void)dummy; + return *g_admin_ui; +} + +void wifiAdminPushRxPacket(float snr, float rssi, const uint8_t* raw, int len) { + if (g_admin_ui) g_admin_ui->pushRxPacket(snr, rssi, raw, len); +} + +void wifiAdminPushTxPacket(const uint8_t* raw, int len) { + if (g_admin_ui) g_admin_ui->pushTxPacket(raw, len); +} + +void wifiAdminPushChat(const char* channel, const char* sender, const char* text, uint32_t timestamp) { + if (g_admin_ui) g_admin_ui->pushChat(channel, sender, text, timestamp); +} + +WifiAdminUI::WifiAdminUI(AsyncWebServer* server, MeshInfoProvider* mesh, WifiCmdHandler cmd_handler) + : _server(server), _mesh(mesh), _cmd_handler(cmd_handler) { + g_admin_ui = this; +} + +namespace { +String _packetToJson(bool is_rx, float snr, float rssi, const uint8_t* raw, int len) { + static const char H[] = "0123456789abcdef"; + String hex; hex.reserve(2 * len); + for (int i = 0; i < len; i++) { hex += H[(raw[i] >> 4) & 0xF]; hex += H[raw[i] & 0xF]; } + String out = "{"; + if (is_rx) { + out += "\"dir\":\"rx\",\"rssi\":"; out += String((int)rssi); + out += ",\"snr\":"; out += String(snr, 2); + out += ",\"len\":"; out += String(len); + } else { + out += "\"dir\":\"tx\",\"len\":"; out += String(len); + } + out += ",\"raw\":\""; out += hex; out += "\"}"; + return out; +} +} + +void WifiAdminUI::pushRxPacket(float snr, float rssi, const uint8_t* raw, int len) { + if (!_ws || _ws->count() == 0) return; + _ws->textAll(_packetToJson(true, snr, rssi, raw, len)); +} + +void WifiAdminUI::pushTxPacket(const uint8_t* raw, int len) { + if (!_ws || _ws->count() == 0) return; + _ws->textAll(_packetToJson(false, 0, 0, raw, len)); +} + +namespace { +String _jsEscape(const char* s) { + String out; out.reserve(strlen(s) + 8); + for (const char* p = s; *p; p++) { + char c = *p; + if (c == '"' || c == '\\') { out += '\\'; out += c; } + else if (c == '\n') out += "\\n"; + else if (c == '\r') out += "\\r"; + else if (c == '\t') out += "\\t"; + else if ((uint8_t)c < 0x20) { char b[8]; snprintf(b, sizeof(b), "\\u%04x", c); out += b; } + else out += c; + } + return out; +} +} + +void WifiAdminUI::pushChat(const char* channel, const char* sender, const char* text, uint32_t timestamp) { + if (!_ws || _ws->count() == 0) return; + String out = "{\"type\":\"chat\",\"ts\":"; + out += String(timestamp); + out += ",\"channel\":\""; out += _jsEscape(channel ? channel : ""); + out += "\",\"sender\":\""; out += _jsEscape(sender ? sender : ""); + out += "\",\"text\":\""; out += _jsEscape(text ? text : ""); + out += "\"}"; + _ws->textAll(out); +} + +void WifiAdminUI::begin() { + if (!_server) return; + + _ws = new AsyncWebSocket("/ws"); + _ws->onEvent([](AsyncWebSocket*, AsyncWebSocketClient* c, AwsEventType type, void*, uint8_t*, size_t) { + if (type == WS_EVT_CONNECT) { + c->text("{\"hello\":\"meshcore\"}"); + } + }); + _server->addHandler(_ws); + + _server->on("/", HTTP_GET, [](AsyncWebServerRequest* req) { + req->send_P(200, "text/html", ADMIN_HTML); + }); + + _server->on("/api/status", HTTP_GET, [this](AsyncWebServerRequest* req) { + String out = "{"; + out += "\"name\":\""; out += _mesh->nodeName(); out += "\","; + out += "\"role\":\""; out += _mesh->role(); out += "\","; + out += "\"fw\":\""; out += _mesh->firmwareVer(); out += "\","; + out += "\"uptime_s\":"; out += String(millis() / 1000); out += ","; + out += "\"ssid\":\""; out += WiFi.SSID(); out += "\","; + out += "\"ip\":\""; out += WiFi.localIP().toString(); out += "\","; + out += "\"rssi\":"; out += String(WiFi.RSSI()); out += ","; + out += "\"mac\":\""; out += WiFi.macAddress(); out += "\""; + out += "}"; + req->send(200, "application/json", out); + }); + + auto text_route = [this](const char* path, void (MeshInfoProvider::*fn)(char*)) { + _server->on(path, HTTP_GET, [this, fn](AsyncWebServerRequest* req) { + char buf[2048]; + buf[0] = '\0'; + (_mesh->*fn)(buf); + req->send(200, "text/plain", buf); + }); + }; + text_route("/api/stats", &MeshInfoProvider::formatStats); + text_route("/api/radio-stats", &MeshInfoProvider::formatRadioStats); + text_route("/api/packet-stats", &MeshInfoProvider::formatPacketStats); + text_route("/api/neighbours", &MeshInfoProvider::formatNeighbours); + text_route("/api/radio-config", &MeshInfoProvider::formatRadioConfig); + + _server->on("/api/reboot", HTTP_POST, [](AsyncWebServerRequest* req) { + req->send(200, "text/plain", "ok"); + delay(200); + ESP.restart(); + }); + + // Shared stylesheet for /, /channels, /mqtt-setup so they look like one app. + _server->on("/style.css", HTTP_GET, [](AsyncWebServerRequest* req) { + AsyncWebServerResponse* r = req->beginResponse_P(200, "text/css", SHARED_CSS); + r->addHeader("Cache-Control", "max-age=3600"); + req->send(r); + }); + + // Channels admin + _server->on("/channels", HTTP_GET, [](AsyncWebServerRequest* req) { + req->send_P(200, "text/html", CHANNELS_HTML); + }); + _server->on("/api/channels", HTTP_GET, [this](AsyncWebServerRequest* req) { + char buf[512]; buf[0] = 0; + _mesh->listChannels(buf); + req->send(200, "text/plain", buf); + }); + _server->on("/api/channels-save", HTTP_POST, [this](AsyncWebServerRequest* req) { + if (!req->hasParam("name", true) || !req->hasParam("psk", true)) { + req->send(400, "text/plain", "missing name or psk"); return; + } + String name = req->getParam("name", true)->value(); + String psk = req->getParam("psk", true)->value(); + if (name.length() == 0 || name.length() > 23) { req->send(400, "text/plain", "bad name length"); return; } + if (psk.length() < 16 || psk.length() > 47) { req->send(400, "text/plain", "bad psk length"); return; } + if (_mesh->addChannel(name.c_str(), psk.c_str())) { + req->send(200, "text/plain", "channel added"); + } else { + req->send(409, "text/plain", "add failed (duplicate, capacity, or invalid PSK)"); + } + }); + + // Forwarding blacklist + _server->on("/blacklist", HTTP_GET, [](AsyncWebServerRequest* req) { + req->send_P(200, "text/html", BLACKLIST_HTML); + }); + _server->on("/api/blacklist", HTTP_GET, [this](AsyncWebServerRequest* req) { + char buf[512]; buf[0] = 0; + _mesh->listBlocked(buf); + req->send(200, "text/plain", buf); + }); + _server->on("/api/blacklist-save", HTTP_POST, [this](AsyncWebServerRequest* req) { + if (!req->hasParam("pattern", true)) { req->send(400, "text/plain", "missing pattern"); return; } + String pat = req->getParam("pattern", true)->value(); + if (pat.length() == 0 || pat.length() > 31) { req->send(400, "text/plain", "bad pattern length"); return; } + if (_mesh->addBlocked(pat.c_str())) req->send(200, "text/plain", "pattern added"); + else req->send(409, "text/plain", "add failed (duplicate or capacity)"); + }); + _server->on("/api/blacklist-remove", HTTP_POST, [this](AsyncWebServerRequest* req) { + if (!req->hasParam("pattern", true)) { req->send(400, "text/plain", "missing pattern"); return; } + String pat = req->getParam("pattern", true)->value(); + if (_mesh->removeBlocked(pat.c_str())) req->send(200, "text/plain", "removed"); + else req->send(404, "text/plain", "not found"); + }); + + // POST /api/cmd body: cmd= → reply text + // NOTE: handler runs on the AsyncTCP task. MeshCore is not formally thread-safe; + // typical CLI commands read simple state so the practical risk is low. If a race + // surfaces, this should be queued and processed from the main loop instead. + _server->on("/api/cmd", HTTP_POST, [this](AsyncWebServerRequest* req) { + if (!_cmd_handler) { req->send(503, "text/plain", "no cmd handler"); return; } + if (!req->hasParam("cmd", true)) { req->send(400, "text/plain", "missing cmd"); return; } + String cmd = req->getParam("cmd", true)->value(); + if (cmd.length() == 0 || cmd.length() > 200) { req->send(400, "text/plain", "bad cmd length"); return; } + char buf[256]; strncpy(buf, cmd.c_str(), sizeof(buf) - 1); buf[sizeof(buf) - 1] = 0; + char reply[512]; reply[0] = 0; + _cmd_handler(buf, reply); + req->send(200, "text/plain", reply); + }); +} + +#endif // ESP_PLATFORM diff --git a/src/helpers/esp32/WifiAdminUI.h b/src/helpers/esp32/WifiAdminUI.h new file mode 100644 index 0000000000..7b0025dcce --- /dev/null +++ b/src/helpers/esp32/WifiAdminUI.h @@ -0,0 +1,67 @@ +#pragma once + +#ifdef ESP_PLATFORM + +#include +#include + +// Read-only view into per-role mesh state for the admin web UI. Implemented by the +// role's main.cpp (which knows the concrete MyMesh type) so this helper stays generic. +class MeshInfoProvider { +public: + virtual ~MeshInfoProvider() = default; + virtual const char* nodeName() = 0; + virtual const char* role() = 0; + virtual const char* firmwareVer() = 0; + // Each formatter must write a null-terminated string of <= 1024 bytes into 'out'. + virtual void formatNeighbours(char* out) = 0; + virtual void formatStats(char* out) = 0; + virtual void formatRadioStats(char* out) = 0; + virtual void formatPacketStats(char* out) = 0; + + // Optional: group-channel management. Default impls return empty / fail so non- + // repeater roles don't have to care. Repeater overrides delegate to the_mesh. + virtual void listChannels(char* out) { out[0] = 0; } + virtual bool addChannel(const char* /*name*/, const char* /*psk_base64*/) { return false; } + + // Optional: name-pattern forwarding blacklist. + virtual void listBlocked(char* out) { out[0] = 0; } + virtual bool addBlocked(const char* /*pattern*/) { return false; } + virtual bool removeBlocked(const char* /*pattern*/) { return false; } + + // Optional: LoRa radio params (freq, bw, sf, cr, tx_power_dbm, path_hash_size). + // Default no-op so non-repeater roles don't need to implement it. + virtual void formatRadioConfig(char* out) { out[0] = 0; } +}; + +// Handler invoked when the user runs a CLI command from the web UI. Same shape as +// the TCP CLI bridge handler so a single adapter in main.cpp serves both. +typedef void (*WifiCmdHandler)(const char* command, char* reply); + +class AsyncWebSocket; + +class WifiAdminUI { +public: + WifiAdminUI(AsyncWebServer* server, MeshInfoProvider* mesh, WifiCmdHandler cmd_handler = nullptr); + void begin(); + + // Broadcast a packet to all connected WS clients as JSON. Safe to call from the + // mesh thread (AsyncWebSocket queues to AsyncTCP task internally). + void pushRxPacket(float snr, float rssi, const uint8_t* raw, int len); + void pushTxPacket(const uint8_t* raw, int len); + void pushChat(const char* channel, const char* sender, const char* text, uint32_t timestamp); + +private: + AsyncWebServer* _server; + AsyncWebSocket* _ws = nullptr; + MeshInfoProvider* _mesh; + WifiCmdHandler _cmd_handler; +}; + +// Singleton helpers, mirroring MqttPublisher pattern. The free functions are +// no-ops until a WifiAdminUI instance has been constructed. +void wifiAdminPushRxPacket(float snr, float rssi, const uint8_t* raw, int len); +void wifiAdminPushTxPacket(const uint8_t* raw, int len); +void wifiAdminPushChat(const char* channel, const char* sender, const char* text, uint32_t timestamp); + +#endif // ESP_PLATFORM diff --git a/src/helpers/esp32/WifiCliBridge.cpp b/src/helpers/esp32/WifiCliBridge.cpp new file mode 100644 index 0000000000..87028c8df3 --- /dev/null +++ b/src/helpers/esp32/WifiCliBridge.cpp @@ -0,0 +1,58 @@ +#ifdef ESP_PLATFORM + +#include "WifiCliBridge.h" + +WifiCliBridge::WifiCliBridge(uint16_t port, Handler handler) + : _port(port), _handler(handler), _server(port) { + _buf[0] = 0; +} + +void WifiCliBridge::begin() { + _server.begin(); + _server.setNoDelay(true); +} + +void WifiCliBridge::loop() { + WiFiClient incoming = _server.available(); + if (incoming) { + if (_client && _client.connected()) { + _client.println("(displaced)"); + _client.stop(); + } + _client = incoming; + _len = 0; + _buf[0] = 0; + _client.print("MeshCore CLI ready. Type 'help'.\r\n> "); + } + + if (!_client || !_client.connected()) return; + + while (_client.available()) { + char c = (char)_client.read(); + if (c == '\n') continue; // tolerate CRLF + if (c == '\b' || c == 0x7f) { // backspace / DEL + if (_len > 0) { _len--; _buf[_len] = 0; } + continue; + } + if (c == '\r' || _len >= CMD_BUF - 1) { + _buf[_len] = 0; + if (_len > 0) { + char reply[256]; + reply[0] = 0; + _handler(_buf, reply); + if (reply[0]) { + _client.print(reply); + _client.print("\r\n"); + } + } + _len = 0; + _buf[0] = 0; + _client.print("> "); + continue; + } + _buf[_len++] = c; + _buf[_len] = 0; + } +} + +#endif // ESP_PLATFORM diff --git a/src/helpers/esp32/WifiCliBridge.h b/src/helpers/esp32/WifiCliBridge.h new file mode 100644 index 0000000000..62d5457063 --- /dev/null +++ b/src/helpers/esp32/WifiCliBridge.h @@ -0,0 +1,31 @@ +#pragma once + +#ifdef ESP_PLATFORM + +#include +#include + +// Telnet-style TCP CLI bridge: each '\r'-terminated line from a connected client is +// passed to the provided handler, whose reply is written back to the client. One +// client at a time; later connections displace the active one. + +class WifiCliBridge { +public: + typedef void (*Handler)(const char* command, char* reply); + + WifiCliBridge(uint16_t port, Handler handler); + void begin(); + void loop(); + bool hasClient() { return _client && _client.connected(); } + +private: + uint16_t _port; + Handler _handler; + WiFiServer _server; + WiFiClient _client; + static constexpr size_t CMD_BUF = 256; + char _buf[CMD_BUF]; + size_t _len = 0; +}; + +#endif // ESP_PLATFORM diff --git a/src/helpers/esp32/WifiProvisioning.cpp b/src/helpers/esp32/WifiProvisioning.cpp new file mode 100644 index 0000000000..3b6b7d2766 --- /dev/null +++ b/src/helpers/esp32/WifiProvisioning.cpp @@ -0,0 +1,294 @@ +#ifdef ESP_PLATFORM + +#include "WifiProvisioning.h" +#include + +#ifdef WIFI_DEBUG_LOGGING + #define WIFI_LOG(fmt, ...) Serial.printf("[wifi] " fmt "\n", ##__VA_ARGS__) +#else + #define WIFI_LOG(fmt, ...) do {} while (0) +#endif + +namespace { +const char PORTAL_HTML[] PROGMEM = R"HTML( + +MeshCore WiFi Setup + +

MeshCore WiFi Setup

+
Pick a network or enter one below.
+
Scanning…
+
+ + + +
+
+)HTML"; + +const char STATUS_HTML[] PROGMEM = R"HTML( +MeshCore + +

MeshCore

loading…
+ + +)HTML"; +} // namespace + +WifiProvisioning::WifiProvisioning(const Config& cfg) : _cfg(cfg) {} + +String WifiProvisioning::_buildApSsid() { + uint8_t mac[6]; + WiFi.macAddress(mac); + char buf[40]; + snprintf(buf, sizeof(buf), "%s-%02X%02X", _cfg.ap_ssid_prefix, mac[4], mac[5]); + return String(buf); +} + +IPAddress WifiProvisioning::localIP() const { + return _state == State::AP_PORTAL ? WiFi.softAPIP() : WiFi.localIP(); +} + +void WifiProvisioning::_checkLongPressWipe() { + if (_cfg.user_btn_pin < 0) return; + pinMode(_cfg.user_btn_pin, INPUT_PULLUP); + delay(20); + if (digitalRead(_cfg.user_btn_pin) != LOW) return; + WIFI_LOG("button held at boot, timing for wipe..."); + uint32_t start = millis(); + while (digitalRead(_cfg.user_btn_pin) == LOW) { + if (millis() - start >= _cfg.long_press_ms) { + WIFI_LOG("long-press confirmed, clearing creds"); + _clearCreds(); + return; + } + delay(50); + } +} + +bool WifiProvisioning::_loadCreds(String& ssid, String& pwd) { + _prefs.begin(_cfg.nvs_namespace, true); + ssid = _prefs.getString("ssid", ""); + pwd = _prefs.getString("pwd", ""); + _prefs.end(); + if (ssid.length() == 0 && _cfg.bootstrap_ssid) { + ssid = _cfg.bootstrap_ssid; + pwd = _cfg.bootstrap_password ? _cfg.bootstrap_password : ""; + } + return ssid.length() > 0; +} + +void WifiProvisioning::_saveCreds(const String& ssid, const String& pwd) { + _prefs.begin(_cfg.nvs_namespace, false); + _prefs.putString("ssid", ssid); + _prefs.putString("pwd", pwd); + _prefs.end(); +} + +void WifiProvisioning::_clearCreds() { + _prefs.begin(_cfg.nvs_namespace, false); + _prefs.clear(); + _prefs.end(); +} + +bool WifiProvisioning::_tryStaConnect(const String& ssid, const String& pwd) { + WIFI_LOG("STA connecting to '%s' (pwd_len=%u)", ssid.c_str(), (unsigned)pwd.length()); + + // One-shot event handler to surface low-level disconnect reason codes. + // Reason 200+ are auth/4-way-handshake failures, 201 = NO_AP_FOUND, 202 = AUTH_FAIL, + // 204 = HANDSHAKE_TIMEOUT, 205 = CONNECTION_FAIL. The bare WL_NO_SSID_AVAIL the + // Arduino layer reports can mask several of these. + static WiFiEventId_t evt_id = 0; + if (evt_id) WiFi.removeEvent(evt_id); + evt_id = WiFi.onEvent([](WiFiEvent_t event, WiFiEventInfo_t info) { + if (event == ARDUINO_EVENT_WIFI_STA_DISCONNECTED) { + WIFI_LOG("STA disconnect reason=%u", (unsigned)info.wifi_sta_disconnected.reason); + } else if (event == ARDUINO_EVENT_WIFI_STA_CONNECTED) { + WIFI_LOG("STA associated, awaiting IP"); + } else if (event == ARDUINO_EVENT_WIFI_STA_GOT_IP) { + WIFI_LOG("STA got IP"); + } + }); + + // Fully clear any prior WiFi state (esp. lingering AP mode from previous boot). + WiFi.disconnect(true, true); + delay(100); + WiFi.mode(WIFI_STA); + delay(100); + WiFi.setSleep(false); // helps with RX reliability on some routers + WiFi.setAutoReconnect(true); + WiFi.begin(ssid.c_str(), pwd.c_str()); + + uint32_t deadline = millis() + _cfg.sta_attempt_timeout_ms; + while (millis() < deadline) { + wl_status_t st = WiFi.status(); + if (st == WL_CONNECTED) { + WIFI_LOG("STA up, IP=%s rssi=%d", WiFi.localIP().toString().c_str(), WiFi.RSSI()); + return true; + } + delay(250); + } + WIFI_LOG("STA attempt timed out (status=%d)", (int)WiFi.status()); + WiFi.disconnect(true, false); + return false; +} + +void WifiProvisioning::_startApPortal() { + WIFI_LOG("starting AP portal '%s'", _ap_ssid.c_str()); + WiFi.mode(WIFI_AP); + WiFi.softAP(_ap_ssid.c_str(), _cfg.ap_password); + delay(100); + IPAddress ip = WiFi.softAPIP(); + WIFI_LOG("AP IP=%s", ip.toString().c_str()); + + _dns = new DNSServer(); + _dns->setErrorReplyCode(DNSReplyCode::NoError); + _dns->start(_cfg.dns_port, "*", ip); + + _server = new AsyncWebServer(_cfg.web_port); + _registerPortalRoutes(); + _server->begin(); + _state = State::AP_PORTAL; +} + +void WifiProvisioning::_startStaWebServer() { + // Lazy: only spin up the server if a caller asks via webServer(); status routes are + // attached the first time the server is materialized. + if (_server) return; + _server = new AsyncWebServer(_cfg.web_port); + _registerStatusRoutes(); + _server->begin(); +} + +void WifiProvisioning::_registerPortalRoutes() { + _server->on("/", HTTP_GET, [](AsyncWebServerRequest* req) { + req->send_P(200, "text/html", PORTAL_HTML); + }); + + _server->on("/scan", HTTP_GET, [](AsyncWebServerRequest* req) { + int n = WiFi.scanComplete(); + if (n == WIFI_SCAN_RUNNING) { req->send(200, "application/json", "[]"); return; } + if (n < 0) { WiFi.scanNetworks(true); req->send(200, "application/json", "[]"); return; } + String out = "["; + for (int i = 0; i < n; i++) { + if (i) out += ','; + String s = WiFi.SSID(i); + s.replace("\\", "\\\\"); s.replace("\"", "\\\""); + out += "{\"s\":\""; out += s; + out += "\",\"r\":"; out += String(WiFi.RSSI(i)); + out += ",\"e\":"; out += (WiFi.encryptionType(i) == WIFI_AUTH_OPEN ? "false" : "true"); + out += "}"; + } + out += "]"; + WiFi.scanDelete(); + WiFi.scanNetworks(true); + req->send(200, "application/json", out); + }); + + _server->on("/save", HTTP_POST, [this](AsyncWebServerRequest* req) { + if (!req->hasParam("ssid", true)) { req->send(400, "text/plain", "missing ssid"); return; } + String ssid = req->getParam("ssid", true)->value(); + String pwd = req->hasParam("pwd", true) ? req->getParam("pwd", true)->value() : String(); + if (ssid.length() == 0 || ssid.length() > 32) { req->send(400, "text/plain", "bad ssid"); return; } + _saveCreds(ssid, pwd); + req->send(200, "text/plain", "ok"); + _reboot_pending = true; + _reboot_at_ms = millis() + 800; + }); + + // Captive-portal probes: send everything to root so the OS pops the portal. + _server->onNotFound([this](AsyncWebServerRequest* req) { + req->redirect("http://" + WiFi.softAPIP().toString() + "/"); + }); + + // Kick off an initial scan in the background. + WiFi.scanNetworks(true); +} + +void WifiProvisioning::_registerStatusRoutes() { + // STA-mode routes live under /wifi-* so callers (e.g. WifiAdminUI) own '/'. + _server->on("/wifi-setup", HTTP_GET, [](AsyncWebServerRequest* req) { + req->send_P(200, "text/html", STATUS_HTML); + }); + _server->on("/wifi-status", HTTP_GET, [this](AsyncWebServerRequest* req) { + String out = "{"; + out += "\"ssid\":\"" + WiFi.SSID() + "\","; + out += "\"ip\":\"" + WiFi.localIP().toString() + "\","; + out += "\"rssi\":" + String(WiFi.RSSI()) + ","; + out += "\"mac\":\"" + WiFi.macAddress() + "\""; + out += "}"; + req->send(200, "application/json", out); + }); + _server->on("/wipe-wifi", HTTP_POST, [this](AsyncWebServerRequest* req) { + req->send(200, "text/plain", "ok"); + _clearCreds(); + _reboot_pending = true; + _reboot_at_ms = millis() + 500; + }); +} + +void WifiProvisioning::wipeAndReboot() { + _clearCreds(); + delay(200); + ESP.restart(); +} + +void WifiProvisioning::begin() { + _ap_ssid = _buildApSsid(); + _checkLongPressWipe(); + + String ssid, pwd; + bool have_creds = _loadCreds(ssid, pwd); + + if (have_creds) { + _state = State::STA_CONNECTING; + for (uint8_t i = 0; i < _cfg.sta_max_attempts; i++) { + if (_tryStaConnect(ssid, pwd)) { + _state = State::STA_CONNECTED; + _startStaWebServer(); + return; + } + delay(500); + } + WIFI_LOG("STA failed %u times, falling back to AP", (unsigned)_cfg.sta_max_attempts); + } + _startApPortal(); +} + +void WifiProvisioning::loop() { + if (_reboot_pending && (int32_t)(millis() - _reboot_at_ms) >= 0) { + WIFI_LOG("rebooting on request"); + delay(50); + ESP.restart(); + } + if (_state == State::AP_PORTAL && _dns) { + _dns->processNextRequest(); + } +} + +#endif // ESP_PLATFORM diff --git a/src/helpers/esp32/WifiProvisioning.h b/src/helpers/esp32/WifiProvisioning.h new file mode 100644 index 0000000000..2d79b7a361 --- /dev/null +++ b/src/helpers/esp32/WifiProvisioning.h @@ -0,0 +1,82 @@ +#pragma once + +#ifdef ESP_PLATFORM + +#include +#include +#include +#include +#include + +// AP-first WiFi provisioning with NVS-persisted STA credentials. +// Boot order: long-press USER_BTN to force AP, else try STA from NVS (3 attempts), +// else fall back to AP captive portal. Save from the portal triggers a reboot into STA. + +class WifiProvisioning { +public: + struct Config { + const char* nvs_namespace = "mc-wifi"; + const char* ap_ssid_prefix = "MeshCore-Setup"; + const char* ap_password = "meshcore123"; // 8+ chars required by ESP32 softAP + int user_btn_pin = -1; // <0 disables long-press wipe + uint32_t long_press_ms = 5000; + uint8_t sta_max_attempts = 3; + uint32_t sta_attempt_timeout_ms = 15000; + uint16_t web_port = 80; + uint16_t dns_port = 53; + const char* bootstrap_ssid = nullptr; // optional: seed NVS on first boot + const char* bootstrap_password = nullptr; + }; + + enum class State { BOOT, STA_CONNECTING, STA_CONNECTED, AP_PORTAL }; + + explicit WifiProvisioning(const Config& cfg); + + // Blocks until STA is up or AP portal is running. Safe to call once from setup(). + void begin(); + + // Must be polled from loop(); drives DNSServer in AP mode and reconnect logic in STA. + void loop(); + + bool isReady() const { return _state == State::STA_CONNECTED || _state == State::AP_PORTAL; } + bool inApMode() const { return _state == State::AP_PORTAL; } + bool inStaMode() const { return _state == State::STA_CONNECTED; } + State state() const { return _state; } + + IPAddress localIP() const; + String apSsid() const { return _ap_ssid; } + + // Returns the underlying server so other modules (e.g. WifiAdminUI) can attach routes + // after provisioning is complete. Only valid once STA is up; in AP mode the server is + // dedicated to the captive portal. + AsyncWebServer* webServer() { return _server; } + + // Wipes saved creds and reboots into AP portal. Safe to call from a web handler. + void wipeAndReboot(); + +private: + void _checkLongPressWipe(); + bool _loadCreds(String& ssid, String& pwd); + void _saveCreds(const String& ssid, const String& pwd); + void _clearCreds(); + bool _tryStaConnect(const String& ssid, const String& pwd); + void _startApPortal(); + void _startStaWebServer(); + void _registerPortalRoutes(); + void _registerStatusRoutes(); + String _buildApSsid(); + + Config _cfg; + Preferences _prefs; + DNSServer* _dns = nullptr; + AsyncWebServer* _server = nullptr; + State _state = State::BOOT; + String _ap_ssid; + bool _portal_routes_registered = false; + bool _status_routes_registered = false; + // Saved-cred staging for /save handler -> main loop reboot: + volatile bool _reboot_pending = false; + uint32_t _reboot_at_ms = 0; +}; + +#endif // ESP_PLATFORM diff --git a/variants/xiao_s3_wio/platformio.ini b/variants/xiao_s3_wio/platformio.ini index db8c5a9486..5a8c4e2a62 100644 --- a/variants/xiao_s3_wio/platformio.ini +++ b/variants/xiao_s3_wio/platformio.ini @@ -3,11 +3,17 @@ extends = esp32_base board = seeed_xiao_esp32s3 board_check = true board_build.mcu = esp32s3 +build_unflags = + -D LORA_FREQ=869.618 + -D LORA_SF=8 build_flags = ${esp32_base.build_flags} ${sensor_base.build_flags} -I variants/xiao_s3_wio -UENV_INCLUDE_GPS -D SEEED_XIAO_S3 + ; USA/Canada region (per docs/faq.md): 910.525 MHz, BW 62.5 (base), SF 7, CR 5 (default) + -D LORA_FREQ=910.525 + -D LORA_SF=7 -D P_LORA_DIO_1=39 -D P_LORA_NSS=41 -D P_LORA_RESET=42 ; RADIOLIB_NC @@ -76,6 +82,29 @@ lib_deps = ; ${Xiao_S3_WIO.lib_deps} ; ${esp32_ota.lib_deps} +[env:Xiao_S3_WIO_repeater_wifi] +extends = Xiao_S3_WIO +build_src_filter = ${Xiao_S3_WIO.build_src_filter} + + + +<../examples/simple_repeater/*.cpp> +build_flags = + ${Xiao_S3_WIO.build_flags} + -D ADVERT_NAME='"XiaoS3 Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D WIFI_PROVISIONING=1 + -D WIFI_DEBUG_LOGGING=1 + -D MQTT_PUBLISHER=1 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +lib_deps = + ${Xiao_S3_WIO.lib_deps} + ${esp32_ota.lib_deps} + knolleary/PubSubClient @ ^2.8 + densaugeo/base64 @ ~1.4.0 + [env:Xiao_S3_WIO_repeater_bridge_espnow] extends = Xiao_S3_WIO build_src_filter = ${Xiao_S3_WIO.build_src_filter} @@ -113,6 +142,26 @@ lib_deps = ${Xiao_S3_WIO.lib_deps} ${esp32_ota.lib_deps} +[env:Xiao_S3_WIO_room_server_wifi] +extends = Xiao_S3_WIO +build_src_filter = ${Xiao_S3_WIO.build_src_filter} + + + +<../examples/simple_room_server> +build_flags = + ${Xiao_S3_WIO.build_flags} + -D ADVERT_NAME='"XiaoS3 Room"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D ROOM_PASSWORD='"hello"' + -D WIFI_PROVISIONING=1 + -D WIFI_DEBUG_LOGGING=1 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +lib_deps = + ${Xiao_S3_WIO.lib_deps} + ${esp32_ota.lib_deps} + [env:Xiao_S3_WIO_terminal_chat] extends = Xiao_S3_WIO build_flags = @@ -210,13 +259,15 @@ build_flags = -D MAX_GROUP_CHANNELS=40 -D OFFLINE_QUEUE_SIZE=256 -D WIFI_DEBUG_LOGGING=1 - -D WIFI_SSID='"myssid"' - -D WIFI_PWD='"password"' + -D WIFI_PROVISIONING=1 + ; -D WIFI_SSID='"myssid"' ; optional: bootstrap creds on first boot + ; -D WIFI_PWD='"password"' ; -D BLE_DEBUG_LOGGING=1 ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 lib_deps = ${Xiao_S3_WIO.lib_deps} + ${esp32_ota.lib_deps} densaugeo/base64 @ ~1.4.0 [env:Xiao_S3_WIO_sensor] From 7112b95301d1f10a2353019d390c9491b45724ea Mon Sep 17 00:00:00 2001 From: zfouts Date: Mon, 25 May 2026 16:53:36 -0500 Subject: [PATCH 09/12] Add Heltec V4 WiFi envs (OLED + TFT) and show WiFi state on the display variants/heltec_v4/platformio.ini: - New env heltec_v4_repeater_wifi: WIFI_PROVISIONING + WifiAdminUI + WifiCliBridge + MqttPublisher on the OLED variant. Mirrors Xiao_S3_WIO_repeater_wifi. - New env heltec_v4_room_server_wifi: provisioning + admin + CLI bridge (no MQTT). - New env heltec_v4_tft_repeater_wifi: same feature set as the OLED repeater, on the ST7789LCDDisplay TFT variant. - New env heltec_v4_tft_room_server_wifi: same as OLED room server, on TFT. - Modify heltec_v4_companion_radio_wifi and heltec_v4_tft_companion_radio_wifi: drop the baked WIFI_SSID/WIFI_PWD, switch to WIFI_PROVISIONING, add ${esp32_ota.lib_deps}. Optional bootstrap creds are now commented out. examples/simple_repeater/UITask.cpp, examples/simple_room_server/UITask.cpp: - Add WiFi status to the home screen under freq/bw/cr (gated on ESP_PLATFORM + WIFI_PROVISIONING). In STA mode shows IP + RSSI in green; in AP mode shows the captive-portal SSID in yellow. Lets a user with a display see whether the node is ready or still expecting WiFi setup, without opening the web UI. Builds verified clean for all six new/modified Heltec V4 envs plus a Xiao_S3_WIO_repeater_wifi regression check. --- examples/simple_repeater/UITask.cpp | 23 ++++++ examples/simple_room_server/UITask.cpp | 23 ++++++ variants/heltec_v4/platformio.ini | 108 ++++++++++++++++++++++++- 3 files changed, 150 insertions(+), 4 deletions(-) diff --git a/examples/simple_repeater/UITask.cpp b/examples/simple_repeater/UITask.cpp index 6a85143887..90055acf55 100644 --- a/examples/simple_repeater/UITask.cpp +++ b/examples/simple_repeater/UITask.cpp @@ -2,6 +2,10 @@ #include #include +#if defined(ESP_PLATFORM) && defined(WIFI_PROVISIONING) + #include +#endif + #ifndef USER_BTN_PRESSED #define USER_BTN_PRESSED LOW #endif @@ -89,6 +93,25 @@ void UITask::renderCurrScreen() { _display->setCursor(0, 30); sprintf(tmp, "BW: %03.2f CR: %d", _node_prefs->bw, _node_prefs->cr); _display->print(tmp); + +#if defined(ESP_PLATFORM) && defined(WIFI_PROVISIONING) + // WiFi state: STA shows IP+RSSI (green if connected), AP shows the setup SSID. + _display->setCursor(0, 42); + if (WiFi.getMode() == WIFI_AP || WiFi.status() != WL_CONNECTED) { + _display->setColor(DisplayDriver::YELLOW); + String ssid = WiFi.softAPSSID(); + if (ssid.length() == 0) ssid = "(not up)"; + sprintf(tmp, "AP: %s", ssid.c_str()); + _display->print(tmp); + } else { + _display->setColor(DisplayDriver::GREEN); + sprintf(tmp, "IP: %s", WiFi.localIP().toString().c_str()); + _display->print(tmp); + _display->setCursor(0, 52); + sprintf(tmp, "RSSI: %d dBm", (int)WiFi.RSSI()); + _display->print(tmp); + } +#endif } } diff --git a/examples/simple_room_server/UITask.cpp b/examples/simple_room_server/UITask.cpp index 640a1d2d10..d6ae7fc6ab 100644 --- a/examples/simple_room_server/UITask.cpp +++ b/examples/simple_room_server/UITask.cpp @@ -2,6 +2,10 @@ #include #include +#if defined(ESP_PLATFORM) && defined(WIFI_PROVISIONING) + #include +#endif + #ifndef USER_BTN_PRESSED #define USER_BTN_PRESSED LOW #endif @@ -89,6 +93,25 @@ void UITask::renderCurrScreen() { _display->setCursor(0, 30); sprintf(tmp, "BW: %03.2f CR: %d", _node_prefs->bw, _node_prefs->cr); _display->print(tmp); + +#if defined(ESP_PLATFORM) && defined(WIFI_PROVISIONING) + // WiFi state: STA shows IP+RSSI (green), AP shows the setup SSID (yellow). + _display->setCursor(0, 42); + if (WiFi.getMode() == WIFI_AP || WiFi.status() != WL_CONNECTED) { + _display->setColor(DisplayDriver::YELLOW); + String ssid = WiFi.softAPSSID(); + if (ssid.length() == 0) ssid = "(not up)"; + sprintf(tmp, "AP: %s", ssid.c_str()); + _display->print(tmp); + } else { + _display->setColor(DisplayDriver::GREEN); + sprintf(tmp, "IP: %s", WiFi.localIP().toString().c_str()); + _display->print(tmp); + _display->setCursor(0, 52); + sprintf(tmp, "RSSI: %d dBm", (int)WiFi.RSSI()); + _display->print(tmp); + } +#endif } } diff --git a/variants/heltec_v4/platformio.ini b/variants/heltec_v4/platformio.ini index fabf38272d..dfb0e39386 100644 --- a/variants/heltec_v4/platformio.ini +++ b/variants/heltec_v4/platformio.ini @@ -232,8 +232,9 @@ build_flags = -D OFFLINE_QUEUE_SIZE=256 -D DISPLAY_CLASS=SSD1306Display -D WIFI_DEBUG_LOGGING=1 - -D WIFI_SSID='"myssid"' - -D WIFI_PWD='"mypwd"' + -D WIFI_PROVISIONING=1 + ; -D WIFI_SSID='"myssid"' ; optional: bootstrap creds on first boot + ; -D WIFI_PWD='"mypwd"' ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 build_src_filter = ${heltec_v4_oled.build_src_filter} @@ -244,7 +245,56 @@ build_src_filter = ${heltec_v4_oled.build_src_filter} +<../examples/companion_radio/ui-new/*.cpp> lib_deps = ${heltec_v4_oled.lib_deps} + ${esp32_ota.lib_deps} + densaugeo/base64 @ ~1.4.0 + +[env:heltec_v4_repeater_wifi] +extends = heltec_v4_oled +build_flags = + ${heltec_v4_oled.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 WIFI_PROVISIONING=1 + -D WIFI_DEBUG_LOGGING=1 + -D MQTT_PUBLISHER=1 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${heltec_v4_oled.build_src_filter} + + + + + +<../examples/simple_repeater> +lib_deps = + ${heltec_v4_oled.lib_deps} + ${esp32_ota.lib_deps} + knolleary/PubSubClient @ ^2.8 densaugeo/base64 @ ~1.4.0 + bakercp/CRC32 @ ^2.0.0 + +[env:heltec_v4_room_server_wifi] +extends = heltec_v4_oled +build_flags = + ${heltec_v4_oled.build_flags} + -D DISPLAY_CLASS=SSD1306Display + -D ADVERT_NAME='"Heltec Room"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D ROOM_PASSWORD='"hello"' + -D WIFI_PROVISIONING=1 + -D WIFI_DEBUG_LOGGING=1 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${heltec_v4_oled.build_src_filter} + + + + + +<../examples/simple_room_server> +lib_deps = + ${heltec_v4_oled.lib_deps} + ${esp32_ota.lib_deps} [env:heltec_v4_sensor] extends = heltec_v4_oled @@ -396,8 +446,9 @@ build_flags = -D OFFLINE_QUEUE_SIZE=256 -D DISPLAY_CLASS=ST7789LCDDisplay -D WIFI_DEBUG_LOGGING=1 - -D WIFI_SSID='"myssid"' - -D WIFI_PWD='"mypwd"' + -D WIFI_PROVISIONING=1 + ; -D WIFI_SSID='"myssid"' ; optional: bootstrap creds on first boot + ; -D WIFI_PWD='"mypwd"' ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 build_src_filter = ${heltec_v4_tft.build_src_filter} @@ -408,7 +459,56 @@ build_src_filter = ${heltec_v4_tft.build_src_filter} +<../examples/companion_radio/ui-new/*.cpp> lib_deps = ${heltec_v4_tft.lib_deps} + ${esp32_ota.lib_deps} + densaugeo/base64 @ ~1.4.0 + +[env:heltec_v4_tft_repeater_wifi] +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 WIFI_PROVISIONING=1 + -D WIFI_DEBUG_LOGGING=1 + -D MQTT_PUBLISHER=1 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${heltec_v4_tft.build_src_filter} + + + + + +<../examples/simple_repeater> +lib_deps = + ${heltec_v4_tft.lib_deps} + ${esp32_ota.lib_deps} + knolleary/PubSubClient @ ^2.8 densaugeo/base64 @ ~1.4.0 + bakercp/CRC32 @ ^2.0.0 + +[env:heltec_v4_tft_room_server_wifi] +extends = heltec_v4_tft +build_flags = + ${heltec_v4_tft.build_flags} + -D DISPLAY_CLASS=ST7789LCDDisplay + -D ADVERT_NAME='"Heltec Room"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D ROOM_PASSWORD='"hello"' + -D WIFI_PROVISIONING=1 + -D WIFI_DEBUG_LOGGING=1 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${heltec_v4_tft.build_src_filter} + + + + + +<../examples/simple_room_server> +lib_deps = + ${heltec_v4_tft.lib_deps} + ${esp32_ota.lib_deps} [env:heltec_v4_tft_sensor] extends = heltec_v4_tft From 886c4e7bc09984716402f41f6f49abe3774d86e7 Mon Sep 17 00:00:00 2001 From: zfouts Date: Mon, 25 May 2026 17:21:27 -0500 Subject: [PATCH 10/12] Make freq/BW/SF editable in admin UI + region presets; revert Xiao defaults to upstream variants/xiao_s3_wio/platformio.ini: - Drop the build_unflags / -D LORA_FREQ=910.525 / -D LORA_SF=7 overrides. Xiao_S3_WIO base now inherits arduino_base defaults (869.618 MHz, BW62.5, SF8). Users pick their region at runtime from the admin UI instead of baking it into firmware at build time. src/helpers/esp32/WifiAdminUI.cpp: - Radio config card: Frequency is now a number input (150-2500 MHz, step 0.025). Bandwidth and Spreading Factor are selects with the full legal value sets (10 BW options for SX1262; SF5-12). Each change fires `set radio f,b,s,c` with the other current values, prompting reboot to apply. - Add region preset row below the card: USA/Canada (910.525/62.5/SF7/CR5), EU868 (869.618/62.5/SF8/CR5), Legacy Wide (869.525/250/SF11/CR5). Clicking a preset confirms then applies all four params in one call. - Refactor setCr to share a setRadio(freq,bw,sf,cr,reason) helper used by all four "set radio" callers (manual edits + presets). Verified: Xiao_S3_WIO_repeater_wifi, heltec_v4_repeater_wifi, and heltec_v4_tft_repeater_wifi all compile clean. --- src/helpers/esp32/WifiAdminUI.cpp | 39 ++++++++++++++++++++++++++--- variants/xiao_s3_wio/platformio.ini | 6 ----- 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/src/helpers/esp32/WifiAdminUI.cpp b/src/helpers/esp32/WifiAdminUI.cpp index 50556c8824..9b4e9ee3d4 100644 --- a/src/helpers/esp32/WifiAdminUI.cpp +++ b/src/helpers/esp32/WifiAdminUI.cpp @@ -359,25 +359,56 @@ function renderRadio(j){ ]); } let _lastRadioCfg=null; +// Common starting points by region. Format: [label, freq, bw, sf, cr] +const RADIO_PRESETS=[ + ['USA / Canada (Recommended)', 910.525, 62.5, 7, 5], + ['EU868 (Default)', 869.618, 62.5, 8, 5], + ['Legacy Wide (pre-1.14)', 869.525, 250.0, 11, 5], +]; function renderRadioCfg(j){ if(!j||!j.freq){return $('rcfg').textContent='(unavailable)';} _lastRadioCfg=j; const phb=j.path_hash_bytes|0; const phCls=phb>=2?'good':'warn'; - const txEd=` dBm`; + const bwOpts=[7.8,10.4,15.6,20.8,31.25,41.7,62.5,125,250,500]; + const fEd=` MHz`; + const bwEd=``; + const sfEd=``; const crEd=``; + const txEd=` dBm`; const phEd=``; renderKV($('rcfg'),[ - ['Frequency', `${j.freq.toFixed(3)} MHz`], - ['Bandwidth', `${j.bw.toFixed(1)} kHz`], - ['Spreading factor', `SF${j.sf}`], + ['Frequency', fEd], + ['Bandwidth', bwEd], + ['Spreading factor', sfEd], ['Coding rate', crEd], ['TX power', txEd], ['Path hash size', phEd, phCls], ['RX boosted gain', j.rx_boosted ? 'on' : 'off'], ]); + // Region preset chips below the kv grid. + const presets=document.createElement('div'); + presets.style.cssText='margin-top:.5em;display:flex;flex-wrap:wrap;gap:.3em'; + presets.innerHTML='
Region presets (sets freq/BW/SF/CR; reboot to apply):
'+ + RADIO_PRESETS.map((p,i)=>``).join(''); + $('rcfg').appendChild(presets); +} +async function setRadio(freq,bw,sf,cr,reason){ + const cmd=`set radio ${freq},${bw},${sf},${cr}`; + const t=await _runCmd(cmd); + $('out').value+=`\n> ${cmd}\n${t}`;$('out').scrollTop=$('out').scrollHeight; + if(t.indexOf('reboot')>=0)alert(`${reason} saved. Reboot the node from the home page to apply.`); + refresh(); +} +async function setFreq(v){if(!_lastRadioCfg)return;const j=_lastRadioCfg;setRadio(parseFloat(v),j.bw,j.sf,j.cr,'Frequency');} +async function setBw(v){if(!_lastRadioCfg)return;const j=_lastRadioCfg;setRadio(j.freq,parseFloat(v),j.sf,j.cr,'Bandwidth');} +async function setSf(v){if(!_lastRadioCfg)return;const j=_lastRadioCfg;setRadio(j.freq,j.bw,parseInt(v,10),j.cr,'Spreading factor');} +function applyPreset(i){const p=RADIO_PRESETS[i];if(!confirm(`Apply preset "${p[0]}":\n ${p[1]} MHz, BW ${p[2]}, SF${p[3]}, CR 4/${p[4]}\n\nReboot required to take effect.`))return; + setRadio(p[1],p[2],p[3],p[4],`Preset "${p[0]}"`); } async function _runCmd(cmd){ const r=await fetch('/api/cmd',{method:'POST',body:new URLSearchParams({cmd})}); diff --git a/variants/xiao_s3_wio/platformio.ini b/variants/xiao_s3_wio/platformio.ini index 5a8c4e2a62..dbc089020b 100644 --- a/variants/xiao_s3_wio/platformio.ini +++ b/variants/xiao_s3_wio/platformio.ini @@ -3,17 +3,11 @@ extends = esp32_base board = seeed_xiao_esp32s3 board_check = true board_build.mcu = esp32s3 -build_unflags = - -D LORA_FREQ=869.618 - -D LORA_SF=8 build_flags = ${esp32_base.build_flags} ${sensor_base.build_flags} -I variants/xiao_s3_wio -UENV_INCLUDE_GPS -D SEEED_XIAO_S3 - ; USA/Canada region (per docs/faq.md): 910.525 MHz, BW 62.5 (base), SF 7, CR 5 (default) - -D LORA_FREQ=910.525 - -D LORA_SF=7 -D P_LORA_DIO_1=39 -D P_LORA_NSS=41 -D P_LORA_RESET=42 ; RADIOLIB_NC From 50ed934f80d1c542d547081ff8908844e2cc9081 Mon Sep 17 00:00:00 2001 From: zfouts Date: Mon, 25 May 2026 17:31:32 -0500 Subject: [PATCH 11/12] Add _repeater_wifi / _room_server_wifi envs to all other ESP32-S3 variants For every WiFi-capable ESP32-S3 board MeshCore supports, add the same env pair we shipped for Xiao S3 WIO and Heltec V4: - *_repeater_wifi: WifiProvisioning + WifiAdminUI + WifiCliBridge + MqttPublisher - *_room_server_wifi: WifiProvisioning + WifiAdminUI + WifiCliBridge Where the variant already had a *_companion_radio_wifi env with baked WIFI_SSID/WIFI_PWD, switch it to -D WIFI_PROVISIONING=1 and add ${esp32_ota.lib_deps} (matches the Heltec V4 / Xiao change). variants/heltec_v3/platformio.ini: - Add Heltec_v3_repeater_wifi, Heltec_v3_room_server_wifi (SSD1306Display) - Modify Heltec_v3_companion_radio_wifi to use provisioning - WSL3 sub-family untouched variants/heltec_tracker/platformio.ini: - Add Heltec_Wireless_Tracker_repeater_wifi, _room_server_wifi (ST7735Display) variants/heltec_tracker_v2/platformio.ini: - Add heltec_tracker_v2_repeater_wifi, _room_server_wifi (ST7735Display) - Modify heltec_tracker_v2_companion_radio_wifi to use provisioning variants/heltec_wireless_paper/platformio.ini: - Add Heltec_Wireless_Paper_repeater_wifi, _room_server_wifi (E213Display) variants/heltec_t190/platformio.ini: - Add Heltec_T190_repeater_wifi_, _room_server_wifi_ (ST7789Display inherited from base; trailing underscore matches the existing env naming convention in this file) variants/rak3112/platformio.ini: - Add RAK_3112_repeater_wifi, RAK_3112_room_server_wifi (headless, no display) - Modify RAK_3112_companion_radio_wifi to use provisioning - Bridge envs (rs232, espnow) untouched variants/lilygo_teth_elite/platformio.ini: - Add LilyGo_TETH_Elite_sx1262_repeater_wifi, _room_server_wifi (headless) variants/heltec_e213/platformio.ini: - Add Heltec_E213_repeater_wifi, Heltec_E213_room_server_wifi (E213Display) variants/heltec_e290/platformio.ini: - Add Heltec_E290_repeater_wifi, Heltec_E290_room_server_wifi (E290Display) All new envs verified to compile clean. Existing BLE / USB / serial / plain-repeater / plain-room-server / sensor / bridge envs unchanged: this is purely additive, users pick their connectivity at flash time. --- variants/heltec_e213/platformio.ini | 49 +++++++++++++++++ variants/heltec_e290/platformio.ini | 49 +++++++++++++++++ variants/heltec_t190/platformio.ini | 44 +++++++++++++++ variants/heltec_tracker/platformio.ini | 50 +++++++++++++++++ variants/heltec_tracker_v2/platformio.ini | 54 ++++++++++++++++++- variants/heltec_v3/platformio.ini | 54 ++++++++++++++++++- variants/heltec_wireless_paper/platformio.ini | 49 +++++++++++++++++ variants/lilygo_teth_elite/platformio.ini | 44 +++++++++++++++ variants/rak3112/platformio.ini | 50 ++++++++++++++++- 9 files changed, 437 insertions(+), 6 deletions(-) diff --git a/variants/heltec_e213/platformio.ini b/variants/heltec_e213/platformio.ini index 123edd91c4..a815017935 100644 --- a/variants/heltec_e213/platformio.ini +++ b/variants/heltec_e213/platformio.ini @@ -171,3 +171,52 @@ lib_deps = extends = Heltec_E213_base build_src_filter = ${Heltec_E213_base.build_src_filter} +<../examples/kiss_modem/> + +[env:Heltec_E213_repeater_wifi] +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 WIFI_PROVISIONING=1 + -D WIFI_DEBUG_LOGGING=1 + -D MQTT_PUBLISHER=1 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${Heltec_E213_base.build_src_filter} + + + + + +<../examples/simple_repeater> +lib_deps = + ${Heltec_E213_base.lib_deps} + ${esp32_ota.lib_deps} + knolleary/PubSubClient @ ^2.8 + densaugeo/base64 @ ~1.4.0 + bakercp/CRC32 @ ^2.0.0 + +[env:Heltec_E213_room_server_wifi] +extends = Heltec_E213_base +build_flags = + ${Heltec_E213_base.build_flags} + -D DISPLAY_CLASS=E213Display + -D ADVERT_NAME='"Heltec E213 Room"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D ROOM_PASSWORD='"hello"' + -D WIFI_PROVISIONING=1 + -D WIFI_DEBUG_LOGGING=1 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${Heltec_E213_base.build_src_filter} + + + + + +<../examples/simple_room_server> +lib_deps = + ${Heltec_E213_base.lib_deps} + ${esp32_ota.lib_deps} + bakercp/CRC32 @ ^2.0.0 diff --git a/variants/heltec_e290/platformio.ini b/variants/heltec_e290/platformio.ini index c7cd5f21ea..7eccf28298 100644 --- a/variants/heltec_e290/platformio.ini +++ b/variants/heltec_e290/platformio.ini @@ -166,3 +166,52 @@ lib_deps = extends = Heltec_E290_base build_src_filter = ${Heltec_E290_base.build_src_filter} +<../examples/kiss_modem/> + +[env:Heltec_E290_repeater_wifi] +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 WIFI_PROVISIONING=1 + -D WIFI_DEBUG_LOGGING=1 + -D MQTT_PUBLISHER=1 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${Heltec_E290_base.build_src_filter} + + + + + +<../examples/simple_repeater> +lib_deps = + ${Heltec_E290_base.lib_deps} + ${esp32_ota.lib_deps} + knolleary/PubSubClient @ ^2.8 + densaugeo/base64 @ ~1.4.0 + bakercp/CRC32 @ ^2.0.0 + +[env:Heltec_E290_room_server_wifi] +extends = Heltec_E290_base +build_flags = + ${Heltec_E290_base.build_flags} + -D DISPLAY_CLASS=E290Display + -D ADVERT_NAME='"Heltec E290 Room"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D ROOM_PASSWORD='"hello"' + -D WIFI_PROVISIONING=1 + -D WIFI_DEBUG_LOGGING=1 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${Heltec_E290_base.build_src_filter} + + + + + +<../examples/simple_room_server> +lib_deps = + ${Heltec_E290_base.lib_deps} + ${esp32_ota.lib_deps} + bakercp/CRC32 @ ^2.0.0 diff --git a/variants/heltec_t190/platformio.ini b/variants/heltec_t190/platformio.ini index 411fee8518..cf447a3f98 100644 --- a/variants/heltec_t190/platformio.ini +++ b/variants/heltec_t190/platformio.ini @@ -158,3 +158,47 @@ lib_deps = extends = Heltec_T190_base build_src_filter = ${Heltec_T190_base.build_src_filter} +<../examples/kiss_modem/> + +[env:Heltec_T190_repeater_wifi_] +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 WIFI_PROVISIONING=1 + -D WIFI_DEBUG_LOGGING=1 + -D MQTT_PUBLISHER=1 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${Heltec_T190_base.build_src_filter} + + + +<../examples/simple_repeater> +lib_deps = + ${Heltec_T190_base.lib_deps} + ${esp32_ota.lib_deps} + knolleary/PubSubClient @ ^2.8 + densaugeo/base64 @ ~1.4.0 + bakercp/CRC32 @ ^2.0.0 + +[env:Heltec_T190_room_server_wifi_] +extends = Heltec_T190_base +build_flags = + ${Heltec_T190_base.build_flags} + -D ADVERT_NAME='"Heltec T190 Room"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D ROOM_PASSWORD='"hello"' + -D WIFI_PROVISIONING=1 + -D WIFI_DEBUG_LOGGING=1 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${Heltec_T190_base.build_src_filter} + + + +<../examples/simple_room_server> +lib_deps = + ${Heltec_T190_base.lib_deps} + ${esp32_ota.lib_deps} diff --git a/variants/heltec_tracker/platformio.ini b/variants/heltec_tracker/platformio.ini index 69293d7070..6728f4ce40 100644 --- a/variants/heltec_tracker/platformio.ini +++ b/variants/heltec_tracker/platformio.ini @@ -191,3 +191,53 @@ lib_deps = extends = Heltec_tracker_base build_src_filter = ${Heltec_tracker_base.build_src_filter} +<../examples/kiss_modem/> + +[env:Heltec_Wireless_Tracker_repeater_wifi] +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 WIFI_PROVISIONING=1 + -D WIFI_DEBUG_LOGGING=1 + -D MQTT_PUBLISHER=1 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${Heltec_tracker_base.build_src_filter} + + + + + +<../examples/simple_repeater> +lib_deps = + ${Heltec_tracker_base.lib_deps} + ${esp32_ota.lib_deps} + knolleary/PubSubClient @ ^2.8 + densaugeo/base64 @ ~1.4.0 + bakercp/CRC32 @ ^2.0.0 + +[env:Heltec_Wireless_Tracker_room_server_wifi] +extends = Heltec_tracker_base +build_flags = + ${Heltec_tracker_base.build_flags} + -D DISPLAY_ROTATION=1 + -D DISPLAY_CLASS=ST7735Display + -D ADVERT_NAME='"Heltec Room"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D ROOM_PASSWORD='"hello"' + -D WIFI_PROVISIONING=1 + -D WIFI_DEBUG_LOGGING=1 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${Heltec_tracker_base.build_src_filter} + + + + + +<../examples/simple_room_server> +lib_deps = + ${Heltec_tracker_base.lib_deps} + ${esp32_ota.lib_deps} diff --git a/variants/heltec_tracker_v2/platformio.ini b/variants/heltec_tracker_v2/platformio.ini index d57c2113f0..1b03434ec3 100644 --- a/variants/heltec_tracker_v2/platformio.ini +++ b/variants/heltec_tracker_v2/platformio.ini @@ -185,8 +185,9 @@ build_flags = -D MAX_GROUP_CHANNELS=40 -D DISPLAY_CLASS=ST7735Display -D WIFI_DEBUG_LOGGING=1 - -D WIFI_SSID='"myssid"' - -D WIFI_PWD='"mypwd"' + -D WIFI_PROVISIONING=1 + ; -D WIFI_SSID='"myssid"' ; optional: bootstrap creds on first boot + ; -D WIFI_PWD='"mypwd"' -D OFFLINE_QUEUE_SIZE=256 ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 @@ -198,7 +199,56 @@ build_src_filter = ${Heltec_tracker_v2.build_src_filter} +<../examples/companion_radio/ui-new/*.cpp> lib_deps = ${Heltec_tracker_v2.lib_deps} + ${esp32_ota.lib_deps} + densaugeo/base64 @ ~1.4.0 + +[env:heltec_tracker_v2_repeater_wifi] +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 WIFI_PROVISIONING=1 + -D WIFI_DEBUG_LOGGING=1 + -D MQTT_PUBLISHER=1 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${Heltec_tracker_v2.build_src_filter} + + + + + +<../examples/simple_repeater> +lib_deps = + ${Heltec_tracker_v2.lib_deps} + ${esp32_ota.lib_deps} + knolleary/PubSubClient @ ^2.8 densaugeo/base64 @ ~1.4.0 + bakercp/CRC32 @ ^2.0.0 + +[env:heltec_tracker_v2_room_server_wifi] +extends = Heltec_tracker_v2 +build_flags = + ${Heltec_tracker_v2.build_flags} + -D DISPLAY_CLASS=ST7735Display + -D ADVERT_NAME='"Heltec Room"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D ROOM_PASSWORD='"hello"' + -D WIFI_PROVISIONING=1 + -D WIFI_DEBUG_LOGGING=1 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${Heltec_tracker_v2.build_src_filter} + + + + + +<../examples/simple_room_server> +lib_deps = + ${Heltec_tracker_v2.lib_deps} + ${esp32_ota.lib_deps} [env:heltec_tracker_v2_sensor] extends = Heltec_tracker_v2 diff --git a/variants/heltec_v3/platformio.ini b/variants/heltec_v3/platformio.ini index a70a93a508..7bad2bfd94 100644 --- a/variants/heltec_v3/platformio.ini +++ b/variants/heltec_v3/platformio.ini @@ -188,8 +188,9 @@ build_flags = -D MAX_GROUP_CHANNELS=40 -D DISPLAY_CLASS=SSD1306Display -D WIFI_DEBUG_LOGGING=1 - -D WIFI_SSID='"myssid"' - -D WIFI_PWD='"mypwd"' + -D WIFI_PROVISIONING=1 + ; -D WIFI_SSID='"myssid"' ; optional: bootstrap creds on first boot + ; -D WIFI_PWD='"mypwd"' -D OFFLINE_QUEUE_SIZE=256 ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 @@ -201,8 +202,57 @@ build_src_filter = ${Heltec_lora32_v3.build_src_filter} +<../examples/companion_radio/ui-new/*.cpp> lib_deps = ${Heltec_lora32_v3.lib_deps} + ${esp32_ota.lib_deps} densaugeo/base64 @ ~1.4.0 +[env:Heltec_v3_repeater_wifi] +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 WIFI_PROVISIONING=1 + -D WIFI_DEBUG_LOGGING=1 + -D MQTT_PUBLISHER=1 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${Heltec_lora32_v3.build_src_filter} + + + + + +<../examples/simple_repeater> +lib_deps = + ${Heltec_lora32_v3.lib_deps} + ${esp32_ota.lib_deps} + knolleary/PubSubClient @ ^2.8 + densaugeo/base64 @ ~1.4.0 + bakercp/CRC32 @ ^2.0.0 + +[env:Heltec_v3_room_server_wifi] +extends = Heltec_lora32_v3 +build_flags = + ${Heltec_lora32_v3.build_flags} + -D DISPLAY_CLASS=SSD1306Display + -D ADVERT_NAME='"Heltec Room"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D ROOM_PASSWORD='"hello"' + -D WIFI_PROVISIONING=1 + -D WIFI_DEBUG_LOGGING=1 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${Heltec_lora32_v3.build_src_filter} + + + + + +<../examples/simple_room_server> +lib_deps = + ${Heltec_lora32_v3.lib_deps} + ${esp32_ota.lib_deps} + [env:Heltec_v3_sensor] extends = Heltec_lora32_v3 build_flags = diff --git a/variants/heltec_wireless_paper/platformio.ini b/variants/heltec_wireless_paper/platformio.ini index 48723d169a..c0e10ffd19 100644 --- a/variants/heltec_wireless_paper/platformio.ini +++ b/variants/heltec_wireless_paper/platformio.ini @@ -170,3 +170,52 @@ lib_deps = extends = Heltec_Wireless_Paper_base build_src_filter = ${Heltec_Wireless_Paper_base.build_src_filter} +<../examples/kiss_modem/> + +[env:Heltec_Wireless_Paper_repeater_wifi] +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 WIFI_PROVISIONING=1 + -D WIFI_DEBUG_LOGGING=1 + -D MQTT_PUBLISHER=1 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${Heltec_Wireless_Paper_base.build_src_filter} + + + + + +<../examples/simple_repeater> +lib_deps = + ${Heltec_Wireless_Paper_base.lib_deps} + ${esp32_ota.lib_deps} + knolleary/PubSubClient @ ^2.8 + densaugeo/base64 @ ~1.4.0 + bakercp/CRC32 @ ^2.0.0 + +[env:Heltec_Wireless_Paper_room_server_wifi] +extends = Heltec_Wireless_Paper_base +build_flags = + ${Heltec_Wireless_Paper_base.build_flags} + -D DISPLAY_CLASS=E213Display + -D ADVERT_NAME='"Heltec WP Room"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D ROOM_PASSWORD='"hello"' + -D WIFI_PROVISIONING=1 + -D WIFI_DEBUG_LOGGING=1 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${Heltec_Wireless_Paper_base.build_src_filter} + + + + + +<../examples/simple_room_server> +lib_deps = + ${Heltec_Wireless_Paper_base.lib_deps} + ${esp32_ota.lib_deps} + bakercp/CRC32 @ ^2.0.0 diff --git a/variants/lilygo_teth_elite/platformio.ini b/variants/lilygo_teth_elite/platformio.ini index 97728f8b4c..52e129be68 100644 --- a/variants/lilygo_teth_elite/platformio.ini +++ b/variants/lilygo_teth_elite/platformio.ini @@ -65,6 +65,50 @@ lib_deps = ${LilyGo_TETH_Elite_sx1262.lib_deps} ${esp32_ota.lib_deps} +[env:LilyGo_TETH_Elite_sx1262_repeater_wifi] +extends = LilyGo_TETH_Elite_sx1262 +build_flags = + ${LilyGo_TETH_Elite_sx1262.build_flags} + -D ADVERT_NAME='"T-ETH Elite Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D WIFI_PROVISIONING=1 + -D WIFI_DEBUG_LOGGING=1 + -D MQTT_PUBLISHER=1 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${LilyGo_TETH_Elite_sx1262.build_src_filter} + + + +<../examples/simple_repeater> +lib_deps = + ${LilyGo_TETH_Elite_sx1262.lib_deps} + ${esp32_ota.lib_deps} + knolleary/PubSubClient @ ^2.8 + densaugeo/base64 @ ~1.4.0 + bakercp/CRC32 @ ^2.0.0 + +[env:LilyGo_TETH_Elite_sx1262_room_server_wifi] +extends = LilyGo_TETH_Elite_sx1262 +build_flags = + ${LilyGo_TETH_Elite_sx1262.build_flags} + -D ADVERT_NAME='"T-ETH Elite Room"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D ROOM_PASSWORD='"hello"' + -D WIFI_PROVISIONING=1 + -D WIFI_DEBUG_LOGGING=1 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${LilyGo_TETH_Elite_sx1262.build_src_filter} + + + +<../examples/simple_room_server> +lib_deps = + ${LilyGo_TETH_Elite_sx1262.lib_deps} + ${esp32_ota.lib_deps} + [env:LilyGo_TETH_Elite_sx1262_companion_radio_usb] extends = LilyGo_TETH_Elite_sx1262 build_flags = diff --git a/variants/rak3112/platformio.ini b/variants/rak3112/platformio.ini index 9cd32c4b4d..2b16c99127 100644 --- a/variants/rak3112/platformio.ini +++ b/variants/rak3112/platformio.ini @@ -115,6 +115,50 @@ lib_deps = ${rak3112.lib_deps} ${esp32_ota.lib_deps} +[env:RAK_3112_repeater_wifi] +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 WIFI_PROVISIONING=1 + -D WIFI_DEBUG_LOGGING=1 + -D MQTT_PUBLISHER=1 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${rak3112.build_src_filter} + + + +<../examples/simple_repeater> +lib_deps = + ${rak3112.lib_deps} + ${esp32_ota.lib_deps} + knolleary/PubSubClient @ ^2.8 + densaugeo/base64 @ ~1.4.0 + bakercp/CRC32 @ ^2.0.0 + +[env:RAK_3112_room_server_wifi] +extends = rak3112 +build_flags = + ${rak3112.build_flags} + -D ADVERT_NAME='"RAK3112 Room"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D ROOM_PASSWORD='"hello"' + -D WIFI_PROVISIONING=1 + -D WIFI_DEBUG_LOGGING=1 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${rak3112.build_src_filter} + + + +<../examples/simple_room_server> +lib_deps = + ${rak3112.lib_deps} + ${esp32_ota.lib_deps} + [env:RAK_3112_terminal_chat] extends = rak3112 build_flags = @@ -174,8 +218,9 @@ build_flags = -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 -D WIFI_DEBUG_LOGGING=1 - -D WIFI_SSID='"myssid"' - -D WIFI_PWD='"mypwd"' + -D WIFI_PROVISIONING=1 + ; -D WIFI_SSID='"myssid"' ; optional: bootstrap creds on first boot + ; -D WIFI_PWD='"mypwd"' -D OFFLINE_QUEUE_SIZE=256 ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 @@ -185,6 +230,7 @@ build_src_filter = ${rak3112.build_src_filter} +<../examples/companion_radio/ui-orig/*.cpp> lib_deps = ${rak3112.lib_deps} + ${esp32_ota.lib_deps} densaugeo/base64 @ ~1.4.0 [env:RAK_3112_sensor] From 2881eec127ca7348ff45ab4ef3b3e7123946960e Mon Sep 17 00:00:00 2001 From: zfouts Date: Mon, 25 May 2026 18:08:00 -0500 Subject: [PATCH 12/12] Fix Radio config card layout src/helpers/esp32/WifiAdminUI.cpp: - Pull the Radio config card out of the small `.cards` grid (which uses repeat(auto-fit, minmax(220px, 1fr)) and squeezed editor selects to the point that "2B (recommended)" was clipped and the MHz / dBm unit labels next to the freq and tx_power inputs were pushed offscreen). - New full-width `.rcard` section with looser inner grid (minmax(9em,11em) for the label column, auto for the value column). - Move unit suffixes into a dedicated `` so they stay next to their input instead of disappearing under right-align width competition. - Region preset chips now render in a dedicated #rcfg-presets container with its own border-top, so they read as a related but separate row rather than a fourth column getting absorbed into the kv grid. - Drop the inappropriate `class=kv` on `#rcfg` itself (it now wraps a kv child instead of being the grid). Verified visually via Playwright screenshot before/after on a live Xiao S3 WIO repeater: editors aligned, units visible, presets fit on one row. --- src/helpers/esp32/WifiAdminUI.cpp | 34 ++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/src/helpers/esp32/WifiAdminUI.cpp b/src/helpers/esp32/WifiAdminUI.cpp index 9b4e9ee3d4..5708536cd2 100644 --- a/src/helpers/esp32/WifiAdminUI.cpp +++ b/src/helpers/esp32/WifiAdminUI.cpp @@ -155,8 +155,18 @@ button.ghost{background:transparent} .kv .k{color:var(--mute);font-size:.85em} .kv .v{font-family:var(--mono);text-align:right;color:var(--ink)} .kv .v.warn{color:var(--warn)}.kv .v.bad{color:var(--danger)}.kv .v.good{color:var(--tx)} -.kv input[type=number],.kv select{padding:.15em .4em;font:inherit;font-family:var(--mono);border:1px solid var(--line);border-radius:3px;background:var(--bg);color:var(--ink);min-width:6em;text-align:right} +.kv input[type=number],.kv select{padding:.15em .4em;font:inherit;font-family:var(--mono);border:1px solid var(--line);border-radius:3px;background:var(--bg);color:var(--ink);text-align:right} .kv select{text-align:left;text-align-last:left} +.kv input[type=number]{width:7em}.kv select{min-width:9em;max-width:14em} +.rcard{background:var(--card);border:1px solid var(--line);border-radius:6px;padding:.6em;margin:.6em 0} +.rcard h3{margin:0 0 .5em;font-size:.85em;color:var(--mute);text-transform:uppercase;letter-spacing:.04em} +.rcard .kv{grid-template-columns:minmax(9em,11em) auto;column-gap:1em;row-gap:.3em} +.rcard .kv .v{text-align:left} +.rcard .unit{color:var(--mute);margin-left:.4em;font-family:var(--mono);font-size:.9em} +.rcard .presets{margin-top:.7em;padding-top:.5em;border-top:1px dashed var(--line)} +.rcard .presets .lbl{font-size:.8em;color:var(--mute);margin-bottom:.3em} +.rcard .presets .row{display:flex;flex-wrap:wrap;gap:.35em} +.rcard .presets button{font-size:.85em;padding:.3em .7em} .main{display:grid;grid-template-columns:1fr 360px;gap:.6em;margin-top:.4em} @media (max-width:880px){.main{grid-template-columns:1fr}} .panel{background:var(--card);border:1px solid var(--line);border-radius:6px;display:flex;flex-direction:column;min-height:320px} @@ -206,12 +216,17 @@ button.ghost{background:transparent}

Network

-

Radio config

Mesh

Radio stats

Packets

+
+

Radio config

+
+
+
+
@@ -371,14 +386,14 @@ function renderRadioCfg(j){ const phb=j.path_hash_bytes|0; const phCls=phb>=2?'good':'warn'; const bwOpts=[7.8,10.4,15.6,20.8,31.25,41.7,62.5,125,250,500]; - const fEd=` MHz`; + const fEd=`MHz`; const bwEd=``; const sfEd=``; const crEd=``; - const txEd=` dBm`; + const txEd=`dBm`; const phEd=``; renderKV($('rcfg'),[ @@ -390,12 +405,11 @@ function renderRadioCfg(j){ ['Path hash size', phEd, phCls], ['RX boosted gain', j.rx_boosted ? 'on' : 'off'], ]); - // Region preset chips below the kv grid. - const presets=document.createElement('div'); - presets.style.cssText='margin-top:.5em;display:flex;flex-wrap:wrap;gap:.3em'; - presets.innerHTML='
Region presets (sets freq/BW/SF/CR; reboot to apply):
'+ - RADIO_PRESETS.map((p,i)=>``).join(''); - $('rcfg').appendChild(presets); + $('rcfg-presets').innerHTML= + `
Region presets (applies freq/BW/SF/CR; reboot to take effect):
`+ + `
`+ + RADIO_PRESETS.map((p,i)=>``).join('')+ + `
`; } async function setRadio(freq,bw,sf,cr,reason){ const cmd=`set radio ${freq},${bw},${sf},${cr}`;