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
|
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
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
|
#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
|
||||||
|
|||||||
27
src/MyMesh.h
27
src/MyMesh.h
@ -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
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