Add WiFi MQTT companion setup portal

This commit is contained in:
mikecarper committed 2026-07-15 00:27:20 -07:00
1 parent 11aa4bc191
commit dce99fd5b3
23 files changed
+1664 -117

No files matched your search

+14 -2
View File
@@ -31,7 +31,7 @@ PARSED_COMMAND_ARGS=()
FIRMWARE_VERSION_EXPLICIT=0
OUTPUT_POLICY_EXPLICIT=0
ENV_VARIANT_SUFFIX_PATTERN='companion_radio_serial|companion_radio_wifi|companion_radio_usb|comp_radio_usb|companion_usb|companion_radio_ble|companion_ble|repeater_bridge_rs232_serial1|repeater_bridge_rs232_serial2|repeater_bridge_rs232|repeater_bridge_espnow|terminal_chat|room_server|room_svr|kiss_modem|sensor|repeatr|repeater'
ENV_VARIANT_SUFFIX_PATTERN='companion_radio_wifi_mqtt|companion_radio_serial|companion_radio_wifi|companion_radio_usb|comp_radio_usb|companion_usb|companion_radio_ble|companion_ble|repeater_bridge_rs232_serial1|repeater_bridge_rs232_serial2|repeater_bridge_rs232|repeater_bridge_espnow|terminal_chat|room_server|room_svr|kiss_modem|sensor|repeatr|repeater'
BOARD_MODIFIER_WITHOUT_DISPLAY="_without_display"
BOARD_MODIFIER_LOGGING="_logging"
BOARD_MODIFIER_TFT="_tft"
@@ -360,6 +360,12 @@ prompt_for_debug_build_settings() {
}
prompt_for_mqtt_bridge_build_setting() {
if [ -n "$SELECTED_TARGET" ] && is_mqtt_bridge_target "$SELECTED_TARGET"; then
MQTT_BRIDGE_OVERRIDE="on"
echo "MQTT bridge enabled by selected target: ${SELECTED_TARGET}"
return 0
fi
echo "MQTT bridge sends mesh radio traffic directly to MQTT over WiFi."
prompt_on_off_choice "MQTT bridge (radio WiFi to MQTT direct)" "off"
MQTT_BRIDGE_OVERRIDE="$MENU_CHOICE"
@@ -1384,12 +1390,18 @@ normalize_resolved_targets_for_mqtt() {
if [ "${MQTT_BRIDGE_OVERRIDE,,}" == "on" ]; then
if is_mqtt_bridge_target "$target"; then
candidate=$target
elif is_mqtt_bridge_target "${target}_mqtt"; then
candidate="${target}_mqtt"
elif is_mqtt_bridge_target "${target}_observer_mqtt"; then
candidate="${target}_observer_mqtt"
fi
else
if is_mqtt_bridge_target "$target"; then
candidate=${target%_observer_mqtt}
if [[ "$target" == *_observer_mqtt ]]; then
candidate=${target%_observer_mqtt}
elif [[ "$target" == *_companion_radio_wifi_mqtt ]]; then
candidate=${target%_mqtt}
fi
if ! is_supported_build_env "$candidate" || is_mqtt_bridge_target "$candidate"; then
candidate=""
fi
+10 -5
View File
@@ -760,9 +760,9 @@ After this bootloader is flashed onto the device, you can trigger an over-the-ai
2. From the MeshCore app, log in remotely to the repeater you want to update with admin privileges.
3. Go to the Command Line tab, type `start ota` and hit enter.
4. You should see `OK` to confirm the repeater device is now in OTA mode.
5. The command `start ota` on an ESP32-based device starts a Wi-Fi hotspot named `MeshCore OTA`.
6. From your phone or computer connect to the 'MeshCore OTA' hotspot.
7. From a browser, go to <http://192.168.4.1/update> and upload the non-merged bin from the flasher.
5. If the ESP32 is not already joined to Wi-Fi, `start ota` starts an open hotspot named `MeshCore-OTA` at `192.168.4.1`. If it is already joined, the command reports and uses its router-assigned address instead.
6. From your phone or computer, connect to the `MeshCore-OTA` hotspot when one was started.
7. Open the URL reported by `start ota` and upload the non-merged bin from the flasher. When the device started `MeshCore-OTA`, the URL is <http://192.168.4.1/update>.
### 7.3. Q: Is there a way to lower the chance of a failed OTA device firmware update (DFU)?
@@ -800,8 +800,13 @@ Where `&type` is:
### 7.6. Q: How do I connect to the companion via Wi-Fi, e.g. using a Heltec V3?
**A:**
Wi-Fi firmware requires you to compile it yourself, as you need to set the Wi-Fi SSID and password.
Edit WIFI_SSID and WIFI_PWD in `./variants/heltec_v3/platformio.ini` and then flash it to your device.
Flash a companion Wi-Fi build. If it has no saved Wi-Fi configuration, it starts an open `MeshCore-Setup` access point. Join it, browse to <http://192.168.4.1/>, and submit the network SSID and password. The device saves the credentials only after a successful connection. The page and device screen then show the station IP assigned by your router.
If a saved network remains unreachable for two minutes, `MeshCore-Setup` starts as a fallback. The node retries the saved SSID and password every two minutes while the setup AP is open. If the original network returns, the node reconnects and closes `MeshCore-Setup` automatically.
The setup AP always uses `192.168.4.1`; the companion's station IP is assigned by DHCP and can be different. A real `WIFI_SSID` and `WIFI_PWD` can still be supplied at compile time as initial defaults, but they are no longer required.
The `Heltec_v3_companion_radio_wifi_mqtt` and `heltec_v4_companion_radio_wifi_mqtt` builds use a two-stage first-run setup. The open `MeshCore-Setup` AP serves only the SSID/password form at <http://192.168.4.1/>. After the companion joins that network, reconnect your phone or computer to the same network and open the station IP shown on the setup page or device screen. That second, station-only page asks for the MQTT preset or broker, routing identity, credentials, and publish options. It remains available at the node's station IP so MQTT settings can be changed later; the companion interface remains available on TCP port `5000`.
### 7.7. Q: I have a Station G2, or a Heltec V4, or an Ikoka Stick, or a radio with an EByte E22-900M30S or an EByte E22-900M33S module, what should their transmit power be set to?
**A:**
+108
View File
@@ -3,6 +3,10 @@
#include <Arduino.h> // needed for PlatformIO
#include <Mesh.h>
#if defined(WITH_MQTT_BRIDGE) && defined(ESP32_PLATFORM) && defined(WIFI_SSID)
#include <helpers/MQTTDefaults.h>
#endif
static uint32_t nextRadioApplyRetryDelay(uint8_t& failure_count) {
uint8_t shift = failure_count < 5 ? failure_count : 5;
if (failure_count < 6) failure_count++;
@@ -408,6 +412,12 @@ void MyMesh::updateGpsTelemetryPolicy() {
}
void MyMesh::logRxRaw(float snr, float rssi, const uint8_t raw[], int len) {
#if defined(WITH_MQTT_BRIDGE) && defined(ESP32_PLATFORM) && defined(WIFI_SSID)
if (_mqtt_bridge && _mqtt_bridge->isRunning()) {
_mqtt_bridge->storeRawRadioData(raw, len, snr, rssi);
}
#endif
if (_serial->isConnected() && len + 3 <= MAX_FRAME_SIZE) {
int i = 0;
out_frame[i++] = PUSH_CODE_LOG_RX_DATA;
@@ -420,6 +430,20 @@ void MyMesh::logRxRaw(float snr, float rssi, const uint8_t raw[], int len) {
}
}
#if defined(WITH_MQTT_BRIDGE) && defined(ESP32_PLATFORM) && defined(WIFI_SSID)
void MyMesh::logRx(mesh::Packet* packet, int, float) {
if (_mqtt_bridge && _mqtt_bridge->isRunning()) {
_mqtt_bridge->onPacketReceived(packet);
}
}
void MyMesh::logTx(mesh::Packet* packet, int) {
if (_mqtt_bridge && _mqtt_bridge->isRunning()) {
_mqtt_bridge->sendPacket(packet);
}
}
#endif
bool MyMesh::isAutoAddEnabled() const {
return (_prefs.manual_add_contacts & 1) == 0;
}
@@ -1128,6 +1152,13 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe
#endif
#endif
_prefs.radio_fem_rxgain = 1;
#if defined(WITH_MQTT_BRIDGE) && defined(ESP32_PLATFORM) && defined(WIFI_SSID)
memset(&_mqtt_prefs, 0, sizeof(_mqtt_prefs));
_mqtt_bridge = nullptr;
_mqtt_configured = false;
_mqtt_started = false;
#endif
}
void MyMesh::begin(bool has_display) {
@@ -1219,6 +1250,32 @@ void MyMesh::begin(bool has_display) {
radio_driver.getRxBoostedGainMode() ? "Enabled" : "Disabled");
// NOTE: no FEM LNA wiring here — companion has its own NodePrefs without
// radio_fem_rxgain, matching upstream (which also doesn't wire companion).
#if defined(WITH_MQTT_BRIDGE) && defined(ESP32_PLATFORM) && defined(WIFI_SSID)
applyMQTTDefaults(&_mqtt_prefs);
_mqtt_configured = CompanionMqttSetupPortal::loadStoredConfig(_mqtt_prefs);
if (!_mqtt_configured) applyMQTTDefaults(&_mqtt_prefs);
MQTTNodeInfo node_info;
node_info.node_name = _prefs.node_name;
node_info.freq = &_prefs.freq;
node_info.bw = &_prefs.bw;
node_info.sf = &_prefs.sf;
node_info.cr = &_prefs.cr;
node_info.repeat_flag = &_prefs.client_repeat;
node_info.repeat_when_nonzero = true;
_mqtt_bridge = new MQTTBridge(node_info, &_mqtt_prefs,
getRTCClock(), &self_id, false);
if (_mqtt_bridge) {
char device_id[65];
mesh::Utils::toHex(device_id, self_id.pub_key, PUB_KEY_SIZE);
_mqtt_bridge->setDeviceID(device_id);
_mqtt_bridge->setFirmwareVersion(FIRMWARE_VERSION);
_mqtt_bridge->setBoardModel(board.getManufacturerName());
_mqtt_bridge->setBuildDate(FIRMWARE_BUILD_DATE);
_mqtt_bridge->setStatsSources(this, _radio, &board, _ms);
}
#endif
}
bool MyMesh::applySavedRadioParams() {
@@ -1239,6 +1296,57 @@ uint32_t MyMesh::getBLEPin() {
return _active_ble_pin;
}
#if defined(WITH_MQTT_BRIDGE) && defined(ESP32_PLATFORM) && defined(WIFI_SSID)
static void copyMqttString(char* dest, size_t dest_size, const char* src) {
strncpy(dest, src ? src : "", dest_size - 1);
dest[dest_size - 1] = 0;
}
void MyMesh::serviceMQTT(const char* wifi_ssid, const char* wifi_password,
bool allow_setup_page) {
if (!_mqtt_started && !_mqtt_setup.isActive()) {
if (strcmp(_mqtt_prefs.wifi_ssid, wifi_ssid ? wifi_ssid : "") != 0) {
copyMqttString(_mqtt_prefs.wifi_ssid, sizeof(_mqtt_prefs.wifi_ssid), wifi_ssid);
}
if (strcmp(_mqtt_prefs.wifi_password, wifi_password ? wifi_password : "") != 0) {
copyMqttString(_mqtt_prefs.wifi_password, sizeof(_mqtt_prefs.wifi_password), wifi_password);
}
}
if (WiFi.status() != WL_CONNECTED || !allow_setup_page) {
_mqtt_setup.stop();
return;
}
if (_mqtt_setup.loop()) {
if (_mqtt_started && _mqtt_bridge) {
_mqtt_bridge->end();
}
_mqtt_started = false;
_mqtt_configured = true;
Serial.println("MQTT companion: applying settings saved from the web page");
}
if (!_mqtt_setup.isActive()) {
_mqtt_setup.begin(&_mqtt_prefs);
}
if (_mqtt_configured && !_mqtt_started && _mqtt_bridge) {
_mqtt_started = true; // begin is one-shot; avoid retrying partial allocations
_mqtt_bridge->begin();
if (_mqtt_bridge->isRunning()) {
Serial.println("MQTT companion: bridge started");
} else {
Serial.println("MQTT companion: bridge could not start");
}
}
}
void MyMesh::stopMQTTSetupPage() {
_mqtt_setup.stop();
}
#endif
struct FreqRange {
uint32_t lower_freq, upper_freq;
};
+27
View File
@@ -26,6 +26,12 @@
#include "DataStore.h"
#include "NodePrefs.h"
#if defined(WITH_MQTT_BRIDGE) && defined(ESP32_PLATFORM) && defined(WIFI_SSID)
#include <helpers/CompanionMqttSetupPortal.h>
#include <helpers/MQTTPrefs.h>
#include <helpers/bridges/MQTTBridge.h>
#endif
#include <RTClib.h>
#include <helpers/ArduinoHelpers.h>
#include <helpers/BaseSerialInterface.h>
@@ -95,6 +101,13 @@ public:
NodePrefs *getNodePrefs();
uint32_t getBLEPin();
#if defined(WITH_MQTT_BRIDGE) && defined(ESP32_PLATFORM) && defined(WIFI_SSID)
void serviceMQTT(const char* wifi_ssid, const char* wifi_password,
bool allow_setup_page);
void stopMQTTSetupPage();
bool isMQTTConfigured() const { return _mqtt_configured; }
#endif
void loop();
void handleCmdFrame(size_t len);
bool advert();
@@ -106,6 +119,9 @@ protected:
float getAirtimeBudgetFactor() const override;
int getInterferenceThreshold() const override;
bool getCADEnabled() const override;
#ifdef WITH_MQTT_BRIDGE
uint32_t getRadioWatchdogMillis() const override { return 0; }
#endif
int calcRxDelay(float score, uint32_t air_time) const override;
uint32_t getRetransmitDelay(const mesh::Packet *packet) override;
uint32_t getDirectRetransmitDelay(const mesh::Packet *packet) override;
@@ -119,6 +135,10 @@ protected:
bool sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pkt, uint32_t delay_millis=0) override;
void logRxRaw(float snr, float rssi, const uint8_t raw[], int len) override;
#if defined(WITH_MQTT_BRIDGE) && defined(ESP32_PLATFORM) && defined(WIFI_SSID)
void logRx(mesh::Packet* packet, int len, float score) override;
void logTx(mesh::Packet* packet, int len) override;
#endif
bool isAutoAddEnabled() const override;
bool shouldAutoAddContactType(uint8_t type) const override;
bool shouldOverwriteWhenFull() const override;
@@ -210,6 +230,13 @@ private:
DataStore* _store;
NodePrefs _prefs;
#if defined(WITH_MQTT_BRIDGE) && defined(ESP32_PLATFORM) && defined(WIFI_SSID)
MQTTPrefs _mqtt_prefs;
MQTTBridge* _mqtt_bridge;
CompanionMqttSetupPortal _mqtt_setup;
bool _mqtt_configured;
bool _mqtt_started;
#endif
uint32_t pending_login;
uint32_t pending_status;
uint32_t pending_telemetry, pending_discovery; // pending _TELEMETRY_REQ
+134 -2
View File
@@ -42,10 +42,14 @@ static uint32_t _atoi(const char* sp) {
#ifdef ESP32
#ifdef WIFI_SSID
#include <helpers/esp32/SerialWifiInterface.h>
#include <helpers/WiFiSetupPortal.h>
SerialWifiInterface serial_interface;
#ifndef TCP_PORT
#define TCP_PORT 5000
#endif
#ifndef WIFI_PWD
#define WIFI_PWD ""
#endif
#elif defined(BLE_PIN_CODE)
#include <helpers/esp32/SerialBLEInterface.h>
SerialBLEInterface serial_interface;
@@ -112,8 +116,75 @@ void halt() {
/* WIFI RECONNECT TRACKERS */
#if defined(ESP32) && defined(WIFI_SSID)
static const unsigned long WIFI_SETUP_FALLBACK_MS = 120000UL;
static const char COMPANION_WIFI_SETUP_AP[] = "MeshCore-Setup";
bool wifi_needs_reconnect = false;
unsigned long last_wifi_reconnect_attempt = 0;
unsigned long wifi_disconnected_since = 0;
bool wifi_setup_recovery_mode = false;
static char configured_wifi_ssid[32];
static char configured_wifi_password[64];
#if defined(WITH_MQTT_BRIDGE) && defined(DISPLAY_CLASS)
static DisplayDriver* companion_setup_display = nullptr;
static unsigned long companion_setup_display_refresh = 0;
static void renderCompanionSetupDisplay() {
if (!companion_setup_display
|| static_cast<int32_t>(millis() - companion_setup_display_refresh) < 0) return;
companion_setup_display_refresh = millis() + 1000;
companion_setup_display->turnOn();
companion_setup_display->startFrame();
companion_setup_display->setTextSize(1);
companion_setup_display->setColor(DisplayDriver::LIGHT);
if (WiFi.status() == WL_CONNECTED) {
companion_setup_display->drawTextCentered(
companion_setup_display->width() / 2, 0, "MQTT setup");
companion_setup_display->setCursor(0, 14);
companion_setup_display->print("Join WiFi:");
companion_setup_display->drawTextEllipsized(
0, 25, companion_setup_display->width(), configured_wifi_ssid);
companion_setup_display->setCursor(0, 39);
companion_setup_display->print("Open in browser:");
const String ip = WiFi.localIP().toString();
companion_setup_display->drawTextCentered(
companion_setup_display->width() / 2, 51, ip.c_str());
} else if (wifiSetupPortal().isActive()) {
companion_setup_display->drawTextCentered(
companion_setup_display->width() / 2, 0, "WiFi setup");
companion_setup_display->setCursor(0, 14);
companion_setup_display->print("Join open WiFi:");
companion_setup_display->drawTextEllipsized(
0, 25, companion_setup_display->width(), COMPANION_WIFI_SETUP_AP);
companion_setup_display->setCursor(0, 39);
companion_setup_display->print("Open in browser:");
const String ip = wifiSetupPortal().apIP().toString();
companion_setup_display->drawTextCentered(
companion_setup_display->width() / 2, 51, ip.c_str());
} else {
companion_setup_display->drawTextCentered(
companion_setup_display->width() / 2, 8, "WiFi connecting");
companion_setup_display->setCursor(0, 25);
companion_setup_display->print("SSID:");
companion_setup_display->drawTextEllipsized(
0, 38, companion_setup_display->width(), configured_wifi_ssid);
companion_setup_display->drawTextCentered(
companion_setup_display->width() / 2, 52, "Please wait...");
}
companion_setup_display->endFrame();
}
#endif
static bool saveCompanionWiFi(void*, const char* ssid, const char* password) {
if (!WiFiSetupPortal::saveStoredCredentials(ssid, password)) return false;
strncpy(configured_wifi_ssid, ssid, sizeof(configured_wifi_ssid) - 1);
configured_wifi_ssid[sizeof(configured_wifi_ssid) - 1] = '\0';
strncpy(configured_wifi_password, password ? password : "", sizeof(configured_wifi_password) - 1);
configured_wifi_password[sizeof(configured_wifi_password) - 1] = '\0';
return true;
}
#endif
/* WIFI OTA SEEDER — relay a host folder of .mota over WiFi (motatool `serve --tcp`), on a DEDICATED port
@@ -213,6 +284,9 @@ void setup() {
DisplayDriver* disp = NULL;
if (display.begin()) {
disp = &display;
#if defined(ESP32) && defined(WIFI_SSID) && defined(WITH_MQTT_BRIDGE)
companion_setup_display = disp;
#endif
disp->startFrame();
#ifdef ST7789
disp->setTextSize(2);
@@ -300,14 +374,35 @@ void setup() {
if (event == ARDUINO_EVENT_WIFI_STA_DISCONNECTED) {
WIFI_DEBUG_PRINTLN("WiFi disconnected. Flagging for reconnect...");
wifi_needs_reconnect = true;
if (wifi_disconnected_since == 0) {
wifi_disconnected_since = millis() ? millis() : 1;
}
} else if (event == ARDUINO_EVENT_WIFI_STA_GOT_IP) {
WIFI_DEBUG_PRINTLN("connected! IP %s (companion app on :%d)",
WiFi.localIP().toString().c_str(), TCP_PORT);
wifi_needs_reconnect = false;
wifi_disconnected_since = 0;
}
});
WiFi.begin(WIFI_SSID, WIFI_PWD);
bool have_wifi = WiFiSetupPortal::loadStoredCredentials(
configured_wifi_ssid, sizeof(configured_wifi_ssid),
configured_wifi_password, sizeof(configured_wifi_password));
if (!have_wifi && !WiFiSetupPortal::isPlaceholderSSID(WIFI_SSID)) {
strncpy(configured_wifi_ssid, WIFI_SSID, sizeof(configured_wifi_ssid) - 1);
configured_wifi_ssid[sizeof(configured_wifi_ssid) - 1] = '\0';
strncpy(configured_wifi_password, WIFI_PWD, sizeof(configured_wifi_password) - 1);
configured_wifi_password[sizeof(configured_wifi_password) - 1] = '\0';
have_wifi = true;
}
if (have_wifi) {
WiFi.mode(WIFI_STA);
wifi_disconnected_since = millis() ? millis() : 1;
WiFi.begin(configured_wifi_ssid, configured_wifi_password);
} else if (!wifiSetupPortal().begin(COMPANION_WIFI_SETUP_AP, saveCompanionWiFi, nullptr)) {
WIFI_DEBUG_PRINTLN("WiFi setup: could not start setup portal");
}
// Disable WiFi modem power-save: its periodic modem/light-sleep stalls the SX1262 SPI+DIO servicing,
// which makes the LoRa radio go deaf (no TX/RX) while WiFi is associated. Required for LoRa+WiFi to
// coexist on this ESP32 — the small extra idle current is well worth a working radio.
@@ -381,7 +476,15 @@ void loop() {
the_mesh.loop();
sensors.loop();
#ifdef DISPLAY_CLASS
#if defined(ESP32) && defined(WIFI_SSID) && defined(WITH_MQTT_BRIDGE)
if (the_mesh.isMQTTConfigured()) {
ui_task.loop();
} else {
renderCompanionSetupDisplay();
}
#else
ui_task.loop();
#endif
#endif
rtc_clock.tick();
#ifdef HAS_EXTERNAL_WATCHDOG
@@ -403,12 +506,41 @@ void loop() {
ota_seeder_loop(); // accept/drop a motatool `serve --tcp` connection on the dedicated seeder port
ota_console_loop(); // service the OTA text console (port 5002)
#endif
if (WiFi.status() == WL_CONNECTED) {
wifi_disconnected_since = 0;
if (wifi_setup_recovery_mode && !wifiSetupPortal().isActive()) {
wifi_setup_recovery_mode = false;
}
} else if (configured_wifi_ssid[0]) {
unsigned long now = millis();
if (wifi_disconnected_since == 0) wifi_disconnected_since = now ? now : 1;
if (!wifi_setup_recovery_mode && !wifiSetupPortal().isActive()
&& now - wifi_disconnected_since >= WIFI_SETUP_FALLBACK_MS) {
#ifdef WITH_MQTT_BRIDGE
the_mesh.stopMQTTSetupPage();
#endif
if (wifiSetupPortal().begin(COMPANION_WIFI_SETUP_AP, saveCompanionWiFi, nullptr)) {
wifiSetupPortal().configureRecovery(
configured_wifi_ssid, configured_wifi_password, WIFI_SETUP_FALLBACK_MS);
wifi_setup_recovery_mode = true;
WIFI_DEBUG_PRINTLN("WiFi unavailable for two minutes; setup AP started");
} else {
// Avoid retrying portal creation on every pass through loop().
wifi_disconnected_since = now;
}
}
}
// Safely attempt to reconnect every 10 seconds if flagged
if (wifi_needs_reconnect && (millis() - last_wifi_reconnect_attempt > 10000)) {
if (!wifiSetupPortal().isActive() && wifi_needs_reconnect
&& (millis() - last_wifi_reconnect_attempt > 10000)) {
WIFI_DEBUG_PRINTLN("Attempting manual WiFi reconnect...");
WiFi.disconnect();
WiFi.reconnect();
last_wifi_reconnect_attempt = millis();
}
#ifdef WITH_MQTT_BRIDGE
the_mesh.serviceMQTT(configured_wifi_ssid, configured_wifi_password,
!wifiSetupPortal().isActive());
#endif
#endif
}
+13 -4
View File
@@ -2481,9 +2481,18 @@ void MyMesh::begin(FILESYSTEM *fs) {
if (_prefs.bridge_enabled) {
#ifdef WITH_MQTT_BRIDGE
// Defer construction to avoid static init crashes on ESP32 classic
mqtt_bridge = new MQTTBridge(&_prefs, _cli.getObserverPrefs(), _mgr, getRTCClock(), &self_id);
MQTTNodeInfo node_info;
node_info.node_name = _prefs.node_name;
node_info.freq = &_prefs.freq;
node_info.bw = &_prefs.bw;
node_info.sf = &_prefs.sf;
node_info.cr = &_prefs.cr;
node_info.repeat_flag = &_prefs.disable_fwd;
node_info.repeat_when_nonzero = false;
mqtt_bridge = new MQTTBridge(node_info, _cli.getObserverPrefs(),
getRTCClock(), &self_id);
#endif
BridgeBase* active_bridge = activeBridge();
AbstractBridge* active_bridge = activeBridge();
if (active_bridge) {
#ifdef WITH_MQTT_BRIDGE
// Set device public key for MQTT topics
@@ -4383,7 +4392,7 @@ void MyMesh::loop() {
#if defined(WITH_BRIDGE) && !defined(WITH_MQTT_BRIDGE)
// MQTT runs its own task; serial and ESP-NOW bridges remain cooperative.
BridgeBase* active_bridge = activeBridge();
AbstractBridge* active_bridge = activeBridge();
if (active_bridge && active_bridge->isRunning()) active_bridge->loop();
#endif
@@ -4458,7 +4467,7 @@ void MyMesh::loop() {
// To check if there is pending work
bool MyMesh::hasPendingWork() const {
#if defined(WITH_BRIDGE)
const BridgeBase* active_bridge = activeBridge();
const AbstractBridge* active_bridge = activeBridge();
if (active_bridge && active_bridge->isRunning()) return true;
#endif
if (radio_driver.isWatchdogObserving()) return true; // keep MCU awake for one radio duty cycle
+14 -5
View File
@@ -211,14 +211,14 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks {
ESPNowBridge bridge;
#endif
#ifdef WITH_BRIDGE
BridgeBase* activeBridge() {
AbstractBridge* activeBridge() {
#ifdef WITH_MQTT_BRIDGE
return mqtt_bridge;
#else
return &bridge;
#endif
}
const BridgeBase* activeBridge() const {
const AbstractBridge* activeBridge() const {
#ifdef WITH_MQTT_BRIDGE
return mqtt_bridge;
#else
@@ -472,11 +472,20 @@ public:
void setBridgeState(bool enable) override {
#ifdef WITH_MQTT_BRIDGE
if (!mqtt_bridge) {
mqtt_bridge = new MQTTBridge(&_prefs, _cli.getObserverPrefs(), _mgr, getRTCClock(), &self_id);
MQTTNodeInfo node_info;
node_info.node_name = _prefs.node_name;
node_info.freq = &_prefs.freq;
node_info.bw = &_prefs.bw;
node_info.sf = &_prefs.sf;
node_info.cr = &_prefs.cr;
node_info.repeat_flag = &_prefs.disable_fwd;
node_info.repeat_when_nonzero = false;
mqtt_bridge = new MQTTBridge(node_info, _cli.getObserverPrefs(),
getRTCClock(), &self_id);
if (!mqtt_bridge) return;
}
#endif
BridgeBase* active_bridge = activeBridge();
AbstractBridge* active_bridge = activeBridge();
if (!active_bridge || enable == active_bridge->isRunning()) return;
if (enable)
{
@@ -506,7 +515,7 @@ public:
}
void restartBridge() override {
BridgeBase* active_bridge = activeBridge();
AbstractBridge* active_bridge = activeBridge();
if (!active_bridge || !active_bridge->isRunning()) return;
active_bridge->end();
#ifdef WITH_MQTT_BRIDGE
+10 -1
View File
@@ -840,7 +840,16 @@ void MyMesh::begin(FILESYSTEM *fs) {
#ifdef WITH_MQTT_BRIDGE
if (_prefs.bridge_enabled) {
// Defer construction to avoid static init crashes on ESP32 classic
bridge = new MQTTBridge(&_prefs, _cli.getObserverPrefs(), _mgr, getRTCClock(), &self_id);
MQTTNodeInfo node_info;
node_info.node_name = _prefs.node_name;
node_info.freq = &_prefs.freq;
node_info.bw = &_prefs.bw;
node_info.sf = &_prefs.sf;
node_info.cr = &_prefs.cr;
node_info.repeat_flag = &_prefs.disable_fwd;
node_info.repeat_when_nonzero = false;
bridge = new MQTTBridge(node_info, _cli.getObserverPrefs(),
getRTCClock(), &self_id);
if (bridge) {
// Set device public key for MQTT topics
char device_id[65];
+10 -1
View File
@@ -277,7 +277,16 @@ public:
void setBridgeState(bool enable) override {
if (!bridge) {
#ifdef WITH_MQTT_BRIDGE
bridge = new MQTTBridge(&_prefs, _cli.getObserverPrefs(), _mgr, getRTCClock(), &self_id);
MQTTNodeInfo node_info;
node_info.node_name = _prefs.node_name;
node_info.freq = &_prefs.freq;
node_info.bw = &_prefs.bw;
node_info.sf = &_prefs.sf;
node_info.cr = &_prefs.cr;
node_info.repeat_flag = &_prefs.disable_fwd;
node_info.repeat_when_nonzero = false;
bridge = new MQTTBridge(node_info, _cli.getObserverPrefs(),
getRTCClock(), &self_id);
#endif
if (!bridge) return;
}
+2 -70
View File
@@ -5,6 +5,7 @@
#include <helpers/SensorManager.h>
#include <helpers/ClientACL.h>
#include <helpers/MQTTPresets.h> // For MAX_MQTT_SLOTS (used in NodePrefs struct layout)
#include <helpers/MQTTPrefs.h>
#include <helpers/RegionMap.h>
#if defined(WITH_RS232_BRIDGE) || defined(WITH_ESPNOW_BRIDGE) || defined(WITH_MQTT_BRIDGE)
@@ -197,7 +198,7 @@ struct NodePrefs { // persisted to file
// NOTE: observer settings (MQTT/WiFi/timezone/SNMP/alert) were moved out of
// NodePrefs into MQTTPrefs (persisted to /mqtt_prefs) so this struct stays
// aligned with upstream (plus the keymind retry/flood/telemetry tail above). See
// struct MQTTPrefs below.
// helpers/MQTTPrefs.h.
#if defined(ENABLE_OTA)
// OTA config (persisted; synced to OtaContext on load, written on change). 0 = conservative defaults.
@@ -245,75 +246,6 @@ struct OldMQTTPrefs {
char mqtt_email[64];
};
// MQTT preferences stored in separate file to avoid conflicts with upstream NodePrefs changes
struct MQTTPrefs {
// MQTT settings
char mqtt_origin[32]; // Device name for MQTT topics
char mqtt_iata[8]; // IATA code for MQTT topics
uint8_t mqtt_status_enabled; // Enable status messages
uint8_t mqtt_packets_enabled; // Enable packet messages
uint8_t mqtt_raw_enabled; // Enable raw messages
uint8_t mqtt_tx_enabled; // Enable TX packet uplinking
uint32_t mqtt_status_interval; // Status publish interval (ms)
// WiFi settings
char wifi_ssid[32]; // WiFi SSID
char wifi_password[64]; // WiFi password
uint8_t wifi_power_save; // WiFi power save mode: 0=min, 1=none, 2=max (default: 1=none)
// Timezone settings
char timezone_string[32]; // Timezone string (e.g., "America/Los_Angeles")
int8_t timezone_offset; // Timezone offset in hours (-12 to +14) - fallback
// Slot presets (up to MAX_MQTT_SLOTS)
char mqtt_slot_preset[MAX_MQTT_SLOTS][24]; // e.g. "analyzer-us", "meshmapper", "custom", "none"
// Per-slot custom broker settings (only used when preset is "custom")
char mqtt_slot_host[MAX_MQTT_SLOTS][64];
uint16_t mqtt_slot_port[MAX_MQTT_SLOTS];
char mqtt_slot_username[MAX_MQTT_SLOTS][32];
char mqtt_slot_password[MAX_MQTT_SLOTS][64];
// Shared authentication
char mqtt_owner_public_key[65]; // Owner public key (hex string)
char mqtt_email[64]; // Owner email address
// Per-slot extended fields
char mqtt_slot_token[MAX_MQTT_SLOTS][48]; // Per-slot token (e.g., MeshRank account token)
char mqtt_slot_topic[MAX_MQTT_SLOTS][96]; // Per-slot custom topic template (custom preset only)
char mqtt_slot_audience[MAX_MQTT_SLOTS][64]; // JWT audience (non-empty enables JWT auth for custom slots)
uint8_t mqtt_rx_enabled; // Enable RX packet uplinking (default: on)
char mqtt_ntp_server[64]; // Custom NTP server; empty = pool.ntp.org
// Observer non-MQTT settings (moved out of NodePrefs so this file stays aligned
// with upstream). New fields are appended here so a shorter /mqtt_prefs payload
// from an earlier v1 firmware still loads; the missing tail keeps its default.
uint8_t snmp_enabled; // boolean
char snmp_community[24]; // community string (default "public")
uint8_t radio_watchdog_minutes; // 0=disabled, 1-120 minutes (observer-only radio recovery)
uint8_t alert_enabled; // 0 = off (default)
char alert_psk_hex[33]; // 32 hex chars + null; empty = alerts disabled
uint16_t alert_wifi_minutes; // WiFi-down threshold (0 = disabled), default 30
uint16_t alert_mqtt_minutes; // MQTT-down threshold (0 = disabled), default 240
uint16_t alert_min_interval_min; // min minutes between same-fault alerts, default 60
char alert_hashtag[24]; // readback for `get alert.hashtag`
char alert_region[31]; // optional region override; empty = default_scope
};
// /mqtt_prefs is written with an 8-byte header so the format is self-describing.
// Files with no header are legacy (versionless) and detected by size in loadMQTTPrefs.
// The magic leads with a non-ASCII byte so it can never collide with the first
// bytes of a legacy file, whose payload starts with the mqtt_origin string.
static const uint8_t MQTT_PREFS_MAGIC[4] = {0xF5, 'M', 'Q', 'P'};
static const uint16_t MQTT_PREFS_VERSION = 1; // bump when the MQTTPrefs payload layout changes incompatibly
struct MQTTPrefsHeader {
uint8_t magic[4]; // MQTT_PREFS_MAGIC
uint16_t version; // MQTT_PREFS_VERSION
uint16_t payload_len; // sizeof(MQTTPrefs) at write time (sanity / forward-compat)
};
// 3-slot MQTTPrefs layout — used for migrating from 3-slot to 6-slot format.
// Changing array sizes from [3] to [6] shifts all field offsets, so raw file.read()
// into the new struct would corrupt data. This struct preserves the old binary layout.
+2 -1
View File
@@ -674,7 +674,8 @@ bool CommonCLI::handleObserverGetCmd(uint32_t sender_timestamp, const char* conf
#ifdef WITH_MQTT_BRIDGE
} else if (memcmp(config, "mqtt.origin", 11) == 0) {
char effective_origin[32];
MQTTBridge::getEffectiveMqttOrigin(_prefs, &_mqtt_prefs, effective_origin, sizeof(effective_origin));
MQTTBridge::getEffectiveMqttOrigin(_prefs->node_name, &_mqtt_prefs,
effective_origin, sizeof(effective_origin));
sprintf(reply, "> %s", effective_origin);
} else if (memcmp(config, "mqtt.iata", 9) == 0) {
sprintf(reply, "> %s", _mqtt_prefs.mqtt_iata);
+633
View File
@@ -0,0 +1,633 @@
#include "CompanionMqttSetupPortal.h"
#if defined(ESP32_PLATFORM) && defined(WIFI_SSID) && defined(WITH_MQTT_BRIDGE)
#include <Arduino.h>
#include <Preferences.h>
#include <WiFi.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <helpers/MQTTDefaults.h>
#include <helpers/MQTTPresets.h>
#include <strings.h>
namespace {
static const char* NVS_NAMESPACE = "mesh-mqtt";
static const char* NVS_VERSION_KEY = "version";
static const char* NVS_PREFS_KEY = "prefs";
static const size_t MAX_FORM_BODY = 2048;
struct PortalImpl {
WiFiServer server{80};
MQTTPrefs* prefs = nullptr;
MQTTPrefs page_prefs{};
TaskHandle_t task = nullptr;
volatile bool* active = nullptr;
volatile bool saved = false;
portMUX_TYPE prefs_lock = portMUX_INITIALIZER_UNLOCKED;
};
template <size_t N>
static bool hasTerminator(const char (&value)[N]) {
return memchr(value, 0, N) != nullptr;
}
static bool storedStringsAreValid(const MQTTPrefs& prefs) {
if (!hasTerminator(prefs.mqtt_origin)
|| !hasTerminator(prefs.mqtt_iata)
|| !hasTerminator(prefs.wifi_ssid)
|| !hasTerminator(prefs.wifi_password)
|| !hasTerminator(prefs.timezone_string)
|| !hasTerminator(prefs.mqtt_owner_public_key)
|| !hasTerminator(prefs.mqtt_email)
|| !hasTerminator(prefs.mqtt_ntp_server)
|| !hasTerminator(prefs.snmp_community)
|| !hasTerminator(prefs.alert_psk_hex)
|| !hasTerminator(prefs.alert_hashtag)
|| !hasTerminator(prefs.alert_region)) {
return false;
}
for (int i = 0; i < MAX_MQTT_SLOTS; i++) {
if (!hasTerminator(prefs.mqtt_slot_preset[i])
|| !hasTerminator(prefs.mqtt_slot_host[i])
|| !hasTerminator(prefs.mqtt_slot_username[i])
|| !hasTerminator(prefs.mqtt_slot_password[i])
|| !hasTerminator(prefs.mqtt_slot_token[i])
|| !hasTerminator(prefs.mqtt_slot_topic[i])
|| !hasTerminator(prefs.mqtt_slot_audience[i])) {
return false;
}
}
return true;
}
static bool readLine(WiFiClient& client, char* line, size_t capacity) {
size_t length = 0;
const uint32_t deadline = millis() + 5000;
while (client.connected()) {
while (client.available()) {
int c = client.read();
if (c < 0) break;
if (c == '\n') {
if (length && line[length - 1] == '\r') length--;
line[length] = 0;
return true;
}
if (length + 1 < capacity) line[length++] = static_cast<char>(c);
}
if (static_cast<int32_t>(millis() - deadline) >= 0) break;
delay(1);
}
return false;
}
static int hexValue(char c) {
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
return -1;
}
static bool decodeFormValue(const char* encoded, size_t encoded_len,
char* output, size_t output_size) {
if (!output || output_size == 0) return false;
size_t out = 0;
for (size_t i = 0; i < encoded_len; i++) {
char c = encoded[i];
if (c == '+') {
c = ' ';
} else if (c == '%' && i + 2 < encoded_len) {
const int hi = hexValue(encoded[i + 1]);
const int lo = hexValue(encoded[i + 2]);
if (hi < 0 || lo < 0) return false;
c = static_cast<char>((hi << 4) | lo);
i += 2;
}
if (c == 0 || out + 1 >= output_size) return false;
output[out++] = c;
}
output[out] = 0;
return true;
}
static bool getFormField(const char* body, const char* name,
char* output, size_t output_size) {
const size_t name_len = strlen(name);
const char* field = body;
while (field && *field) {
const char* end = strchr(field, '&');
const size_t field_len = end ? static_cast<size_t>(end - field) : strlen(field);
if (field_len >= name_len + 1 && field[name_len] == '='
&& strncmp(field, name, name_len) == 0) {
return decodeFormValue(field + name_len + 1, field_len - name_len - 1,
output, output_size);
}
field = end ? end + 1 : nullptr;
}
if (output && output_size) output[0] = 0;
return false;
}
static bool hasFormField(const char* body, const char* name) {
char value[8];
return getFormField(body, name, value, sizeof(value));
}
static void sendHeader(WiFiClient& client, int code, const char* reason,
const char* content_type = "text/html") {
client.printf("HTTP/1.1 %d %s\r\nContent-Type: %s\r\n"
"Connection: close\r\nCache-Control: no-store\r\n\r\n",
code, reason, content_type);
}
static void printHtmlEscaped(WiFiClient& client, const char* value) {
if (!value) return;
while (*value) {
switch (*value++) {
case '&': client.print(F("&amp;")); break;
case '<': client.print(F("&lt;")); break;
case '>': client.print(F("&gt;")); break;
case '\"': client.print(F("&quot;")); break;
case '\'': client.print(F("&#39;")); break;
default: client.write(static_cast<uint8_t>(value[-1])); break;
}
}
}
static void printValueAttribute(WiFiClient& client, const char* value) {
client.print(F(" value=\""));
printHtmlEscaped(client, value);
client.print('\"');
}
static void snapshotPagePrefs(PortalImpl* impl, MQTTPrefs& snapshot) {
portENTER_CRITICAL(&impl->prefs_lock);
snapshot = impl->page_prefs;
portEXIT_CRITICAL(&impl->prefs_lock);
}
static uint16_t presetPort(const MQTTPresetDef& preset) {
const char* scheme = strstr(preset.server_url, "://");
if (!scheme) return 0;
const char* host = scheme + 3;
const char* colon = strchr(host, ':');
if (colon) {
char* end = nullptr;
const unsigned long port = strtoul(colon + 1, &end, 10);
if (end != colon + 1 && port <= 65535) return static_cast<uint16_t>(port);
}
if (strncmp(preset.server_url, "mqtt://", 7) == 0) return 1883;
if (strncmp(preset.server_url, "mqtts://", 8) == 0) return 8883;
if (strncmp(preset.server_url, "ws://", 5) == 0) return 80;
return 443;
}
static void sendConfigPage(WiFiClient& client, const MQTTPrefs& prefs) {
const char* selected_preset = prefs.mqtt_slot_preset[0][0]
? prefs.mqtt_slot_preset[0] : MQTT_PRESET_CUSTOM;
sendHeader(client, 200, "OK");
client.print(F(R"HTML(<!doctype html><html><head>
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>MeshCore MQTT setup</title><style>
body{font-family:sans-serif;max-width:38rem;margin:2rem auto;padding:0 1rem;color:#222}
label{display:block;margin-top:1rem}input,select,button{box-sizing:border-box;width:100%;font-size:1rem;padding:.65rem;margin-top:.3rem}
.checks label{display:inline-block;width:auto;margin-right:1rem}.checks input{width:auto}fieldset{margin-top:1.2rem;border:1px solid #bbb}
small{color:#555}button{margin-top:1.3rem}#iata.iata-missing{border:2px solid #c00}#iata.iata-ready{border:2px solid #111}</style></head><body>
<h2>MeshCore companion MQTT setup</h2>
<p>Configure or update the MQTT destination for this companion.</p>
<form method="post" action="/save">
<label>Preset<select id="preset" name="preset">
)HTML"));
client.print(F("<option value=\"custom\""));
if (strcmp(selected_preset, MQTT_PRESET_CUSTOM) == 0) client.print(F(" selected"));
client.print(F(">Custom broker</option>\n"));
for (int i = 0; i < MQTT_PRESET_COUNT; i++) {
const MQTTPresetDef& preset = MQTT_PRESETS[i];
client.print(F("<option value=\""));
printHtmlEscaped(client, preset.name);
client.print(F("\" data-url=\""));
printHtmlEscaped(client, preset.server_url);
client.printf("\" data-port=\"%u\" data-user=\"", presetPort(preset));
printHtmlEscaped(client, preset.userpass_username ? preset.userpass_username : "");
client.print(F("\" data-audience=\""));
printHtmlEscaped(client, preset.jwt_audience ? preset.jwt_audience : "");
client.print('\"');
if (strcmp(selected_preset, preset.name) == 0) client.print(F(" selected"));
client.print('>');
printHtmlEscaped(client, preset.name);
client.print(F("</option>\n"));
}
client.print(F(R"HTML(</select></label>
<small>Selecting a preset fills its endpoint details. Built-in presets use their compiled endpoint and authentication settings.</small>
<fieldset><legend>Identity and routing</legend>
)HTML"));
client.print(F("<label>Origin name (optional)<input name=\"origin\" maxlength=\"31\" autocomplete=\"off\""));
printValueAttribute(client, prefs.mqtt_origin);
client.print(F("></label>\n<label>IATA code<input id=\"iata\" name=\"iata\" maxlength=\"3\" pattern=\"[A-Za-z]{3}\" placeholder=\"SEA\""));
printValueAttribute(client, prefs.mqtt_iata);
client.print(F(R"HTML(></label>
<small>A three-letter IATA code is required unless a custom topic template or MeshRank token supplies the route.</small>
)HTML"));
client.print(F("<label>Owner public key (optional)<input name=\"owner\" maxlength=\"64\" autocomplete=\"off\""));
printValueAttribute(client, prefs.mqtt_owner_public_key);
client.print(F("></label>\n<label>Email (optional)<input name=\"email\" type=\"email\" maxlength=\"63\" autocomplete=\"off\""));
printValueAttribute(client, prefs.mqtt_email);
client.print(F(R"HTML(></label>
</fieldset>
<fieldset><legend>Custom broker or preset credentials</legend>
)HTML"));
client.print(F("<label>Broker host or URI<input id=\"host\" name=\"host\" maxlength=\"63\" placeholder=\"mqtt://broker.example:1883\""));
printValueAttribute(client, prefs.mqtt_slot_host[0]);
client.print(F("></label>\n<label>Port<input id=\"port\" name=\"port\" type=\"number\" min=\"0\" max=\"65535\""));
client.printf(" value=\"%u\"></label>\n", prefs.mqtt_slot_port[0]);
client.print(F("<label>Username<input id=\"username\" name=\"username\" maxlength=\"31\" autocomplete=\"username\""));
printValueAttribute(client, prefs.mqtt_slot_username[0]);
client.print(F("></label>\n<label>Password<input id=\"password\" name=\"password\" type=\"password\" maxlength=\"63\" autocomplete=\"new-password\" placeholder=\"Leave blank to keep saved password\"></label>\n"));
if (prefs.mqtt_slot_password[0][0]) {
client.print(F("<label><input style=\"width:auto\" type=\"checkbox\" name=\"clear_password\" value=\"1\"> Clear saved password</label>\n"));
}
client.print(F("<label>Token (optional)<input id=\"token\" name=\"token\" maxlength=\"47\" autocomplete=\"off\""));
printValueAttribute(client, prefs.mqtt_slot_token[0]);
client.print(F("></label>\n<label>Topic template (optional)<input id=\"topic\" name=\"topic\" maxlength=\"95\" placeholder=\"meshcore/{device_id}/packets\""));
printValueAttribute(client, prefs.mqtt_slot_topic[0]);
client.print(F("></label>\n<label>JWT audience (custom broker only, optional)<input id=\"audience\" name=\"audience\" maxlength=\"63\""));
printValueAttribute(client, prefs.mqtt_slot_audience[0]);
client.print(F(R"HTML(></label>
</fieldset>
<fieldset class="checks"><legend>Publish</legend>
)HTML"));
client.printf("<label><input type=\"checkbox\" name=\"status\" value=\"1\"%s> Status</label>\n",
prefs.mqtt_status_enabled ? " checked" : "");
client.printf("<label><input type=\"checkbox\" name=\"packets\" value=\"1\"%s> Packets</label>\n",
prefs.mqtt_packets_enabled ? " checked" : "");
client.printf("<label><input type=\"checkbox\" name=\"raw\" value=\"1\"%s> Raw</label>\n",
prefs.mqtt_raw_enabled ? " checked" : "");
client.printf("<label><input type=\"checkbox\" name=\"rx\" value=\"1\"%s> RX</label>\n",
prefs.mqtt_rx_enabled ? " checked" : "");
client.print(F("<label>TX<select name=\"tx\"><option value=\"2\""));
if (prefs.mqtt_tx_enabled == 2) client.print(F(" selected"));
client.print(F(">Self adverts</option><option value=\"1\""));
if (prefs.mqtt_tx_enabled == 1) client.print(F(" selected"));
client.print(F(">All TX</option><option value=\"0\""));
if (prefs.mqtt_tx_enabled == 0) client.print(F(" selected"));
client.print(F(R"HTML(>Off</option></select></label>
</fieldset>
<button type="submit">Save and apply MQTT settings</button></form>
<p><small>This page remains available on the joined WiFi network at this node's IP address.</small></p>
<script>
const preset=document.getElementById('preset');
const iata=document.getElementById('iata');
function updateIataBox(){
const ready=/^[A-Za-z]{3}$/.test(iata.value.trim());
iata.classList.toggle('iata-ready',ready);
iata.classList.toggle('iata-missing',!ready);
}
function fillPreset(){
const o=preset.options[preset.selectedIndex], custom=o.value==='custom';
document.getElementById('host').value=custom?'':(o.dataset.url||'');
document.getElementById('port').value=custom?'1883':(o.dataset.port||'0');
document.getElementById('username').value=custom?'':(o.dataset.user||'');
document.getElementById('password').value='';
document.getElementById('audience').value=custom?'':(o.dataset.audience||'');
document.getElementById('topic').value='';
if(o.value!=='meshrank')document.getElementById('token').value='';
}
preset.addEventListener('change',fillPreset);
iata.addEventListener('input',updateIataBox);
updateIataBox();
if(preset.value!=='custom'&&!document.getElementById('host').value)fillPreset();
</script>
</body></html>)HTML"));
}
static void sendMessage(WiFiClient& client, int code, const char* reason,
const char* heading, const char* message) {
sendHeader(client, code, reason);
client.printf("<!doctype html><meta name=viewport content='width=device-width'>"
"<h2>%s</h2><p>%s</p>", heading, message);
client.print("<p><a href='/'>Return to MQTT settings</a></p>");
}
static bool isThreeLetterCode(const char* value) {
if (!value || strlen(value) != 3) return false;
for (int i = 0; i < 3; i++) {
if (!isalpha(static_cast<unsigned char>(value[i]))) return false;
}
return strcasecmp(value, "XXX") != 0;
}
static void copyString(char* dest, size_t dest_size, const char* src) {
strncpy(dest, src ? src : "", dest_size - 1);
dest[dest_size - 1] = 0;
}
static bool populatePrefs(const char* body, MQTTPrefs& prefs,
char* error, size_t error_size) {
char previous_preset[sizeof(prefs.mqtt_slot_preset[0])];
char previous_password[sizeof(prefs.mqtt_slot_password[0])];
char preset[24];
char origin[32];
char iata[8];
char owner[65];
char email[64];
char host[64];
char port_text[8];
char username[32];
char password[64];
char token[48];
char topic[96];
char audience[64];
char tx_text[4];
copyString(previous_preset, sizeof(previous_preset), prefs.mqtt_slot_preset[0]);
copyString(previous_password, sizeof(previous_password), prefs.mqtt_slot_password[0]);
if (!getFormField(body, "preset", preset, sizeof(preset))) {
snprintf(error, error_size, "Select an MQTT preset or custom broker.");
return false;
}
const MQTTPresetDef* preset_def = findMQTTPreset(preset);
const bool custom = strcmp(preset, MQTT_PRESET_CUSTOM) == 0;
if (!custom && !preset_def) {
snprintf(error, error_size, "The selected MQTT preset is not valid.");
return false;
}
getFormField(body, "origin", origin, sizeof(origin));
getFormField(body, "iata", iata, sizeof(iata));
getFormField(body, "owner", owner, sizeof(owner));
getFormField(body, "email", email, sizeof(email));
getFormField(body, "host", host, sizeof(host));
getFormField(body, "port", port_text, sizeof(port_text));
getFormField(body, "username", username, sizeof(username));
getFormField(body, "password", password, sizeof(password));
getFormField(body, "token", token, sizeof(token));
getFormField(body, "topic", topic, sizeof(topic));
getFormField(body, "audience", audience, sizeof(audience));
getFormField(body, "tx", tx_text, sizeof(tx_text));
if (!password[0] && !hasFormField(body, "clear_password")
&& strcmp(preset, previous_preset) == 0) {
copyString(password, sizeof(password), previous_password);
}
char* port_end = nullptr;
const unsigned long parsed_port = strtoul(port_text, &port_end, 10);
if (!port_text[0] || !port_end || *port_end || parsed_port > 65535) {
snprintf(error, error_size, "Enter a valid MQTT port from 0 through 65535.");
return false;
}
if (custom && !host[0]) {
snprintf(error, error_size, "A broker host or URI is required for a custom broker.");
return false;
}
if (custom && parsed_port == 0 && strstr(host, "://") == nullptr) {
snprintf(error, error_size, "A custom host without a URI scheme needs a nonzero port.");
return false;
}
if (strcmp(preset, "meshrank") == 0 && !token[0]) {
snprintf(error, error_size, "The MeshRank preset requires an account token.");
return false;
}
if (preset_def && mqttPresetNeedsSlotCredentials(preset_def)
&& (!username[0] || !password[0])) {
snprintf(error, error_size, "That preset requires a username and password.");
return false;
}
const bool preset_needs_iata = preset_def
&& preset_def->topic_style == MQTT_TOPIC_MESHCORE;
const bool custom_needs_iata = custom && !topic[0];
if (preset_needs_iata && !isThreeLetterCode(iata)) {
snprintf(error, error_size, "That preset requires a valid three-letter IATA code.");
return false;
}
if (custom_needs_iata && !isThreeLetterCode(iata)) {
snprintf(error, error_size, "Enter a three-letter IATA code, or provide a custom topic template.");
return false;
}
const uint8_t tx_mode = (strcmp(tx_text, "0") == 0) ? 0
: (strcmp(tx_text, "1") == 0) ? 1 : 2;
char wifi_ssid[sizeof(prefs.wifi_ssid)];
char wifi_password[sizeof(prefs.wifi_password)];
copyString(wifi_ssid, sizeof(wifi_ssid), prefs.wifi_ssid);
copyString(wifi_password, sizeof(wifi_password), prefs.wifi_password);
applyMQTTDefaults(&prefs);
copyString(prefs.wifi_ssid, sizeof(prefs.wifi_ssid), wifi_ssid);
copyString(prefs.wifi_password, sizeof(prefs.wifi_password), wifi_password);
for (int i = 0; i < MAX_MQTT_SLOTS; i++) {
copyString(prefs.mqtt_slot_preset[i], sizeof(prefs.mqtt_slot_preset[i]), MQTT_PRESET_NONE);
}
copyString(prefs.mqtt_origin, sizeof(prefs.mqtt_origin), origin);
copyString(prefs.mqtt_iata, sizeof(prefs.mqtt_iata), iata);
for (size_t i = 0; prefs.mqtt_iata[i]; i++) {
prefs.mqtt_iata[i] = static_cast<char>(toupper(static_cast<unsigned char>(prefs.mqtt_iata[i])));
}
copyString(prefs.mqtt_owner_public_key, sizeof(prefs.mqtt_owner_public_key), owner);
copyString(prefs.mqtt_email, sizeof(prefs.mqtt_email), email);
copyString(prefs.mqtt_slot_preset[0], sizeof(prefs.mqtt_slot_preset[0]), preset);
copyString(prefs.mqtt_slot_host[0], sizeof(prefs.mqtt_slot_host[0]), host);
prefs.mqtt_slot_port[0] = static_cast<uint16_t>(parsed_port);
copyString(prefs.mqtt_slot_username[0], sizeof(prefs.mqtt_slot_username[0]), username);
copyString(prefs.mqtt_slot_password[0], sizeof(prefs.mqtt_slot_password[0]), password);
copyString(prefs.mqtt_slot_token[0], sizeof(prefs.mqtt_slot_token[0]), token);
copyString(prefs.mqtt_slot_topic[0], sizeof(prefs.mqtt_slot_topic[0]), topic);
copyString(prefs.mqtt_slot_audience[0], sizeof(prefs.mqtt_slot_audience[0]), audience);
prefs.mqtt_status_enabled = hasFormField(body, "status") ? 1 : 0;
prefs.mqtt_packets_enabled = hasFormField(body, "packets") ? 1 : 0;
prefs.mqtt_raw_enabled = hasFormField(body, "raw") ? 1 : 0;
prefs.mqtt_rx_enabled = hasFormField(body, "rx") ? 1 : 0;
prefs.mqtt_tx_enabled = tx_mode;
return true;
}
static bool handleClient(PortalImpl* impl, WiFiClient& client) {
char line[256];
if (!readLine(client, line, sizeof(line))) return false;
const bool post_save = strncmp(line, "POST /save ", 11) == 0;
const bool get_request = strncmp(line, "GET ", 4) == 0;
size_t content_length = 0;
do {
if (!readLine(client, line, sizeof(line))) return false;
if (strncasecmp(line, "Content-Length:", 15) == 0) {
content_length = static_cast<size_t>(strtoul(line + 15, nullptr, 10));
}
} while (line[0]);
if (get_request) {
sendConfigPage(client, impl->page_prefs);
return false;
}
if (!post_save || content_length == 0 || content_length > MAX_FORM_BODY) {
sendMessage(client, 400, "Bad Request", "Invalid request", "The MQTT form data was missing or too large.");
return false;
}
char* body = static_cast<char*>(malloc(content_length + 1));
if (!body) {
sendMessage(client, 503, "Service Unavailable", "Out of memory", "Reboot the companion and try again.");
return false;
}
const size_t received = client.readBytes(body, content_length);
body[received] = 0;
if (received != content_length) {
free(body);
sendMessage(client, 408, "Request Timeout", "Incomplete request", "The complete MQTT form was not received.");
return false;
}
MQTTPrefs candidate;
snapshotPagePrefs(impl, candidate);
char error[180];
const bool valid = populatePrefs(body, candidate, error, sizeof(error));
free(body);
if (!valid) {
sendMessage(client, 400, "Bad Request", "MQTT setup needs attention", error);
return false;
}
if (!CompanionMqttSetupPortal::saveStoredConfig(candidate)) {
sendMessage(client, 500, "Internal Server Error", "Could not save MQTT setup",
"The configuration was valid but could not be written to flash.");
return false;
}
portENTER_CRITICAL(&impl->prefs_lock);
impl->page_prefs = candidate;
impl->saved = true;
portEXIT_CRITICAL(&impl->prefs_lock);
sendMessage(client, 200, "OK", "MQTT setup saved",
"The MQTT client is applying the new settings. This page remains available for later changes.");
return true;
}
static void portalTask(void* arg) {
PortalImpl* impl = static_cast<PortalImpl*>(arg);
while (impl->active && *impl->active
&& WiFi.status() == WL_CONNECTED && WiFi.getMode() == WIFI_STA) {
WiFiClient client = impl->server.available();
if (client) {
client.setTimeout(5000);
handleClient(impl, client);
client.stop();
}
delay(5);
}
impl->server.stop();
impl->task = nullptr;
if (impl->active) *impl->active = false;
vTaskDelete(nullptr);
}
} // namespace
CompanionMqttSetupPortal::CompanionMqttSetupPortal()
: _active(false), _impl(nullptr) {}
bool CompanionMqttSetupPortal::begin(MQTTPrefs* prefs) {
if (_active) return true;
if (!prefs || WiFi.status() != WL_CONNECTED || WiFi.getMode() != WIFI_STA) return false;
PortalImpl* impl = static_cast<PortalImpl*>(_impl);
if (!impl) {
impl = new PortalImpl();
if (!impl) return false;
_impl = impl;
}
if (impl->task != nullptr) return false;
impl->prefs = prefs;
impl->active = &_active;
impl->page_prefs = *prefs;
impl->saved = false;
impl->server.begin();
impl->server.setNoDelay(true);
_active = true;
if (xTaskCreatePinnedToCore(portalTask, "mqtt-setup", 8192, impl, 1,
&impl->task, 0) != pdPASS) {
_active = false;
impl->server.stop();
impl->task = nullptr;
return false;
}
Serial.printf("MQTT setup: reconnect to the joined WiFi and open http://%s/\n",
WiFi.localIP().toString().c_str());
return true;
}
void CompanionMqttSetupPortal::stop() {
_active = false;
PortalImpl* impl = static_cast<PortalImpl*>(_impl);
if (impl) impl->server.stop();
}
bool CompanionMqttSetupPortal::loop() {
PortalImpl* impl = static_cast<PortalImpl*>(_impl);
if (!impl) return false;
bool saved = false;
portENTER_CRITICAL(&impl->prefs_lock);
if (impl->saved && impl->prefs) {
*impl->prefs = impl->page_prefs;
impl->saved = false;
saved = true;
}
portEXIT_CRITICAL(&impl->prefs_lock);
return saved;
}
bool CompanionMqttSetupPortal::loadStoredConfig(MQTTPrefs& prefs) {
Preferences nvs;
if (!nvs.begin(NVS_NAMESPACE, true)) return false;
const uint16_t version = nvs.getUShort(NVS_VERSION_KEY, 0);
const size_t length = nvs.getBytesLength(NVS_PREFS_KEY);
const bool shape_ok = version == MQTT_PREFS_VERSION && length == sizeof(MQTTPrefs);
const size_t read = shape_ok ? nvs.getBytes(NVS_PREFS_KEY, &prefs, sizeof(prefs)) : 0;
nvs.end();
if (read != sizeof(prefs)) return false;
if (!storedStringsAreValid(prefs)
|| prefs.mqtt_status_enabled > 1
|| prefs.mqtt_packets_enabled > 1
|| prefs.mqtt_raw_enabled > 1
|| prefs.mqtt_rx_enabled > 1
|| prefs.mqtt_tx_enabled > 2
|| prefs.wifi_power_save > 2) {
return false;
}
const char* preset = prefs.mqtt_slot_preset[0];
const bool custom = strcmp(preset, MQTT_PRESET_CUSTOM) == 0;
const MQTTPresetDef* preset_def = custom ? nullptr : findMQTTPreset(preset);
if (!preset[0] || (!custom && !preset_def)) return false;
if (custom) {
const char* host = prefs.mqtt_slot_host[0];
if (!host[0]
|| (prefs.mqtt_slot_port[0] == 0 && strstr(host, "://") == nullptr)
|| (!prefs.mqtt_slot_topic[0][0] && !isThreeLetterCode(prefs.mqtt_iata))) {
return false;
}
} else {
if (preset_def->topic_style == MQTT_TOPIC_MESHCORE
&& !isThreeLetterCode(prefs.mqtt_iata)) return false;
if (preset_def->topic_style == MQTT_TOPIC_MESHRANK
&& !prefs.mqtt_slot_token[0][0]) return false;
if (mqttPresetNeedsSlotCredentials(preset_def)
&& (!prefs.mqtt_slot_username[0][0] || !prefs.mqtt_slot_password[0][0])) {
return false;
}
}
return true;
}
bool CompanionMqttSetupPortal::saveStoredConfig(const MQTTPrefs& prefs) {
Preferences nvs;
if (!nvs.begin(NVS_NAMESPACE, false)) return false;
const size_t written = nvs.putBytes(NVS_PREFS_KEY, &prefs, sizeof(prefs));
const size_t version_written = nvs.putUShort(NVS_VERSION_KEY, MQTT_PREFS_VERSION);
nvs.end();
return written == sizeof(prefs) && version_written == sizeof(uint16_t);
}
#endif
+30
View File
@@ -0,0 +1,30 @@
#pragma once
#if defined(ESP32_PLATFORM) && defined(WIFI_SSID) && defined(WITH_MQTT_BRIDGE)
#include <helpers/MQTTPrefs.h>
class CompanionMqttSetupPortal {
public:
CompanionMqttSetupPortal();
// Starts a station-only settings page on port 80. The caller must ensure
// the captive setup AP is no longer active. The page stays available until
// WiFi disconnects or stop() is called.
bool begin(MQTTPrefs* prefs);
void stop();
// Polls the background HTTP task. Returns true after a valid MQTT
// configuration has been copied into prefs; the server remains active.
bool loop();
bool isActive() const { return _active; }
static bool loadStoredConfig(MQTTPrefs& prefs);
static bool saveStoredConfig(const MQTTPrefs& prefs);
private:
volatile bool _active;
void* _impl;
};
#endif
+12 -2
View File
@@ -220,7 +220,10 @@ bool ESP32Board::startOTAUpdate(const char* id, char reply[]) {
ip = WiFi.localIP();
} else {
if (!lightweight_ota_started_ap) {
lightweight_ota_started_ap = WiFi.softAP("MeshCore-OTA", nullptr);
const IPAddress ap_ip(192, 168, 4, 1);
const IPAddress ap_mask(255, 255, 255, 0);
lightweight_ota_started_ap = WiFi.softAPConfig(ap_ip, ap_ip, ap_mask)
&& WiFi.softAP("MeshCore-OTA", nullptr);
}
if (!lightweight_ota_started_ap) {
inhibit_sleep = false;
@@ -286,7 +289,14 @@ bool ESP32Board::startOTAUpdate(const char* id, char reply[]) {
if (WiFi.status() == WL_CONNECTED) {
ip = WiFi.localIP();
} else {
WiFi.softAP("MeshCore-OTA", NULL);
const IPAddress ap_ip(192, 168, 4, 1);
const IPAddress ap_mask(255, 255, 255, 0);
if (!WiFi.softAPConfig(ap_ip, ap_ip, ap_mask)
|| !WiFi.softAP("MeshCore-OTA", NULL)) {
inhibit_sleep = false;
strcpy(reply, "ERR: OTA WiFi failed");
return false;
}
ip = WiFi.softAPIP();
}
+1 -1
View File
@@ -4,7 +4,7 @@
#include <string.h>
#include <MeshCore.h>
#include "CommonCLI.h"
#include "MQTTPrefs.h"
#include "MQTTPresets.h"
// Compile-time defaults for fresh /mqtt_prefs (override via platformio build_flags).
+67
View File
@@ -0,0 +1,67 @@
#pragma once
#ifdef WITH_MQTT_BRIDGE
#include <stdint.h>
#include <helpers/MQTTPresets.h>
// MQTT preferences are kept separate from role-specific NodePrefs. Companion
// and infrastructure roles intentionally use different NodePrefs layouts, but
// they can safely share this MQTT configuration structure.
struct MQTTPrefs {
char mqtt_origin[32];
char mqtt_iata[8];
uint8_t mqtt_status_enabled;
uint8_t mqtt_packets_enabled;
uint8_t mqtt_raw_enabled;
uint8_t mqtt_tx_enabled;
uint32_t mqtt_status_interval;
char wifi_ssid[32];
char wifi_password[64];
uint8_t wifi_power_save;
char timezone_string[32];
int8_t timezone_offset;
char mqtt_slot_preset[MAX_MQTT_SLOTS][24];
char mqtt_slot_host[MAX_MQTT_SLOTS][64];
uint16_t mqtt_slot_port[MAX_MQTT_SLOTS];
char mqtt_slot_username[MAX_MQTT_SLOTS][32];
char mqtt_slot_password[MAX_MQTT_SLOTS][64];
char mqtt_owner_public_key[65];
char mqtt_email[64];
char mqtt_slot_token[MAX_MQTT_SLOTS][48];
char mqtt_slot_topic[MAX_MQTT_SLOTS][96];
char mqtt_slot_audience[MAX_MQTT_SLOTS][64];
uint8_t mqtt_rx_enabled;
char mqtt_ntp_server[64];
uint8_t snmp_enabled;
char snmp_community[24];
uint8_t radio_watchdog_minutes;
uint8_t alert_enabled;
char alert_psk_hex[33];
uint16_t alert_wifi_minutes;
uint16_t alert_mqtt_minutes;
uint16_t alert_min_interval_min;
char alert_hashtag[24];
char alert_region[31];
};
// /mqtt_prefs is written with an 8-byte header so the format is
// self-describing. This is also used by the companion NVS wrapper to reject
// incompatible payloads cleanly.
static const uint8_t MQTT_PREFS_MAGIC[4] = {0xF5, 'M', 'Q', 'P'};
static const uint16_t MQTT_PREFS_VERSION = 1;
struct MQTTPrefsHeader {
uint8_t magic[4];
uint16_t version;
uint16_t payload_len;
};
#endif // WITH_MQTT_BRIDGE
+11 -2
View File
@@ -1,20 +1,29 @@
#pragma once
#include <cstring>
// Maximum number of configurable MQTT connection slots (available to all builds for struct layout).
// Used in NodePrefs/MQTTPrefs for persistent storage — do NOT change without migration.
static const int MAX_MQTT_SLOTS = 6;
// Runtime slot array size: fewer slots on non-PSRAM boards to save ~1.2KB of heap.
// Non-PSRAM boards are limited to 2 active connections (_max_active_slots), so 3 runtime
// slots (2 active + 1 spare for reconfiguration) is sufficient.
#if defined(BOARD_HAS_PSRAM)
// slots (2 active + 1 spare for reconfiguration) is sufficient. Purpose-built builds
// can set MQTT_RUNTIME_SLOT_COUNT lower when they intentionally expose fewer slots.
#if defined(MQTT_RUNTIME_SLOT_COUNT)
static const int RUNTIME_MQTT_SLOTS = MQTT_RUNTIME_SLOT_COUNT;
#elif defined(BOARD_HAS_PSRAM)
static const int RUNTIME_MQTT_SLOTS = 6;
#else
static const int RUNTIME_MQTT_SLOTS = 3;
#endif
static_assert(RUNTIME_MQTT_SLOTS >= 1 && RUNTIME_MQTT_SLOTS <= MAX_MQTT_SLOTS,
"MQTT_RUNTIME_SLOT_COUNT must be between 1 and MAX_MQTT_SLOTS");
#ifdef WITH_MQTT_BRIDGE
#include <Arduino.h>
enum MQTTAuthType : uint8_t {
MQTT_AUTH_NONE, // No authentication
MQTT_AUTH_USERPASS, // Username/password
+378
View File
@@ -0,0 +1,378 @@
#include "WiFiSetupPortal.h"
#if defined(ESP32_PLATFORM) && defined(WIFI_SSID)
#include <WiFi.h>
#include <DNSServer.h>
#include <Preferences.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <strings.h>
namespace {
static const IPAddress SETUP_IP(192, 168, 4, 1);
static const IPAddress SETUP_MASK(255, 255, 255, 0);
static const uint32_t CONNECT_TIMEOUT_MS = 20000;
static const uint32_t AP_CLOSE_DELAY_MS = 5000;
static const char SETUP_PAGE[] = R"HTML(<!doctype html>
<html><head><meta name="viewport" content="width=device-width,initial-scale=1">
<title>MeshCore WiFi setup</title><style>
body{font-family:sans-serif;max-width:28rem;margin:2rem auto;padding:0 1rem;color:#222}
label{display:block;margin-top:1rem}input,button{box-sizing:border-box;width:100%;font-size:1rem;padding:.7rem;margin-top:.35rem}
button{margin-top:1.2rem}small{color:#555}</style></head><body>
<h2>MeshCore WiFi setup</h2><p>Enter the WiFi network this node should join.</p>
<form method="post" action="/save">
<label>SSID<input name="ssid" maxlength="31" required autocomplete="off"></label>
<label>Password<input name="password" type="password" maxlength="63" autocomplete="off"></label>
<small>Leave the password blank for an open network.</small>
<button type="submit">Save and connect</button></form></body></html>)HTML";
struct PortalImpl {
WiFiServer server{80};
DNSServer dns;
TaskHandle_t task = nullptr;
WiFiSetupPortal::SaveCallback save_callback = nullptr;
void* callback_context = nullptr;
volatile bool* active = nullptr;
uint32_t close_ap_at = 0;
char ap_name[33] = "MeshCore-Setup";
char recovery_ssid[32] = "";
char recovery_password[64] = "";
uint32_t recovery_interval_ms = 0;
uint32_t next_recovery_at = 0;
uint32_t recovery_deadline = 0;
bool recovery_connecting = false;
};
static bool readLine(WiFiClient& client, char* line, size_t capacity) {
size_t length = 0;
uint32_t deadline = millis() + 5000;
while (client.connected()) {
while (client.available()) {
int c = client.read();
if (c < 0) break;
if (c == '\n') {
if (length && line[length - 1] == '\r') length--;
line[length] = 0;
return true;
}
if (length + 1 < capacity) line[length++] = static_cast<char>(c);
}
if (static_cast<int32_t>(millis() - deadline) >= 0) break;
delay(1);
}
return false;
}
static void sendResponse(WiFiClient& client, int code, const char* reason,
const char* content_type, const char* body) {
size_t length = strlen(body);
client.printf("HTTP/1.1 %d %s\r\nContent-Type: %s\r\nContent-Length: %u\r\n"
"Connection: close\r\nCache-Control: no-store\r\n\r\n",
code, reason, content_type, static_cast<unsigned>(length));
client.write(reinterpret_cast<const uint8_t*>(body), length);
}
static int hexValue(char c) {
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
return -1;
}
static bool decodeFormValue(const char* encoded, size_t encoded_len,
char* output, size_t output_size) {
if (!output || output_size == 0) return false;
size_t out = 0;
for (size_t i = 0; i < encoded_len; i++) {
char c = encoded[i];
if (c == '+') {
c = ' ';
} else if (c == '%' && i + 2 < encoded_len) {
int hi = hexValue(encoded[i + 1]);
int lo = hexValue(encoded[i + 2]);
if (hi < 0 || lo < 0) return false;
c = static_cast<char>((hi << 4) | lo);
i += 2;
}
if (c == 0 || out + 1 >= output_size) return false;
output[out++] = c;
}
output[out] = 0;
return true;
}
static bool getFormField(const char* body, const char* name,
char* output, size_t output_size) {
size_t name_len = strlen(name);
const char* field = body;
while (field && *field) {
const char* end = strchr(field, '&');
size_t field_len = end ? static_cast<size_t>(end - field) : strlen(field);
if (field_len > name_len + 1 && field[name_len] == '='
&& strncmp(field, name, name_len) == 0) {
return decodeFormValue(field + name_len + 1, field_len - name_len - 1,
output, output_size);
}
field = end ? end + 1 : nullptr;
}
if (output_size) output[0] = 0;
return false;
}
static void handleSave(PortalImpl* impl, WiFiClient& client, const char* body) {
char ssid[32];
char password[64];
if (!getFormField(body, "ssid", ssid, sizeof(ssid)) || ssid[0] == 0) {
sendResponse(client, 400, "Bad Request", "text/plain", "A valid SSID is required.");
return;
}
// Browsers omit an empty password only if the form is modified. Treat either
// an empty field or an absent one as an open network.
if (!getFormField(body, "password", password, sizeof(password))) password[0] = 0;
// A submitted configuration takes priority over a background retry of the
// previously saved network.
impl->recovery_connecting = false;
Serial.printf("WiFi setup: connecting to '%s'...\n", ssid);
WiFi.begin(ssid, password);
uint32_t deadline = millis() + CONNECT_TIMEOUT_MS;
while (impl->active && *impl->active && WiFi.status() != WL_CONNECTED
&& static_cast<int32_t>(millis() - deadline) < 0) {
impl->dns.processNextRequest();
delay(50);
}
if (WiFi.status() != WL_CONNECTED) {
WiFi.disconnect();
sendResponse(client, 200, "OK", "text/html",
"<!doctype html><meta name=viewport content='width=device-width'><h2>Connection failed</h2>"
"<p>Check the SSID and password, then <a href='/'>try again</a>.</p>");
Serial.println("WiFi setup: connection failed; setup AP remains active");
return;
}
if (impl->save_callback && !impl->save_callback(impl->callback_context, ssid, password)) {
WiFi.disconnect();
sendResponse(client, 500, "Internal Server Error", "text/plain",
"Connected, but the credentials could not be saved.");
Serial.println("WiFi setup: connected, but credential persistence failed");
return;
}
char response[600];
String ip = WiFi.localIP().toString();
#ifdef WITH_MQTT_BRIDGE
snprintf(response, sizeof(response),
"<!doctype html><meta name=viewport content='width=device-width'><h2>WiFi connected</h2>"
"<p>MeshCore joined the selected network.</p><p>IP address: <strong>%s</strong></p>"
"<p>Reconnect this phone or computer to that WiFi network, then open "
"<strong>http://%s/</strong> to finish MQTT setup.</p>"
"<p>The temporary setup access point will close shortly.</p>",
ip.c_str(), ip.c_str());
#else
snprintf(response, sizeof(response),
"<!doctype html><meta name=viewport content='width=device-width'><h2>Connected</h2>"
"<p>MeshCore joined the WiFi network.</p><p>IP address: <strong>%s</strong></p>"
"<p>The setup access point will close shortly.</p>", ip.c_str());
#endif
sendResponse(client, 200, "OK", "text/html", response);
Serial.printf("WiFi setup: connected; IP %s\n", ip.c_str());
impl->close_ap_at = millis() + AP_CLOSE_DELAY_MS;
}
static void handleClient(PortalImpl* impl, WiFiClient& client) {
char line[256];
if (!readLine(client, line, sizeof(line))) return;
bool post_save = strncmp(line, "POST /save ", 11) == 0;
bool get_request = strncmp(line, "GET ", 4) == 0;
size_t content_length = 0;
do {
if (!readLine(client, line, sizeof(line))) return;
if (strncasecmp(line, "Content-Length:", 15) == 0) {
content_length = static_cast<size_t>(strtoul(line + 15, nullptr, 10));
}
} while (line[0]);
if (post_save) {
if (content_length == 0 || content_length >= 256) {
sendResponse(client, 400, "Bad Request", "text/plain", "Invalid form data.");
return;
}
char body[256];
size_t received = client.readBytes(body, content_length);
if (received != content_length) {
sendResponse(client, 408, "Request Timeout", "text/plain", "Incomplete form data.");
return;
}
body[received] = 0;
handleSave(impl, client, body);
} else if (get_request) {
// Serve the form for every GET path. Together with wildcard DNS this makes
// common captive-portal probes land on the setup page.
sendResponse(client, 200, "OK", "text/html", SETUP_PAGE);
} else {
sendResponse(client, 404, "Not Found", "text/plain", "Not found.");
}
}
static void portalTask(void* arg) {
PortalImpl* impl = static_cast<PortalImpl*>(arg);
while (impl->active && *impl->active) {
impl->dns.processNextRequest();
WiFiClient client = impl->server.available();
if (client) {
client.setTimeout(5000);
handleClient(impl, client);
client.stop();
}
uint32_t now = millis();
if (impl->recovery_connecting) {
if (WiFi.status() == WL_CONNECTED) {
String ip = WiFi.localIP().toString();
Serial.printf("WiFi setup: saved network recovered; IP %s\n", ip.c_str());
impl->recovery_connecting = false;
break;
}
if (static_cast<int32_t>(now - impl->recovery_deadline) >= 0) {
WiFi.disconnect();
impl->recovery_connecting = false;
Serial.println("WiFi setup: saved network still unavailable; setup AP remains active");
}
} else if (impl->recovery_ssid[0] && impl->recovery_interval_ms
&& static_cast<int32_t>(now - impl->next_recovery_at) >= 0) {
Serial.printf("WiFi setup: retrying saved network '%s'...\n", impl->recovery_ssid);
WiFi.begin(impl->recovery_ssid, impl->recovery_password);
impl->recovery_connecting = true;
impl->recovery_deadline = now + CONNECT_TIMEOUT_MS;
impl->next_recovery_at = now + impl->recovery_interval_ms;
}
if (impl->close_ap_at != 0
&& static_cast<int32_t>(now - impl->close_ap_at) >= 0) {
break;
}
delay(10);
}
impl->dns.stop();
impl->server.stop();
WiFi.softAPdisconnect(true);
if (impl->active) *impl->active = false;
impl->task = nullptr;
vTaskDelete(nullptr);
}
} // namespace
WiFiSetupPortal::WiFiSetupPortal() : _active(false), _impl(nullptr) {}
bool WiFiSetupPortal::begin(const char* ap_name, SaveCallback save_callback, void* context) {
if (_active) return true;
PortalImpl* impl = static_cast<PortalImpl*>(_impl);
if (!impl) {
impl = new PortalImpl();
if (!impl) return false;
_impl = impl;
}
if (impl->task != nullptr) return false;
impl->save_callback = save_callback;
impl->callback_context = context;
impl->active = &_active;
impl->close_ap_at = 0;
impl->recovery_ssid[0] = 0;
impl->recovery_interval_ms = 0;
impl->recovery_connecting = false;
strncpy(impl->ap_name, ap_name && ap_name[0] ? ap_name : "MeshCore-Setup",
sizeof(impl->ap_name) - 1);
impl->ap_name[sizeof(impl->ap_name) - 1] = 0;
WiFi.mode(WIFI_AP_STA);
if (!WiFi.softAPConfig(SETUP_IP, SETUP_IP, SETUP_MASK)
|| !WiFi.softAP(impl->ap_name, nullptr)) {
WiFi.softAPdisconnect(true);
return false;
}
if (!impl->dns.start(53, "*", SETUP_IP)) {
WiFi.softAPdisconnect(true);
return false;
}
impl->server.begin();
impl->server.setNoDelay(true);
_active = true;
if (xTaskCreatePinnedToCore(portalTask, "wifi-setup", 6144, impl, 1,
&impl->task, 0) != pdPASS) {
_active = false;
impl->dns.stop();
impl->server.stop();
WiFi.softAPdisconnect(true);
impl->task = nullptr;
return false;
}
Serial.printf("WiFi setup: join open AP '%s' and open http://%s/\n",
impl->ap_name, SETUP_IP.toString().c_str());
return true;
}
void WiFiSetupPortal::configureRecovery(const char* ssid, const char* password,
uint32_t interval_ms) {
PortalImpl* impl = static_cast<PortalImpl*>(_impl);
if (!impl) return;
impl->recovery_ssid[0] = 0;
strncpy(impl->recovery_password, password ? password : "", sizeof(impl->recovery_password) - 1);
impl->recovery_password[sizeof(impl->recovery_password) - 1] = 0;
impl->recovery_interval_ms = interval_ms;
impl->recovery_connecting = false;
impl->next_recovery_at = millis() + interval_ms;
if (ssid) {
strncpy(impl->recovery_ssid, ssid, sizeof(impl->recovery_ssid) - 1);
impl->recovery_ssid[sizeof(impl->recovery_ssid) - 1] = 0;
}
}
bool WiFiSetupPortal::loadStoredCredentials(char* ssid, size_t ssid_size,
char* password, size_t password_size) {
if (!ssid || ssid_size == 0 || !password || password_size == 0) return false;
Preferences prefs;
if (!prefs.begin("mesh-wifi", true)) return false;
String stored_ssid = prefs.getString("ssid", "");
String stored_password = prefs.getString("password", "");
prefs.end();
if (stored_ssid.length() == 0 || stored_ssid.length() >= ssid_size
|| stored_password.length() >= password_size) return false;
strncpy(ssid, stored_ssid.c_str(), ssid_size - 1);
ssid[ssid_size - 1] = 0;
strncpy(password, stored_password.c_str(), password_size - 1);
password[password_size - 1] = 0;
return true;
}
bool WiFiSetupPortal::saveStoredCredentials(const char* ssid, const char* password) {
if (!ssid || !ssid[0] || strlen(ssid) > 31 || (password && strlen(password) > 63)) return false;
Preferences prefs;
if (!prefs.begin("mesh-wifi", false)) return false;
const char* saved_password = password ? password : "";
bool ok = prefs.putString("ssid", ssid) == strlen(ssid);
prefs.putString("password", saved_password); // An empty String reports zero bytes even on success.
ok = ok && prefs.getString("password", "\x01") == saved_password;
prefs.end();
return ok;
}
bool WiFiSetupPortal::isPlaceholderSSID(const char* ssid) {
if (!ssid || !ssid[0]) return true;
return strcasecmp(ssid, "ssid") == 0
|| strcasecmp(ssid, "myssid") == 0
|| strcasecmp(ssid, "wifi_ssid") == 0
|| strcasecmp(ssid, "yourssid") == 0;
}
WiFiSetupPortal& wifiSetupPortal() {
static WiFiSetupPortal portal;
return portal;
}
#endif
+35
View File
@@ -0,0 +1,35 @@
#pragma once
#if defined(ESP32_PLATFORM) && defined(WIFI_SSID)
#include <Arduino.h>
#include <IPAddress.h>
class WiFiSetupPortal {
public:
typedef bool (*SaveCallback)(void* context, const char* ssid, const char* password);
WiFiSetupPortal();
// Starts an open setup AP and captive HTTP form. The AP remains available
// after a failed association and shuts down shortly after a successful one.
bool begin(const char* ap_name, SaveCallback save_callback, void* context);
void configureRecovery(const char* ssid, const char* password, uint32_t interval_ms);
bool isActive() const { return _active; }
IPAddress apIP() const { return IPAddress(192, 168, 4, 1); }
// Companion WiFi builds keep their runtime credentials in NVS.
static bool loadStoredCredentials(char* ssid, size_t ssid_size,
char* password, size_t password_size);
static bool saveStoredCredentials(const char* ssid, const char* password);
static bool isPlaceholderSSID(const char* ssid);
private:
volatile bool _active;
void* _impl;
};
WiFiSetupPortal& wifiSetupPortal();
#endif
+69 -16
View File
@@ -24,10 +24,11 @@
#endif
// Effective MQTT origin: empty mqtt_origin follows node_name; otherwise mqtt_origin override (quotes stripped).
static void applyEffectiveOrigin(const NodePrefs* np, const MQTTPrefs* obs, char* dest, size_t dest_size) {
if (!np || !obs || !dest || dest_size == 0) return;
static void applyEffectiveOrigin(const char* node_name, const MQTTPrefs* obs,
char* dest, size_t dest_size) {
if (!node_name || !obs || !dest || dest_size == 0) return;
if (obs->mqtt_origin[0] == '\0') {
strncpy(dest, np->node_name, dest_size - 1);
strncpy(dest, node_name, dest_size - 1);
} else {
strncpy(dest, obs->mqtt_origin, dest_size - 1);
}
@@ -80,17 +81,26 @@ const char* MQTTBridge::effectiveNtpPrimary(const MQTTPrefs* obs) {
}
void MQTTBridge::refreshOriginFromPrefs() {
if (!_prefs) return;
applyEffectiveOrigin(_prefs, _obs, _origin, sizeof(_origin));
if (!_node_info.node_name) return;
applyEffectiveOrigin(_node_info.node_name, _obs, _origin, sizeof(_origin));
}
void MQTTBridge::getEffectiveMqttOrigin(const NodePrefs* np, const MQTTPrefs* obs, char* buf, size_t buf_size) {
void MQTTBridge::getEffectiveMqttOrigin(const char* node_name, const MQTTPrefs* obs,
char* buf, size_t buf_size) {
if (!buf || buf_size == 0) return;
if (!np || !obs) {
if (!node_name || !obs) {
buf[0] = '\0';
return;
}
applyEffectiveOrigin(np, obs, buf, buf_size);
applyEffectiveOrigin(node_name, obs, buf, buf_size);
}
const char* MQTTBridge::repeatStatus() const {
if (!_node_info.repeat_flag) return nullptr;
const bool enabled = _node_info.repeat_when_nonzero
? (*_node_info.repeat_flag != 0)
: (*_node_info.repeat_flag == 0);
return enabled ? "on" : "off";
}
// Helper function to check if WiFi credentials are valid
@@ -391,9 +401,13 @@ void MQTTBridge::formatSlotDiagReply(char* buf, size_t bufsize, int slot_index)
// ---------------------------------------------------------------------------
// Constructor
// ---------------------------------------------------------------------------
MQTTBridge::MQTTBridge(NodePrefs *prefs, MQTTPrefs *obs, mesh::PacketManager *mgr, mesh::RTCClock *rtc, mesh::LocalIdentity *identity)
: BridgeBase(prefs, mgr, rtc),
MQTTBridge::MQTTBridge(const MQTTNodeInfo& node_info, MQTTPrefs *obs,
mesh::RTCClock *rtc, mesh::LocalIdentity *identity,
bool manage_wifi)
: _rtc(rtc),
_obs(obs),
_node_info(node_info),
_manage_wifi(manage_wifi),
_queue_count(0),
_last_status_publish(0), _last_status_retry(0), _status_interval(300000),
_ntp_client(_ntp_udp, effectiveNtpPrimary(obs), 0, 60000), _last_ntp_sync(0), _ntp_synced(false), _ntp_sync_pending(false), _slots_setup_done(false), _max_active_slots(RUNTIME_MQTT_SLOTS),
@@ -502,6 +516,37 @@ MQTTBridge::MQTTBridge(NodePrefs *prefs, MQTTPrefs *obs, mesh::PacketManager *mg
// ---------------------------------------------------------------------------
void MQTTBridge::begin() {
MQTT_DEBUG_PRINTLN("Initializing MQTT Bridge...");
if (_initialized) return;
// end() releases the PSRAM-backed scratch buffers. Recreate them when the
// same bridge object is started again after a settings or OTA restart.
#if defined(BOARD_HAS_PSRAM)
if (_last_raw_data == nullptr) {
_last_raw_data = static_cast<uint8_t*>(psram_malloc(LAST_RAW_DATA_SIZE));
}
if (_publish_json_buffer == nullptr) {
_publish_json_buffer = static_cast<char*>(psram_malloc(PUBLISH_JSON_BUFFER_SIZE));
}
if (_status_json_buffer == nullptr) {
_status_json_buffer = static_cast<char*>(psram_malloc(STATUS_JSON_BUFFER_SIZE));
}
#endif
// A restarted bridge may be using a different preset or custom endpoint.
// Clear the old slot description before rebuilding it from _obs below.
for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) {
_slots[i].enabled = false;
_slots[i].preset = nullptr;
_slots[i].host[0] = '\0';
_slots[i].username[0] = '\0';
_slots[i].password[0] = '\0';
_slots[i].audience[0] = '\0';
_slots[i].broker_uri[0] = '\0';
_slots[i].port = 1883;
_slot_reconfigure_pending[i] = false;
}
_cached_has_connected_slots = false;
_slots_setup_done = false;
// PSRAM diagnostic - helps debug memory fragmentation on boards with external RAM
#ifdef BOARD_HAS_PSRAM
@@ -853,7 +898,9 @@ void MQTTBridge::initializeWiFiInTask() {
// When already connected, the deferred slot setup still fires in mqttTaskLoop()
// because _ntp_synced persists across end() (only _slots_setup_done is reset).
if (WiFi.status() != WL_CONNECTED) {
WiFi.begin(_obs->wifi_ssid, _obs->wifi_password);
if (_manage_wifi) {
WiFi.begin(_obs->wifi_ssid, _obs->wifi_password);
}
} else if (!_ntp_synced && !_ntp_sync_pending) {
_ntp_sync_pending = true; // already connected but never synced — kick NTP now
}
@@ -1826,7 +1873,10 @@ void MQTTBridge::publishStatusToSlot(int index) {
MQTTMessageBuilder::formatIsoTimestampForMqtt(now_tv.tv_sec, now_tv.tv_usec, _timezone, timestamp, sizeof(timestamp));
snprintf(radio_info, sizeof(radio_info), "%.6f,%.1f,%d,%d",
_prefs->freq, _prefs->bw, _prefs->sf, _prefs->cr);
_node_info.freq ? *_node_info.freq : 0.0f,
_node_info.bw ? *_node_info.bw : 0.0f,
_node_info.sf ? *_node_info.sf : 0,
_node_info.cr ? *_node_info.cr : 0);
strncpy(origin_id, _device_id, sizeof(origin_id) - 1);
origin_id[sizeof(origin_id) - 1] = '\0';
@@ -1869,7 +1919,7 @@ void MQTTBridge::publishStatusToSlot(int index) {
battery_mv, uptime_secs, errors, _queue_count, noise_floor,
tx_air_secs, rx_air_secs, recv_errors, internal_heap_free,
packets_sent, packets_received,
_prefs->disable_fwd ? "off" : "on"
repeatStatus()
);
if (len > 0) {
@@ -2056,7 +2106,7 @@ bool MQTTBridge::handleWiFiConnection(unsigned long now) {
unsigned long elapsed_since_attempt = (now >= _last_wifi_reconnect_attempt)
? (now - _last_wifi_reconnect_attempt)
: (ULONG_MAX - _last_wifi_reconnect_attempt + now + 1);
if (disconnected_duration >= delay_ms && elapsed_since_attempt >= delay_ms) {
if (_manage_wifi && disconnected_duration >= delay_ms && elapsed_since_attempt >= delay_ms) {
_last_wifi_reconnect_attempt = now;
if (_wifi_reconnect_backoff_attempt < 5) {
_wifi_reconnect_backoff_attempt++;
@@ -2539,7 +2589,10 @@ bool MQTTBridge::publishStatus() {
MQTTMessageBuilder::formatIsoTimestampForMqtt(now_tv.tv_sec, now_tv.tv_usec, _timezone, timestamp, sizeof(timestamp));
snprintf(radio_info, sizeof(radio_info), "%.6f,%.1f,%d,%d",
_prefs->freq, _prefs->bw, _prefs->sf, _prefs->cr);
_node_info.freq ? *_node_info.freq : 0.0f,
_node_info.bw ? *_node_info.bw : 0.0f,
_node_info.sf ? *_node_info.sf : 0,
_node_info.cr ? *_node_info.cr : 0);
strncpy(origin_id, _device_id, sizeof(origin_id) - 1);
origin_id[sizeof(origin_id) - 1] = '\0';
@@ -2582,7 +2635,7 @@ bool MQTTBridge::publishStatus() {
battery_mv, uptime_secs, errors, _queue_count, noise_floor,
tx_air_secs, rx_air_secs, recv_errors, internal_heap_free,
packets_sent, packets_received,
_prefs->disable_fwd ? "off" : "on"
repeatStatus()
);
if (len > 0) {
+30 -5
View File
@@ -1,7 +1,9 @@
#pragma once
#include "MeshCore.h"
#include "helpers/bridges/BridgeBase.h"
#include "helpers/AbstractBridge.h"
#include "helpers/MQTTPrefs.h"
#include <ArduinoJson.h>
#include <PsychicMqttClient.h>
#include <WiFi.h>
#include <NTPClient.h>
@@ -35,6 +37,19 @@ class MeshSNMPAgent; // Forward declaration
#ifdef WITH_MQTT_BRIDGE
// Live pointers to the small set of role-specific values MQTT publishes. This
// avoids treating repeater and companion NodePrefs as though they had the same
// binary layout (they intentionally do not).
struct MQTTNodeInfo {
const char* node_name = nullptr;
const float* freq = nullptr;
const float* bw = nullptr;
const uint8_t* sf = nullptr;
const uint8_t* cr = nullptr;
const uint8_t* repeat_flag = nullptr;
bool repeat_when_nonzero = true;
};
/**
* @brief Bridge implementation using MQTT protocol for packet transport
*
@@ -55,7 +70,7 @@ class MeshSNMPAgent; // Forward declaration
* - Configure slots via: set mqtt1.preset <name>, set mqtt2.preset <name>, etc.
* - Available presets: analyzer-us, analyzer-eu, meshmapper, custom, none
*/
class MQTTBridge : public BridgeBase {
class MQTTBridge : public AbstractBridge {
public:
// Max NTP servers in a try-list: 1 custom primary + the built-in fallbacks.
static const int kMaxNtpServers = 6;
@@ -63,6 +78,9 @@ public:
private:
static const size_t AUTH_TOKEN_SIZE = 768;
bool _initialized = false;
mesh::RTCClock* _rtc = nullptr;
// Connection slot - each slot holds one MQTT connection
struct MQTTSlot {
PsychicMqttClient* client;
@@ -376,14 +394,20 @@ private:
void refreshOriginFromPrefs();
// Observer config (MQTT/WiFi/timezone/SNMP/alert), persisted to /mqtt_prefs.
// _prefs (held by BridgeBase) still provides upstream fields (freq/sf/node_name…).
MQTTPrefs* _obs = nullptr;
MQTTNodeInfo _node_info;
bool _manage_wifi;
const char* repeatStatus() const;
public:
MQTTBridge(NodePrefs *prefs, MQTTPrefs *obs, mesh::PacketManager *mgr, mesh::RTCClock *rtc, mesh::LocalIdentity *identity);
MQTTBridge(const MQTTNodeInfo& node_info, MQTTPrefs *obs,
mesh::RTCClock *rtc, mesh::LocalIdentity *identity,
bool manage_wifi = true);
void begin() override;
void end() override;
bool isRunning() const override { return _initialized; }
void loop() override;
void onPacketReceived(mesh::Packet *packet) override;
void sendPacket(mesh::Packet *packet) override;
@@ -443,7 +467,8 @@ public:
const char* getSlotPresetName(int slot_index) const;
static int getRuntimeSlotCount() { return RUNTIME_MQTT_SLOTS; }
/** Resolved origin for MQTT JSON: node_name when mqtt_origin is empty, else mqtt_origin (with quote stripping). */
static void getEffectiveMqttOrigin(const NodePrefs* np, const MQTTPrefs* obs, char* buf, size_t buf_size);
static void getEffectiveMqttOrigin(const char* node_name, const MQTTPrefs* obs,
char* buf, size_t buf_size);
static const char* effectiveNtpPrimary(const MQTTPrefs* obs);
/** Sync system clock via NTP. force=true bypasses the 5s post-sync rate limit.
* primary_only=true tests just the effective primary server (no fallback walk) so a
+27
View File
@@ -307,6 +307,33 @@ lib_deps =
${Heltec_lora32_v3.lib_deps}
densaugeo/base64 @ ~1.4.0
[env:Heltec_v3_companion_radio_wifi_mqtt]
extends = env:Heltec_v3_companion_radio_wifi
extra_scripts =
${esp32_base.extra_scripts}
pre:scripts/generate_cert_bundle.py
board_ssl_cert_source = adafruit
board_build.embed_files = src/certs/x509_crt_bundle.bin
build_flags =
${env:Heltec_v3_companion_radio_wifi.build_flags}
-D WITH_MQTT_BRIDGE=1
-D MQTT_RUNTIME_SLOT_COUNT=1
-D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y
-D ESP32_CPU_FREQ=160
-D MQTT_WIFI_TX_POWER=WIFI_POWER_11dBm
build_src_filter = ${env:Heltec_v3_companion_radio_wifi.build_src_filter}
+<helpers/bridges/MQTTBridge.cpp>
+<helpers/MQTTMessageBuilder.cpp>
+<helpers/JWTHelper.cpp>
lib_deps =
${env:Heltec_v3_companion_radio_wifi.lib_deps}
${esp32_ota.lib_deps}
elims/PsychicMqttClient@^0.2.4
bblanchon/ArduinoJson
arduino-libraries/NTPClient
JChristensen/Timezone
paulstoffregen/Time@1.6.1
[env:Heltec_v3_sensor]
extends = Heltec_lora32_v3
build_flags =
+27
View File
@@ -495,6 +495,33 @@ lib_deps =
${heltec_v4_oled.lib_deps}
densaugeo/base64 @ ~1.4.0
[env:heltec_v4_companion_radio_wifi_mqtt]
extends = env:heltec_v4_companion_radio_wifi
extra_scripts =
${esp32_base.extra_scripts}
pre:scripts/generate_cert_bundle.py
board_ssl_cert_source = adafruit-full
board_build.embed_files = src/certs/x509_crt_bundle.bin
build_flags =
${env:heltec_v4_companion_radio_wifi.build_flags}
-D WITH_MQTT_BRIDGE=1
-D MQTT_RUNTIME_SLOT_COUNT=1
-D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y
-D ESP32_CPU_FREQ=160
-D MQTT_WIFI_TX_POWER=WIFI_POWER_11dBm
build_src_filter = ${env:heltec_v4_companion_radio_wifi.build_src_filter}
+<helpers/bridges/MQTTBridge.cpp>
+<helpers/MQTTMessageBuilder.cpp>
+<helpers/JWTHelper.cpp>
lib_deps =
${env:heltec_v4_companion_radio_wifi.lib_deps}
${esp32_ota.lib_deps}
elims/PsychicMqttClient@^0.2.4
bblanchon/ArduinoJson
arduino-libraries/NTPClient
JChristensen/Timezone
paulstoffregen/Time@1.6.1
[env:heltec_v4_sensor]
extends = heltec_v4_oled
build_flags =