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:
parent
d3a128e12f
commit
04667f5161
@ -114,3 +114,31 @@ build_flags =
|
||||
|
||||
upload_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
402
src/MQTTBridge.cpp
Normal 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
122
src/MQTTBridge.h
Normal 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
|
||||
162
src/MyMesh.cpp
162
src/MyMesh.cpp
@ -345,6 +345,12 @@ void MyMesh::logRx(mesh::Packet *pkt, int len, float score) {
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef WITH_MQTT
|
||||
if (_mqtt_bridge && _mqtt_bridge->isConnected()) {
|
||||
_mqtt_bridge->publishPacketRx(pkt, len, _radio->getLastSNR(), _radio->getLastRSSI());
|
||||
}
|
||||
#endif
|
||||
|
||||
if (_logging) {
|
||||
File f = openAppend(PACKET_LOG_FILE);
|
||||
if (f) {
|
||||
@ -371,6 +377,12 @@ void MyMesh::logTx(mesh::Packet *pkt, int len) {
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef WITH_MQTT
|
||||
if (_mqtt_bridge && _mqtt_bridge->isConnected()) {
|
||||
_mqtt_bridge->publishPacketTx(pkt, len);
|
||||
}
|
||||
#endif
|
||||
|
||||
if (_logging) {
|
||||
File f = openAppend(PACKET_LOG_FILE);
|
||||
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));
|
||||
|
||||
// Owner info default (empty)
|
||||
_prefs.owner_info[0] = 0;
|
||||
|
||||
// GPS defaults
|
||||
_prefs.gps_enabled = 0;
|
||||
_prefs.gps_interval = 0;
|
||||
@ -733,6 +748,11 @@ void MyMesh::begin(FILESYSTEM *fs) {
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef WITH_MQTT
|
||||
// Initialize MQTT gateway
|
||||
initMQTT();
|
||||
#endif
|
||||
|
||||
radio_set_params(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr);
|
||||
radio_set_tx_power(_prefs.tx_power_dbm);
|
||||
|
||||
@ -1071,6 +1091,41 @@ void MyMesh::loop() {
|
||||
bridge.loop();
|
||||
#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();
|
||||
|
||||
if (next_flood_advert && millisHasNowPassed(next_flood_advert)) {
|
||||
@ -1109,3 +1164,110 @@ void MyMesh::loop() {
|
||||
uptime_millis += now - last_millis;
|
||||
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
|
||||
|
||||
27
src/MyMesh.h
27
src/MyMesh.h
@ -23,6 +23,12 @@
|
||||
#define WITH_BRIDGE
|
||||
#endif
|
||||
|
||||
#ifdef WITH_MQTT
|
||||
#include "WiFiManager.h"
|
||||
#include "MQTTBridge.h"
|
||||
#include "WebConfig.h"
|
||||
#endif
|
||||
|
||||
#include <helpers/AdvertDataHelpers.h>
|
||||
#include <helpers/ArduinoHelpers.h>
|
||||
#include <helpers/ClientACL.h>
|
||||
@ -112,6 +118,17 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks {
|
||||
ESPNowBridge bridge;
|
||||
#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);
|
||||
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);
|
||||
@ -206,6 +223,16 @@ public:
|
||||
void handleCommand(uint32_t sender_timestamp, char* command, char* reply);
|
||||
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)
|
||||
void setBridgeState(bool enable) override {
|
||||
if (enable == bridge.isRunning()) return;
|
||||
|
||||
284
src/WebConfig.cpp
Normal file
284
src/WebConfig.cpp
Normal 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
86
src/WebConfig.h
Normal 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
222
src/WiFiManager.cpp
Normal 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
80
src/WiFiManager.h
Normal 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
369
src/web_ui.h
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user