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
This commit is contained in:
Ryan Malloy 2026-01-25 22:39:09 -07:00
parent d3a128e12f
commit 04667f5161
10 changed files with 1782 additions and 0 deletions

View File

@ -114,3 +114,31 @@ build_flags =
upload_port = /dev/ttyUSB0 upload_port = /dev/ttyUSB0
monitor_port = /dev/ttyUSB0 monitor_port = /dev/ttyUSB0
; =============================================================================
; MQTT Gateway Environment
; Adds WiFi + MQTT bridging capability to the repeater
; Configure WiFi credentials and MQTT broker below or via web UI
; =============================================================================
[env:heltec_v3_mqtt]
extends = env:heltec_v3_repeater
; Additional libraries for MQTT functionality
lib_deps =
${env:heltec_v3_repeater.lib_deps}
knolleary/PubSubClient @ ^2.8
bblanchon/ArduinoJson @ ^7.0
build_flags =
${env:heltec_v3_repeater.build_flags}
; Enable MQTT gateway feature
-D WITH_MQTT=1
; WiFi credentials (configure these or use web UI)
-D MQTT_WIFI_SSID='"YourNetworkSSID"'
-D MQTT_WIFI_PASS='"YourNetworkPassword"'
; MQTT broker settings (defaults, can be changed via web UI)
-D MQTT_BROKER='"meshqt.l.supported.systems"'
-D MQTT_PORT=443
-D MQTT_USER='""'
-D MQTT_PASS='""'
-D MQTT_TOPIC_PREFIX='"meshcore/repeater"'

402
src/MQTTBridge.cpp Normal file
View File

@ -0,0 +1,402 @@
#ifdef WITH_MQTT
#include "MQTTBridge.h"
#include <MeshCore.h>
#include <RTClib.h>
// Static instance for callback
MQTTBridge* MQTTBridge::_instance = nullptr;
MQTTBridge::MQTTBridge(WiFiManager& wifi, mesh::PacketManager* mgr, mesh::RTCClock* rtc)
: _wifi(wifi),
_mqtt_client(_wifi_client),
_state(MQTTState::DISCONNECTED),
_mgr(mgr),
_rtc(rtc),
_last_connect_attempt(0),
_last_status_publish(0),
_last_stats_publish(0),
_connected_since(0),
_messages_sent(0),
_messages_received(0),
_reconnect_count(0),
_hash_index(0),
_command_callback(nullptr),
_initialized(false) {
memset(&_config, 0, sizeof(_config));
memset(_self_pubkey, 0, sizeof(_self_pubkey));
memset(_gateway_id, 0, sizeof(_gateway_id));
memset(_seen_hashes, 0, sizeof(_seen_hashes));
_instance = this;
}
void MQTTBridge::begin(const MQTTConfig& config, const uint8_t* self_pubkey) {
_config = config;
memcpy(_self_pubkey, self_pubkey, 32);
// Generate gateway ID from first 8 bytes of pubkey
for (int i = 0; i < 8; i++) {
sprintf(&_gateway_id[i * 2], "%02x", _self_pubkey[i]);
}
setupTopics();
// Configure TLS - skip cert verification (traffic still encrypted)
_wifi_client.setInsecure();
_mqtt_client.setServer(_config.broker, _config.port);
_mqtt_client.setCallback(mqttCallback);
_mqtt_client.setKeepAlive(_config.keepalive_secs > 0 ? _config.keepalive_secs : 60);
_mqtt_client.setBufferSize(512);
_initialized = true;
_state = MQTTState::DISCONNECTED;
Serial.printf("[MQTTS] Initialized: %s:%d (TLS), prefix=%s, gateway=%s\n",
_config.broker, _config.port, _config.topic_prefix, _gateway_id);
if (_config.enabled && _wifi.isConnected()) {
attemptConnection();
}
}
void MQTTBridge::loop() {
if (!_initialized || !_config.enabled) return;
if (!_wifi.isConnected()) {
if (_state == MQTTState::CONNECTED) {
_state = MQTTState::DISCONNECTED;
Serial.println("[MQTTS] WiFi lost, disconnected");
}
return;
}
switch (_state) {
case MQTTState::DISCONNECTED:
if (millis() - _last_connect_attempt > 5000) {
attemptConnection();
}
break;
case MQTTState::CONNECTING:
break;
case MQTTState::CONNECTED:
if (!_mqtt_client.connected()) {
_state = MQTTState::DISCONNECTED;
Serial.println("[MQTTS] Connection lost");
_last_connect_attempt = millis();
} else {
_mqtt_client.loop();
if (millis() - _last_status_publish > 60000) {
publishStatus();
_last_status_publish = millis();
}
}
break;
case MQTTState::ERROR:
if (millis() - _last_connect_attempt > 30000) {
_state = MQTTState::DISCONNECTED;
}
break;
}
}
void MQTTBridge::end() {
if (!_initialized) return;
if (_mqtt_client.connected()) {
JsonDocument doc;
doc["status"] = "offline";
doc["gateway_id"] = _gateway_id;
doc["timestamp"] = getTimestamp();
publishJson(_topic_status, doc, true);
_mqtt_client.disconnect();
}
_state = MQTTState::DISCONNECTED;
_initialized = false;
Serial.println("[MQTTS] Stopped");
}
void MQTTBridge::attemptConnection() {
if (!_wifi.isConnected()) return;
_state = MQTTState::CONNECTING;
_last_connect_attempt = millis();
Serial.printf("[MQTTS] Connecting to %s:%d...\n", _config.broker, _config.port);
String client_id = String(_config.client_id);
if (client_id.length() == 0) {
client_id = "meshcore-" + String(_gateway_id);
}
char lwt_topic[80];
snprintf(lwt_topic, sizeof(lwt_topic), "%s/gateway/%s/status", _config.topic_prefix, _gateway_id);
bool connected = false;
if (strlen(_config.user) > 0) {
connected = _mqtt_client.connect(client_id.c_str(), _config.user, _config.password,
lwt_topic, 1, true, "{\"status\":\"offline\"}");
} else {
connected = _mqtt_client.connect(client_id.c_str(),
lwt_topic, 1, true, "{\"status\":\"offline\"}");
}
if (connected) {
_state = MQTTState::CONNECTED;
_connected_since = millis();
_reconnect_count++;
Serial.println("[MQTTS] Connected!");
subscribeToCommands();
publishStatus();
_last_status_publish = millis();
} else {
int rc = _mqtt_client.state();
Serial.printf("[MQTTS] Connection failed, rc=%d\n", rc);
_state = MQTTState::ERROR;
}
}
void MQTTBridge::setupTopics() {
snprintf(_topic_status, sizeof(_topic_status),
"%s/gateway/%s/status", _config.topic_prefix, _gateway_id);
snprintf(_topic_packets_rx, sizeof(_topic_packets_rx),
"%s/packets/rx", _config.topic_prefix);
snprintf(_topic_packets_tx, sizeof(_topic_packets_tx),
"%s/packets/tx", _config.topic_prefix);
snprintf(_topic_adverts, sizeof(_topic_adverts),
"%s/adverts", _config.topic_prefix);
snprintf(_topic_stats, sizeof(_topic_stats),
"%s/gateway/%s/stats", _config.topic_prefix, _gateway_id);
snprintf(_topic_cmd_prefix, sizeof(_topic_cmd_prefix),
"%s/gateway/%s/cmd/", _config.topic_prefix, _gateway_id);
}
void MQTTBridge::subscribeToCommands() {
char topic[100];
snprintf(topic, sizeof(topic), "%s#", _topic_cmd_prefix);
_mqtt_client.subscribe(topic);
Serial.printf("[MQTTS] Subscribed to: %s\n", topic);
}
void MQTTBridge::updateConfig(const MQTTConfig& config) {
bool reconnect_needed = (strcmp(_config.broker, config.broker) != 0 ||
_config.port != config.port ||
strcmp(_config.user, config.user) != 0 ||
strcmp(_config.password, config.password) != 0);
_config = config;
setupTopics();
if (reconnect_needed && _mqtt_client.connected()) {
_mqtt_client.disconnect();
_state = MQTTState::DISCONNECTED;
_last_connect_attempt = 0;
}
}
void MQTTBridge::publishStatus() {
if (!_mqtt_client.connected()) return;
JsonDocument doc;
doc["status"] = "online";
doc["gateway_id"] = _gateway_id;
doc["ip"] = _wifi.getLocalIP().toString();
doc["rssi"] = _wifi.getRSSI();
doc["uptime_secs"] = (_connected_since > 0) ? (millis() - _connected_since) / 1000 : 0;
doc["free_heap"] = ESP.getFreeHeap();
doc["timestamp"] = getTimestamp();
publishJson(_topic_status, doc, true);
}
void MQTTBridge::publishStats(uint32_t uptime_secs, uint32_t packets_rx, uint32_t packets_tx,
uint32_t air_time_secs, int16_t noise_floor) {
if (!_mqtt_client.connected()) return;
JsonDocument doc;
doc["gateway_id"] = _gateway_id;
doc["uptime_secs"] = uptime_secs;
doc["packets_rx"] = packets_rx;
doc["packets_tx"] = packets_tx;
doc["air_time_secs"] = air_time_secs;
doc["noise_floor"] = noise_floor;
doc["mqtt_msgs_sent"] = _messages_sent;
doc["mqtt_msgs_recv"] = _messages_received;
doc["timestamp"] = getTimestamp();
publishJson(_topic_stats, doc);
}
void MQTTBridge::publishPacketRx(mesh::Packet* pkt, int len, float snr, float rssi) {
if (!_mqtt_client.connected()) return;
if (isDuplicate(reinterpret_cast<uint8_t*>(pkt), len)) {
return;
}
JsonDocument doc;
doc["gateway_id"] = _gateway_id;
doc["direction"] = "rx";
doc["len"] = len;
doc["payload_type"] = pkt->getPayloadType();
doc["route_type"] = pkt->isRouteDirect() ? "direct" : "flood";
doc["path_len"] = pkt->path_len;
doc["payload_len"] = pkt->payload_len;
doc["snr"] = snr;
doc["rssi"] = rssi;
doc["timestamp"] = getTimestamp();
char hex_buf[MAX_TRANS_UNIT * 2 + 1];
size_t hex_len = 0;
for (int i = 0; i < len && hex_len < sizeof(hex_buf) - 2; i++) {
sprintf(&hex_buf[hex_len], "%02x", reinterpret_cast<uint8_t*>(pkt)[i]);
hex_len += 2;
}
hex_buf[hex_len] = '\0';
doc["raw"] = hex_buf;
publishJson(_topic_packets_rx, doc);
}
void MQTTBridge::publishPacketTx(mesh::Packet* pkt, int len) {
if (!_mqtt_client.connected()) return;
if (isDuplicate(reinterpret_cast<uint8_t*>(pkt), len)) {
return;
}
JsonDocument doc;
doc["gateway_id"] = _gateway_id;
doc["direction"] = "tx";
doc["len"] = len;
doc["payload_type"] = pkt->getPayloadType();
doc["route_type"] = pkt->isRouteDirect() ? "direct" : "flood";
doc["path_len"] = pkt->path_len;
doc["payload_len"] = pkt->payload_len;
doc["timestamp"] = getTimestamp();
char hex_buf[MAX_TRANS_UNIT * 2 + 1];
size_t hex_len = 0;
for (int i = 0; i < len && hex_len < sizeof(hex_buf) - 2; i++) {
sprintf(&hex_buf[hex_len], "%02x", reinterpret_cast<uint8_t*>(pkt)[i]);
hex_len += 2;
}
hex_buf[hex_len] = '\0';
doc["raw"] = hex_buf;
publishJson(_topic_packets_tx, doc);
}
void MQTTBridge::publishAdvert(const uint8_t* pubkey, uint32_t timestamp,
const uint8_t* app_data, size_t app_data_len) {
if (!_mqtt_client.connected()) return;
JsonDocument doc;
doc["gateway_id"] = _gateway_id;
char node_id[17];
for (int i = 0; i < 8; i++) {
sprintf(&node_id[i * 2], "%02x", pubkey[i]);
}
doc["node_id"] = node_id;
doc["advert_timestamp"] = timestamp;
doc["timestamp"] = getTimestamp();
if (app_data_len > 0) {
char hex_buf[128];
size_t hex_len = 0;
for (size_t i = 0; i < app_data_len && hex_len < sizeof(hex_buf) - 2; i++) {
sprintf(&hex_buf[hex_len], "%02x", app_data[i]);
hex_len += 2;
}
hex_buf[hex_len] = '\0';
doc["app_data"] = hex_buf;
}
publishJson(_topic_adverts, doc);
}
void MQTTBridge::publishMessage(const char* topic, const char* payload, bool retained) {
if (_mqtt_client.publish(topic, payload, retained)) {
_messages_sent++;
} else {
Serial.printf("[MQTTS] Publish failed to %s\n", topic);
}
}
void MQTTBridge::publishJson(const char* topic, JsonDocument& doc, bool retained) {
char buffer[512];
size_t len = serializeJson(doc, buffer, sizeof(buffer));
if (len > 0 && len < sizeof(buffer)) {
publishMessage(topic, buffer, retained);
}
}
uint32_t MQTTBridge::fnv1a_hash(const uint8_t* data, size_t len) {
uint32_t hash = 2166136261u;
for (size_t i = 0; i < len; i++) {
hash ^= data[i];
hash *= 16777619u;
}
return hash;
}
bool MQTTBridge::isDuplicate(const uint8_t* data, size_t len) {
uint32_t hash = fnv1a_hash(data, len);
for (size_t i = 0; i < HASH_TABLE_SIZE; i++) {
if (_seen_hashes[i] == hash) {
return true;
}
}
_seen_hashes[_hash_index] = hash;
_hash_index = (_hash_index + 1) % HASH_TABLE_SIZE;
return false;
}
void MQTTBridge::mqttCallback(char* topic, uint8_t* payload, unsigned int length) {
if (_instance) {
_instance->handleMessage(topic, payload, length);
}
}
void MQTTBridge::handleMessage(char* topic, uint8_t* payload, unsigned int length) {
_messages_received++;
if (strncmp(topic, _topic_cmd_prefix, strlen(_topic_cmd_prefix)) == 0) {
const char* cmd = topic + strlen(_topic_cmd_prefix);
Serial.printf("[MQTTS] Command received: %s\n", cmd);
if (strcmp(cmd, "reboot") == 0) {
Serial.println("[MQTTS] Reboot requested");
publishStatus();
delay(100);
ESP.restart();
} else if (strcmp(cmd, "status") == 0) {
publishStatus();
}
if (_command_callback) {
_command_callback(cmd, payload, length);
}
}
}
const char* MQTTBridge::getTimestamp() {
static char buf[32];
uint32_t now = _rtc->getCurrentTime();
DateTime dt = DateTime(now);
snprintf(buf, sizeof(buf), "%04d-%02d-%02dT%02d:%02d:%02dZ",
dt.year(), dt.month(), dt.day(), dt.hour(), dt.minute(), dt.second());
return buf;
}
#endif // WITH_MQTT

122
src/MQTTBridge.h Normal file
View File

@ -0,0 +1,122 @@
#pragma once
#ifdef WITH_MQTT
#include <Arduino.h>
#include <WiFiClientSecure.h>
#include <PubSubClient.h>
#include <ArduinoJson.h>
#include <Mesh.h>
#include "WiFiManager.h"
// Forward declarations
struct NodePrefs;
// MQTT connection states
enum class MQTTState {
DISCONNECTED,
CONNECTING,
CONNECTED,
ERROR
};
// MQTT configuration
struct MQTTConfig {
char broker[64];
uint16_t port;
char user[32];
char password[64];
char topic_prefix[32];
char client_id[24];
uint8_t enabled;
uint16_t keepalive_secs;
uint16_t publish_interval_ms;
};
class MQTTBridge {
public:
MQTTBridge(WiFiManager& wifi, mesh::PacketManager* mgr, mesh::RTCClock* rtc);
void begin(const MQTTConfig& config, const uint8_t* self_pubkey);
void loop();
void end();
MQTTState getState() const { return _state; }
bool isConnected() const { return _state == MQTTState::CONNECTED; }
bool isEnabled() const { return _config.enabled; }
void updateConfig(const MQTTConfig& config);
// Publish methods
void publishStatus();
void publishStats(uint32_t uptime_secs, uint32_t packets_rx, uint32_t packets_tx,
uint32_t air_time_secs, int16_t noise_floor);
void publishPacketRx(mesh::Packet* pkt, int len, float snr, float rssi);
void publishPacketTx(mesh::Packet* pkt, int len);
void publishAdvert(const uint8_t* pubkey, uint32_t timestamp,
const uint8_t* app_data, size_t app_data_len);
const char* getBroker() const { return _config.broker; }
uint16_t getPort() const { return _config.port; }
uint32_t getMessagesSent() const { return _messages_sent; }
uint32_t getMessagesReceived() const { return _messages_received; }
uint32_t getReconnectCount() const { return _reconnect_count; }
using CommandCallback = void (*)(const char* topic, const uint8_t* payload, size_t len);
void setCommandCallback(CommandCallback cb) { _command_callback = cb; }
private:
WiFiManager& _wifi;
WiFiClientSecure _wifi_client;
PubSubClient _mqtt_client;
MQTTConfig _config;
MQTTState _state;
mesh::PacketManager* _mgr;
mesh::RTCClock* _rtc;
uint8_t _self_pubkey[32];
char _gateway_id[17];
unsigned long _last_connect_attempt;
unsigned long _last_status_publish;
unsigned long _last_stats_publish;
unsigned long _connected_since;
uint32_t _messages_sent;
uint32_t _messages_received;
uint32_t _reconnect_count;
// Deduplication
static const size_t HASH_TABLE_SIZE = 64;
uint32_t _seen_hashes[HASH_TABLE_SIZE];
size_t _hash_index;
CommandCallback _command_callback;
bool _initialized;
// Topic buffers
char _topic_status[80];
char _topic_packets_rx[80];
char _topic_packets_tx[80];
char _topic_adverts[80];
char _topic_stats[80];
char _topic_cmd_prefix[80];
void attemptConnection();
void setupTopics();
void subscribeToCommands();
void publishMessage(const char* topic, const char* payload, bool retained = false);
void publishJson(const char* topic, JsonDocument& doc, bool retained = false);
static uint32_t fnv1a_hash(const uint8_t* data, size_t len);
bool isDuplicate(const uint8_t* data, size_t len);
// Static callback for PubSubClient
static void mqttCallback(char* topic, uint8_t* payload, unsigned int length);
static MQTTBridge* _instance;
void handleMessage(char* topic, uint8_t* payload, unsigned int length);
const char* getTimestamp();
};
#endif // WITH_MQTT

View File

@ -345,6 +345,12 @@ void MyMesh::logRx(mesh::Packet *pkt, int len, float score) {
} }
#endif #endif
#ifdef WITH_MQTT
if (_mqtt_bridge && _mqtt_bridge->isConnected()) {
_mqtt_bridge->publishPacketRx(pkt, len, _radio->getLastSNR(), _radio->getLastRSSI());
}
#endif
if (_logging) { if (_logging) {
File f = openAppend(PACKET_LOG_FILE); File f = openAppend(PACKET_LOG_FILE);
if (f) { if (f) {
@ -371,6 +377,12 @@ void MyMesh::logTx(mesh::Packet *pkt, int len) {
} }
#endif #endif
#ifdef WITH_MQTT
if (_mqtt_bridge && _mqtt_bridge->isConnected()) {
_mqtt_bridge->publishPacketTx(pkt, len);
}
#endif
if (_logging) { if (_logging) {
File f = openAppend(PACKET_LOG_FILE); File f = openAppend(PACKET_LOG_FILE);
if (f) { if (f) {
@ -710,6 +722,9 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc
StrHelper::strncpy(_prefs.bridge_secret, "LVSITANOS", sizeof(_prefs.bridge_secret)); StrHelper::strncpy(_prefs.bridge_secret, "LVSITANOS", sizeof(_prefs.bridge_secret));
// Owner info default (empty)
_prefs.owner_info[0] = 0;
// GPS defaults // GPS defaults
_prefs.gps_enabled = 0; _prefs.gps_enabled = 0;
_prefs.gps_interval = 0; _prefs.gps_interval = 0;
@ -733,6 +748,11 @@ void MyMesh::begin(FILESYSTEM *fs) {
} }
#endif #endif
#ifdef WITH_MQTT
// Initialize MQTT gateway
initMQTT();
#endif
radio_set_params(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); radio_set_params(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr);
radio_set_tx_power(_prefs.tx_power_dbm); radio_set_tx_power(_prefs.tx_power_dbm);
@ -1071,6 +1091,41 @@ void MyMesh::loop() {
bridge.loop(); bridge.loop();
#endif #endif
#ifdef WITH_MQTT
// Process WiFi, MQTT, and web server
_wifi_mgr.loop();
if (_mqtt_bridge) {
_mqtt_bridge->loop();
// Periodic stats publish (every 30 seconds)
if (_mqtt_bridge->isConnected() && millis() - _last_mqtt_stats > 30000) {
_mqtt_bridge->publishStats(
uptime_millis / 1000,
radio_driver.getPacketsRecv(),
radio_driver.getPacketsSent(),
getTotalAirTime() / 1000,
(int16_t)_radio->getNoiseFloor()
);
_last_mqtt_stats = millis();
// Update web config stats
if (_web_config) {
WebConfigStats stats;
stats.uptime_secs = uptime_millis / 1000;
stats.packets_rx = radio_driver.getPacketsRecv();
stats.packets_tx = radio_driver.getPacketsSent();
stats.air_time_secs = getTotalAirTime() / 1000;
stats.noise_floor = (int16_t)_radio->getNoiseFloor();
stats.last_rssi = (int16_t)radio_driver.getLastRSSI();
stats.last_snr = radio_driver.getLastSNR();
stats.tx_queue_len = _mgr->getOutboundCount(0xFFFFFFFF);
stats.batt_mv = board.getBattMilliVolts();
_web_config->updateStats(stats);
}
}
}
#endif
mesh::Mesh::loop(); mesh::Mesh::loop();
if (next_flood_advert && millisHasNowPassed(next_flood_advert)) { if (next_flood_advert && millisHasNowPassed(next_flood_advert)) {
@ -1109,3 +1164,110 @@ void MyMesh::loop() {
uptime_millis += now - last_millis; uptime_millis += now - last_millis;
last_millis = now; last_millis = now;
} }
#ifdef WITH_MQTT
// MQTT initialization and helper methods
void MyMesh::initMQTT() {
Serial.println("[MQTT] Initializing WiFi + MQTT gateway...");
// Configure WiFi
memset(&_wifi_config, 0, sizeof(_wifi_config));
#ifdef MQTT_WIFI_SSID
strncpy(_wifi_config.ssid, MQTT_WIFI_SSID, sizeof(_wifi_config.ssid) - 1);
#endif
#ifdef MQTT_WIFI_PASS
strncpy(_wifi_config.password, MQTT_WIFI_PASS, sizeof(_wifi_config.password) - 1);
#endif
strncpy(_wifi_config.ap_ssid, "MeshCore", sizeof(_wifi_config.ap_ssid) - 1);
_wifi_config.connect_timeout_ms = 15000;
_wifi_config.reconnect_interval_ms = 10000;
_wifi_config.max_retries = 5;
// Configure MQTT
memset(&_mqtt_config, 0, sizeof(_mqtt_config));
#ifdef MQTT_BROKER
strncpy(_mqtt_config.broker, MQTT_BROKER, sizeof(_mqtt_config.broker) - 1);
#else
strncpy(_mqtt_config.broker, "mqtt.example.com", sizeof(_mqtt_config.broker) - 1);
#endif
#ifdef MQTT_PORT
_mqtt_config.port = MQTT_PORT;
#else
_mqtt_config.port = 1883;
#endif
#ifdef MQTT_USER
strncpy(_mqtt_config.user, MQTT_USER, sizeof(_mqtt_config.user) - 1);
#endif
#ifdef MQTT_PASS
strncpy(_mqtt_config.password, MQTT_PASS, sizeof(_mqtt_config.password) - 1);
#endif
#ifdef MQTT_TOPIC_PREFIX
strncpy(_mqtt_config.topic_prefix, MQTT_TOPIC_PREFIX, sizeof(_mqtt_config.topic_prefix) - 1);
#else
strncpy(_mqtt_config.topic_prefix, "meshcore/repeater", sizeof(_mqtt_config.topic_prefix) - 1);
#endif
_mqtt_config.enabled = 1;
_mqtt_config.keepalive_secs = 60;
_mqtt_config.publish_interval_ms = 100;
// Start WiFi
_wifi_mgr.begin(_wifi_config);
// Create MQTT bridge
_mqtt_bridge = new MQTTBridge(_wifi_mgr, _mgr, &rtc_clock);
_mqtt_bridge->begin(_mqtt_config, self_id.pub_key);
// Create web config server
_web_config = new WebConfig(_wifi_mgr, *_mqtt_bridge);
_web_config->begin(&_wifi_config, &_mqtt_config, &_prefs);
_web_config->setNodeInfo(self_id.pub_key, _prefs.node_name, FIRMWARE_VERSION);
_web_config->setSaveCallback([]() {
// Note: In a real implementation, we'd save to preferences file
Serial.println("[WebConfig] Save callback triggered");
});
_web_config->setRebootCallback([]() {
ESP.restart();
});
_last_mqtt_stats = millis();
Serial.println("[MQTT] Gateway initialized");
}
bool MyMesh::isMQTTConnected() const {
return _mqtt_bridge && _mqtt_bridge->isConnected();
}
bool MyMesh::isWiFiConnected() const {
return _wifi_mgr.isConnected();
}
void MyMesh::setMQTTEnabled(bool enable) {
_mqtt_config.enabled = enable ? 1 : 0;
if (_mqtt_bridge) {
_mqtt_bridge->updateConfig(_mqtt_config);
}
}
const char* MyMesh::getMQTTStatus() const {
if (!_mqtt_bridge) return "not initialized";
if (!_mqtt_config.enabled) return "disabled";
if (_mqtt_bridge->isConnected()) return "connected";
return "disconnected";
}
const char* MyMesh::getWiFiStatus() const {
switch (_wifi_mgr.getState()) {
case WiFiState::CONNECTED: return "connected";
case WiFiState::CONNECTING: return "connecting";
case WiFiState::AP_MODE: return "ap_mode";
default: return "disconnected";
}
}
IPAddress MyMesh::getWiFiIP() const {
return _wifi_mgr.getLocalIP();
}
#endif // WITH_MQTT

View File

@ -23,6 +23,12 @@
#define WITH_BRIDGE #define WITH_BRIDGE
#endif #endif
#ifdef WITH_MQTT
#include "WiFiManager.h"
#include "MQTTBridge.h"
#include "WebConfig.h"
#endif
#include <helpers/AdvertDataHelpers.h> #include <helpers/AdvertDataHelpers.h>
#include <helpers/ArduinoHelpers.h> #include <helpers/ArduinoHelpers.h>
#include <helpers/ClientACL.h> #include <helpers/ClientACL.h>
@ -112,6 +118,17 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks {
ESPNowBridge bridge; ESPNowBridge bridge;
#endif #endif
#ifdef WITH_MQTT
WiFiManager _wifi_mgr;
MQTTBridge* _mqtt_bridge;
WebConfig* _web_config;
WiFiConfig _wifi_config;
MQTTConfig _mqtt_config;
unsigned long _last_mqtt_stats;
void initMQTT();
#endif
void putNeighbour(const mesh::Identity& id, uint32_t timestamp, float snr); void putNeighbour(const mesh::Identity& id, uint32_t timestamp, float snr);
uint8_t handleLoginReq(const mesh::Identity& sender, const uint8_t* secret, uint32_t sender_timestamp, const uint8_t* data, bool is_flood); uint8_t handleLoginReq(const mesh::Identity& sender, const uint8_t* secret, uint32_t sender_timestamp, const uint8_t* data, bool is_flood);
int handleRequest(ClientInfo* sender, uint32_t sender_timestamp, uint8_t* payload, size_t payload_len); int handleRequest(ClientInfo* sender, uint32_t sender_timestamp, uint8_t* payload, size_t payload_len);
@ -206,6 +223,16 @@ public:
void handleCommand(uint32_t sender_timestamp, char* command, char* reply); void handleCommand(uint32_t sender_timestamp, char* command, char* reply);
void loop(); void loop();
#ifdef WITH_MQTT
// MQTT gateway methods
bool isMQTTConnected() const;
bool isWiFiConnected() const;
void setMQTTEnabled(bool enable);
const char* getMQTTStatus() const;
const char* getWiFiStatus() const;
IPAddress getWiFiIP() const;
#endif
#if defined(WITH_BRIDGE) #if defined(WITH_BRIDGE)
void setBridgeState(bool enable) override { void setBridgeState(bool enable) override {
if (enable == bridge.isRunning()) return; if (enable == bridge.isRunning()) return;

284
src/WebConfig.cpp Normal file
View File

@ -0,0 +1,284 @@
#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

86
src/WebConfig.h Normal file
View File

@ -0,0 +1,86 @@
#pragma once
#ifdef WITH_MQTT
#include <Arduino.h>
#include <ESPAsyncWebServer.h>
#include <ArduinoJson.h>
#include "WiFiManager.h"
#include "MQTTBridge.h"
// Forward declarations
struct NodePrefs;
// Callback types for WebConfig
using SaveConfigCallback = void (*)();
using RebootCallback = void (*)();
// Stats structure for status endpoint
struct WebConfigStats {
uint32_t uptime_secs;
uint32_t packets_rx;
uint32_t packets_tx;
uint32_t air_time_secs;
int16_t noise_floor;
int16_t last_rssi;
float last_snr;
uint16_t tx_queue_len;
uint16_t batt_mv;
};
class WebConfig {
public:
WebConfig(WiFiManager& wifi, MQTTBridge& mqtt, uint16_t port = 80);
// Initialize web server with configuration references
void begin(WiFiConfig* wifi_config, MQTTConfig* mqtt_config, NodePrefs* prefs);
// Update stats for status endpoint
void updateStats(const WebConfigStats& stats);
// Set callbacks
void setSaveCallback(SaveConfigCallback cb) { _save_callback = cb; }
void setRebootCallback(RebootCallback cb) { _reboot_callback = cb; }
// Set node identity info
void setNodeInfo(const uint8_t* pubkey, const char* name, const char* version);
// Stop web server
void end();
private:
AsyncWebServer _server;
WiFiManager& _wifi;
MQTTBridge& _mqtt;
WiFiConfig* _wifi_config;
MQTTConfig* _mqtt_config;
NodePrefs* _prefs;
WebConfigStats _stats;
char _node_id[17];
const char* _node_name;
const char* _firmware_version;
SaveConfigCallback _save_callback;
RebootCallback _reboot_callback;
bool _initialized;
void setupRoutes();
// Route handlers
void handleRoot(AsyncWebServerRequest* request);
void handleStatus(AsyncWebServerRequest* request);
void handleWiFiGet(AsyncWebServerRequest* request);
void handleWiFiPost(AsyncWebServerRequest* request, uint8_t* data, size_t len);
void handleMQTTGet(AsyncWebServerRequest* request);
void handleMQTTPost(AsyncWebServerRequest* request, uint8_t* data, size_t len);
void handleReboot(AsyncWebServerRequest* request);
void handleNotFound(AsyncWebServerRequest* request);
// Helper methods
void sendJsonResponse(AsyncWebServerRequest* request, JsonDocument& doc, int code = 200);
void sendErrorResponse(AsyncWebServerRequest* request, const char* message, int code = 400);
};
#endif // WITH_MQTT

222
src/WiFiManager.cpp Normal file
View File

@ -0,0 +1,222 @@
#ifdef WITH_MQTT
#include "WiFiManager.h"
WiFiManager::WiFiManager()
: _state(WiFiState::DISCONNECTED),
_connect_start_time(0),
_last_connect_attempt(0),
_connected_since(0),
_retry_count(0),
_initialized(false) {
memset(&_config, 0, sizeof(_config));
}
void WiFiManager::begin(const WiFiConfig& config) {
_config = config;
_initialized = true;
_state = WiFiState::DISCONNECTED;
_retry_count = 0;
// Set WiFi mode and hostname
WiFi.mode(WIFI_STA);
WiFi.setAutoReconnect(false); // We handle reconnection
// Generate unique hostname from MAC
String hostname = "meshcore-" + getMacAddress().substring(9);
hostname.replace(":", "");
WiFi.setHostname(hostname.c_str());
Serial.printf("[WiFi] Starting, hostname: %s\n", hostname.c_str());
// Attempt initial connection if SSID configured
if (strlen(_config.ssid) > 0) {
attemptConnection();
} else {
Serial.println("[WiFi] No SSID configured, starting AP mode");
startAPMode();
}
}
void WiFiManager::loop() {
if (!_initialized) return;
switch (_state) {
case WiFiState::CONNECTING:
// Check for connection timeout
if (millis() - _connect_start_time > _config.connect_timeout_ms) {
Serial.println("[WiFi] Connection timeout");
WiFi.disconnect();
_retry_count++;
if (_retry_count >= _config.max_retries) {
Serial.printf("[WiFi] Max retries (%d) reached, switching to AP mode\n", _config.max_retries);
startAPMode();
} else {
_state = WiFiState::DISCONNECTED;
_last_connect_attempt = millis();
}
} else if (WiFi.status() == WL_CONNECTED) {
_state = WiFiState::CONNECTED;
_connected_since = millis();
_retry_count = 0;
Serial.printf("[WiFi] Connected! IP: %s, RSSI: %d dBm\n",
WiFi.localIP().toString().c_str(), WiFi.RSSI());
}
break;
case WiFiState::CONNECTED:
// Check if still connected
if (WiFi.status() != WL_CONNECTED) {
Serial.println("[WiFi] Connection lost");
_state = WiFiState::DISCONNECTED;
_last_connect_attempt = millis();
}
break;
case WiFiState::DISCONNECTED:
// Try to reconnect after interval
if (strlen(_config.ssid) > 0 &&
millis() - _last_connect_attempt > _config.reconnect_interval_ms) {
attemptConnection();
}
break;
case WiFiState::AP_MODE:
// In AP mode, just monitor for clients
break;
}
}
void WiFiManager::end() {
if (!_initialized) return;
WiFi.disconnect(true);
WiFi.mode(WIFI_OFF);
_state = WiFiState::DISCONNECTED;
_initialized = false;
Serial.println("[WiFi] Stopped");
}
void WiFiManager::attemptConnection() {
if (strlen(_config.ssid) == 0) {
Serial.println("[WiFi] No SSID configured");
return;
}
Serial.printf("[WiFi] Connecting to '%s' (attempt %d/%d)...\n",
_config.ssid, _retry_count + 1, _config.max_retries);
// Ensure we're in STA mode
if (WiFi.getMode() != WIFI_STA) {
WiFi.mode(WIFI_STA);
}
WiFi.begin(_config.ssid, _config.password);
_state = WiFiState::CONNECTING;
_connect_start_time = millis();
}
void WiFiManager::checkConnection() {
// This is handled in loop() now
}
void WiFiManager::reconnect() {
if (_state == WiFiState::AP_MODE) {
// Switch back to STA mode first
WiFi.softAPdisconnect(true);
WiFi.mode(WIFI_STA);
}
_retry_count = 0;
_state = WiFiState::DISCONNECTED;
attemptConnection();
}
void WiFiManager::startAPMode() {
Serial.println("[WiFi] Starting AP mode for configuration...");
WiFi.disconnect();
WiFi.mode(WIFI_AP);
String ap_ssid = generateAPSSID();
bool success;
if (strlen(_config.ap_password) >= 8) {
success = WiFi.softAP(ap_ssid.c_str(), _config.ap_password);
} else {
success = WiFi.softAP(ap_ssid.c_str());
}
if (success) {
_state = WiFiState::AP_MODE;
Serial.printf("[WiFi] AP started: SSID='%s', IP=%s\n",
ap_ssid.c_str(), WiFi.softAPIP().toString().c_str());
} else {
Serial.println("[WiFi] Failed to start AP!");
_state = WiFiState::DISCONNECTED;
}
}
void WiFiManager::startStationMode() {
if (_state == WiFiState::AP_MODE) {
WiFi.softAPdisconnect(true);
}
WiFi.mode(WIFI_STA);
_state = WiFiState::DISCONNECTED;
_retry_count = 0;
if (strlen(_config.ssid) > 0) {
attemptConnection();
}
}
IPAddress WiFiManager::getLocalIP() const {
if (_state == WiFiState::AP_MODE) {
return WiFi.softAPIP();
}
return WiFi.localIP();
}
int32_t WiFiManager::getRSSI() const {
if (_state == WiFiState::CONNECTED) {
return WiFi.RSSI();
}
return 0;
}
const char* WiFiManager::getSSID() const {
if (_state == WiFiState::CONNECTED) {
return WiFi.SSID().c_str();
} else if (_state == WiFiState::AP_MODE) {
return WiFi.softAPSSID().c_str();
}
return "";
}
String WiFiManager::getMacAddress() const {
return WiFi.macAddress();
}
uint32_t WiFiManager::getConnectionUptime() const {
if (_state == WiFiState::CONNECTED && _connected_since > 0) {
return millis() - _connected_since;
}
return 0;
}
String WiFiManager::generateAPSSID() {
// Use last 4 chars of MAC for unique identifier
String mac = getMacAddress();
mac.replace(":", "");
String suffix = mac.substring(mac.length() - 4);
suffix.toUpperCase();
if (strlen(_config.ap_ssid) > 0) {
return String(_config.ap_ssid) + "-" + suffix;
}
return "MeshCore-" + suffix;
}
#endif // WITH_MQTT

80
src/WiFiManager.h Normal file
View File

@ -0,0 +1,80 @@
#pragma once
#ifdef WITH_MQTT
#include <Arduino.h>
#include <WiFi.h>
// WiFi connection states
enum class WiFiState {
DISCONNECTED,
CONNECTING,
CONNECTED,
AP_MODE
};
// Configuration for WiFi manager
struct WiFiConfig {
char ssid[33];
char password[65];
char ap_ssid[33]; // Fallback AP SSID
char ap_password[65]; // Fallback AP password (min 8 chars or empty for open)
uint32_t connect_timeout_ms;
uint32_t reconnect_interval_ms;
uint8_t max_retries;
};
class WiFiManager {
public:
WiFiManager();
// Initialize WiFi with configuration
void begin(const WiFiConfig& config);
// Update WiFi state (call from loop)
void loop();
// Stop WiFi and release resources
void end();
// Get current state
WiFiState getState() const { return _state; }
bool isConnected() const { return _state == WiFiState::CONNECTED; }
bool isAPMode() const { return _state == WiFiState::AP_MODE; }
// Get connection info
IPAddress getLocalIP() const;
int32_t getRSSI() const;
const char* getSSID() const;
String getMacAddress() const;
// Force reconnection attempt
void reconnect();
// Switch to AP mode for configuration
void startAPMode();
// Try to connect to station mode
void startStationMode();
// Get uptime since last connection (ms)
uint32_t getConnectionUptime() const;
// Get retry count
uint8_t getRetryCount() const { return _retry_count; }
private:
WiFiConfig _config;
WiFiState _state;
unsigned long _connect_start_time;
unsigned long _last_connect_attempt;
unsigned long _connected_since;
uint8_t _retry_count;
bool _initialized;
void attemptConnection();
void checkConnection();
String generateAPSSID();
};
#endif // WITH_MQTT

369
src/web_ui.h Normal file
View File

@ -0,0 +1,369 @@
#pragma once
#ifdef WITH_MQTT
// Embedded HTML/CSS/JS for configuration web interface
// Stored in PROGMEM to save RAM
const char WEB_UI_HTML[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MeshCore Gateway</title>
<style>
:root {
--bg: #1a1a2e;
--card: #16213e;
--accent: #0f3460;
--text: #e8e8e8;
--text-dim: #a0a0a0;
--success: #4ade80;
--warning: #fbbf24;
--error: #f87171;
--border: #2a3a5a;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
padding: 1rem;
}
.container { max-width: 600px; margin: 0 auto; }
h1 {
font-size: 1.5rem;
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
h1::before { content: "📡"; }
.card {
background: var(--card);
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
border: 1px solid var(--border);
}
.card h2 {
font-size: 1rem;
color: var(--text-dim);
margin-bottom: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.status-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.5rem;
}
.status-item {
display: flex;
justify-content: space-between;
padding: 0.5rem;
background: var(--accent);
border-radius: 4px;
}
.status-item .label { color: var(--text-dim); font-size: 0.875rem; }
.status-item .value { font-weight: 500; font-family: monospace; }
.indicator {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 0.5rem;
}
.indicator.online { background: var(--success); }
.indicator.offline { background: var(--error); }
.indicator.warning { background: var(--warning); }
form { display: flex; flex-direction: column; gap: 0.75rem; }
label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.875rem;
color: var(--text-dim);
}
input, select {
padding: 0.625rem;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--accent);
color: var(--text);
font-size: 1rem;
}
input:focus, select:focus {
outline: none;
border-color: var(--success);
}
.checkbox-label {
flex-direction: row;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.checkbox-label input {
width: auto;
accent-color: var(--success);
}
button {
padding: 0.75rem 1rem;
border: none;
border-radius: 4px;
background: var(--success);
color: #000;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
}
button:hover { opacity: 0.9; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
button.secondary {
background: var(--accent);
color: var(--text);
border: 1px solid var(--border);
}
button.danger { background: var(--error); }
.btn-row { display: flex; gap: 0.5rem; }
.msg {
padding: 0.75rem;
border-radius: 4px;
margin-bottom: 1rem;
display: none;
}
.msg.success { background: rgba(74, 222, 128, 0.2); border: 1px solid var(--success); display: block; }
.msg.error { background: rgba(248, 113, 113, 0.2); border: 1px solid var(--error); display: block; }
.hidden { display: none; }
@media (max-width: 480px) {
.status-grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div class="container">
<h1>MeshCore Gateway</h1>
<div id="msg" class="msg"></div>
<div class="card">
<h2>Status</h2>
<div class="status-grid">
<div class="status-item">
<span class="label">Node</span>
<span class="value" id="node-name">-</span>
</div>
<div class="status-item">
<span class="label">Node ID</span>
<span class="value" id="node-id">-</span>
</div>
<div class="status-item">
<span class="label">WiFi</span>
<span class="value"><span id="wifi-ind" class="indicator offline"></span><span id="wifi-status">-</span></span>
</div>
<div class="status-item">
<span class="label">MQTT</span>
<span class="value"><span id="mqtt-ind" class="indicator offline"></span><span id="mqtt-status">-</span></span>
</div>
<div class="status-item">
<span class="label">IP Address</span>
<span class="value" id="ip-addr">-</span>
</div>
<div class="status-item">
<span class="label">WiFi RSSI</span>
<span class="value" id="wifi-rssi">-</span>
</div>
<div class="status-item">
<span class="label">Packets RX/TX</span>
<span class="value" id="packets">-</span>
</div>
<div class="status-item">
<span class="label">Free Heap</span>
<span class="value" id="heap">-</span>
</div>
</div>
</div>
<div class="card">
<h2>WiFi Configuration</h2>
<form id="wifi-form">
<label>
Network SSID
<input type="text" id="wifi-ssid" maxlength="32" required>
</label>
<label>
Password
<input type="password" id="wifi-pass" maxlength="64" placeholder="Leave blank to keep current">
</label>
<div class="btn-row">
<button type="submit">Save WiFi</button>
<button type="button" class="secondary" onclick="loadWiFi()">Reset</button>
</div>
</form>
</div>
<div class="card">
<h2>MQTT Configuration</h2>
<form id="mqtt-form">
<label class="checkbox-label">
<input type="checkbox" id="mqtt-enabled">
MQTT Enabled
</label>
<label>
Broker Address
<input type="text" id="mqtt-broker" maxlength="63" placeholder="mqtt.example.com">
</label>
<label>
Port
<input type="number" id="mqtt-port" value="1883" min="1" max="65535">
</label>
<label>
Username (optional)
<input type="text" id="mqtt-user" maxlength="31">
</label>
<label>
Password (optional)
<input type="password" id="mqtt-pass" maxlength="63" placeholder="Leave blank to keep current">
</label>
<label>
Topic Prefix
<input type="text" id="mqtt-prefix" maxlength="31" placeholder="meshcore/repeater">
</label>
<div class="btn-row">
<button type="submit">Save MQTT</button>
<button type="button" class="secondary" onclick="loadMQTT()">Reset</button>
</div>
</form>
</div>
<div class="card">
<h2>System</h2>
<div class="btn-row">
<button type="button" class="danger" onclick="reboot()">Reboot Device</button>
</div>
</div>
</div>
<script>
const $ = id => document.getElementById(id);
let refreshTimer;
function showMsg(text, isError) {
const msg = $('msg');
msg.textContent = text;
msg.className = 'msg ' + (isError ? 'error' : 'success');
setTimeout(() => msg.className = 'msg', 5000);
}
async function api(endpoint, method = 'GET', data = null) {
const opts = { method, headers: {} };
if (data) {
opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(data);
}
const res = await fetch('/api/' + endpoint, opts);
return res.json();
}
async function loadStatus() {
try {
const s = await api('status');
$('node-name').textContent = s.node_name || '-';
$('node-id').textContent = s.node_id ? s.node_id.substring(0, 8) : '-';
$('wifi-ind').className = 'indicator ' + (s.wifi?.connected ? 'online' : 'offline');
$('wifi-status').textContent = s.wifi?.connected ? s.wifi.ssid : (s.wifi?.ap_mode ? 'AP Mode' : 'Disconnected');
$('ip-addr').textContent = s.wifi?.ip || '-';
$('wifi-rssi').textContent = s.wifi?.connected ? s.wifi.rssi + ' dBm' : '-';
$('mqtt-ind').className = 'indicator ' + (s.mqtt?.connected ? 'online' : (s.mqtt?.enabled ? 'warning' : 'offline'));
$('mqtt-status').textContent = s.mqtt?.connected ? 'Connected' : (s.mqtt?.enabled ? 'Connecting...' : 'Disabled');
$('packets').textContent = (s.mesh?.packets_rx || 0) + ' / ' + (s.mesh?.packets_tx || 0);
$('heap').textContent = s.free_heap ? Math.round(s.free_heap / 1024) + ' KB' : '-';
} catch (e) {
console.error('Status error:', e);
}
}
async function loadWiFi() {
try {
const w = await api('wifi');
$('wifi-ssid').value = w.ssid || '';
$('wifi-pass').value = '';
$('wifi-pass').placeholder = w.password_set ? 'Leave blank to keep current' : 'Enter password';
} catch (e) {
showMsg('Failed to load WiFi config', true);
}
}
async function loadMQTT() {
try {
const m = await api('mqtt');
$('mqtt-enabled').checked = m.enabled;
$('mqtt-broker').value = m.broker || '';
$('mqtt-port').value = m.port || 1883;
$('mqtt-user').value = m.user || '';
$('mqtt-pass').value = '';
$('mqtt-prefix').value = m.topic_prefix || 'meshcore/repeater';
} catch (e) {
showMsg('Failed to load MQTT config', true);
}
}
$('wifi-form').onsubmit = async (e) => {
e.preventDefault();
try {
const data = { ssid: $('wifi-ssid').value };
if ($('wifi-pass').value) data.password = $('wifi-pass').value;
const res = await api('wifi', 'POST', data);
showMsg(res.message || 'WiFi config saved');
} catch (e) {
showMsg('Failed to save WiFi config', true);
}
};
$('mqtt-form').onsubmit = async (e) => {
e.preventDefault();
try {
const data = {
enabled: $('mqtt-enabled').checked,
broker: $('mqtt-broker').value,
port: parseInt($('mqtt-port').value),
user: $('mqtt-user').value,
topic_prefix: $('mqtt-prefix').value
};
if ($('mqtt-pass').value) data.password = $('mqtt-pass').value;
const res = await api('mqtt', 'POST', data);
showMsg(res.message || 'MQTT config saved');
} catch (e) {
showMsg('Failed to save MQTT config', true);
}
};
async function reboot() {
if (!confirm('Are you sure you want to reboot the device?')) return;
try {
await api('reboot', 'POST');
showMsg('Rebooting...');
clearInterval(refreshTimer);
} catch (e) {
showMsg('Reboot command sent');
}
}
// Initial load
loadStatus();
loadWiFi();
loadMQTT();
// Auto-refresh status every 5 seconds
refreshTimer = setInterval(loadStatus, 5000);
</script>
</body>
</html>
)rawliteral";
#endif // WITH_MQTT