meshcore-repeater/src/WebConfig.cpp
Ryan Malloy 04667f5161 Add optional MQTT gateway with web configuration UI
Feature-flagged WiFi/MQTT capability (WITH_MQTT) that bridges
LoRa mesh packets to MQTT topics over TLS (port 443). Includes:

- WiFiManager: connection handling with AP fallback mode
- MQTTBridge: TLS-secured pub/sub with FNV-1a deduplication
- WebConfig: REST API for WiFi/MQTT settings
- Embedded web UI dashboard for configuration

Default broker: meshqt.l.supported.systems:443 (MQTTS)
Build with: pio run -e heltec_v3_mqtt
2026-01-25 22:44:16 -07:00

285 lines
8.2 KiB
C++

#ifdef WITH_MQTT
#include "WebConfig.h"
#include "web_ui.h"
#include <helpers/CommonCLI.h>
WebConfig::WebConfig(WiFiManager& wifi, MQTTBridge& mqtt, uint16_t port)
: _server(port),
_wifi(wifi),
_mqtt(mqtt),
_wifi_config(nullptr),
_mqtt_config(nullptr),
_prefs(nullptr),
_node_name(nullptr),
_firmware_version(nullptr),
_save_callback(nullptr),
_reboot_callback(nullptr),
_initialized(false) {
memset(&_stats, 0, sizeof(_stats));
memset(_node_id, 0, sizeof(_node_id));
}
void WebConfig::begin(WiFiConfig* wifi_config, MQTTConfig* mqtt_config, NodePrefs* prefs) {
_wifi_config = wifi_config;
_mqtt_config = mqtt_config;
_prefs = prefs;
setupRoutes();
_server.begin();
_initialized = true;
Serial.printf("[WebConfig] Server started on port 80\n");
}
void WebConfig::setNodeInfo(const uint8_t* pubkey, const char* name, const char* version) {
for (int i = 0; i < 8; i++) {
sprintf(&_node_id[i * 2], "%02x", pubkey[i]);
}
_node_name = name;
_firmware_version = version;
}
void WebConfig::updateStats(const WebConfigStats& stats) {
_stats = stats;
}
void WebConfig::end() {
if (_initialized) {
_server.end();
_initialized = false;
}
}
void WebConfig::setupRoutes() {
// Serve main page
_server.on("/", HTTP_GET, [this](AsyncWebServerRequest* request) {
handleRoot(request);
});
// API endpoints
_server.on("/api/status", HTTP_GET, [this](AsyncWebServerRequest* request) {
handleStatus(request);
});
_server.on("/api/wifi", HTTP_GET, [this](AsyncWebServerRequest* request) {
handleWiFiGet(request);
});
// WiFi POST - use body handler for JSON
_server.on("/api/wifi", HTTP_POST,
// Request handler (called after body is received)
[this](AsyncWebServerRequest* request) {},
// Upload handler (not used)
nullptr,
// Body handler
[this](AsyncWebServerRequest* request, uint8_t* data, size_t len, size_t index, size_t total) {
if (index == 0 && len == total) {
// Complete body received
JsonDocument doc;
DeserializationError error = deserializeJson(doc, data, len);
if (error) {
sendErrorResponse(request, "Invalid JSON");
return;
}
if (doc["ssid"].is<const char*>()) {
strncpy(_wifi_config->ssid, doc["ssid"].as<const char*>(), sizeof(_wifi_config->ssid) - 1);
}
if (doc["password"].is<const char*>()) {
strncpy(_wifi_config->password, doc["password"].as<const char*>(), sizeof(_wifi_config->password) - 1);
}
if (_save_callback) _save_callback();
JsonDocument resp;
resp["success"] = true;
resp["message"] = "WiFi config saved. Reconnecting...";
sendJsonResponse(request, resp);
// Schedule reconnect after response
_wifi.reconnect();
}
}
);
_server.on("/api/mqtt", HTTP_GET, [this](AsyncWebServerRequest* request) {
handleMQTTGet(request);
});
// MQTT POST - use body handler for JSON
_server.on("/api/mqtt", HTTP_POST,
// Request handler
[this](AsyncWebServerRequest* request) {},
// Upload handler
nullptr,
// Body handler
[this](AsyncWebServerRequest* request, uint8_t* data, size_t len, size_t index, size_t total) {
if (index == 0 && len == total) {
JsonDocument doc;
DeserializationError error = deserializeJson(doc, data, len);
if (error) {
sendErrorResponse(request, "Invalid JSON");
return;
}
if (doc["enabled"].is<bool>()) {
_mqtt_config->enabled = doc["enabled"].as<bool>() ? 1 : 0;
}
if (doc["broker"].is<const char*>()) {
strncpy(_mqtt_config->broker, doc["broker"].as<const char*>(), sizeof(_mqtt_config->broker) - 1);
}
if (doc["port"].is<int>()) {
_mqtt_config->port = doc["port"].as<uint16_t>();
}
if (doc["user"].is<const char*>()) {
strncpy(_mqtt_config->user, doc["user"].as<const char*>(), sizeof(_mqtt_config->user) - 1);
}
if (doc["password"].is<const char*>()) {
strncpy(_mqtt_config->password, doc["password"].as<const char*>(), sizeof(_mqtt_config->password) - 1);
}
if (doc["topic_prefix"].is<const char*>()) {
strncpy(_mqtt_config->topic_prefix, doc["topic_prefix"].as<const char*>(), sizeof(_mqtt_config->topic_prefix) - 1);
}
if (_save_callback) _save_callback();
// Update MQTT bridge config
_mqtt.updateConfig(*_mqtt_config);
JsonDocument resp;
resp["success"] = true;
resp["message"] = "MQTT config saved";
sendJsonResponse(request, resp);
}
}
);
_server.on("/api/reboot", HTTP_POST, [this](AsyncWebServerRequest* request) {
handleReboot(request);
});
// Handle 404
_server.onNotFound([this](AsyncWebServerRequest* request) {
handleNotFound(request);
});
}
void WebConfig::handleRoot(AsyncWebServerRequest* request) {
request->send_P(200, "text/html", WEB_UI_HTML);
}
void WebConfig::handleStatus(AsyncWebServerRequest* request) {
JsonDocument doc;
// Node info
doc["node_id"] = _node_id;
doc["node_name"] = _node_name ? _node_name : "MeshCore";
doc["firmware"] = _firmware_version ? _firmware_version : "unknown";
// WiFi status
JsonObject wifi = doc["wifi"].to<JsonObject>();
wifi["connected"] = _wifi.isConnected();
wifi["ap_mode"] = _wifi.isAPMode();
wifi["ip"] = _wifi.getLocalIP().toString();
wifi["rssi"] = _wifi.getRSSI();
wifi["ssid"] = _wifi.getSSID();
wifi["mac"] = _wifi.getMacAddress();
// MQTT status
JsonObject mqtt = doc["mqtt"].to<JsonObject>();
mqtt["connected"] = _mqtt.isConnected();
mqtt["enabled"] = _mqtt.isEnabled();
mqtt["broker"] = _mqtt.getBroker();
mqtt["port"] = _mqtt.getPort();
mqtt["msgs_sent"] = _mqtt.getMessagesSent();
mqtt["msgs_recv"] = _mqtt.getMessagesReceived();
// Mesh stats
JsonObject mesh = doc["mesh"].to<JsonObject>();
mesh["uptime_secs"] = _stats.uptime_secs;
mesh["packets_rx"] = _stats.packets_rx;
mesh["packets_tx"] = _stats.packets_tx;
mesh["air_time_secs"] = _stats.air_time_secs;
mesh["noise_floor"] = _stats.noise_floor;
mesh["last_rssi"] = _stats.last_rssi;
mesh["last_snr"] = _stats.last_snr;
mesh["tx_queue_len"] = _stats.tx_queue_len;
mesh["batt_mv"] = _stats.batt_mv;
// System info
doc["free_heap"] = ESP.getFreeHeap();
doc["uptime_ms"] = millis();
sendJsonResponse(request, doc);
}
void WebConfig::handleWiFiGet(AsyncWebServerRequest* request) {
if (!_wifi_config) {
sendErrorResponse(request, "Config not available");
return;
}
JsonDocument doc;
doc["ssid"] = _wifi_config->ssid;
// Don't send password for security
doc["password_set"] = strlen(_wifi_config->password) > 0;
doc["ap_ssid"] = _wifi_config->ap_ssid;
sendJsonResponse(request, doc);
}
void WebConfig::handleMQTTGet(AsyncWebServerRequest* request) {
if (!_mqtt_config) {
sendErrorResponse(request, "Config not available");
return;
}
JsonDocument doc;
doc["enabled"] = _mqtt_config->enabled != 0;
doc["broker"] = _mqtt_config->broker;
doc["port"] = _mqtt_config->port;
doc["user"] = _mqtt_config->user;
// Don't send password for security
doc["password_set"] = strlen(_mqtt_config->password) > 0;
doc["topic_prefix"] = _mqtt_config->topic_prefix;
sendJsonResponse(request, doc);
}
void WebConfig::handleReboot(AsyncWebServerRequest* request) {
JsonDocument doc;
doc["success"] = true;
doc["message"] = "Rebooting...";
sendJsonResponse(request, doc);
// Schedule reboot after response is sent
delay(100);
if (_reboot_callback) {
_reboot_callback();
} else {
ESP.restart();
}
}
void WebConfig::handleNotFound(AsyncWebServerRequest* request) {
request->send(404, "text/plain", "Not found");
}
void WebConfig::sendJsonResponse(AsyncWebServerRequest* request, JsonDocument& doc, int code) {
String response;
serializeJson(doc, response);
request->send(code, "application/json", response);
}
void WebConfig::sendErrorResponse(AsyncWebServerRequest* request, const char* message, int code) {
JsonDocument doc;
doc["success"] = false;
doc["error"] = message;
sendJsonResponse(request, doc, code);
}
#endif // WITH_MQTT