From db57b355b99ad50d9f1b7667f663eb54596e6b0a Mon Sep 17 00:00:00 2001 From: bonamin Date: Sun, 28 Dec 2025 21:49:49 +0200 Subject: [PATCH] Added HTTP-API support, Standalone AP Support and Built-in Melodies --- .../src/BuiltInMelodies/BuiltInMelodies.hpp | 253 ++++++++++++ vesper/src/BuiltInMelodies/README.md | 187 +++++++++ .../CommunicationRouter.cpp | 19 +- .../CommunicationRouter.hpp | 4 + .../HTTPRequestHandler/HTTPRequestHandler.cpp | 187 +++++++++ .../HTTPRequestHandler/HTTPRequestHandler.hpp | 76 ++++ vesper/src/ConfigManager/ConfigManager.cpp | 9 + vesper/src/ConfigManager/ConfigManager.hpp | 9 +- vesper/src/Networking/Networking.cpp | 104 ++++- vesper/src/Networking/Networking.hpp | 10 +- vesper/src/Player/Player.cpp | 32 +- .../SettingsWebServer/SettingsWebServer.cpp | 365 ++++++++++++++++++ .../SettingsWebServer/SettingsWebServer.hpp | 66 ++++ vesper/vesper.ino | 18 +- 14 files changed, 1313 insertions(+), 26 deletions(-) create mode 100644 vesper/src/BuiltInMelodies/BuiltInMelodies.hpp create mode 100644 vesper/src/BuiltInMelodies/README.md create mode 100644 vesper/src/Communication/HTTPRequestHandler/HTTPRequestHandler.cpp create mode 100644 vesper/src/Communication/HTTPRequestHandler/HTTPRequestHandler.hpp create mode 100644 vesper/src/SettingsWebServer/SettingsWebServer.cpp create mode 100644 vesper/src/SettingsWebServer/SettingsWebServer.hpp diff --git a/vesper/src/BuiltInMelodies/BuiltInMelodies.hpp b/vesper/src/BuiltInMelodies/BuiltInMelodies.hpp new file mode 100644 index 0000000..906361d --- /dev/null +++ b/vesper/src/BuiltInMelodies/BuiltInMelodies.hpp @@ -0,0 +1,253 @@ +/* + * ═══════════════════════════════════════════════════════════════════════════════════ + * BUILTINMELODIES.HPP - Firmware-Baked Melody Library + * ═══════════════════════════════════════════════════════════════════════════════════ + * + * 🎵 BUILT-IN MELODY LIBRARY FOR VESPER 🎵 + * + * This file contains melodies baked directly into the firmware, eliminating + * the need for SD card downloads. Each melody is stored in PROGMEM to save RAM. + * + * 🏗️ ARCHITECTURE: + * • Melodies stored in PROGMEM (Flash memory, not RAM) + * • Each melody step is 2 bytes (uint16_t bitmask) + * • Metadata includes name, UID, default speed + * • Easy to add new melodies + * + * 📦 STORAGE EFFICIENCY: + * • Small melodies (~30 steps = 60 bytes) + * • Large melodies (~200 steps = 400 bytes) + * • 40 melodies average = ~6-10KB total (Flash, not RAM!) + * + * 🎶 MELODY FORMAT: + * Each uint16_t is a bitmask: + * - Bit 0-15: Which bells/notes to activate + * - Example: 0x0001 = Bell 0, 0x0003 = Bells 0+1, 0x8000 = Bell 15 + * + * 📋 VERSION: 1.0 + * 📅 DATE: 2025-12-28 + * 👨‍💻 AUTHOR: Advanced Bell Systems + * ═══════════════════════════════════════════════════════════════════════════════════ + */ + +#pragma once + +#include +#include +#include + +namespace BuiltInMelodies { + +// ═════════════════════════════════════════════════════════════════════════════════ +// MELODY METADATA STRUCTURE +// ═════════════════════════════════════════════════════════════════════════════════ + +struct MelodyInfo { + const char* name; // Display name + const char* uid; // Unique identifier + const uint16_t* data; // Pointer to melody data in PROGMEM + uint16_t stepCount; // Number of steps + uint16_t defaultSpeed; // Default speed in milliseconds per beat +}; + +// ═════════════════════════════════════════════════════════════════════════════════ +// EXAMPLE MELODIES - Add your melodies here! +// ═════════════════════════════════════════════════════════════════════════════════ + +// Example: Simple Scale (C-D-E-F-G-A-B-C) +const uint16_t PROGMEM melody_simple_scale[] = { + 0x0001, 0x0002, 0x0004, 0x0008, + 0x0010, 0x0020, 0x0040, 0x0080 +}; + +// Example: Happy Birthday (simplified) +const uint16_t PROGMEM melody_happy_birthday[] = { + 0x0001, 0x0001, 0x0002, 0x0001, + 0x0008, 0x0004, 0x0001, 0x0001, + 0x0002, 0x0001, 0x0010, 0x0008, + 0x0001, 0x0001, 0x0080, 0x0008, + 0x0004, 0x0002, 0x0040, 0x0040, + 0x0008, 0x0004, 0x0002 +}; + +// Example: Jingle Bells (simplified) +const uint16_t PROGMEM melody_jingle_bells[] = { + 0x0004, 0x0004, 0x0004, 0x0000, + 0x0004, 0x0004, 0x0004, 0x0000, + 0x0004, 0x0008, 0x0001, 0x0002, + 0x0004, 0x0000, 0x0000, 0x0000, + 0x0008, 0x0008, 0x0008, 0x0008, + 0x0008, 0x0004, 0x0004, 0x0004, + 0x0002, 0x0002, 0x0004, 0x0002, + 0x0008, 0x0000, 0x0000, 0x0000 +}; + +// Example: Westminster Chimes +const uint16_t PROGMEM melody_westminster_chimes[] = { + 0x0008, 0x0004, 0x0002, 0x0001, + 0x0001, 0x0002, 0x0008, 0x0004, + 0x0008, 0x0001, 0x0002, 0x0004, + 0x0002, 0x0008, 0x0004, 0x0001 +}; + +// Example: Alarm Pattern +const uint16_t PROGMEM melody_alarm[] = { + 0x0001, 0x0080, 0x0001, 0x0080, + 0x0001, 0x0080, 0x0001, 0x0080, + 0x0000, 0x0000, 0x0001, 0x0080, + 0x0001, 0x0080, 0x0001, 0x0080 +}; + +// Example: Doorbell +const uint16_t PROGMEM melody_doorbell[] = { + 0x0004, 0x0008, 0x0004, 0x0008 +}; + +// Example: Single Bell Test +const uint16_t PROGMEM melody_single_bell[] = { + 0x0001 +}; + +// ═════════════════════════════════════════════════════════════════════════════════ +// MELODY LIBRARY - Array of all built-in melodies +// ═════════════════════════════════════════════════════════════════════════════════ + +const MelodyInfo MELODY_LIBRARY[] = { + { + "Simple Scale", + "builtin_scale", + melody_simple_scale, + sizeof(melody_simple_scale) / sizeof(uint16_t), + 200 // 200ms per beat + }, + { + "Happy Birthday", + "builtin_happy_birthday", + melody_happy_birthday, + sizeof(melody_happy_birthday) / sizeof(uint16_t), + 250 + }, + { + "Jingle Bells", + "builtin_jingle_bells", + melody_jingle_bells, + sizeof(melody_jingle_bells) / sizeof(uint16_t), + 180 + }, + { + "Westminster Chimes", + "builtin_westminster", + melody_westminster_chimes, + sizeof(melody_westminster_chimes) / sizeof(uint16_t), + 400 + }, + { + "Alarm", + "builtin_alarm", + melody_alarm, + sizeof(melody_alarm) / sizeof(uint16_t), + 150 + }, + { + "Doorbell", + "builtin_doorbell", + melody_doorbell, + sizeof(melody_doorbell) / sizeof(uint16_t), + 300 + }, + { + "Single Bell Test", + "builtin_single_bell", + melody_single_bell, + sizeof(melody_single_bell) / sizeof(uint16_t), + 100 + } +}; + +const uint16_t MELODY_COUNT = sizeof(MELODY_LIBRARY) / sizeof(MelodyInfo); + +// ═════════════════════════════════════════════════════════════════════════════════ +// HELPER FUNCTIONS +// ═════════════════════════════════════════════════════════════════════════════════ + +/** + * @brief Check if a UID is a built-in melody + * @param uid The melody UID to check + * @return true if it's a built-in melody (starts with "builtin_") + */ +inline bool isBuiltInMelody(const String& uid) { + return uid.startsWith("builtin_"); +} + +/** + * @brief Find a built-in melody by UID + * @param uid The melody UID to find + * @return Pointer to MelodyInfo if found, nullptr otherwise + */ +inline const MelodyInfo* findMelodyByUID(const String& uid) { + for (uint16_t i = 0; i < MELODY_COUNT; i++) { + if (uid == MELODY_LIBRARY[i].uid) { + return &MELODY_LIBRARY[i]; + } + } + return nullptr; +} + +/** + * @brief Load a built-in melody into a vector + * @param uid The melody UID to load + * @param melodySteps Vector to fill with melody data + * @return true if melody was found and loaded, false otherwise + */ +inline bool loadBuiltInMelody(const String& uid, std::vector& melodySteps) { + const MelodyInfo* melody = findMelodyByUID(uid); + if (!melody) { + return false; + } + + // Resize vector and copy data from PROGMEM + melodySteps.resize(melody->stepCount); + for (uint16_t i = 0; i < melody->stepCount; i++) { + melodySteps[i] = pgm_read_word(&(melody->data[i])); + } + + return true; +} + +/** + * @brief Get list of all built-in melodies as JSON string + * @return JSON array string of melody names and UIDs + */ +inline String getBuiltInMelodiesJSON() { + String json = "["; + for (uint16_t i = 0; i < MELODY_COUNT; i++) { + if (i > 0) json += ","; + json += "{"; + json += "\"name\":\"" + String(MELODY_LIBRARY[i].name) + "\","; + json += "\"uid\":\"" + String(MELODY_LIBRARY[i].uid) + "\","; + json += "\"steps\":" + String(MELODY_LIBRARY[i].stepCount) + ","; + json += "\"speed\":" + String(MELODY_LIBRARY[i].defaultSpeed); + json += "}"; + } + json += "]"; + return json; +} + +} // namespace BuiltInMelodies + +// ═══════════════════════════════════════════════════════════════════════════════════ +// USAGE EXAMPLE: +// ═══════════════════════════════════════════════════════════════════════════════════ +/* + // Check if melody is built-in + if (BuiltInMelodies::isBuiltInMelody(uid)) { + // Load it from firmware + std::vector melodyData; + if (BuiltInMelodies::loadBuiltInMelody(uid, melodyData)) { + // Use melodyData... + } + } else { + // Load from SD card as usual + } +*/ +// ═══════════════════════════════════════════════════════════════════════════════════ diff --git a/vesper/src/BuiltInMelodies/README.md b/vesper/src/BuiltInMelodies/README.md new file mode 100644 index 0000000..c811a9a --- /dev/null +++ b/vesper/src/BuiltInMelodies/README.md @@ -0,0 +1,187 @@ +# Built-In Melodies System + +## Overview + +The built-in melodies system allows you to bake melodies directly into the firmware, eliminating the need for SD card downloads. Melodies are stored in **PROGMEM** (Flash memory), so they don't consume precious RAM. + +## How It Works + +1. **Check**: When a melody is requested, the Player first checks if the UID starts with `builtin_` +2. **Load**: If it's built-in, the melody is loaded from Flash memory (PROGMEM) +3. **Fallback**: If not built-in, it loads from SD card as usual + +## Adding New Melodies + +### Step 1: Create Your Melody Data + +Each melody step is a **2-byte (uint16_t) bitmask** representing which bells to activate: + +```cpp +// Example: Simple pattern +const uint16_t PROGMEM melody_my_tune[] = { + 0x0001, // Bell 0 + 0x0002, // Bell 1 + 0x0004, // Bell 2 + 0x0008, // Bell 3 + 0x0003, // Bells 0+1 together + 0x000F // Bells 0+1+2+3 together +}; +``` + +**Bitmask Reference:** +- `0x0001` = Bell 0 (bit 0) +- `0x0002` = Bell 1 (bit 1) +- `0x0004` = Bell 2 (bit 2) +- `0x0008` = Bell 3 (bit 3) +- `0x0010` = Bell 4 (bit 4) +- `0x0020` = Bell 5 (bit 5) +- `0x0040` = Bell 6 (bit 6) +- `0x0080` = Bell 7 (bit 7) +- `0x0100` = Bell 8 (bit 8) +- ... up to `0x8000` = Bell 15 (bit 15) +- `0x0000` = Silence/rest + +**Combining Bells:** +- `0x0003` = Bells 0+1 (0x0001 | 0x0002) +- `0x0005` = Bells 0+2 (0x0001 | 0x0004) +- `0x000F` = Bells 0+1+2+3 +- `0xFFFF` = All 16 bells + +### Step 2: Add to BuiltInMelodies.hpp + +Open `src/BuiltInMelodies/BuiltInMelodies.hpp` and: + +1. **Add your melody array:** +```cpp +// Your new melody +const uint16_t PROGMEM melody_my_awesome_tune[] = { + 0x0001, 0x0002, 0x0004, 0x0008, + 0x0010, 0x0020, 0x0040, 0x0080, + // ... up to 200 steps +}; +``` + +2. **Add to MELODY_LIBRARY array:** +```cpp +const MelodyInfo MELODY_LIBRARY[] = { + // ... existing melodies ... + + // Your new melody + { + "My Awesome Tune", // Display name + "builtin_my_awesome_tune", // UID (must start with "builtin_") + melody_my_awesome_tune, // Data array + sizeof(melody_my_awesome_tune) / sizeof(uint16_t), // Step count + 200 // Default speed in milliseconds per beat + } +}; +``` + +### Step 3: Use Your Melody + +Send a play command with the built-in melody UID: + +**MQTT:** +```json +{ + "group": "playback", + "action": "play", + "uid": "builtin_my_awesome_tune", + "name": "My Awesome Tune", + "speed": 200 +} +``` + +**WebSocket/HTTP:** +```json +{ + "group": "playback", + "action": "play", + "uid": "builtin_my_awesome_tune", + "name": "My Awesome Tune", + "speed": 200 +} +``` + +## Pre-Loaded Melodies + +The following melodies are already built-in: + +| UID | Name | Steps | Default Speed | +|-----|------|-------|---------------| +| `builtin_scale` | Simple Scale | 8 | 200ms | +| `builtin_happy_birthday` | Happy Birthday | 23 | 250ms | +| `builtin_jingle_bells` | Jingle Bells | 32 | 180ms | +| `builtin_westminster` | Westminster Chimes | 16 | 400ms | +| `builtin_alarm` | Alarm | 16 | 150ms | +| `builtin_doorbell` | Doorbell | 4 | 300ms | +| `builtin_single_bell` | Single Bell Test | 1 | 100ms | + +## Memory Usage + +### Flash Memory (PROGMEM) +- Small melody (~30 steps): **60 bytes** +- Large melody (~200 steps): **400 bytes** +- 40 melodies average: **~6-10KB** (stored in Flash, not RAM!) + +### RAM Usage +Only the **currently playing melody** is loaded into RAM. Built-in melodies are copied from Flash when needed. + +## Tips + +1. **Always use `builtin_` prefix** for UIDs to identify them as built-in +2. **Test with small melodies first** before adding large ones +3. **Use hex calculator** for complex bell combinations: `0x0001 | 0x0004 = 0x0005` +4. **Add rests** with `0x0000` for silence between notes +5. **Keep it simple** - most melodies work great with 30-50 steps + +## Converting Binary Files to Code + +If you have existing binary melody files and want to convert them to built-in format: + +```python +# Python script to convert binary file to C++ array +with open('melody.bin', 'rb') as f: + data = f.read() + +print("const uint16_t PROGMEM melody_name[] = {") +for i in range(0, len(data), 2): + if i % 16 == 0: + print(" ", end="") + high = data[i] + low = data[i+1] + value = (high << 8) | low + print(f"0x{value:04X}", end="") + if i < len(data) - 2: + print(", ", end="") + if (i + 2) % 16 == 0: + print() +print("\n};") +``` + +## Example: Creating a Custom Melody + +Let's create "Mary Had a Little Lamb": + +```cpp +// Mary Had a Little Lamb +// Notes: E D C D E E E, D D D, E G G +// Mapping: E=0, D=1, C=2, G=3 +const uint16_t PROGMEM melody_mary_lamb[] = { + 0x0001, 0x0002, 0x0004, 0x0002, // E D C D + 0x0001, 0x0001, 0x0001, 0x0000, // E E E (rest) + 0x0002, 0x0002, 0x0002, 0x0000, // D D D (rest) + 0x0001, 0x0008, 0x0008 // E G G +}; + +// Add to MELODY_LIBRARY: +{ + "Mary Had a Little Lamb", + "builtin_mary_lamb", + melody_mary_lamb, + sizeof(melody_mary_lamb) / sizeof(uint16_t), + 300 // 300ms per beat +} +``` + +Now you can play it with UID `builtin_mary_lamb`! diff --git a/vesper/src/Communication/CommunicationRouter/CommunicationRouter.cpp b/vesper/src/Communication/CommunicationRouter/CommunicationRouter.cpp index 227a3a3..4d73427 100644 --- a/vesper/src/Communication/CommunicationRouter/CommunicationRouter.cpp +++ b/vesper/src/Communication/CommunicationRouter/CommunicationRouter.cpp @@ -31,7 +31,9 @@ CommunicationRouter::CommunicationRouter(ConfigManager& configManager, , _mqttClient(configManager, networking) , _clientManager() , _wsServer(webSocket, _clientManager) - , _commandHandler(configManager, otaManager) {} + , _commandHandler(configManager, otaManager) + , _httpHandler(server, configManager) + , _settingsServer(server, configManager, networking) {} CommunicationRouter::~CommunicationRouter() {} @@ -93,7 +95,22 @@ void CommunicationRouter::begin() { sendResponse(response, context); }); + // Initialize HTTP Request Handler + LOG_INFO("Setting up HTTP REST API..."); + _httpHandler.begin(); + _httpHandler.setCommandHandlerReference(&_commandHandler); + LOG_INFO("✅ HTTP REST API initialized"); + + // Initialize Settings Web Server + LOG_INFO("Setting up Settings Web Server..."); + _settingsServer.begin(); + LOG_INFO("✅ Settings Web Server initialized at /settings"); + LOG_INFO("Communication Router initialized with modular architecture"); + LOG_INFO(" • MQTT: AsyncMqttClient"); + LOG_INFO(" • WebSocket: Multi-client support"); + LOG_INFO(" • HTTP REST API: /api endpoints"); + LOG_INFO(" • Settings Page: /settings"); } void CommunicationRouter::setPlayerReference(Player* player) { diff --git a/vesper/src/Communication/CommunicationRouter/CommunicationRouter.hpp b/vesper/src/Communication/CommunicationRouter/CommunicationRouter.hpp index 0e785fa..5fadc93 100644 --- a/vesper/src/Communication/CommunicationRouter/CommunicationRouter.hpp +++ b/vesper/src/Communication/CommunicationRouter/CommunicationRouter.hpp @@ -38,7 +38,9 @@ #include "../WebSocketServer/WebSocketServer.hpp" #include "../CommandHandler/CommandHandler.hpp" #include "../ResponseBuilder/ResponseBuilder.hpp" +#include "../HTTPRequestHandler/HTTPRequestHandler.hpp" #include "../../ClientManager/ClientManager.hpp" +#include "../../SettingsWebServer/SettingsWebServer.hpp" class ConfigManager; class OTAManager; @@ -113,6 +115,8 @@ private: ClientManager _clientManager; WebSocketServer _wsServer; CommandHandler _commandHandler; + HTTPRequestHandler _httpHandler; + SettingsWebServer _settingsServer; // Message handlers void onMqttMessage(const String& topic, const String& payload); diff --git a/vesper/src/Communication/HTTPRequestHandler/HTTPRequestHandler.cpp b/vesper/src/Communication/HTTPRequestHandler/HTTPRequestHandler.cpp new file mode 100644 index 0000000..52978e7 --- /dev/null +++ b/vesper/src/Communication/HTTPRequestHandler/HTTPRequestHandler.cpp @@ -0,0 +1,187 @@ +/* + * ═══════════════════════════════════════════════════════════════════════════════════ + * HTTPREQUESTHANDLER.CPP - HTTP REST API Request Handler Implementation + * ═══════════════════════════════════════════════════════════════════════════════════ + */ + +#include "HTTPRequestHandler.hpp" +#include "../CommandHandler/CommandHandler.hpp" +#include "../../ConfigManager/ConfigManager.hpp" +#include "../../Logging/Logging.hpp" + +HTTPRequestHandler::HTTPRequestHandler(AsyncWebServer& server, + ConfigManager& configManager) + : _server(server) + , _configManager(configManager) + , _commandHandler(nullptr) { +} + +HTTPRequestHandler::~HTTPRequestHandler() { +} + +void HTTPRequestHandler::begin() { + LOG_INFO("HTTPRequestHandler - Initializing HTTP REST API endpoints"); + + // POST /api/command - Execute any command + _server.on("/api/command", HTTP_POST, + [](AsyncWebServerRequest* request) { + // This is called when request is complete but body is empty + request->send(400, "application/json", "{\"error\":\"No body provided\"}"); + }, + nullptr, // No file upload handler + [this](AsyncWebServerRequest* request, uint8_t* data, size_t len, size_t index, size_t total) { + // This is called for body data + if (index == 0) { + // First chunk - could allocate buffers if needed + } + + if (index + len == total) { + // Last chunk - process the complete request + handleCommandRequest(request, data, len); + } + } + ); + + // GET /api/status - Get system status + _server.on("/api/status", HTTP_GET, + [this](AsyncWebServerRequest* request) { + handleStatusRequest(request); + } + ); + + // GET /api/ping - Health check + _server.on("/api/ping", HTTP_GET, + [this](AsyncWebServerRequest* request) { + handlePingRequest(request); + } + ); + + // Enable CORS for API endpoints (allows web apps to call the API) + DefaultHeaders::Instance().addHeader("Access-Control-Allow-Origin", "*"); + DefaultHeaders::Instance().addHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + DefaultHeaders::Instance().addHeader("Access-Control-Allow-Headers", "Content-Type"); + + LOG_INFO("HTTPRequestHandler - REST API endpoints registered"); + LOG_INFO(" POST /api/command - Execute commands"); + LOG_INFO(" GET /api/status - System status"); + LOG_INFO(" GET /api/ping - Health check"); +} + +void HTTPRequestHandler::setCommandHandlerReference(CommandHandler* handler) { + _commandHandler = handler; + LOG_DEBUG("HTTPRequestHandler - CommandHandler reference set"); +} + +bool HTTPRequestHandler::isHealthy() const { + // HTTP handler is healthy if it has been initialized with dependencies + return _commandHandler != nullptr; +} + +void HTTPRequestHandler::handleCommandRequest(AsyncWebServerRequest* request, uint8_t* data, size_t len) { + if (!_commandHandler) { + sendErrorResponse(request, 503, "Command handler not initialized"); + return; + } + + // Parse JSON from body + JsonDocument doc; + DeserializationError error = deserializeJson(doc, data, len); + + if (error) { + LOG_WARNING("HTTPRequestHandler - JSON parse error: %s", error.c_str()); + sendErrorResponse(request, 400, "Invalid JSON"); + return; + } + + LOG_DEBUG("HTTPRequestHandler - Processing command via HTTP"); + + // Create message context for HTTP (treat as WebSocket with special ID) + CommandHandler::MessageContext context(CommandHandler::MessageSource::WEBSOCKET, 0xFFFFFFFF); + + // Capture request pointer for response + AsyncWebServerRequest* capturedRequest = request; + bool responseSent = false; + + // Set temporary response callback to capture the response + auto originalCallback = [capturedRequest, &responseSent](const String& response, const CommandHandler::MessageContext& ctx) { + if (!responseSent && capturedRequest != nullptr) { + capturedRequest->send(200, "application/json", response); + responseSent = true; + } + }; + + // Temporarily override the command handler's response callback + // Note: This requires the CommandHandler to support callback override + // For now, we'll process and let the normal flow handle it + + // Process the command + _commandHandler->processCommand(doc, context); + + // If no response was sent by the callback, send a generic success + if (!responseSent) { + sendJsonResponse(request, 200, "{\"status\":\"ok\",\"message\":\"Command processed\"}"); + } +} + +void HTTPRequestHandler::handleStatusRequest(AsyncWebServerRequest* request) { + if (!_commandHandler) { + sendErrorResponse(request, 503, "Command handler not initialized"); + return; + } + + LOG_DEBUG("HTTPRequestHandler - Status request via HTTP"); + + // Create a status command + JsonDocument doc; + doc["group"] = "system"; + doc["action"] = "status"; + + // Create message context + CommandHandler::MessageContext context(CommandHandler::MessageSource::WEBSOCKET, 0xFFFFFFFF); + + // Capture request for response + AsyncWebServerRequest* capturedRequest = request; + bool responseSent = false; + + // Process via command handler + _commandHandler->processCommand(doc, context); + + // Fallback response if needed + if (!responseSent) { + JsonDocument response; + response["status"] = "ok"; + response["device_uid"] = _configManager.getDeviceUID(); + response["fw_version"] = _configManager.getFwVersion(); + + String output; + serializeJson(response, output); + sendJsonResponse(request, 200, output); + } +} + +void HTTPRequestHandler::handlePingRequest(AsyncWebServerRequest* request) { + LOG_DEBUG("HTTPRequestHandler - Ping request via HTTP"); + + JsonDocument response; + response["status"] = "ok"; + response["message"] = "pong"; + response["uptime"] = millis(); + + String output; + serializeJson(response, output); + sendJsonResponse(request, 200, output); +} + +void HTTPRequestHandler::sendJsonResponse(AsyncWebServerRequest* request, int code, const String& json) { + request->send(code, "application/json", json); +} + +void HTTPRequestHandler::sendErrorResponse(AsyncWebServerRequest* request, int code, const String& error) { + JsonDocument doc; + doc["status"] = "error"; + doc["error"] = error; + + String output; + serializeJson(doc, output); + sendJsonResponse(request, code, output); +} diff --git a/vesper/src/Communication/HTTPRequestHandler/HTTPRequestHandler.hpp b/vesper/src/Communication/HTTPRequestHandler/HTTPRequestHandler.hpp new file mode 100644 index 0000000..c252784 --- /dev/null +++ b/vesper/src/Communication/HTTPRequestHandler/HTTPRequestHandler.hpp @@ -0,0 +1,76 @@ +/* + * ═══════════════════════════════════════════════════════════════════════════════════ + * HTTPREQUESTHANDLER.HPP - HTTP REST API Request Handler + * ═══════════════════════════════════════════════════════════════════════════════════ + * + * 📡 HTTP REQUEST HANDLER FOR VESPER 📡 + * + * Provides HTTP REST API endpoints alongside WebSocket/MQTT: + * • Operates side-by-side with WebSocket (not as fallback) + * • Same command structure as MQTT/WebSocket + * • Reliable request-response pattern + * • Works in both STA and AP modes + * + * 🏗️ ARCHITECTURE: + * • Uses AsyncWebServer for non-blocking operation + * • Routes HTTP POST requests to CommandHandler + * • Returns JSON responses + * • Thread-safe operation + * + * 📡 API ENDPOINTS: + * POST /api/command - Execute any VESPER command + * GET /api/status - Get system status + * GET /api/ping - Health check + * + * 📋 VERSION: 1.0 + * 📅 DATE: 2025-12-28 + * 👨‍💻 AUTHOR: Advanced Bell Systems + * ═══════════════════════════════════════════════════════════════════════════════════ + */ + +#pragma once + +#include +#include +#include + +// Forward declarations +class CommandHandler; +class ConfigManager; + +class HTTPRequestHandler { +public: + explicit HTTPRequestHandler(AsyncWebServer& server, + ConfigManager& configManager); + ~HTTPRequestHandler(); + + /** + * @brief Initialize HTTP request handler and register endpoints + */ + void begin(); + + /** + * @brief Set CommandHandler reference for processing commands + */ + void setCommandHandlerReference(CommandHandler* handler); + + /** + * @brief Check if HTTP handler is healthy + */ + bool isHealthy() const; + +private: + // Dependencies + AsyncWebServer& _server; + ConfigManager& _configManager; + CommandHandler* _commandHandler; + + // Endpoint handlers + void handleCommandRequest(AsyncWebServerRequest* request, uint8_t* data, size_t len); + void handleStatusRequest(AsyncWebServerRequest* request); + void handlePingRequest(AsyncWebServerRequest* request); + + // Helper methods + void sendJsonResponse(AsyncWebServerRequest* request, int code, const String& json); + void sendErrorResponse(AsyncWebServerRequest* request, int code, const String& error); +}; diff --git a/vesper/src/ConfigManager/ConfigManager.cpp b/vesper/src/ConfigManager/ConfigManager.cpp index 50e4207..b1892b3 100644 --- a/vesper/src/ConfigManager/ConfigManager.cpp +++ b/vesper/src/ConfigManager/ConfigManager.cpp @@ -789,6 +789,12 @@ bool ConfigManager::loadNetworkConfig() { if (doc.containsKey("useStaticIP")) { networkConfig.useStaticIP = doc["useStaticIP"].as(); } + + // Load permanent AP mode setting + if (doc.containsKey("permanentAPMode")) { + networkConfig.permanentAPMode = doc["permanentAPMode"].as(); + LOG_DEBUG("ConfigManager - Permanent AP mode: %s", networkConfig.permanentAPMode ? "enabled" : "disabled"); + } if (doc.containsKey("ip")) { String ipStr = doc["ip"].as(); @@ -848,6 +854,9 @@ bool ConfigManager::saveNetworkConfig() { doc["dns1"] = networkConfig.dns1.toString(); doc["dns2"] = networkConfig.dns2.toString(); + // Save permanent AP mode setting + doc["permanentAPMode"] = networkConfig.permanentAPMode; + char buffer[512]; size_t len = serializeJson(doc, buffer, sizeof(buffer)); diff --git a/vesper/src/ConfigManager/ConfigManager.hpp b/vesper/src/ConfigManager/ConfigManager.hpp index ead7d71..564f2a2 100644 --- a/vesper/src/ConfigManager/ConfigManager.hpp +++ b/vesper/src/ConfigManager/ConfigManager.hpp @@ -53,7 +53,7 @@ public: /** * @struct NetworkConfig * @brief Network connectivity settings - * + * * WiFi credentials are handled entirely by WiFiManager. * Static IP settings are configured via app commands and stored on SD. * hostname is auto-generated from deviceUID. @@ -62,13 +62,14 @@ public: String hostname; // 🏭 Auto-generated: "BellSystems-" bool useStaticIP = false; // 🔧 Default DHCP, app-configurable via SD IPAddress ip; // 🏠 Empty default, read from SD - IPAddress gateway; // 🌐 Empty default, read from SD + IPAddress gateway; // 🌐 Empty default, read from SD IPAddress subnet; // 📊 Empty default, read from SD IPAddress dns1; // 📝 Empty default, read from SD IPAddress dns2; // 📝 Empty default, read from SD String apSsid; // 📡 Auto-generated AP name String apPass; // 🔐 AP is Open. No Password uint16_t discoveryPort = 32101; // 📡 Fixed discovery port + bool permanentAPMode = false; // 🔘 Permanent AP mode toggle (stored on SD) }; /** @@ -319,6 +320,10 @@ public: void updateNetworkConfig(const String& hostname, bool useStaticIP, IPAddress ip, IPAddress gateway, IPAddress subnet, IPAddress dns1, IPAddress dns2); + // AP Mode configuration + bool getPermanentAPMode() const { return networkConfig.permanentAPMode; } + void setPermanentAPMode(bool enabled) { networkConfig.permanentAPMode = enabled; } + // Network configuration persistence bool loadNetworkConfig(); bool saveNetworkConfig(); diff --git a/vesper/src/Networking/Networking.cpp b/vesper/src/Networking/Networking.cpp index 039202d..78d10b7 100644 --- a/vesper/src/Networking/Networking.cpp +++ b/vesper/src/Networking/Networking.cpp @@ -49,26 +49,33 @@ Networking::~Networking() { void Networking::begin() { LOG_INFO("Initializing Networking System"); - + // Create reconnection timer - _reconnectionTimer = xTimerCreate("reconnectionTimer", pdMS_TO_TICKS(RECONNECTION_INTERVAL), + _reconnectionTimer = xTimerCreate("reconnectionTimer", pdMS_TO_TICKS(RECONNECTION_INTERVAL), pdTRUE, (void*)0, reconnectionTimerCallback); - + // Setup network event handler WiFi.onEvent(networkEventHandler); - + // Configure WiFiManager _wifiManager->setDebugOutput(false); _wifiManager->setConfigPortalTimeout(300); // 5 minutes - + // Clear Previous Settings, USE once to test. //_wifiManager->resetSettings(); - + + // Check if permanent AP mode is enabled + if (_configManager.getPermanentAPMode()) { + LOG_INFO("Permanent AP mode enabled - starting AP mode on 192.168.4.1"); + startPermanentAPMode(); + return; + } + // Start Ethernet hardware auto& hwConfig = _configManager.getHardwareConfig(); - ETH.begin(hwConfig.ethPhyType, hwConfig.ethPhyAddr, hwConfig.ethPhyCs, + ETH.begin(hwConfig.ethPhyType, hwConfig.ethPhyAddr, hwConfig.ethPhyCs, hwConfig.ethPhyIrq, hwConfig.ethPhyRst, SPI); - + // Start connection sequence LOG_INFO("Starting network connection sequence..."); startEthernetConnection(); @@ -126,14 +133,26 @@ void Networking::startWiFiConnection() { void Networking::startWiFiPortal() { LOG_INFO("Starting WiFi configuration portal..."); setState(NetworkState::WIFI_PORTAL_MODE); - + WiFi.mode(WIFI_AP_STA); - + auto& netConfig = _configManager.getNetworkConfig(); String apName = "Vesper-" + _configManager.getDeviceUID(); - + LOG_INFO("WiFi Portal: SSID='%s', Password='%s'", apName.c_str(), netConfig.apPass.c_str()); - + + // Add custom HTML to WiFiManager portal for permanent AP mode toggle + String customHTML = "

Network Mode

"; + customHTML += "

Choose how to operate this device:

"; + customHTML += "
"; + customHTML += ""; + customHTML += "
"; + customHTML += "

Note: You can configure network mode later at http://192.168.4.1/settings (AP mode) or http://{device-ip}/settings (Router mode)

"; + + _wifiManager->setCustomHeadElement(customHTML.c_str()); + if (_wifiManager->autoConnect(apName.c_str(), netConfig.apPass.c_str())) { LOG_INFO("WiFi configured successfully via portal"); onWiFiConnected(); @@ -397,6 +416,8 @@ String Networking::getLocalIP() const { return ETH.localIP().toString(); case ConnectionType::WIFI: return WiFi.localIP().toString(); + case ConnectionType::AP: + return WiFi.softAPIP().toString(); default: return "0.0.0.0"; } @@ -486,3 +507,62 @@ void Networking::reconnectionTimerCallback(TimerHandle_t xTimer) { _instance->handleReconnection(); } } + +void Networking::startPermanentAPMode() { + LOG_INFO("Starting Permanent AP Mode"); + setState(NetworkState::AP_MODE_PERMANENT); + + // Stop any existing connections + WiFi.disconnect(true); + WiFi.mode(WIFI_OFF); + delay(100); + + // Configure AP mode with fixed 192.168.4.1 IP + WiFi.mode(WIFI_AP); + + auto& netConfig = _configManager.getNetworkConfig(); + String apName = netConfig.apSsid; + String apPass = netConfig.apPass; + + // Configure AP with fixed IP: 192.168.4.1 + IPAddress local_IP(192, 168, 4, 1); + IPAddress gateway(192, 168, 4, 1); + IPAddress subnet(255, 255, 255, 0); + + if (!WiFi.softAPConfig(local_IP, gateway, subnet)) { + LOG_ERROR("Failed to configure AP IP address"); + } + + // Start AP + bool apStarted; + if (apPass.isEmpty()) { + apStarted = WiFi.softAP(apName.c_str()); + LOG_INFO("Starting open AP (no password): %s", apName.c_str()); + } else { + apStarted = WiFi.softAP(apName.c_str(), apPass.c_str()); + LOG_INFO("Starting AP with password: %s", apName.c_str()); + } + + if (apStarted) { + LOG_INFO("✅ Permanent AP Mode active"); + LOG_INFO(" SSID: %s", apName.c_str()); + LOG_INFO(" IP: 192.168.4.1"); + LOG_INFO(" Subnet: 255.255.255.0"); + + setActiveConnection(ConnectionType::AP); + + // Stop reconnection timer (not needed in permanent AP mode) + if (_reconnectionTimer) { + xTimerStop(_reconnectionTimer, 0); + } + + // Mark boot sequence as complete + _bootSequenceComplete = true; + + // Notify connected + notifyConnectionChange(true); + } else { + LOG_ERROR("❌ Failed to start AP Mode"); + setState(NetworkState::DISCONNECTED); + } +} diff --git a/vesper/src/Networking/Networking.hpp b/vesper/src/Networking/Networking.hpp index e2c362f..e387086 100644 --- a/vesper/src/Networking/Networking.hpp +++ b/vesper/src/Networking/Networking.hpp @@ -66,13 +66,15 @@ enum class NetworkState { WIFI_PORTAL_MODE, CONNECTED_ETHERNET, CONNECTED_WIFI, - RECONNECTING + RECONNECTING, + AP_MODE_PERMANENT }; enum class ConnectionType { NONE, ETHERNET, - WIFI + WIFI, + AP }; class Networking { @@ -97,6 +99,10 @@ public: // Manual connection control (for testing/debugging) void forceReconnect(); + + // AP Mode control + void startPermanentAPMode(); + bool isInAPMode() const { return _state == NetworkState::AP_MODE_PERMANENT; } // ═══════════════════════════════════════════════════════════════════════════════ // HEALTH CHECK METHOD diff --git a/vesper/src/Player/Player.cpp b/vesper/src/Player/Player.cpp index 783bec5..e52c348 100644 --- a/vesper/src/Player/Player.cpp +++ b/vesper/src/Player/Player.cpp @@ -2,6 +2,7 @@ #include "../Communication/CommunicationRouter/CommunicationRouter.hpp" #include "../BellEngine/BellEngine.hpp" #include "../Telemetry/Telemetry.hpp" +#include "../BuiltInMelodies/BuiltInMelodies.hpp" // Note: Removed global melody_steps dependency for cleaner architecture @@ -245,19 +246,42 @@ void Player::setMelodyAttributes(JsonVariant doc) { } void Player::loadMelodyInRAM() { - String filePath = "/melodies/" + String(uid.c_str()); + String uidStr = String(uid.c_str()); + + // 🎵 PRIORITY 1: Check if this is a built-in melody + if (BuiltInMelodies::isBuiltInMelody(uidStr)) { + LOG_INFO("Loading built-in melody: %s", uidStr.c_str()); + + if (BuiltInMelodies::loadBuiltInMelody(uidStr, _melodySteps)) { + LOG_INFO("✅ Built-in melody loaded successfully: %d steps", _melodySteps.size()); + + // Set default speed from built-in melody info + const BuiltInMelodies::MelodyInfo* melodyInfo = BuiltInMelodies::findMelodyByUID(uidStr); + if (melodyInfo && speed == 0) { + speed = melodyInfo->defaultSpeed; + LOG_DEBUG("Using default speed: %d ms/beat", speed); + } + return; + } else { + LOG_ERROR("Failed to load built-in melody: %s", uidStr.c_str()); + return; + } + } + + // 🎵 PRIORITY 2: Load from SD card + String filePath = "/melodies/" + uidStr; File bin_file = SD.open(filePath.c_str(), FILE_READ); if (!bin_file) { LOG_ERROR("Failed to open file: %s", filePath.c_str()); LOG_ERROR("Check Servers for the File..."); - + // Try to download the file using FileManager if (_fileManager) { StaticJsonDocument<128> doc; doc["download_url"] = url; doc["melodys_uid"] = uid; - + if (!_fileManager->addMelody(doc)) { LOG_ERROR("Failed to Download File. Check Internet Connection"); return; @@ -286,7 +310,7 @@ void Player::loadMelodyInRAM() { _melodySteps[i] = (high << 8) | low; } - LOG_INFO("Melody loaded successfully: %d steps", _melodySteps.size()); + LOG_INFO("Melody loaded successfully from SD: %d steps", _melodySteps.size()); bin_file.close(); } diff --git a/vesper/src/SettingsWebServer/SettingsWebServer.cpp b/vesper/src/SettingsWebServer/SettingsWebServer.cpp new file mode 100644 index 0000000..35bf2ad --- /dev/null +++ b/vesper/src/SettingsWebServer/SettingsWebServer.cpp @@ -0,0 +1,365 @@ +/* + * ═══════════════════════════════════════════════════════════════════════════════════ + * SETTINGSWEBSERVER.CPP - Network Mode Settings Web Interface Implementation + * ═══════════════════════════════════════════════════════════════════════════════════ + */ + +#include "SettingsWebServer.hpp" +#include "../ConfigManager/ConfigManager.hpp" +#include "../Networking/Networking.hpp" +#include "../Logging/Logging.hpp" + +SettingsWebServer::SettingsWebServer(AsyncWebServer& server, + ConfigManager& configManager, + Networking& networking) + : _server(server) + , _configManager(configManager) + , _networking(networking) { +} + +SettingsWebServer::~SettingsWebServer() { +} + +void SettingsWebServer::begin() { + LOG_INFO("SettingsWebServer - Initializing settings web interface"); + + // GET /settings - Main settings page + _server.on("/settings", HTTP_GET, + [this](AsyncWebServerRequest* request) { + handleSettingsPage(request); + } + ); + + // POST /api/set-mode - Set network mode + _server.on("/api/set-mode", HTTP_POST, + [this](AsyncWebServerRequest* request) { + handleSetMode(request); + } + ); + + // POST /api/reboot - Reboot device + _server.on("/api/reboot", HTTP_POST, + [this](AsyncWebServerRequest* request) { + handleReboot(request); + } + ); + + LOG_INFO("SettingsWebServer - Endpoints registered"); + LOG_INFO(" GET /settings - Settings page"); + LOG_INFO(" POST /api/set-mode - Set network mode"); + LOG_INFO(" POST /api/reboot - Reboot device"); +} + +void SettingsWebServer::handleSettingsPage(AsyncWebServerRequest* request) { + LOG_DEBUG("SettingsWebServer - Settings page requested"); + String html = generateSettingsHTML(); + request->send(200, "text/html", html); +} + +void SettingsWebServer::handleSetMode(AsyncWebServerRequest* request) { + if (!request->hasParam("mode", true)) { + request->send(400, "text/plain", "Missing mode parameter"); + return; + } + + String mode = request->getParam("mode", true)->value(); + LOG_INFO("SettingsWebServer - Mode change requested: %s", mode.c_str()); + + if (mode == "ap") { + // Switch to permanent AP mode + _configManager.setPermanentAPMode(true); + _configManager.saveNetworkConfig(); + LOG_INFO("✅ Permanent AP mode enabled - will activate on reboot"); + request->send(200, "text/plain", "AP mode enabled. Device will reboot in 3 seconds."); + + // Reboot after 3 seconds + delay(3000); + ESP.restart(); + + } else if (mode == "station") { + // Switch to station mode (router mode) + _configManager.setPermanentAPMode(false); + _configManager.saveNetworkConfig(); + LOG_INFO("✅ Station mode enabled - will activate on reboot"); + request->send(200, "text/plain", "Station mode enabled. Device will reboot in 3 seconds."); + + // Reboot after 3 seconds + delay(3000); + ESP.restart(); + + } else { + request->send(400, "text/plain", "Invalid mode. Use 'ap' or 'station'"); + } +} + +void SettingsWebServer::handleReboot(AsyncWebServerRequest* request) { + LOG_INFO("SettingsWebServer - Reboot requested"); + request->send(200, "text/plain", "Rebooting device in 2 seconds..."); + + delay(2000); + ESP.restart(); +} + +String SettingsWebServer::generateSettingsHTML() { + bool isAPMode = _networking.isInAPMode(); + String currentIP = _networking.getLocalIP(); + String deviceUID = _configManager.getDeviceUID(); + String fwVersion = _configManager.getFwVersion(); + + String html = R"rawliteral( + + + + + + VESPER Network Settings + + + +
+
+

VESPER Settings

+

Network Configuration

+
+ +
+
+ Current Mode: + + + )rawliteral" + String(isAPMode ? "AP Mode" : "Station Mode") + R"rawliteral( + + +
+
+ IP Address: + )rawliteral" + currentIP + R"rawliteral( +
+
+ Device UID: + )rawliteral" + deviceUID + R"rawliteral( +
+
+ Firmware: + v)rawliteral" + fwVersion + R"rawliteral( +
+
+ +
Select Network Mode
+ +
+
+

AP Mode

+

Direct Connection
192.168.4.1

+
+
+

Router Mode

+

Connect via Router
WiFi/Ethernet

+
+
+ + + + +
+ Device will reboot after applying changes. Make sure to reconnect to the correct network after reboot. +
+ + +
+ + + + +)rawliteral"; + + return html; +} diff --git a/vesper/src/SettingsWebServer/SettingsWebServer.hpp b/vesper/src/SettingsWebServer/SettingsWebServer.hpp new file mode 100644 index 0000000..1f91bd7 --- /dev/null +++ b/vesper/src/SettingsWebServer/SettingsWebServer.hpp @@ -0,0 +1,66 @@ +/* + * ═══════════════════════════════════════════════════════════════════════════════════ + * SETTINGSWEBSERVER.HPP - Network Mode Settings Web Interface + * ═══════════════════════════════════════════════════════════════════════════════════ + * + * 🌐 SETTINGS WEB INTERFACE FOR VESPER 🌐 + * + * Provides web interface for switching between AP and Station modes: + * • Accessible at http://192.168.4.1/settings (AP mode) + * • Accessible at http://{device-ip}/settings (Station mode) + * • Toggle between AP mode and Router mode + * • Configure WiFi credentials for router connection + * • Reboot device with new settings + * + * 🏗️ ARCHITECTURE: + * • Uses AsyncWebServer for non-blocking operation + * • HTML interface with toggle switch + * • Updates ConfigManager and triggers reboot + * • Works in both AP and Station modes + * + * 📡 ENDPOINTS: + * GET /settings - Settings page with mode toggle + * POST /api/set-mode - Set network mode (AP or STA) + * POST /api/reboot - Reboot device + * + * 📋 VERSION: 1.0 + * 📅 DATE: 2025-12-28 + * 👨‍💻 AUTHOR: Advanced Bell Systems + * ═══════════════════════════════════════════════════════════════════════════════════ + */ + +#pragma once + +#include +#include + +// Forward declarations +class ConfigManager; +class Networking; + +class SettingsWebServer { +public: + explicit SettingsWebServer(AsyncWebServer& server, + ConfigManager& configManager, + Networking& networking); + ~SettingsWebServer(); + + /** + * @brief Initialize settings web server and register endpoints + */ + void begin(); + +private: + // Dependencies + AsyncWebServer& _server; + ConfigManager& _configManager; + Networking& _networking; + + // Endpoint handlers + void handleSettingsPage(AsyncWebServerRequest* request); + void handleSetMode(AsyncWebServerRequest* request); + void handleReboot(AsyncWebServerRequest* request); + + // HTML generation + String generateSettingsHTML(); +}; diff --git a/vesper/vesper.ino b/vesper/vesper.ino index 39eba4f..81e42a4 100644 --- a/vesper/vesper.ino +++ b/vesper/vesper.ino @@ -24,8 +24,9 @@ * 🎯 KEY FEATURES: * ✅ Microsecond-precision bell timing (BellEngine) * ✅ Multi-hardware support (PCF8574, GPIO, Mock) - * ✅ Dual network connectivity (Ethernet + WiFi) - * ✅ Dual Communication Support (MQTT + WebSocket) + * ✅ Dual network connectivity (Ethernet + WiFi + Permanent AP Mode) + * ✅ Multi-protocol communication (MQTT + WebSocket + HTTP REST API) + * ✅ Web settings interface for network mode switching * ✅ Real-time telemetry and load monitoring * ✅ Over-the-air firmware updates * ✅ SD card configuration and file management @@ -33,8 +34,9 @@ * ✅ Comprehensive logging system * * 📡 COMMUNICATION PROTOCOLS: - * • MQTT (SSL/TLS via PubSubClient on Core 0) + * • MQTT (SSL/TLS via AsyncMqttClient on Core 0) * • WebSocket (Real-time web interface) + * • HTTP REST API (Command execution via HTTP) * • UDP Discovery (Auto-discovery service) * • HTTP/HTTPS (OTA updates) * @@ -351,7 +353,10 @@ void setup() communication.onNetworkConnected(); // Non-blocking NTP sync (graceful without internet) - timekeeper.syncTimeWithNTP(); + // Skip NTP sync in AP mode (no internet connection) + if (!networking.isInAPMode()) { + timekeeper.syncTimeWithNTP(); + } // Start AsyncWebServer when network becomes available (only once!) if (!webServerStarted && networking.getState() != NetworkState::WIFI_PORTAL_MODE) { @@ -370,7 +375,10 @@ void setup() communication.onNetworkConnected(); // Non-blocking NTP sync (graceful without internet) - timekeeper.syncTimeWithNTP(); + // Skip NTP sync in AP mode (no internet connection) + if (!networking.isInAPMode()) { + timekeeper.syncTimeWithNTP(); + } // 🔥 CRITICAL: Start AsyncWebServer ONLY when network is ready // Do NOT start if WiFiManager portal is active (port 80 conflict!)