diff --git a/platformio.ini b/platformio.ini index 2746f79..f67cfac 100644 --- a/platformio.ini +++ b/platformio.ini @@ -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"' diff --git a/src/MQTTBridge.cpp b/src/MQTTBridge.cpp new file mode 100644 index 0000000..bd4b3e2 --- /dev/null +++ b/src/MQTTBridge.cpp @@ -0,0 +1,402 @@ +#ifdef WITH_MQTT + +#include "MQTTBridge.h" +#include +#include + +// 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(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(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(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(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 diff --git a/src/MQTTBridge.h b/src/MQTTBridge.h new file mode 100644 index 0000000..874a5b6 --- /dev/null +++ b/src/MQTTBridge.h @@ -0,0 +1,122 @@ +#pragma once + +#ifdef WITH_MQTT + +#include +#include +#include +#include +#include +#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 diff --git a/src/MyMesh.cpp b/src/MyMesh.cpp index 5fb1a72..ecdb953 100644 --- a/src/MyMesh.cpp +++ b/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 diff --git a/src/MyMesh.h b/src/MyMesh.h index ed9f0c5..1ce9c09 100644 --- a/src/MyMesh.h +++ b/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 #include #include @@ -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; diff --git a/src/WebConfig.cpp b/src/WebConfig.cpp new file mode 100644 index 0000000..42dfe40 --- /dev/null +++ b/src/WebConfig.cpp @@ -0,0 +1,284 @@ +#ifdef WITH_MQTT + +#include "WebConfig.h" +#include "web_ui.h" +#include + +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()) { + strncpy(_wifi_config->ssid, doc["ssid"].as(), sizeof(_wifi_config->ssid) - 1); + } + if (doc["password"].is()) { + strncpy(_wifi_config->password, doc["password"].as(), 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()) { + _mqtt_config->enabled = doc["enabled"].as() ? 1 : 0; + } + if (doc["broker"].is()) { + strncpy(_mqtt_config->broker, doc["broker"].as(), sizeof(_mqtt_config->broker) - 1); + } + if (doc["port"].is()) { + _mqtt_config->port = doc["port"].as(); + } + if (doc["user"].is()) { + strncpy(_mqtt_config->user, doc["user"].as(), sizeof(_mqtt_config->user) - 1); + } + if (doc["password"].is()) { + strncpy(_mqtt_config->password, doc["password"].as(), sizeof(_mqtt_config->password) - 1); + } + if (doc["topic_prefix"].is()) { + strncpy(_mqtt_config->topic_prefix, doc["topic_prefix"].as(), 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(); + 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(); + 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(); + 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 diff --git a/src/WebConfig.h b/src/WebConfig.h new file mode 100644 index 0000000..d1c428b --- /dev/null +++ b/src/WebConfig.h @@ -0,0 +1,86 @@ +#pragma once + +#ifdef WITH_MQTT + +#include +#include +#include +#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 diff --git a/src/WiFiManager.cpp b/src/WiFiManager.cpp new file mode 100644 index 0000000..6ade395 --- /dev/null +++ b/src/WiFiManager.cpp @@ -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 diff --git a/src/WiFiManager.h b/src/WiFiManager.h new file mode 100644 index 0000000..dfee0f7 --- /dev/null +++ b/src/WiFiManager.h @@ -0,0 +1,80 @@ +#pragma once + +#ifdef WITH_MQTT + +#include +#include + +// 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 diff --git a/src/web_ui.h b/src/web_ui.h new file mode 100644 index 0000000..fb5f797 --- /dev/null +++ b/src/web_ui.h @@ -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( + + + + + + MeshCore Gateway + + + +
+

MeshCore Gateway

+ +
+ +
+

Status

+
+
+ Node + - +
+
+ Node ID + - +
+
+ WiFi + - +
+
+ MQTT + - +
+
+ IP Address + - +
+
+ WiFi RSSI + - +
+
+ Packets RX/TX + - +
+
+ Free Heap + - +
+
+
+ +
+

WiFi Configuration

+
+ + +
+ + +
+
+
+ +
+

MQTT Configuration

+
+ + + + + + +
+ + +
+
+
+ +
+

System

+
+ +
+
+
+ + + + +)rawliteral"; + +#endif // WITH_MQTT