Websocket Fix, Added Clock NTP Sync, Updated MQTT IP

This commit is contained in:
2025-12-26 09:33:24 +02:00
parent b04590d270
commit 7d9bc42078
6 changed files with 193 additions and 56 deletions

View File

@@ -105,53 +105,83 @@ void WebSocketServer::onConnect(AsyncWebSocketClient* client) {
void WebSocketServer::onDisconnect(AsyncWebSocketClient* client) { void WebSocketServer::onDisconnect(AsyncWebSocketClient* client) {
LOG_INFO("WebSocket client #%u disconnected", client->id()); LOG_INFO("WebSocket client #%u disconnected", client->id());
// Clean up any fragment buffer for this client
_fragmentBuffers.erase(client->id());
_clientManager.removeClient(client->id()); _clientManager.removeClient(client->id());
_clientManager.cleanupDisconnectedClients(); _clientManager.cleanupDisconnectedClients();
} }
void WebSocketServer::onData(AsyncWebSocketClient* client, void* arg, uint8_t* data, size_t len) { void WebSocketServer::onData(AsyncWebSocketClient* client, void* arg, uint8_t* data, size_t len) {
AwsFrameInfo* info = (AwsFrameInfo*)arg; AwsFrameInfo* info = (AwsFrameInfo*)arg;
uint32_t clientId = client->id();
// Only handle complete, single-frame text messages
if (info->final && info->index == 0 && info->len == len && info->opcode == WS_TEXT) { // Use message_opcode to get the actual message type (not the frame opcode)
// Allocate buffer for payload uint8_t messageOpcode = info->message_opcode;
char* payload = (char*)malloc(len + 1);
if (!payload) { // Only handle TEXT and BINARY messages
LOG_ERROR("Failed to allocate memory for WebSocket payload"); if (messageOpcode != WS_TEXT && messageOpcode != WS_BINARY) {
String errorResponse = ResponseBuilder::error("memory_error", "Out of memory"); LOG_DEBUG("WebSocket client #%u: ignoring non-text/binary message (opcode: %u)", clientId, messageOpcode);
_clientManager.sendToClient(client->id(), errorResponse); return;
return; }
// NOTE: As of ESPAsyncWebServer v3.9.3, the library automatically unmasks the data
// before calling this handler. The 'data' pointer contains plain, unmasked bytes.
// Handle fragmented messages - reassemble them
if (info->index == 0) {
// First chunk - create or reset buffer
FragmentBuffer& buffer = _fragmentBuffers[clientId];
buffer.data = "";
if (info->len > 0 && info->len < 65536) {
buffer.data.reserve(info->len);
} }
memcpy(payload, data, len); buffer.opcode = messageOpcode;
payload[len] = '\0'; buffer.lastUpdate = millis();
LOG_DEBUG("WebSocket client #%u sent: %s", client->id(), payload); if (info->len > len) {
LOG_DEBUG("WebSocket client #%u: started fragmented message (%llu bytes total)", clientId, info->len);
}
}
// Get or create buffer for this client
FragmentBuffer& buffer = _fragmentBuffers[clientId];
// Append this chunk to the buffer (data is already unmasked by the library)
buffer.data.concat((const char*)data, len);
buffer.lastUpdate = millis();
// Check if message is complete
if (info->final && buffer.data.length() >= info->len) {
String completeMessage = buffer.data;
uint8_t opcode = buffer.opcode;
_fragmentBuffers.erase(clientId);
LOG_DEBUG("WebSocket client #%u sent (%u bytes): %s",
clientId, completeMessage.length(), completeMessage.c_str());
// Parse JSON // Parse JSON
StaticJsonDocument<2048> doc; StaticJsonDocument<2048> doc;
DeserializationError error = deserializeJson(doc, payload); DeserializationError error = deserializeJson(doc, completeMessage);
if (error) { if (error) {
LOG_ERROR("Failed to parse WebSocket JSON from client #%u: %s", client->id(), error.c_str()); LOG_ERROR("Failed to parse WebSocket JSON from client #%u: %s", clientId, error.c_str());
String errorResponse = ResponseBuilder::error("parse_error", "Invalid JSON"); String errorResponse = ResponseBuilder::error("parse_error", "Invalid JSON");
_clientManager.sendToClient(client->id(), errorResponse); _clientManager.sendToClient(clientId, errorResponse);
} else { } else {
// Update client last seen time // Update client last seen time
_clientManager.updateClientLastSeen(client->id()); _clientManager.updateClientLastSeen(clientId);
// Call user callback if set // Call user callback if set
if (_messageCallback) { if (_messageCallback) {
LOG_DEBUG("Routing message from client #%u to callback handler", client->id()); LOG_DEBUG("Routing message from client #%u to callback handler", clientId);
_messageCallback(client->id(), doc); _messageCallback(clientId, doc);
} else { } else {
LOG_WARNING("WebSocket message received but no callback handler is set!"); LOG_WARNING("WebSocket message received but no callback handler is set!");
} }
} }
free(payload);
} else {
LOG_WARNING("Received fragmented or non-text WebSocket message from client #%u - ignoring", client->id());
} }
} }

View File

@@ -22,6 +22,7 @@
#include <Arduino.h> #include <Arduino.h>
#include <ESPAsyncWebServer.h> #include <ESPAsyncWebServer.h>
#include <ArduinoJson.h> #include <ArduinoJson.h>
#include <map>
#include "../../ClientManager/ClientManager.hpp" #include "../../ClientManager/ClientManager.hpp"
class WebSocketServer { class WebSocketServer {
@@ -77,6 +78,14 @@ private:
ClientManager& _clientManager; ClientManager& _clientManager;
MessageCallback _messageCallback; MessageCallback _messageCallback;
// Fragment reassembly buffer (stores incomplete messages per client)
struct FragmentBuffer {
String data;
uint8_t opcode;
unsigned long lastUpdate;
};
std::map<uint32_t, FragmentBuffer> _fragmentBuffers;
/** /**
* @brief Static WebSocket event handler * @brief Static WebSocket event handler
*/ */

View File

@@ -79,7 +79,13 @@ bool ConfigManager::begin() {
LOG_WARNING("ConfigManager - ⚠️ Creating default network config file"); LOG_WARNING("ConfigManager - ⚠️ Creating default network config file");
saveNetworkConfig(); saveNetworkConfig();
} }
// Load time config, save defaults if not found
if (!loadTimeConfig()) {
LOG_WARNING("ConfigManager - ⚠️ Creating default time config file (GMT+2)");
saveTimeConfig();
}
// Load bell durations, save defaults if not found // Load bell durations, save defaults if not found
if (!loadBellDurations()) { if (!loadBellDurations()) {
LOG_WARNING("ConfigManager - ⚠️ Creating default bell durations file"); LOG_WARNING("ConfigManager - ⚠️ Creating default bell durations file");
@@ -580,48 +586,50 @@ void ConfigManager::updateClockSilence(JsonVariant doc) {
bool ConfigManager::loadClockConfig() { bool ConfigManager::loadClockConfig() {
if (!ensureSDCard()) return false; if (!ensureSDCard()) return false;
File file = SD.open("/settings/clockConfig.json", FILE_READ); File file = SD.open("/settings/clockConfig.json", FILE_READ);
if (!file) { if (!file) {
LOG_WARNING("ConfigManager - ⚠️ Clock config file not found - using defaults"); LOG_WARNING("ConfigManager - ⚠️ Clock config file not found - using defaults");
return false; return false;
} }
StaticJsonDocument<512> doc; StaticJsonDocument<512> doc;
DeserializationError error = deserializeJson(doc, file); DeserializationError error = deserializeJson(doc, file);
file.close(); file.close();
if (error) { if (error) {
LOG_ERROR("ConfigManager - ❌ Failed to parse clock config from SD: %s", error.c_str()); LOG_ERROR("ConfigManager - ❌ Failed to parse clock config from SD: %s", error.c_str());
return false; return false;
} }
if (doc.containsKey("enabled")) clockConfig.enabled = doc["enabled"].as<bool>();
if (doc.containsKey("c1output")) clockConfig.c1output = doc["c1output"].as<uint8_t>(); if (doc.containsKey("c1output")) clockConfig.c1output = doc["c1output"].as<uint8_t>();
if (doc.containsKey("c2output")) clockConfig.c2output = doc["c2output"].as<uint8_t>(); if (doc.containsKey("c2output")) clockConfig.c2output = doc["c2output"].as<uint8_t>();
if (doc.containsKey("pulseDuration")) clockConfig.pulseDuration = doc["pulseDuration"].as<uint16_t>(); if (doc.containsKey("pulseDuration")) clockConfig.pulseDuration = doc["pulseDuration"].as<uint16_t>();
if (doc.containsKey("pauseDuration")) clockConfig.pauseDuration = doc["pauseDuration"].as<uint16_t>(); if (doc.containsKey("pauseDuration")) clockConfig.pauseDuration = doc["pauseDuration"].as<uint16_t>();
LOG_DEBUG("ConfigManager - Clock config loaded"); LOG_DEBUG("ConfigManager - Clock config loaded");
return true; return true;
} }
bool ConfigManager::saveClockConfig() { bool ConfigManager::saveClockConfig() {
if (!ensureSDCard()) return false; if (!ensureSDCard()) return false;
StaticJsonDocument<512> doc; StaticJsonDocument<512> doc;
doc["enabled"] = clockConfig.enabled;
doc["c1output"] = clockConfig.c1output; doc["c1output"] = clockConfig.c1output;
doc["c2output"] = clockConfig.c2output; doc["c2output"] = clockConfig.c2output;
doc["pulseDuration"] = clockConfig.pulseDuration; doc["pulseDuration"] = clockConfig.pulseDuration;
doc["pauseDuration"] = clockConfig.pauseDuration; doc["pauseDuration"] = clockConfig.pauseDuration;
char buffer[512]; char buffer[512];
size_t len = serializeJson(doc, buffer, sizeof(buffer)); size_t len = serializeJson(doc, buffer, sizeof(buffer));
if (len == 0 || len >= sizeof(buffer)) { if (len == 0 || len >= sizeof(buffer)) {
LOG_ERROR("ConfigManager - ❌ Failed to serialize clock config JSON"); LOG_ERROR("ConfigManager - ❌ Failed to serialize clock config JSON");
return false; return false;
} }
saveFileToSD("/settings", "clockConfig.json", buffer); saveFileToSD("/settings", "clockConfig.json", buffer);
LOG_DEBUG("ConfigManager - Clock config saved"); LOG_DEBUG("ConfigManager - Clock config saved");
return true; return true;
@@ -727,8 +735,8 @@ std::vector<String> ConfigManager::getUpdateServers() const {
void ConfigManager::updateTimeConfig(long gmtOffsetSec, int daylightOffsetSec) { void ConfigManager::updateTimeConfig(long gmtOffsetSec, int daylightOffsetSec) {
timeConfig.gmtOffsetSec = gmtOffsetSec; timeConfig.gmtOffsetSec = gmtOffsetSec;
timeConfig.daylightOffsetSec = daylightOffsetSec; timeConfig.daylightOffsetSec = daylightOffsetSec;
saveToSD(); saveTimeConfig(); // Save time config specifically
LOG_DEBUG("ConfigManager - TimeConfig updated - GMT offset %ld sec, DST offset %d sec", LOG_DEBUG("ConfigManager - TimeConfig updated - GMT offset %ld sec, DST offset %d sec",
gmtOffsetSec, daylightOffsetSec); gmtOffsetSec, daylightOffsetSec);
} }
@@ -826,12 +834,12 @@ bool ConfigManager::loadNetworkConfig() {
bool ConfigManager::saveNetworkConfig() { bool ConfigManager::saveNetworkConfig() {
if (!ensureSDCard()) return false; if (!ensureSDCard()) return false;
StaticJsonDocument<512> doc; StaticJsonDocument<512> doc;
// Save hostname (user can customize) // Save hostname (user can customize)
doc["hostname"] = networkConfig.hostname; doc["hostname"] = networkConfig.hostname;
// Save static IP configuration // Save static IP configuration
doc["useStaticIP"] = networkConfig.useStaticIP; doc["useStaticIP"] = networkConfig.useStaticIP;
doc["ip"] = networkConfig.ip.toString(); doc["ip"] = networkConfig.ip.toString();
@@ -839,20 +847,93 @@ bool ConfigManager::saveNetworkConfig() {
doc["subnet"] = networkConfig.subnet.toString(); doc["subnet"] = networkConfig.subnet.toString();
doc["dns1"] = networkConfig.dns1.toString(); doc["dns1"] = networkConfig.dns1.toString();
doc["dns2"] = networkConfig.dns2.toString(); doc["dns2"] = networkConfig.dns2.toString();
char buffer[512]; char buffer[512];
size_t len = serializeJson(doc, buffer, sizeof(buffer)); size_t len = serializeJson(doc, buffer, sizeof(buffer));
if (len == 0 || len >= sizeof(buffer)) { if (len == 0 || len >= sizeof(buffer)) {
LOG_ERROR("ConfigManager - ❌ Failed to serialize network config JSON"); LOG_ERROR("ConfigManager - ❌ Failed to serialize network config JSON");
return false; return false;
} }
saveFileToSD("/settings", "networkConfig.json", buffer); saveFileToSD("/settings", "networkConfig.json", buffer);
LOG_DEBUG("ConfigManager - Network config saved to SD"); LOG_DEBUG("ConfigManager - Network config saved to SD");
return true; return true;
} }
// ════════════════════════════════════════════════════════════════════════════
// TIME CONFIGURATION PERSISTENCE
// ════════════════════════════════════════════════════════════════════════════
bool ConfigManager::loadTimeConfig() {
if (!ensureSDCard()) return false;
File file = SD.open("/settings/timeConfig.json", FILE_READ);
if (!file) {
LOG_DEBUG("ConfigManager - Time config file not found - using defaults (GMT+2)");
return false;
}
StaticJsonDocument<256> doc;
DeserializationError error = deserializeJson(doc, file);
file.close();
if (error) {
LOG_ERROR("ConfigManager - ❌ Failed to parse time config from SD: %s", error.c_str());
return false;
}
// Load NTP server if present
if (doc.containsKey("ntpServer")) {
String ntpServer = doc["ntpServer"].as<String>();
if (!ntpServer.isEmpty()) {
timeConfig.ntpServer = ntpServer;
}
}
// Load GMT offset
if (doc.containsKey("gmtOffsetSec")) {
timeConfig.gmtOffsetSec = doc["gmtOffsetSec"].as<long>();
}
// Load daylight saving offset
if (doc.containsKey("daylightOffsetSec")) {
timeConfig.daylightOffsetSec = doc["daylightOffsetSec"].as<int>();
}
LOG_DEBUG("ConfigManager - Time config loaded - NTP: %s, GMT offset: %ld, DST offset: %d",
timeConfig.ntpServer.c_str(),
timeConfig.gmtOffsetSec,
timeConfig.daylightOffsetSec);
return true;
}
bool ConfigManager::saveTimeConfig() {
if (!ensureSDCard()) return false;
StaticJsonDocument<256> doc;
// Save NTP server
doc["ntpServer"] = timeConfig.ntpServer;
// Save timezone offsets
doc["gmtOffsetSec"] = timeConfig.gmtOffsetSec;
doc["daylightOffsetSec"] = timeConfig.daylightOffsetSec;
char buffer[256];
size_t len = serializeJson(doc, buffer, sizeof(buffer));
if (len == 0 || len >= sizeof(buffer)) {
LOG_ERROR("ConfigManager - ❌ Failed to serialize time config JSON");
return false;
}
saveFileToSD("/settings", "timeConfig.json", buffer);
LOG_DEBUG("ConfigManager - Time config saved to SD");
return true;
}
// ═══════════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════════
// SETTINGS RESET IMPLEMENTATION // SETTINGS RESET IMPLEMENTATION
// ═══════════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════════
@@ -878,6 +959,7 @@ bool ConfigManager::resetAllToDefaults() {
const char* settingsFiles[] = { const char* settingsFiles[] = {
"/settings/deviceConfig.json", "/settings/deviceConfig.json",
"/settings/networkConfig.json", "/settings/networkConfig.json",
"/settings/timeConfig.json",
"/settings/relayTimings.json", "/settings/relayTimings.json",
"/settings/bellOutputs.json", "/settings/bellOutputs.json",
"/settings/clockConfig.json", "/settings/clockConfig.json",

View File

@@ -79,7 +79,7 @@ public:
* Username defaults to deviceUID for unique identification. * Username defaults to deviceUID for unique identification.
*/ */
struct MqttConfig { struct MqttConfig {
IPAddress host = IPAddress(145, 223, 96, 251); // 📡 Local Mosquitto broker IPAddress host = IPAddress(72,61,191,197); // 📡 Local Mosquitto broker
int port = 1883; // 🔌 Standard MQTT port (non-SSL) int port = 1883; // 🔌 Standard MQTT port (non-SSL)
String user; // 👤 Auto-set to deviceUID String user; // 👤 Auto-set to deviceUID
String password = "vesper"; // 🔑 Default password String password = "vesper"; // 🔑 Default password
@@ -119,7 +119,7 @@ public:
*/ */
struct TimeConfig { struct TimeConfig {
String ntpServer = "pool.ntp.org"; // ⏰ Universal NTP - OK as is String ntpServer = "pool.ntp.org"; // ⏰ Universal NTP - OK as is
long gmtOffsetSec = 0; // 🌍 Default UTC, app-configurable via SD long gmtOffsetSec = 7200; // 🌍 Default GMT+2 (Greek Time), app-configurable via SD
int daylightOffsetSec = 0; // ☀️ Default no DST, app-configurable via SD int daylightOffsetSec = 0; // ☀️ Default no DST, app-configurable via SD
}; };
@@ -211,7 +211,7 @@ public:
* All clock settings are loaded from SD card at startup. * All clock settings are loaded from SD card at startup.
*/ */
struct GeneralConfig { struct GeneralConfig {
uint8_t serialLogLevel = 0; uint8_t serialLogLevel = 5;
uint8_t sdLogLevel = 0; uint8_t sdLogLevel = 0;
}; };
@@ -313,12 +313,16 @@ public:
// Configuration update methods for app commands // Configuration update methods for app commands
void updateTimeConfig(long gmtOffsetSec, int daylightOffsetSec); void updateTimeConfig(long gmtOffsetSec, int daylightOffsetSec);
void updateNetworkConfig(const String& hostname, bool useStaticIP, IPAddress ip, IPAddress gateway, void updateNetworkConfig(const String& hostname, bool useStaticIP, IPAddress ip, IPAddress gateway,
IPAddress subnet, IPAddress dns1, IPAddress dns2); IPAddress subnet, IPAddress dns1, IPAddress dns2);
// Network configuration persistence // Network configuration persistence
bool loadNetworkConfig(); bool loadNetworkConfig();
bool saveNetworkConfig(); bool saveNetworkConfig();
// Time configuration persistence
bool loadTimeConfig();
bool saveTimeConfig();
// Bell and clock configuration methods (unchanged) // Bell and clock configuration methods (unchanged)
bool loadBellDurations(); bool loadBellDurations();

View File

@@ -39,13 +39,18 @@ bool FileManager::ensureDirectoryExists(const String& dirPath) {
if (!initializeSD()) { if (!initializeSD()) {
return false; return false;
} }
// Ensure the directory ends with '/' // Ensure the directory ends with '/'
String normalizedPath = dirPath; String normalizedPath = dirPath;
if (!normalizedPath.endsWith("/")) { if (!normalizedPath.endsWith("/")) {
normalizedPath += "/"; normalizedPath += "/";
} }
// Check if directory already exists
if (SD.exists(normalizedPath.c_str())) {
return true; // Directory already exists, success
}
// Create directory if it doesn't exist // Create directory if it doesn't exist
return SD.mkdir(normalizedPath.c_str()); return SD.mkdir(normalizedPath.c_str());
} }

View File

@@ -342,8 +342,11 @@ void setup()
// Set up network callbacks // Set up network callbacks
networking.setNetworkCallbacks( networking.setNetworkCallbacks(
[]() { []() {
communication.onNetworkConnected(); communication.onNetworkConnected();
// Sync time with NTP server when network becomes available
LOG_INFO("⏰ Syncing time with NTP server...");
timekeeper.syncTimeWithNTP();
// Start AsyncWebServer when network becomes available // Start AsyncWebServer when network becomes available
if (networking.getState() != NetworkState::WIFI_PORTAL_MODE) { if (networking.getState() != NetworkState::WIFI_PORTAL_MODE) {
LOG_INFO("🚀 Starting AsyncWebServer on port 80..."); LOG_INFO("🚀 Starting AsyncWebServer on port 80...");
@@ -358,7 +361,11 @@ void setup()
if (networking.isConnected()) { if (networking.isConnected()) {
LOG_INFO("Network already connected - triggering MQTT connection"); LOG_INFO("Network already connected - triggering MQTT connection");
communication.onNetworkConnected(); communication.onNetworkConnected();
// Sync time with NTP server if network is already connected
LOG_INFO("⏰ Syncing time with NTP server...");
timekeeper.syncTimeWithNTP();
// 🔥 CRITICAL: Start AsyncWebServer ONLY when network is ready // 🔥 CRITICAL: Start AsyncWebServer ONLY when network is ready
// Do NOT start if WiFiManager portal is active (port 80 conflict!) // Do NOT start if WiFiManager portal is active (port 80 conflict!)
LOG_INFO("🚀 Starting AsyncWebServer on port 80..."); LOG_INFO("🚀 Starting AsyncWebServer on port 80...");