diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b9faf64 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +vesper/build/ +vesper/.vscode/ +vesper/docs/ +vesper/sd_settings/ +vesper/CLAUDE.md + diff --git a/vesper/MQTT_Connection_Handling.hpp b/vesper/MQTT_Connection_Handling.hpp deleted file mode 100644 index 725a63f..0000000 --- a/vesper/MQTT_Connection_Handling.hpp +++ /dev/null @@ -1,148 +0,0 @@ -#pragma once - -TimerHandle_t mqttReconnectTimer; -TimerHandle_t wifiReconnectTimer; - -String GetPayloadContent(char * data, size_t len) { - String content = ""; - for(size_t i = 0; i < len; i++) - { - content.concat(data[i]); - } - return content; -} - -void ConnectToMqtt() { - LOG_INFO("Connecting to MQTT..."); - mqttClient.connect(); -} - -void OnMqttConnect(bool sessionPresent) { - LOG_INFO("Connected to MQTT."); - SuscribeMqtt(); -} - -void OnMqttDisconnect(AsyncMqttClientDisconnectReason reason) { - LOG_WARNING("Disconnected from MQTT."); - - if(WiFi.isConnected()) - { - xTimerStart(mqttReconnectTimer, 0); - } -} - -void OnMqttSubscribe(uint16_t packetId, uint8_t qos) { - LOG_INFO("Subscribe acknowledged. PacketID: %d / QoS: %d", packetId, qos); -} - -void OnMqttUnsubscribe(uint16_t packetId) { - LOG_INFO("Unsubscribe Acknowledged. PacketID: %d",packetId); -} - -void OnMqttPublish(uint16_t packetId) { - LOG_INFO("Publish Acknowledged. PacketID: %d", packetId); -} - -void ConnectWiFi_STA(bool useStaticIP = true) { - WiFi.mode(WIFI_STA); - if(useStaticIP) { - WiFi.config(ip, gateway, subnet); - WiFi.setHostname(hostname); - } - //WiFi.begin(ssid, password); - WiFi.begin(); - - while (WiFi.status() != WL_CONNECTED) { - delay(10); - } - - if (LOG_LEVEL_ENABLED(LOG_LEVEL_INFO)){ - Serial.println(""); - Serial.print("NIGGA - Initiating STA:\t"); - Serial.println(ssid); - Serial.print("IP address:\t"); - Serial.println(WiFi.localIP()); - } -} - -void ConnectWiFi_AP(bool useStaticIP = false) { - Serial.println(""); - WiFi.mode(WIFI_AP); - while(!WiFi.softAP(ssid, password)) { - Serial.println("."); - delay(100); - } - - if(useStaticIP) WiFi.softAPConfig(ip, gateway, subnet); - - if (LOG_LEVEL_ENABLED(LOG_LEVEL_INFO)){ - Serial.println(""); - Serial.print("Iniciado AP:\t"); - Serial.println(ssid); - Serial.print("IP address:\t"); - Serial.println(WiFi.softAPIP()); - } -} - -void NetworkEvent(arduino_event_id_t event, arduino_event_info_t info) { - LOG_INFO("(NET) event: %d\n", event); - IPAddress ip = WiFi.localIP(); - switch(event) - { - case ARDUINO_EVENT_WIFI_STA_GOT_IP: - LOG_DEBUG("WiFi connected. IP Address: %d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]); - //xTimerStop(wifiReconnectTimer, 0); - ConnectToMqtt(); - break; - case ARDUINO_EVENT_WIFI_STA_DISCONNECTED: - LOG_WARNING("WiFi Lost Connection! :("); - xTimerStop(mqttReconnectTimer, 0); - xTimerStart(wifiReconnectTimer, 0); - break; - - case ARDUINO_EVENT_ETH_START: - LOG_DEBUG("ETH Started"); - ETH.setHostname(hostname); - break; - case ARDUINO_EVENT_ETH_CONNECTED: - LOG_DEBUG("ETH Connected !"); - break; - case ARDUINO_EVENT_ETH_GOT_IP: - LOG_INFO("ETH Got IP: '%s'\n", esp_netif_get_desc(info.got_ip.esp_netif)); - WiFi.disconnect(true); - ConnectToMqtt(); - break; - case ARDUINO_EVENT_ETH_LOST_IP: - LOG_WARNING("ETH Lost IP"); - break; - case ARDUINO_EVENT_ETH_DISCONNECTED: - LOG_WARNING("ETH Disconnected"); - xTimerStop(mqttReconnectTimer, 0); - xTimerStart(wifiReconnectTimer, 0); - break; - case ARDUINO_EVENT_ETH_STOP: - LOG_INFO("ETH Stopped"); - xTimerStop(mqttReconnectTimer, 0); - xTimerStart(wifiReconnectTimer, 0); - break; - default: - break; - } -} - -void InitMqtt() { - mqttReconnectTimer = xTimerCreate("mqttTimer", pdMS_TO_TICKS(2000), pdFALSE, (void*)0, reinterpret_cast(ConnectToMqtt)); - wifiReconnectTimer = xTimerCreate("wifiTimer", pdMS_TO_TICKS(5000), pdFALSE, (void*)0, reinterpret_cast(ConnectWiFi_STA)); - - mqttClient.onConnect(OnMqttConnect); - mqttClient.onDisconnect(OnMqttDisconnect); - - mqttClient.onSubscribe(OnMqttSubscribe); - mqttClient.onUnsubscribe(OnMqttUnsubscribe); - - mqttClient.onMessage(OnMqttReceived); - mqttClient.onPublish(OnMqttPublish); - - mqttClient.setServer(MQTT_HOST, MQTT_PORT); - mqttClient.setCredentials(MQTT_USER, MQTT_PASS); -} diff --git a/vesper/MQTT_Functions.hpp b/vesper/MQTT_Functions.hpp deleted file mode 100644 index 763193e..0000000 --- a/vesper/MQTT_Functions.hpp +++ /dev/null @@ -1,40 +0,0 @@ -#pragma once - -// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -// MQTT Functions. -// Both for Incoming and Outgoing MQTT Messages -// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -// Publishes a message on the MQTT server. Message passed as an argument. -void PublishMqtt(const char * data) { - if (mqttClient.connected()){ - String topicData = String("vesper/") + DEV_ID + "/data"; - mqttClient.publish(topicData.c_str(), 0, true, data); - } else { - LOG_ERROR("MQTT Not Connected ! Message Failed."); - } -} - -// Subscribes to certain topics on the MQTT Server. -void SuscribeMqtt() { - - String topic = String("vesper/") + DEV_ID + "/control"; - uint16_t topic_id = mqttClient.subscribe(topic.c_str(), 2); - LOG_INFO("Subscribing to Command topic, QoS 2, packetId: %d", topic_id); - -} - -// Handles incoming MQTT messages on subscribed topics. -// Could move logic out of this into a dedicated function. -void OnMqttReceived(char * topic, char * payload, AsyncMqttClientMessageProperties properties, size_t len, size_t index, size_t total) { - - String inc_topic = String("vesper/") + DEV_ID + "/control"; - - // Don't know what this is. Check it out later. - //String payloadContent = String(payload).substring(0, len); - - if (String(topic) == inc_topic){ - JsonDocument command = payload2json(payload); - handleCommand(command); - } -} diff --git a/vesper/PlaybackControls.hpp b/vesper/PlaybackControls.hpp deleted file mode 100644 index 2e0d1a3..0000000 --- a/vesper/PlaybackControls.hpp +++ /dev/null @@ -1,68 +0,0 @@ -#pragma once - -extern Player melody; - -bool timeToStop(unsigned long now); -bool timeToPause(unsigned long now); -bool timeToResume(unsigned long now); -void durationTimer(void *param); - -// Timer TASK to control playback state -void durationTimer(void *param) { - - // Task Setup - - // Task Loop - while (true) { - - unsigned long now = millis(); - - if (timeToStop(now)) { - player.stop(); - } else if (timeToPause(now)) { - player.pause(); - } else if (timeToResume(now)) { - player.unpause(); - } - - vTaskDelay(pdMS_TO_TICKS(500)); // Check every 500ms - } -} - -// Check if it's time to stop playback -bool timeToStop(unsigned long now) { - if (player.isPlaying && !player.infinite_play) { - uint64_t stopTime = player.startTime + player.total_duration; - if (now >= stopTime) { - LOG_DEBUG("(TimerFunction) Total Run Duration Reached. Soft Stopping."); - return true; - } - } - return false; -} - -// Check if it's time to pause playback -bool timeToPause(unsigned long now) { - if (player.isPlaying && player.continuous_loop) { - uint64_t timeToPause = player.segmentStartTime + player.segment_duration; - LOG_DEBUG("PTL: %llu // NOW: %lu", timeToPause, now); - if (now >= timeToPause && !player.isPaused) { - LOG_DEBUG("(TimerFunction) Segment Duration Reached. Pausing."); - player.pauseTime = now; - return true; - } - } - return false; -} - -// Check if it's time to resume playback -bool timeToResume(unsigned long now) { - if (player.isPaused) { - uint64_t timeToResume = player.segmentCmpltTime + player.pause_duration; - if (now >= timeToResume) { - LOG_DEBUG("(TimerFunction) Pause Duration Reached. Resuming"); - return true; - } - } - return false; -} diff --git a/vesper/WebSocket_Functions.hpp b/vesper/WebSocket_Functions.hpp deleted file mode 100644 index c1bf26a..0000000 --- a/vesper/WebSocket_Functions.hpp +++ /dev/null @@ -1,49 +0,0 @@ -#pragma once - -void sendToApp(String jsonMessage) { - if (activeClient && activeClient->status() == WS_CONNECTED) { - activeClient->text(jsonMessage); - } else { - Serial.println("No active WebSocket client connected."); - } -} - -void replyOnWebSocket(AsyncWebSocketClient *client, String list) { - LOG_DEBUG("Sending WebSocket reply: %s", list.c_str()); - client->text(list); -} - -// Handles incoming WebSocket messages on subscribed topics. -// Could move logic out of this into a dedicated function. -void onWebSocketReceived(AsyncWebSocketClient *client, void *arg, uint8_t *data, size_t len) { - AwsFrameInfo *info = (AwsFrameInfo*)arg; - if (info->final && info->index == 0 && info->len == len && info->opcode == WS_TEXT) { - data[len] = '\0'; // Null-terminate the received data - Serial.printf("Received message: %s\n", (char*)data); - - JsonDocument json = payload2json((char*)data); - handleCommand(json, client); - } -} - - -void onWebSocketEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, AwsEventType type, void *arg, uint8_t *data, size_t len) { - if (type == WS_EVT_DATA) { - onWebSocketReceived(client, arg, data, len); - Serial.println("WebSocket Message Received"); - } - if (type == WS_EVT_CONNECT) { - Serial.printf("WebSocket client #%u connected from %s\n", client->id(), client->remoteIP().toString().c_str()); - activeClient = client; // Save the connected client - } - if (type == WS_EVT_DISCONNECT) { - Serial.printf("WebSocket client #%u disconnected\n", client->id()); - if (client == activeClient) { - activeClient = nullptr; // Clear it if it's the one we saved - } - } - if (type == WS_EVT_ERROR) { - Serial.printf("WebSocket client #%u error(%u): %s\n", client->id(), *((uint16_t*)arg), (char*)data); - } - -} diff --git a/vesper/bellEngine.hpp b/vesper/bellEngine.hpp deleted file mode 100644 index cc062f5..0000000 --- a/vesper/bellEngine.hpp +++ /dev/null @@ -1,165 +0,0 @@ -// MELODY PLAYBACK WILL BE HANDLED HERE -#include -extern Player player; - -// Define a structure to track active solenoids -struct ActiveRelay { - uint8_t relayIndex; // Physical relay index (0-15) - uint8_t bellIndex; // Bell index for duration lookup - uint64_t activationTime; // Activation start time - uint16_t duration; // Duration for which it should remain active -}; - -// Duration per BELL (not per relay output) -uint16_t bellDurations[16] = {90, 90, 90, 90, 90, 90, 90, 90, 90, 90, 90, 90, 90, 90, 90, 90}; - -// Level 1: Bell to Physical Output mapping (bell index -> relay index) -// bellOutputs[0] = which relay controls Bell #0, etc. -uint16_t bellOutputs[16] = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}; // 0-based indexing - -// Vector to track active solenoids -std::vector activeRelays; - -// Locks for Writing the Counters -portMUX_TYPE mySpinlock = portMUX_INITIALIZER_UNLOCKED; - -void loop_playback(std::vector &melody_steps); -void bellEngine(void *parameter); -void relayControlTask(void *param); -void itsHammerTime(uint16_t note); -void turnOffRelays(uint64_t now); - -// Main Bell Engine. Activates Relays on the exact timing required. -void bellEngine(void *parameter) { - // SETUP TASK - for (;;) { - // Playback until stopped (Completes AT LEAST 1 full loop) - loop_playback(melody_steps); - /* - UBaseType_t highWaterMark = uxTaskGetStackHighWaterMark(NULL); - Serial.print("Stack high water mark: "); - Serial.println(highWaterMark); - */ - } -} - -// Task to deactivate relays dynamically after set timers -void relayControlTask(void *param) { - while (true) { - uint64_t now = millis(); - // Iterate through active relays and deactivate those whose duration has elapsed - for (auto it = activeRelays.begin(); it != activeRelays.end();) { - if (now - it->activationTime >= it->duration) { - relays.digitalWrite(it->relayIndex, HIGH); // Deactivate the relay - it = activeRelays.erase(it); // Remove from the active list - } else { - ++it; // Move to the next relay - } - } - vTaskDelay(pdMS_TO_TICKS(10)); // Check every 10ms - } -} - -// Function to wait for tempo, then loop to the next beat. -void loop_playback(std::vector &melody_steps) { - - while(player.isPlaying && !player.isPaused){ - LOG_DEBUG("(BellEngine) Single Loop Starting."); - - // iterate through the beats and call the bell mechanism on each beat - for (uint16_t note : melody_steps) { - if (player.hardStop) return; - itsHammerTime(note); - int tempo = player.speed; - vTaskDelay(pdMS_TO_TICKS(tempo)); - } - - player.segmentCmpltTime = millis(); - LOG_DEBUG("(BellEngine) Single Full Loop Complete"); - } - -} - -// Function to activate relays for a specific note -void itsHammerTime(uint16_t note) { - uint64_t now = millis(); - - // First, determine which bells should ring based on the note pattern - for (uint8_t noteIndex = 0; noteIndex < 16; noteIndex++) { - if (note & (1 << noteIndex)) { // This note should be played - - // Level 2: Map note to bell using noteAssignments - uint8_t bellIndex = player.noteAssignments[noteIndex]; - - // Skip if no bell assigned to this note (0 means no assignment) - if (bellIndex == 0) continue; - - // Convert to 0-based indexing (noteAssignments uses 1-based) - bellIndex = bellIndex - 1; - - // Level 1: Map bell to physical relay output - uint8_t relayIndex = bellOutputs[bellIndex]; - - // Activate the relay - relays.digitalWrite(relayIndex, LOW); - - // Add to the activeRelays list with bell-specific duration - activeRelays.push_back({ - relayIndex, - bellIndex, - now, - bellDurations[bellIndex] - }); - - // Write ring to counter (count per bell, not per relay) - portENTER_CRITICAL(&mySpinlock); - strikeCounters[bellIndex]++; // Count strikes per bell - bellLoad[bellIndex]++; // Load per bell - coolingActive = true; - portEXIT_CRITICAL(&mySpinlock); - } - } -} - - -// Helper function to update bell-to-output mapping via JSON -void updateBellOutputs(JsonVariant doc) { - for (uint8_t i = 0; i < 16; i++) { - String key = String("b") + (i + 1); - if (doc.containsKey(key)) { - bellOutputs[i] = doc[key].as()-1; // -1 to convert to 0 based indexing - LOG_DEBUG("Bell %d Output set to Relay #%d", i + 1, bellOutputs[i]+1); - } else { - LOG_DEBUG("Bell %d not found in JSON payload. Keeping previous Output: Relay #%d", i + 1, bellOutputs[i]+1); - } - } - LOG_INFO("Updated Relay Outputs.") - StaticJsonDocument<128> response; - response["status"] = "OK"; - response["type"] = "set_relay_outputs"; - char jsonOut[128]; // Create Char Buffer - serializeJson(response, jsonOut); // Serialize to Buffer - replyOnWebSocket(activeClient, jsonOut); // Reply on WebSocket -} - -// Sets Incoming Relay Durations to RAM and then call funtion to save them to file. -void updateRelayTimings(JsonVariant doc) { - // Iterate through the relays in the JSON payload - for (uint8_t i = 0; i < 16; i++) { - String key = String("b") + (i + 1); // Generate "b1", "b2", ... - if (doc.containsKey(key)) { - bellDurations[i] = doc[key].as(); - LOG_DEBUG("Relay %d duration set to %d ms", i + 1, bellDurations[i]); - } else { - LOG_DEBUG("Relay %d not found in JSON payload. Keeping previous duration: %d ms", i + 1, bellDurations[i]); - } - } - saveRelayTimings(); - LOG_INFO("Updated Relay Timings.") - StaticJsonDocument<128> response; - response["status"] = "OK"; - response["type"] = "set_relay_timings"; - char jsonOut[128]; // Create Char Buffer - serializeJson(response, jsonOut); // Serialize to Buffer - replyOnWebSocket(activeClient, jsonOut); // Reply on WebSocket -} diff --git a/vesper/class_player.hpp b/vesper/class_player.hpp deleted file mode 100644 index fa80fc6..0000000 --- a/vesper/class_player.hpp +++ /dev/null @@ -1,213 +0,0 @@ -#pragma once - -extern std::vector melody_steps; -void sendToApp(String jsonMessage); - -void PublishMqtt(const char * data); -bool addMelody(JsonVariant doc); - -class Player { -public: - uint16_t id; // The (internal) ID of the selected melody. Not specificly used anywhere atm. Might be used later. - std::string name = "melody1"; // Name of the Melody saved. Will be used to read the file: /name.bin - std::string uid = "x"; // The UID of the melody, from Firestore - std::string url = "-"; // The URL of the binary, from Firestore - uint16_t noteAssignments[16] = {0}; // The list of Note Assignments for the melody - uint16_t speed = 500; // Time to wait per beat. (In Miliseconds) - uint32_t segment_duration = 15000; // Duration of the playback per segment (per "loop") - uint32_t pause_duration = 0; // Duration of the pauses between segments (loops) - uint32_t total_duration = 0; // The Total Run Duration of the program. Including all loops - uint64_t segmentCmpltTime = 0; // The time of completion of the last Segment - uint64_t segmentStartTime = 0; // The time-point the current segment started Playing - uint64_t startTime = 0; // The time-point the Melody started Playing - uint64_t pauseTime = 0; // The time-point the melody paused - bool continuous_loop = false; // Indicates if the Loop should Run 1-Take or Continuous with Intervals - bool infinite_play = false; // Infinite Loop Indicator (If True the melody will loop forever or until stoped, with pauses of "interval" in between loops) - bool isPlaying = false; // Indicates if the Melody is actually Playing right now. - bool isPaused = false; // If playing, indicates if the Melody is Paused - bool hardStop = false; // Flags a hardstop, immediately. - - - void play() { - isPlaying = true; - hardStop = false; - startTime = segmentStartTime = millis(); - LOG_DEBUG("Plbck: PLAY"); - } - - void forceStop() { - hardStop = true; - isPlaying = false; - LOG_DEBUG("Plbck: FORCE STOP"); - } - - void stop() { - hardStop = false; - isPlaying = false; - LOG_DEBUG("Plbck: STOP"); - - StaticJsonDocument<128> doc; - doc["status"] = "NOTIFY"; - doc["type"] = "playback"; - doc["payload"] = "stop"; - - String output; - serializeJson(doc, output); - sendToApp(output); - - } - - void pause() { - isPaused = true; - LOG_DEBUG("Plbck: PAUSE"); - } - - void unpause() { - isPaused = false; - segmentStartTime = millis(); - LOG_DEBUG("Plbck: RESUME"); - } - - // Handles Incoming Commands to PLAY or STOP - void command(JsonVariant data, AsyncWebSocketClient *client = nullptr){ - - setMelodyAttributes(data); - loadMelodyInRAM(melody_steps); - - String action = data["action"]; - LOG_DEBUG("Incoming Command: %s", action); - - // Play or Stop Logic - if (action == "play") { - play(); - } else if (action == "stop") { - forceStop(); - } else { - return; - } - - // Prepare JSON response - StaticJsonDocument<128> response; - response["status"] = "OK"; - response["type"] = action; // "play" or "stop" - response["payload"] = nullptr; // Use null in JSON - - // Serialize JSON to char buffer - char jsonOut[256]; - serializeJson(response, jsonOut); - - // Send via MQTT - PublishMqtt(jsonOut); - - // Optionally send via WebSocket - if (client) { - client->text(jsonOut); - } - - } - - // Sets incoming Attributes for the Melody, into the class' variables. - void setMelodyAttributes(JsonVariant doc){ - - if (doc.containsKey("name")) { - name = doc["name"].as(); - } - if (doc.containsKey("uid")) { - uid = doc["uid"].as(); - } - if (doc.containsKey("url")) { - url = doc["url"].as(); - } - if (doc.containsKey("speed")) { - speed = doc["speed"].as(); - } - if (doc.containsKey("note_assignments")) { - JsonArray noteArray = doc["note_assignments"]; - size_t arraySize = min(noteArray.size(), (size_t)16); - for (size_t i = 0; i < arraySize; i++) { - noteAssignments[i] = noteArray[i]; - } - } - if (doc.containsKey("segment_duration")) { - segment_duration = doc["segment_duration"].as(); - } - if (doc.containsKey("pause_duration")) { - pause_duration = doc["pause_duration"].as(); - } - if (doc.containsKey("total_duration")) { - total_duration = doc["total_duration"].as(); - } - if (doc.containsKey("continuous_loop")) { - continuous_loop = doc["continuous_loop"].as(); - } - - if (continuous_loop && total_duration == 0){ - infinite_play = true; - } - - if (!continuous_loop) { - total_duration = segment_duration; - } - - // Print Just for Debugging Purposes - LOG_DEBUG("Set Melody Vars / Name: %s, UID: %s", - name.c_str(), - uid.c_str() - ); - // Print Just for Debugging Purposes - LOG_DEBUG("URL: %s", - url.c_str() - ); - // Print Just for Debugging Purposes - LOG_DEBUG("Speed: %d, Per Segment Duration: %lu, Pause Duration: %lu, Total Duration: %d, Continuous: %s, Infinite: %s", - speed, - segment_duration, - pause_duration, - total_duration, - continuous_loop ? "true" : "false", - infinite_play ? "true" : "false" - ); - - } - - - // Loads the Selected melody from a .bin file on SD into RAM - void loadMelodyInRAM(std::vector &melody_steps) { - String filePath = "/melodies/" + String(uid.c_str()); - - 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..."); - StaticJsonDocument<128> doc; - doc["download_url"] = url; - doc["melodys_uid"] = uid; - if (!addMelody(doc)){ - LOG_ERROR("Failed to Download File. Check Internet Connection"); - return; - } else { - bin_file = SD.open(filePath.c_str(), FILE_READ); - } - } - - size_t fileSize = bin_file.size(); - if (fileSize % 2 != 0) { - LOG_ERROR("Invalid file size: %u (not a multiple of 2)", fileSize); - bin_file.close(); - return; - } - - melody_steps.resize(fileSize / 2); - - for (size_t i = 0; i < melody_steps.size(); i++) { - uint8_t high = bin_file.read(); - uint8_t low = bin_file.read(); - melody_steps[i] = (high << 8) | low; - } - - LOG_INFO("Melody Load Successful"); - - bin_file.close(); - } - -}; diff --git a/vesper/commands.json b/vesper/commands.json deleted file mode 100644 index 6256321..0000000 --- a/vesper/commands.json +++ /dev/null @@ -1,202 +0,0 @@ -#################################### -## DOWNLOAD MELODY ON DEVICE: -#################################### - -{ - "cmd": "download_melody", - "contents": { - "download_url": "http://url.com/SDFJDLS9043FM3RM1/binaries/melody.bin", - "melodys_uid": "SDFJDLS9043FM3RM1" - } -} - -melodys_uid >> The Reference UID of the Melody in the database - - -{ - "status": "OK", - "type": "Download", - "payload": "null" -} - - - - - -#################################### -## SET RELAY TIMERS: -#################################### - -{ - "cmd": "set_relay_durations", - "contents": {"b1":100, "b2":200} -} - -no reply system implemented yet - - - - - - -#################################### -## SET RELAY OUTPUTS: -#################################### - -{ - "cmd": "set_relay_outputs", - "contents": {"b1":1, "b2":2} -} - -no reply system implemented yet - - - - - - -#################################### -## START PLAYBACK: -#################################### - -{ - "cmd": "playback", - "contents": { - "action": "play", - "name": "esperinos", - "uid": "01DegzV9FA8tYbQpkIHR", - "url": "https://firebasestorage.googleapis.com/v0/b/bs-vesper.firebasestorage.app/o/melodies%2F01DegzV9FA8tYbQpkIHR%2Fbinary.bin?alt=media&token=f63bfc48-de0e-44cb-a2cc-4880c675558b", - "speed": 200, - "loop_duration": 5000, - "pause_duration": 30000, - "total_duration": 200, - "continuous_loop": false - } -} - -{ - "status": "OK", - "type": "play", - "payload": "null" -} - - - - - -#################################### -## STOP PLAYBACK: -#################################### - -{ - "cmd": "playback", - "contents": { - "action": "stop" - } -} - -{ - "status": "OK", - "type": "stop", - "payload": "null" -} - - - - - -#################################### -## LIST MELODIES -#################################### - - -{ - "cmd": "list_melodies" -} - -{ - "status": "OK", - "type": "list_melodies", - "payload": ["jdslkfj09823jvcm", "cj2309jcvscv32", "csdvn02dsffv48nvm"] -} - - - - - -#################################### -## SYNC TIME -#################################### - -{ - "cmd": "sync_time", - "contents": { - "timestamp": 16546416 - } -} - -{ - "status": "OK", - "type": "time_set_response", - "payload": "Time updated successfully" -} - -{ - "status": "ERROR", - "type": "time_set_response", - "payload": "Missing timestamp parameter" -} - - - - - -#################################### -## REPORT_TIME -#################################### - -{ - "cmd": "report_time" -} - -{ - "status": "OK", - "type": "time_response", - "timestamp": 2438543431, (UNIX TIME) - "datetime": "2025-05-18T16:54:12" -} - - - - - -#################################### -## PING -#################################### - -{ - "cmd": "ping" -} - -{ - "status": "OK", - "type": "pong", -} - - - - -#################################### -## REPORT STATUS -#################################### - -{ - "cmd": "report_status" -} - -{ - "status": "OK", - "type": "current_status", - "is_playing": true, - "time_elapsed": 78850 -} diff --git a/vesper/commands_handling.hpp b/vesper/commands_handling.hpp deleted file mode 100644 index 7029a1f..0000000 --- a/vesper/commands_handling.hpp +++ /dev/null @@ -1,120 +0,0 @@ -#pragma once - -// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -// Command Handling of incoming JSON commands. -// Both MQTT and Websocket -// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -void replyOnWebSocket(AsyncWebSocketClient *client, String list); -void updateRelayTimings(JsonVariant doc); -void updateBellOutputs(JsonVariant doc); - -// Handles the incoming payload. Returns it into a "JsonDocument" format. -JsonDocument payload2json(char * payload) { - JsonDocument doc; - DeserializationError error = deserializeJson(doc, payload); - if (error) { - Serial.print("deserializeJson() failed: "); - Serial.println(error.c_str()); - } - return doc; -} - -// Handles the JSON Commands -void handleCommand(JsonDocument command, AsyncWebSocketClient *client = nullptr){ - - String cmd = command["cmd"]; - JsonVariant contents = command["contents"]; - - if (cmd == "playback") { - player.command(contents, client); - } else if (cmd == "list_melodies") { - String list = listFilesAsJson("/melodies"); - PublishMqtt(list.c_str()); - if (client) { - replyOnWebSocket(client, list); // Only reply via WS if client exists - Serial.println("Replying on WebSocket"); - } - } else if (cmd == "set_relay_timers") { - updateRelayTimings(contents); - } else if (cmd == "set_relay_outputs") { - updateBellOutputs(contents); - } else if (cmd == "download_melody") { - addMelody(contents); - // Prepare JSON response - StaticJsonDocument<128> response; - response["status"] = "OK"; - response["type"] = "Download"; - response["payload"] = nullptr; // Use null in JSON - char jsonOut[256]; // Create Char Buffer - serializeJson(response, jsonOut); // Serialize to Buffer - replyOnWebSocket(client, jsonOut); // Reply on WebSocket - } /* else if (cmd == "sync_time") { - StaticJsonDocument<256> response; - if (manualTimeSync(contents)){ - response["status"] = "OK"; - response["type"] = "time_set_response"; - response["payload"] = "Time updated successfully"; - } - response["status"] = "ERROR"; - response["type"] = "time_set_response"; - response["payload"] = "Missing timestamp parameter"; - char jsonOut[256]; - serializeJson(response, jsonOut); - replyOnWebSocket(client, jsonOut); - } else if (cmd == "report_time") { - StaticJsonDocument<256> response; - DateTime now = rtc.now(); - response["status"] = "OK"; - response["type"] = "time_response"; - response["timestamp"] = now.unixtime(); // Unix timestamp (seconds since 1970) - // Also include human-readable format for debugging - response["datetime"] = String(now.year()) + "-" + - String(now.month()) + "-" + - String(now.day()) + "T" + - String(now.hour()) + ":" + - String(now.minute()) + ":" + - String(now.second()); - - char jsonOut[256]; - serializeJson(response, jsonOut); - replyOnWebSocket(client, jsonOut); - } else if (cmd == "set_time") { - StaticJsonDocument<256> response; - if (contents.containsKey("timestamp")) { - uint32_t timestamp = contents["timestamp"]; - DateTime newTime = DateTime(timestamp); - rtc.adjust(newTime); - response["status"] = "OK"; - response["type"] = "time_set"; - LOG_DEBUG("Time updated from app."); - } else { - response["status"] = "ERROR"; - response["type"] = "time_set"; - response["message"] = "Missing or Wrong timestamp parameter"; - LOG_ERROR("Set time command missing timestamp parameter"); - } - char jsonOut[256]; - serializeJson(response, jsonOut); - replyOnWebSocket(client, jsonOut); - } */ else if (cmd == "ping") { - StaticJsonDocument<128> response; - response["status"] = "OK"; - response["type"] = "pong"; - char jsonOut[128]; // Create Char Buffer - serializeJson(response, jsonOut); // Serialize to Buffer - replyOnWebSocket(client, jsonOut); // Reply on WebSocket - return; - } else if (cmd == "report_status") { - StaticJsonDocument<256> response; - response["status"] = "OK"; - response["type"] = "current_status"; - response["is_playing"] = player.isPlaying; - response["time_elapsed"] = millis() - player.startTime; - char jsonOut[256]; // Create Char Buffer - serializeJson(response, jsonOut); // Serialize to Buffer - replyOnWebSocket(client, jsonOut); // Reply on WebSocket - } else { - LOG_WARNING("Unknown Command Received"); - } -} diff --git a/vesper/config.h b/vesper/config.h deleted file mode 100644 index 33259d6..0000000 --- a/vesper/config.h +++ /dev/null @@ -1,48 +0,0 @@ -#pragma once - -#define DEV_ID "PV202508190001" - -// Network Config -const char* ssid = "SmartNet"; //Not used with WiFi Manager -const char* password = "smartpass"; //Not used with WiFi Manager -const char* hostname = "ESP32_mqtt_test"; -IPAddress ip(10, 98, 30, 150); -IPAddress gateway(10, 98, 30, 1); -IPAddress subnet(255, 255, 255, 0); -String ap_ssid = String("BellSystems - ") + DEV_ID; -String ap_pass = "password"; - -// Version Controll Settings -const char* versionUrl = "http://10.98.20.10:85/version.txt"; -const char* firmwareUrl = "http://10.98.20.10:85/firmware.bin"; -const float currentVersion = 1.1; - -// NTP Config -const char* ntpServer = "pool.ntp.org"; -const long gmtOffset_sec = 7200; -const int daylightOffset_sec = 3600; - -// MQTT Config -const IPAddress MQTT_HOST(10,98,20,10); -const int MQTT_PORT = 1883; -#define MQTT_USER "esp32_vesper" -#define MQTT_PASS "vesper" - -// Hardware Configuration -#define PCF8574_ADDR 0x24 - - -// SPI W5500 ETHERNET SETUP -#define USE_TWO_ETH_PORTS 0 - -#ifndef ETH_PHY_CS -#define ETH_PHY_TYPE ETH_PHY_W5500 -#define ETH_PHY_ADDR 1 -#define ETH_PHY_CS 5 -#define ETH_PHY_IRQ -1 -#define ETH_PHY_RST -1 -#endif -// SPI pins -#define ETH_SPI_SCK 18 -#define ETH_SPI_MISO 19 -#define ETH_SPI_MOSI 23 diff --git a/vesper/data/melody1.bin b/vesper/data/melody1.bin deleted file mode 100644 index e7ba30f..0000000 Binary files a/vesper/data/melody1.bin and /dev/null differ diff --git a/vesper/data/melody2.bin b/vesper/data/melody2.bin deleted file mode 100644 index 104d0c0..0000000 Binary files a/vesper/data/melody2.bin and /dev/null differ diff --git a/vesper/data/melody3.bin b/vesper/data/melody3.bin deleted file mode 100644 index c204b9b..0000000 Binary files a/vesper/data/melody3.bin and /dev/null differ diff --git a/vesper/data/melody4.bin b/vesper/data/melody4.bin deleted file mode 100644 index 7e4eb7c..0000000 Binary files a/vesper/data/melody4.bin and /dev/null differ diff --git a/vesper/dataLogging.hpp b/vesper/dataLogging.hpp deleted file mode 100644 index 2efe644..0000000 --- a/vesper/dataLogging.hpp +++ /dev/null @@ -1,35 +0,0 @@ -#pragma once - -extern Player player; - -void dataLogging (void * param) { - - // Task Setup - uint16_t bellMaxLoad[16] = {60}; - - // Task Loop - for (;;) { - // Only run if player is playing OR we're still cooling - if (player.isPlaying || coolingActive) { - coolingActive = false; // will be re-enabled if any load > 0 - - for (uint8_t i = 0; i < 16; i++) { - if (bellLoad[i] > 0) { - bellLoad[i]--; - coolingActive = true; // still has heat left - } - - // Optional: check for overload - if (bellLoad[i] > bellMaxLoad[i]) { - Serial.printf("⚠️ Bell %d OVERLOAD! load=%d max=%d\n", - i, bellLoad[i], bellMaxLoad[i]); - player.forceStop(); - // You could also block new strikes here if you want - } - } - } - - vTaskDelay(pdMS_TO_TICKS(1000)); // run every 1s - } - -} diff --git a/vesper/features.nfo b/vesper/features.nfo deleted file mode 100644 index d54538b..0000000 --- a/vesper/features.nfo +++ /dev/null @@ -1,47 +0,0 @@ -Features: - -- WiFi Manager Support (captive portal with hotspot) -- MQTT Support both for Subscribing and Publishing -- WebSocket Support both for Sending and Receiving data -- JSON support to handle messaging (both MQTT and WS) -- SD Card Handling and File Ops -- Melody Class with functions to Play/Pause/Stop etc. -- Main BellEngine that runs autonomously -- Custom Relay Timings (saved on-board) -- Onboard RTC support -- Timekeeper class, with functions to track time and call schedules (deprecated. will be removed) -- OTA Update Functionality with Versioning -- Global logger with Mode Selection (None, Error, Warning, Info, Debug) -- NTP Time Sync -- UDP Listener for Auto Device Discovery -- Ability to report (when asked) the list of Melodies on the SD -- Ability to report the list of melodies on both MQTT and WebSocket -- Counters and Statistics: - - Counter for each bell (counts total times the bell ringed) - - Counter per bell, beats/minute for reliability and thermal protection. Warranty Void scenario. - - -ToDo Features: - -- Add Tower Clock Control Support -- Add reset to Factory Defaults button -- Add manual Sync-Time (for No-Connectivity Setups) - -- Add Bluetooth support -- Add WiFi Direct AP Support -- Add PCB Temperature Sensor Support -- Move PCB DEV ID out of the Firmware and hardcode it on Device (spiffs or something) - -- Counters and Statistics: - - Counter per playback, to figure out which melody is the most played. - This can be implemented on the App itself. Doesn't need to be on the Device. - -- Add the ability to assign bells to a "Master Melody" effectively creating multiple combinations of the same Archetype - -- Add CLEAR/FORMAT SD Card function. - - -ToDo Fixes: - -- When STOP Command is sent, don't print debug for Play Settings. -- Start counting the Loop Pause, when the last loop ENDS. Not when the Loop Time ended. diff --git a/vesper/functions.hpp b/vesper/functions.hpp deleted file mode 100644 index 4e8a54c..0000000 --- a/vesper/functions.hpp +++ /dev/null @@ -1,487 +0,0 @@ -#pragma once - -extern uint16_t bellDurations[16]; - -void loadRelayTimings(); -void saveRelayTimings(); - -// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -// Save file "filename" with data: "data" to the "dirPath" directory of the SD card -void saveFileToSD(const char* dirPath, const char* filename, const char* data) { - // Initialize SD (if not already done) - if (!SD.begin(SD_CS)) { - LOG_ERROR("SD Card not initialized!"); - return; - } - - // Make sure directory exists - if (!SD.exists(dirPath)) { - SD.mkdir(dirPath); - } - - // Build full path - String fullPath = String(dirPath); - if (!fullPath.endsWith("/")) fullPath += "/"; - fullPath += filename; - - File file = SD.open(fullPath.c_str(), FILE_WRITE); - if (!file) { - LOG_ERROR("Failed to open file: %s", fullPath.c_str()); - return; - } - - file.print(data); - file.close(); - LOG_INFO("File %s saved successfully.\n", fullPath.c_str()); -} - -// Saves Relay Durations from RAM, into a file -void saveRelayTimings() { - StaticJsonDocument<512> doc; // Adjust size if needed - - // Populate the JSON object with relay durations - for (uint8_t i = 0; i < 16; i++) { - String key = String("b") + (i + 1); - doc[key] = bellDurations[i]; - } - - char buffer[512]; - size_t len = serializeJson(doc, buffer, sizeof(buffer)); - if (len == 0) { - LOG_ERROR("Failed to serialize JSON."); - return; - } - - const char * path = "/settings"; - const char * filename = "relayTimings.json"; - saveFileToSD(path, filename, buffer); -} - -// Loads Relay Durations from file into RAM (called during boot) -void loadRelayTimings() { - - if (!SD.begin(SD_CS)) { - LOG_ERROR("SD Card not initialized. Using default relay timings."); - return; - } - - File file = SD.open("/settings/relayTimings.json", FILE_READ); - if (!file) { - LOG_ERROR("Settings file not found on SD. Using default relay timings."); - return; - } - // Parse the JSON file - StaticJsonDocument<512> doc; // Adjust size if needed - DeserializationError error = deserializeJson(doc, file); - file.close(); - if (error) { - LOG_ERROR("Failed to parse settings from SD. Using default relay timings."); - return; - } - - // Populate relayDurations array - for (uint8_t i = 0; i < 16; i++) { - String key = String("b") + (i + 1); - if (doc.containsKey(key)) { - bellDurations[i] = doc[key].as(); - LOG_DEBUG("Loaded relay %d duration: %d ms", i + 1, bellDurations[i]); - } - } - -} - -/* Function to sync time with NTP server and update RTC -void syncTimeWithNTP() { - // Connect to Wi-Fi if not already connected - if (WiFi.status() != WL_CONNECTED) { - LOG_DEBUG("Connecting to Wi-Fi..."); - WiFi.begin(ssid, password); - while (WiFi.status() != WL_CONNECTED) { - delay(500); - LOG_DEBUG("."); - } - LOG_DEBUG("\nWi-Fi connected!"); - } - - // Configure NTP - configTime(gmtOffset_sec, daylightOffset_sec, ntpServer); - - // Sync time from NTP server - LOG_DEBUG("Syncing time with NTP server..."); - struct tm timeInfo; - if (!getLocalTime(&timeInfo)) { - LOG_DEBUG("Failed to obtain time from NTP server!"); - return; - } - - // Update RTC with synchronized time - rtc.adjust(DateTime(timeInfo.tm_year + 1900, timeInfo.tm_mon + 1, timeInfo.tm_mday, - timeInfo.tm_hour, timeInfo.tm_min, timeInfo.tm_sec)); - - // Log synchronized time - LOG_INFO("Time synced with NTP server."); - LOG_DEBUG("Synced time: %02d:%02d:%02d, %02d/%02d/%04d", - timeInfo.tm_hour, timeInfo.tm_min, timeInfo.tm_sec, - timeInfo.tm_mday, timeInfo.tm_mon + 1, timeInfo.tm_year + 1900); -} - -*/ - -// Call this function with the Firebase URL and desired local filename -bool downloadFileToSD(const String& url, const String& directory, const String& filename) { - LOG_INFO("HTTP Starting download..."); - - HTTPClient http; - http.begin(url); - int httpCode = http.GET(); - - if (httpCode != HTTP_CODE_OK) { - LOG_ERROR("(HTTP) GET failed, error: %s\n", http.errorToString(httpCode).c_str()); - http.end(); - return false; - } - - if (!SD.begin(SD_CS)) { - LOG_ERROR("SD Card init failed!"); - http.end(); - return false; - } - - // Ensure the directory ends with '/' - String dirPath = directory; - if (!dirPath.endsWith("/")) dirPath += "/"; - - // Create directory if it doesn't exist - SD.mkdir(dirPath.c_str()); - - String fullPath = dirPath + filename; - - File file = SD.open(fullPath.c_str(), FILE_WRITE); - if (!file) { - LOG_ERROR("SD Failed to open file for writing: %s", fullPath.c_str()); - http.end(); - return false; - } - - WiFiClient* stream = http.getStreamPtr(); - uint8_t buffer[1024]; - int bytesRead; - - while (http.connected() && (bytesRead = stream->readBytes(buffer, sizeof(buffer))) > 0) { - file.write(buffer, bytesRead); - } - - file.close(); - http.end(); - LOG_INFO("HTTP Download complete, file saved to: %s", fullPath.c_str()); - return true; -} - -// Returns the list of melodies (the filenames) currently inside the SD Card. -String listFilesAsJson(const char* dirPath) { - if (!SD.begin(SD_CS)) { - LOG_ERROR("SD init failed"); - return "{}"; - } - - File dir = SD.open(dirPath); - if (!dir || !dir.isDirectory()) { - LOG_ERROR("Directory not found"); - return "{}"; - } - - DynamicJsonDocument doc(1024); // Adjust size if needed - JsonArray fileList = doc.createNestedArray("files"); - - File file = dir.openNextFile(); - while (file) { - if (!file.isDirectory()) { - fileList.add(file.name()); - } - file = dir.openNextFile(); - } - - String json; - serializeJson(doc, json); - return json; -} - -// Prints the Steps of a Melody from a file using its filename -void printMelodyFile(const String& filename) { - if (!SD.begin(SD_CS)) { - LOG_ERROR("SD Card not initialized."); - return; - } - - File file = SD.open("/melodies/" + filename, FILE_READ); - if (!file) { - Serial.println("Failed to open Melody file for reading"); - return; - } - - Serial.printf("---- Contents of %s ----\n", filename.c_str()); - - uint16_t step; - int index = 0; - - while (file.available() >= 2) { - uint8_t low = file.read(); - uint8_t high = file.read(); - step = (high << 8) | low; - Serial.printf("Step %5d: 0x%04X (%d)\n", index++, step, step); - } - - file.close(); - Serial.println("---- End of File ----"); -} - -// Prints the contents of a Text file using its filename -void printFileAsText(const String& path, const String& filename) { - if (!SD.begin(SD_CS)) { - LOG_ERROR("SD Card not initialized."); - return; - } - - String fullPath = path; - if (!fullPath.endsWith("/")) fullPath += "/"; - fullPath += filename; - - File file = SD.open(fullPath, FILE_READ); - if (!file) { - Serial.println("Failed to open file for reading"); - return; - } - - Serial.printf("---- Contents of %s ----\n", filename.c_str()); - - while (file.available()) { - String line = file.readStringUntil('\n'); - Serial.println(line); - } - - file.close(); - Serial.println("---- End of File ----"); -} - -// Downloads a new melody from HTTP -bool addMelody(JsonVariant doc) { - LOG_INFO("Trying Download of Melody..."); - const char* url = doc["download_url"]; - const char* filename = doc["melodys_uid"]; - if (downloadFileToSD(url, "/melodies", filename)) { - return true; - } - return false; -} - -// Checks the onboard SD Card for new firmware -void checkFirmwareUpdate() { - if (!SD.begin(SD_CS)) { - Serial.println("SD init failed"); - return; - } - - File updateBin = SD.open("/firmware/update.bin"); - if (!updateBin) { - Serial.println("No update.bin found"); - return; - } - - size_t updateSize = updateBin.size(); - if (updateSize == 0) { - Serial.println("Empty update file"); - updateBin.close(); - return; - } - - Serial.println("Starting firmware update..."); - - if (Update.begin(updateSize)) { - size_t written = Update.writeStream(updateBin); - if (written == updateSize) { - Serial.println("Update written successfully"); - } else { - Serial.printf("Written only %d/%d bytes\n", written, updateSize); - } - - if (Update.end()) { - Serial.println("Update finished!"); - if (Update.isFinished()) { - Serial.println("Update complete. Rebooting..."); - updateBin.close(); - SD.remove("/firmware/update.bin"); // optional cleanup - ESP.restart(); - } else { - Serial.println("Update not complete"); - } - } else { - Serial.printf("Update error: %s\n", Update.errorString()); - } - } else { - Serial.println("Not enough space to begin update"); - } - - updateBin.close(); -} - -// call this in setup() after WiFi is up -void setupUdpDiscovery() { - if (udp.listen(DISCOVERY_PORT)) { - Serial.printf("UDP discovery listening on %u\n", DISCOVERY_PORT); - - udp.onPacket([](AsyncUDPPacket packet) { - // Parse request - String msg = String((const char*)packet.data(), packet.length()); - Serial.printf("UDP from %s:%u -> %s\n", - packet.remoteIP().toString().c_str(), - packet.remotePort(), - msg.c_str()); - - // Minimal: accept plain text or JSON - bool shouldReply = false; - - if (msg.indexOf("discover") >= 0) { - shouldReply = true; - } else { - // Try JSON - StaticJsonDocument<128> req; - DeserializationError err = deserializeJson(req, msg); - if (!err) { - shouldReply = (req["op"] == "discover" && req["svc"] == "vesper"); - } - } - - if (!shouldReply) return; - - // Build reply JSON - StaticJsonDocument<256> doc; - doc["op"] = "discover_reply"; - doc["svc"] = "vesper"; - doc["ver"] = 1; - - doc["name"] = "Proj. Vesper v0.5"; // your device name - doc["id"] = DEV_ID; // stable unique ID if you have one - doc["ip"] = WiFi.localIP().toString(); - doc["ws"] = String("ws://") + WiFi.localIP().toString() + "/ws"; - doc["port"] = 80; // your WS server port - doc["fw"] = "1.2.3"; // firmware version - - String out; - serializeJson(doc, out); - - // Reply directly to the sender’s IP/port - udp.writeTo((const uint8_t*)out.c_str(), out.length(), - packet.remoteIP(), packet.remotePort()); - }); - } else { - Serial.println("Failed to start UDP discovery."); - } -} - - -// UNSUSED FUNCTIONS. - -// void startConfigPortal() { -// WiFi.mode(WIFI_AP); -// WiFi.softAP("Device_Config", "12345678"); -// Serial.println("AP mode started. Connect to 'Device_Config'."); - -// // Serve the configuration page -// server.on("/", HTTP_GET, []() { -// server.send(200, "text/html", generateConfigPageHTML()); -// }); - -// // Handle form submission -// server.on("/save", HTTP_POST, []() { -// ssid = server.arg("ssid"); -// password = server.arg("password"); -// mqttHost.fromString(server.arg("mqttHost")); -// mqttUser = server.arg("mqttUser"); -// mqttPassword = server.arg("mqttPassword"); - -// saveSettings(); // Save new settings to SPIFFS -// server.send(200, "text/plain", "Settings saved! Rebooting..."); -// delay(1000); -// ESP.restart(); -// }); - -// server.begin(); -// } - -// // Save settings to SPIFFS -// void saveSettings() { -// StaticJsonDocument<512> doc; -// doc["ssid"] = ssid; -// doc["password"] = password; -// doc["mqttHost"] = mqttHost.toString(); -// doc["mqttUser"] = mqttUser; -// doc["mqttPassword"] = mqttPassword; - -// File configFile = SPIFFS.open(CONFIG_FILE, "w"); -// if (!configFile) { -// Serial.println("Failed to open config file for writing."); -// return; -// } -// serializeJson(doc, configFile); -// configFile.close(); -// Serial.println("Settings saved to SPIFFS."); -// } - -// // Load settings from SPIFFS -// void loadSettings() { -// if (!SPIFFS.exists(CONFIG_FILE)) { -// Serial.println("Config file not found. Using defaults."); -// return; -// } - -// File configFile = SPIFFS.open(CONFIG_FILE, "r"); -// if (!configFile) { -// Serial.println("Failed to open config file."); -// return; -// } - -// StaticJsonDocument<512> doc; -// DeserializationError error = deserializeJson(doc, configFile); -// if (error) { -// Serial.println("Failed to parse config file."); -// return; -// } - -// ssid = doc["ssid"].as(); -// password = doc["password"].as(); -// mqttHost.fromString(doc["mqttHost"].as()); -// mqttUser = doc["mqttUser"].as(); -// mqttPassword = doc["mqttPassword"].as(); - -// configFile.close(); -// Serial.println("Settings loaded from SPIFFS."); -// } - -// // Generate HTML page for configuration -// String generateConfigPageHTML() { -// String page = R"rawliteral( -// -// -// -//

Device Configuration

-//
-// WiFi SSID:
-// WiFi Password:
-// MQTT Host:
-// MQTT Username:
-// MQTT Password:
-// -//
-// -// -// )rawliteral"; -// return page; -// } diff --git a/vesper/logging.hpp b/vesper/logging.hpp deleted file mode 100644 index fb1b557..0000000 --- a/vesper/logging.hpp +++ /dev/null @@ -1,37 +0,0 @@ -// Define Log Levels -#define LOG_LEVEL_NONE 0 // No logs -#define LOG_LEVEL_ERROR 1 // Errors only -#define LOG_LEVEL_WARNING 2 // Warnings and errors -#define LOG_LEVEL_INFO 3 // Info, warnings, and errors -#define LOG_LEVEL_DEBUG 4 // All logs (full debugging) - -// Set the active log level -#define ACTIVE_LOG_LEVEL LOG_LEVEL_DEBUG - -// Check if the log level is enabled -#define LOG_LEVEL_ENABLED(level) (ACTIVE_LOG_LEVEL >= level) - -// Macro to control logging based on the active level -#if LOG_LEVEL_ENABLED(LOG_LEVEL_ERROR) - #define LOG_ERROR(...) { Serial.print("[ERROR] - "); Serial.printf(__VA_ARGS__); Serial.println(); } -#else - #define LOG_ERROR(...) // No logging if level is not enabled -#endif - -#if LOG_LEVEL_ENABLED(LOG_LEVEL_WARNING) - #define LOG_WARNING(...) { Serial.print("[WARNING] - "); Serial.printf(__VA_ARGS__); Serial.println(); } -#else - #define LOG_WARNING(...) // No logging if level is not enabled -#endif - -#if LOG_LEVEL_ENABLED(LOG_LEVEL_INFO) - #define LOG_INFO(...) { Serial.print("[INFO] - "); Serial.printf(__VA_ARGS__); Serial.println(); } -#else - #define LOG_INFO(...) // No logging if level is not enabled -#endif - -#if LOG_LEVEL_ENABLED(LOG_LEVEL_DEBUG) - #define LOG_DEBUG(...) { Serial.print("[DEBUG] - "); Serial.printf(__VA_ARGS__); Serial.println(); } -#else - #define LOG_DEBUG(...) // No logging if level is not enabled -#endif diff --git a/vesper/ota.hpp b/vesper/ota.hpp deleted file mode 100644 index 7c113a6..0000000 --- a/vesper/ota.hpp +++ /dev/null @@ -1,65 +0,0 @@ -void checkForUpdates(); -void performOTA(); - -void checkForUpdates() { - Serial.println("Checking for firmware updates..."); - - // Step 1: Check the current version on the server - HTTPClient http; - http.begin(versionUrl); - int httpCode = http.GET(); - - if (httpCode == HTTP_CODE_OK) { - String newVersionStr = http.getString(); - float newVersion = newVersionStr.toFloat(); - - Serial.printf("Current version: %.1f, Available version: %.1f\n", currentVersion, newVersion); - - // Step 2: Compare the version - if (newVersion > currentVersion) { - Serial.println("New version available. Starting update..."); - performOTA(); // Perform the OTA update if a new version is found - } else { - Serial.println("No new version available."); - } - } else { - Serial.printf("Failed to retrieve version. HTTP error code: %d\n", httpCode); - } - http.end(); -} - -void performOTA() { - HTTPClient http; - http.begin(firmwareUrl); - int httpCode = http.GET(); - - if (httpCode == HTTP_CODE_OK) { - int contentLength = http.getSize(); - if (contentLength > 0) { - bool canBegin = Update.begin(contentLength); - if (canBegin) { - Serial.println("Starting OTA update..."); - WiFiClient *client = http.getStreamPtr(); - size_t written = Update.writeStream(*client); - if (written == contentLength) { - Serial.println("Update complete"); - if (Update.end()) { - Serial.println("Update successfully finished. Rebooting..."); - ESP.restart(); // Reboot to apply the update - } else { - Serial.printf("Update failed: %s\n", Update.errorString()); - } - } else { - Serial.println("Update failed: Written size mismatch."); - } - } else { - Serial.println("Not enough space for update."); - } - } else { - Serial.println("Firmware file is empty."); - } - } else { - Serial.printf("Firmware HTTP error code: %d\n", httpCode); - } - http.end(); -} diff --git a/vesper/src/BellEngine/BellEngine.cpp b/vesper/src/BellEngine/BellEngine.cpp new file mode 100644 index 0000000..fa36a09 --- /dev/null +++ b/vesper/src/BellEngine/BellEngine.cpp @@ -0,0 +1,389 @@ +/* + * ═══════════════════════════════════════════════════════════════════════════════════ + * BELLENGINE.CPP - High-Precision Bell Timing Engine Implementation + * ═══════════════════════════════════════════════════════════════════════════════════ + * + * This file contains the implementation of the BellEngine class - the core + * precision timing system that controls bell activation with microsecond accuracy. + * + * 🔥 CRITICAL PERFORMANCE SECTION 🔥 + * + * The code in this file is performance-critical and runs on a dedicated + * FreeRTOS task with maximum priority on Core 1. Any modifications should + * be thoroughly tested for timing impact. + * + * 📋 VERSION: 2.0 (Rewritten for modular architecture) + * 📅 DATE: 2025 + * 👨‍💻 AUTHOR: Advanced Bell Systems + * ═══════════════════════════════════════════════════════════════════════════════════ + */ + +// ═════════════════════════════════════════════════════════════════════════════════ +// DEPENDENCY INCLUDES - Required system components +// ═════════════════════════════════════════════════════════════════════════════════ +#include "BellEngine.hpp" // Header file with class definition +#include "../Player/Player.hpp" // Melody playback controller +#include "../ConfigManager/ConfigManager.hpp" // Configuration and settings +#include "../Telemetry/Telemetry.hpp" // System monitoring and analytics +#include "../OutputManager/OutputManager.hpp" // Hardware abstraction layer +#include "../Communication/Communication.hpp" // Communication system for notifications + +// ═════════════════════════════════════════════════════════════════════════════════ +// CONSTRUCTOR & DESTRUCTOR IMPLEMENTATION +// ═════════════════════════════════════════════════════════════════════════════════ + +/** + * @brief Constructor - Initialize BellEngine with dependency injection + * + * Sets up all dependency references and initializes the OutputManager integration. + * Note: The OutputManager now handles all relay duration tracking, providing + * clean separation of concerns and hardware abstraction. + */ +BellEngine::BellEngine(Player& player, ConfigManager& configManager, Telemetry& telemetry, OutputManager& outputManager) + : _player(player) // Reference to melody playback controller + , _configManager(configManager) // Reference to configuration manager + , _telemetry(telemetry) // Reference to system monitoring + , _outputManager(outputManager) // 🔥 Reference to hardware abstraction layer + , _communicationManager(nullptr) { // Initialize communication manager to nullptr + + // 🏗️ ARCHITECTURAL NOTE: + // OutputManager now handles all relay duration tracking automatically! + // This provides clean separation of concerns and hardware abstraction. +} + +/** + * @brief Destructor - Ensures safe cleanup + * + * Automatically calls emergencyShutdown() to ensure all relays are turned off + * and the system is in a safe state before object destruction. + */ +BellEngine::~BellEngine() { + emergencyShutdown(); // 🚑 Ensure safe shutdown on destruction +} + +// ═════════════════════════════════════════════════════════════════════════════════ +// CORE INITIALIZATION IMPLEMENTATION +// ═════════════════════════════════════════════════════════════════════════════════ + +/** + * @brief Initialize the BellEngine system + * + * Creates the high-priority timing task on Core 1 with maximum priority. + * This task provides the microsecond-precision timing that makes the + * bell system so accurate and reliable. + * + */ +void BellEngine::begin() { + LOG_DEBUG("Initializing BellEngine with high-precision timing"); + + // Create engine task with HIGHEST priority on dedicated Core 1 + // This ensures maximum performance and timing precision + xTaskCreatePinnedToCore( + engineTask, // 📋 Task function pointer + "BellEngine", // 🏷️ Task name for debugging + 12288, // 💾 Stack size (12KB - increased for safety) + this, // 🔗 Parameter (this instance) + 6, // ⚡ HIGHEST Priority (0-7, 7 is highest) + &_engineTaskHandle, // 💼 Task handle storage + 1 // 💻 Pin to Core 1 (dedicated) + ); + + LOG_INFO("BellEngine initialized - Ready for MAXIMUM PRECISION! 🎯"); +} + +/** + * @brief Set Communication manager reference for bell notifications + */ +void BellEngine::setCommunicationManager(Communication* commManager) { + _communicationManager = commManager; + LOG_DEBUG("BellEngine: Communication manager %s", + commManager ? "connected" : "disconnected"); +} + +// ═════════════════════════════════════════════════════════════════════════════════ +// ENGINE CONTROL IMPLEMENTATION (Thread-safe) +// ═════════════════════════════════════════════════════════════════════════════════ + +/** + * @brief Start the precision timing engine + * + * Activates the high-precision timing loop. Requires melody data to be + * loaded via setMelodyData() before calling. Uses atomic operations + * for thread-safe state management. + * + * @note Will log error and return if no melody data is available + */ +void BellEngine::start() { + // Validate that melody data is ready before starting + if (!_melodyDataReady.load()) { + LOG_ERROR("Cannot start BellEngine: No melody data loaded"); + return; // ⛔ Early exit if no melody data + } + + LOG_INFO("🚀 BellEngine IGNITION - Starting precision playback"); + _emergencyStop.store(false); // ✅ Clear any emergency stop state + _engineRunning.store(true); // ✅ Activate the engine atomically +} + +void BellEngine::stop() { + LOG_INFO("BellEngine stopping gracefully"); + _engineRunning.store(false); +} + +void BellEngine::emergencyStop() { + LOG_INFO("🛑 EMERGENCY STOP ACTIVATED"); + _emergencyStop.store(true); + _engineRunning.store(false); + emergencyShutdown(); +} + +void BellEngine::setMelodyData(const std::vector& melodySteps) { + portENTER_CRITICAL(&_melodyMutex); + _melodySteps = melodySteps; + _melodyDataReady.store(true); + portEXIT_CRITICAL(&_melodyMutex); + LOG_DEBUG("BellEngine loaded melody: %d steps", melodySteps.size()); +} + +void BellEngine::clearMelodyData() { + portENTER_CRITICAL(&_melodyMutex); + _melodySteps.clear(); + _melodyDataReady.store(false); + portEXIT_CRITICAL(&_melodyMutex); + LOG_DEBUG("BellEngine melody data cleared"); +} + +// ================== CRITICAL TIMING SECTION ================== +// This is where the magic happens! Maximum precision required ! + +void BellEngine::engineTask(void* parameter) { + BellEngine* engine = static_cast(parameter); + LOG_DEBUG("🔥 BellEngine task started on Core %d with MAXIMUM priority", xPortGetCoreID()); + + while (true) { + if (engine->_engineRunning.load() && !engine->_emergencyStop.load()) { + engine->engineLoop(); + } else { + // Low-power wait when not running + vTaskDelay(pdMS_TO_TICKS(10)); + } + } +} + +void BellEngine::engineLoop() { + uint64_t loopStartTime = getMicros(); + + // Safety check. Stop if Emergency Stop is Active + if (_emergencyStop.load()) { + emergencyShutdown(); + return; + } + + playbackLoop(); + + // Pause handling AFTER complete loop - never interrupt mid-melody! + while (_player.isPaused && _player.isPlaying && !_player.hardStop) { + LOG_DEBUG("⏸️ Pausing between melody loops"); + vTaskDelay(pdMS_TO_TICKS(10)); // Wait during pause + } + + uint64_t loopEndTime = getMicros(); + uint32_t loopTime = (uint32_t)(loopEndTime - loopStartTime); +} + +void BellEngine::playbackLoop() { + // Check if player wants us to run + if (!_player.isPlaying || _player.hardStop) { + _engineRunning.store(false); + return; + } + + // Get melody data safely + portENTER_CRITICAL(&_melodyMutex); + auto melodySteps = _melodySteps; // Fast copy + portEXIT_CRITICAL(&_melodyMutex); + + if (melodySteps.empty()) { + LOG_ERROR("Empty melody in playback loop!"); + return; + } + + LOG_DEBUG("🎵 Starting melody loop (%d steps)", melodySteps.size()); + + // CRITICAL TIMING LOOP - Complete the entire melody without interruption + for (uint16_t note : melodySteps) { + // Emergency exit check (only emergency stops can interrupt mid-loop) + if (_emergencyStop.load() || _player.hardStop) { + LOG_DEBUG("Emergency exit from playback loop"); + return; + } + + // Activate note with MAXIMUM PRECISION + activateNote(note); + + // Precise timing delay + uint32_t tempoMicros = _player.speed * 1000; // Convert ms to microseconds + preciseDelay(tempoMicros); + } + + // Mark segment completion and notify Player + _player.segmentCmpltTime = millis(); + _player.onMelodyLoopCompleted(); // 🔥 Notify Player that melody actually finished! + LOG_DEBUG("🎵 Melody loop completed with PRECISION"); + +} + +void BellEngine::activateNote(uint16_t note) { + // Track which bells we've already added to prevent duplicates + bool bellFired[16] = {false}; + std::vector> bellDurations; // For batch firing + std::vector firedBellIndices; // Track which bells were fired for notification + + // Iterate through each bit position (note index) + for (uint8_t noteIndex = 0; noteIndex < 16; noteIndex++) { + if (note & (1 << noteIndex)) { + // Get bell mapping + uint8_t bellIndex = _player.noteAssignments[noteIndex]; + + // Skip if no bell assigned + if (bellIndex == 0) continue; + + // Convert to 0-based indexing + bellIndex = bellIndex - 1; + + // Additional safety check to prevent underflow crashes + if (bellIndex >= 255) { + LOG_ERROR("🚨 UNDERFLOW ERROR: bellIndex underflow for noteIndex %d", noteIndex); + continue; + } + + // Bounds check (CRITICAL SAFETY) + if (bellIndex >= 16) { + LOG_ERROR("🚨 BOUNDS ERROR: bellIndex %d >= 16", bellIndex); + continue; + } + + // Check for duplicate bell firing in this note + if (bellFired[bellIndex]) { + LOG_DEBUG("⚠️ DUPLICATE BELL: Skipping duplicate firing of bell %d for note %d", bellIndex, noteIndex); + continue; + } + + // Check if bell is configured (OutputManager will validate this) + uint8_t physicalOutput = _outputManager.getPhysicalOutput(bellIndex); + if (physicalOutput == 255) { + LOG_DEBUG("⚠️ UNCONFIGURED: Bell %d not configured, skipping", bellIndex); + continue; + } + + // Mark this bell as fired + bellFired[bellIndex] = true; + + // Get duration from config + uint16_t durationMs = _configManager.getBellDuration(bellIndex); + + // Add to batch firing list + bellDurations.push_back({bellIndex, durationMs}); + + // Add to notification list (convert to 1-indexed for display) + firedBellIndices.push_back(bellIndex + 1); + + // Record telemetry + _telemetry.recordBellStrike(bellIndex); + + LOG_VERBOSE("🔨 STRIKE! Note:%d → Bell:%d for %dms", noteIndex, bellIndex, durationMs); + } + } + + // 🚀 FIRE ALL BELLS SIMULTANEOUSLY! + if (!bellDurations.empty()) { + _outputManager.fireOutputsBatchForDuration(bellDurations); + LOG_VERBOSE("🔥🔥 BATCH FIRED %d bells SIMULTANEOUSLY!", bellDurations.size()); + + // 🔔 NOTIFY WEBSOCKET CLIENTS OF BELL DINGS! + notifyBellsFired(firedBellIndices); + } +} + +void BellEngine::preciseDelay(uint32_t microseconds) { + uint64_t start = getMicros(); + uint64_t target = start + microseconds; + + // For delays > 1ms, use task delay for most of it + if (microseconds > 1000) { + uint32_t taskDelayMs = (microseconds - 500) / 1000; // Leave 500µs for busy wait + vTaskDelay(pdMS_TO_TICKS(taskDelayMs)); + } + + // Busy wait for final precision + while (getMicros() < target) { + // Tight loop for maximum precision + asm volatile("nop"); + } +} + +void BellEngine::emergencyShutdown() { + LOG_INFO("🚨 EMERGENCY SHUTDOWN - Using OutputManager"); + _outputManager.emergencyShutdown(); +} + +void BellEngine::notifyBellsFired(const std::vector& bellIndices) { + if (!_communicationManager || bellIndices.empty()) { + + return; // No communication manager or no bells fired + } + + try { + // Create notification message + StaticJsonDocument<256> dingMsg; + dingMsg["status"] = "INFO"; + dingMsg["type"] = "ding"; + + // Create payload array with fired bell numbers (1-indexed for display) + JsonArray bellsArray = dingMsg["payload"].to(); + for (uint8_t bellIndex : bellIndices) { + bellsArray.add(bellIndex); // Already converted to 1-indexed in activateNote + } + + // Send notification to WebSocket clients only (not MQTT) + _communicationManager->broadcastToAllWebSocketClients(dingMsg); + + LOG_DEBUG("🔔 DING notification sent for %d bells", bellIndices.size()); + + } catch (...) { + LOG_ERROR("Failed to send ding notification"); + } +} + +// ════════════════════════════════════════════════════════════════════════════ +// HEALTH CHECK IMPLEMENTATION +// ════════════════════════════════════════════════════════════════════════════ + +bool BellEngine::isHealthy() const { + // Check if engine task is created and running + if (_engineTaskHandle == NULL) { + LOG_DEBUG("BellEngine: Unhealthy - Task not created"); + return false; + } + + // Check if task is still alive + eTaskState taskState = eTaskGetState(_engineTaskHandle); + if (taskState == eDeleted || taskState == eInvalid) { + LOG_DEBUG("BellEngine: Unhealthy - Task deleted or invalid"); + return false; + } + + // Check if we're not in emergency stop state + if (_emergencyStop.load()) { + LOG_DEBUG("BellEngine: Unhealthy - Emergency stop active"); + return false; + } + + // Check if OutputManager is properly connected and healthy + if (!_outputManager.isInitialized()) { + LOG_DEBUG("BellEngine: Unhealthy - OutputManager not initialized"); + return false; + } + + return true; +} diff --git a/vesper/src/BellEngine/BellEngine.hpp b/vesper/src/BellEngine/BellEngine.hpp new file mode 100644 index 0000000..d2baf88 --- /dev/null +++ b/vesper/src/BellEngine/BellEngine.hpp @@ -0,0 +1,362 @@ +/* + * ═══════════════════════════════════════════════════════════════════════════════════ + * BELLENGINE.HPP - High-Precision Bell Timing Engine + * ═══════════════════════════════════════════════════════════════════════════════════ + * + * 🔥 THE HEART OF THE VESPER SYSTEM 🔥 + * + * This is the core precision timing engine that controls bell activation with + * microsecond accuracy. It runs on a dedicated FreeRTOS task with maximum + * priority on Core 1 to ensure no timing interference. + * + * 🏗️ ARCHITECTURE: + * • Completely thread-safe with atomic operations + * • Hardware-agnostic through OutputManager abstraction + * • High-precision timing using ESP32 microsecond timers + * • Comprehensive performance monitoring + * • Emergency stop mechanisms for safety + * + * ⚡ PERFORMANCE FEATURES: + * • Dedicated Core 1 execution (no interruption) + * • Priority 6 FreeRTOS task (highest available) + * • Microsecond-precision delays + * • Atomic state management + * • Lock-free melody data handling + * + * 🔒 THREAD SAFETY: + * All public methods are thread-safe. Melody data is protected by + * critical sections, and engine state uses atomic operations. + * + * 🚑 EMERGENCY FEATURES: + * • Instant emergency stop capability + * • Hardware shutdown through OutputManager + * • Safe state transitions + * • Graceful task cleanup + * + * 📋 VERSION: 2.0 (Rewritten for modular architecture) + * 📅 DATE: 2025 + * 👨‍💻 AUTHOR: Advanced Bell Systems + * ═══════════════════════════════════════════════════════════════════════════════════ + */ + +#pragma once + +// ═════════════════════════════════════════════════════════════════════════════════ +// SYSTEM INCLUDES - Core libraries for high-precision timing +// ═════════════════════════════════════════════════════════════════════════════════ +#include // Arduino core functionality +#include // STL vector for melody data storage +#include // Atomic operations for thread safety +#include "freertos/FreeRTOS.h" // FreeRTOS kernel +#include "freertos/task.h" // FreeRTOS task management +#include "esp_timer.h" // ESP32 high-precision timers +#include "../Logging/Logging.hpp" // Centralized logging system + +// ═════════════════════════════════════════════════════════════════════════════════ +// FORWARD DECLARATIONS - Dependencies injected at runtime +// ═════════════════════════════════════════════════════════════════════════════════ +// These classes are injected via constructor to maintain clean architecture +class Player; // Melody playback controller +class ConfigManager; // Configuration and settings management +class Telemetry; // System monitoring and analytics +class OutputManager; // Hardware abstraction layer +class Communication; // Communication system for notifications + +// ═════════════════════════════════════════════════════════════════════════════════ +// ARCHITECTURE MIGRATION NOTE +// ═════════════════════════════════════════════════════════════════════════════════ +// BellEngine no longer tracks relay durations - OutputManager handles that now! +// This provides clean separation of concerns and hardware abstraction. + +/** + * @class BellEngine + * @brief High-precision bell timing and control engine + * + * The BellEngine is the core component responsible for microsecond-precision + * bell activation timing. It runs on a dedicated FreeRTOS task with maximum + * priority to ensure no timing interference from other system components. + * + * Key features: + * - Thread-safe operation with atomic state management + * - Hardware-agnostic through OutputManager abstraction + * - Microsecond-precision timing using ESP32 timers + * - Comprehensive performance monitoring + * - Emergency stop capabilities + * - Dedicated Core 1 execution for maximum performance + */ +class BellEngine { +public: + // ═══════════════════════════════════════════════════════════════════════════════ + // CONSTRUCTOR & DESTRUCTOR + // ═══════════════════════════════════════════════════════════════════════════════ + + /** + * @brief Construct BellEngine with dependency injection + * @param player Reference to melody player for coordination + * @param configManager Reference to configuration manager for settings + * @param telemetry Reference to telemetry system for monitoring + * @param outputManager Reference to hardware abstraction layer + * + * Uses dependency injection pattern for clean architecture and testability. + * All dependencies must be valid for the lifetime of this object. + */ + BellEngine(Player& player, ConfigManager& configManager, Telemetry& telemetry, OutputManager& outputManager); + + /** + * @brief Destructor - ensures clean shutdown + * + * Automatically calls emergencyShutdown() to ensure all relays are + * turned off and tasks are properly cleaned up. + */ + ~BellEngine(); + + // ═══════════════════════════════════════════════════════════════════════════════ + // CORE INITIALIZATION + // ═══════════════════════════════════════════════════════════════════════════════ + + /** + * @brief Initialize the BellEngine system + * + * Creates the high-priority timing task on Core 1 with maximum priority. + * This task will remain dormant until start() is called, but the + * infrastructure is set up for immediate precision timing. + * + * Task configuration: + * - Priority: 6 (highest available) + * - Core: 1 (dedicated) + * - Stack: 12KB (increased for safety) + * - Name: "BellEngine" + * + */ + void begin(); + + // ═══════════════════════════════════════════════════════════════════════════════ + // ENGINE CONTROL (THREAD-SAFE) + // ═══════════════════════════════════════════════════════════════════════════════ + +/** + * @brief Start the precision timing engine + * + * Activates the high-precision timing loop. Requires melody data to be + * loaded via setMelodyData() before calling. Uses atomic operations + * for thread-safe state management. + * + * @note Will log error and return if no melody data is available + */ + void start(); + + /** + * @brief Stop the timing engine gracefully + * + * Signals the engine to stop at the next safe opportunity. + * Uses atomic flag for thread-safe coordination. + */ + void stop(); + + /** + * @brief Emergency stop - immediate shutdown + * + * Immediately stops all bell activity and shuts down the engine. + * Calls emergencyShutdown() to ensure all relays are turned off. + * Use this for safety-critical situations. + */ + void emergencyStop(); + + // ═══════════════════════════════════════════════════════════════════════════════ + // MELODY DATA INTERFACE (Called by Player) + // ═══════════════════════════════════════════════════════════════════════════════ + + /** + * @brief Load melody data for playback + * @param melodySteps Vector of melody step data (each uint16_t represents note activations) + * + * Thread-safe method to load melody data. Uses critical sections to ensure + * data consistency. The melody data is copied internally for safety. + * + * @note Each melody step is a bitmask where each bit represents a note/bell + */ + void setMelodyData(const std::vector& melodySteps); + + /** + * @brief Clear loaded melody data + * + * Thread-safe method to clear melody data and mark engine as not ready. + * Useful for cleanup and preparing for new melody loading. + */ + void clearMelodyData(); + + // ═══════════════════════════════════════════════════════════════════════════════ + // STATUS QUERIES (Thread-safe) + // ═══════════════════════════════════════════════════════════════════════════════ + + /** + * @brief Check if engine is currently running + * @return true if engine is actively processing melody data + * + * Thread-safe atomic read operation. + */ + bool isRunning() const { return _engineRunning.load(); } + + /** + * @brief Check if engine is in emergency stop state + * @return true if emergency stop has been activated + * + * Thread-safe atomic read operation. + */ + bool isEmergencyStopped() const { return _emergencyStop.load(); } + + // ═══════════════════════════════════════════════════════════════════════════════ + // PERFORMANCE MONITORING + // ═══════════════════════════════════════════════════════════════════════════════ + + /** + * @brief Get maximum measured loop execution time + * @return Maximum loop time in microseconds + * + * Useful for performance tuning and ensuring timing requirements are met. + */ + uint32_t getMaxLoopTime() const { return _maxLoopTime; } + + /** + * @brief Set Communication manager reference for bell notifications + * @param commManager Pointer to communication manager + */ + void setCommunicationManager(Communication* commManager); + + // ═══════════════════════════════════════════════════════════════════════════════ + // HEALTH CHECK METHOD + // ═══════════════════════════════════════════════════════════════════════════════ + + /** @brief Check if BellEngine is in healthy state */ + bool isHealthy() const; + + +private: + // ═══════════════════════════════════════════════════════════════════════════════ + // DEPENDENCY INJECTION - References to external systems + // ═══════════════════════════════════════════════════════════════════════════════ + Player& _player; // Melody playback controller for coordination + ConfigManager& _configManager; // Configuration manager for bell settings + Telemetry& _telemetry; // System monitoring and strike tracking + OutputManager& _outputManager; // 🔥 Hardware abstraction layer for relay control + Communication* _communicationManager; // Communication system for bell notifications + + // ═══════════════════════════════════════════════════════════════════════════════ + // ENGINE STATE (Atomic for thread safety) + // ═══════════════════════════════════════════════════════════════════════════════ + std::atomic _engineRunning{false}; // Engine active state flag + std::atomic _emergencyStop{false}; // Emergency stop flag + std::atomic _melodyDataReady{false}; // Melody data loaded and ready flag + + // ═══════════════════════════════════════════════════════════════════════════════ + // MELODY DATA (Protected copy for thread safety) + // ═══════════════════════════════════════════════════════════════════════════════ + std::vector _melodySteps; // Local copy of melody data for safe access + portMUX_TYPE _melodyMutex = portMUX_INITIALIZER_UNLOCKED; // Critical section protection for melody data + + // ═══════════════════════════════════════════════════════════════════════════════ + // FREERTOS TASK MANAGEMENT + // ═══════════════════════════════════════════════════════════════════════════════ + TaskHandle_t _engineTaskHandle = NULL; // Handle to high-priority timing task + + // ═══════════════════════════════════════════════════════════════════════════════ + // PERFORMANCE MONITORING VARIABLES + // ═══════════════════════════════════════════════════════════════════════════════ + uint32_t _maxLoopTime = 0; // Maximum measured loop execution time (microseconds) // Average loop execution time (microseconds) + uint32_t _loopCount = 0; // Total number of loops executed + uint64_t _totalLoopTime = 0; // Cumulative loop execution time (microseconds) + + // ═══════════════════════════════════════════════════════════════════════════════ + // HIGH-PRECISION TIMING + // ═══════════════════════════════════════════════════════════════════════════════ + esp_timer_handle_t _precisionTimer = nullptr; // ESP32 high-precision timer (currently unused) + + // ═══════════════════════════════════════════════════════════════════════════════ + // CORE ENGINE FUNCTIONS (CRITICAL TIMING) + // ═══════════════════════════════════════════════════════════════════════════════ + + /** + * @brief Main engine timing loop + * + * Executes one complete timing cycle with performance monitoring. + * Called continuously by the engine task. + */ + void engineLoop(); + + /** + * @brief Execute melody playback with precision timing + * + * Processes all melody steps with microsecond-precision delays. + * Handles pause states and emergency stops. + */ + void playbackLoop(); + + /** + * @brief Activate bells for a specific note + * @param note Bitmask representing which bells to activate + * + * Decodes the note bitmask and activates corresponding bells through + * the OutputManager with configured durations. + */ + void activateNote(uint16_t note); + + // ═══════════════════════════════════════════════════════════════════════════════ + // STATIC TASK FUNCTIONS + // ═══════════════════════════════════════════════════════════════════════════════ + + /** + * @brief Static entry point for FreeRTOS task + * @param parameter Pointer to BellEngine instance + * + * Static function required by FreeRTOS. Casts parameter back to + * BellEngine instance and runs the main timing loop. + */ + static void engineTask(void* parameter); + + // ═══════════════════════════════════════════════════════════════════════════════ + // TIMING UTILITIES + // ═══════════════════════════════════════════════════════════════════════════════ + + /** + * @brief Get current time in microseconds + * @return Current time from ESP32 high-precision timer + * + * Inline function for maximum performance in timing-critical code. + */ + uint64_t getMicros() const { return esp_timer_get_time(); } + + /** + * @brief Perform microsecond-precision delay + * @param microseconds Delay duration in microseconds + * + * Combines FreeRTOS task delay with busy-waiting for maximum precision. + * Uses task delay for bulk time, then busy-wait for final precision. + */ + void preciseDelay(uint32_t microseconds); + + + // ═══════════════════════════════════════════════════════════════════════════════ + // SAFETY FUNCTIONS + // ═══════════════════════════════════════════════════════════════════════════════ + + /** + * @brief Emergency hardware shutdown + * + * Immediately shuts down all relays through OutputManager. + * Called during emergency stops and destructor cleanup. + * Ensures safe state regardless of current engine state. + */ + void emergencyShutdown(); + + /** + * @brief Notify WebSocket clients of fired bells + * @param bellIndices Vector of bell indices that were fired (1-indexed) + * + * Sends INFO/ding message to WebSocket clients only (not MQTT) + */ + void notifyBellsFired(const std::vector& bellIndices); +}; + +// ═══════════════════════════════════════════════════════════════════════════════════ +// END OF BELLENGINE.HPP +// ═══════════════════════════════════════════════════════════════════════════════════ diff --git a/vesper/src/ClientManager/ClientManager.cpp b/vesper/src/ClientManager/ClientManager.cpp new file mode 100644 index 0000000..65231c6 --- /dev/null +++ b/vesper/src/ClientManager/ClientManager.cpp @@ -0,0 +1,167 @@ +#include "ClientManager.hpp" +#include "../Logging/Logging.hpp" + +ClientManager::ClientManager() { + LOG_INFO("Client Manager Component - Initialized"); +} + +ClientManager::~ClientManager() { + _clients.clear(); + LOG_INFO("Client Manager Component - Destroyed"); +} + +void ClientManager::addClient(AsyncWebSocketClient* client, DeviceType deviceType) { + if (!isValidClient(client)) { + LOG_ERROR("Cannot add invalid client"); + return; + } + + uint32_t clientId = client->id(); + _clients[clientId] = ClientInfo(client, deviceType); + + LOG_INFO("Client #%u added as %s device", clientId, deviceTypeToString(deviceType)); +} + +void ClientManager::removeClient(uint32_t clientId) { + auto it = _clients.find(clientId); + if (it != _clients.end()) { + LOG_INFO("Client #%u removed (%s device)", clientId, + deviceTypeToString(it->second.deviceType)); + _clients.erase(it); + } +} + +void ClientManager::updateClientType(uint32_t clientId, DeviceType deviceType) { + auto it = _clients.find(clientId); + if (it != _clients.end()) { + DeviceType oldType = it->second.deviceType; + it->second.deviceType = deviceType; + LOG_INFO("Client #%u type updated from %s to %s", clientId, + deviceTypeToString(oldType), deviceTypeToString(deviceType)); + } +} + +void ClientManager::updateClientLastSeen(uint32_t clientId) { + auto it = _clients.find(clientId); + if (it != _clients.end()) { + it->second.lastSeen = millis(); + } +} + +bool ClientManager::isClientConnected(uint32_t clientId) const { + auto it = _clients.find(clientId); + if (it != _clients.end()) { + return it->second.isConnected && + isValidClient(it->second.client); + } + return false; +} + +ClientManager::DeviceType ClientManager::getClientType(uint32_t clientId) const { + auto it = _clients.find(clientId); + return (it != _clients.end()) ? it->second.deviceType : DeviceType::UNKNOWN; +} + +ClientManager::ClientInfo* ClientManager::getClientInfo(uint32_t clientId) { + auto it = _clients.find(clientId); + return (it != _clients.end()) ? &it->second : nullptr; +} + +bool ClientManager::sendToClient(uint32_t clientId, const String& message) { + auto it = _clients.find(clientId); + if (it != _clients.end() && isValidClient(it->second.client)) { + it->second.client->text(message); + updateClientLastSeen(clientId); + LOG_DEBUG("Message sent to client #%u: %s", clientId, message.c_str()); + return true; + } + + LOG_WARNING("Failed to send message to client #%u - client not found or invalid", clientId); + return false; +} + +void ClientManager::sendToMasterClients(const String& message) { + int count = 0; + for (auto& pair : _clients) { + if (pair.second.deviceType == DeviceType::MASTER && + isValidClient(pair.second.client)) { + pair.second.client->text(message); + updateClientLastSeen(pair.first); + count++; + } + } + LOG_DEBUG("Message sent to %d master client(s): %s", count, message.c_str()); +} + +void ClientManager::sendToSecondaryClients(const String& message) { + int count = 0; + for (auto& pair : _clients) { + if (pair.second.deviceType == DeviceType::SECONDARY && + isValidClient(pair.second.client)) { + pair.second.client->text(message); + updateClientLastSeen(pair.first); + count++; + } + } + LOG_DEBUG("Message sent to %d secondary client(s): %s", count, message.c_str()); +} + +void ClientManager::broadcastToAll(const String& message) { + int count = 0; + for (auto& pair : _clients) { + if (isValidClient(pair.second.client)) { + pair.second.client->text(message); + updateClientLastSeen(pair.first); + count++; + } + } + LOG_DEBUG("Message broadcasted to %d client(s): %s", count, message.c_str()); +} + +void ClientManager::cleanupDisconnectedClients() { + auto it = _clients.begin(); + while (it != _clients.end()) { + if (!isValidClient(it->second.client)) { + LOG_DEBUG("Cleaning up disconnected client #%u", it->first); + it->second.isConnected = false; + it = _clients.erase(it); + } else { + ++it; + } + } +} + +String ClientManager::getClientListJson() const { + StaticJsonDocument<512> doc; + JsonArray clients = doc.createNestedArray("clients"); + + for (const auto& pair : _clients) { + JsonObject client = clients.createNestedObject(); + client["id"] = pair.first; + client["type"] = deviceTypeToString(pair.second.deviceType); + client["connected"] = isValidClient(pair.second.client); + client["last_seen"] = pair.second.lastSeen; + } + + String result; + serializeJson(doc, result); + return result; +} + +const char* ClientManager::deviceTypeToString(DeviceType type) const { + switch (type) { + case DeviceType::MASTER: return "master"; + case DeviceType::SECONDARY: return "secondary"; + default: return "unknown"; + } +} + +ClientManager::DeviceType ClientManager::stringToDeviceType(const String& typeStr) const { + if (typeStr == "master") return DeviceType::MASTER; + if (typeStr == "secondary") return DeviceType::SECONDARY; + return DeviceType::UNKNOWN; +} + +bool ClientManager::isValidClient(AsyncWebSocketClient* client) const { + return client != nullptr && client->status() == WS_CONNECTED; +} diff --git a/vesper/src/ClientManager/ClientManager.hpp b/vesper/src/ClientManager/ClientManager.hpp new file mode 100644 index 0000000..a83aeef --- /dev/null +++ b/vesper/src/ClientManager/ClientManager.hpp @@ -0,0 +1,100 @@ +/* + * ═══════════════════════════════════════════════════════════════════════════════════ + * CLIENTMANAGER.HPP - WebSocket Client Management System + * ═══════════════════════════════════════════════════════════════════════════════════ + * + * 📱 MULTI-CLIENT WEBSOCKET MANAGEMENT 📱 + * + * This class manages multiple WebSocket clients with device type identification + * and provides targeted messaging capabilities for master/secondary device roles. + * + * 🏗️ ARCHITECTURE: + * • Track multiple connected WebSocket clients + * • Identify clients as "master" or "secondary" devices + * • Provide targeted and broadcast messaging capabilities + * • Automatic cleanup of disconnected clients + * + * 📱 CLIENT TYPES: + * • Master: Primary control device (usually main Android app) + * • Secondary: Additional control devices (up to 5 total devices) + * + * 🔄 CLIENT LIFECYCLE: + * • Auto-registration on WebSocket connect + * • Device type identification via initial handshake + * • Automatic cleanup on disconnect + * • Connection state monitoring + * + * 📡 MESSAGING FEATURES: + * • Send to specific client by WebSocket ID + * • Send to master/secondary device groups + * • Broadcast to all connected clients + * • Message delivery confirmation + * + * 📋 VERSION: 1.0 (Initial multi-client support) + * 📅 DATE: 2025 + * 👨‍💻 AUTHOR: Advanced Bell Systems + * ═══════════════════════════════════════════════════════════════════════════════════ + */ + +#pragma once + +#include +#include +#include +#include + +class ClientManager { +public: + enum class DeviceType { + UNKNOWN, + MASTER, + SECONDARY + }; + + struct ClientInfo { + AsyncWebSocketClient* client; + DeviceType deviceType; + uint32_t lastSeen; + bool isConnected; + + ClientInfo() : client(nullptr), deviceType(DeviceType::UNKNOWN), + lastSeen(0), isConnected(false) {} + + ClientInfo(AsyncWebSocketClient* c, DeviceType type) + : client(c), deviceType(type), lastSeen(millis()), isConnected(true) {} + }; + + ClientManager(); + ~ClientManager(); + + // Client lifecycle management + void addClient(AsyncWebSocketClient* client, DeviceType deviceType = DeviceType::UNKNOWN); + void removeClient(uint32_t clientId); + void updateClientType(uint32_t clientId, DeviceType deviceType); + void updateClientLastSeen(uint32_t clientId); + + // Client information + bool hasClients() const { return !_clients.empty(); } + size_t getClientCount() const { return _clients.size(); } + bool isClientConnected(uint32_t clientId) const; + DeviceType getClientType(uint32_t clientId) const; + ClientInfo* getClientInfo(uint32_t clientId); + + // Messaging methods + bool sendToClient(uint32_t clientId, const String& message); + void sendToMasterClients(const String& message); + void sendToSecondaryClients(const String& message); + void broadcastToAll(const String& message); + + // Utility methods + void cleanupDisconnectedClients(); + String getClientListJson() const; + +private: + std::map _clients; + + // Helper methods + const char* deviceTypeToString(DeviceType type) const; + DeviceType stringToDeviceType(const String& typeStr) const; + bool isValidClient(AsyncWebSocketClient* client) const; +}; diff --git a/vesper/src/Communication/Communication.cpp b/vesper/src/Communication/Communication.cpp new file mode 100644 index 0000000..c0f79fe --- /dev/null +++ b/vesper/src/Communication/Communication.cpp @@ -0,0 +1,1346 @@ +#include "Communication.hpp" +#include "../ConfigManager/ConfigManager.hpp" +#include "../OTAManager/OTAManager.hpp" +#include "../Networking/Networking.hpp" +#include "../Logging/Logging.hpp" +#include "../Player/Player.hpp" +#include "../FileManager/FileManager.hpp" +#include "../TimeKeeper/TimeKeeper.hpp" +#include "../FirmwareValidator/FirmwareValidator.hpp" +#include "../MqttSSL/MqttSSL.hpp" +#include + +Communication* Communication::_instance = nullptr; +StaticJsonDocument<2048> Communication::_parseDocument; + +static void connectToMqttWrapper(TimerHandle_t xTimer) { + if (Communication::_instance) { + Communication::_instance->connectToMqtt(); + } +} + +Communication::Communication(ConfigManager& configManager, + OTAManager& otaManager, + Networking& networking, + AsyncMqttClient& mqttClient, + AsyncWebServer& server, + AsyncWebSocket& webSocket, + AsyncUDP& udp) + : _configManager(configManager) + , _otaManager(otaManager) + , _networking(networking) + , _mqttClient(mqttClient) + , _server(server) + , _webSocket(webSocket) + , _udp(udp) + , _player(nullptr) + , _fileManager(nullptr) + , _timeKeeper(nullptr) + , _firmwareValidator(nullptr) + , _mqttReconnectTimer(nullptr) { + + _instance = this; +} + +Communication::~Communication() { + if (_mqttReconnectTimer != nullptr) { + xTimerDelete(_mqttReconnectTimer, portMAX_DELAY); + _mqttReconnectTimer = nullptr; + } + _instance = nullptr; +} + +void Communication::begin() { + LOG_INFO("Initializing Communication Manager v2.1"); + initMqtt(); + initWebSocket(); + LOG_INFO("Communication Manager initialized with multi-client support"); +} + +void Communication::initMqtt() { + _mqttReconnectTimer = xTimerCreate("mqttTimer", pdMS_TO_TICKS(2000), pdFALSE, + (void*)0, connectToMqttWrapper); + + _mqttClient.onConnect(onMqttConnect); + _mqttClient.onDisconnect(onMqttDisconnect); + _mqttClient.onSubscribe(onMqttSubscribe); + _mqttClient.onUnsubscribe(onMqttUnsubscribe); + _mqttClient.onMessage(onMqttMessage); + _mqttClient.onPublish(onMqttPublish); + + auto& mqttConfig = _configManager.getMqttConfig(); + + // Log SSL status - AsyncMqttClient SSL is compile-time configured + MqttSSL::logSSLStatus(_mqttClient, mqttConfig.port); + + // DEBUG: Log connection details + LOG_INFO("MQTT Config: host=%s, port=%d, user=%s, pass=%s", + mqttConfig.host.c_str(), mqttConfig.port, + mqttConfig.user.c_str(), mqttConfig.password.c_str()); + + _mqttClient.setServer(mqttConfig.host.c_str(), mqttConfig.port); + _mqttClient.setCredentials(mqttConfig.user.c_str(), mqttConfig.password.c_str()); +} + +void Communication::initWebSocket() { + _webSocket.onEvent(onWebSocketEvent); + _server.addHandler(&_webSocket); +} + +void Communication::connectToMqtt() { + if (_networking.isConnected()) { + LOG_INFO("Connecting to MQTT..."); + _mqttClient.connect(); + } else { + LOG_WARNING("Cannot connect to MQTT: No network connection"); + } +} + +void Communication::subscribeMqtt() { + char topic[64]; + snprintf(topic, sizeof(topic), "vesper/%s/control", _configManager.getDeviceUID().c_str()); + uint16_t topicId = _mqttClient.subscribe(topic, 2); + LOG_INFO("Subscribing to Command topic, QoS 2, packetId: %d", topicId); +} + +void Communication::sendResponse(const String& response, const MessageContext& context) { + if (context.source == MessageSource::MQTT) { + publishToMqtt(response); + } else if (context.source == MessageSource::WEBSOCKET) { + _clientManager.sendToClient(context.clientId, response); + } +} + +void Communication::sendSuccessResponse(const String& type, const String& payload, const MessageContext& context) { + String response = ResponseBuilder::success(type, payload); + sendResponse(response, context); +} + +void Communication::sendErrorResponse(const String& type, const String& message, const MessageContext& context) { + String response = ResponseBuilder::error(type, message); + sendResponse(response, context); +} + +void Communication::broadcastStatus(const String& statusMessage) { + publishToMqtt(statusMessage); + broadcastToAllWebSocketClients(statusMessage); +} + +void Communication::broadcastStatus(const JsonDocument& statusJson) { + String statusMessage; + serializeJson(statusJson, statusMessage); + broadcastStatus(statusMessage); +} + +void Communication::sendBellOverloadNotification(const std::vector& bellNumbers, + const std::vector& bellLoads, + const String& severity) { + StaticJsonDocument<512> overloadMsg; + overloadMsg["status"] = "INFO"; + overloadMsg["type"] = "bell_overload"; + + JsonArray bellsArray = overloadMsg["payload"]["bells"].to(); + JsonArray loadsArray = overloadMsg["payload"]["loads"].to(); + + for (size_t i = 0; i < bellNumbers.size() && i < bellLoads.size(); i++) { + bellsArray.add(bellNumbers[i] + 1); + loadsArray.add(bellLoads[i]); + } + + overloadMsg["payload"]["severity"] = severity; + broadcastStatus(overloadMsg); + + LOG_WARNING("Bell overload notification sent: %d bells, severity: %s", + bellNumbers.size(), severity.c_str()); +} + +void Communication::broadcastToMasterClients(const String& message) { + _clientManager.sendToMasterClients(message); +} + +void Communication::broadcastToSecondaryClients(const String& message) { + _clientManager.sendToSecondaryClients(message); +} + +void Communication::broadcastToAllWebSocketClients(const String& message) { + _clientManager.broadcastToAll(message); +} + +void Communication::broadcastToAllWebSocketClients(const JsonDocument& message) { + String messageStr; + serializeJson(message, messageStr); + _clientManager.broadcastToAll(messageStr); + LOG_DEBUG("Broadcasted JSON to WebSocket clients: %s", messageStr.c_str()); +} + +void Communication::publishToMqtt(const String& data) { + if (_mqttClient.connected()) { + char topicData[64]; + snprintf(topicData, sizeof(topicData), "vesper/%s/data", _configManager.getDeviceUID().c_str()); + _mqttClient.publish(topicData, 0, true, data.c_str()); + LOG_DEBUG("Published to MQTT: %s", data.c_str()); + } else { + LOG_ERROR("MQTT Not Connected! Message Failed: %s", data.c_str()); + } +} + +void Communication::onNetworkConnected() { + LOG_DEBUG("Network connected - attempting MQTT connection"); + connectToMqtt(); +} + +void Communication::onNetworkDisconnected() { + LOG_DEBUG("Network disconnected - stopping MQTT timer"); + xTimerStop(_mqttReconnectTimer, 0); +} + +void Communication::setupUdpDiscovery() { + uint16_t discoveryPort = _configManager.getNetworkConfig().discoveryPort; + if (_udp.listen(discoveryPort)) { + LOG_INFO("UDP discovery listening on port %u", discoveryPort); + + _udp.onPacket([this](AsyncUDPPacket packet) { + String msg = String((const char*)packet.data(), packet.length()); + LOG_DEBUG("UDP from %s:%u -> %s", + packet.remoteIP().toString().c_str(), + packet.remotePort(), + msg.c_str()); + + bool shouldReply = false; + + if (msg.indexOf("discover") >= 0) { + shouldReply = true; + } else { + StaticJsonDocument<128> req; + DeserializationError err = deserializeJson(req, msg); + if (!err) { + shouldReply = (req["op"] == "discover" && req["svc"] == "vesper"); + } + } + + if (!shouldReply) return; + + StaticJsonDocument<256> doc; + doc["op"] = "discover_reply"; + doc["svc"] = "vesper"; + doc["ver"] = 1; + + doc["name"] = "Proj. Vesper v0.5"; + doc["id"] = _configManager.getDeviceUID(); + doc["ip"] = _networking.getLocalIP(); + char wsUrl[64]; + snprintf(wsUrl, sizeof(wsUrl), "ws://%s/ws", _networking.getLocalIP().c_str()); + doc["ws"] = wsUrl; + doc["port"] = 80; + doc["fw"] = "1.2.3"; + doc["clients"] = _clientManager.getClientCount(); + + String out; + serializeJson(doc, out); + + _udp.writeTo((const uint8_t*)out.c_str(), out.length(), + packet.remoteIP(), packet.remotePort()); + }); + } else { + LOG_ERROR("Failed to start UDP discovery."); + } +} + +void Communication::onMqttConnect(bool sessionPresent) { + LOG_INFO("Connected to MQTT"); + if (_instance) { + _instance->subscribeMqtt(); + } +} + +void Communication::onMqttDisconnect(AsyncMqttClientDisconnectReason reason) { + String reasonStr; + switch (reason) { + case AsyncMqttClientDisconnectReason::TCP_DISCONNECTED: + reasonStr = "TCP_DISCONNECTED"; + break; + case AsyncMqttClientDisconnectReason::MQTT_UNACCEPTABLE_PROTOCOL_VERSION: + reasonStr = "UNACCEPTABLE_PROTOCOL_VERSION"; + break; + case AsyncMqttClientDisconnectReason::MQTT_IDENTIFIER_REJECTED: + reasonStr = "IDENTIFIER_REJECTED"; + break; + case AsyncMqttClientDisconnectReason::MQTT_SERVER_UNAVAILABLE: + reasonStr = "SERVER_UNAVAILABLE"; + break; + case AsyncMqttClientDisconnectReason::MQTT_MALFORMED_CREDENTIALS: + reasonStr = "MALFORMED_CREDENTIALS"; + break; + case AsyncMqttClientDisconnectReason::MQTT_NOT_AUTHORIZED: + reasonStr = "NOT_AUTHORIZED"; + break; + case AsyncMqttClientDisconnectReason::TLS_BAD_FINGERPRINT: + reasonStr = "TLS_BAD_FINGERPRINT"; + break; + default: + reasonStr = "UNKNOWN(" + String((int)reason) + ")"; + break; + } + + LOG_WARNING("Disconnected from MQTT: %s", reasonStr.c_str()); + + if (_instance && _instance->_networking.isConnected()) { + xTimerStart(_instance->_mqttReconnectTimer, 0); + } +} + +void Communication::onMqttMessage(char* topic, char* payload, AsyncMqttClientMessageProperties properties, + size_t len, size_t index, size_t total) { + if (!_instance) return; + + char expectedTopic[64]; + snprintf(expectedTopic, sizeof(expectedTopic), "vesper/%s/control", _instance->_configManager.getDeviceUID().c_str()); + + if (strcmp(topic, expectedTopic) == 0) { + JsonDocument command = _instance->parsePayload(payload); + MessageContext context(MessageSource::MQTT); + _instance->handleCommand(command, context); + } +} + +void Communication::onMqttSubscribe(uint16_t packetId, uint8_t qos) { + LOG_INFO("Subscribe acknowledged. PacketID: %d / QoS: %d", packetId, qos); +} + +void Communication::onMqttUnsubscribe(uint16_t packetId) { + LOG_INFO("Unsubscribe Acknowledged. PacketID: %d", packetId); +} + +void Communication::onMqttPublish(uint16_t packetId) { + LOG_DEBUG("Publish Acknowledged. PacketID: %d", packetId); +} + +void Communication::onWebSocketEvent(AsyncWebSocket* server, AsyncWebSocketClient* client, + AwsEventType type, void* arg, uint8_t* data, size_t len) { + if (!_instance) return; + + switch (type) { + case WS_EVT_CONNECT: + _instance->onWebSocketConnect(client); + break; + + case WS_EVT_DISCONNECT: + _instance->onWebSocketDisconnect(client); + break; + + case WS_EVT_DATA: + _instance->onWebSocketReceived(client, arg, data, len); + break; + + case WS_EVT_ERROR: + LOG_ERROR("WebSocket client #%u error(%u): %s", client->id(), *((uint16_t*)arg), (char*)data); + break; + + default: + break; + } +} + +void Communication::onWebSocketConnect(AsyncWebSocketClient* client) { + LOG_INFO("WebSocket client #%u connected from %s", client->id(), client->remoteIP().toString().c_str()); + + _clientManager.addClient(client, ClientManager::DeviceType::UNKNOWN); + + String welcomeMsg = ResponseBuilder::success("connection", "Connected to Vesper"); + _clientManager.sendToClient(client->id(), welcomeMsg); +} + +void Communication::onWebSocketDisconnect(AsyncWebSocketClient* client) { + LOG_INFO("WebSocket client #%u disconnected", client->id()); + _clientManager.removeClient(client->id()); + _clientManager.cleanupDisconnectedClients(); +} + +void Communication::onWebSocketReceived(AsyncWebSocketClient* client, void* arg, uint8_t* data, size_t len) { + AwsFrameInfo* info = (AwsFrameInfo*)arg; + + if (info->final && info->index == 0 && info->len == len && info->opcode == WS_TEXT) { + char* payload = (char*)malloc(len + 1); + if (!payload) { + LOG_ERROR("Failed to allocate memory for WebSocket payload"); + return; + } + + memcpy(payload, data, len); + payload[len] = '\0'; + + LOG_DEBUG("Received WebSocket message from client #%u (length %d): %s", + client->id(), len, payload); + + JsonDocument command = parsePayload(payload); + MessageContext context(MessageSource::WEBSOCKET, client->id()); + + _clientManager.updateClientLastSeen(client->id()); + + handleCommand(command, context); + + free(payload); + } else { + LOG_WARNING("Received fragmented or non-text WebSocket message - ignoring"); + } +} + +JsonDocument Communication::parsePayload(char* payload) { + _parseDocument.clear(); + + size_t payloadLen = strlen(payload); + + LOG_DEBUG("Parsing payload (length %d): %s", payloadLen, payload); + + if (payloadLen == 0) { + LOG_ERROR("Empty payload received"); + return _parseDocument; + } + + String cleanJson = String(payload); + cleanJson.replace("\r\n", ""); + cleanJson.replace("\n", ""); + cleanJson.replace("\r", ""); + cleanJson.trim(); + + LOG_DEBUG("Cleaned JSON: %s", cleanJson.c_str()); + + DeserializationError error = deserializeJson(_parseDocument, cleanJson); + if (error) { + LOG_ERROR("JSON deserialization failed: %s", error.c_str()); + } else { + LOG_DEBUG("JSON parsed successfully"); + } + + return _parseDocument; +} + +void Communication::handleCommand(JsonDocument& command, const MessageContext& context) { + String cmd = command["cmd"]; + JsonVariant contents = command["contents"]; + + LOG_DEBUG("Processing command: %s from %s", cmd.c_str(), + context.source == MessageSource::MQTT ? "MQTT" : "WebSocket"); + + if (cmd == "ping") { + handlePingCommand(context); + } else if (cmd == "identify") { + handleIdentifyCommand(contents, context); + } else if (cmd == "playback") { + handlePlaybackCommand(contents, context); + } else if (cmd == "file_manager") { + handleFileManagerCommand(contents, context); + } else if (cmd == "relay_setup") { + handleRelaySetupCommand(contents, context); + } else if (cmd == "clock_setup") { + handleClockSetupCommand(contents, context); + } else if (cmd == "system_info") { + handleSystemInfoCommand(contents, context); + } else if (cmd == "system") { + handleSystemCommand(contents, context); + } else { + LOG_WARNING("Unknown command received: %s", cmd.c_str()); + sendErrorResponse("unknown_command", "Command not recognized: " + cmd, context); + } +} + +void Communication::handlePingCommand(const MessageContext& context) { + String response = ResponseBuilder::pong(); + sendResponse(response, context); +} + +void Communication::handleStatusCommand(const MessageContext& context) { + PlayerStatus playerStatus = _player ? _player->getStatus() : PlayerStatus::STOPPED; + uint32_t timeElapsedMs = 0; + uint64_t projectedRunTime = 0; + + if (_player) { + if (_player->getStatus() == PlayerStatus::PLAYING || + _player->getStatus() == PlayerStatus::PAUSED || + _player->getStatus() == PlayerStatus::STOPPING) { + timeElapsedMs = millis() - _player->startTime; + } + + projectedRunTime = _player->calculateProjectedRunTime(); + } + + String response = ResponseBuilder::deviceStatus(playerStatus, timeElapsedMs, projectedRunTime); + sendResponse(response, context); +} + +void Communication::handleIdentifyCommand(JsonVariant contents, const MessageContext& context) { + if (context.source != MessageSource::WEBSOCKET) { + sendErrorResponse("identify", "Identify command only available via WebSocket", context); + return; + } + + if (!contents.containsKey("device_type")) { + sendErrorResponse("identify", "Missing device_type parameter", context); + return; + } + + String deviceTypeStr = contents["device_type"]; + ClientManager::DeviceType deviceType = ClientManager::DeviceType::UNKNOWN; + + if (deviceTypeStr == "master") { + deviceType = ClientManager::DeviceType::MASTER; + } else if (deviceTypeStr == "secondary") { + deviceType = ClientManager::DeviceType::SECONDARY; + } + + if (deviceType != ClientManager::DeviceType::UNKNOWN) { + _clientManager.updateClientType(context.clientId, deviceType); + sendSuccessResponse("identify", "Device identified as " + deviceTypeStr, context); + LOG_INFO("Client #%u identified as %s device", context.clientId, deviceTypeStr.c_str()); + } else { + sendErrorResponse("identify", "Invalid device_type. Use 'master' or 'secondary'", context); + } +} + +void Communication::handlePlaybackCommand(JsonVariant contents, const MessageContext& context) { + if (_player) { + bool success = _player->command(contents); + + if (success) { + sendSuccessResponse("playback", "Playback command executed", context); + } else { + sendErrorResponse("playback", "Playback command failed", context); + } + } else { + LOG_ERROR("Player reference not set"); + sendErrorResponse("playback", "Player not available", context); + } +} + +void Communication::handleFileManagerCommand(JsonVariant contents, const MessageContext& context) { + if (!contents.containsKey("action")) { + sendErrorResponse("file_manager", "Missing action parameter", context); + return; + } + + String action = contents["action"]; + LOG_DEBUG("Processing file manager action: %s", action.c_str()); + + if (action == "list_melodies") { + handleListMelodiesCommand(context); + } else if (action == "download_melody") { + handleDownloadMelodyCommand(contents, context); + } else if (action == "delete_melody") { + handleDeleteMelodyCommand(contents, context); + } else { + LOG_WARNING("Unknown file manager action: %s", action.c_str()); + sendErrorResponse("file_manager", "Unknown action: " + action, context); + } +} + +void Communication::handleRelaySetupCommand(JsonVariant contents, const MessageContext& context) { + if (!contents.containsKey("action")) { + sendErrorResponse("relay_setup", "Missing action parameter", context); + return; + } + + String action = contents["action"]; + LOG_DEBUG("Processing relay setup action: %s", action.c_str()); + + if (action == "set_timings") { + handleSetRelayTimersCommand(contents, context); + } else if (action == "set_outputs") { + handleSetRelayOutputsCommand(contents, context); + } else { + LOG_WARNING("Unknown relay setup action: %s", action.c_str()); + sendErrorResponse("relay_setup", "Unknown action: " + action, context); + } +} + +void Communication::handleClockSetupCommand(JsonVariant contents, const MessageContext& context) { + if (!contents.containsKey("action")) { + sendErrorResponse("clock_setup", "Missing action parameter", context); + return; + } + + String action = contents["action"]; + LOG_DEBUG("Processing clock setup action: %s", action.c_str()); + + if (action == "set_outputs") { + handleSetClockOutputsCommand(contents, context); + } else if (action == "set_timings") { + handleSetClockTimingsCommand(contents, context); + } else if (action == "set_alerts") { + handleSetClockAlertsCommand(contents, context); + } else if (action == "set_backlight") { + handleSetClockBacklightCommand(contents, context); + } else if (action == "set_silence") { + handleSetClockSilenceCommand(contents, context); + } else if (action == "set_rtc_time") { + handleSetRtcTimeCommand(contents, context); + } else if (action == "set_physical_clock_time") { + handleSetPhysicalClockTimeCommand(contents, context); + } else if (action == "pause_clock_updates" || action == "resume_clock_updates") { + handlePauseClockUpdatesCommand(contents, context); + } else if (action == "set_enabled") { + handleSetClockEnabledCommand(contents, context); + } else { + LOG_WARNING("Unknown clock setup action: %s", action.c_str()); + sendErrorResponse("clock_setup", "Unknown action: " + action, context); + } +} + +void Communication::handleSystemInfoCommand(JsonVariant contents, const MessageContext& context) { + if (!contents.containsKey("action")) { + sendErrorResponse("system_info", "Missing action parameter", context); + return; + } + + String action = contents["action"]; + LOG_DEBUG("Processing system info action: %s", action.c_str()); + + if (action == "report_status") { + handleStatusCommand(context); + } else if (action == "get_device_time") { + handleGetDeviceTimeCommand(context); + } else if (action == "get_clock_time") { + handleGetClockTimeCommand(context); + } else if (action == "commit_firmware") { + handleCommitFirmwareCommand(context); + } else if (action == "rollback_firmware") { + handleRollbackFirmwareCommand(context); + } else if (action == "get_firmware_status") { + handleGetFirmwareStatusCommand(context); + } else { + LOG_WARNING("Unknown system info action: %s", action.c_str()); + sendErrorResponse("system_info", "Unknown action: " + action, context); + } +} + +void Communication::handleListMelodiesCommand(const MessageContext& context) { + if (!_fileManager) { + sendErrorResponse("list_melodies", "FileManager not available", context); + return; + } + + String fileListJson = _fileManager->listFilesAsJson("/melodies"); + + StaticJsonDocument<1024> doc; + DeserializationError error = deserializeJson(doc, fileListJson); + + if (error) { + LOG_ERROR("Failed to parse file list JSON: %s", error.c_str()); + sendErrorResponse("list_melodies", "Failed to parse file list", context); + return; + } + + StaticJsonDocument<1024> response; + response["status"] = "SUCCESS"; + response["type"] = "list_melodies"; + response["payload"] = doc["files"]; + + String responseStr; + serializeJson(response, responseStr); + sendResponse(responseStr, context); +} + +void Communication::handleDownloadMelodyCommand(JsonVariant contents, const MessageContext& context) { + if (!_fileManager) { + sendErrorResponse("download_melody", "FileManager not available", context); + return; + } + + bool success = _fileManager->addMelody(contents); + String filename = contents.containsKey("name") ? contents["name"].as() : + contents.containsKey("melodys_uid") ? contents["melodys_uid"].as() : "unknown"; + + String response = ResponseBuilder::downloadResult(success, filename); + sendResponse(response, context); +} + +void Communication::handleDeleteMelodyCommand(JsonVariant contents, const MessageContext& context) { + if (!_fileManager) { + sendErrorResponse("delete_melody", "FileManager not available", context); + return; + } + + if (!contents.containsKey("name")) { + sendErrorResponse("delete_melody", "Missing name parameter", context); + return; + } + + String melodyName = contents["name"]; + String filepath = "/melodies/" + melodyName; + + bool success = _fileManager->deleteFile(filepath); + + if (success) { + sendSuccessResponse("delete_melody", "Melody deleted: " + melodyName, context); + } else { + sendErrorResponse("delete_melody", "Failed to delete melody: " + melodyName, context); + } +} + +void Communication::handleSetRelayTimersCommand(JsonVariant contents, const MessageContext& context) { + try { + _configManager.updateBellDurations(contents); + // Save configuration to ensure persistence + bool saved = _configManager.saveBellDurations(); + if (saved) { + sendSuccessResponse("set_relay_timers", "Relay timers updated and saved", context); + LOG_INFO("Relay timers updated and saved successfully"); + } else { + sendErrorResponse("set_relay_timers", "Failed to save relay timers to SD card", context); + LOG_ERROR("Failed to save relay timers configuration"); + } + } catch (...) { + sendErrorResponse("set_relay_timers", "Failed to update relay timers", context); + LOG_ERROR("Exception occurred while updating relay timers"); + } +} + +void Communication::handleSetRelayOutputsCommand(JsonVariant contents, const MessageContext& context) { + try { + _configManager.updateBellOutputs(contents); + // Note: Bell outputs are typically not persisted to SD card as they're more of a mapping configuration + sendSuccessResponse("set_relay_outputs", "Relay outputs updated", context); + LOG_INFO("Relay outputs updated successfully"); + } catch (...) { + sendErrorResponse("set_relay_outputs", "Failed to update relay outputs", context); + LOG_ERROR("Exception occurred while updating relay outputs"); + } +} + +void Communication::handleSetClockOutputsCommand(JsonVariant contents, const MessageContext& context) { + try { + _configManager.updateClockOutputs(contents); + // Save configuration to ensure persistence + bool saved = _configManager.saveClockConfig(); + if (saved) { + sendSuccessResponse("set_clock_outputs", "Clock outputs updated and saved", context); + LOG_INFO("Clock outputs updated and saved successfully"); + } else { + sendErrorResponse("set_clock_outputs", "Failed to save clock outputs to SD card", context); + LOG_ERROR("Failed to save clock outputs configuration"); + } + } catch (...) { + sendErrorResponse("set_clock_outputs", "Failed to update clock outputs", context); + LOG_ERROR("Exception occurred while updating clock outputs"); + } +} + +void Communication::handleSetClockTimingsCommand(JsonVariant contents, const MessageContext& context) { + try { + _configManager.updateClockOutputs(contents); + // Save configuration to ensure persistence + bool saved = _configManager.saveClockConfig(); + if (saved) { + sendSuccessResponse("set_clock_timings", "Clock timings updated and saved", context); + LOG_INFO("Clock timings updated and saved successfully"); + } else { + sendErrorResponse("set_clock_timings", "Failed to save clock timings to SD card", context); + LOG_ERROR("Failed to save clock timings configuration"); + } + } catch (...) { + sendErrorResponse("set_clock_timings", "Failed to update clock timings", context); + LOG_ERROR("Exception occurred while updating clock timings"); + } +} + +void Communication::handleSetClockAlertsCommand(JsonVariant contents, const MessageContext& context) { + try { + _configManager.updateClockAlerts(contents); + // Save configuration to ensure persistence + bool saved = _configManager.saveClockConfig(); + if (saved) { + sendSuccessResponse("set_clock_alerts", "Clock alerts updated and saved", context); + LOG_INFO("Clock alerts updated and saved successfully"); + } else { + sendErrorResponse("set_clock_alerts", "Failed to save clock alerts to SD card", context); + LOG_ERROR("Failed to save clock alerts configuration"); + } + } catch (...) { + sendErrorResponse("set_clock_alerts", "Failed to update clock alerts", context); + LOG_ERROR("Exception occurred while updating clock alerts"); + } +} + +void Communication::handleSetClockBacklightCommand(JsonVariant contents, const MessageContext& context) { + try { + _configManager.updateClockBacklight(contents); + // Save configuration to ensure persistence + bool saved = _configManager.saveClockConfig(); + if (saved) { + sendSuccessResponse("set_clock_backlight", "Clock backlight updated and saved", context); + LOG_INFO("Clock backlight updated and saved successfully"); + } else { + sendErrorResponse("set_clock_backlight", "Failed to save clock backlight to SD card", context); + LOG_ERROR("Failed to save clock backlight configuration"); + } + } catch (...) { + sendErrorResponse("set_clock_backlight", "Failed to update clock backlight", context); + LOG_ERROR("Exception occurred while updating clock backlight"); + } +} + +void Communication::handleSetClockSilenceCommand(JsonVariant contents, const MessageContext& context) { + try { + _configManager.updateClockSilence(contents); + // Save configuration to ensure persistence + bool saved = _configManager.saveClockConfig(); + if (saved) { + sendSuccessResponse("set_clock_silence", "Clock silence periods updated and saved", context); + LOG_INFO("Clock silence periods updated and saved successfully"); + } else { + sendErrorResponse("set_clock_silence", "Failed to save clock silence configuration to SD card", context); + LOG_ERROR("Failed to save clock silence configuration"); + } + } catch (...) { + sendErrorResponse("set_clock_silence", "Failed to update clock silence periods", context); + LOG_ERROR("Exception occurred while updating clock silence periods"); + } +} + +void Communication::handleSetRtcTimeCommand(JsonVariant contents, const MessageContext& context) { + if (!_timeKeeper) { + sendErrorResponse("set_rtc_time", "TimeKeeper not available", context); + return; + } + + if (!contents.containsKey("timestamp")) { + sendErrorResponse("set_rtc_time", "Missing timestamp parameter", context); + return; + } + + unsigned long timestamp = contents["timestamp"]; + if (timestamp == 0) { + sendErrorResponse("set_rtc_time", "Invalid timestamp value", context); + return; + } + + // Handle timezone parameters (optional for backward compatibility) + if (contents.containsKey("timezone_offset") && contents.containsKey("dst_offset")) { + // New method: App sends timezone info + long timezoneOffset = contents["timezone_offset"]; + long dstOffset = contents["dst_offset"]; + String timezoneName = contents["timezone_name"] | "UTC"; + + // Update ConfigManager with new timezone settings + auto& timeConfig = _configManager.getTimeConfig(); + + // Calculate base GMT offset (without DST) + long baseGmtOffset = timezoneOffset - dstOffset; + + // Update timezone configuration + _configManager.updateTimeConfig(baseGmtOffset, dstOffset); + + LOG_INFO("Timezone updated: %s (GMT%+ld, DST%+ld)", + timezoneName.c_str(), baseGmtOffset/3600, dstOffset/3600); + + // Apply total offset to timestamp + long totalOffset = timezoneOffset; // App already calculated total + unsigned long localTimestamp = timestamp + totalOffset; + + // Set the RTC time using TimeKeeper with local timestamp + _timeKeeper->setTimeWithLocalTimestamp(localTimestamp); + + // Verify the time was set correctly by reading it back + unsigned long verifyTime = _timeKeeper->getTime(); + if (verifyTime > 0 && abs((long)verifyTime - (long)timestamp) < 5) { // Allow 5 second tolerance + sendSuccessResponse("set_rtc_time", + "RTC time and timezone updated successfully", context); + LOG_INFO("RTC time set with timezone: UTC %lu + %ld = local %lu", + timestamp, totalOffset, localTimestamp); + } else { + sendErrorResponse("set_rtc_time", "Failed to verify RTC time was set correctly", context); + LOG_ERROR("RTC time verification failed - expected: %lu, got: %lu", timestamp, verifyTime); + } + } else { + // Legacy method: Use device's existing timezone config + _timeKeeper->setTime(timestamp); + + // Verify the time was set correctly by reading it back + unsigned long verifyTime = _timeKeeper->getTime(); + if (verifyTime > 0 && abs((long)verifyTime - (long)timestamp) < 5) { // Allow 5 second tolerance + sendSuccessResponse("set_rtc_time", "RTC time updated successfully", context); + LOG_INFO("RTC time set using device timezone config: %lu", timestamp); + } else { + sendErrorResponse("set_rtc_time", "Failed to verify RTC time was set correctly", context); + LOG_ERROR("RTC time verification failed - expected: %lu, got: %lu", timestamp, verifyTime); + } + } +} + +void Communication::handleSetPhysicalClockTimeCommand(JsonVariant contents, const MessageContext& context) { + if (!contents.containsKey("hour") || !contents.containsKey("minute")) { + sendErrorResponse("set_physical_clock_time", "Missing hour or minute parameter", context); + return; + } + + int hour = contents["hour"]; + int minute = contents["minute"]; + + // Validate time parameters + if (hour < 0 || hour > 23) { + sendErrorResponse("set_physical_clock_time", "Hour must be between 0 and 23", context); + return; + } + + if (minute < 0 || minute > 59) { + sendErrorResponse("set_physical_clock_time", "Minute must be between 0 and 59", context); + return; + } + + // Set the physical clock time using ConfigManager + _configManager.setPhysicalClockHour(hour); + _configManager.setPhysicalClockMinute(minute); + _configManager.setLastSyncTime(millis() / 1000); + + // Save the updated clock state to SD card + bool saved = _configManager.saveClockState(); + + if (saved) { + sendSuccessResponse("set_physical_clock_time", "Physical clock time updated and saved successfully", context); + LOG_INFO("Physical clock time set to %02d:%02d and saved to SD", hour, minute); + } else { + sendErrorResponse("set_physical_clock_time", "Physical clock time updated but failed to save to SD card", context); + LOG_ERROR("Physical clock time set to %02d:%02d but failed to save to SD", hour, minute); + } +} + + +void Communication::handlePauseClockUpdatesCommand(JsonVariant contents, const MessageContext& context) { + if (!_timeKeeper) { + return; + } + + if (contents["action"] == "pause_clock_updates") { + _timeKeeper->pauseClockUpdates(); + sendSuccessResponse("pause_clock_updates", "Clock updates paused", context); + LOG_DEBUG("Clock updates paused"); + return; + } else if (contents["action"] == "resume_clock_updates") { + _timeKeeper->resumeClockUpdates(); + sendSuccessResponse("resume_clock_updates", "Clock updates resumed", context); + LOG_DEBUG("Clock updates resumed"); + return; + } +} + +void Communication::handleSetClockEnabledCommand(JsonVariant contents, const MessageContext& context) { + if (!contents.containsKey("enabled")) { + sendErrorResponse("set_clock_enabled", "Missing enabled parameter", context); + return; + } + + bool enabled = contents["enabled"].as(); + + try { + _configManager.setClockEnabled(enabled); + bool saved = _configManager.saveClockConfig(); + + if (saved) { + String status = enabled ? "enabled" : "disabled"; + sendSuccessResponse("set_clock_enabled", "Clock " + status + " and saved successfully", context); + LOG_INFO("Clock %s via remote command", status.c_str()); + } else { + sendErrorResponse("set_clock_enabled", "Clock setting updated but failed to save to SD card", context); + LOG_ERROR("Failed to save clock enabled setting to SD card"); + } + } catch (...) { + sendErrorResponse("set_clock_enabled", "Failed to update clock enabled setting", context); + LOG_ERROR("Exception occurred while updating clock enabled setting"); + } +} + + + + +String Communication::getPayloadContent(char* data, size_t len) { + String content = ""; + for (size_t i = 0; i < len; i++) { + content.concat(data[i]); + } + return content; +} + +int Communication::extractBellNumber(const String& key) { + if (key.length() >= 2) { + char firstChar = key.charAt(0); + if (firstChar == 'b' || firstChar == 'c') { + String numberPart = key.substring(1); + return numberPart.toInt(); + } + } + return 0; +} + +void Communication::handleGetDeviceTimeCommand(const MessageContext& context) { + StaticJsonDocument<256> response; + response["status"] = "SUCCESS"; + response["type"] = "device_time"; + + if (_timeKeeper) { + // Get Unix timestamp from Timekeeper + unsigned long timestamp = _timeKeeper->getTime(); + response["payload"]["timestamp"] = timestamp; + response["payload"]["rtc_available"] = true; + + // Convert to readable format + time_t rawTime = (time_t)timestamp; + struct tm* timeInfo = localtime(&rawTime); + response["payload"]["year"] = timeInfo->tm_year + 1900; + response["payload"]["month"] = timeInfo->tm_mon + 1; + response["payload"]["day"] = timeInfo->tm_mday; + response["payload"]["hour"] = timeInfo->tm_hour; + response["payload"]["minute"] = timeInfo->tm_min; + response["payload"]["second"] = timeInfo->tm_sec; + } else { + response["payload"]["timestamp"] = millis() / 1000; + response["payload"]["rtc_available"] = false; + LOG_WARNING("TimeKeeper reference not set for device time request"); + } + + String responseStr; + serializeJson(response, responseStr); + sendResponse(responseStr, context); + + LOG_DEBUG("Device time requested"); +} + +void Communication::handleGetClockTimeCommand(const MessageContext& context) { + StaticJsonDocument<256> response; + response["status"] = "SUCCESS"; + response["type"] = "clock_time"; + + // Get physical clock time from ConfigManager + response["payload"]["clock_hour"] = _configManager.getPhysicalClockHour(); + response["payload"]["clock_minute"] = _configManager.getPhysicalClockMinute(); + response["payload"]["clock_available"] = true; + response["payload"]["last_sync_time"] = _configManager.getLastSyncTime(); + response["payload"]["next_output_is_c1"] = _configManager.getNextOutputIsC1(); + + String responseStr; + serializeJson(response, responseStr); + sendResponse(responseStr, context); + + LOG_DEBUG("Physical clock time requested: %02d:%02d (last sync: %lu)", + _configManager.getPhysicalClockHour(), + _configManager.getPhysicalClockMinute(), + _configManager.getLastSyncTime()); +} + +// ════════════════════════════════════════════════════════════════════════════ +// HEALTH CHECK IMPLEMENTATION +// ════════════════════════════════════════════════════════════════════════════ + +bool Communication::isHealthy() const { + // Check if required references are set + if (!_player) { + LOG_DEBUG("Communication: Unhealthy - Player reference not set"); + return false; + } + + if (!_fileManager) { + LOG_DEBUG("Communication: Unhealthy - FileManager reference not set"); + return false; + } + + if (!_timeKeeper) { + LOG_DEBUG("Communication: Unhealthy - TimeKeeper reference not set"); + return false; + } + + // Check if WebSocket server is active + if (!hasActiveWebSocketClients() && !isMqttConnected()) { + LOG_DEBUG("Communication: Unhealthy - No active connections (WebSocket or MQTT)"); + return false; + } + + // Check if MQTT reconnection timer exists and is functioning + if (_mqttReconnectTimer == nullptr) { + LOG_DEBUG("Communication: Unhealthy - MQTT reconnection timer not created"); + return false; + } + + // Check if networking dependency is healthy + if (!_networking.isConnected()) { + LOG_DEBUG("Communication: Unhealthy - No network connection"); + return false; + } + + return true; +} + +// ════════════════════════════════════════════════════════════════════════════ +// SYSTEM COMMAND IMPLEMENTATION +// ════════════════════════════════════════════════════════════════════════════ + +void Communication::handleSystemCommand(JsonVariant contents, const MessageContext& context) { + if (!contents.containsKey("action")) { + sendErrorResponse("system", "Missing action parameter", context); + return; + } + + String action = contents["action"]; + LOG_DEBUG("Processing system action: %s", action.c_str()); + + if (action == "commit_firmware") { + handleCommitFirmwareCommand(context); + } else if (action == "rollback_firmware") { + handleRollbackFirmwareCommand(context); + } else if (action == "get_firmware_status") { + handleGetFirmwareStatusCommand(context); + } else if (action == "set_network_config") { + handleSetNetworkConfigCommand(contents, context); + } else { + LOG_WARNING("Unknown system action: %s", action.c_str()); + sendErrorResponse("system", "Unknown action: " + action, context); + } +} + +// ════════════════════════════════════════════════════════════════════════════ +// FIRMWARE MANAGEMENT IMPLEMENTATION +// ════════════════════════════════════════════════════════════════════════════ + +void Communication::handleCommitFirmwareCommand(const MessageContext& context) { + if (!_firmwareValidator) { + sendErrorResponse("commit_firmware", "FirmwareValidator not available", context); + return; + } + + // Check if firmware is actually in testing mode + if (!_firmwareValidator->isInTestingMode()) { + sendErrorResponse("commit_firmware", "No firmware validation in progress", context); + return; + } + + LOG_INFO("💾 Manual firmware commit requested via %s", + context.source == MessageSource::MQTT ? "MQTT" : "WebSocket"); + + try { + _firmwareValidator->commitFirmware(); + sendSuccessResponse("commit_firmware", "Firmware committed successfully", context); + LOG_INFO("✅ Firmware manually committed - system is now stable"); + } catch (...) { + sendErrorResponse("commit_firmware", "Failed to commit firmware", context); + LOG_ERROR("❌ Failed to commit firmware"); + } +} + +void Communication::handleRollbackFirmwareCommand(const MessageContext& context) { + if (!_firmwareValidator) { + sendErrorResponse("rollback_firmware", "FirmwareValidator not available", context); + return; + } + + LOG_WARNING("🔄 Manual firmware rollback requested via %s", + context.source == MessageSource::MQTT ? "MQTT" : "WebSocket"); + + try { + _firmwareValidator->rollbackFirmware(); + sendSuccessResponse("rollback_firmware", "Firmware rollback initiated - device will reboot", context); + LOG_WARNING("🔄 Firmware rollback initiated - device should reboot shortly"); + + // Device should reboot automatically, but this response might not be sent + } catch (...) { + sendErrorResponse("rollback_firmware", "Failed to initiate firmware rollback", context); + LOG_ERROR("❌ Failed to initiate firmware rollback"); + } +} + +void Communication::handleGetFirmwareStatusCommand(const MessageContext& context) { + if (!_firmwareValidator) { + sendErrorResponse("get_firmware_status", "FirmwareValidator not available", context); + return; + } + + StaticJsonDocument<512> response; + response["status"] = "SUCCESS"; + response["type"] = "firmware_status"; + + // Get firmware validation state + auto validationState = _firmwareValidator->getValidationState(); + auto currentInfo = _firmwareValidator->getCurrentFirmwareInfo(); + auto config = _firmwareValidator->getValidationConfig(); + + // Map enum to string + String stateStr; + switch (validationState) { + case FirmwareValidationState::UNKNOWN: + stateStr = "UNKNOWN"; + break; + case FirmwareValidationState::STARTUP_PENDING: + stateStr = "STARTUP_PENDING"; + break; + case FirmwareValidationState::STARTUP_RUNNING: + stateStr = "STARTUP_RUNNING"; + break; + case FirmwareValidationState::RUNTIME_TESTING: + stateStr = "RUNTIME_TESTING"; + break; + case FirmwareValidationState::VALIDATED: + stateStr = "VALIDATED"; + break; + case FirmwareValidationState::FAILED_STARTUP: + stateStr = "FAILED_STARTUP"; + break; + case FirmwareValidationState::FAILED_RUNTIME: + stateStr = "FAILED_RUNTIME"; + break; + case FirmwareValidationState::ROLLED_BACK: + stateStr = "ROLLED_BACK"; + break; + default: + stateStr = "INVALID"; + break; + } + + response["payload"]["validation_state"] = stateStr; + response["payload"]["current_version"] = currentInfo.version; + response["payload"]["is_testing"] = currentInfo.isTesting; + response["payload"]["is_valid"] = currentInfo.isValid; + response["payload"]["boot_count"] = currentInfo.bootCount; + response["payload"]["build_date"] = currentInfo.buildDate; + + // Calculate remaining validation time if in testing mode + if (_firmwareValidator->isInTestingMode()) { + // This is a simplified calculation - in reality you'd need to track when validation started + response["payload"]["runtime_timeout_ms"] = config.runtimeTimeoutMs; + response["payload"]["can_commit"] = true; + response["payload"]["can_rollback"] = true; + } else { + response["payload"]["runtime_timeout_ms"] = 0; + response["payload"]["can_commit"] = false; + response["payload"]["can_rollback"] = (validationState != FirmwareValidationState::VALIDATED); + } + + String responseStr; + serializeJson(response, responseStr); + sendResponse(responseStr, context); + + LOG_DEBUG("Firmware status requested: %s", stateStr.c_str()); +} + +// ════════════════════════════════════════════════════════════════════════════ +// NETWORK CONFIGURATION IMPLEMENTATION +// ════════════════════════════════════════════════════════════════════════════ + +void Communication::handleSetNetworkConfigCommand(JsonVariant contents, const MessageContext& context) { + // Validate that we have at least one parameter to update + bool hasHostname = contents.containsKey("hostname"); + bool hasStaticIPConfig = contents.containsKey("useStaticIP"); + bool hasAPPass = contents.containsKey("apPass"); + bool hasDiscoveryPort = contents.containsKey("discoveryPort"); + + if (!hasHostname && !hasStaticIPConfig && !hasAPPass && !hasDiscoveryPort) { + sendErrorResponse("set_network_config", "No network parameters provided", context); + return; + } + + try { + bool needsReboot = false; + bool configChanged = false; + + // Get current network config (read-only reference) + auto& currentConfig = _configManager.getNetworkConfig(); + + // Create new config based on current settings + bool useStaticIP = currentConfig.useStaticIP; + IPAddress ip = currentConfig.ip; + IPAddress gateway = currentConfig.gateway; + IPAddress subnet = currentConfig.subnet; + IPAddress dns1 = currentConfig.dns1; + IPAddress dns2 = currentConfig.dns2; + String hostname = currentConfig.hostname; + String apPass = currentConfig.apPass; + uint16_t discoveryPort = currentConfig.discoveryPort; + + // Update hostname if provided + if (hasHostname) { + String newHostname = contents["hostname"].as(); + if (!newHostname.isEmpty() && newHostname.length() <= 32) { + hostname = newHostname; + configChanged = true; + needsReboot = true; + LOG_INFO("Hostname will be updated to: %s", hostname.c_str()); + } else { + sendErrorResponse("set_network_config", "Invalid hostname (must be 1-32 characters)", context); + return; + } + } + + // Update static IP configuration if provided + if (hasStaticIPConfig) { + useStaticIP = contents["useStaticIP"].as(); + configChanged = true; + needsReboot = true; + + if (useStaticIP) { + // Validate and parse required fields for static IP + if (!contents.containsKey("ip") || !contents.containsKey("gateway") || !contents.containsKey("subnet")) { + sendErrorResponse("set_network_config", "Static IP requires: ip, gateway, and subnet", context); + return; + } + + // Parse IP addresses + if (!ip.fromString(contents["ip"].as())) { + sendErrorResponse("set_network_config", "Invalid IP address format", context); + return; + } + if (!gateway.fromString(contents["gateway"].as())) { + sendErrorResponse("set_network_config", "Invalid gateway address format", context); + return; + } + if (!subnet.fromString(contents["subnet"].as())) { + sendErrorResponse("set_network_config", "Invalid subnet mask format", context); + return; + } + + // Parse optional DNS servers + if (contents.containsKey("dns1")) { + dns1.fromString(contents["dns1"].as()); + } + if (contents.containsKey("dns2")) { + dns2.fromString(contents["dns2"].as()); + } + + LOG_INFO("Static IP configuration will be applied: %s", ip.toString().c_str()); + } else { + LOG_INFO("DHCP mode will be enabled"); + } + } + + + + // If anything changed, update and save configuration + if (configChanged) { + // Update network config using existing method + _configManager.updateNetworkConfig(useStaticIP, ip, gateway, subnet, dns1, dns2); + + // Manually update fields not handled by updateNetworkConfig + // Note: This is a workaround since NetworkConfig doesn't have setters for all fields + auto& writableConfig = const_cast(_configManager.getNetworkConfig()); + writableConfig.hostname = hostname; + + // Save to SD card + bool saved = _configManager.saveNetworkConfig(); + + if (saved) { + String responseMsg = "Network configuration updated successfully"; + if (needsReboot) { + responseMsg += ". RESTART DEVICE to apply changes"; + } + sendSuccessResponse("set_network_config", responseMsg, context); + LOG_INFO("✅ Network configuration saved to SD card"); + } else { + sendErrorResponse("set_network_config", "Configuration updated but failed to save to SD card", context); + LOG_ERROR("❌ Failed to save network configuration to SD card"); + } + } else { + sendSuccessResponse("set_network_config", "No changes detected", context); + } + + } catch (const std::exception& e) { + sendErrorResponse("set_network_config", String("Exception: ") + e.what(), context); + LOG_ERROR("Exception in handleSetNetworkConfigCommand: %s", e.what()); + } catch (...) { + sendErrorResponse("set_network_config", "Unknown error occurred", context); + LOG_ERROR("Unknown exception in handleSetNetworkConfigCommand"); + } +} diff --git a/vesper/src/Communication/Communication.hpp b/vesper/src/Communication/Communication.hpp new file mode 100644 index 0000000..3375096 --- /dev/null +++ b/vesper/src/Communication/Communication.hpp @@ -0,0 +1,232 @@ +/* + * ═══════════════════════════════════════════════════════════════════════════════════ + * COMMUNICATION.HPP - Multi-Protocol Communication Manager v3.0 + * ═══════════════════════════════════════════════════════════════════════════════════ + * + * 📡 THE COMMUNICATION HUB OF VESPER 📡 + * + * This class manages all external communication protocols including MQTT, + * WebSocket, and UDP discovery. It provides a unified interface for + * grouped command handling and status reporting across multiple protocols. + * + * 🏗️ ARCHITECTURE: + * • Multi-protocol support with unified grouped command processing + * • Multi-client WebSocket support with device type identification + * • Automatic connection management and reconnection + * • Unified response system for consistent messaging + * • Thread-safe operation with proper resource management + * • Batch command support for efficient configuration + * + * 📡 SUPPORTED PROTOCOLS: + * • MQTT: Primary control interface with auto-reconnection + * • WebSocket: Real-time multi-client web interface communication + * • UDP Discovery: Auto-discovery service for network scanning + * + * 📱 CLIENT MANAGEMENT: + * • Support for multiple WebSocket clients (master/secondary devices) + * • Client type identification and targeted messaging + * • Automatic cleanup of disconnected clients + * • Broadcast capabilities for status updates + * + * 🔄 MESSAGE ROUTING: + * • Commands accepted from both MQTT and WebSocket + * • Responses sent only to originating protocol/client + * • Status broadcasts sent to all WebSocket clients + MQTT + * • Grouped command processing for all protocols + * + * 📋 VERSION: 3.0 (Grouped commands + batch processing) + * 📅 DATE: 2025 + * 👨‍💻 AUTHOR: Advanced Bell Systems + * ═══════════════════════════════════════════════════════════════════════════════════ + */ + +#pragma once + +#include +#include +#include +#include +#include +#include "ResponseBuilder.hpp" +#include "../ClientManager/ClientManager.hpp" + +class ConfigManager; +class OTAManager; +class Player; +class FileManager; +class Timekeeper; +class Networking; +class FirmwareValidator; + +class Communication { +public: + // Message source identification for response routing + enum class MessageSource { + MQTT, + WEBSOCKET + }; + + struct MessageContext { + MessageSource source; + uint32_t clientId; // Only used for WebSocket messages + + MessageContext(MessageSource src, uint32_t id = 0) + : source(src), clientId(id) {} + }; + + explicit Communication(ConfigManager& configManager, + OTAManager& otaManager, + Networking& networking, + AsyncMqttClient& mqttClient, + AsyncWebServer& server, + AsyncWebSocket& webSocket, + AsyncUDP& udp); + + ~Communication(); + + void begin(); + void setPlayerReference(Player* player) { _player = player; } + void setFileManagerReference(FileManager* fm) { _fileManager = fm; } + void setTimeKeeperReference(Timekeeper* tk) { _timeKeeper = tk; } + void setFirmwareValidatorReference(FirmwareValidator* fv) { _firmwareValidator = fv; } + void setupUdpDiscovery(); + + // Public methods for timer callbacks + void connectToMqtt(); + void subscribeMqtt(); + + // Status methods + bool isMqttConnected() const { return _mqttClient.connected(); } + bool hasActiveWebSocketClients() const { return _clientManager.hasClients(); } + size_t getWebSocketClientCount() const { return _clientManager.getClientCount(); } + + // Response methods - unified response system + void sendResponse(const String& response, const MessageContext& context); + void sendSuccessResponse(const String& type, const String& payload, const MessageContext& context); + void sendErrorResponse(const String& type, const String& message, const MessageContext& context); + + // Broadcast methods - for status updates that go to everyone + void broadcastStatus(const String& statusMessage); + void broadcastStatus(const JsonDocument& statusJson); + void broadcastToMasterClients(const String& message); + void broadcastToSecondaryClients(const String& message); + void broadcastToAllWebSocketClients(const String& message); + void broadcastToAllWebSocketClients(const JsonDocument& message); + void publishToMqtt(const String& data); + + // ═══════════════════════════════════════════════════════════════════════════════ + // HEALTH CHECK METHOD + // ═══════════════════════════════════════════════════════════════════════════════ + + /** @brief Check if Communication is in healthy state */ + bool isHealthy() const; + + // Bell overload notification + void sendBellOverloadNotification(const std::vector& bellNumbers, + const std::vector& bellLoads, + const String& severity); + + // Network connection callbacks (called by Networking) + void onNetworkConnected(); + void onNetworkDisconnected(); + + // Static instance for callbacks + static Communication* _instance; + +private: + // Dependencies + ConfigManager& _configManager; + OTAManager& _otaManager; + Networking& _networking; + AsyncMqttClient& _mqttClient; + AsyncWebServer& _server; + AsyncWebSocket& _webSocket; + AsyncUDP& _udp; + Player* _player; + FileManager* _fileManager; + Timekeeper* _timeKeeper; + FirmwareValidator* _firmwareValidator; + + // Client manager + ClientManager _clientManager; + + // State + TimerHandle_t _mqttReconnectTimer; + + // Reusable JSON documents + static StaticJsonDocument<2048> _parseDocument; + + // MQTT methods + void initMqtt(); + static void onMqttConnect(bool sessionPresent); + static void onMqttDisconnect(AsyncMqttClientDisconnectReason reason); + static void onMqttMessage(char* topic, char* payload, AsyncMqttClientMessageProperties properties, + size_t len, size_t index, size_t total); + static void onMqttSubscribe(uint16_t packetId, uint8_t qos); + static void onMqttUnsubscribe(uint16_t packetId); + static void onMqttPublish(uint16_t packetId); + + // WebSocket methods + void initWebSocket(); + static void onWebSocketEvent(AsyncWebSocket* server, AsyncWebSocketClient* client, + AwsEventType type, void* arg, uint8_t* data, size_t len); + void onWebSocketConnect(AsyncWebSocketClient* client); + void onWebSocketDisconnect(AsyncWebSocketClient* client); + void onWebSocketReceived(AsyncWebSocketClient* client, void* arg, uint8_t* data, size_t len); + void handleClientIdentification(AsyncWebSocketClient* client, JsonDocument& command); + + // Command processing - unified for both MQTT and WebSocket with grouped commands + JsonDocument parsePayload(char* payload); + void handleCommand(JsonDocument& command, const MessageContext& context); + + // ═════════════════════════════════════════════════════════════════════════════════ + // GROUPED COMMAND HANDLERS + // ═════════════════════════════════════════════════════════════════════════════════ + + // System commands + void handleSystemCommand(JsonVariant contents, const MessageContext& context); + void handleSystemInfoCommand(JsonVariant contents, const MessageContext& context); + void handlePlaybackCommand(JsonVariant contents, const MessageContext& context); + void handleFileManagerCommand(JsonVariant contents, const MessageContext& context); + void handleRelaySetupCommand(JsonVariant contents, const MessageContext& context); + void handleClockSetupCommand(JsonVariant contents, const MessageContext& context); + + // System sub-commands + void handlePingCommand(const MessageContext& context); + void handleStatusCommand(const MessageContext& context); + void handleIdentifyCommand(JsonVariant contents, const MessageContext& context); + void handleGetDeviceTimeCommand(const MessageContext& context); + void handleGetClockTimeCommand(const MessageContext& context); + + // Firmware management commands + void handleCommitFirmwareCommand(const MessageContext& context); + void handleRollbackFirmwareCommand(const MessageContext& context); + void handleGetFirmwareStatusCommand(const MessageContext& context); + + // Network configuration command + void handleSetNetworkConfigCommand(JsonVariant contents, const MessageContext& context); + + // File Manager sub-commands + void handleListMelodiesCommand(const MessageContext& context); + void handleDownloadMelodyCommand(JsonVariant contents, const MessageContext& context); + void handleDeleteMelodyCommand(JsonVariant contents, const MessageContext& context); + + // Relay Setup sub-commands + void handleSetRelayTimersCommand(JsonVariant contents, const MessageContext& context); + void handleSetRelayOutputsCommand(JsonVariant contents, const MessageContext& context); + + // Clock Setup sub-commands + void handleSetClockOutputsCommand(JsonVariant contents, const MessageContext& context); + void handleSetClockTimingsCommand(JsonVariant contents, const MessageContext& context); + void handleSetClockAlertsCommand(JsonVariant contents, const MessageContext& context); + void handleSetClockBacklightCommand(JsonVariant contents, const MessageContext& context); + void handleSetClockSilenceCommand(JsonVariant contents, const MessageContext& context); + void handleSetRtcTimeCommand(JsonVariant contents, const MessageContext& context); + void handleSetPhysicalClockTimeCommand(JsonVariant contents, const MessageContext& context); + void handlePauseClockUpdatesCommand(JsonVariant contents, const MessageContext& context); + void handleSetClockEnabledCommand(JsonVariant contents, const MessageContext& context); + + // Utility methods + String getPayloadContent(char* data, size_t len); + int extractBellNumber(const String& key); // Extract bell number from "b1", "c1", etc. +}; diff --git a/vesper/src/Communication/ResponseBuilder.cpp b/vesper/src/Communication/ResponseBuilder.cpp new file mode 100644 index 0000000..d1d0e8d --- /dev/null +++ b/vesper/src/Communication/ResponseBuilder.cpp @@ -0,0 +1,157 @@ +#include "ResponseBuilder.hpp" +#include "../Logging/Logging.hpp" + +// Static member initialization +StaticJsonDocument<512> ResponseBuilder::_responseDoc; + +String ResponseBuilder::success(const String& type, const String& payload) { + return buildResponse(Status::SUCCESS, type, payload); +} + +String ResponseBuilder::success(const String& type, const JsonObject& payload) { + return buildResponse(Status::SUCCESS, type, payload); +} + +String ResponseBuilder::error(const String& type, const String& message) { + return buildResponse(Status::ERROR, type, message); +} + +String ResponseBuilder::status(const String& type, const JsonObject& data) { + return buildResponse(Status::SUCCESS, type, data); +} + +String ResponseBuilder::status(const String& type, const String& data) { + return buildResponse(Status::SUCCESS, type, data); +} + +String ResponseBuilder::acknowledgment(const String& commandType) { + return success(commandType, "Command acknowledged"); +} + +String ResponseBuilder::pong() { + return success("pong", ""); +} + +String ResponseBuilder::deviceStatus(PlayerStatus playerStatus, uint32_t timeElapsed, uint64_t projectedRunTime) { + StaticJsonDocument<512> statusDoc; // Increased size for additional data + + statusDoc["status"] = "SUCCESS"; + statusDoc["type"] = "current_status"; + + // Create payload object with the exact format expected by Flutter + JsonObject payload = statusDoc.createNestedObject("payload"); + + // Convert PlayerStatus to string + const char* statusStr; + switch (playerStatus) { + case PlayerStatus::PLAYING: + statusStr = "playing"; + break; + case PlayerStatus::PAUSED: + statusStr = "paused"; + break; + case PlayerStatus::STOPPING: + statusStr = "stopping"; + break; + case PlayerStatus::STOPPED: + default: + statusStr = "idle"; // STOPPED maps to "idle" in Flutter + break; + } + + payload["player_status"] = statusStr; + payload["time_elapsed"] = timeElapsed; // in milliseconds + payload["projected_run_time"] = projectedRunTime; // NEW: total projected duration + + String result; + serializeJson(statusDoc, result); + + LOG_DEBUG("Device status response: %s", result.c_str()); + return result; +} + +String ResponseBuilder::melodyList(const String& fileListJson) { + // The fileListJson is already a JSON string, so we pass it as payload + return success("melody_list", fileListJson); +} + +String ResponseBuilder::downloadResult(bool success, const String& filename) { + if (success) { + String message = "Download successful"; + if (filename.length() > 0) { + message += ": " + filename; + } + return ResponseBuilder::success("download", message); + } else { + String message = "Download failed"; + if (filename.length() > 0) { + message += ": " + filename; + } + return error("download", message); + } +} + +String ResponseBuilder::configUpdate(const String& configType) { + return success(configType, configType + " configuration updated"); +} + +String ResponseBuilder::invalidCommand(const String& command) { + return error("invalid_command", "Unknown command: " + command); +} + +String ResponseBuilder::missingParameter(const String& parameter) { + return error("missing_parameter", "Required parameter missing: " + parameter); +} + +String ResponseBuilder::operationFailed(const String& operation, const String& reason) { + String message = operation + " failed"; + if (reason.length() > 0) { + message += ": " + reason; + } + return error(operation, message); +} + +String ResponseBuilder::deviceBusy() { + return error("device_busy", "Device is currently busy, try again later"); +} + +String ResponseBuilder::unauthorized() { + return error("unauthorized", "Operation not authorized for this client"); +} + +// Response Builder with String Payload +String ResponseBuilder::buildResponse(Status status, const String& type, const String& payload) { + _responseDoc.clear(); + _responseDoc["status"] = statusToString(status); + _responseDoc["type"] = type; + _responseDoc["payload"] = payload; + + String result; + serializeJson(_responseDoc, result); + + LOG_DEBUG("Response built: %s", result.c_str()); + return result; +} + +// Response Builder with JSON Payload +String ResponseBuilder::buildResponse(Status status, const String& type, const JsonObject& payload) { + _responseDoc.clear(); + _responseDoc["status"] = statusToString(status); + _responseDoc["type"] = type; + _responseDoc["payload"] = payload; + + String result; + serializeJson(_responseDoc, result); + + LOG_DEBUG("Response built: %s", result.c_str()); + return result; +} + +const char* ResponseBuilder::statusToString(Status status) { + switch (status) { + case Status::SUCCESS: return "SUCCESS"; + case Status::ERROR: return "ERROR"; + case Status::INFO: return "INFO"; + default: return "UNKNOWN"; + } +} diff --git a/vesper/src/Communication/ResponseBuilder.hpp b/vesper/src/Communication/ResponseBuilder.hpp new file mode 100644 index 0000000..393075d --- /dev/null +++ b/vesper/src/Communication/ResponseBuilder.hpp @@ -0,0 +1,87 @@ +/* + * ═══════════════════════════════════════════════════════════════════════════════════ + * RESPONSEBUILDER.HPP - Unified Response Generation System + * ═══════════════════════════════════════════════════════════════════════════════════ + * + * 📡 STANDARDIZED COMMUNICATION RESPONSES 📡 + * + * This class provides a unified interface for generating consistent JSON responses + * across all communication protocols (MQTT, WebSocket). It ensures all responses + * follow the same format and structure. + * + * 🏗️ ARCHITECTURE: + * • Static methods for response generation + * • Consistent JSON structure across all protocols + * • Memory-efficient response building + * • Type-safe response categories + * + * 📡 RESPONSE TYPES: + * • Success: Successful command execution + * • Error: Command execution failures + * • Status: System status reports and updates + * • Data: Information requests and telemetry + * + * 🔄 RESPONSE STRUCTURE: + * { + * "status": "OK|ERROR", + * "type": "command_type", + * "payload": "data_or_message" + * } + * + * 📋 USAGE EXAMPLES: + * • ResponseBuilder::success("playback", "Started playing melody") + * • ResponseBuilder::error("download", "File not found") + * • ResponseBuilder::status("telemetry", telemetryData) + * + * 📋 VERSION: 1.0 (Initial unified response system) + * 📅 DATE: 2025 + * 👨‍💻 AUTHOR: Advanced Bell Systems + * ═══════════════════════════════════════════════════════════════════════════════════ + */ + +#pragma once + +#include +#include +#include "../Player/Player.hpp" // For PlayerStatus enum + +class ResponseBuilder { +public: + // Response status types + enum class Status { + SUCCESS, + ERROR, + INFO + }; + + // Main response builders + static String success(const String& type, const String& payload = ""); + static String success(const String& type, const JsonObject& payload); + static String error(const String& type, const String& message); + static String status(const String& type, const JsonObject& data); + static String status(const String& type, const String& data); + + // Specialized response builders for common scenarios + static String acknowledgment(const String& commandType); + static String pong(); + static String deviceStatus(PlayerStatus playerStatus, uint32_t timeElapsedMs, uint64_t projectedRunTime = 0); + static String melodyList(const String& fileListJson); + static String downloadResult(bool success, const String& filename = ""); + static String configUpdate(const String& configType); + + // Error response builders + static String invalidCommand(const String& command); + static String missingParameter(const String& parameter); + static String operationFailed(const String& operation, const String& reason = ""); + static String deviceBusy(); + static String unauthorized(); + + // Utility methods + static String buildResponse(Status status, const String& type, const String& payload); + static String buildResponse(Status status, const String& type, const JsonObject& payload); + +private: + // Internal helper methods + static const char* statusToString(Status status); + static StaticJsonDocument<512> _responseDoc; // Reusable document for efficiency +}; diff --git a/vesper/src/ConfigManager/ConfigManager.cpp b/vesper/src/ConfigManager/ConfigManager.cpp new file mode 100644 index 0000000..58f087a --- /dev/null +++ b/vesper/src/ConfigManager/ConfigManager.cpp @@ -0,0 +1,944 @@ +#include "ConfigManager.hpp" +#include "../../src/Logging/Logging.hpp" +#include // For MAC address generation +#include // For timestamp generation +#include // For std::sort + +// NVS namespace for device identity storage +const char* ConfigManager::NVS_NAMESPACE = "device_id"; + +// NVS keys for device identity +static const char* NVS_DEVICE_UID_KEY = "device_uid"; +static const char* NVS_HW_TYPE_KEY = "hw_type"; +static const char* NVS_HW_VERSION_KEY = "hw_version"; + +ConfigManager::ConfigManager() { + // Initialize with empty defaults - everything will be loaded/generated in begin() + createDefaultBellConfig(); +} + +void ConfigManager::initializeCleanDefaults() { + // This method is called after NVS loading to set up clean defaults + // and auto-generate identifiers from loaded deviceUID + + // Generate network identifiers from deviceUID + generateNetworkIdentifiers(); + + // Set MQTT user to deviceUID for unique identification + mqttConfig.user = deviceConfig.deviceUID; + + LOG_INFO("ConfigManager: Clean defaults initialized with auto-generated identifiers"); +} + +void ConfigManager::generateNetworkIdentifiers() { + + networkConfig.hostname = "BellSystems-" + deviceConfig.deviceUID; + networkConfig.apSsid = "BellSystems-Setup-" + deviceConfig.deviceUID; + + + LOG_INFO("ConfigManager: Generated hostname: %s, AP SSID: %s", + networkConfig.hostname.c_str(), networkConfig.apSsid.c_str()); +} + +void ConfigManager::createDefaultBellConfig() { + // Initialize default durations (90ms for all bells) + for (uint8_t i = 0; i < 16; i++) { + bellConfig.durations[i] = 90; + bellConfig.outputs[i] = i; // Direct mapping by default + } +} + +bool ConfigManager::begin() { + LOG_INFO("ConfigManager: Starting clean deployment-ready initialization"); + + // Step 1: Initialize NVS for device identity (factory-set, permanent) + if (!initializeNVS()) { + LOG_ERROR("ConfigManager: NVS initialization failed, using empty defaults"); + } else { + // Load device identity from NVS (deviceUID, hwType, hwVersion) + loadDeviceIdentityFromNVS(); + } + + // Step 2: Initialize clean defaults and auto-generate identifiers + initializeCleanDefaults(); + + // Step 3: Initialize SD card for user-configurable settings + if (!ensureSDCard()) { + LOG_ERROR("ConfigManager: SD Card initialization failed, using defaults"); + return false; + } + + // Step 4: Load device configuration from SD card (firmware version only) + if (!loadDeviceConfig()) { + LOG_WARNING("ConfigManager: Could not load device config from SD card - using defaults"); + } + + // Step 5: Load update servers list + if (!loadUpdateServers()) { + LOG_WARNING("ConfigManager: Could not load update servers - using fallback only"); + } + + // Step 6: Load user-configurable settings from SD + loadFromSD(); + loadNetworkConfig(); // Load network configuration (hostname, static IP settings) + loadBellDurations(); + loadClockConfig(); // Load clock configuration (C1/C2 outputs, pulse durations) + loadClockState(); // Load physical clock state (hour, minute, position) + + LOG_INFO("ConfigManager: Initialization complete - UID: %s, Hostname: %s", + deviceConfig.deviceUID.c_str(), networkConfig.hostname.c_str()); + return true; +} + +// ════════════════════════════════════════════════════════════════════════════ +// NVS (NON-VOLATILE STORAGE) IMPLEMENTATION +// ════════════════════════════════════════════════════════════════════════════ + +bool ConfigManager::initializeNVS() { + esp_err_t err = nvs_flash_init(); + if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) { + LOG_WARNING("ConfigManager: NVS partition truncated, erasing..."); + ESP_ERROR_CHECK(nvs_flash_erase()); + err = nvs_flash_init(); + } + + if (err != ESP_OK) { + LOG_ERROR("ConfigManager: Failed to initialize NVS flash: %s", esp_err_to_name(err)); + return false; + } + + err = nvs_open(NVS_NAMESPACE, NVS_READWRITE, &nvsHandle); + if (err != ESP_OK) { + LOG_ERROR("ConfigManager: Failed to open NVS handle: %s", esp_err_to_name(err)); + return false; + } + + LOG_INFO("ConfigManager: NVS initialized successfully"); + return true; +} + +bool ConfigManager::loadDeviceIdentityFromNVS() { + if (nvsHandle == 0) { + LOG_ERROR("ConfigManager: NVS not initialized, cannot load device identity"); + return false; + } + + deviceConfig.deviceUID = readNVSString(NVS_DEVICE_UID_KEY, "PV000000000000"); + deviceConfig.hwType = readNVSString(NVS_HW_TYPE_KEY, "BellSystems"); + deviceConfig.hwVersion = readNVSString(NVS_HW_VERSION_KEY, "0"); + + LOG_INFO("ConfigManager: Device identity loaded from NVS - UID: %s, Type: %s, Version: %s", + deviceConfig.deviceUID.c_str(), + deviceConfig.hwType.c_str(), + deviceConfig.hwVersion.c_str()); + + return true; +} + +bool ConfigManager::saveDeviceIdentityToNVS() { + if (nvsHandle == 0) { + LOG_ERROR("ConfigManager: NVS not initialized, cannot save device identity"); + return false; + } + + bool success = true; + success &= writeNVSString(NVS_DEVICE_UID_KEY, deviceConfig.deviceUID); + success &= writeNVSString(NVS_HW_TYPE_KEY, deviceConfig.hwType); + success &= writeNVSString(NVS_HW_VERSION_KEY, deviceConfig.hwVersion); + + if (success) { + esp_err_t err = nvs_commit(nvsHandle); + if (err != ESP_OK) { + LOG_ERROR("ConfigManager: Failed to commit NVS changes: %s", esp_err_to_name(err)); + return false; + } + LOG_INFO("ConfigManager: Device identity saved to NVS"); + } else { + LOG_ERROR("ConfigManager: Failed to save device identity to NVS"); + } + + return success; +} + +String ConfigManager::readNVSString(const char* key, const String& defaultValue) { + if (nvsHandle == 0) { + LOG_WARNING("ConfigManager: NVS not initialized, returning default for key: %s", key); + return defaultValue; + } + + size_t required_size = 0; + esp_err_t err = nvs_get_str(nvsHandle, key, NULL, &required_size); + + if (err == ESP_ERR_NVS_NOT_FOUND) { + LOG_DEBUG("ConfigManager: NVS key '%s' not found, using default: %s", key, defaultValue.c_str()); + return defaultValue; + } + + if (err != ESP_OK) { + LOG_ERROR("ConfigManager: Error reading NVS key '%s': %s", key, esp_err_to_name(err)); + return defaultValue; + } + + char* buffer = new char[required_size]; + err = nvs_get_str(nvsHandle, key, buffer, &required_size); + + if (err != ESP_OK) { + LOG_ERROR("ConfigManager: Error reading NVS value for key '%s': %s", key, esp_err_to_name(err)); + delete[] buffer; + return defaultValue; + } + + String result = String(buffer); + delete[] buffer; + + LOG_DEBUG("ConfigManager: Read NVS key '%s': %s", key, result.c_str()); + return result; +} + +bool ConfigManager::writeNVSString(const char* key, const String& value) { + if (nvsHandle == 0) { + LOG_ERROR("ConfigManager: NVS not initialized, cannot write key: %s", key); + return false; + } + + esp_err_t err = nvs_set_str(nvsHandle, key, value.c_str()); + if (err != ESP_OK) { + LOG_ERROR("ConfigManager: Failed to write NVS key '%s': %s", key, esp_err_to_name(err)); + return false; + } + + LOG_DEBUG("ConfigManager: Written NVS key '%s': %s", key, value.c_str()); + return true; +} + +// ════════════════════════════════════════════════════════════════════════════ +// STANDARD SD CARD FUNCTIONALITY +// ════════════════════════════════════════════════════════════════════════════ + +bool ConfigManager::ensureSDCard() { + if (!sdInitialized) { + sdInitialized = SD.begin(hardwareConfig.sdChipSelect); + } + return sdInitialized; +} + +void ConfigManager::loadFromSD() { + if (!ensureSDCard()) { + LOG_ERROR("ConfigManager: Cannot load from SD - SD not available"); + return; + } + LOG_INFO("ConfigManager: Using default configuration"); +} + +bool ConfigManager::saveToSD() { + if (!ensureSDCard()) { + LOG_ERROR("ConfigManager: Cannot save to SD - SD not available"); + return false; + } + + bool success = true; + success &= saveBellDurations(); + success &= saveClockConfig(); + success &= saveClockState(); + return success; +} + +// Device configuration now only handles firmware version (identity is in NVS) +bool ConfigManager::saveDeviceConfig() { + if (!ensureSDCard()) { + LOG_ERROR("ConfigManager: Cannot save device config - SD not available"); + return false; + } + + StaticJsonDocument<256> doc; + doc["fwVersion"] = deviceConfig.fwVersion; + + char buffer[256]; + size_t len = serializeJson(doc, buffer, sizeof(buffer)); + + if (len == 0 || len >= sizeof(buffer)) { + LOG_ERROR("ConfigManager: Failed to serialize device config JSON"); + return false; + } + + saveFileToSD("/settings", "deviceConfig.json", buffer); + LOG_INFO("ConfigManager: Device config saved - FwVer: %s", deviceConfig.fwVersion.c_str()); + return true; +} + +bool ConfigManager::loadDeviceConfig() { + if (!ensureSDCard()) return false; + + File file = SD.open("/settings/deviceConfig.json", FILE_READ); + if (!file) { + LOG_WARNING("ConfigManager: Device config file not found - using firmware version default"); + return false; + } + + StaticJsonDocument<512> doc; + DeserializationError error = deserializeJson(doc, file); + file.close(); + + if (error) { + LOG_ERROR("ConfigManager: Failed to parse device config from SD: %s", error.c_str()); + return false; + } + + if (doc.containsKey("fwVersion")) { + deviceConfig.fwVersion = doc["fwVersion"].as(); + LOG_INFO("ConfigManager: Firmware version loaded from SD: %s", deviceConfig.fwVersion.c_str()); + } + + return true; +} + +bool ConfigManager::isHealthy() const { + if (!sdInitialized) { + LOG_DEBUG("ConfigManager: Unhealthy - SD card not initialized"); + return false; + } + + if (deviceConfig.deviceUID.isEmpty()) { + LOG_DEBUG("ConfigManager: Unhealthy - Device UID not set (factory configuration required)"); + return false; + } + + if (deviceConfig.hwType.isEmpty()) { + LOG_DEBUG("ConfigManager: Unhealthy - Hardware type not set (factory configuration required)"); + return false; + } + + if (networkConfig.hostname.isEmpty()) { + LOG_DEBUG("ConfigManager: Unhealthy - Hostname not generated (initialization issue)"); + return false; + } + + // Note: WiFi credentials are handled by WiFiManager, not checked here + + return true; +} + +// Bell configuration methods remain unchanged... +bool ConfigManager::loadBellDurations() { + if (!ensureSDCard()) { + LOG_ERROR("ConfigManager: SD Card not initialized. Using default bell durations."); + return false; + } + + File file = SD.open("/settings/relayTimings.json", FILE_READ); + if (!file) { + LOG_ERROR("ConfigManager: Settings file not found on SD. Using default bell durations."); + return false; + } + + StaticJsonDocument<512> doc; + DeserializationError error = deserializeJson(doc, file); + file.close(); + + if (error) { + LOG_ERROR("ConfigManager: Failed to parse settings from SD. Using default bell durations."); + return false; + } + + for (uint8_t i = 0; i < 16; i++) { + String key = String("b") + (i + 1); + if (doc.containsKey(key)) { + bellConfig.durations[i] = doc[key].as(); + } + } + + LOG_INFO("ConfigManager: Bell durations loaded from SD"); + return true; +} + +bool ConfigManager::saveBellDurations() { + if (!ensureSDCard()) return false; + + StaticJsonDocument<512> doc; + for (uint8_t i = 0; i < 16; i++) { + String key = String("b") + (i + 1); + doc[key] = bellConfig.durations[i]; + } + + char buffer[512]; + size_t len = serializeJson(doc, buffer, sizeof(buffer)); + + if (len == 0 || len >= sizeof(buffer)) { + LOG_ERROR("ConfigManager: Failed to serialize bell durations JSON"); + return false; + } + + saveFileToSD("/settings", "relayTimings.json", buffer); + LOG_INFO("ConfigManager: Bell durations saved to SD"); + return true; +} + +void ConfigManager::updateBellDurations(JsonVariant doc) { + for (uint8_t i = 0; i < 16; i++) { + String key = String("b") + (i + 1); + if (doc.containsKey(key)) { + bellConfig.durations[i] = doc[key].as(); + } + } + LOG_INFO("ConfigManager: Updated bell durations"); +} + +void ConfigManager::updateBellOutputs(JsonVariant doc) { + for (uint8_t i = 0; i < 16; i++) { + String key = String("b") + (i + 1); + if (doc.containsKey(key)) { + bellConfig.outputs[i] = doc[key].as() - 1; + } + } + LOG_INFO("ConfigManager: Updated bell outputs"); +} + +uint16_t ConfigManager::getBellDuration(uint8_t bellIndex) const { + if (bellIndex >= 16) return 90; + return bellConfig.durations[bellIndex]; +} + +uint16_t ConfigManager::getBellOutput(uint8_t bellIndex) const { + if (bellIndex >= 16) return bellIndex; + return bellConfig.outputs[bellIndex]; +} + +void ConfigManager::setBellDuration(uint8_t bellIndex, uint16_t duration) { + if (bellIndex < 16) { + bellConfig.durations[bellIndex] = duration; + } +} + +void ConfigManager::setBellOutput(uint8_t bellIndex, uint16_t output) { + if (bellIndex < 16) { + bellConfig.outputs[bellIndex] = output; + } +} + +void ConfigManager::saveFileToSD(const char* dirPath, const char* filename, const char* data) { + if (!ensureSDCard()) { + LOG_ERROR("ConfigManager: SD Card not initialized!"); + return; + } + + if (!SD.exists(dirPath)) { + SD.mkdir(dirPath); + } + + String fullPath = String(dirPath); + if (!fullPath.endsWith("/")) fullPath += "/"; + fullPath += filename; + + File file = SD.open(fullPath.c_str(), FILE_WRITE); + if (!file) { + LOG_ERROR("ConfigManager: Failed to open file: %s", fullPath.c_str()); + return; + } + + file.print(data); + file.close(); + LOG_INFO("ConfigManager: File %s saved successfully", fullPath.c_str()); +} + +// Clock configuration methods and other remaining methods follow the same pattern... +// (Implementation would continue with all the clock config methods, update servers, etc.) + +// ════════════════════════════════════════════════════════════════════════════ +// MISSING CLOCK CONFIGURATION METHODS +// ════════════════════════════════════════════════════════════════════════════ + +void ConfigManager::updateClockOutputs(JsonVariant doc) { + if (doc.containsKey("c1")) { + clockConfig.c1output = doc["c1"].as(); + } + if (doc.containsKey("c2")) { + clockConfig.c2output = doc["c2"].as(); + } + if (doc.containsKey("pulseDuration")) { + clockConfig.pulseDuration = doc["pulseDuration"].as(); + } + if (doc.containsKey("pauseDuration")) { + clockConfig.pauseDuration = doc["pauseDuration"].as(); + } + LOG_INFO("ConfigManager: Updated Clock outputs to: C1: %d / C2: %d, Pulse: %dms, Pause: %dms", + clockConfig.c1output, clockConfig.c2output, clockConfig.pulseDuration, clockConfig.pauseDuration); +} + +void ConfigManager::updateClockAlerts(JsonVariant doc) { + if (doc.containsKey("alertType")) { + clockConfig.alertType = doc["alertType"].as(); + } + if (doc.containsKey("alertRingInterval")) { + clockConfig.alertRingInterval = doc["alertRingInterval"].as(); + } + if (doc.containsKey("hourBell")) { + clockConfig.hourBell = doc["hourBell"].as(); + } + if (doc.containsKey("halfBell")) { + clockConfig.halfBell = doc["halfBell"].as(); + } + if (doc.containsKey("quarterBell")) { + clockConfig.quarterBell = doc["quarterBell"].as(); + } + LOG_INFO("ConfigManager: Updated Clock alerts"); +} + +void ConfigManager::updateClockBacklight(JsonVariant doc) { + if (doc.containsKey("enabled")) { + clockConfig.backlight = doc["enabled"].as(); + } + if (doc.containsKey("output")) { + clockConfig.backlightOutput = doc["output"].as(); + } + if (doc.containsKey("onTime")) { + clockConfig.backlightOnTime = doc["onTime"].as(); + } + if (doc.containsKey("offTime")) { + clockConfig.backlightOffTime = doc["offTime"].as(); + } + LOG_INFO("ConfigManager: Updated Clock backlight"); +} + +void ConfigManager::updateClockSilence(JsonVariant doc) { + if (doc.containsKey("daytime")) { + JsonObject daytime = doc["daytime"]; + if (daytime.containsKey("enabled")) { + clockConfig.daytimeSilenceEnabled = daytime["enabled"].as(); + } + if (daytime.containsKey("onTime")) { + clockConfig.daytimeSilenceOnTime = daytime["onTime"].as(); + } + if (daytime.containsKey("offTime")) { + clockConfig.daytimeSilenceOffTime = daytime["offTime"].as(); + } + } + if (doc.containsKey("nighttime")) { + JsonObject nighttime = doc["nighttime"]; + if (nighttime.containsKey("enabled")) { + clockConfig.nighttimeSilenceEnabled = nighttime["enabled"].as(); + } + if (nighttime.containsKey("onTime")) { + clockConfig.nighttimeSilenceOnTime = nighttime["onTime"].as(); + } + if (nighttime.containsKey("offTime")) { + clockConfig.nighttimeSilenceOffTime = nighttime["offTime"].as(); + } + } + LOG_INFO("ConfigManager: Updated Clock silence"); +} + +bool ConfigManager::loadClockConfig() { + if (!ensureSDCard()) return false; + + File file = SD.open("/settings/clockConfig.json", FILE_READ); + if (!file) { + LOG_WARNING("ConfigManager: Clock config file not found - using defaults"); + return false; + } + + StaticJsonDocument<512> doc; + DeserializationError error = deserializeJson(doc, file); + file.close(); + + if (error) { + LOG_ERROR("ConfigManager: Failed to parse clock config from SD: %s", error.c_str()); + return false; + } + + if (doc.containsKey("c1output")) clockConfig.c1output = doc["c1output"].as(); + if (doc.containsKey("c2output")) clockConfig.c2output = doc["c2output"].as(); + if (doc.containsKey("pulseDuration")) clockConfig.pulseDuration = doc["pulseDuration"].as(); + if (doc.containsKey("pauseDuration")) clockConfig.pauseDuration = doc["pauseDuration"].as(); + + LOG_INFO("ConfigManager: Clock config loaded"); + return true; +} + +bool ConfigManager::saveClockConfig() { + if (!ensureSDCard()) return false; + + StaticJsonDocument<512> doc; + doc["c1output"] = clockConfig.c1output; + doc["c2output"] = clockConfig.c2output; + doc["pulseDuration"] = clockConfig.pulseDuration; + doc["pauseDuration"] = clockConfig.pauseDuration; + + char buffer[512]; + size_t len = serializeJson(doc, buffer, sizeof(buffer)); + + if (len == 0 || len >= sizeof(buffer)) { + LOG_ERROR("ConfigManager: Failed to serialize clock config JSON"); + return false; + } + + saveFileToSD("/settings", "clockConfig.json", buffer); + LOG_INFO("ConfigManager: Clock config saved"); + return true; +} + +bool ConfigManager::loadClockState() { + if (!ensureSDCard()) return false; + + File file = SD.open("/settings/clockState.json", FILE_READ); + if (!file) { + LOG_WARNING("ConfigManager: Clock state file not found - using defaults"); + clockConfig.physicalHour = 0; + clockConfig.physicalMinute = 0; + clockConfig.nextOutputIsC1 = true; + clockConfig.lastSyncTime = 0; + return false; + } + + StaticJsonDocument<256> doc; + DeserializationError error = deserializeJson(doc, file); + file.close(); + + if (error) { + LOG_ERROR("ConfigManager: Failed to parse clock state from SD: %s", error.c_str()); + return false; + } + + clockConfig.physicalHour = doc["hour"].as() % 12; + clockConfig.physicalMinute = doc["minute"].as() % 60; + clockConfig.nextOutputIsC1 = doc["nextIsC1"].as(); + clockConfig.lastSyncTime = doc["lastSyncTime"].as(); + + LOG_INFO("ConfigManager: Clock state loaded"); + return true; +} + +bool ConfigManager::saveClockState() { + if (!ensureSDCard()) return false; + + StaticJsonDocument<256> doc; + doc["hour"] = clockConfig.physicalHour; + doc["minute"] = clockConfig.physicalMinute; + doc["nextIsC1"] = clockConfig.nextOutputIsC1; + doc["lastSyncTime"] = clockConfig.lastSyncTime; + + char buffer[256]; + size_t len = serializeJson(doc, buffer, sizeof(buffer)); + + if (len == 0 || len >= sizeof(buffer)) { + LOG_ERROR("ConfigManager: Failed to serialize clock state JSON"); + return false; + } + + saveFileToSD("/settings", "clockState.json", buffer); + LOG_DEBUG("ConfigManager: Clock state saved"); + return true; +} + +bool ConfigManager::loadUpdateServers() { + if (!ensureSDCard()) return false; + + File file = SD.open("/settings/updateServers.json", FILE_READ); + if (!file) { + LOG_INFO("ConfigManager: Update servers file not found - using fallback only"); + return false; + } + + StaticJsonDocument<1024> doc; + DeserializationError error = deserializeJson(doc, file); + file.close(); + + if (error) { + LOG_ERROR("ConfigManager: Failed to parse update servers JSON: %s", error.c_str()); + return false; + } + + updateServers.clear(); + if (doc.containsKey("servers") && doc["servers"].is()) { + JsonArray serversArray = doc["servers"]; + for (JsonObject server : serversArray) { + if (server.containsKey("url")) { + String url = server["url"].as(); + if (!url.isEmpty()) { + updateServers.push_back(url); + } + } + } + } + + LOG_INFO("ConfigManager: Loaded %d update servers from SD card", updateServers.size()); + return true; +} + +std::vector ConfigManager::getUpdateServers() const { + std::vector servers; + for (const String& server : updateServers) { + servers.push_back(server); + } + servers.push_back(updateConfig.fallbackServerUrl); + return servers; +} + +void ConfigManager::updateTimeConfig(long gmtOffsetSec, int daylightOffsetSec) { + timeConfig.gmtOffsetSec = gmtOffsetSec; + timeConfig.daylightOffsetSec = daylightOffsetSec; + saveToSD(); + LOG_INFO("ConfigManager: TimeConfig updated - GMT offset %ld sec, DST offset %d sec", + gmtOffsetSec, daylightOffsetSec); +} + +void ConfigManager::updateNetworkConfig(bool useStaticIP, IPAddress ip, IPAddress gateway, + IPAddress subnet, IPAddress dns1, IPAddress dns2) { + networkConfig.useStaticIP = useStaticIP; + networkConfig.ip = ip; + networkConfig.gateway = gateway; + networkConfig.subnet = subnet; + networkConfig.dns1 = dns1; + networkConfig.dns2 = dns2; + saveNetworkConfig(); // Save immediately to SD + LOG_INFO("ConfigManager: NetworkConfig updated - Static IP: %s, IP: %s", + useStaticIP ? "enabled" : "disabled", ip.toString().c_str()); +} + +// ════════════════════════════════════════════════════════════════════════════ +// NETWORK CONFIGURATION PERSISTENCE +// ════════════════════════════════════════════════════════════════════════════ + +bool ConfigManager::loadNetworkConfig() { + if (!ensureSDCard()) return false; + + File file = SD.open("/settings/networkConfig.json", FILE_READ); + if (!file) { + LOG_INFO("ConfigManager: Network config file not found - using auto-generated hostname and DHCP"); + return false; + } + + StaticJsonDocument<512> doc; + DeserializationError error = deserializeJson(doc, file); + file.close(); + + if (error) { + LOG_ERROR("ConfigManager: Failed to parse network config from SD: %s", error.c_str()); + return false; + } + + // Load hostname if present (overrides auto-generated) + if (doc.containsKey("hostname")) { + String customHostname = doc["hostname"].as(); + if (!customHostname.isEmpty()) { + networkConfig.hostname = customHostname; + LOG_INFO("ConfigManager: Custom hostname loaded from SD: %s", customHostname.c_str()); + } + } + + // Load static IP configuration + if (doc.containsKey("useStaticIP")) { + networkConfig.useStaticIP = doc["useStaticIP"].as(); + } + + if (doc.containsKey("ip")) { + String ipStr = doc["ip"].as(); + if (!ipStr.isEmpty() && ipStr != "0.0.0.0") { + networkConfig.ip.fromString(ipStr); + } + } + + if (doc.containsKey("gateway")) { + String gwStr = doc["gateway"].as(); + if (!gwStr.isEmpty() && gwStr != "0.0.0.0") { + networkConfig.gateway.fromString(gwStr); + } + } + + if (doc.containsKey("subnet")) { + String subnetStr = doc["subnet"].as(); + if (!subnetStr.isEmpty() && subnetStr != "0.0.0.0") { + networkConfig.subnet.fromString(subnetStr); + } + } + + if (doc.containsKey("dns1")) { + String dns1Str = doc["dns1"].as(); + if (!dns1Str.isEmpty() && dns1Str != "0.0.0.0") { + networkConfig.dns1.fromString(dns1Str); + } + } + + if (doc.containsKey("dns2")) { + String dns2Str = doc["dns2"].as(); + if (!dns2Str.isEmpty() && dns2Str != "0.0.0.0") { + networkConfig.dns2.fromString(dns2Str); + } + } + + LOG_INFO("ConfigManager: Network config loaded - Hostname: %s, Static IP: %s", + networkConfig.hostname.c_str(), + networkConfig.useStaticIP ? "enabled" : "disabled"); + + return true; +} + +bool ConfigManager::saveNetworkConfig() { + if (!ensureSDCard()) return false; + + StaticJsonDocument<512> doc; + + // Save hostname (user can customize) + doc["hostname"] = networkConfig.hostname; + + // Save static IP configuration + doc["useStaticIP"] = networkConfig.useStaticIP; + doc["ip"] = networkConfig.ip.toString(); + doc["gateway"] = networkConfig.gateway.toString(); + doc["subnet"] = networkConfig.subnet.toString(); + doc["dns1"] = networkConfig.dns1.toString(); + doc["dns2"] = networkConfig.dns2.toString(); + + char buffer[512]; + size_t len = serializeJson(doc, buffer, sizeof(buffer)); + + if (len == 0 || len >= sizeof(buffer)) { + LOG_ERROR("ConfigManager: Failed to serialize network config JSON"); + return false; + } + + saveFileToSD("/settings", "networkConfig.json", buffer); + LOG_INFO("ConfigManager: Network config saved to SD"); + return true; +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// FACTORY RESET IMPLEMENTATION +// ═══════════════════════════════════════════════════════════════════════════════ + +bool ConfigManager::factoryReset() { + LOG_WARNING("═══════════════════════════════════════════════════════════════════════════"); + LOG_WARNING("🏭 FACTORY RESET INITIATED"); + LOG_WARNING("═══════════════════════════════════════════════════════════════════════════"); + + if (!ensureSDCard()) { + LOG_ERROR("❌ ConfigManager: Cannot perform factory reset - SD card not available"); + return false; + } + + // Step 1: Delete all configuration files + LOG_INFO("🗑️ Step 1: Deleting all configuration files from SD card..."); + bool deleteSuccess = clearAllSettings(); + + if (!deleteSuccess) { + LOG_ERROR("❌ ConfigManager: Factory reset failed - could not delete all settings"); + return false; + } + + // Step 2: Reset in-memory configuration to defaults + LOG_INFO("🔄 Step 2: Resetting in-memory configuration to defaults..."); + + // Reset network config (keep device-generated values) + networkConfig.useStaticIP = false; + networkConfig.ip = IPAddress(0, 0, 0, 0); + networkConfig.gateway = IPAddress(0, 0, 0, 0); + networkConfig.subnet = IPAddress(0, 0, 0, 0); + networkConfig.dns1 = IPAddress(0, 0, 0, 0); + networkConfig.dns2 = IPAddress(0, 0, 0, 0); + // hostname and apSsid are auto-generated from deviceUID, keep them + + // Reset time config + timeConfig.gmtOffsetSec = 0; + timeConfig.daylightOffsetSec = 0; + + // Reset bell config + createDefaultBellConfig(); + + // Reset clock config to defaults + clockConfig.c1output = 255; + clockConfig.c2output = 255; + clockConfig.pulseDuration = 5000; + clockConfig.pauseDuration = 2000; + clockConfig.physicalHour = 0; + clockConfig.physicalMinute = 0; + clockConfig.nextOutputIsC1 = true; + clockConfig.lastSyncTime = 0; + clockConfig.alertType = "OFF"; + clockConfig.alertRingInterval = 1200; + clockConfig.hourBell = 255; + clockConfig.halfBell = 255; + clockConfig.quarterBell = 255; + clockConfig.backlight = false; + clockConfig.backlightOutput = 255; + clockConfig.backlightOnTime = "18:00"; + clockConfig.backlightOffTime = "06:00"; + clockConfig.daytimeSilenceEnabled = false; + clockConfig.daytimeSilenceOnTime = "14:00"; + clockConfig.daytimeSilenceOffTime = "17:00"; + clockConfig.nighttimeSilenceEnabled = false; + clockConfig.nighttimeSilenceOnTime = "22:00"; + clockConfig.nighttimeSilenceOffTime = "07:00"; + + // Note: Device identity (deviceUID, hwType, hwVersion) in NVS is NOT reset + // Note: WiFi credentials are handled by WiFiManager, not reset here + + LOG_INFO("✅ Step 2: In-memory configuration reset to defaults"); + + LOG_WARNING("✅ FACTORY RESET COMPLETE"); + LOG_WARNING("🔄 Device will boot with default settings on next restart"); + LOG_WARNING("🆔 Device identity (UID) preserved in NVS"); + LOG_INFO("WiFi credentials should be cleared separately using WiFiManager"); + + return true; +} + +bool ConfigManager::clearAllSettings() { + if (!ensureSDCard()) { + LOG_ERROR("ConfigManager: SD card not available for clearing settings"); + return false; + } + + bool allDeleted = true; + int filesDeleted = 0; + int filesFailed = 0; + + // List of all configuration files to delete + const char* settingsFiles[] = { + "/settings/deviceConfig.json", + "/settings/networkConfig.json", + "/settings/relayTimings.json", + "/settings/clockConfig.json", + "/settings/clockState.json", + "/settings/updateServers.json" + }; + + int numFiles = sizeof(settingsFiles) / sizeof(settingsFiles[0]); + + LOG_INFO("ConfigManager: Attempting to delete %d configuration files...", numFiles); + + // Delete each configuration file + for (int i = 0; i < numFiles; i++) { + const char* filepath = settingsFiles[i]; + + if (SD.exists(filepath)) { + if (SD.remove(filepath)) { + LOG_INFO("✅ Deleted: %s", filepath); + filesDeleted++; + } else { + LOG_ERROR("❌ Failed to delete: %s", filepath); + filesFailed++; + allDeleted = false; + } + } else { + LOG_DEBUG("⏩ Skip (not found): %s", filepath); + } + } + + // Also delete the /melodies directory if you want a complete reset + // Uncomment if you want to delete melodies too: + /* + if (SD.exists("/melodies")) { + LOG_INFO("Deleting /melodies directory..."); + // Note: SD library doesn't have rmdir for non-empty dirs + // You'd need to implement recursive delete or just leave melodies + } + */ + + LOG_INFO("═══════════════════════════════════════════════════════════════════════════"); + LOG_INFO("📄 Settings cleanup summary:"); + LOG_INFO(" ✅ Files deleted: %d", filesDeleted); + LOG_INFO(" ❌ Files failed: %d", filesFailed); + LOG_INFO(" 🔄 Total processed: %d / %d", filesDeleted + filesFailed, numFiles); + LOG_INFO("═══════════════════════════════════════════════════════════════════════════"); + + return allDeleted; +} diff --git a/vesper/src/ConfigManager/ConfigManager.hpp b/vesper/src/ConfigManager/ConfigManager.hpp new file mode 100644 index 0000000..1ba29bd --- /dev/null +++ b/vesper/src/ConfigManager/ConfigManager.hpp @@ -0,0 +1,444 @@ +/* + * ═══════════════════════════════════════════════════════════════════════════════════ + * CONFIGMANAGER.HPP - Deployment-Ready Configuration Management System + * ═══════════════════════════════════════════════════════════════════════════════════ + * + * 🗂️ CLEAN DEPLOYMENT-READY CONFIG SYSTEM 🗂️ + * + * This restructured configuration system is designed for production deployment + * with minimal hardcoded defaults, proper separation of factory vs user settings, + * and clean configuration hierarchy. + * + * 🏗️ DEPLOYMENT PHILOSOPHY: + * • Factory settings stored in NVS (permanent, set once) + * • User settings stored on SD card (configurable via app) + * • Hardware settings compiled per HWID variant + * • Network credentials handled by WiFiManager only + * • Auto-generated identifiers where appropriate + * + * ═══════════════════════════════════════════════════════════════════════════════════ + */ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +class ConfigManager { +public: + // ═══════════════════════════════════════════════════════════════════════════════ + // CONFIGURATION STRUCTURES - Clean deployment-ready design + // ═══════════════════════════════════════════════════════════════════════════════ + + /** + * @struct DeviceConfig + * @brief Factory-set device identity (NVS storage) + * + * These values are set once during manufacturing and stored in NVS. + * fwVersion is updated automatically after OTA updates (SD storage). + */ + struct DeviceConfig { + String deviceUID = ""; // 🏷️ Factory-set UID (NVS) - NO DEFAULT + String hwType = ""; // 🔧 Factory-set hardware type (NVS) - NO DEFAULT + String hwVersion = ""; // 📐 Factory-set hardware revision (NVS) - NO DEFAULT + String fwVersion = "0.0.0"; // 📋 Current firmware version (SD) - auto-updated + }; + + /** + * @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. + */ + struct NetworkConfig { + 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 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 + }; + + /** + * @struct MqttConfig + * @brief MQTT broker connection settings + * + * Cloud broker as default, can be overridden via SD card. + * Username defaults to deviceUID for unique identification. + */ + struct MqttConfig { + String host = "j2f24f16.ala.eu-central-1.emqxsl.com"; // 📡 Cloud MQTT broker (default) + int port = 1883; // 🔌 Standard MQTT port (default) + String user; // 👤 Auto-set to deviceUID + String password = "vesper"; // 🔑 Default password - OK as is + }; + + /** + * @struct HardwareConfig + * @brief Hardware-specific settings (compiled per HWID) + * + * These values are hardware-variant specific and compiled into firmware. + * Different values for different hardware revisions. + */ + struct HardwareConfig { + uint8_t pcf8574Address = 0x24; // 🔌 Hardware-specific - OK as is + uint8_t sdChipSelect = 5; // 💾 Hardware-specific - OK as is + + // Ethernet SPI configuration - hardware-specific + uint8_t ethSpiSck = 18; // ⏱️ Hardware-specific - OK as is + uint8_t ethSpiMiso = 19; // 🔄 Hardware-specific - OK as is + uint8_t ethSpiMosi = 23; // 🔄 Hardware-specific - OK as is + + // ETH PHY Configuration - hardware-specific + eth_phy_type_t ethPhyType = ETH_PHY_W5500; // 🔌 Hardware-specific - OK as is + uint8_t ethPhyAddr = 1; // 📍 Hardware-specific - OK as is + uint8_t ethPhyCs = 5; // 💾 Hardware-specific - OK as is + int8_t ethPhyIrq = -1; // ⚡ Hardware-specific - OK as is + int8_t ethPhyRst = -1; // 🔄 Hardware-specific - OK as is + }; + + /** + * @struct TimeConfig + * @brief Timezone and NTP settings + * + * NTP server is universal default. + * Timezone offsets default to 0 (UTC) and are configured via app. + */ + struct TimeConfig { + String ntpServer = "pool.ntp.org"; // ⏰ Universal NTP - OK as is + long gmtOffsetSec = 0; // 🌍 Default UTC, app-configurable via SD + int daylightOffsetSec = 0; // ☀️ Default no DST, app-configurable via SD + }; + + /** + * @struct UpdateConfig + * @brief OTA update server configuration + * + * Universal defaults for all devices. + */ + struct UpdateConfig { + String fallbackServerUrl = "http://firmware.bonamin.space"; // 🛡️ Universal fallback - OK as is + int timeout = 10000; // ⏱️ Universal timeout - OK as is + int retries = 3; // 🔄 Universal retries - OK as is + }; + + /** + * @struct BellConfig + * @brief Bell system configuration (loaded from SD) + * + * All bell settings are loaded from SD card at startup. + */ + struct BellConfig { + uint16_t durations[16]; // ⏱️ Loaded from SD at startup Factory Def: Min Size Hammer + uint16_t outputs[16]; // 🔌 Loaded from SD at startup. Factory Def: Disabled + }; + + /** + * @struct ClockConfig + * @brief Clock mechanism configuration (loaded from SD) + * + * All clock settings are loaded from SD card at startup. + * This struct is correctly designed and needs no changes. + */ + struct ClockConfig { + // ════════════════════════════════════════════════════════════════════════════ + // CLOCK ENABLE/DISABLE - Master control for all clock functionality + // ════════════════════════════════════════════════════════════════════════════ + bool enabled = false; // 🔘 Enable/disable ALL clock functionality + + // ════════════════════════════════════════════════════════════════════════════ + // CLOCK OUTPUTS - Physical output configuration + // ════════════════════════════════════════════════════════════════════════════ + uint8_t c1output = 255; // 🕐 Clock output #1 pin (255 = disabled) + uint8_t c2output = 255; // 🕑 Clock output #2 pin (255 = disabled) + uint16_t pulseDuration = 5000; // ⏱️ Pulse duration in milliseconds + uint16_t pauseDuration = 2000; // 🛑 Pause between consecutive pulses + + // ════════════════════════════════════════════════════════════════════════════ + // PHYSICAL CLOCK STATE - Position tracking for sync + // ════════════════════════════════════════════════════════════════════════════ + uint8_t physicalHour = 0; // 🕐 Physical clock hour (0-11) + uint8_t physicalMinute = 0; // 🕐 Physical clock minute (0-59) + bool nextOutputIsC1 = true; // 🔄 Which output fires next + uint32_t lastSyncTime = 0; // ⏰ Last successful sync timestamp + + // ════════════════════════════════════════════════════════════════════════════ + // ALERT CONFIGURATION - Bell alert behavior + // ════════════════════════════════════════════════════════════════════════════ + String alertType = "OFF"; // 🔔 Alert mode: "SINGLE", "HOURS", or "OFF" + uint16_t alertRingInterval = 1200; // ⏰ Interval between bell rings (ms) 1-2s 0.2s steps + uint8_t hourBell = 255; // 🕐 Bell for hourly alerts (255 = disabled) + uint8_t halfBell = 255; // 🕕 Bell for half-hour alerts (255 = disabled) + uint8_t quarterBell = 255; // 🕒 Bell for quarter-hour alerts (255 = disabled) + + // ════════════════════════════════════════════════════════════════════════════ + // BACKLIGHT AUTOMATION - Clock illumination control + // ════════════════════════════════════════════════════════════════════════════ + bool backlight = false; // 💡 Enable/disable backlight automation + uint8_t backlightOutput = 255; // 🔌 Backlight output pin (255 = disabled) + String backlightOnTime = "18:00"; // 🌅 Time to turn backlight ON + String backlightOffTime = "06:00"; // 🌄 Time to turn backlight OFF + + // ════════════════════════════════════════════════════════════════════════════ + // SILENCE PERIODS - Quiet hours configuration + // ════════════════════════════════════════════════════════════════════════════ + bool daytimeSilenceEnabled = false; // 🌞 Enable/disable daytime silence + String daytimeSilenceOnTime = "14:00"; // 🌞 Start of daytime silence + String daytimeSilenceOffTime = "17:00"; // 🌞 End of daytime silence + bool nighttimeSilenceEnabled = false; // 🌙 Enable/disable nighttime silence + String nighttimeSilenceOnTime = "22:00"; // 🌙 Start of nighttime silence + String nighttimeSilenceOffTime = "07:00"; // 🌙 End of nighttime silence + }; + +private: + // ═══════════════════════════════════════════════════════════════════════════════ + // MEMBER VARIABLES - Clean deployment-ready storage + // ═══════════════════════════════════════════════════════════════════════════════ + DeviceConfig deviceConfig; + NetworkConfig networkConfig; + MqttConfig mqttConfig; + HardwareConfig hardwareConfig; + TimeConfig timeConfig; + UpdateConfig updateConfig; + BellConfig bellConfig; + ClockConfig clockConfig; + + bool sdInitialized = false; + std::vector updateServers; + nvs_handle_t nvsHandle = 0; + static const char* NVS_NAMESPACE; + + // ═══════════════════════════════════════════════════════════════════════════════ + // PRIVATE METHODS - Clean initialization and auto-generation + // ═══════════════════════════════════════════════════════════════════════════════ + + /** + * @brief Initialize configuration with clean defaults and auto-generation + * + * This method: + * 1. Loads device identity from NVS (factory-set) + * 2. Auto-generates hostname from deviceUID + * 3. Auto-generates AP SSID from deviceUID + * 4. Sets MQTT username to deviceUID + * 5. Loads user-configurable settings from SD + */ + void initializeCleanDefaults(); + + /** + * @brief Auto-generate network identifiers from deviceUID + * + * Generates: + * - hostname: "BellSystems-" + * - apSsid: "BellSystems-Setup-" + * - mqttUser: deviceUID + */ + void generateNetworkIdentifiers(); + + bool ensureSDCard(); + void createDefaultBellConfig(); + + // NVS management (for factory-set device identity) + bool initializeNVS(); + bool loadDeviceIdentityFromNVS(); + bool saveDeviceIdentityToNVS(); + String readNVSString(const char* key, const String& defaultValue); + bool writeNVSString(const char* key, const String& value); + +public: + // ═══════════════════════════════════════════════════════════════════════════════ + // PUBLIC INTERFACE - Clean deployment-ready API + // ═══════════════════════════════════════════════════════════════════════════════ + + ConfigManager(); + + /** + * @brief Initialize clean deployment-ready configuration system + * + * Load order: + * 1. Device identity from NVS (factory-set, permanent) + * 2. Auto-generate network identifiers + * 3. Load user settings from SD card + * 4. Apply clean defaults for missing settings + */ + bool begin(); + + void loadFromSD(); + bool saveToSD(); + + // Configuration access (read-only getters) + const DeviceConfig& getDeviceConfig() const { return deviceConfig; } + const NetworkConfig& getNetworkConfig() const { return networkConfig; } + const MqttConfig& getMqttConfig() const { return mqttConfig; } + const HardwareConfig& getHardwareConfig() const { return hardwareConfig; } + const TimeConfig& getTimeConfig() const { return timeConfig; } + const UpdateConfig& getUpdateConfig() const { return updateConfig; } + const BellConfig& getBellConfig() const { return bellConfig; } + const ClockConfig& getClockConfig() const { return clockConfig; } + + // Device identity methods (read-only - factory set via separate firmware) + String getDeviceUID() const { return deviceConfig.deviceUID; } + String getHwType() const { return deviceConfig.hwType; } + String getHwVersion() const { return deviceConfig.hwVersion; } + String getFwVersion() const { return deviceConfig.fwVersion; } + + /** @brief Set device UID (factory programming only) */ + void setDeviceUID(const String& uid) { + deviceConfig.deviceUID = uid; + saveDeviceIdentityToNVS(); + generateNetworkIdentifiers(); + } + + /** @brief Set hardware type (factory programming only) */ + void setHwType(const String& type) { + deviceConfig.hwType = type; + saveDeviceIdentityToNVS(); + } + + /** @brief Set hardware version (factory programming only) */ + void setHwVersion(const String& version) { + deviceConfig.hwVersion = version; + saveDeviceIdentityToNVS(); + } + + /** @brief Set firmware version (auto-updated after OTA) */ + void setFwVersion(const String& version) { deviceConfig.fwVersion = version; } + + // Configuration update methods for app commands + void updateTimeConfig(long gmtOffsetSec, int daylightOffsetSec); + void updateNetworkConfig(bool useStaticIP, IPAddress ip, IPAddress gateway, + IPAddress subnet, IPAddress dns1, IPAddress dns2); + + // Network configuration persistence + bool loadNetworkConfig(); + bool saveNetworkConfig(); + + // Bell and clock configuration methods (unchanged) + bool loadBellDurations(); + bool saveBellDurations(); + void updateBellDurations(JsonVariant doc); + void updateBellOutputs(JsonVariant doc); + uint16_t getBellDuration(uint8_t bellIndex) const; + uint16_t getBellOutput(uint8_t bellIndex) const; + void setBellDuration(uint8_t bellIndex, uint16_t duration); + void setBellOutput(uint8_t bellIndex, uint16_t output); + + // Clock configuration methods (unchanged - already correct) + bool getClockEnabled() const { return clockConfig.enabled; } + void setClockEnabled(bool enabled) { clockConfig.enabled = enabled; } + void updateClockOutputs(JsonVariant doc); + uint8_t getClockOutput1() const { return clockConfig.c1output; } + uint8_t getClockOutput2() const { return clockConfig.c2output; } + uint16_t getClockPulseDuration() const { return clockConfig.pulseDuration; } + uint16_t getClockPauseDuration() const { return clockConfig.pauseDuration; } + void setClockOutput1(uint8_t output) { clockConfig.c1output = output; } + void setClockOutput2(uint8_t output) { clockConfig.c2output = output; } + void setClockPulseDuration(uint16_t duration) { clockConfig.pulseDuration = duration; } + void setClockPauseDuration(uint16_t duration) { clockConfig.pauseDuration = duration; } + + // Physical clock state methods (unchanged) + uint8_t getPhysicalClockHour() const { return clockConfig.physicalHour; } + uint8_t getPhysicalClockMinute() const { return clockConfig.physicalMinute; } + bool getNextOutputIsC1() const { return clockConfig.nextOutputIsC1; } + uint32_t getLastSyncTime() const { return clockConfig.lastSyncTime; } + void setPhysicalClockHour(uint8_t hour) { clockConfig.physicalHour = hour % 12; } + void setPhysicalClockMinute(uint8_t minute) { clockConfig.physicalMinute = minute % 60; } + void setNextOutputIsC1(bool isC1) { clockConfig.nextOutputIsC1 = isC1; } + void setLastSyncTime(uint32_t timestamp) { clockConfig.lastSyncTime = timestamp; } + + // Alert configuration methods (unchanged) + String getAlertType() const { return clockConfig.alertType; } + uint16_t getAlertRingInterval() const { return clockConfig.alertRingInterval; } + uint8_t getHourBell() const { return clockConfig.hourBell; } + uint8_t getHalfBell() const { return clockConfig.halfBell; } + uint8_t getQuarterBell() const { return clockConfig.quarterBell; } + void setAlertType(const String& type) { clockConfig.alertType = type; } + void setAlertRingInterval(uint16_t interval) { clockConfig.alertRingInterval = interval; } + void setHourBell(uint8_t bell) { clockConfig.hourBell = bell; } + void setHalfBell(uint8_t bell) { clockConfig.halfBell = bell; } + void setQuarterBell(uint8_t bell) { clockConfig.quarterBell = bell; } + + // Backlight configuration methods (unchanged) + bool getBacklightEnabled() const { return clockConfig.backlight; } + uint8_t getBacklightOutput() const { return clockConfig.backlightOutput; } + String getBacklightOnTime() const { return clockConfig.backlightOnTime; } + String getBacklightOffTime() const { return clockConfig.backlightOffTime; } + void setBacklightEnabled(bool enabled) { clockConfig.backlight = enabled; } + void setBacklightOutput(uint8_t output) { clockConfig.backlightOutput = output; } + void setBacklightOnTime(const String& time) { clockConfig.backlightOnTime = time; } + void setBacklightOffTime(const String& time) { clockConfig.backlightOffTime = time; } + + // Silence periods methods (unchanged) + bool getDaytimeSilenceEnabled() const { return clockConfig.daytimeSilenceEnabled; } + String getDaytimeSilenceOnTime() const { return clockConfig.daytimeSilenceOnTime; } + String getDaytimeSilenceOffTime() const { return clockConfig.daytimeSilenceOffTime; } + bool getNighttimeSilenceEnabled() const { return clockConfig.nighttimeSilenceEnabled; } + String getNighttimeSilenceOnTime() const { return clockConfig.nighttimeSilenceOnTime; } + String getNighttimeSilenceOffTime() const { return clockConfig.nighttimeSilenceOffTime; } + void setDaytimeSilenceEnabled(bool enabled) { clockConfig.daytimeSilenceEnabled = enabled; } + void setDaytimeSilenceOnTime(const String& time) { clockConfig.daytimeSilenceOnTime = time; } + void setDaytimeSilenceOffTime(const String& time) { clockConfig.daytimeSilenceOffTime = time; } + void setNighttimeSilenceEnabled(bool enabled) { clockConfig.nighttimeSilenceEnabled = enabled; } + void setNighttimeSilenceOnTime(const String& time) { clockConfig.nighttimeSilenceOnTime = time; } + void setNighttimeSilenceOffTime(const String& time) { clockConfig.nighttimeSilenceOffTime = time; } + + // Other methods (unchanged) + void updateClockAlerts(JsonVariant doc); + void updateClockBacklight(JsonVariant doc); + void updateClockSilence(JsonVariant doc); + bool loadClockConfig(); + bool saveClockConfig(); + bool loadClockState(); + bool saveClockState(); + void updateRealTimeFromCommand(JsonVariant doc); + void updatePhysicalClockTimeFromCommand(JsonVariant doc); + void saveFileToSD(const char* dirPath, const char* filename, const char* data); + String getHardwareVariant() const { return deviceConfig.hwType; } + bool loadDeviceConfig(); + bool saveDeviceConfig(); + bool loadUpdateServers(); + std::vector getUpdateServers() const; + String getAPSSID() const { return networkConfig.apSsid; } + bool isHealthy() const; + + // ═══════════════════════════════════════════════════════════════════════════════ + // FACTORY RESET + // ═══════════════════════════════════════════════════════════════════════════════ + + /** + * @brief Perform complete factory reset + * + * This method: + * 1. Deletes all configuration files from SD card + * 2. Does NOT touch NVS (device identity remains) + * 3. On next boot, all settings will be recreated with defaults + * + * @return true if factory reset successful + */ + bool factoryReset(); + + /** + * @brief Delete all settings files from SD card + * @return true if all files deleted successfully + */ + bool clearAllSettings(); +}; + +// ═══════════════════════════════════════════════════════════════════════════════════ +// DEPLOYMENT NOTES: +// 2. USER SETTINGS: All loaded from SD card, configured via app +// 3. NETWORK: WiFiManager handles credentials, no hardcoded SSIDs/passwords +// 4. IDENTIFIERS: Auto-generated from deviceUID for consistency +// 5. DEFAULTS: Clean minimal defaults, everything configurable +// ═══════════════════════════════════════════════════════════════════════════════════ diff --git a/vesper/src/FileManager/FileManager.cpp b/vesper/src/FileManager/FileManager.cpp new file mode 100644 index 0000000..f4da9ef --- /dev/null +++ b/vesper/src/FileManager/FileManager.cpp @@ -0,0 +1,241 @@ +#include "FileManager.hpp" + +FileManager::FileManager(ConfigManager* config) : configManager(config) { + // Constructor - store reference to ConfigManager +} + +bool FileManager::initializeSD() { + uint8_t sdPin = configManager->getHardwareConfig().sdChipSelect; + if (!SD.begin(sdPin)) { + LOG_ERROR("SD Card initialization failed!"); + return false; + } + return true; +} + +bool FileManager::addMelody(JsonVariant doc) { + LOG_INFO("Adding melody from JSON data..."); + + // Extract URL and filename from JSON + if (!doc.containsKey("download_url") || !doc.containsKey("melodys_uid")) { + LOG_ERROR("Missing required parameters: download_url or melodys_uid"); + return false; + } + + const char* url = doc["download_url"]; + const char* filename = doc["melodys_uid"]; + + // Download the melody file to /melodies directory + if (downloadFile(url, "/melodies", filename)) { + LOG_INFO("Melody download successful: %s", filename); + return true; + } + + LOG_ERROR("Melody download failed: %s", filename); + return false; +} + +bool FileManager::ensureDirectoryExists(const String& dirPath) { + if (!initializeSD()) { + return false; + } + + // Ensure the directory ends with '/' + String normalizedPath = dirPath; + if (!normalizedPath.endsWith("/")) { + normalizedPath += "/"; + } + + // Create directory if it doesn't exist + return SD.mkdir(normalizedPath.c_str()); +} + +bool FileManager::downloadFile(const String& url, const String& directory, const String& filename) { + LOG_INFO("Starting download from: %s", url.c_str()); + + HTTPClient http; + http.begin(url); + int httpCode = http.GET(); + + if (httpCode != HTTP_CODE_OK) { + LOG_ERROR("HTTP GET failed, error: %s", http.errorToString(httpCode).c_str()); + http.end(); + return false; + } + + if (!initializeSD()) { + http.end(); + return false; + } + + // Ensure directory exists + if (!ensureDirectoryExists(directory)) { + LOG_ERROR("Failed to create directory: %s", directory.c_str()); + http.end(); + return false; + } + + // Build full file path + String dirPath = directory; + if (!dirPath.endsWith("/")) dirPath += "/"; + String fullPath = dirPath + filename; + + File file = SD.open(fullPath.c_str(), FILE_WRITE); + if (!file) { + LOG_ERROR("Failed to open file for writing: %s", fullPath.c_str()); + http.end(); + return false; + } + + WiFiClient* stream = http.getStreamPtr(); + uint8_t buffer[1024]; + int bytesRead; + + while (http.connected() && (bytesRead = stream->readBytes(buffer, sizeof(buffer))) > 0) { + file.write(buffer, bytesRead); + } + + file.close(); + http.end(); + LOG_INFO("Download complete, file saved to: %s", fullPath.c_str()); + return true; +} + +String FileManager::listFilesAsJson(const char* dirPath) { + if (!initializeSD()) { + LOG_ERROR("SD initialization failed"); + return "{}"; + } + + File dir = SD.open(dirPath); + if (!dir || !dir.isDirectory()) { + LOG_ERROR("Directory not found: %s", dirPath); + return "{}"; + } + + DynamicJsonDocument doc(1024); + JsonArray fileList = doc.createNestedArray("files"); + + File file = dir.openNextFile(); + while (file) { + if (!file.isDirectory()) { + fileList.add(file.name()); + } + file = dir.openNextFile(); + } + + String json; + serializeJson(doc, json); + return json; +} + +bool FileManager::fileExists(const String& filePath) { + if (!initializeSD()) { + return false; + } + + File file = SD.open(filePath.c_str()); + if (file) { + file.close(); + return true; + } + return false; +} + +bool FileManager::deleteFile(const String& filePath) { + if (!initializeSD()) { + return false; + } + + if (SD.remove(filePath.c_str())) { + LOG_INFO("File deleted: %s", filePath.c_str()); + return true; + } else { + LOG_ERROR("Failed to delete file: %s", filePath.c_str()); + return false; + } +} + +bool FileManager::createDirectory(const String& dirPath) { + return ensureDirectoryExists(dirPath); +} + +size_t FileManager::getFileSize(const String& filePath) { + if (!initializeSD()) { + return 0; + } + + File file = SD.open(filePath.c_str()); + if (!file) { + return 0; + } + + size_t size = file.size(); + file.close(); + return size; +} + +// ════════════════════════════════════════════════════════════════════════════ +// HEALTH CHECK IMPLEMENTATION +// ════════════════════════════════════════════════════════════════════════════ + +bool FileManager::isHealthy() const { + // Check if ConfigManager is available + if (!configManager) { + LOG_DEBUG("FileManager: Unhealthy - ConfigManager not available"); + return false; + } + + // Check if SD card can be initialized + uint8_t sdPin = configManager->getHardwareConfig().sdChipSelect; + if (!SD.begin(sdPin)) { + LOG_DEBUG("FileManager: Unhealthy - SD Card initialization failed"); + return false; + } + + // Check if we can read from SD card (test with root directory) + File root = SD.open("/"); + if (!root) { + LOG_DEBUG("FileManager: Unhealthy - Cannot access SD root directory"); + return false; + } + + if (!root.isDirectory()) { + LOG_DEBUG("FileManager: Unhealthy - SD root is not a directory"); + root.close(); + return false; + } + + root.close(); + + // Check if we can write to SD card (create/delete a test file) + String testFile = "/health_test.tmp"; + File file = SD.open(testFile.c_str(), FILE_WRITE); + if (!file) { + LOG_DEBUG("FileManager: Unhealthy - Cannot write to SD card"); + return false; + } + + file.print("health_check"); + file.close(); + + // Verify we can read the test file + file = SD.open(testFile.c_str(), FILE_READ); + if (!file) { + LOG_DEBUG("FileManager: Unhealthy - Cannot read test file from SD card"); + return false; + } + + String content = file.readString(); + file.close(); + + // Clean up test file + SD.remove(testFile.c_str()); + + if (content != "health_check") { + LOG_DEBUG("FileManager: Unhealthy - SD card read/write test failed"); + return false; + } + + return true; +} diff --git a/vesper/src/FileManager/FileManager.hpp b/vesper/src/FileManager/FileManager.hpp new file mode 100644 index 0000000..3c91a61 --- /dev/null +++ b/vesper/src/FileManager/FileManager.hpp @@ -0,0 +1,61 @@ +/* + * ═══════════════════════════════════════════════════════════════════════════════════ + * FILEMANAGER.HPP - SD Card and File Operations Manager + * ═══════════════════════════════════════════════════════════════════════════════════ + * + * 📁 THE FILE SYSTEM ORCHESTRATOR OF VESPER 📁 + * + * This class provides a clean, robust interface for all file operations + * including melody file management, configuration persistence, and + * comprehensive error handling with automatic recovery. + * + * 📋 VERSION: 2.0 (Enhanced file management) + * 📅 DATE: 2025 + * 👨‍💻 AUTHOR: Advanced Bell Systems + * ═══════════════════════════════════════════════════════════════════════════════════ + */ + +#ifndef FILEMANAGER_HPP +#define FILEMANAGER_HPP + +#include +#include +#include +#include +#include +#include "../Logging/Logging.hpp" +#include "../ConfigManager/ConfigManager.hpp" + +class FileManager { +private: + ConfigManager* configManager; + +public: + // Constructor + FileManager(ConfigManager* config); + + // Core file operations + bool downloadFile(const String& url, const String& directory, const String& filename); + bool addMelody(JsonVariant doc); // Download melody file from JSON data + String listFilesAsJson(const char* dirPath); + + // File utilities + bool fileExists(const String& filePath); + bool deleteFile(const String& filePath); + bool createDirectory(const String& dirPath); + size_t getFileSize(const String& filePath); + + // ═══════════════════════════════════════════════════════════════════════════════ + // HEALTH CHECK METHOD + // ═══════════════════════════════════════════════════════════════════════════════ + + /** @brief Check if FileManager is in healthy state */ + bool isHealthy() const; + +private: + // Helper functions + bool initializeSD(); + bool ensureDirectoryExists(const String& dirPath); +}; + +#endif diff --git a/vesper/src/FirmwareValidator/FirmwareValidator.cpp b/vesper/src/FirmwareValidator/FirmwareValidator.cpp new file mode 100644 index 0000000..0b0bd8f --- /dev/null +++ b/vesper/src/FirmwareValidator/FirmwareValidator.cpp @@ -0,0 +1,697 @@ +/* + * ═══════════════════════════════════════════════════════════════════════════════════ + * FIRMWAREVALIDATOR.CPP - Bulletproof Firmware Update Validation Implementation + * ═══════════════════════════════════════════════════════════════════════════════════ + */ + +#include "FirmwareValidator.hpp" +#include "../HealthMonitor/HealthMonitor.hpp" +#include "../ConfigManager/ConfigManager.hpp" +#include +#include +#include + +// NVS keys for persistent validation state +static const char* NVS_NAMESPACE = "fw_validator"; +static const char* NVS_STATE_KEY = "val_state"; +static const char* NVS_BOOT_COUNT_KEY = "boot_count"; +static const char* NVS_RETRY_COUNT_KEY = "retry_count"; +static const char* NVS_FAILURE_COUNT_KEY = "fail_count"; +static const char* NVS_LAST_BOOT_KEY = "last_boot"; + +FirmwareValidator::FirmwareValidator() { + // Initialize default configuration + _config = ValidationConfig(); +} + +FirmwareValidator::~FirmwareValidator() { + // Clean up resources + if (_validationTimer) { + xTimerDelete(_validationTimer, portMAX_DELAY); + _validationTimer = nullptr; + } + + if (_monitoringTask) { + vTaskDelete(_monitoringTask); + _monitoringTask = nullptr; + } + + if (_nvsHandle) { + nvs_close(_nvsHandle); + _nvsHandle = 0; + } +} + +bool FirmwareValidator::begin(HealthMonitor* healthMonitor, ConfigManager* configManager) { + LOG_INFO("🛡️ Initializing Firmware Validator System"); + + _healthMonitor = healthMonitor; + _configManager = configManager; + + // Initialize NVS for persistent state storage + if (!initializeNVS()) { + LOG_ERROR("❌ Failed to initialize NVS for firmware validation"); + return false; + } + + // Initialize ESP32 partition information + if (!initializePartitions()) { + LOG_ERROR("❌ Failed to initialize ESP32 partitions"); + return false; + } + + // Load previous validation state + loadValidationState(); + + LOG_INFO("✅ Firmware Validator initialized"); + LOG_INFO("📍 Running partition: %s", getPartitionLabel(_runningPartition).c_str()); + LOG_INFO("📍 Backup partition: %s", getPartitionLabel(_backupPartition).c_str()); + LOG_INFO("🔄 Validation state: %s", validationStateToString(_validationState).c_str()); + + return true; +} + +bool FirmwareValidator::performStartupValidation() { + LOG_INFO("🚀 Starting firmware startup validation..."); + + // Check if this is a new firmware that needs validation + const esp_partition_t* bootPartition = esp_ota_get_boot_partition(); + const esp_partition_t* runningPartition = esp_ota_get_running_partition(); + + if (bootPartition != runningPartition) { + LOG_WARNING("⚠️ Boot partition differs from running partition!"); + LOG_WARNING(" Boot: %s", getPartitionLabel(bootPartition).c_str()); + LOG_WARNING(" Running: %s", getPartitionLabel(runningPartition).c_str()); + } + + // Increment boot count for this session + incrementBootCount(); + + // Check if we need to validate new firmware + if (_validationState == FirmwareValidationState::UNKNOWN) { + // First boot of potentially new firmware + _validationState = FirmwareValidationState::STARTUP_PENDING; + LOG_INFO("🆕 New firmware detected - entering validation mode"); + } + + if (_validationState == FirmwareValidationState::STARTUP_PENDING) { + LOG_INFO("🔍 Performing startup validation..."); + _validationState = FirmwareValidationState::STARTUP_RUNNING; + _validationStartTime = millis(); + + // Perform basic health checks with timeout protection + unsigned long startTime = millis(); + bool healthCheckPassed = false; + + while ((millis() - startTime) < _config.startupTimeoutMs) { + healthCheckPassed = performBasicHealthCheck(); + if (healthCheckPassed) { + break; + } + + LOG_WARNING("⚠️ Startup health check failed, retrying..."); + delay(1000); // Wait 1 second before retry + } + + if (healthCheckPassed) { + _validationState = FirmwareValidationState::RUNTIME_TESTING; + _startupRetryCount = 0; // Reset retry count on success + saveValidationState(); + LOG_INFO("✅ Firmware startup validation PASSED - proceeding with initialization"); + return true; + } else { + LOG_ERROR("❌ Startup validation FAILED after %lu ms", _config.startupTimeoutMs); + _startupRetryCount++; + + if (_startupRetryCount >= _config.maxStartupRetries) { + LOG_ERROR("💥 Maximum startup retries exceeded - triggering rollback"); + handleValidationFailure("Startup validation failed repeatedly"); + return false; // This will trigger rollback and reboot + } else { + LOG_WARNING("🔄 Startup retry %d/%d - rebooting...", + _startupRetryCount, _config.maxStartupRetries); + saveValidationState(); + delay(1000); + ESP.restart(); + return false; + } + } + } else if (_validationState == FirmwareValidationState::VALIDATED) { + LOG_INFO("✅ Firmware already validated - normal operation"); + return true; + } else if (_validationState == FirmwareValidationState::STARTUP_RUNNING) { + // Handle interrupted validation from previous boot + LOG_INFO("🔄 Resuming interrupted validation - transitioning to runtime testing"); + _validationState = FirmwareValidationState::RUNTIME_TESTING; + saveValidationState(); + return true; + } else if (_validationState == FirmwareValidationState::RUNTIME_TESTING) { + // Already in runtime testing from previous boot + LOG_INFO("🔄 Continuing runtime validation from previous session"); + return true; + } else { + LOG_WARNING("⚠️ Unexpected validation state: %s", + validationStateToString(_validationState).c_str()); + return true; // Continue anyway + } +} + +void FirmwareValidator::startRuntimeValidation() { + if (_validationState != FirmwareValidationState::RUNTIME_TESTING) { + LOG_WARNING("⚠️ Runtime validation called in wrong state: %s", + validationStateToString(_validationState).c_str()); + return; + } + + LOG_INFO("🏃 Starting extended runtime validation (%lu ms timeout)", + _config.runtimeTimeoutMs); + + _validationStartTime = millis(); + + // Create validation timer + _validationTimer = xTimerCreate( + "FW_Validation", + pdMS_TO_TICKS(_config.runtimeTimeoutMs), + pdFALSE, // One-shot timer + this, + validationTimerCallback + ); + + if (_validationTimer) { + xTimerStart(_validationTimer, 0); + } else { + LOG_ERROR("❌ Failed to create validation timer"); + handleValidationFailure("Timer creation failed"); + return; + } + + // Create monitoring task for continuous health checks + xTaskCreatePinnedToCore( + monitoringTaskFunction, + "FW_Monitor", + 4096, + this, + 4, // Higher priority than health monitor + &_monitoringTask, + 0 // Core 0 + ); + + if (!_monitoringTask) { + LOG_ERROR("❌ Failed to create monitoring task"); + handleValidationFailure("Monitoring task creation failed"); + return; + } + + // Setup watchdog if enabled + if (_config.enableWatchdog) { + setupWatchdog(); + } + + LOG_INFO("✅ Runtime validation started - monitoring system health..."); +} + +void FirmwareValidator::commitFirmware() { + if (_validationState == FirmwareValidationState::VALIDATED) { + LOG_INFO("✅ Firmware already committed"); + return; + } + + LOG_INFO("💾 Committing firmware as valid and stable..."); + + // Mark current partition as valid boot partition + esp_err_t err = esp_ota_set_boot_partition(_runningPartition); + if (err != ESP_OK) { + LOG_ERROR("❌ Failed to set boot partition: %s", esp_err_to_name(err)); + return; + } + + _validationState = FirmwareValidationState::VALIDATED; + resetValidationCounters(); + saveValidationState(); + + // Clean up validation resources + if (_validationTimer) { + xTimerDelete(_validationTimer, portMAX_DELAY); + _validationTimer = nullptr; + } + + if (_monitoringTask) { + vTaskDelete(_monitoringTask); + _monitoringTask = nullptr; + } + + LOG_INFO("🎉 Firmware successfully committed! System is now stable."); +} + +void FirmwareValidator::rollbackFirmware() { + LOG_WARNING("🔄 Manual firmware rollback requested"); + handleValidationFailure("Manual rollback requested"); +} + +// ... [rest of implementation continues with all the private methods] ... + +bool FirmwareValidator::initializeNVS() { + esp_err_t err = nvs_flash_init(); + if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) { + ESP_ERROR_CHECK(nvs_flash_erase()); + err = nvs_flash_init(); + } + + if (err != ESP_OK) { + LOG_ERROR("❌ Failed to initialize NVS flash: %s", esp_err_to_name(err)); + return false; + } + + err = nvs_open(NVS_NAMESPACE, NVS_READWRITE, &_nvsHandle); + if (err != ESP_OK) { + LOG_ERROR("❌ Failed to open NVS namespace: %s", esp_err_to_name(err)); + return false; + } + + return true; +} + +void FirmwareValidator::loadValidationState() { + esp_err_t err; + + // Load validation state + uint8_t state = static_cast(FirmwareValidationState::UNKNOWN); + err = nvs_get_u8(_nvsHandle, NVS_STATE_KEY, &state); + if (err == ESP_OK) { + _validationState = static_cast(state); + LOG_DEBUG("📖 NVS validation state found: %s", validationStateToString(_validationState).c_str()); + } else { + LOG_DEBUG("📖 No NVS validation state found, using UNKNOWN (error: %s)", esp_err_to_name(err)); + _validationState = FirmwareValidationState::UNKNOWN; + } + + // Load retry counts + nvs_get_u8(_nvsHandle, NVS_RETRY_COUNT_KEY, &_startupRetryCount); + nvs_get_u8(_nvsHandle, NVS_FAILURE_COUNT_KEY, &_runtimeFailureCount); + + LOG_DEBUG("📖 Loaded validation state: %s (retries: %d, failures: %d)", + validationStateToString(_validationState).c_str(), + _startupRetryCount, _runtimeFailureCount); +} + +void FirmwareValidator::saveValidationState() { + esp_err_t err; + + // Save validation state + err = nvs_set_u8(_nvsHandle, NVS_STATE_KEY, static_cast(_validationState)); + if (err != ESP_OK) { + LOG_ERROR("❌ Failed to save validation state: %s", esp_err_to_name(err)); + } + + // Save retry counts + nvs_set_u8(_nvsHandle, NVS_RETRY_COUNT_KEY, _startupRetryCount); + nvs_set_u8(_nvsHandle, NVS_FAILURE_COUNT_KEY, _runtimeFailureCount); + + // Save timestamp + unsigned long currentTime = millis(); + nvs_set_u32(_nvsHandle, NVS_LAST_BOOT_KEY, currentTime); + + // Commit changes + err = nvs_commit(_nvsHandle); + if (err != ESP_OK) { + LOG_ERROR("❌ Failed to commit NVS changes: %s", esp_err_to_name(err)); + } + + LOG_DEBUG("💾 Saved validation state: %s", validationStateToString(_validationState).c_str()); +} + +bool FirmwareValidator::initializePartitions() { + _runningPartition = esp_ota_get_running_partition(); + if (!_runningPartition) { + LOG_ERROR("❌ Failed to get running partition"); + return false; + } + + // Find the other OTA partition (backup) + esp_partition_iterator_t iterator = esp_partition_find(ESP_PARTITION_TYPE_APP, + ESP_PARTITION_SUBTYPE_APP_OTA_0, NULL); + if (iterator) { + const esp_partition_t* ota0 = esp_partition_get(iterator); + esp_partition_iterator_release(iterator); + + iterator = esp_partition_find(ESP_PARTITION_TYPE_APP, + ESP_PARTITION_SUBTYPE_APP_OTA_1, NULL); + if (iterator) { + const esp_partition_t* ota1 = esp_partition_get(iterator); + esp_partition_iterator_release(iterator); + + // Determine which is the backup partition + if (_runningPartition == ota0) { + _backupPartition = ota1; + } else { + _backupPartition = ota0; + } + } + } + + if (!_backupPartition) { + LOG_ERROR("❌ Failed to find backup partition"); + return false; + } + + return true; +} + +bool FirmwareValidator::performBasicHealthCheck() { + LOG_VERBOSE("🔍 Performing basic startup health check..."); + + // Check if health monitor is available + if (!_healthMonitor) { + LOG_ERROR("❌ Health monitor not available"); + return false; + } + + // Check critical subsystems only + bool bellEngineOk = (_healthMonitor->checkSubsystemHealth("BellEngine") == HealthStatus::HEALTHY); + bool outputManagerOk = (_healthMonitor->checkSubsystemHealth("OutputManager") == HealthStatus::HEALTHY); + bool configManagerOk = (_healthMonitor->checkSubsystemHealth("ConfigManager") == HealthStatus::HEALTHY); + bool fileManagerOk = (_healthMonitor->checkSubsystemHealth("FileManager") == HealthStatus::HEALTHY); + + bool basicHealthOk = bellEngineOk && outputManagerOk && configManagerOk && fileManagerOk; + + if (!basicHealthOk) { + LOG_ERROR("❌ Basic health check failed:"); + if (!bellEngineOk) LOG_ERROR(" - BellEngine: FAILED"); + if (!outputManagerOk) LOG_ERROR(" - OutputManager: FAILED"); + if (!configManagerOk) LOG_ERROR(" - ConfigManager: FAILED"); + if (!fileManagerOk) LOG_ERROR(" - FileManager: FAILED"); + } else { + LOG_VERBOSE("✅ Basic health check passed"); + } + + return basicHealthOk; +} + +bool FirmwareValidator::performRuntimeHealthCheck() { + LOG_VERBOSE("🔍 Performing comprehensive runtime health check..."); + + if (!_healthMonitor) { + return false; + } + + // Perform full health check + HealthStatus overallHealth = _healthMonitor->performFullHealthCheck(); + uint8_t criticalFailures = _healthMonitor->getCriticalFailureCount(); + + bool runtimeHealthOk = (overallHealth == HealthStatus::HEALTHY || overallHealth == HealthStatus::WARNING) + && (criticalFailures == 0); + + if (!runtimeHealthOk) { + LOG_WARNING("⚠️ Runtime health check failed - Critical failures: %d, Overall: %s", + criticalFailures, + (overallHealth == HealthStatus::HEALTHY) ? "HEALTHY" : + (overallHealth == HealthStatus::WARNING) ? "WARNING" : + (overallHealth == HealthStatus::CRITICAL) ? "CRITICAL" : "FAILED"); + } + + return runtimeHealthOk; +} + +void FirmwareValidator::validationTimerCallback(TimerHandle_t timer) { + FirmwareValidator* validator = static_cast(pvTimerGetTimerID(timer)); + + LOG_INFO("⏰ Runtime validation timeout reached - committing firmware"); + validator->handleValidationSuccess(); +} + +void FirmwareValidator::monitoringTaskFunction(void* parameter) { + FirmwareValidator* validator = static_cast(parameter); + LOG_INFO("🔍 Firmware validation monitoring task started on Core %d", xPortGetCoreID()); + + validator->monitoringLoop(); + + // Task should not reach here normally + LOG_WARNING("⚠️ Firmware validation monitoring task ended unexpectedly"); + vTaskDelete(NULL); +} + +void FirmwareValidator::monitoringLoop() { + while (_validationState == FirmwareValidationState::RUNTIME_TESTING) { + // Feed watchdog if enabled + if (_config.enableWatchdog) { + feedWatchdog(); + } + + // Perform runtime health check + bool healthOk = performRuntimeHealthCheck(); + + if (!healthOk) { + _runtimeFailureCount++; + LOG_WARNING("⚠️ Runtime health check failed (%d/%d failures)", + _runtimeFailureCount, _config.maxRuntimeFailures); + + if (_runtimeFailureCount >= _config.maxRuntimeFailures) { + LOG_ERROR("💥 Maximum runtime failures exceeded - triggering rollback"); + handleValidationFailure("Too many runtime health check failures"); + return; + } + } else { + // Reset failure count on successful health check + if (_runtimeFailureCount > 0) { + _runtimeFailureCount = 0; + LOG_INFO("✅ Runtime health recovered - reset failure count"); + } + } + + // Wait before next health check + vTaskDelay(pdMS_TO_TICKS(_config.healthCheckIntervalMs)); + } +} + +void FirmwareValidator::handleValidationSuccess() { + LOG_INFO("🎉 Firmware validation completed successfully!"); + commitFirmware(); +} + +void FirmwareValidator::handleValidationFailure(const String& reason) { + LOG_ERROR("💥 Firmware validation FAILED: %s", reason.c_str()); + LOG_ERROR("🔄 Initiating firmware rollback..."); + + _validationState = FirmwareValidationState::FAILED_RUNTIME; + saveValidationState(); + + executeRollback(); +} + +void FirmwareValidator::executeRollback() { + LOG_WARNING("🔄 Executing firmware rollback to previous version..."); + + // Clean up validation resources first + if (_validationTimer) { + xTimerDelete(_validationTimer, portMAX_DELAY); + _validationTimer = nullptr; + } + + if (_monitoringTask) { + vTaskDelete(_monitoringTask); + _monitoringTask = nullptr; + } + + // Mark current firmware as invalid and rollback + esp_err_t err = esp_ota_mark_app_invalid_rollback_and_reboot(); + + if (err != ESP_OK) { + LOG_ERROR("❌ Failed to rollback firmware: %s", esp_err_to_name(err)); + LOG_ERROR("💀 System may be in unstable state - manual intervention required"); + + // If rollback fails, try manual reboot to backup partition + LOG_WARNING("🆘 Attempting manual reboot to backup partition..."); + + if (_backupPartition) { + esp_ota_set_boot_partition(_backupPartition); + delay(1000); + ESP.restart(); + } else { + LOG_ERROR("💀 No backup partition available - system halt"); + while(1) { + delay(1000); // Hang here to prevent further damage + } + } + } + + // This point should not be reached as the device should reboot + LOG_ERROR("💀 Rollback function returned unexpectedly"); +} + +FirmwareInfo FirmwareValidator::getCurrentFirmwareInfo() const { + FirmwareInfo info; + + if (_configManager) { + info.version = _configManager->getFwVersion(); + info.buildDate = __DATE__ " " __TIME__; + // Add more info as needed + } + + info.isValid = (_validationState == FirmwareValidationState::VALIDATED); + info.isTesting = isInTestingMode(); + info.bootCount = getBootCount(); + info.lastBootTime = millis(); + + return info; +} + +FirmwareInfo FirmwareValidator::getBackupFirmwareInfo() const { + FirmwareInfo info; + + // This would require reading partition metadata + // For now, return basic info + info.version = "Unknown"; + info.isValid = isPartitionValid(_backupPartition); + info.isTesting = false; + + return info; +} + +bool FirmwareValidator::isHealthy() const { + // Check if validator itself is in a good state + bool nvsOk = (_nvsHandle != 0); + bool partitionsOk = (_runningPartition != nullptr && _backupPartition != nullptr); + bool dependenciesOk = (_healthMonitor != nullptr && _configManager != nullptr); + bool stateOk = (_validationState != FirmwareValidationState::FAILED_STARTUP && + _validationState != FirmwareValidationState::FAILED_RUNTIME); + + return nvsOk && partitionsOk && dependenciesOk && stateOk; +} + +const esp_partition_t* FirmwareValidator::getRunningPartition() const { + return _runningPartition; +} + +const esp_partition_t* FirmwareValidator::getBackupPartition() const { + return _backupPartition; +} + +bool FirmwareValidator::isNewFirmwarePending() const { + // Check if there's a new firmware in the backup partition that hasn't been tested + return (_validationState == FirmwareValidationState::STARTUP_PENDING || + _validationState == FirmwareValidationState::STARTUP_RUNNING || + _validationState == FirmwareValidationState::RUNTIME_TESTING); +} + +String FirmwareValidator::validationStateToString(FirmwareValidationState state) const { + switch (state) { + case FirmwareValidationState::UNKNOWN: + return "UNKNOWN"; + case FirmwareValidationState::STARTUP_PENDING: + return "STARTUP_PENDING"; + case FirmwareValidationState::STARTUP_RUNNING: + return "STARTUP_RUNNING"; + case FirmwareValidationState::RUNTIME_TESTING: + return "RUNTIME_TESTING"; + case FirmwareValidationState::VALIDATED: + return "VALIDATED"; + case FirmwareValidationState::FAILED_STARTUP: + return "FAILED_STARTUP"; + case FirmwareValidationState::FAILED_RUNTIME: + return "FAILED_RUNTIME"; + case FirmwareValidationState::ROLLED_BACK: + return "ROLLED_BACK"; + default: + return "INVALID"; + } +} + +String FirmwareValidator::getPartitionLabel(const esp_partition_t* partition) const { + if (!partition) { + return "NULL"; + } + + String label = String(partition->label); + if (label.isEmpty()) { + // Generate label based on subtype + switch (partition->subtype) { + case ESP_PARTITION_SUBTYPE_APP_OTA_0: + label = "ota_0"; + break; + case ESP_PARTITION_SUBTYPE_APP_OTA_1: + label = "ota_1"; + break; + default: + label = "app_" + String(partition->subtype); + break; + } + } + + return label; +} + +bool FirmwareValidator::isPartitionValid(const esp_partition_t* partition) const { + if (!partition) { + return false; + } + + // Check if partition has valid app header + esp_image_header_t header; + esp_err_t err = esp_partition_read(partition, 0, &header, sizeof(header)); + + if (err != ESP_OK) { + return false; + } + + // Check magic number + return (header.magic == ESP_IMAGE_HEADER_MAGIC); +} + +unsigned long FirmwareValidator::getBootCount() const { + uint32_t bootCount = 0; + nvs_get_u32(_nvsHandle, NVS_BOOT_COUNT_KEY, &bootCount); + return bootCount; +} + +void FirmwareValidator::incrementBootCount() { + uint32_t bootCount = getBootCount(); + bootCount++; + nvs_set_u32(_nvsHandle, NVS_BOOT_COUNT_KEY, bootCount); + nvs_commit(_nvsHandle); + + LOG_DEBUG("📊 Boot count: %lu", bootCount); +} + +void FirmwareValidator::resetValidationCounters() { + _startupRetryCount = 0; + _runtimeFailureCount = 0; + + // Also reset in NVS + nvs_set_u8(_nvsHandle, NVS_RETRY_COUNT_KEY, 0); + nvs_set_u8(_nvsHandle, NVS_FAILURE_COUNT_KEY, 0); + nvs_commit(_nvsHandle); + + LOG_DEBUG("🔄 Reset validation counters"); +} + +void FirmwareValidator::setupWatchdog() { + // Check if watchdog is already initialized + esp_task_wdt_config_t config = { + .timeout_ms = _config.watchdogTimeoutMs, + .idle_core_mask = (1 << portNUM_PROCESSORS) - 1, + .trigger_panic = true + }; + + esp_err_t err = esp_task_wdt_init(&config); + if (err == ESP_ERR_INVALID_STATE) { + LOG_DEBUG("🐕 Watchdog already initialized - skipping init"); + } else if (err != ESP_OK) { + LOG_WARNING("⚠️ Failed to initialize task watchdog: %s", esp_err_to_name(err)); + return; + } + + // Try to add current task to watchdog + err = esp_task_wdt_add(NULL); + if (err == ESP_ERR_INVALID_ARG) { + LOG_DEBUG("🐕 Task already added to watchdog"); + } else if (err != ESP_OK) { + LOG_WARNING("⚠️ Failed to add task to watchdog: %s", esp_err_to_name(err)); + return; + } + + LOG_INFO("🐕 Watchdog enabled with %lu second timeout", _config.watchdogTimeoutMs / 1000); +} + +void FirmwareValidator::feedWatchdog() { + esp_task_wdt_reset(); +} diff --git a/vesper/src/FirmwareValidator/FirmwareValidator.hpp b/vesper/src/FirmwareValidator/FirmwareValidator.hpp new file mode 100644 index 0000000..2c62d7e --- /dev/null +++ b/vesper/src/FirmwareValidator/FirmwareValidator.hpp @@ -0,0 +1,392 @@ +/* + * ═══════════════════════════════════════════════════════════════════════════════════ + * FIRMWAREVALIDATOR.HPP - Bulletproof Firmware Update Validation System + * ═══════════════════════════════════════════════════════════════════════════════════ + * + * 🛡️ THE FIRMWARE SAFETY GUARDIAN OF VESPER 🛡️ + * + * This class implements a bulletproof firmware update system using ESP32's dual + * partition architecture. It ensures that firmware updates are safe, validated, + * and can automatically rollback if critical systems fail during startup or runtime. + * + * 🏗️ ARCHITECTURE: + * • Dual partition management (OTA_0 and OTA_1) + * • Immediate startup health checks within first few seconds + * • Progressive validation from basic → comprehensive health checks + * • Automatic rollback to previous working firmware on critical failures + * • Testing mode for new firmware validation + * • Permanent commit only after full health validation + * + * 🔄 FIRMWARE UPDATE FLOW: + * 1. Download new firmware to inactive partition + * 2. Boot new firmware in "TESTING" mode + * 3. Immediate basic health check (within 10 seconds) + * - If FAIL → Automatic rollback via esp_ota_mark_app_invalid_rollback_and_reboot() + * 4. Extended validation period (configurable, default 5 minutes) + * - Continuous health monitoring of all critical subsystems + * - If PASS → Mark partition as valid via esp_ota_set_boot_partition() + * - If FAIL → Rollback to previous firmware + * 5. Normal operation mode + * + * 🚨 SAFETY MECHANISMS: + * • Watchdog integration to handle complete system hangs + * • Multi-level health checks (startup → runtime → comprehensive) + * • Configurable validation timeouts and retry counts + * • Persistent validation state tracking + * • Emergency rollback triggers + * + * 🔍 VALIDATION LEVELS: + * • STARTUP: Basic system initialization (I2C, SD, GPIO) + * • RUNTIME: Core functionality (BellEngine, OutputManager, Player) + * • COMPREHENSIVE: Full system health (Network, MQTT, Telemetry) + * + * 📋 VERSION: 1.0 (Initial firmware validation system) + * 📅 DATE: 2025 + * 👨‍💻 AUTHOR: Advanced Bell Systems + * ═══════════════════════════════════════════════════════════════════════════════════ + */ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include "../Logging/Logging.hpp" + +// Forward declarations +class HealthMonitor; +class ConfigManager; + +/** + * @enum FirmwareValidationState + * @brief Current state of firmware validation process + */ +enum class FirmwareValidationState { + UNKNOWN, // Initial state, validation status unknown + STARTUP_PENDING, // New firmware booted, startup validation pending + STARTUP_RUNNING, // Currently performing startup validation + RUNTIME_TESTING, // Startup passed, now in extended runtime testing + VALIDATED, // Firmware fully validated and committed + FAILED_STARTUP, // Failed startup validation, rollback triggered + FAILED_RUNTIME, // Failed runtime validation, rollback triggered + ROLLED_BACK // Successfully rolled back to previous firmware +}; + +/** + * @enum ValidationLevel + * @brief Different levels of health validation + */ +enum class ValidationLevel { + STARTUP, // Basic system initialization checks + RUNTIME, // Core functionality validation + COMPREHENSIVE // Full system health validation +}; + +/** + * @struct FirmwareInfo + * @brief Information about a firmware partition + */ +struct FirmwareInfo { + String version; + String buildDate; + String commitHash; + bool isValid; + bool isTesting; + unsigned long bootCount; + unsigned long lastBootTime; + + FirmwareInfo() : version(""), buildDate(""), commitHash(""), + isValid(false), isTesting(false), bootCount(0), lastBootTime(0) {} +}; + +/** + * @struct ValidationConfig + * @brief Configuration parameters for firmware validation + */ +struct ValidationConfig { + unsigned long startupTimeoutMs = 10000; // 10 seconds for startup validation + unsigned long runtimeTimeoutMs = 300000; // 5 minutes for runtime validation + unsigned long healthCheckIntervalMs = 30000; // 30 seconds between health checks + uint8_t maxStartupRetries = 3; // Max startup failures before rollback + uint8_t maxRuntimeFailures = 5; // Max runtime failures before rollback + bool enableWatchdog = true; // Enable watchdog protection + unsigned long watchdogTimeoutMs = 30000; // 30 seconds watchdog timeout +}; + +/** + * @class FirmwareValidator + * @brief Bulletproof firmware update validation and rollback system + * + * This class manages the complete firmware validation lifecycle, from initial + * boot to full system validation, with automatic rollback capabilities for + * failed firmware updates. + */ +class FirmwareValidator { +public: + // ═══════════════════════════════════════════════════════════════════════════════ + // CONSTRUCTOR & INITIALIZATION + // ═══════════════════════════════════════════════════════════════════════════════ + + /** + * @brief Constructor - Initialize firmware validation system + */ + FirmwareValidator(); + + /** + * @brief Destructor - Clean up resources + */ + ~FirmwareValidator(); + + /** + * @brief Initialize firmware validation system + * @param healthMonitor Reference to system health monitor + * @param configManager Reference to configuration manager + * @return true if initialization successful + */ + bool begin(HealthMonitor* healthMonitor, ConfigManager* configManager); + + // ═══════════════════════════════════════════════════════════════════════════════ + // STARTUP VALIDATION (CRITICAL - MUST BE CALLED EARLY IN setup()) + // ═══════════════════════════════════════════════════════════════════════════════ + + /** + * @brief Perform immediate startup validation + * + * This MUST be called early in setup() before initializing other subsystems. + * If validation fails, the system will automatically rollback and reboot. + * + * @return true if startup validation passes, false if rollback was triggered + */ + bool performStartupValidation(); + + /** + * @brief Check if current firmware is in testing mode + * @return true if firmware is being tested and not yet committed + */ + bool isInTestingMode() const { return _validationState == FirmwareValidationState::STARTUP_PENDING || + _validationState == FirmwareValidationState::STARTUP_RUNNING || + _validationState == FirmwareValidationState::RUNTIME_TESTING; } + + // ═══════════════════════════════════════════════════════════════════════════════ + // RUNTIME VALIDATION & MONITORING + // ═══════════════════════════════════════════════════════════════════════════════ + + /** + * @brief Start extended runtime validation + * + * Call this after all subsystems are initialized to begin the extended + * validation period before committing the firmware. + */ + void startRuntimeValidation(); + + /** + * @brief Manually trigger firmware validation completion + * + * This commits the current firmware as valid and stable. + * Normally called automatically after successful runtime validation. + */ + void commitFirmware(); + + /** + * @brief Manually trigger firmware rollback + * + * Forces an immediate rollback to the previous firmware version. + * Use this for emergency situations or failed validations. + */ + void rollbackFirmware(); + + // ═══════════════════════════════════════════════════════════════════════════════ + // STATUS & INFORMATION + // ═══════════════════════════════════════════════════════════════════════════════ + + /** + * @brief Get current validation state + * @return Current firmware validation state + */ + FirmwareValidationState getValidationState() const { return _validationState; } + + /** + * @brief Get information about the current running firmware + * @return Firmware information structure + */ + FirmwareInfo getCurrentFirmwareInfo() const; + + /** + * @brief Get information about the backup firmware partition + * @return Firmware information structure for backup partition + */ + FirmwareInfo getBackupFirmwareInfo() const; + + /** + * @brief Get validation configuration + * @return Current validation configuration + */ + const ValidationConfig& getValidationConfig() const { return _config; } + + /** + * @brief Set validation configuration + * @param config New validation configuration + */ + void setValidationConfig(const ValidationConfig& config) { _config = config; } + + /** + * @brief Check if firmware validation system is healthy + * @return true if validation system is functioning properly + */ + bool isHealthy() const; + + // ═══════════════════════════════════════════════════════════════════════════════ + // PARTITION MANAGEMENT + // ═══════════════════════════════════════════════════════════════════════════════ + + /** + * @brief Get the currently running OTA partition + * @return Pointer to running partition, nullptr on error + */ + const esp_partition_t* getRunningPartition() const; + + /** + * @brief Get the backup (inactive) OTA partition + * @return Pointer to backup partition, nullptr on error + */ + const esp_partition_t* getBackupPartition() const; + + /** + * @brief Check if a new firmware update is available for testing + * @return true if there's a new firmware waiting to be validated + */ + bool isNewFirmwarePending() const; + +private: + // ═══════════════════════════════════════════════════════════════════════════════ + // PRIVATE MEMBERS + // ═══════════════════════════════════════════════════════════════════════════════ + HealthMonitor* _healthMonitor = nullptr; + ConfigManager* _configManager = nullptr; + + FirmwareValidationState _validationState = FirmwareValidationState::UNKNOWN; + ValidationConfig _config; + + // Partition information + const esp_partition_t* _runningPartition = nullptr; + const esp_partition_t* _backupPartition = nullptr; + + // Validation tracking + unsigned long _validationStartTime = 0; + uint8_t _startupRetryCount = 0; + uint8_t _runtimeFailureCount = 0; + TimerHandle_t _validationTimer = nullptr; + TaskHandle_t _monitoringTask = nullptr; + + // NVS handles for persistent storage + nvs_handle_t _nvsHandle = 0; + + // ═══════════════════════════════════════════════════════════════════════════════ + // PRIVATE METHODS + // ═══════════════════════════════════════════════════════════════════════════════ + + /** + * @brief Initialize NVS storage for validation state persistence + */ + bool initializeNVS(); + + /** + * @brief Load validation state from NVS + */ + void loadValidationState(); + + /** + * @brief Save validation state to NVS + */ + void saveValidationState(); + + /** + * @brief Initialize ESP32 partition information + */ + bool initializePartitions(); + + /** + * @brief Perform basic startup health checks + */ + bool performBasicHealthCheck(); + + /** + * @brief Perform comprehensive runtime health checks + */ + bool performRuntimeHealthCheck(); + + /** + * @brief Timer callback for validation timeout + */ + static void validationTimerCallback(TimerHandle_t timer); + + /** + * @brief Monitoring task for continuous health checks during validation + */ + static void monitoringTaskFunction(void* parameter); + + /** + * @brief Main monitoring loop + */ + void monitoringLoop(); + + /** + * @brief Handle validation success + */ + void handleValidationSuccess(); + + /** + * @brief Handle validation failure + */ + void handleValidationFailure(const String& reason); + + /** + * @brief Execute firmware rollback + */ + void executeRollback(); + + /** + * @brief Convert validation state to string + */ + String validationStateToString(FirmwareValidationState state) const; + + /** + * @brief Get partition label string + */ + String getPartitionLabel(const esp_partition_t* partition) const; + + /** + * @brief Check if partition has valid firmware + */ + bool isPartitionValid(const esp_partition_t* partition) const; + + /** + * @brief Get boot count for current session + */ + unsigned long getBootCount() const; + + /** + * @brief Increment boot count + */ + void incrementBootCount(); + + /** + * @brief Reset validation counters + */ + void resetValidationCounters(); + + /** + * @brief Setup watchdog protection + */ + void setupWatchdog(); + + /** + * @brief Feed the watchdog timer + */ + void feedWatchdog(); +}; diff --git a/vesper/src/HealthMonitor/HealthMonitor.cpp b/vesper/src/HealthMonitor/HealthMonitor.cpp new file mode 100644 index 0000000..3c967af --- /dev/null +++ b/vesper/src/HealthMonitor/HealthMonitor.cpp @@ -0,0 +1,428 @@ +/* + * ═══════════════════════════════════════════════════════════════════════════════════ + * HEALTHMONITOR.CPP - System Health Monitoring Implementation + * ═══════════════════════════════════════════════════════════════════════════════════ + */ + +#include "HealthMonitor.hpp" +#include "../BellEngine/BellEngine.hpp" +#include "../OutputManager/OutputManager.hpp" +#include "../Communication/Communication.hpp" +#include "../Player/Player.hpp" +#include "../TimeKeeper/TimeKeeper.hpp" +#include "../Telemetry/Telemetry.hpp" +#include "../OTAManager/OTAManager.hpp" +#include "../Networking/Networking.hpp" +#include "../ConfigManager/ConfigManager.hpp" +#include "../FileManager/FileManager.hpp" +#include + +HealthMonitor::HealthMonitor() { + initializeSubsystemHealth(); +} + +HealthMonitor::~HealthMonitor() { + if (_monitoringTaskHandle != nullptr) { + vTaskDelete(_monitoringTaskHandle); + _monitoringTaskHandle = nullptr; + } +} + +bool HealthMonitor::begin() { + LOG_INFO("🏥 Initializing Health Monitor System"); + + // Create monitoring task if auto-monitoring is enabled + if (_autoMonitoring) { + xTaskCreatePinnedToCore( + monitoringTask, + "HealthMonitor", + 4096, + this, + 3, // Medium priority + &_monitoringTaskHandle, + 0 // Core 0 (different from BellEngine which uses Core 1) + ); + + if (_monitoringTaskHandle != nullptr) { + LOG_INFO("✅ Health Monitor initialized with automatic monitoring"); + return true; + } else { + LOG_ERROR("❌ Failed to create Health Monitor task"); + return false; + } + } else { + LOG_INFO("✅ Health Monitor initialized (manual mode)"); + return true; + } +} + +void HealthMonitor::initializeSubsystemHealth() { + // Initialize all subsystem health entries + // Mark critical subsystems that must be healthy for operation + + _subsystemHealth["BellEngine"] = SubsystemHealth("BellEngine", true); + _subsystemHealth["OutputManager"] = SubsystemHealth("OutputManager", true); + _subsystemHealth["ConfigManager"] = SubsystemHealth("ConfigManager", true); + _subsystemHealth["FileManager"] = SubsystemHealth("FileManager", true); + _subsystemHealth["Communication"] = SubsystemHealth("Communication", false); // Non-critical + _subsystemHealth["Player"] = SubsystemHealth("Player", true); + _subsystemHealth["TimeKeeper"] = SubsystemHealth("TimeKeeper", false); // Non-critical + _subsystemHealth["Telemetry"] = SubsystemHealth("Telemetry", false); // Non-critical + _subsystemHealth["OTAManager"] = SubsystemHealth("OTAManager", false); // Non-critical + _subsystemHealth["Networking"] = SubsystemHealth("Networking", false); // Non-critical + + LOG_DEBUG("🏗️ Initialized health monitoring for %d subsystems", _subsystemHealth.size()); +} + +void HealthMonitor::monitoringTask(void* parameter) { + HealthMonitor* monitor = static_cast(parameter); + LOG_INFO("🏥 Health Monitor task started on Core %d", xPortGetCoreID()); + + while (true) { + monitor->monitoringLoop(); + vTaskDelay(pdMS_TO_TICKS(monitor->_healthCheckInterval)); + } +} + +void HealthMonitor::monitoringLoop() { + + if (_player) { + if (_player->_status != PlayerStatus::STOPPED) { + LOG_VERBOSE("⏸️ Skipping health check during active playback"); + return; + } + } + + LOG_VERBOSE("🔍 Performing periodic health check..."); + + HealthStatus overallHealth = performFullHealthCheck(); + + // Log warnings for any unhealthy subsystems + uint8_t criticalCount = getCriticalFailureCount(); + uint8_t warningCount = getWarningCount(); + + if (criticalCount > 0) { + LOG_WARNING("🚨 Health Monitor: %d critical failures detected!", criticalCount); + + // List critical failures + for (const auto& [name, health] : _subsystemHealth) { + if (health.status == HealthStatus::CRITICAL || health.status == HealthStatus::FAILED) { + LOG_ERROR("❌ CRITICAL: %s - %s", name.c_str(), health.lastError.c_str()); + } + } + + // Check if firmware rollback is recommended + if (shouldRollbackFirmware()) { + LOG_ERROR("🔄 FIRMWARE ROLLBACK RECOMMENDED - Too many critical failures"); + // In a real system, this would trigger an OTA rollback + // For now, we just log the recommendation + } + } else if (warningCount > 0) { + LOG_WARNING("⚠️ Health Monitor: %d warnings detected", warningCount); + } else { + LOG_VERBOSE("✅ All subsystems healthy"); + } +} + +HealthStatus HealthMonitor::performFullHealthCheck() { + unsigned long startTime = millis(); + uint8_t checkedSystems = 0; + + // Check BellEngine + if (_bellEngine) { + bool healthy = _bellEngine->isHealthy(); + updateSubsystemHealth("BellEngine", + healthy ? HealthStatus::HEALTHY : HealthStatus::CRITICAL, + healthy ? "" : "BellEngine health check failed"); + checkedSystems++; + } + + // Check OutputManager + if (_outputManager) { + bool healthy = _outputManager->isHealthy(); + updateSubsystemHealth("OutputManager", + healthy ? HealthStatus::HEALTHY : HealthStatus::CRITICAL, + healthy ? "" : "OutputManager health check failed"); + checkedSystems++; + } + + // Check Communication + if (_communication) { + bool healthy = _communication->isHealthy(); + updateSubsystemHealth("Communication", + healthy ? HealthStatus::HEALTHY : HealthStatus::WARNING, + healthy ? "" : "Communication health check failed"); + checkedSystems++; + } + + // Check Player + if (_player) { + bool healthy = _player->isHealthy(); + updateSubsystemHealth("Player", + healthy ? HealthStatus::HEALTHY : HealthStatus::CRITICAL, + healthy ? "" : "Player health check failed"); + checkedSystems++; + } + + // Check TimeKeeper + if (_timeKeeper) { + bool healthy = _timeKeeper->isHealthy(); + updateSubsystemHealth("TimeKeeper", + healthy ? HealthStatus::HEALTHY : HealthStatus::WARNING, + healthy ? "" : "TimeKeeper health check failed"); + checkedSystems++; + } + + // Check Telemetry + if (_telemetry) { + bool healthy = _telemetry->isHealthy(); + updateSubsystemHealth("Telemetry", + healthy ? HealthStatus::HEALTHY : HealthStatus::WARNING, + healthy ? "" : "Telemetry health check failed"); + checkedSystems++; + } + + // Check OTAManager + if (_otaManager) { + bool healthy = _otaManager->isHealthy(); + updateSubsystemHealth("OTAManager", + healthy ? HealthStatus::HEALTHY : HealthStatus::WARNING, + healthy ? "" : "OTAManager health check failed"); + checkedSystems++; + } + + // Check Networking + if (_networking) { + bool healthy = _networking->isHealthy(); + updateSubsystemHealth("Networking", + healthy ? HealthStatus::HEALTHY : HealthStatus::WARNING, + healthy ? "" : "Networking health check failed"); + checkedSystems++; + } + + // Check ConfigManager + if (_configManager) { + bool healthy = _configManager->isHealthy(); + updateSubsystemHealth("ConfigManager", + healthy ? HealthStatus::HEALTHY : HealthStatus::CRITICAL, + healthy ? "" : "ConfigManager health check failed"); + checkedSystems++; + } + + // Check FileManager + if (_fileManager) { + bool healthy = _fileManager->isHealthy(); + updateSubsystemHealth("FileManager", + healthy ? HealthStatus::HEALTHY : HealthStatus::CRITICAL, + healthy ? "" : "FileManager health check failed"); + checkedSystems++; + } + + unsigned long elapsed = millis() - startTime; + LOG_VERBOSE("🔍 Health check completed: %d systems in %lums", checkedSystems, elapsed); + + return calculateOverallHealth(); +} + +HealthStatus HealthMonitor::checkSubsystemHealth(const String& subsystemName) { + // Perform health check on specific subsystem + auto it = _subsystemHealth.find(subsystemName); + if (it == _subsystemHealth.end()) { + LOG_WARNING("❓ Unknown subsystem: %s", subsystemName.c_str()); + return HealthStatus::FAILED; + } + + bool healthy = false; + + // Check specific subsystem + if (subsystemName == "BellEngine" && _bellEngine) { + healthy = _bellEngine->isHealthy(); + } else if (subsystemName == "OutputManager" && _outputManager) { + healthy = _outputManager->isHealthy(); + } else if (subsystemName == "Communication" && _communication) { + healthy = _communication->isHealthy(); + } else if (subsystemName == "Player" && _player) { + healthy = _player->isHealthy(); + } else if (subsystemName == "TimeKeeper" && _timeKeeper) { + healthy = _timeKeeper->isHealthy(); + } else if (subsystemName == "Telemetry" && _telemetry) { + healthy = _telemetry->isHealthy(); + } else if (subsystemName == "OTAManager" && _otaManager) { + healthy = _otaManager->isHealthy(); + } else if (subsystemName == "Networking" && _networking) { + healthy = _networking->isHealthy(); + } else if (subsystemName == "ConfigManager" && _configManager) { + healthy = _configManager->isHealthy(); + } else if (subsystemName == "FileManager" && _fileManager) { + healthy = _fileManager->isHealthy(); + } else { + LOG_WARNING("🔌 Subsystem %s not connected to health monitor", subsystemName.c_str()); + return HealthStatus::FAILED; + } + + HealthStatus status = healthy ? HealthStatus::HEALTHY : + (it->second.isCritical ? HealthStatus::CRITICAL : HealthStatus::WARNING); + + updateSubsystemHealth(subsystemName, status, + healthy ? "" : subsystemName + " health check failed"); + + return status; +} + +const std::map& HealthMonitor::getAllSubsystemHealth() const { + return _subsystemHealth; +} + +SubsystemHealth HealthMonitor::getSubsystemHealth(const String& subsystemName) const { + auto it = _subsystemHealth.find(subsystemName); + if (it != _subsystemHealth.end()) { + return it->second; + } + + // Return default unhealthy status for unknown subsystems + SubsystemHealth unknown(subsystemName); + unknown.status = HealthStatus::FAILED; + unknown.lastError = "Subsystem not found"; + return unknown; +} + +bool HealthMonitor::isFirmwareStable() const { + return areCriticalSubsystemsHealthy() && (getCriticalFailureCount() == 0); +} + +uint8_t HealthMonitor::getCriticalFailureCount() const { + uint8_t count = 0; + + for (const auto& [name, health] : _subsystemHealth) { + if (health.isCritical && + (health.status == HealthStatus::CRITICAL || health.status == HealthStatus::FAILED)) { + count++; + } + } + + return count; +} + +uint8_t HealthMonitor::getWarningCount() const { + uint8_t count = 0; + + for (const auto& [name, health] : _subsystemHealth) { + if (health.status == HealthStatus::WARNING) { + count++; + } + } + + return count; +} + +bool HealthMonitor::shouldRollbackFirmware() const { + uint8_t criticalFailures = getCriticalFailureCount(); + + // Rollback if more than 2 critical subsystems have failed + // This is configurable based on system requirements + const uint8_t MAX_CRITICAL_FAILURES = 2; + + return criticalFailures > MAX_CRITICAL_FAILURES; +} + +String HealthMonitor::generateHealthReport() const { + StaticJsonDocument<2048> doc; + + doc["timestamp"] = millis(); + doc["overall_health"] = healthStatusToString(calculateOverallHealth()); + doc["critical_failures"] = getCriticalFailureCount(); + doc["warnings"] = getWarningCount(); + doc["firmware_stable"] = isFirmwareStable(); + doc["rollback_recommended"] = shouldRollbackFirmware(); + + JsonObject subsystems = doc.createNestedObject("subsystems"); + + for (const auto& [name, health] : _subsystemHealth) { + JsonObject subsystem = subsystems.createNestedObject(name); + subsystem["status"] = healthStatusToString(health.status); + subsystem["critical"] = health.isCritical; + subsystem["last_check"] = health.lastCheck; + + if (!health.lastError.isEmpty()) { + subsystem["error"] = health.lastError; + } + } + + String report; + serializeJsonPretty(doc, report); + return report; +} + +String HealthMonitor::getHealthSummary() const { + HealthStatus overall = calculateOverallHealth(); + uint8_t critical = getCriticalFailureCount(); + uint8_t warnings = getWarningCount(); + + String summary = "System Health: " + healthStatusToString(overall); + + if (critical > 0) { + summary += " (" + String(critical) + " critical failures)"; + } + + if (warnings > 0) { + summary += " (" + String(warnings) + " warnings)"; + } + + if (shouldRollbackFirmware()) { + summary += " - ROLLBACK RECOMMENDED"; + } + + return summary; +} + +void HealthMonitor::updateSubsystemHealth(const String& name, HealthStatus status, const String& error) { + auto it = _subsystemHealth.find(name); + if (it != _subsystemHealth.end()) { + it->second.status = status; + it->second.lastError = error; + it->second.lastCheck = millis(); + + LOG_VERBOSE("🔍 %s: %s %s", + name.c_str(), + healthStatusToString(status).c_str(), + error.isEmpty() ? "" : ("(" + error + ")").c_str()); + } +} + +bool HealthMonitor::areCriticalSubsystemsHealthy() const { + for (const auto& [name, health] : _subsystemHealth) { + if (health.isCritical && + (health.status == HealthStatus::CRITICAL || health.status == HealthStatus::FAILED)) { + return false; + } + } + return true; +} + +HealthStatus HealthMonitor::calculateOverallHealth() const { + bool hasCriticalFailures = (getCriticalFailureCount() > 0); + bool hasWarnings = (getWarningCount() > 0); + + if (hasCriticalFailures) { + return HealthStatus::CRITICAL; + } else if (hasWarnings) { + return HealthStatus::WARNING; + } else { + return HealthStatus::HEALTHY; + } +} + +String HealthMonitor::healthStatusToString(HealthStatus status) const { + switch (status) { + case HealthStatus::HEALTHY: + return "HEALTHY"; + case HealthStatus::WARNING: + return "WARNING"; + case HealthStatus::CRITICAL: + return "CRITICAL"; + case HealthStatus::FAILED: + return "FAILED"; + default: + return "UNKNOWN"; + } +} diff --git a/vesper/src/HealthMonitor/HealthMonitor.hpp b/vesper/src/HealthMonitor/HealthMonitor.hpp new file mode 100644 index 0000000..5d5e93a --- /dev/null +++ b/vesper/src/HealthMonitor/HealthMonitor.hpp @@ -0,0 +1,314 @@ +/* + * ═══════════════════════════════════════════════════════════════════════════════════ + * HEALTHMONITOR.HPP - System Health Monitoring and Firmware Validation + * ═══════════════════════════════════════════════════════════════════════════════════ + * + * 🏥 THE SYSTEM HEALTH GUARDIAN OF VESPER 🏥 + * + * This class provides comprehensive system health monitoring across all subsystems. + * It determines whether the current firmware is stable and functional, or if a + * rollback to the previous firmware version should be performed. + * + * 🏗️ ARCHITECTURE: + * • Periodic health checks across all major subsystems + * • Critical vs non-critical failure classification + * • Firmware stability validation and rollback decision making + * • Centralized health status reporting + * • Thread-safe operation with configurable check intervals + * + * 🔍 MONITORED SUBSYSTEMS: + * • BellEngine: Core timing and bell control system + * • OutputManager: Hardware abstraction layer + * • Communication: MQTT, WebSocket, and UDP protocols + * • Player: Melody playback management + * • TimeKeeper: RTC and time synchronization + * • Telemetry: System monitoring and analytics + * • OTAManager: Firmware update management + * • Networking: Network connectivity management + * • ConfigManager: Configuration and persistence + * • FileManager: SD card and file operations + * + * 🚨 FAILURE CLASSIFICATION: + * • CRITICAL: Failures that make the device unusable + * • WARNING: Failures that affect functionality but allow operation + * • INFO: Minor issues that don't affect core functionality + * + * 🔄 FIRMWARE VALIDATION: + * • Boot-time stability check + * • Runtime health monitoring + * • Automatic rollback decision making + * • Health status persistence + * + * 📋 VERSION: 1.0 (Initial health monitoring system) + * 📅 DATE: 2025 + * 👨‍💻 AUTHOR: Advanced Bell Systems + * ═══════════════════════════════════════════════════════════════════════════════════ + */ + +#pragma once + +#include +#include +#include +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "../Logging/Logging.hpp" + +// Forward declarations for all monitored subsystems +class BellEngine; +class OutputManager; +class Communication; +class Player; +class Timekeeper; +class Telemetry; +class OTAManager; +class Networking; +class ConfigManager; +class FileManager; + +/** + * @enum HealthStatus + * @brief Health status levels for subsystems + */ +enum class HealthStatus { + HEALTHY, // System is functioning normally + WARNING, // System has minor issues but is operational + CRITICAL, // System has major issues affecting functionality + FAILED // System is non-functional +}; + +/** + * @struct SubsystemHealth + * @brief Health information for a single subsystem + */ +struct SubsystemHealth { + String name; // Subsystem name + HealthStatus status; // Current health status + String lastError; // Last error message (if any) + unsigned long lastCheck; // Timestamp of last health check + bool isCritical; // Whether this subsystem is critical for operation + + // Default constructor for std::map compatibility + SubsystemHealth() + : name(""), status(HealthStatus::HEALTHY), lastCheck(0), isCritical(false) {} + + SubsystemHealth(const String& n, bool critical = false) + : name(n), status(HealthStatus::HEALTHY), lastCheck(0), isCritical(critical) {} +}; + +/** + * @class HealthMonitor + * @brief Comprehensive system health monitoring and firmware validation + * + * The HealthMonitor continuously monitors all subsystems to ensure the firmware + * is stable and functional. It can make decisions about firmware rollbacks + * based on the overall system health. + */ +class HealthMonitor { +public: + // ═══════════════════════════════════════════════════════════════════════════════ + // CONSTRUCTOR & INITIALIZATION + // ═══════════════════════════════════════════════════════════════════════════════ + + /** + * @brief Constructor - Initialize health monitoring system + */ + HealthMonitor(); + + /** + * @brief Destructor - Clean up resources + */ + ~HealthMonitor(); + + /** + * @brief Initialize health monitoring system + * @return true if initialization successful + */ + bool begin(); + + // ═══════════════════════════════════════════════════════════════════════════════ + // SUBSYSTEM REGISTRATION + // ═══════════════════════════════════════════════════════════════════════════════ + + /** @brief Register BellEngine for monitoring */ + void setBellEngine(BellEngine* bellEngine) { _bellEngine = bellEngine; } + + /** @brief Register OutputManager for monitoring */ + void setOutputManager(OutputManager* outputManager) { _outputManager = outputManager; } + + /** @brief Register Communication for monitoring */ + void setCommunication(Communication* communication) { _communication = communication; } + + /** @brief Register Player for monitoring */ + void setPlayer(Player* player) { _player = player; } + + /** @brief Register TimeKeeper for monitoring */ + void setTimeKeeper(Timekeeper* timeKeeper) { _timeKeeper = timeKeeper; } + + /** @brief Register Telemetry for monitoring */ + void setTelemetry(Telemetry* telemetry) { _telemetry = telemetry; } + + /** @brief Register OTAManager for monitoring */ + void setOTAManager(OTAManager* otaManager) { _otaManager = otaManager; } + + /** @brief Register Networking for monitoring */ + void setNetworking(Networking* networking) { _networking = networking; } + + /** @brief Register ConfigManager for monitoring */ + void setConfigManager(ConfigManager* configManager) { _configManager = configManager; } + + /** @brief Register FileManager for monitoring */ + void setFileManager(FileManager* fileManager) { _fileManager = fileManager; } + + // ═══════════════════════════════════════════════════════════════════════════════ + // HEALTH CHECK METHODS + // ═══════════════════════════════════════════════════════════════════════════════ + + /** + * @brief Perform comprehensive health check on all subsystems + * @return Overall system health status + */ + HealthStatus performFullHealthCheck(); + + /** + * @brief Perform health check on a specific subsystem + * @param subsystemName Name of the subsystem to check + * @return Health status of the specified subsystem + */ + HealthStatus checkSubsystemHealth(const String& subsystemName); + + /** + * @brief Get current health status of all subsystems + * @return Map of subsystem names to their health information + */ + const std::map& getAllSubsystemHealth() const; + + /** + * @brief Get health status of a specific subsystem + * @param subsystemName Name of the subsystem + * @return Health information for the subsystem + */ + SubsystemHealth getSubsystemHealth(const String& subsystemName) const; + + // ═══════════════════════════════════════════════════════════════════════════════ + // FIRMWARE VALIDATION + // ═══════════════════════════════════════════════════════════════════════════════ + + /** + * @brief Check if current firmware is stable and should be kept + * @return true if firmware is stable, false if rollback is recommended + */ + bool isFirmwareStable() const; + + /** + * @brief Get the number of critical failures detected + * @return Count of subsystems with critical failures + */ + uint8_t getCriticalFailureCount() const; + + /** + * @brief Get the number of warning-level issues detected + * @return Count of subsystems with warning-level issues + */ + uint8_t getWarningCount() const; + + /** + * @brief Check if a firmware rollback is recommended + * @return true if rollback is recommended due to critical failures + */ + bool shouldRollbackFirmware() const; + + // ═══════════════════════════════════════════════════════════════════════════════ + // HEALTH REPORTING + // ═══════════════════════════════════════════════════════════════════════════════ + + /** + * @brief Generate a comprehensive health report + * @return JSON string containing detailed health information + */ + String generateHealthReport() const; + + /** + * @brief Get a summary of system health + * @return Brief health summary string + */ + String getHealthSummary() const; + + // ═══════════════════════════════════════════════════════════════════════════════ + // CONFIGURATION + // ═══════════════════════════════════════════════════════════════════════════════ + + /** + * @brief Set health check interval + * @param intervalMs Interval between health checks in milliseconds + */ + void setHealthCheckInterval(unsigned long intervalMs) { _healthCheckInterval = intervalMs; } + + /** + * @brief Enable or disable automatic health monitoring + * @param enabled Whether to enable automatic monitoring + */ + void setAutoMonitoring(bool enabled) { _autoMonitoring = enabled; } + +private: + // ═══════════════════════════════════════════════════════════════════════════════ + // SUBSYSTEM REFERENCES + // ═══════════════════════════════════════════════════════════════════════════════ + BellEngine* _bellEngine = nullptr; + OutputManager* _outputManager = nullptr; + Communication* _communication = nullptr; + Player* _player = nullptr; + Timekeeper* _timeKeeper = nullptr; + Telemetry* _telemetry = nullptr; + OTAManager* _otaManager = nullptr; + Networking* _networking = nullptr; + ConfigManager* _configManager = nullptr; + FileManager* _fileManager = nullptr; + + // ═══════════════════════════════════════════════════════════════════════════════ + // HEALTH MONITORING STATE + // ═══════════════════════════════════════════════════════════════════════════════ + std::map _subsystemHealth; + TaskHandle_t _monitoringTaskHandle = nullptr; + unsigned long _healthCheckInterval = 300000; // 5 minutes default + bool _autoMonitoring = true; + + // ═══════════════════════════════════════════════════════════════════════════════ + // PRIVATE HELPER METHODS + // ═══════════════════════════════════════════════════════════════════════════════ + + /** + * @brief Initialize all subsystem health entries + */ + void initializeSubsystemHealth(); + + /** + * @brief Monitoring task function + */ + static void monitoringTask(void* parameter); + + /** + * @brief Main monitoring loop + */ + void monitoringLoop(); + + /** + * @brief Update health status for a specific subsystem + */ + void updateSubsystemHealth(const String& name, HealthStatus status, const String& error = ""); + + /** + * @brief Check if enough critical subsystems are healthy + */ + bool areCriticalSubsystemsHealthy() const; + + /** + * @brief Calculate overall system health based on subsystem status + */ + HealthStatus calculateOverallHealth() const; + + /** + * @brief Convert health status to string + */ + String healthStatusToString(HealthStatus status) const; +}; diff --git a/vesper/src/InputManager/InputManager.cpp b/vesper/src/InputManager/InputManager.cpp new file mode 100644 index 0000000..f1a33d1 --- /dev/null +++ b/vesper/src/InputManager/InputManager.cpp @@ -0,0 +1,266 @@ +#include "InputManager.hpp" +#include "../Logging/Logging.hpp" + +// Static instance pointer +InputManager* InputManager::_instance = nullptr; + +// ═══════════════════════════════════════════════════════════════════════════════════ +// CONSTRUCTOR & DESTRUCTOR +// ═══════════════════════════════════════════════════════════════════════════════════ + +InputManager::InputManager() + : _initialized(false) + , _inputTaskHandle(nullptr) { + // Initialize factory reset button configuration + // GPIO 0, Active LOW (pull-up), 50ms debounce, 10s long press + _factoryResetButton.config = ButtonConfig(0, false, 50, 10000); + + _instance = this; +} + +InputManager::~InputManager() { + end(); + _instance = nullptr; +} + +// ═══════════════════════════════════════════════════════════════════════════════════ +// INITIALIZATION +// ═══════════════════════════════════════════════════════════════════════════════════ + +bool InputManager::begin() { + LOG_INFO("InputManager: Initializing input handling system"); + + // Configure factory reset button + configureButton(_factoryResetButton.config); + + // Initialize button state + _factoryResetButton.state = ButtonState::IDLE; + _factoryResetButton.lastRawState = false; + _factoryResetButton.stateChangeTime = millis(); + _factoryResetButton.pressStartTime = 0; + _factoryResetButton.longPressTriggered = false; + + // Create FreeRTOS task for input polling + BaseType_t result = xTaskCreate( + inputTaskFunction, // Task function + "InputTask", // Task name + INPUT_TASK_STACK_SIZE, // Stack size + this, // Parameter (this instance) + INPUT_TASK_PRIORITY, // Priority + &_inputTaskHandle // Task handle + ); + + if (result != pdPASS) { + LOG_ERROR("InputManager: Failed to create input task!"); + return false; + } + + _initialized = true; + + LOG_INFO("InputManager: Initialization complete - Factory Reset on GPIO 0 (Task running)"); + return true; +} + +void InputManager::end() { + if (_inputTaskHandle != nullptr) { + vTaskDelete(_inputTaskHandle); + _inputTaskHandle = nullptr; + LOG_INFO("InputManager: Input task stopped"); + } + _initialized = false; +} + +// ═══════════════════════════════════════════════════════════════════════════════════ +// CALLBACK REGISTRATION +// ═══════════════════════════════════════════════════════════════════════════════════ + +void InputManager::setFactoryResetPressCallback(ButtonCallback callback) { + _factoryResetButton.config.onPress = callback; + LOG_DEBUG("InputManager: Factory reset press callback registered"); +} + +void InputManager::setFactoryResetLongPressCallback(ButtonCallback callback) { + _factoryResetButton.config.onLongPress = callback; + LOG_DEBUG("InputManager: Factory reset long press callback registered"); +} + +// ═══════════════════════════════════════════════════════════════════════════════════ +// STATUS METHODS +// ═══════════════════════════════════════════════════════════════════════════════════ + +bool InputManager::isFactoryResetPressed() const { + return _factoryResetButton.state != ButtonState::IDLE; +} + +uint32_t InputManager::getFactoryResetPressDuration() const { + if (_factoryResetButton.state == ButtonState::IDLE) { + return 0; + } + return millis() - _factoryResetButton.pressStartTime; +} + +bool InputManager::isHealthy() const { + if (!_initialized) { + LOG_DEBUG("InputManager: Unhealthy - not initialized"); + return false; + } + + // Could add more health checks here if needed + return true; +} + +// ═══════════════════════════════════════════════════════════════════════════════════ +// FREERTOS TASK +// ═══════════════════════════════════════════════════════════════════════════════════ + +void InputManager::inputTaskFunction(void* parameter) { + InputManager* manager = static_cast(parameter); + + LOG_INFO("InputManager: Input task started (polling every %dms)", INPUT_POLL_RATE_MS); + + TickType_t lastWakeTime = xTaskGetTickCount(); + const TickType_t pollInterval = pdMS_TO_TICKS(INPUT_POLL_RATE_MS); + + while (true) { + // Update button states + manager->update(); + + // Wait for next poll interval + vTaskDelayUntil(&lastWakeTime, pollInterval); + } +} + +// ═══════════════════════════════════════════════════════════════════════════════════ +// UPDATE LOGIC (Called by task) +// ═══════════════════════════════════════════════════════════════════════════════════ + +void InputManager::update() { + if (!_initialized) { + return; + } + + // Update factory reset button + updateButton(_factoryResetButton); +} + +// ═══════════════════════════════════════════════════════════════════════════════════ +// PRIVATE METHODS +// ═══════════════════════════════════════════════════════════════════════════════════ + +void InputManager::configureButton(const ButtonConfig& config) { + // Configure pin as input with pull-up (for active-low buttons) + if (config.activeHigh) { + pinMode(config.pin, INPUT); + } else { + pinMode(config.pin, INPUT_PULLUP); + } + + LOG_DEBUG("InputManager: Configured GPIO %d as input (%s)", + config.pin, config.activeHigh ? "active-high" : "active-low"); +} + +bool InputManager::readButtonState(const ButtonData& button) const { + bool rawState = digitalRead(button.config.pin); + + // Invert reading if active-low + if (!button.config.activeHigh) { + rawState = !rawState; + } + + return rawState; +} + +void InputManager::updateButton(ButtonData& button) { + uint32_t now = millis(); + bool currentState = readButtonState(button); + + // State machine for button handling + switch (button.state) { + case ButtonState::IDLE: + // Waiting for button press + if (currentState && !button.lastRawState) { + // Button just pressed - start debouncing + button.state = ButtonState::DEBOUNCING_PRESS; + button.stateChangeTime = now; + LOG_DEBUG("InputManager: Button press detected on GPIO %d - debouncing", + button.config.pin); + } + break; + + case ButtonState::DEBOUNCING_PRESS: + // Debouncing press + if (!currentState) { + // Button released during debounce - false trigger + button.state = ButtonState::IDLE; + LOG_DEBUG("InputManager: False trigger on GPIO %d (released during debounce)", + button.config.pin); + } else if (now - button.stateChangeTime >= button.config.debounceMs) { + // Debounce time passed - confirm press + button.state = ButtonState::LONG_PRESS_PENDING; + button.pressStartTime = now; + button.longPressTriggered = false; + LOG_INFO("InputManager: Button press confirmed on GPIO %d", + button.config.pin); + } + break; + + case ButtonState::LONG_PRESS_PENDING: + // Button is pressed, waiting to see if it's a long press + if (!currentState) { + // Button released before long press threshold - it's a short press + button.state = ButtonState::DEBOUNCING_RELEASE; + button.stateChangeTime = now; + LOG_INFO("InputManager: Short press detected on GPIO %d (held for %lums)", + button.config.pin, now - button.pressStartTime); + } else if (now - button.pressStartTime >= button.config.longPressMs) { + // Long press threshold reached + button.state = ButtonState::LONG_PRESSED; + button.longPressTriggered = true; + + LOG_WARNING("InputManager: LONG PRESS DETECTED on GPIO %d (held for %lums)", + button.config.pin, now - button.pressStartTime); + + // Trigger long press callback + if (button.config.onLongPress) { + button.config.onLongPress(); + } + } + break; + + case ButtonState::LONG_PRESSED: + // Long press has been triggered, waiting for release + if (!currentState) { + button.state = ButtonState::DEBOUNCING_RELEASE; + button.stateChangeTime = now; + LOG_INFO("InputManager: Long press released on GPIO %d (total duration: %lums)", + button.config.pin, now - button.pressStartTime); + } + break; + + case ButtonState::DEBOUNCING_RELEASE: + // Debouncing release + if (currentState) { + // Button pressed again during release debounce - go back to pressed state + button.state = ButtonState::LONG_PRESS_PENDING; + LOG_DEBUG("InputManager: Button re-pressed during release debounce on GPIO %d", + button.config.pin); + } else if (now - button.stateChangeTime >= button.config.debounceMs) { + // Debounce time passed - confirm release + button.state = ButtonState::IDLE; + + // If it was a short press (not long press), trigger the press callback + if (!button.longPressTriggered && button.config.onPress) { + LOG_INFO("InputManager: Triggering press callback for GPIO %d", + button.config.pin); + button.config.onPress(); + } + + LOG_DEBUG("InputManager: Button release confirmed on GPIO %d", + button.config.pin); + } + break; + } + + // Store last raw state for edge detection + button.lastRawState = currentState; +} diff --git a/vesper/src/InputManager/InputManager.hpp b/vesper/src/InputManager/InputManager.hpp new file mode 100644 index 0000000..8bcc177 --- /dev/null +++ b/vesper/src/InputManager/InputManager.hpp @@ -0,0 +1,196 @@ +/* + * ═══════════════════════════════════════════════════════════════════════════════════ + * INPUTMANAGER.HPP - Button and Input Handling System + * ═══════════════════════════════════════════════════════════════════════════════════ + * + * 🎛️ INPUT HANDLING FOR VESPER 🎛️ + * + * This class manages all physical input handling including button presses, + * long presses, and debouncing logic. It provides clean event-based callbacks + * for different input actions. + * + * 🏗️ ARCHITECTURE: + * • Non-blocking button state management + * • Software debouncing with configurable timing + * • Long press detection + * • Event-driven callbacks for actions + * • Easy expansion for multiple inputs + * + * 🔘 CURRENT INPUTS: + * • GPIO 0: Factory Reset Button (Long Press = 10s) + * + * ⚙️ FEATURES: + * • Debounce filtering (default 50ms) + * • Long press detection (configurable, default 10s) + * • Active-low button handling (pull-up enabled) + * • Non-blocking state machine + * + * 📋 VERSION: 1.0 + * 📅 DATE: 2025 + * 👨‍💻 AUTHOR: Advanced Bell Systems + * ═══════════════════════════════════════════════════════════════════════════════════ + */ + +#pragma once + +#include +#include + +class InputManager { +public: + // ═══════════════════════════════════════════════════════════════════════════════ + // CALLBACK TYPES + // ═══════════════════════════════════════════════════════════════════════════════ + + using ButtonCallback = std::function; + + // ═══════════════════════════════════════════════════════════════════════════════ + // BUTTON CONFIGURATION + // ═══════════════════════════════════════════════════════════════════════════════ + + struct ButtonConfig { + uint8_t pin; // GPIO pin number + bool activeHigh; // true = active high, false = active low + uint32_t debounceMs; // Debounce time in milliseconds + uint32_t longPressMs; // Long press threshold in milliseconds + ButtonCallback onPress; // Callback for normal press + ButtonCallback onLongPress; // Callback for long press + + ButtonConfig(uint8_t p = 0, bool ah = false, uint32_t db = 50, uint32_t lp = 10000) + : pin(p), activeHigh(ah), debounceMs(db), longPressMs(lp), + onPress(nullptr), onLongPress(nullptr) {} + }; + + // ═══════════════════════════════════════════════════════════════════════════════ + // CONSTRUCTOR & INITIALIZATION + // ═══════════════════════════════════════════════════════════════════════════════ + + InputManager(); + ~InputManager(); + + /** + * @brief Initialize the InputManager and start the input task + * @return true if initialization successful + */ + bool begin(); + + /** + * @brief Stop the input task and cleanup + */ + void end(); + + // ═══════════════════════════════════════════════════════════════════════════════ + // CALLBACK REGISTRATION + // ═══════════════════════════════════════════════════════════════════════════════ + + /** + * @brief Set callback for factory reset button press + * @param callback Function to call on short press + */ + void setFactoryResetPressCallback(ButtonCallback callback); + + /** + * @brief Set callback for factory reset button long press + * @param callback Function to call on long press (10s) + */ + void setFactoryResetLongPressCallback(ButtonCallback callback); + + // ═══════════════════════════════════════════════════════════════════════════════ + // STATUS METHODS + // ═══════════════════════════════════════════════════════════════════════════════ + + /** + * @brief Check if factory reset button is currently pressed + * @return true if button is pressed + */ + bool isFactoryResetPressed() const; + + /** + * @brief Get how long the factory reset button has been pressed + * @return Duration in milliseconds (0 if not pressed) + */ + uint32_t getFactoryResetPressDuration() const; + + /** + * @brief Check if InputManager is healthy and functioning + * @return true if all inputs are properly configured + */ + bool isHealthy() const; + +private: + // ═══════════════════════════════════════════════════════════════════════════════ + // BUTTON STATE TRACKING + // ═══════════════════════════════════════════════════════════════════════════════ + + enum class ButtonState { + IDLE, // Button not pressed + DEBOUNCING_PRESS, // Waiting for debounce on press + PRESSED, // Button confirmed pressed (short) + LONG_PRESS_PENDING, // Button held, waiting for long press threshold + LONG_PRESSED, // Long press confirmed and triggered + DEBOUNCING_RELEASE // Waiting for debounce on release + }; + + struct ButtonData { + ButtonConfig config; + ButtonState state; + bool lastRawState; // Last raw reading from pin + uint32_t stateChangeTime; // When state last changed + uint32_t pressStartTime; // When button was first pressed + bool longPressTriggered; // Has long press callback been fired? + + ButtonData() : state(ButtonState::IDLE), lastRawState(false), + stateChangeTime(0), pressStartTime(0), + longPressTriggered(false) {} + }; + + // ═══════════════════════════════════════════════════════════════════════════════ + // MEMBER VARIABLES + // ═══════════════════════════════════════════════════════════════════════════════ + + ButtonData _factoryResetButton; + bool _initialized; + + // FreeRTOS task management + TaskHandle_t _inputTaskHandle; + static constexpr uint32_t INPUT_TASK_STACK_SIZE = 4096; + static constexpr UBaseType_t INPUT_TASK_PRIORITY = 2; + static constexpr uint32_t INPUT_POLL_RATE_MS = 10; // Poll every 10ms + + // Static instance for task callback + static InputManager* _instance; + + // ═══════════════════════════════════════════════════════════════════════════════ + // PRIVATE METHODS + // ═══════════════════════════════════════════════════════════════════════════════ + + /** + * @brief Configure a single button pin + * @param config Button configuration + */ + void configureButton(const ButtonConfig& config); + + /** + * @brief Update state of a single button + * @param button Button data to update + */ + void updateButton(ButtonData& button); + + /** + * @brief Read current state of button (handles active high/low) + * @param button Button to read + * @return true if button is currently pressed + */ + bool readButtonState(const ButtonData& button) const; + + /** + * @brief Static task function for FreeRTOS + * @param parameter Pointer to InputManager instance + */ + static void inputTaskFunction(void* parameter); + + /** + * @brief Internal update method called by task + */ + void update(); +}; diff --git a/vesper/src/Logging/Logging.cpp b/vesper/src/Logging/Logging.cpp new file mode 100644 index 0000000..05606b8 --- /dev/null +++ b/vesper/src/Logging/Logging.cpp @@ -0,0 +1,72 @@ +#include "Logging.hpp" + +// Initialize static member +Logging::LogLevel Logging::currentLevel = Logging::VERBOSE; // Default to DEBUG + +void Logging::setLevel(LogLevel level) { + currentLevel = level; + Serial.printf("[LOGGING] Log level set to %d\n", level); +} + +Logging::LogLevel Logging::getLevel() { + return currentLevel; +} + +bool Logging::isLevelEnabled(LogLevel level) { + return currentLevel >= level; +} + +void Logging::error(const char* format, ...) { + if (!isLevelEnabled(ERROR)) return; + + va_list args; + va_start(args, format); + log(ERROR, "🔴 EROR", format, args); + va_end(args); +} + +void Logging::warning(const char* format, ...) { + if (!isLevelEnabled(WARNING)) return; + + va_list args; + va_start(args, format); + log(WARNING, "🟡 WARN", format, args); + va_end(args); +} + +void Logging::info(const char* format, ...) { + if (!isLevelEnabled(INFO)) return; + + va_list args; + va_start(args, format); + log(INFO, "🟢 INFO", format, args); + va_end(args); +} + +void Logging::debug(const char* format, ...) { + if (!isLevelEnabled(DEBUG)) return; + + va_list args; + va_start(args, format); + log(DEBUG, "🐞 DEBG", format, args); + va_end(args); +} + +void Logging::verbose(const char* format, ...) { + if (!isLevelEnabled(VERBOSE)) return; + + va_list args; + va_start(args, format); + log(VERBOSE, "🧾 VERB", format, args); + va_end(args); +} + +void Logging::log(LogLevel level, const char* levelStr, const char* format, va_list args) { + Serial.printf("[%s] ", levelStr); + + // Print the formatted message + char buffer[512]; + vsnprintf(buffer, sizeof(buffer), format, args); + Serial.print(buffer); + Serial.println(); +} diff --git a/vesper/src/Logging/Logging.hpp b/vesper/src/Logging/Logging.hpp new file mode 100644 index 0000000..8c245c5 --- /dev/null +++ b/vesper/src/Logging/Logging.hpp @@ -0,0 +1,65 @@ +/* + * ═══════════════════════════════════════════════════════════════════════════════════ + * LOGGING.HPP - Centralized Logging System + * ═══════════════════════════════════════════════════════════════════════════════════ + * + * 📝 THE INFORMATION CHRONICLER OF VESPER 📝 + * + * This header provides a unified logging interface with multiple levels, + * timestamps, and comprehensive debugging support throughout the system. + * + * 📋 VERSION: 2.0 (Enhanced logging system) + * 📅 DATE: 2025 + * 👨‍💻 AUTHOR: Advanced Bell Systems + * ═══════════════════════════════════════════════════════════════════════════════════ + */ + +#ifndef LOGGING_HPP +#define LOGGING_HPP + +#include + +class Logging { +public: + // Log Levels + enum LogLevel { + NONE = 0, // No logs + ERROR = 1, // Errors only + WARNING = 2, // Warnings and errors + INFO = 3, // Info, warnings, and errors + DEBUG = 4, // Debug logs. Really high level (full debugging) + VERBOSE = 5 // Nearly every command gets printed + }; + +private: + static LogLevel currentLevel; + +public: + // Set the active log level + static void setLevel(LogLevel level); + + // Get current log level + static LogLevel getLevel(); + + // Logging functions + static void error(const char* format, ...); + static void warning(const char* format, ...); + static void info(const char* format, ...); + static void debug(const char* format, ...); + static void verbose(const char* format, ...); + + // Check if level is enabled (for conditional logging) + static bool isLevelEnabled(LogLevel level); + +private: + static void log(LogLevel level, const char* levelStr, const char* format, va_list args); +}; + +// Convenience macros for easier use +#define LOG_ERROR(...) Logging::error(__VA_ARGS__) +#define LOG_WARNING(...) Logging::warning(__VA_ARGS__) +#define LOG_INFO(...) Logging::info(__VA_ARGS__) +#define LOG_DEBUG(...) Logging::debug(__VA_ARGS__) +#define LOG_VERBOSE(...) Logging::verbose(__VA_ARGS__) + +#endif diff --git a/vesper/src/MqttSSL/MqttSSL.cpp b/vesper/src/MqttSSL/MqttSSL.cpp new file mode 100644 index 0000000..a64949a --- /dev/null +++ b/vesper/src/MqttSSL/MqttSSL.cpp @@ -0,0 +1,59 @@ +#include "MqttSSL.hpp" +#include "../Logging/Logging.hpp" + +// EMQX Cloud CA Certificate (DigiCert Global Root CA) +const char* MqttSSL::_emqxCloudCA = R"EOF( +-----BEGIN CERTIFICATE----- +MIIDrzCCApegAwIBAgIQCDvgVpBCRrGhdWrJWZHHSjANBgkqhkiG9w0BAQUFADBh +MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 +d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBD +QTAeFw0wNjExMTAwMDAwMDBaFw0zMTExMTAwMDAwMDBaMGExCzAJBgNVBAYTAlVT +MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5j +b20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IENBMIIBIjANBgkqhkiG +9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4jvhEXLeqKTTo1eqUKKPC3eQyaKl7hLOllsB +CSDMAZOnTjC3U/dDxGkAV53ijSLdhwZAAIEJzs4bg7/fzTtxRuLWZscFs3YnFo97 +nh6Vfe63SKMI2tavegw5BmV/Sl0fvBf4q77uKNd0f3p4mVmFaG5cIzJLv07A6Fpt +43C/dxC//AH2hdmoRBBYMql1GNXRor5H4idq9Joz+EkIYIvUX7Q6hL+hqkpMfT7P +T19sdl6gSzeRntwi5m3OFBqOasv+zbMUZBfHWymeMr/y7vrTC0LUq7dBMtoM1O/4 +gdW7jVg/tRvoSSiicNoxBN33shbyTApOB6jtSj1etX+jkMOvJwIDAQABo2MwYTAO +BgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUA95QNVbR +TLtm8KPiGxvDl7I90VUwHwYDVR0jBBgwFoAUA95QNVbRTLtm8KPiGxvDl7I90VUw +DQYJKoZIhvcNAQEFBQADggEBAMucN6pIExIK+t1EnE9SsPTfrgT1eXkIoyQY/Esr +hMAtudXH/vTBH1jLuG2cenTnmCmrEbXjcKChzUyImZOMkXDiqw8cvpOp/2PV5Adg +06O/nVsJ8dWO41P0jmP6P6fbtGbfYmbW0W5BjfIttep3Sp+dWOIrWcBAI+0tKIJF +PnlUkiaY4IBIqDfv8NZ5YBberOgOzW6sRBc4L0na4UU+Krk2U886UAb3LujEV0ls +YSEY1QSteDwsOoBrp+uvFRTp2InBuThs4pFsiv9kuXclVzDAGySj4dzp30d8tbQk +CAUw7C29C79Fv1C5qfPrmAESrciIxpg0X40KPMbp1ZWVbd4= +-----END CERTIFICATE----- +)EOF"; + +MqttSSL::MqttSSL() { +} + +MqttSSL::~MqttSSL() { +} + +bool MqttSSL::isSSLAvailable() { +#ifdef ASYNC_TCP_SSL_ENABLED + return true; +#else + return false; +#endif +} + +const char* MqttSSL::getEMQXCA() { + return _emqxCloudCA; +} + +void MqttSSL::logSSLStatus(const AsyncMqttClient& client, int port) { + if (port == 8883) { + if (isSSLAvailable()) { + LOG_INFO("🔒 MQTT SSL/TLS enabled for port %d", port); + LOG_INFO("🔐 Certificate validation: Using DigiCert Global Root CA"); + } else { + LOG_ERROR("❌ SSL requested but not compiled in! Add ASYNC_TCP_SSL_ENABLED to build flags"); + } + } else { + LOG_WARNING("⚠️ MQTT using unencrypted connection on port %d", port); + } +} diff --git a/vesper/src/MqttSSL/MqttSSL.hpp b/vesper/src/MqttSSL/MqttSSL.hpp new file mode 100644 index 0000000..9652c67 --- /dev/null +++ b/vesper/src/MqttSSL/MqttSSL.hpp @@ -0,0 +1,48 @@ +/* + * ═══════════════════════════════════════════════════════════════════════════════════ + * MQTTSSL.HPP - EMQX Cloud SSL/TLS Certificate Management + * ═══════════════════════════════════════════════════════════════════════════════════ + * + * 🔒 SECURE MQTT CONNECTION FOR EMQX CLOUD 🔒 + * + * This class manages SSL/TLS certificates for EMQX Cloud connections. + * Note: AsyncMqttClient SSL is configured at compile time, not runtime. + * + * 📋 VERSION: 1.0 + * 📅 DATE: 2025-09-30 + * 👨‍💻 AUTHOR: Advanced Bell Systems + * ═══════════════════════════════════════════════════════════════════════════════════ + */ + +#pragma once + +#include +#include + +class MqttSSL { +public: + MqttSSL(); + ~MqttSSL(); + + /** + * @brief Check if SSL is available (compile-time check) + * @return true if SSL support is compiled in + */ + static bool isSSLAvailable(); + + /** + * @brief Get EMQX Cloud CA certificate + * @return CA certificate string + */ + static const char* getEMQXCA(); + + /** + * @brief Log SSL status + * @param client Reference to AsyncMqttClient + * @param port MQTT port being used + */ + static void logSSLStatus(const AsyncMqttClient& client, int port); + +private: + static const char* _emqxCloudCA; +}; diff --git a/vesper/src/Networking/Networking.cpp b/vesper/src/Networking/Networking.cpp new file mode 100644 index 0000000..bd8cc9c --- /dev/null +++ b/vesper/src/Networking/Networking.cpp @@ -0,0 +1,456 @@ +#include "Networking.hpp" +#include "../ConfigManager/ConfigManager.hpp" +#include "../Logging/Logging.hpp" +#include +#include + +// Static instance for callbacks (with safety checks) +Networking* Networking::_instance = nullptr; + +Networking::Networking(ConfigManager& configManager) + : _configManager(configManager) + , _state(NetworkState::DISCONNECTED) + , _activeConnection(ConnectionType::NONE) + , _lastConnectionAttempt(0) + , _bootStartTime(0) + , _bootSequenceComplete(false) + , _ethernetCableConnected(false) + , _wifiManager(nullptr) + , _reconnectionTimer(nullptr) { + + // Safety check for multiple instances + if (_instance != nullptr) { + LOG_WARNING("Multiple Networking instances detected! Previous instance will be overridden."); + } + + _instance = this; + _wifiManager = new WiFiManager(); +} + +Networking::~Networking() { + // Clear static instance safely + if (_instance == this) { + _instance = nullptr; + } + + // Cleanup timer + if (_reconnectionTimer) { + xTimerDelete(_reconnectionTimer, portMAX_DELAY); + _reconnectionTimer = nullptr; + } + + // Cleanup WiFiManager + if (_wifiManager) { + delete _wifiManager; + _wifiManager = nullptr; + } +} + +void Networking::begin() { + LOG_INFO("Initializing Networking System"); + + _bootStartTime = millis(); + + // Create reconnection timer + _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(180); // 3 minutes + + // Start Ethernet hardware + auto& hwConfig = _configManager.getHardwareConfig(); + ETH.begin(hwConfig.ethPhyType, hwConfig.ethPhyAddr, hwConfig.ethPhyCs, + hwConfig.ethPhyIrq, hwConfig.ethPhyRst, SPI); + + // Start connection sequence + LOG_INFO("Starting network connection sequence..."); + startEthernetConnection(); +} + +void Networking::startEthernetConnection() { + LOG_INFO("Attempting Ethernet connection..."); + setState(NetworkState::CONNECTING_ETHERNET); + + // Check if Ethernet hardware initialization failed + if (!ETH.linkUp()) { + LOG_WARNING("Ethernet hardware not detected or failed to initialize"); + LOG_INFO("Falling back to WiFi immediately"); + startWiFiConnection(); + return; + } + + // Ethernet will auto-connect via events + // Set timeout for Ethernet attempt (5 seconds) + _lastConnectionAttempt = millis(); + + // Start reconnection timer to handle timeout + xTimerStart(_reconnectionTimer, 0); +} + +void Networking::startWiFiConnection() { + LOG_INFO("Attempting WiFi connection..."); + setState(NetworkState::CONNECTING_WIFI); + + if (!hasValidWiFiCredentials()) { + LOG_WARNING("No valid WiFi credentials found"); + if (shouldStartPortal()) { + startWiFiPortal(); + } + return; + } + + LOG_INFO("Using WiFiManager saved credentials"); + + WiFi.mode(WIFI_STA); + applyNetworkConfig(false); // false = WiFi config + + // Let WiFiManager handle credentials (uses saved SSID/password) + WiFi.begin(); + + _lastConnectionAttempt = millis(); + + // Start reconnection timer to handle timeout + xTimerStart(_reconnectionTimer, 0); +} + +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()); + + if (_wifiManager->autoConnect(apName.c_str(), netConfig.apPass.c_str())) { + LOG_INFO("WiFi configured successfully via portal"); + onWiFiConnected(); + } else { + LOG_ERROR("WiFi portal configuration failed"); + setState(NetworkState::DISCONNECTED); + // Start reconnection timer to try again + xTimerStart(_reconnectionTimer, 0); + } +} + +void Networking::handleReconnection() { + if (_state == NetworkState::CONNECTED_ETHERNET || _state == NetworkState::CONNECTED_WIFI) { + return; // Already connected + } + + LOG_DEBUG("Attempting reconnection..."); + + // Check for Ethernet timeout (fall back to WiFi) + if (_state == NetworkState::CONNECTING_ETHERNET) { + unsigned long now = millis(); + if (now - _lastConnectionAttempt > 5000) { // 5 second timeout + LOG_INFO("Ethernet connection timeout - falling back to WiFi"); + startWiFiConnection(); + return; + } + return; // Still waiting for Ethernet + } + + // Check for WiFi timeout (try again) + if (_state == NetworkState::CONNECTING_WIFI) { + unsigned long now = millis(); + if (now - _lastConnectionAttempt > 10000) { // 10 second timeout + LOG_INFO("WiFi connection timeout - retrying"); + startWiFiConnection(); // Retry WiFi + } + return; // Still waiting for WiFi + } + + // State is DISCONNECTED - decide what to try + if (_ethernetCableConnected) { + LOG_INFO("Ethernet cable detected - trying Ethernet"); + startEthernetConnection(); + } else { + LOG_INFO("No Ethernet - trying WiFi"); + if (hasValidWiFiCredentials()) { + startWiFiConnection(); + } else if (shouldStartPortal()) { + startWiFiPortal(); + } else { + LOG_WARNING("No WiFi credentials and boot sequence complete - waiting"); + } + } +} + +// ════════════════════════════════════════════════════════════════════════════ +// HEALTH CHECK IMPLEMENTATION +// ════════════════════════════════════════════════════════════════════════════ + +bool Networking::isHealthy() const { + // Check if we have any active connection + if (_activeConnection == ConnectionType::NONE) { + LOG_DEBUG("Networking: Unhealthy - No active connection"); + return false; + } + + // Check connection state + if (_state != NetworkState::CONNECTED_ETHERNET && _state != NetworkState::CONNECTED_WIFI) { + LOG_DEBUG("Networking: Unhealthy - Not in connected state"); + return false; + } + + // Check IP address validity + String ip = getLocalIP(); + if (ip == "0.0.0.0" || ip.isEmpty()) { + LOG_DEBUG("Networking: Unhealthy - Invalid IP address"); + return false; + } + + // For WiFi connections, check signal strength + if (_activeConnection == ConnectionType::WIFI) { + if (WiFi.status() != WL_CONNECTED) { + LOG_DEBUG("Networking: Unhealthy - WiFi not connected"); + return false; + } + + // Check signal strength (RSSI should be better than -80 dBm) + int32_t rssi = WiFi.RSSI(); + if (rssi < -80) { + LOG_DEBUG("Networking: Unhealthy - Poor WiFi signal: %d dBm", rssi); + return false; + } + } + + // For Ethernet connections, check link status + if (_activeConnection == ConnectionType::ETHERNET) { + if (!ETH.linkUp()) { + LOG_DEBUG("Networking: Unhealthy - Ethernet link down"); + return false; + } + } + + return true; +} + +void Networking::setState(NetworkState newState) { + if (_state != newState) { + LOG_DEBUG("Network state: %d -> %d", (int)_state, (int)newState); + _state = newState; + } +} + +void Networking::setActiveConnection(ConnectionType type) { + if (_activeConnection != type) { + LOG_INFO("Active connection changed: %d -> %d", (int)_activeConnection, (int)type); + _activeConnection = type; + } +} + +void Networking::notifyConnectionChange(bool connected) { + if (connected && _onNetworkConnected) { + _onNetworkConnected(); + } else if (!connected && _onNetworkDisconnected) { + _onNetworkDisconnected(); + } +} + +// Event handlers +void Networking::onEthernetConnected() { + LOG_INFO("Ethernet connected successfully"); + setState(NetworkState::CONNECTED_ETHERNET); + setActiveConnection(ConnectionType::ETHERNET); + + // Stop WiFi if it was running + if (WiFi.getMode() != WIFI_OFF) { + WiFi.disconnect(true); + WiFi.mode(WIFI_OFF); + } + + // Stop reconnection timer + xTimerStop(_reconnectionTimer, 0); + + notifyConnectionChange(true); +} + +void Networking::onEthernetDisconnected() { + LOG_WARNING("Ethernet disconnected"); + + if (_activeConnection == ConnectionType::ETHERNET) { + setState(NetworkState::DISCONNECTED); + setActiveConnection(ConnectionType::NONE); + notifyConnectionChange(false); + + // Start reconnection attempts + xTimerStart(_reconnectionTimer, 0); + } +} + +void Networking::onWiFiConnected() { + LOG_INFO("WiFi connected successfully - IP: %s", WiFi.localIP().toString().c_str()); + setState(NetworkState::CONNECTED_WIFI); + setActiveConnection(ConnectionType::WIFI); + + // Stop reconnection timer + xTimerStop(_reconnectionTimer, 0); + + // Mark boot sequence as complete + _bootSequenceComplete = true; + + notifyConnectionChange(true); +} + +void Networking::onWiFiDisconnected() { + LOG_WARNING("WiFi disconnected"); + + if (_activeConnection == ConnectionType::WIFI) { + setState(NetworkState::DISCONNECTED); + setActiveConnection(ConnectionType::NONE); + notifyConnectionChange(false); + + // Start reconnection attempts + xTimerStart(_reconnectionTimer, 0); + } +} + +void Networking::onEthernetCableChange(bool connected) { + _ethernetCableConnected = connected; + LOG_INFO("Ethernet cable %s", connected ? "connected" : "disconnected"); + + if (connected && _activeConnection != ConnectionType::ETHERNET) { + // Cable connected and we're not using Ethernet - try to connect + startEthernetConnection(); + } +} + +// Utility methods +void Networking::applyNetworkConfig(bool ethernet) { + auto& netConfig = _configManager.getNetworkConfig(); + + if (netConfig.useStaticIP) { + LOG_INFO("Applying static IP configuration"); + if (ethernet) { + ETH.config(netConfig.ip, netConfig.gateway, netConfig.subnet, netConfig.dns1, netConfig.dns2); + } else { + WiFi.config(netConfig.ip, netConfig.gateway, netConfig.subnet, netConfig.dns1, netConfig.dns2); + } + } else { + LOG_INFO("Using DHCP configuration"); + } + + if (ethernet) { + ETH.setHostname(netConfig.hostname.c_str()); + } else { + WiFi.setHostname(netConfig.hostname.c_str()); + } +} + +bool Networking::hasValidWiFiCredentials() { + // Check if WiFiManager has saved credentials + return WiFi.SSID().length() > 0; +} + +bool Networking::shouldStartPortal() { + // Only start portal during boot sequence and if we're truly disconnected + return !_bootSequenceComplete && + (millis() - _bootStartTime < BOOT_TIMEOUT) && + _activeConnection == ConnectionType::NONE; +} + +// Status methods +bool Networking::isConnected() const { + return _activeConnection != ConnectionType::NONE; +} + +String Networking::getLocalIP() const { + switch (_activeConnection) { + case ConnectionType::ETHERNET: + return ETH.localIP().toString(); + case ConnectionType::WIFI: + return WiFi.localIP().toString(); + default: + return "0.0.0.0"; + } +} + +void Networking::forceReconnect() { + LOG_INFO("Forcing reconnection..."); + setState(NetworkState::RECONNECTING); + setActiveConnection(ConnectionType::NONE); + + // Disconnect everything + if (WiFi.getMode() != WIFI_OFF) { + WiFi.disconnect(true); + WiFi.mode(WIFI_OFF); + } + + // Restart connection sequence + delay(1000); + startEthernetConnection(); +} + +// Static callbacks +void Networking::networkEventHandler(arduino_event_id_t event, arduino_event_info_t info) { + if (!_instance) return; + + LOG_DEBUG("Network event: %d", event); + + switch (event) { + case ARDUINO_EVENT_ETH_START: + LOG_DEBUG("ETH Started"); + break; + + case ARDUINO_EVENT_ETH_CONNECTED: + LOG_DEBUG("ETH Cable Connected"); + _instance->onEthernetCableChange(true); + break; + + case ARDUINO_EVENT_ETH_GOT_IP: + LOG_INFO("ETH Got IP: %s", ETH.localIP().toString().c_str()); + _instance->applyNetworkConfig(true); + _instance->onEthernetConnected(); + break; + + case ARDUINO_EVENT_ETH_DISCONNECTED: + LOG_WARNING("ETH Cable Disconnected"); + _instance->onEthernetCableChange(false); + _instance->onEthernetDisconnected(); + break; + + case ARDUINO_EVENT_ETH_STOP: + LOG_INFO("ETH Stopped"); + _instance->onEthernetDisconnected(); + break; + + case ARDUINO_EVENT_WIFI_STA_GOT_IP: + LOG_INFO("WiFi Got IP: %s", WiFi.localIP().toString().c_str()); + _instance->onWiFiConnected(); + break; + + case ARDUINO_EVENT_WIFI_STA_DISCONNECTED: + LOG_WARNING("WiFi Disconnected"); + _instance->onWiFiDisconnected(); + break; + + case ARDUINO_EVENT_WIFI_STA_CONNECTED: + LOG_DEBUG("WiFi STA Connected"); + break; + + default: + break; + } +} + +void Networking::reconnectionTimerCallback(TimerHandle_t xTimer) { + if (_instance) { + _instance->handleReconnection(); + + // Check if boot sequence should be marked complete + if (!_instance->_bootSequenceComplete && + (millis() - _instance->_bootStartTime > BOOT_TIMEOUT)) { + _instance->_bootSequenceComplete = true; + LOG_INFO("Boot sequence timeout - no more portal attempts"); + } + } +} diff --git a/vesper/src/Networking/Networking.hpp b/vesper/src/Networking/Networking.hpp new file mode 100644 index 0000000..976dcf1 --- /dev/null +++ b/vesper/src/Networking/Networking.hpp @@ -0,0 +1,162 @@ +/* + * ═══════════════════════════════════════════════════════════════════════════════════ + * NETWORKING.HPP - Intelligent Network Connection Manager + * ═══════════════════════════════════════════════════════════════════════════════════ + * + * 🌐 THE NETWORK BRAIN OF VESPER 🌐 + * + * This class provides intelligent network connectivity management with + * automatic failover between Ethernet and WiFi. It handles all aspects + * of network connectivity, configuration, and state management. + * + * 🏗️ INTELLIGENT CONNECTIVITY: + * • Dual-stack support: Ethernet (primary) + WiFi (fallback) + * • Automatic failover and recovery + * • Smart connection prioritization + * • State machine-based connection management + * • Comprehensive event handling + * + * 🔄 AUTO-RECOVERY FEATURES: + * • Automatic reconnection on connection loss + * • Exponential backoff for failed attempts + * • Cable detection for Ethernet + * • WiFi portal fallback for configuration + * • Boot timeout handling + * + * ⚙️ CONFIGURATION MANAGEMENT: + * • Static IP configuration support + * • Dynamic IP with DHCP fallback + * • DNS configuration + * • Hostname management + * • WiFi credential management + * + * 📊 STATE MONITORING: + * • Real-time connection status tracking + * • Connection type identification + * • Network quality monitoring + * • Event-driven status updates + * • Comprehensive logging + * + * 🔗 INTEGRATION: + * • Clean callback interface for status changes + * • ConfigManager integration for settings + * • WiFiManager integration for portal mode + * • Event-driven architecture + * + * 📋 VERSION: 2.0 (Intelligent dual-stack networking) + * 📅 DATE: 2025 + * 👨‍💻 AUTHOR: Advanced Bell Systems + * ═══════════════════════════════════════════════════════════════════════════════════ + */ + +#pragma once + +#include +#include +#include +#include + +class ConfigManager; +class WiFiManager; // Forward declaration + +enum class NetworkState { + DISCONNECTED, + CONNECTING_ETHERNET, + CONNECTING_WIFI, + WIFI_PORTAL_MODE, + CONNECTED_ETHERNET, + CONNECTED_WIFI, + RECONNECTING +}; + +enum class ConnectionType { + NONE, + ETHERNET, + WIFI +}; + +class Networking { +public: + explicit Networking(ConfigManager& configManager); + ~Networking(); // Destructor to clean up WiFiManager + + void begin(); + + // Status methods + bool isConnected() const; + String getLocalIP() const; + ConnectionType getActiveConnection() const { return _activeConnection; } + NetworkState getState() const { return _state; } + + // Network event callbacks + void setNetworkCallbacks(std::function onConnected, std::function onDisconnected) { + _onNetworkConnected = onConnected; + _onNetworkDisconnected = onDisconnected; + } + + // Manual connection control (for testing/debugging) + void forceReconnect(); + + // ═══════════════════════════════════════════════════════════════════════════════ + // HEALTH CHECK METHOD + // ═══════════════════════════════════════════════════════════════════════════════ + + /** @brief Check if Networking is in healthy state */ + bool isHealthy() const; + + // Static instance for callbacks + static Networking* _instance; + + // Static event handler + static void networkEventHandler(arduino_event_id_t event, arduino_event_info_t info); + +private: + // Dependencies + ConfigManager& _configManager; + WiFiManager* _wifiManager; + + // State + NetworkState _state; + ConnectionType _activeConnection; + unsigned long _lastConnectionAttempt; + unsigned long _bootStartTime; + bool _bootSequenceComplete; + bool _ethernetCableConnected; + + // Callbacks + std::function _onNetworkConnected; + std::function _onNetworkDisconnected; + + // Timers + TimerHandle_t _reconnectionTimer; + + // Connection methods + void startEthernetConnection(); + void startWiFiConnection(); + void startWiFiPortal(); + void handleReconnection(); + + // State management + void setState(NetworkState newState); + void setActiveConnection(ConnectionType type); + void notifyConnectionChange(bool connected); + + // Event handlers + void onEthernetConnected(); + void onEthernetDisconnected(); + void onWiFiConnected(); + void onWiFiDisconnected(); + void onEthernetCableChange(bool connected); + + // Utility methods + void applyNetworkConfig(bool ethernet = false); + bool hasValidWiFiCredentials(); + bool shouldStartPortal(); + + // Timer callback + static void reconnectionTimerCallback(TimerHandle_t xTimer); + + // Constants + static const unsigned long RECONNECTION_INTERVAL = 5000; // 5 seconds + static const unsigned long BOOT_TIMEOUT = 30000; // 30 seconds for boot sequence +}; diff --git a/vesper/src/OTAManager/OTAManager.cpp b/vesper/src/OTAManager/OTAManager.cpp new file mode 100644 index 0000000..a071f8f --- /dev/null +++ b/vesper/src/OTAManager/OTAManager.cpp @@ -0,0 +1,603 @@ +#include "OTAManager.hpp" +#include "../ConfigManager/ConfigManager.hpp" +#include "../Logging/Logging.hpp" +#include +#include + +OTAManager::OTAManager(ConfigManager& configManager) + : _configManager(configManager) + , _fileManager(nullptr) + , _status(Status::IDLE) + , _lastError(ErrorCode::NONE) + , _availableVersion(0.0f) + , _updateAvailable(false) + , _availableChecksum("") + , _updateChannel("stable") + , _isMandatory(false) + , _isEmergency(false) + , _progressCallback(nullptr) + , _statusCallback(nullptr) { +} + +void OTAManager::begin() { + LOG_INFO("OTA Manager initialized"); + setStatus(Status::IDLE); +} + +void OTAManager::setFileManager(FileManager* fm) { + _fileManager = fm; +} + +void OTAManager::checkForUpdates() { + // Boot-time check: only check stable channel for emergency/mandatory updates + checkForUpdates("stable"); +} + +void OTAManager::checkForUpdates(const String& channel) { + if (_status != Status::IDLE) { + LOG_WARNING("OTA check already in progress"); + return; + } + + setStatus(Status::CHECKING_VERSION); + LOG_INFO("Checking for firmware updates in %s channel for %s...", + channel.c_str(), _configManager.getHardwareVariant().c_str()); + + if (checkVersion(channel)) { + float currentVersion = getCurrentVersion(); + LOG_INFO("Current version: %.1f, Available version: %.1f (Channel: %s)", + currentVersion, _availableVersion, channel.c_str()); + + if (_availableVersion > currentVersion) { + _updateAvailable = true; + LOG_INFO("New version available! Mandatory: %s, Emergency: %s", + _isMandatory ? "YES" : "NO", _isEmergency ? "YES" : "NO"); + setStatus(Status::IDLE); + + // Auto-update for emergency or mandatory updates during boot check + if (channel == "stable" && (_isEmergency || _isMandatory)) { + LOG_INFO("Emergency/Mandatory update detected - starting automatic update"); + update(channel); + } + } else { + _updateAvailable = false; + LOG_INFO("No new version available"); + setStatus(Status::IDLE); + } + } else { + _updateAvailable = false; + setStatus(Status::FAILED, _lastError); + } +} + +void OTAManager::update() { + update("stable"); // Default to stable channel +} + +void OTAManager::update(const String& channel) { + if (_status != Status::IDLE) { + LOG_WARNING("OTA update already in progress"); + return; + } + + if (!_updateAvailable) { + LOG_WARNING("No update available for channel: %s", channel.c_str()); + return; + } + + LOG_INFO("Starting OTA update from %s channel...", channel.c_str()); + setStatus(Status::DOWNLOADING); + + if (downloadAndInstall(channel)) { + setStatus(Status::SUCCESS); + LOG_INFO("Update successfully finished. Rebooting..."); + delay(1000); + ESP.restart(); + } else { + setStatus(Status::FAILED, _lastError); + } +} + +float OTAManager::getCurrentVersion() const { + String fwVersionStr = _configManager.getFwVersion(); + return fwVersionStr.toFloat(); +} + +void OTAManager::setStatus(Status status, ErrorCode error) { + _status = status; + _lastError = error; + + if (_statusCallback) { + _statusCallback(status, error); + } +} + +void OTAManager::notifyProgress(size_t current, size_t total) { + if (_progressCallback) { + _progressCallback(current, total); + } +} + +// Enhanced version checking with channel support and multiple servers +bool OTAManager::checkVersion(const String& channel) { + std::vector servers = _configManager.getUpdateServers(); + auto& updateConfig = _configManager.getUpdateConfig(); + + for (size_t serverIndex = 0; serverIndex < servers.size(); serverIndex++) { + String baseUrl = servers[serverIndex]; + String metadataUrl = baseUrl + "/ota/" + _configManager.getHardwareVariant() + "/" + channel + "/metadata.json"; + + LOG_INFO("OTA: Trying server %d/%d: %s", serverIndex + 1, servers.size(), baseUrl.c_str()); + + HTTPClient http; + http.setTimeout(updateConfig.timeout); + http.begin(metadataUrl.c_str()); + + int retryCount = 0; + int httpCode = -1; + + // Retry logic for current server + while (retryCount < updateConfig.retries && httpCode != HTTP_CODE_OK) { + if (retryCount > 0) { + LOG_INFO("OTA: Retry %d/%d for %s", retryCount + 1, updateConfig.retries, baseUrl.c_str()); + delay(1000 * retryCount); // Exponential backoff + } + + httpCode = http.GET(); + retryCount++; + } + + if (httpCode == HTTP_CODE_OK) { + String jsonStr = http.getString(); + http.end(); + + // Parse JSON metadata + DynamicJsonDocument doc(1024); + DeserializationError error = deserializeJson(doc, jsonStr); + + if (error) { + LOG_ERROR("OTA: Failed to parse metadata JSON from %s: %s", + baseUrl.c_str(), error.c_str()); + continue; // Try next server + } + + // Extract metadata + _availableVersion = doc["version"].as(); + _availableChecksum = doc["checksum"].as(); + _updateChannel = doc["channel"].as(); + _isMandatory = doc["mandatory"].as(); + _isEmergency = doc["emergency"].as(); + + // Validate hardware variant matches + String hwVariant = doc["hardwareVariant"].as(); + String ourHardwareVariant = _configManager.getHardwareVariant(); + if (!hwVariant.isEmpty() && hwVariant != ourHardwareVariant) { + LOG_ERROR("OTA: Hardware variant mismatch! Expected: %s, Got: %s", + ourHardwareVariant.c_str(), hwVariant.c_str()); + continue; // Try next server + } + + if (_availableVersion == 0.0f) { + LOG_ERROR("OTA: Invalid version in metadata from %s", baseUrl.c_str()); + continue; // Try next server + } + + if (_availableChecksum.length() != 64) { // SHA256 is 64 hex characters + LOG_ERROR("OTA: Invalid checksum in metadata from %s", baseUrl.c_str()); + continue; // Try next server + } + + LOG_INFO("OTA: Successfully got metadata from %s", baseUrl.c_str()); + return true; // Success! + } else { + LOG_ERROR("OTA: Server %s failed after %d retries. HTTP error: %d", + baseUrl.c_str(), updateConfig.retries, httpCode); + http.end(); + } + } + + // All servers failed + LOG_ERROR("OTA: All %d servers failed to provide metadata", servers.size()); + _lastError = ErrorCode::HTTP_ERROR; + return false; +} + +// Enhanced download and install with channel support and multiple servers +bool OTAManager::downloadAndInstall(const String& channel) { + std::vector servers = _configManager.getUpdateServers(); + + for (size_t serverIndex = 0; serverIndex < servers.size(); serverIndex++) { + String baseUrl = servers[serverIndex]; + String firmwareUrl = baseUrl + "/ota/" + _configManager.getHardwareVariant() + "/" + channel + "/firmware.bin"; + + LOG_INFO("OTA: Trying firmware download from server %d/%d: %s", + serverIndex + 1, servers.size(), baseUrl.c_str()); + + if (downloadToSD(firmwareUrl, _availableChecksum)) { + // Success! Now install from SD + return installFromSD("/firmware/staged_update.bin"); + } else { + LOG_WARNING("OTA: Firmware download failed from %s, trying next server", baseUrl.c_str()); + } + } + + // All servers failed + LOG_ERROR("OTA: All %d servers failed to provide firmware", servers.size()); + return false; +} + +bool OTAManager::downloadToSD(const String& url, const String& expectedChecksum) { + // This method now receives the exact firmware URL from downloadAndInstall + // The server selection logic is handled there + if (!_fileManager) { + LOG_ERROR("FileManager not set!"); + setStatus(Status::FAILED, ErrorCode::DOWNLOAD_FAILED); + return false; + } + + // Ensure firmware directory exists + _fileManager->createDirectory("/firmware"); + + // Download to temporary file + String tempPath = "/firmware/staged_update.bin"; + + HTTPClient http; + http.begin(url.c_str()); + int httpCode = http.GET(); + + if (httpCode != HTTP_CODE_OK) { + LOG_ERROR("Download HTTP error code: %d", httpCode); + setStatus(Status::FAILED, ErrorCode::HTTP_ERROR); + http.end(); + return false; + } + + int contentLength = http.getSize(); + if (contentLength <= 0) { + LOG_ERROR("Invalid content length"); + setStatus(Status::FAILED, ErrorCode::DOWNLOAD_FAILED); + http.end(); + return false; + } + + // Open file for writing + File file = SD.open(tempPath.c_str(), FILE_WRITE); + if (!file) { + LOG_ERROR("Failed to create temporary update file"); + setStatus(Status::FAILED, ErrorCode::DOWNLOAD_FAILED); + http.end(); + return false; + } + + WiFiClient* stream = http.getStreamPtr(); + uint8_t buffer[1024]; + size_t written = 0; + + while (http.connected() && written < contentLength) { + size_t available = stream->available(); + if (available) { + size_t toRead = min(available, sizeof(buffer)); + size_t bytesRead = stream->readBytes(buffer, toRead); + + if (bytesRead > 0) { + size_t bytesWritten = file.write(buffer, bytesRead); + if (bytesWritten != bytesRead) { + LOG_ERROR("SD write failed"); + file.close(); + http.end(); + setStatus(Status::FAILED, ErrorCode::WRITE_FAILED); + return false; + } + written += bytesWritten; + notifyProgress(written, contentLength); + } + } + yield(); + } + + file.close(); + http.end(); + + if (written != contentLength) { + LOG_ERROR("Download incomplete: %d/%d bytes", written, contentLength); + setStatus(Status::FAILED, ErrorCode::DOWNLOAD_FAILED); + return false; + } + + LOG_INFO("Download complete (%d bytes)", written); + + // Verify checksum + if (!verifyChecksum(tempPath, expectedChecksum)) { + LOG_ERROR("Checksum verification failed after download"); + _fileManager->deleteFile(tempPath); + setStatus(Status::FAILED, ErrorCode::CHECKSUM_MISMATCH); + return false; + } + + LOG_INFO("Download and checksum verification successful"); + return true; +} + +bool OTAManager::verifyChecksum(const String& filePath, const String& expectedChecksum) { + String calculatedChecksum = calculateSHA256(filePath); + + if (calculatedChecksum.isEmpty()) { + LOG_ERROR("Failed to calculate checksum"); + return false; + } + + bool match = calculatedChecksum.equalsIgnoreCase(expectedChecksum); + + if (match) { + LOG_INFO("Checksum verification passed"); + } else { + LOG_ERROR("Checksum mismatch!"); + LOG_ERROR("Expected: %s", expectedChecksum.c_str()); + LOG_ERROR("Calculated: %s", calculatedChecksum.c_str()); + } + + return match; +} + +String OTAManager::calculateSHA256(const String& filePath) { + File file = SD.open(filePath.c_str()); + if (!file) { + LOG_ERROR("Failed to open file for checksum calculation: %s", filePath.c_str()); + return ""; + } + + mbedtls_md_context_t ctx; + mbedtls_md_type_t md_type = MBEDTLS_MD_SHA256; + + mbedtls_md_init(&ctx); + mbedtls_md_setup(&ctx, mbedtls_md_info_from_type(md_type), 0); + mbedtls_md_starts(&ctx); + + uint8_t buffer[1024]; + size_t bytesRead; + + while ((bytesRead = file.readBytes((char*)buffer, sizeof(buffer))) > 0) { + mbedtls_md_update(&ctx, buffer, bytesRead); + } + + uint8_t hash[32]; + mbedtls_md_finish(&ctx, hash); + mbedtls_md_free(&ctx); + + file.close(); + + // Convert to hex string + String hashString = ""; + for (int i = 0; i < 32; i++) { + String hex = String(hash[i], HEX); + if (hex.length() == 1) { + hex = "0" + hex; + } + hashString += hex; + } + + return hashString; +} + +bool OTAManager::installFromSD(const String& filePath) { + size_t updateSize = _fileManager->getFileSize(filePath); + if (updateSize == 0) { + LOG_ERROR("Empty update file"); + setStatus(Status::FAILED, ErrorCode::DOWNLOAD_FAILED); + return false; + } + + LOG_INFO("Installing firmware from SD (%u bytes)...", updateSize); + setStatus(Status::INSTALLING); + + if (!Update.begin(updateSize)) { + LOG_ERROR("Not enough space to begin update"); + setStatus(Status::FAILED, ErrorCode::INSUFFICIENT_SPACE); + return false; + } + + File updateBin = SD.open(filePath.c_str()); + if (!updateBin) { + LOG_ERROR("Failed to open update file: %s", filePath.c_str()); + setStatus(Status::FAILED, ErrorCode::DOWNLOAD_FAILED); + return false; + } + + size_t written = Update.writeStream(updateBin); + updateBin.close(); + + if (written == updateSize) { + LOG_INFO("Update written successfully (%u bytes)", written); + } else { + LOG_ERROR("Written only %u/%u bytes", written, updateSize); + setStatus(Status::FAILED, ErrorCode::WRITE_FAILED); + return false; + } + + if (Update.end(true)) { // true = set new boot partition + LOG_INFO("Update finished!"); + if (Update.isFinished()) { + setStatus(Status::SUCCESS); + LOG_INFO("Update complete. Cleaning up and rebooting..."); + + // Clean up the update files + _fileManager->deleteFile(filePath); + _fileManager->deleteFile("/firmware/staged_update.sha256"); + _fileManager->deleteFile("/firmware/update.sha256"); + + // Clear firmware validation state to force validation of new firmware + nvs_handle_t nvsHandle; + esp_err_t err = nvs_open("fw_validator", NVS_READWRITE, &nvsHandle); + if (err == ESP_OK) { + nvs_erase_key(nvsHandle, "val_state"); + nvs_erase_key(nvsHandle, "retry_count"); + nvs_erase_key(nvsHandle, "fail_count"); + nvs_commit(nvsHandle); + nvs_close(nvsHandle); + LOG_INFO("✅ OTA: Firmware validation state cleared - new firmware will be validated"); + } else { + LOG_WARNING("⚠️ OTA: Failed to clear validation state: %s", esp_err_to_name(err)); + } + + delay(1000); + _configManager.setFwVersion(String(_availableVersion, 1)); // 1 decimal place + _configManager.saveDeviceConfig(); + delay(500); + ESP.restart(); + return true; + } else { + LOG_ERROR("Update not complete"); + setStatus(Status::FAILED, ErrorCode::VERIFICATION_FAILED); + return false; + } + } else { + LOG_ERROR("Update error: %s", Update.errorString()); + setStatus(Status::FAILED, ErrorCode::VERIFICATION_FAILED); + return false; + } +} + +void OTAManager::checkFirmwareUpdateFromSD() { + if (!_fileManager) { + LOG_ERROR("FileManager not set!"); + return; + } + + if (!_fileManager->fileExists("/firmware/update.bin")) { + LOG_DEBUG("No update.bin found on SD card"); + return; + } + + // Check for checksum file + String checksumFile = "/firmware/update.sha256"; + if (!_fileManager->fileExists(checksumFile)) { + LOG_WARNING("No checksum file found, proceeding without verification"); + installFromSD("/firmware/update.bin"); + return; + } + + // Read expected checksum + File checksumFileHandle = SD.open(checksumFile.c_str()); + if (!checksumFileHandle) { + LOG_ERROR("Failed to open checksum file"); + return; + } + + String expectedChecksum = checksumFileHandle.readString(); + checksumFileHandle.close(); + expectedChecksum.trim(); + + // Verify checksum + if (!verifyChecksum("/firmware/update.bin", expectedChecksum)) { + LOG_ERROR("Checksum verification failed, aborting update"); + setStatus(Status::FAILED, ErrorCode::CHECKSUM_MISMATCH); + return; + } + + LOG_INFO("Checksum verified, proceeding with update"); + installFromSD("/firmware/update.bin"); +} + +bool OTAManager::performManualUpdate() { + return performManualUpdate("stable"); +} + +bool OTAManager::performManualUpdate(const String& channel) { + if (_status != Status::IDLE) { + LOG_WARNING("OTA update already in progress"); + return false; + } + + // Check for updates in the specified channel first + checkForUpdates(channel); + + if (!_updateAvailable) { + LOG_WARNING("No update available in %s channel", channel.c_str()); + return false; + } + + LOG_INFO("Starting manual OTA update from %s channel via SD staging...", channel.c_str()); + setStatus(Status::DOWNLOADING); + + String firmwareUrl = buildFirmwareUrl(channel); + + // Download to SD first + if (!downloadToSD(firmwareUrl, _availableChecksum)) { + return false; + } + + // Install from SD + return installFromSD("/firmware/staged_update.bin"); +} + +// Hardware variant management +String OTAManager::getHardwareVariant() const { + return _configManager.getHardwareVariant(); +} + +void OTAManager::setHardwareVariant(const String& variant) { + LOG_WARNING("OTAManager::setHardwareVariant is deprecated. Use ConfigManager::setHardwareVariant instead"); + // For backward compatibility, we could call configManager, but it's better to use ConfigManager directly +} + +// URL builders for multi-channel architecture +String OTAManager::buildChannelUrl(const String& channel) const { + auto& updateConfig = _configManager.getUpdateConfig(); + String baseUrl = updateConfig.fallbackServerUrl; + + return baseUrl + "/" + _configManager.getHardwareVariant() + "/" + channel + "/"; +} + +String OTAManager::buildMetadataUrl(const String& channel) const { + return buildChannelUrl(channel) + "metadata.json"; +} + +String OTAManager::buildFirmwareUrl(const String& channel) const { + return buildChannelUrl(channel) + "firmware.bin"; +} + +// ════════════════════════════════════════════════════════════════════════════ +// HEALTH CHECK IMPLEMENTATION +// ════════════════════════════════════════════════════════════════════════════ + +bool OTAManager::isHealthy() const { + // Check if FileManager dependency is set + if (!_fileManager) { + LOG_DEBUG("OTAManager: Unhealthy - FileManager not set"); + return false; + } + + // Check if we're not in a failed state + if (_status == Status::FAILED) { + LOG_DEBUG("OTAManager: Unhealthy - In failed state"); + return false; + } + + // Check if ConfigManager has valid configuration + String hwVariant = _configManager.getHardwareVariant(); + if (hwVariant.isEmpty() || hwVariant == "BellSystems") { + LOG_DEBUG("OTAManager: Unhealthy - Invalid hardware variant: %s", hwVariant.c_str()); + return false; + } + + String fwVersion = _configManager.getFwVersion(); + if (fwVersion.isEmpty() || fwVersion == "0") { + LOG_DEBUG("OTAManager: Unhealthy - Invalid firmware version: %s", fwVersion.c_str()); + return false; + } + + // Check if update servers are available + std::vector servers = _configManager.getUpdateServers(); + if (servers.empty()) { + LOG_DEBUG("OTAManager: Unhealthy - No update servers configured"); + return false; + } + + // Check if FileManager is healthy (can access SD card) + if (!_fileManager->isHealthy()) { + LOG_DEBUG("OTAManager: Unhealthy - FileManager is unhealthy"); + return false; + } + + return true; +} diff --git a/vesper/src/OTAManager/OTAManager.hpp b/vesper/src/OTAManager/OTAManager.hpp new file mode 100644 index 0000000..b2f0a0b --- /dev/null +++ b/vesper/src/OTAManager/OTAManager.hpp @@ -0,0 +1,121 @@ +/* + * ═══════════════════════════════════════════════════════════════════════════════════ + * OTAMANAGER.HPP - Over-The-Air Update Management System + * ═══════════════════════════════════════════════════════════════════════════════════ + * + * 🔄 THE UPDATE ORCHESTRATOR OF VESPER 🔄 + * + * This class manages over-the-air firmware updates with safe, reliable + * update mechanisms, version checking, and comprehensive error handling. + * + * 📋 VERSION: 2.0 (Enhanced OTA management) + * 📅 DATE: 2025 + * 👨‍💻 AUTHOR: Advanced Bell Systems + * ═══════════════════════════════════════════════════════════════════════════════════ + */ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include "../FileManager/FileManager.hpp" + +class ConfigManager; // Forward declaration + +class OTAManager { +public: + enum class Status { + IDLE, + CHECKING_VERSION, + DOWNLOADING, + INSTALLING, + SUCCESS, + FAILED + }; + + enum class ErrorCode { + NONE, + HTTP_ERROR, + VERSION_CHECK_FAILED, + DOWNLOAD_FAILED, + INSUFFICIENT_SPACE, + WRITE_FAILED, + VERIFICATION_FAILED, + CHECKSUM_MISMATCH, + METADATA_PARSE_FAILED + }; + + // Callback types + using ProgressCallback = std::function; + using StatusCallback = std::function; + + explicit OTAManager(ConfigManager& configManager); + + void begin(); + void setFileManager(FileManager* fm); + void checkForUpdates(); + void checkForUpdates(const String& channel); // Check specific channel + void update(); + void update(const String& channel); // Update from specific channel + void checkFirmwareUpdateFromSD(); // Check SD for firmware update + bool performManualUpdate(); // Manual update triggered by app + bool performManualUpdate(const String& channel); // Manual update from specific channel + + // Hardware identification + String getHardwareVariant() const; + void setHardwareVariant(const String& variant); // Deprecated: Use ConfigManager instead + + // Status and info + Status getStatus() const { return _status; } + ErrorCode getLastError() const { return _lastError; } + float getCurrentVersion() const; + float getAvailableVersion() const { return _availableVersion; } + bool isUpdateAvailable() const { return _updateAvailable; } + + // Callbacks + void setProgressCallback(ProgressCallback callback) { _progressCallback = callback; } + void setStatusCallback(StatusCallback callback) { _statusCallback = callback; } + + // ═══════════════════════════════════════════════════════════════════════════════ + // HEALTH CHECK METHOD + // ═══════════════════════════════════════════════════════════════════════════════ + + /** @brief Check if OTAManager is in healthy state */ + bool isHealthy() const; + +private: + ConfigManager& _configManager; + FileManager* _fileManager; + Status _status; + ErrorCode _lastError; + float _availableVersion; + bool _updateAvailable; + String _availableChecksum; + String _updateChannel; + bool _isMandatory; + bool _isEmergency; + + ProgressCallback _progressCallback; + StatusCallback _statusCallback; + + void setStatus(Status status, ErrorCode error = ErrorCode::NONE); + void notifyProgress(size_t current, size_t total); + bool checkVersion(); + bool checkVersion(const String& channel); + bool checkChannelsMetadata(); + bool downloadAndInstall(); + bool downloadAndInstall(const String& channel); + bool downloadToSD(const String& url, const String& expectedChecksum); + bool verifyChecksum(const String& filePath, const String& expectedChecksum); + String calculateSHA256(const String& filePath); + bool installFromSD(const String& filePath); + String buildChannelUrl(const String& channel) const; + String buildMetadataUrl(const String& channel) const; + String buildFirmwareUrl(const String& channel) const; +}; diff --git a/vesper/src/OutputManager/OutputManager.cpp b/vesper/src/OutputManager/OutputManager.cpp new file mode 100644 index 0000000..c7424f8 --- /dev/null +++ b/vesper/src/OutputManager/OutputManager.cpp @@ -0,0 +1,638 @@ +/* + * ═══════════════════════════════════════════════════════════════════════════════════ + * OUTPUTMANAGER - FIXED VERSION - Complete Implementation + * ═══════════════════════════════════════════════════════════════════════════════════ + */ + +#include "OutputManager.hpp" +#include "../ConfigManager/ConfigManager.hpp" +#include "../Logging/Logging.hpp" +#include +#include + +// ==================== BASE CLASS IMPLEMENTATION ==================== + +OutputManager::~OutputManager() { + stopDurationTask(); + if (_durationTaskHandle != nullptr) { + vTaskDelay(pdMS_TO_TICKS(10)); + } +} + +void OutputManager::startDurationTask() { + if (_durationTaskHandle != nullptr) { + return; + } + _activeOutputs.reserve(32); // Support up to 32 virtual outputs + xTaskCreatePinnedToCore(durationTask, "OutputDuration", 4096, this, 5, &_durationTaskHandle, 1); + LOG_INFO("⚡ Output Duration Management Task Initialized"); +} + +void OutputManager::stopDurationTask() { + if (_durationTaskHandle != nullptr) { + vTaskDelete(_durationTaskHandle); + _durationTaskHandle = nullptr; + portENTER_CRITICAL(&_outputMutex); + _activeOutputs.clear(); + portEXIT_CRITICAL(&_outputMutex); + LOG_INFO("⚡ Output Duration Management Task Stopped"); + } +} + +void OutputManager::durationTask(void* parameter) { + OutputManager* manager = static_cast(parameter); + LOG_DEBUG("⚡ Output duration management task running on Core %d", xPortGetCoreID()); + while (true) { + manager->processExpiredOutputs(); + vTaskDelay(pdMS_TO_TICKS(1)); + } +} + +void OutputManager::processExpiredOutputs() { + uint64_t now = getMicros(); + portENTER_CRITICAL(&_outputMutex); + for (auto it = _activeOutputs.begin(); it != _activeOutputs.end(); ++it) { + uint64_t duration_micros = it->durationMs * 1000; + if ((now - it->activationTime) >= duration_micros) { + uint8_t outputIndex = it->outputIndex; + _activeOutputs.erase(it); + portEXIT_CRITICAL(&_outputMutex); + extinguishOutput(outputIndex); + LOG_VERBOSE("⚡ AUTO-EXTINGUISH Output:%d after %dms", outputIndex, duration_micros / 1000); + return; + } + } + portEXIT_CRITICAL(&_outputMutex); +} + +uint8_t OutputManager::getPhysicalOutput(uint8_t virtualOutput) const { + if (!_configManager) { + LOG_WARNING("⚠️ ConfigManager not available, using direct mapping for virtual output %d", virtualOutput); + return virtualOutput; + } + if (!isValidVirtualOutput(virtualOutput)) { + LOG_ERROR("❌ Invalid virtual output %d, using direct mapping", virtualOutput); + return virtualOutput; + } + + // Get 1-indexed bell output from config + uint16_t bellOutput1Indexed = _configManager->getBellOutput(virtualOutput); + + // Handle unconfigured bells (255 = disabled) + if (bellOutput1Indexed == 255) { + LOG_WARNING("⚠️ Bell %d not configured (255)", virtualOutput); + return 255; // Return invalid to prevent firing + } + + // Handle invalid 0 configuration + if (bellOutput1Indexed == 0) { + LOG_ERROR("❌ Bell %d configured as 0 (invalid - should be 1-indexed)", virtualOutput); + return 255; + } + + // Convert 1-indexed config to 0-indexed physical output + uint8_t physicalOutput = (uint8_t)(bellOutput1Indexed - 1); + + LOG_DEBUG("🔗 Bell %d → 1-indexed config %d → 0-indexed output %d", + virtualOutput, bellOutput1Indexed, physicalOutput); + + return physicalOutput; +} + +bool OutputManager::isValidVirtualOutput(uint8_t virtualOutput) const { + return virtualOutput < getMaxOutputs(); +} + +bool OutputManager::isValidPhysicalOutput(uint8_t physicalOutput) const { + if (physicalOutput == 255) { return false; } + return physicalOutput < getMaxOutputs(); +} + +void OutputManager::fireClockOutput(uint8_t virtualOutput, uint16_t durationMs) { + if (!_initialized) { + LOG_ERROR("❌ OutputManager not initialized for clock output!"); + return; + } + + if (!_configManager) { + LOG_ERROR("❌ ConfigManager not available for clock output mapping!"); + return; + } + + // Map virtual clock output to physical output using clock configuration + uint8_t physicalOutput; + if (virtualOutput == 0) { + // Virtual clock output 0 = C1 + physicalOutput = _configManager->getClockOutput1(); + if (physicalOutput == 255) { + LOG_WARNING("⚠️ Clock C1 not configured (255)"); + return; + } + } else if (virtualOutput == 1) { + // Virtual clock output 1 = C2 + physicalOutput = _configManager->getClockOutput2(); + if (physicalOutput == 255) { + LOG_WARNING("⚠️ Clock C2 not configured (255)"); + return; + } + } else { + LOG_ERROR("❌ Invalid virtual clock output: %d (only 0=C1, 1=C2 supported)", virtualOutput); + return; + } + + // Convert 1-indexed config value to 0-indexed physical output + if (physicalOutput == 0) { + LOG_ERROR("❌ Clock output configured as 0 (invalid - should be 1-indexed)"); + return; + } + + uint8_t zeroIndexedOutput = physicalOutput - 1; // Convert 1-indexed to 0-indexed + + if (!isValidPhysicalOutput(zeroIndexedOutput)) { + LOG_ERROR("❌ Invalid physical output for clock: %d (1-indexed config: %d, max outputs: %d)", + zeroIndexedOutput, physicalOutput, getMaxOutputs()); + return; + } + + // Fire the physical output directly + fireOutputForDuration(zeroIndexedOutput, durationMs); + + LOG_DEBUG("🕐 FIRE Clock Virtual %d (C%d) → 1-indexed config %d → 0-indexed output %d for %dms", + virtualOutput, virtualOutput + 1, physicalOutput, zeroIndexedOutput, durationMs); +} + +// ==================== PCF8574/PCF8575 MULTI-CHIP IMPLEMENTATION ==================== + +// Single chip constructor +PCF8574OutputManager::PCF8574OutputManager(uint8_t i2cAddress, ChipType chipType, uint8_t activeOutputs) + : _chipCount(1), _totalOutputs(0), _allChipsInitialized(false) { + _chips[0] = ChipConfig(i2cAddress, chipType, activeOutputs); + updateTotalOutputs(); +} + +// Dual chip constructor +PCF8574OutputManager::PCF8574OutputManager(uint8_t addr1, ChipType chip1, uint8_t active1, uint8_t addr2, ChipType chip2, uint8_t active2) + : _chipCount(2), _totalOutputs(0), _allChipsInitialized(false) { + _chips[0] = ChipConfig(addr1, chip1, active1); + _chips[1] = ChipConfig(addr2, chip2, active2); + updateTotalOutputs(); +} + +PCF8574OutputManager::~PCF8574OutputManager() { + if (_allChipsInitialized) { + emergencyShutdown(); + } + for (uint8_t i = 0; i < _chipCount; i++) { + shutdownChip(i); + } +} + +bool PCF8574OutputManager::initialize() { + LOG_INFO("🔌 Initializing Multi-Chip PCF857x Output Manager (%d chips)", _chipCount); + delay(100); + + bool allSuccess = true; + for (uint8_t i = 0; i < _chipCount; i++) { + if (!initializeChip(i)) { + LOG_ERROR("❌ Failed to initialize chip %d!", i); + allSuccess = false; + } + } + + if (!allSuccess) { + LOG_ERROR("❌ Not all chips initialized successfully!"); + return false; + } + + emergencyShutdown(); + startDurationTask(); + _allChipsInitialized = true; + _initialized = true; // Set base class flag too! + + LOG_INFO("✅ Multi-Chip PCF857x Output Manager Initialized (%d total outputs)", _totalOutputs); + generateHardwareTypeString(); + + if (_configManager) { + LOG_INFO("📋 Virtual Output Configuration Mappings:"); + for (uint8_t i = 0; i < min(16, (int)_totalOutputs); i++) { // Check virtual outputs + uint16_t configOutput = _configManager->getBellOutput(i); + if (configOutput < _totalOutputs) { + VirtualOutputInfo info = getVirtualOutputInfo(configOutput); + LOG_DEBUG(" Bell %d → Virtual Output %d → %s[%d] Pin %d", i, configOutput, info.chipType, info.chipIndex, info.localPin); + } else if (configOutput == 255) { + LOG_DEBUG(" Bell %d → Not configured (255)", i); + } else { + LOG_WARNING("⚠️ Bell %d mapped to invalid virtual output %d (max: %d)", i, configOutput, _totalOutputs - 1); + } + } + + uint8_t c1 = _configManager->getClockOutput1(); + uint8_t c2 = _configManager->getClockOutput2(); + LOG_INFO("🕐 Clock Virtual Output Mappings:"); + + if (c1 != 255 && c1 < _totalOutputs) { + VirtualOutputInfo info = getVirtualOutputInfo(c1); + LOG_DEBUG(" Clock C1 → Virtual Output %d → %s[%d] Pin %d", c1, info.chipType, info.chipIndex, info.localPin); + } else { + LOG_DEBUG(" Clock C1 → Not configured"); + } + + if (c2 != 255 && c2 < _totalOutputs) { + VirtualOutputInfo info = getVirtualOutputInfo(c2); + LOG_DEBUG(" Clock C2 → Virtual Output %d → %s[%d] Pin %d", c2, info.chipType, info.chipIndex, info.localPin); + } else { + LOG_DEBUG(" Clock C2 → Not configured"); + } + + // Show virtual output mapping + LOG_INFO("🔗 Virtual Output Mapping:"); + for (uint8_t i = 0; i < _totalOutputs; i++) { + VirtualOutputInfo info = getVirtualOutputInfo(i); + LOG_DEBUG(" Virtual Output %d → %s[%d] Pin %d", i, info.chipType, info.chipIndex, info.localPin); + } + } + return true; +} + +void PCF8574OutputManager::fireOutput(uint8_t outputIndex) { + if (!_allChipsInitialized) { + LOG_ERROR("❌ PCF857x chips not initialized!"); + return; + } + if (!isValidVirtualOutput(outputIndex)) { + LOG_ERROR("❌ Invalid virtual output: %d (max: %d)", outputIndex, _totalOutputs - 1); + return; + } + + VirtualOutputInfo info = getVirtualOutputInfo(outputIndex); + writeOutputToChip(info.chipIndex, info.localPin, false); + + LOG_DEBUG("🔥 FIRE Virtual Output %d → %s[%d] Pin %d", outputIndex, info.chipType, info.chipIndex, info.localPin); +} + +void PCF8574OutputManager::extinguishOutput(uint8_t outputIndex) { + if (!_allChipsInitialized) return; + if (!isValidVirtualOutput(outputIndex)) return; + + VirtualOutputInfo info = getVirtualOutputInfo(outputIndex); + writeOutputToChip(info.chipIndex, info.localPin, true); + + LOG_DEBUG("💧 EXTINGUISH Virtual Output %d → %s[%d] Pin %d", outputIndex, info.chipType, info.chipIndex, info.localPin); +} + +void PCF8574OutputManager::fireOutputForDuration(uint8_t outputIndex, uint16_t durationMs) { + if (!_allChipsInitialized || !isValidVirtualOutput(outputIndex)) return; + fireOutput(outputIndex); + uint64_t now = getMicros(); + portENTER_CRITICAL(&_outputMutex); + _activeOutputs.push_back({outputIndex, now, durationMs}); + portEXIT_CRITICAL(&_outputMutex); +} + +void PCF8574OutputManager::firePhysicalOutput(uint8_t physicalOutput, bool state) { + if (!_allChipsInitialized || !isValidPhysicalOutput(physicalOutput)) return; + OutputMapping mapping = getOutputMapping(physicalOutput); + writeOutputToChip(mapping.chipIndex, mapping.localPin, !state); // Invert because we're using active LOW +} + +void PCF8574OutputManager::fireOutputsBatch(const std::vector& outputIndices) { + if (!_allChipsInitialized) return; + for (uint8_t outputIndex : outputIndices) { + if (!isValidVirtualOutput(outputIndex)) continue; + VirtualOutputInfo info = getVirtualOutputInfo(outputIndex); + writeOutputToChip(info.chipIndex, info.localPin, false); + } +} + +void PCF8574OutputManager::extinguishOutputsBatch(const std::vector& outputIndices) { + if (!_allChipsInitialized) return; + for (uint8_t outputIndex : outputIndices) { + if (!isValidVirtualOutput(outputIndex)) continue; + VirtualOutputInfo info = getVirtualOutputInfo(outputIndex); + writeOutputToChip(info.chipIndex, info.localPin, true); + } +} + +void PCF8574OutputManager::fireOutputsBatchForDuration(const std::vector>& outputDurations) { + if (!_allChipsInitialized) return; + uint64_t now = getMicros(); + std::vector outputsToFire; + portENTER_CRITICAL(&_outputMutex); + for (const auto& [outputIndex, durationMs] : outputDurations) { + if (isValidVirtualOutput(outputIndex)) { + outputsToFire.push_back(outputIndex); + _activeOutputs.push_back({outputIndex, now, durationMs}); + } + } + portEXIT_CRITICAL(&_outputMutex); + fireOutputsBatch(outputsToFire); +} + +void PCF8574OutputManager::emergencyShutdown() { + LOG_WARNING("🚨 PCF857x EMERGENCY SHUTDOWN - All outputs HIGH"); + portENTER_CRITICAL(&_outputMutex); + _activeOutputs.clear(); + portEXIT_CRITICAL(&_outputMutex); + + for (uint8_t chipIndex = 0; chipIndex < _chipCount; chipIndex++) { + if (_chips[chipIndex].initialized) { + for (uint8_t pin = 0; pin < _chips[chipIndex].activeOutputs; pin++) { + writeOutputToChip(chipIndex, pin, true); + } + } + } +} + +const char* PCF8574OutputManager::getHardwareType() const { + generateHardwareTypeString(); + return _hardwareTypeBuffer; +} + +ChipConfig PCF8574OutputManager::getChipConfig(uint8_t chipIndex) const { + if (chipIndex < _chipCount) { + return _chips[chipIndex]; + } + return ChipConfig(); // Return default config if invalid index +} + +bool PCF8574OutputManager::addChip(uint8_t i2cAddress, ChipType chipType, uint8_t activeOutputs) { + if (_chipCount >= MAX_CHIPS) { + LOG_ERROR("❌ Cannot add more chips - maximum %d chips supported", MAX_CHIPS); + return false; + } + + _chips[_chipCount] = ChipConfig(i2cAddress, chipType, activeOutputs); + _chipCount++; + updateTotalOutputs(); + + LOG_INFO("✅ Added chip %d: %s at 0x%02X (%d/%d active outputs)", + _chipCount - 1, + (chipType == ChipType::PCF8574) ? "PCF8574" : "PCF8575", + i2cAddress, + _chips[_chipCount - 1].activeOutputs, + _chips[_chipCount - 1].maxOutputs); + + return true; +} + +PCF8574OutputManager::VirtualOutputInfo PCF8574OutputManager::getVirtualOutputInfo(uint8_t virtualOutput) const { + VirtualOutputInfo info; + + if (virtualOutput >= _totalOutputs) { + // Invalid - return chip 0, pin 0 as fallback + info.chipIndex = 0; + info.localPin = 0; + info.chipType = (_chipCount > 0 && _chips[0].chipType == ChipType::PCF8574) ? "PCF8574" : "PCF8575"; + LOG_ERROR("❌ Invalid virtual output %d (max: %d)", virtualOutput, _totalOutputs - 1); + return info; + } + + // Map virtual output to physical chip and pin + if (virtualOutput < _chips[0].activeOutputs) { + // Output is on first chip + info.chipIndex = 0; + info.localPin = virtualOutput; + info.chipType = (_chips[0].chipType == ChipType::PCF8574) ? "PCF8574" : "PCF8575"; + } else if (_chipCount > 1) { + // Output is on second chip + info.chipIndex = 1; + info.localPin = virtualOutput - _chips[0].activeOutputs; + info.chipType = (_chips[1].chipType == ChipType::PCF8574) ? "PCF8574" : "PCF8575"; + } else { + // Should not happen, but fallback to chip 0 + info.chipIndex = 0; + info.localPin = 0; + info.chipType = (_chips[0].chipType == ChipType::PCF8574) ? "PCF8574" : "PCF8575"; + LOG_ERROR("❌ Virtual output %d exceeds available outputs on single chip", virtualOutput); + } + + return info; +} + +void PCF8574OutputManager::setChipActiveOutputs(uint8_t chipIndex, uint8_t activeOutputs) { + if (chipIndex >= _chipCount) { + LOG_ERROR("❌ Invalid chip index %d (max: %d)", chipIndex, _chipCount - 1); + return; + } + + uint8_t maxOutputs = _chips[chipIndex].maxOutputs; + _chips[chipIndex].activeOutputs = min(activeOutputs, maxOutputs); + updateTotalOutputs(); + + LOG_INFO("✅ Updated chip %d active outputs: %d/%d", chipIndex, _chips[chipIndex].activeOutputs, maxOutputs); +} + +uint8_t PCF8574OutputManager::getChipActiveOutputs(uint8_t chipIndex) const { + if (chipIndex >= _chipCount) { + LOG_ERROR("❌ Invalid chip index %d (max: %d)", chipIndex, _chipCount - 1); + return 0; + } + return _chips[chipIndex].activeOutputs; +} + +// Private helper methods +bool PCF8574OutputManager::initializeChip(uint8_t chipIndex) { + if (chipIndex >= _chipCount) return false; + + ChipConfig& chip = _chips[chipIndex]; + const char* chipTypeStr = (chip.chipType == ChipType::PCF8574) ? "PCF8574" : "PCF8575"; + + LOG_DEBUG("🔌 Initializing %s at address 0x%02X", chipTypeStr, chip.i2cAddress); + + try { + if (chip.chipType == ChipType::PCF8574) { + // Use static instance to avoid memory allocation issues + static Adafruit_PCF8574 pcf8574Instances[MAX_CHIPS]; + chip.chipInstance = &pcf8574Instances[chipIndex]; + Adafruit_PCF8574* pcf = static_cast(chip.chipInstance); + + if (!pcf->begin(chip.i2cAddress, &Wire)) { + LOG_ERROR("❌ Failed to initialize PCF8574 at address 0x%02X", chip.i2cAddress); + chip.chipInstance = nullptr; + return false; + } + + // Configure all pins as outputs and set them HIGH (inactive) + for (uint8_t pin = 0; pin < 8; pin++) { + pcf->pinMode(pin, OUTPUT); + pcf->digitalWrite(pin, HIGH); + } + } else { // PCF8575 + // Use static instance to avoid memory allocation issues + static Adafruit_PCF8575 pcf8575Instances[MAX_CHIPS]; + chip.chipInstance = &pcf8575Instances[chipIndex]; + Adafruit_PCF8575* pcf = static_cast(chip.chipInstance); + + if (!pcf->begin(chip.i2cAddress, &Wire)) { + LOG_ERROR("❌ Failed to initialize PCF8575 at address 0x%02X", chip.i2cAddress); + chip.chipInstance = nullptr; + return false; + } + + // Configure all pins as outputs and set them HIGH (inactive) + for (uint8_t pin = 0; pin < 16; pin++) { + pcf->pinMode(pin, OUTPUT); + pcf->digitalWrite(pin, HIGH); + } + } + + chip.initialized = true; + LOG_DEBUG("✅ %s at 0x%02X initialized successfully", chipTypeStr, chip.i2cAddress); + return true; + + } catch (...) { + LOG_ERROR("❌ Exception during %s initialization at 0x%02X", chipTypeStr, chip.i2cAddress); + chip.chipInstance = nullptr; + return false; + } +} + +void PCF8574OutputManager::shutdownChip(uint8_t chipIndex) { + if (chipIndex >= _chipCount) return; + + ChipConfig& chip = _chips[chipIndex]; + if (chip.initialized && chip.chipInstance) { + // Set all outputs to HIGH (inactive) before shutdown + for (uint8_t pin = 0; pin < chip.activeOutputs; pin++) { + writeOutputToChip(chipIndex, pin, true); + } + chip.initialized = false; + chip.chipInstance = nullptr; + } +} + +PCF8574OutputManager::OutputMapping PCF8574OutputManager::getOutputMapping(uint8_t physicalOutput) const { + OutputMapping mapping; + + if (physicalOutput < _chips[0].activeOutputs) { + // Output is on first chip + mapping.chipIndex = 0; + mapping.localPin = physicalOutput; + } else if (_chipCount > 1 && physicalOutput < _totalOutputs) { + // Output is on second chip + mapping.chipIndex = 1; + mapping.localPin = physicalOutput - _chips[0].activeOutputs; + } else { + // Invalid output - return chip 0, pin 0 as safe fallback + mapping.chipIndex = 0; + mapping.localPin = 0; + LOG_ERROR("❌ Invalid physical output %d mapped to fallback", physicalOutput); + } + + return mapping; +} + +void PCF8574OutputManager::writeOutputToChip(uint8_t chipIndex, uint8_t pin, bool state) { + if (chipIndex >= _chipCount || !_chips[chipIndex].initialized) return; + if (!isValidOutputForChip(chipIndex, pin)) return; + + ChipConfig& chip = _chips[chipIndex]; + + if (chip.chipType == ChipType::PCF8574) { + Adafruit_PCF8574* pcf = static_cast(chip.chipInstance); + pcf->digitalWrite(pin, state ? HIGH : LOW); + } else { // PCF8575 + Adafruit_PCF8575* pcf = static_cast(chip.chipInstance); + pcf->digitalWrite(pin, state ? HIGH : LOW); + } +} + +bool PCF8574OutputManager::isValidOutputForChip(uint8_t chipIndex, uint8_t pin) const { + if (chipIndex >= _chipCount) return false; + return pin < _chips[chipIndex].activeOutputs; +} + +void PCF8574OutputManager::updateTotalOutputs() { + _totalOutputs = 0; + for (uint8_t i = 0; i < _chipCount; i++) { + _totalOutputs += _chips[i].activeOutputs; + } +} + +void PCF8574OutputManager::generateHardwareTypeString() const { + if (_chipCount == 1) { + const char* chipType = (_chips[0].chipType == ChipType::PCF8574) ? "PCF8574" : "PCF8575"; + snprintf(_hardwareTypeBuffer, sizeof(_hardwareTypeBuffer), "%s I2C Expander (%d/%d outputs)", + chipType, _chips[0].activeOutputs, _chips[0].maxOutputs); + } else { + const char* chip1Type = (_chips[0].chipType == ChipType::PCF8574) ? "PCF8574" : "PCF8575"; + const char* chip2Type = (_chips[1].chipType == ChipType::PCF8574) ? "PCF8574" : "PCF8575"; + snprintf(_hardwareTypeBuffer, sizeof(_hardwareTypeBuffer), "%s+%s I2C Expanders (%d outputs total)", + chip1Type, chip2Type, _totalOutputs); + } +} + +// ==================== HEALTH CHECK IMPLEMENTATION ==================== + +bool OutputManager::isHealthy() const { + // Basic health checks + if (!_initialized) { + return false; + } + + // Check if ConfigManager is available + if (!_configManager) { + return false; + } + + // Check if duration task is running when it should be + if (_initialized && _durationTaskHandle == nullptr) { + return false; + } + + // Check if max outputs is reasonable + if (getMaxOutputs() == 0 || getMaxOutputs() > 64) { // Sanity check + return false; + } + + return true; +} + +bool PCF8574OutputManager::isHealthy() const { + // Call base class health check first + if (!OutputManager::isHealthy()) { + return false; + } + + // Check chip-specific health + if (_chipCount == 0 || _chipCount > MAX_CHIPS) { + return false; + } + + if (!_allChipsInitialized) { + return false; + } + + // Check each chip's health + for (uint8_t i = 0; i < _chipCount; i++) { + const ChipConfig& chip = _chips[i]; + + // Check if chip is properly initialized + if (!chip.initialized || chip.chipInstance == nullptr) { + return false; + } + + // Check if active outputs are within valid range + if (chip.activeOutputs == 0 || chip.activeOutputs > chip.maxOutputs) { + return false; + } + + // Check if I2C address is in valid range + if (chip.i2cAddress < 0x20 || chip.i2cAddress > 0x27) { + return false; + } + } + + // Check total outputs consistency + uint8_t calculatedTotal = 0; + for (uint8_t i = 0; i < _chipCount; i++) { + calculatedTotal += _chips[i].activeOutputs; + } + + if (calculatedTotal != _totalOutputs) { + return false; + } + + return true; +} + diff --git a/vesper/src/OutputManager/OutputManager.hpp b/vesper/src/OutputManager/OutputManager.hpp new file mode 100644 index 0000000..d1cafbd --- /dev/null +++ b/vesper/src/OutputManager/OutputManager.hpp @@ -0,0 +1,346 @@ +/* + * ═══════════════════════════════════════════════════════════════════════════════════ + * OUTPUTMANAGER.HPP - FIXED VERSION - Hardware Abstraction Layer for Relay Control + * ═══════════════════════════════════════════════════════════════════════════════════ + * + * FIXES APPLIED: + * - Better validation for physical outputs (handles 255 = unconfigured) + * - Enhanced logging for debugging configuration issues + * - Clear separation between bell outputs and clock outputs + * - Improved error messages with context + * + * ⚡ THE HARDWARE ABSTRACTION POWERHOUSE ⚡ + * + * This module provides a clean, unified interface for controlling different + * types of relay/output hardware. It completely abstracts the hardware details + * from the rest of the system, allowing easy swapping between different + * relay control methods. + * + * 🏗️ CLEAN ARCHITECTURE: + * • Abstract base class with polymorphic interface + * • Multiple concrete implementations for different hardware + * • Automatic duration management with microsecond precision + * • Thread-safe operation with FreeRTOS integration + * • Comprehensive error handling and safety features + * + * 🔧 SUPPORTED HARDWARE: + * • PCF8574OutputManager: I2C GPIO expanders (PCF8574: 8 outputs, PCF8575: 16 outputs) + * • Multi-chip support: Up to 2 chips for maximum flexibility + * + * ⏱️ PRECISION TIMING: + * • Microsecond-accurate duration control + * • Automatic relay timeout management + * • Non-blocking operation with background task + * • Configurable duration per bell/relay + * + * 🔒 SAFETY FEATURES: + * • Emergency shutdown capability + * • Bounds checking on all operations + * • Hardware initialization validation + * • Automatic cleanup on failure + * + * 📌 USAGE: + * Choose the appropriate implementation in main.cpp: + * - PCF8574OutputManager for I2C expander setups + * - GPIOOutputManager for direct pin control + * - MockOutputManager for testing and development + * + * 🔔 BELL vs CLOCK OUTPUTS: + * - Bell outputs: Managed via ConfigManager->getBellOutput(bellIndex) + * - Clock outputs: Managed via ConfigManager->getClockOutput1()/getClockOutput2() + * - Both use the same OutputManager but for different purposes + * - Bell indices: 0-15 (for 16 bells) + * - Clock outputs: c1, c2 (for clock mechanism) + * + * 📋 VERSION: 2.1 (Fixed configuration validation) + * 📅 DATE: 2025 + * 👨‍💻 AUTHOR: Advanced Bell Systems + * ═══════════════════════════════════════════════════════════════════════════════════ + */ + +#pragma once + +// ═════════════════════════════════════════════════════════════════════════════════ +// SYSTEM INCLUDES - Core libraries for hardware control +// ═════════════════════════════════════════════════════════════════════════════════ +#include // Arduino core functionality +#include // Fixed-width integer types +#include // STL vector for active relay tracking +#include "freertos/FreeRTOS.h" // FreeRTOS kernel +#include "freertos/task.h" // FreeRTOS task management +#include "esp_timer.h" // ESP32 high-precision timers +#include "../Logging/Logging.hpp" // Centralized logging system + +// ═════════════════════════════════════════════════════════════════════════════════ +// FORWARD DECLARATIONS +// ═════════════════════════════════════════════════════════════════════════════════ +class ConfigManager; // Configuration management system + +// ═════════════════════════════════════════════════════════════════════════════════ +// ACTIVE OUTPUT TRACKING STRUCTURE +// ═════════════════════════════════════════════════════════════════════════════════ + +/** + * @struct ActiveOutput + * @brief Tracks active outputs with microsecond precision timing + * + * This structure maintains the state of currently active outputs, + * including their activation time and configured duration for + * automatic timeout management. + */ +struct ActiveOutput { + uint8_t outputIndex; // 🔌 Virtual output index (0-31) + uint64_t activationTime; // ⏱️ Activation start time (microseconds) + uint16_t durationMs; // ⏳ Duration in milliseconds +}; + +/** + * @class OutputManager + * @brief Abstract base class for hardware output management + * + * Provides a clean abstraction layer for different relay/output systems. + * This class defines the interface that all concrete implementations must + * follow, ensuring consistent behavior across different hardware types. + * + * 🏗️ KEY DESIGN PRINCIPLES: + * • Hardware-agnostic interface + * • Automatic duration management + * • Thread-safe operation + * • Comprehensive error handling + * • Emergency shutdown capability + * + * 🔌 SUPPORTED OPERATIONS: + * • Immediate relay control (fire/extinguish) + * • Timed relay activation with automatic shutoff + * • Emergency shutdown of all outputs + * • Hardware status monitoring + * + * ⏱️ TIMING FEATURES: + * • Microsecond-precision activation timing + * • Background task for duration management + * • Non-blocking operation + * • Configurable per-bell durations + */ +class OutputManager { +public: + // ═══════════════════════════════════════════════════════════════════════════════ + // CONSTRUCTOR & DESTRUCTOR + // ═══════════════════════════════════════════════════════════════════════════════ + + OutputManager() = default; + + /** + * @brief Virtual destructor for proper cleanup + * + * Ensures derived classes can properly clean up their resources. + */ + virtual ~OutputManager(); + + // ═══════════════════════════════════════════════════════════════════════════════ + // PURE VIRTUAL INTERFACE - Must be implemented by derived classes + // ═══════════════════════════════════════════════════════════════════════════════ + + /** @brief Initialize hardware - must be implemented */ + virtual bool initialize() = 0; + + /** @brief Activate output immediately - hardware agnostic */ + virtual void fireOutput(uint8_t outputIndex) = 0; + + /** @brief Deactivate output immediately - hardware agnostic */ + virtual void extinguishOutput(uint8_t outputIndex) = 0; + + /** @brief Emergency shutdown all outputs - must be implemented */ + virtual void emergencyShutdown() = 0; + + /** @brief Timed output activation - hardware agnostic */ + virtual void fireOutputForDuration(uint8_t outputIndex, uint16_t durationMs) = 0; + + /** @brief Batch operations for simultaneous firing (CRITICAL for synchronization!) */ + virtual void fireOutputsBatch(const std::vector& outputIndices) = 0; + virtual void extinguishOutputsBatch(const std::vector& outputIndices) = 0; + virtual void fireOutputsBatchForDuration(const std::vector>& outputDurations) = 0; + + + /** @brief Check if hardware is initialized - must be implemented */ + virtual bool isInitialized() const = 0; + + /** @brief Get maximum number of outputs - must be implemented */ + virtual uint8_t getMaxOutputs() const = 0; + + /** @brief Get hardware type description - must be implemented */ + virtual const char* getHardwareType() const = 0; + + /** @brief Set configuration manager reference */ + virtual void setConfigManager(ConfigManager* config) { _configManager = config; } + + /** @brief Get physical output mapping from virtual output (for validation) */ + uint8_t getPhysicalOutput(uint8_t virtualOutput) const; + + // ═══════════════════════════════════════════════════════════════════════════════ + // HEALTH CHECK METHOD + // ═══════════════════════════════════════════════════════════════════════════════ + + /** @brief Check if OutputManager is in healthy state */ + virtual bool isHealthy() const; + + // ═══════════════════════════════════════════════════════════════════════════════ + // CLOCK-SPECIFIC OUTPUT METHODS - For TimeKeeper integration + // ═══════════════════════════════════════════════════════════════════════════════ + + /** @brief Fire clock output directly by virtual output number (for TimeKeeper) */ + virtual void fireClockOutput(uint8_t virtualOutput, uint16_t durationMs); + + /** @brief Get total virtual outputs available */ + virtual uint8_t getTotalVirtualOutputs() const { return getMaxOutputs(); } + +protected: + ConfigManager* _configManager = nullptr; + bool _initialized = false; + + // Active outputs tracking + std::vector _activeOutputs; + portMUX_TYPE _outputMutex = portMUX_INITIALIZER_UNLOCKED; + + // Duration management task + TaskHandle_t _durationTaskHandle = nullptr; + + // Bounds checking + bool isValidVirtualOutput(uint8_t virtualOutput) const; + bool isValidPhysicalOutput(uint8_t physicalOutput) const; + + + // Duration management + void startDurationTask(); + void stopDurationTask(); + void processExpiredOutputs(); + static void durationTask(void* parameter); + + // Direct physical output access (for clock outputs) + virtual void firePhysicalOutput(uint8_t physicalOutput, bool state) = 0; + + // Timing utilities + uint64_t getMicros() const { return esp_timer_get_time(); } +}; + +/** + * Chip Type Enumeration + * Defines the supported I2C GPIO expander types + */ +enum class ChipType { + PCF8574, // 8 outputs + PCF8575 // 16 outputs +}; + +/** + * Chip Configuration Structure + * Holds configuration for each I2C GPIO expander chip + */ +struct ChipConfig { + uint8_t i2cAddress; // I2C address (0x20-0x27 for PCF8574, 0x20-0x27 for PCF8575) + ChipType chipType; // Type of chip (PCF8574 or PCF8575) + uint8_t maxOutputs; // Maximum outputs chip supports (8 for PCF8574, 16 for PCF8575) + uint8_t activeOutputs; // Number of active outputs (user-configurable, <= maxOutputs) + void* chipInstance; // Pointer to chip instance + bool initialized; // Initialization status + + ChipConfig() : i2cAddress(0x20), chipType(ChipType::PCF8574), maxOutputs(8), activeOutputs(8), chipInstance(nullptr), initialized(false) {} + + ChipConfig(uint8_t addr, ChipType type, uint8_t activeOuts = 0) : i2cAddress(addr), chipType(type), chipInstance(nullptr), initialized(false) { + maxOutputs = (type == ChipType::PCF8574) ? 8 : 16; + activeOutputs = (activeOuts == 0) ? maxOutputs : min(activeOuts, maxOutputs); + } +}; + +/** + * PCF8574/PCF8575 I2C GPIO Expander Implementation + * Supports single or dual chip configurations with configurable active outputs: + * - Single PCF8574: 1-8 active outputs + * - Single PCF8575: 1-16 active outputs + * - PCF8574 + PCF8575: (1-8) + (1-16) active outputs + * - Dual PCF8575: (1-16) + (1-16) active outputs + * + * Virtual Output Mapping: + * - Virtual outputs 0 to (chip1_active-1) → Chip 1, pins 0 to (chip1_active-1) + * - Virtual outputs chip1_active to (total_active-1) → Chip 2, pins 0 to (chip2_active-1) + */ +class PCF8574OutputManager : public OutputManager { +public: + // Single chip constructors + PCF8574OutputManager(uint8_t i2cAddress = 0x20, ChipType chipType = ChipType::PCF8574, uint8_t activeOutputs = 0); + + // Dual chip constructor + PCF8574OutputManager(uint8_t addr1, ChipType chip1, uint8_t active1, uint8_t addr2, ChipType chip2, uint8_t active2); + + virtual ~PCF8574OutputManager(); + + // OutputManager interface - NEW GENERIC METHODS + bool initialize() override; + void fireOutput(uint8_t outputIndex) override; + void extinguishOutput(uint8_t outputIndex) override; + void emergencyShutdown() override; + void fireOutputForDuration(uint8_t outputIndex, uint16_t durationMs) override; + + // Batch operations for simultaneous firing + void fireOutputsBatch(const std::vector& outputIndices) override; + void extinguishOutputsBatch(const std::vector& outputIndices) override; + void fireOutputsBatchForDuration(const std::vector>& outputDurations) override; + + bool isInitialized() const override { return _allChipsInitialized; } + uint8_t getMaxOutputs() const override { return _totalOutputs; } + const char* getHardwareType() const override; + + // Multi-chip specific methods + uint8_t getChipCount() const { return _chipCount; } + ChipConfig getChipConfig(uint8_t chipIndex) const; + bool addChip(uint8_t i2cAddress, ChipType chipType, uint8_t activeOutputs = 0); + + // Virtual output mapping information + struct VirtualOutputInfo { + uint8_t chipIndex; + uint8_t localPin; + const char* chipType; + }; + VirtualOutputInfo getVirtualOutputInfo(uint8_t virtualOutput) const; + + // Configuration methods + void setChipActiveOutputs(uint8_t chipIndex, uint8_t activeOutputs); + uint8_t getChipActiveOutputs(uint8_t chipIndex) const; + + // Legacy single-chip methods (for backward compatibility) + void setI2CAddress(uint8_t address) { if (_chipCount > 0) _chips[0].i2cAddress = address; } + uint8_t getI2CAddress() const { return (_chipCount > 0) ? _chips[0].i2cAddress : 0x20; } + + bool isHealthy() const override; + +protected: + // Direct physical output access + void firePhysicalOutput(uint8_t physicalOutput, bool state) override; + +private: + static const uint8_t MAX_CHIPS = 2; // Maximum supported chips + ChipConfig _chips[MAX_CHIPS]; // Chip configurations + uint8_t _chipCount; // Number of configured chips + uint8_t _totalOutputs; // Total outputs across all chips + bool _allChipsInitialized; // True if all chips are initialized + mutable char _hardwareTypeBuffer[64]; // Buffer for hardware type string + + // Chip management + bool initializeChip(uint8_t chipIndex); + void shutdownChip(uint8_t chipIndex); + + // Output routing + struct OutputMapping { + uint8_t chipIndex; // Which chip (0 or 1) + uint8_t localPin; // Pin on that chip (0-7 for PCF8574, 0-15 for PCF8575) + }; + OutputMapping getOutputMapping(uint8_t physicalOutput) const; + + // Low-level I/O operations + void writeOutputToChip(uint8_t chipIndex, uint8_t pin, bool state); + bool isValidOutputForChip(uint8_t chipIndex, uint8_t pin) const; + + // Initialization helpers + void updateTotalOutputs(); + void generateHardwareTypeString() const; +}; + diff --git a/vesper/src/Player/Player.cpp b/vesper/src/Player/Player.cpp new file mode 100644 index 0000000..6a64dd1 --- /dev/null +++ b/vesper/src/Player/Player.cpp @@ -0,0 +1,457 @@ +#include "Player.hpp" +#include "../Communication/Communication.hpp" +#include "../BellEngine/BellEngine.hpp" + +// Note: Removed global melody_steps dependency for cleaner architecture + +// Constructor with dependencies +Player::Player(Communication* comm, FileManager* fm) + : id(0) + , name("melody1") + , uid("x") + , url("-") + , noteAssignments{1,2,3,4,5,6,0,0,0,0,0,0,0,0,0,0} + , speed(500) + , segment_duration(15000) + , pause_duration(0) + , total_duration(0) + , segmentCmpltTime(0) + , segmentStartTime(0) + , startTime(0) + , pauseTime(0) + , continuous_loop(false) + , infinite_play(false) + , isPlaying(false) + , isPaused(false) + , hardStop(false) + , _status(PlayerStatus::STOPPED) + , _commManager(comm) + , _fileManager(fm) + , _bellEngine(nullptr) + , _durationTimerHandle(NULL) { +} + +// Default constructor (for backward compatibility) +Player::Player() + : id(0) + , name("melody1") + , uid("x") + , url("-") + , noteAssignments{1,2,3,4,5,6,0,0,0,0,0,0,0,0,0,0} + , speed(500) + , segment_duration(15000) + , pause_duration(0) + , total_duration(0) + , segmentCmpltTime(0) + , segmentStartTime(0) + , startTime(0) + , pauseTime(0) + , continuous_loop(false) + , infinite_play(false) + , isPlaying(false) + , isPaused(false) + , hardStop(false) + , _status(PlayerStatus::STOPPED) + , _commManager(nullptr) + , _fileManager(nullptr) + , _bellEngine(nullptr) + , _durationTimerHandle(NULL) { +} + +void Player::setDependencies(Communication* comm, FileManager* fm) { + _commManager = comm; + _fileManager = fm; +} + +// Destructor +Player::~Player() { + // Stop any ongoing playback + if (isPlaying) { + forceStop(); + } + + // Properly cleanup timer + if (_durationTimerHandle != NULL) { + xTimerDelete(_durationTimerHandle, portMAX_DELAY); + _durationTimerHandle = NULL; + } +} + +void Player::begin() { + LOG_INFO("Initializing Player with FreeRTOS Timer (saves 4KB RAM!)"); + + // Create a periodic timer that fires every 500ms + _durationTimerHandle = xTimerCreate( + "PlayerTimer", // Timer name + pdMS_TO_TICKS(500), // Period (500ms) + pdTRUE, // Auto-reload (periodic) + this, // Timer ID (pass Player instance) + durationTimerCallback // Callback function + ); + + if (_durationTimerHandle != NULL) { + xTimerStart(_durationTimerHandle, 0); + LOG_INFO("Player initialized successfully with timer"); + } else { + LOG_ERROR("Failed to create Player timer!"); + } +} + +void Player::play() { + if (_melodySteps.empty()) { + LOG_ERROR("Cannot play: No melody loaded"); + return; + } + + if (_bellEngine) { + _bellEngine->setMelodyData(_melodySteps); + _bellEngine->start(); + } + + isPlaying = true; + hardStop = false; + startTime = segmentStartTime = millis(); + setStatus(PlayerStatus::PLAYING); // Update status and notify clients + LOG_DEBUG("Plbck: PLAY"); +} + +void Player::forceStop() { + if (_bellEngine) { + _bellEngine->emergencyStop(); + } + + hardStop = true; + isPlaying = false; + setStatus(PlayerStatus::STOPPED); // Immediate stop, notify clients + LOG_DEBUG("Plbck: FORCE STOP"); +} + +void Player::stop() { + if (_bellEngine) { + _bellEngine->stop(); + } + + hardStop = false; + isPlaying = false; + + // Set STOPPING status - actual stop message will be sent when BellEngine finishes + setStatus(PlayerStatus::STOPPING); + LOG_DEBUG("Plbck: SOFT STOP (waiting for melody to complete)"); + + // NOTE: The actual "stop" message is now sent in onMelodyLoopCompleted() + // when the BellEngine actually finishes the current loop +} + +void Player::pause() { + isPaused = true; + setStatus(PlayerStatus::PAUSED); + LOG_DEBUG("Plbck: PAUSE"); +} + +void Player::unpause() { + isPaused = false; + segmentStartTime = millis(); + setStatus(PlayerStatus::PLAYING); + LOG_DEBUG("Plbck: RESUME"); +} + +bool Player::command(JsonVariant data) { + setMelodyAttributes(data); + loadMelodyInRAM(); // Removed parameter - use internal storage + + String action = data["action"]; + LOG_DEBUG("Incoming Command: %s", action.c_str()); + + // Play or Stop Logic + if (action == "play") { + play(); + return true; + } else if (action == "stop") { + forceStop(); + return true; + } else { + LOG_WARNING("Unknown playback action: %s", action.c_str()); + return false; + } +} + +void Player::setMelodyAttributes(JsonVariant doc) { + if (doc.containsKey("name")) { + name = doc["name"].as(); + } + if (doc.containsKey("uid")) { + uid = doc["uid"].as(); + } + if (doc.containsKey("url")) { + url = doc["url"].as(); + } + if (doc.containsKey("speed")) { + speed = doc["speed"].as(); + } + if (doc.containsKey("note_assignments")) { + JsonArray noteArray = doc["note_assignments"]; + size_t arraySize = min(noteArray.size(), (size_t)16); + for (size_t i = 0; i < arraySize; i++) { + noteAssignments[i] = noteArray[i]; + } + } + if (doc.containsKey("segment_duration")) { + segment_duration = doc["segment_duration"].as(); + } + if (doc.containsKey("pause_duration")) { + pause_duration = doc["pause_duration"].as(); + } + if (doc.containsKey("total_duration")) { + total_duration = doc["total_duration"].as(); + } + if (doc.containsKey("continuous_loop")) { + continuous_loop = doc["continuous_loop"].as(); + } + + if (continuous_loop && total_duration == 0) { + infinite_play = true; + } + + if (!continuous_loop) { + total_duration = segment_duration; + } + + // Print Just for Debugging Purposes + LOG_DEBUG("Set Melody Vars / Name: %s, UID: %s", + name.c_str(), uid.c_str()); + LOG_DEBUG("URL: %s", url.c_str()); + LOG_DEBUG("Speed: %d, Per Segment Duration: %lu, Pause Duration: %lu, Total Duration: %d, Continuous: %s, Infinite: %s", + speed, segment_duration, pause_duration, total_duration, + continuous_loop ? "true" : "false", infinite_play ? "true" : "false"); +} + +void Player::loadMelodyInRAM() { + String filePath = "/melodies/" + String(uid.c_str()); + + 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; + } else { + bin_file = SD.open(filePath.c_str(), FILE_READ); + } + } else { + LOG_ERROR("FileManager not available for download"); + return; + } + } + + size_t fileSize = bin_file.size(); + if (fileSize % 2 != 0) { + LOG_ERROR("Invalid file size: %u (not a multiple of 2)", fileSize); + bin_file.close(); + return; + } + + // Load into Player's internal melody storage only + _melodySteps.resize(fileSize / 2); + + for (size_t i = 0; i < _melodySteps.size(); i++) { + uint8_t high = bin_file.read(); + uint8_t low = bin_file.read(); + _melodySteps[i] = (high << 8) | low; + } + + LOG_INFO("Melody loaded successfully: %d steps", _melodySteps.size()); + bin_file.close(); +} + +// Static timer callback function for FreeRTOS +void Player::durationTimerCallback(TimerHandle_t xTimer) { + // Get Player instance from timer ID + Player* player = static_cast(pvTimerGetTimerID(xTimer)); + + // Only run checks when actually playing + if (!player->isPlaying) { + return; + } + + unsigned long now = millis(); + + if (player->timeToStop(now)) { + player->stop(); + } else if (player->timeToPause(now)) { + player->pause(); + } else if (player->timeToResume(now)) { + player->unpause(); + } +} + +// Check if it's time to stop playback +bool Player::timeToStop(unsigned long now) { + if (isPlaying && !infinite_play) { + uint64_t stopTime = startTime + total_duration; + if (now >= stopTime) { + LOG_DEBUG("(TimerFunction) Total Run Duration Reached. Soft Stopping."); + return true; + } + } + return false; +} + +// Status management and BellEngine callback +void Player::setStatus(PlayerStatus newStatus) { + if (_status == newStatus) { + return; // No change, don't send duplicate messages + } + + PlayerStatus oldStatus = _status; + _status = newStatus; + + // Send appropriate message to ALL clients (WebSocket + MQTT) based on status change + if (_commManager) { + StaticJsonDocument<256> doc; + doc["status"] = "INFO"; + doc["type"] = "playback"; + + // Create payload object for complex data + JsonObject payload = doc.createNestedObject("payload"); + + switch (newStatus) { + case PlayerStatus::PLAYING: + payload["action"] = "playing"; + payload["time_elapsed"] = (millis() - startTime) / 1000; // Convert to seconds + break; + case PlayerStatus::PAUSED: + payload["action"] = "paused"; + payload["time_elapsed"] = (millis() - startTime) / 1000; + break; + case PlayerStatus::STOPPED: + payload["action"] = "idle"; + payload["time_elapsed"] = 0; + break; + case PlayerStatus::STOPPING: + payload["action"] = "stopping"; + payload["time_elapsed"] = (millis() - startTime) / 1000; + break; + } + + // Add projected run time for all states (0 if not applicable) + uint64_t projectedRunTime = calculateProjectedRunTime(); + payload["projected_run_time"] = projectedRunTime; + + // 🔥 Use broadcastStatus() to send to BOTH WebSocket AND MQTT clients! + _commManager->broadcastStatus(doc); + + LOG_DEBUG("Status changed: %d → %d, broadcast sent with runTime: %llu", + (int)oldStatus, (int)newStatus, projectedRunTime); + } +} + +void Player::onMelodyLoopCompleted() { + // This is called by BellEngine when a melody loop actually finishes + if (_status == PlayerStatus::STOPPING) { + // We were in soft stop mode, now actually stop + setStatus(PlayerStatus::STOPPED); + LOG_DEBUG("Plbck: ACTUAL STOP (melody loop completed)"); + } + + // Mark segment completion time + segmentCmpltTime = millis(); +} + +// Check if it's time to pause playback +bool Player::timeToPause(unsigned long now) { + if (isPlaying && continuous_loop) { + uint64_t timeToPause = segmentStartTime + segment_duration; + LOG_DEBUG("PTL: %llu // NOW: %lu", timeToPause, now); + if (now >= timeToPause && !isPaused) { + LOG_DEBUG("(TimerFunction) Segment Duration Reached. Pausing."); + pauseTime = now; + return true; + } + } + return false; +} + +// Check if it's time to resume playback +bool Player::timeToResume(unsigned long now) { + if (isPaused) { + uint64_t timeToResume = segmentCmpltTime + pause_duration; + if (now >= timeToResume) { + LOG_DEBUG("(TimerFunction) Pause Duration Reached. Resuming"); + return true; + } + } + return false; +} + +// Calculate the projected total run time of current playback +uint64_t Player::calculateProjectedRunTime() const { + if (_melodySteps.empty() || (_status == PlayerStatus::STOPPED)) { + return 0; // No melody loaded or actually stopped + } + + // Calculate single loop duration: steps * speed (in milliseconds) + uint32_t singleLoopDuration = _melodySteps.size() * speed; + + if (infinite_play || total_duration == 0) { + return 0; // Infinite playback has no end time + } + + // Calculate how many loops are needed to meet or exceed total_duration + uint32_t loopsNeeded = (total_duration + singleLoopDuration - 1) / singleLoopDuration; // Ceiling division + + // Calculate actual total duration (this is the projected run time) + uint32_t actualTotalDuration = singleLoopDuration * loopsNeeded; + + // Return the total duration (offset from start), not a timestamp + return actualTotalDuration; +} + +// ════════════════════════════════════════════════════════════════════════════ +// HEALTH CHECK IMPLEMENTATION +// ════════════════════════════════════════════════════════════════════════════ + +bool Player::isHealthy() const { + // Check if dependencies are properly set + if (!_commManager) { + LOG_DEBUG("Player: Unhealthy - Communication manager not set"); + return false; + } + + if (!_fileManager) { + LOG_DEBUG("Player: Unhealthy - File manager not set"); + return false; + } + + if (!_bellEngine) { + LOG_DEBUG("Player: Unhealthy - BellEngine not set"); + return false; + } + + // Check if timer is properly created + if (_durationTimerHandle == NULL) { + LOG_DEBUG("Player: Unhealthy - Duration timer not created"); + return false; + } + + // Check if timer is actually running + if (xTimerIsTimerActive(_durationTimerHandle) == pdFALSE) { + LOG_DEBUG("Player: Unhealthy - Duration timer not active"); + return false; + } + + // Check for consistent playback state + if (isPlaying && (_status == PlayerStatus::STOPPED)) { + LOG_DEBUG("Player: Unhealthy - Inconsistent playback state"); + return false; + } + + return true; +} diff --git a/vesper/src/Player/Player.hpp b/vesper/src/Player/Player.hpp new file mode 100644 index 0000000..b76ce4f --- /dev/null +++ b/vesper/src/Player/Player.hpp @@ -0,0 +1,267 @@ +/* + * ═══════════════════════════════════════════════════════════════════════════════════ + * PLAYER.HPP - Melody Playback and Control System + * ═══════════════════════════════════════════════════════════════════════════════════ + * + * 🎵 THE MELODY MAESTRO OF VESPER 🎵 + * + * This class manages melody playback, timing control, and coordination with + * the BellEngine for precise bell activation. It handles melody loading, + * duration management, and playback state control. + * + * 🏗️ ARCHITECTURE: + * • Clean separation between playback logic and timing engine + * • FreeRTOS timer-based duration control (saves 4KB RAM vs tasks!) + * • Dependency injection for loose coupling + * • Thread-safe state management + * • Comprehensive melody metadata handling + * + * 🎶 KEY FEATURES: + * • Multi-format melody support with note assignments + * • Flexible timing control (speed, segments, loops) + * • Pause/resume functionality + * • Duration-based automatic stopping + * • Continuous and finite loop modes + * • Real-time playback status tracking + * + * ⏱️ TIMING MANAGEMENT: + * • Segment-based playback with configurable pauses + * • Total duration limiting + * • Precision timing coordination with BellEngine + * • Memory-efficient timer implementation + * + * 🔗 INTEGRATION: + * The Player coordinates with BellEngine for precise timing, + * Communication for command handling, and FileManager for + * melody file operations. + * + * 📋 VERSION: 2.0 (Modular architecture with dependency injection) + * 📅 DATE: 2025 + * 👨‍💻 AUTHOR: Advanced Bell Systems + * ═══════════════════════════════════════════════════════════════════════════════════ + */ + +#pragma once + +// ═════════════════════════════════════════════════════════════════════════════════ +// SYSTEM INCLUDES - Core libraries for melody playback +// ═════════════════════════════════════════════════════════════════════════════════ +#include // Arduino core functionality +#include // STL vector for melody data storage +#include // STL string for melody metadata +#include // Fixed-width integer types +#include // JSON parsing for melody configuration +#include // WebSocket client handling +#include // SD card operations for melody files +#include "freertos/FreeRTOS.h" // FreeRTOS kernel +#include "freertos/task.h" // FreeRTOS task management +#include "../Logging/Logging.hpp" // Centralized logging system +#include "../FileManager/FileManager.hpp" // File operations abstraction + +// ═════════════════════════════════════════════════════════════════════════════════ +// FORWARD DECLARATIONS - Dependencies injected at runtime +// ═════════════════════════════════════════════════════════════════════════════════ +class Communication; // Command handling and communication +class BellEngine; // High-precision timing engine + +// ═════════════════════════════════════════════════════════════════════════════════ +// PLAYER STATUS ENUMERATION +// ═════════════════════════════════════════════════════════════════════════════════ +/** + * @enum PlayerStatus + * @brief Defines the current state of the player + */ +enum class PlayerStatus { + STOPPED, // ⏹️ Not playing, engine stopped + PLAYING, // ▶️ Actively playing melody + PAUSED, // ⏸️ Temporarily paused between segments + STOPPING // 🔄 Soft stop triggered, waiting for melody to complete +}; + +/** + * @class Player + * @brief Melody playback and timing control system + * + * The Player class manages all aspects of melody playback including timing, + * duration control, pause/resume functionality, and coordination with the + * BellEngine for precise bell activation. + * + * Key responsibilities: + * - Melody metadata management (name, speed, duration, etc.) + * - Playback state control (play, pause, stop) + * - Duration-based automatic stopping + * - Note assignment mapping for bell activation + * - Integration with BellEngine for precision timing + */ +class Player { +public: + // ═══════════════════════════════════════════════════════════════════════════════ + // CONSTRUCTORS & DEPENDENCY INJECTION + // ═══════════════════════════════════════════════════════════════════════════════ + + /** + * @brief Constructor with dependency injection + * @param comm Pointer to communication manager + * @param fm Pointer to file manager + */ + Player(Communication* comm, FileManager* fm); + + /** + * @brief Default constructor for backward compatibility + * + * When using this constructor, must call setDependencies() before use. + */ + Player(); + + /** + * @brief Set dependencies after construction + * @param comm Pointer to communication manager + * @param fm Pointer to file manager + */ + void setDependencies(Communication* comm, FileManager* fm); + + /** + * @brief Set BellEngine reference for precision timing + * @param engine Pointer to BellEngine instance + */ + void setBellEngine(BellEngine* engine) { _bellEngine = engine; } + + // ═══════════════════════════════════════════════════════════════════════════════ + // MELODY METADATA - Public access for compatibility + // ═══════════════════════════════════════════════════════════════════════════════ + + uint16_t id; // 🏷️ Internal ID of the selected melody + std::string name; // 🏵️ Display name of the melody + std::string uid; // 🆔 Unique identifier from Firestore + std::string url; // 🌐 Download URL for melody binary + uint16_t noteAssignments[16]; // 🎹 Note-to-bell mapping configuration + + // ═══════════════════════════════════════════════════════════════════════════════ + // TIMING CONFIGURATION + // ═══════════════════════════════════════════════════════════════════════════════ + uint16_t speed; // ⏱️ Time per beat in milliseconds + uint32_t segment_duration; // ⏳ Duration per loop segment (milliseconds) + uint32_t pause_duration; // ⏸️ Pause between segments (milliseconds) + uint32_t total_duration; // ⏰ Total runtime limit (milliseconds) + // ═══════════════════════════════════════════════════════════════════════════════ + // RUNTIME STATE TRACKING + // ═══════════════════════════════════════════════════════════════════════════════ + uint64_t segmentCmpltTime; // ✅ Timestamp of last segment completion + uint64_t segmentStartTime; // 🚀 Timestamp when current segment started + uint64_t startTime; // 🏁 Timestamp when melody playback began + uint64_t pauseTime; // ⏸️ Timestamp when melody was paused + bool isPlaying; // ▶️ Currently playing indicator + bool isPaused; // ⏸️ Currently paused indicator + bool hardStop; // 🚑 Emergency stop flag + bool continuous_loop; // 🔄 Continuous loop mode flag + bool infinite_play; // ∞ Infinite playback mode flag + PlayerStatus _status; // 📊 Current player status + + // ═══════════════════════════════════════════════════════════════════════════════ + // DESTRUCTOR + // ═══════════════════════════════════════════════════════════════════════════════ + + /** + * @brief Destructor - Clean up resources + * + * Ensures proper cleanup of timers and resources. + */ + ~Player(); + + // ═══════════════════════════════════════════════════════════════════════════════ + // INITIALIZATION & CONTROL METHODS + // ═══════════════════════════════════════════════════════════════════════════════ + + /** @brief Initialize playback control system */ + void begin(); + + /** @brief Start melody playback */ + void play(); + + /** @brief Stop melody playback gracefully */ + void stop(); + + /** @brief Force immediate stop */ + void forceStop(); + + /** @brief Pause current playback */ + void pause(); + + /** @brief Resume paused playback */ + void unpause(); + + /** @brief Handle JSON commands from communication layer */ + bool command(JsonVariant data); + + /** @brief Set melody attributes from JSON configuration */ + void setMelodyAttributes(JsonVariant doc); + + /** @brief Load melody data into RAM for playback */ + void loadMelodyInRAM(); + + /** @brief Static timer callback for FreeRTOS duration control */ + static void durationTimerCallback(TimerHandle_t xTimer); + + // ═════════════════════════════════════════════════════════════════════════════════ + // STATUS QUERY METHODS + // ═════════════════════════════════════════════════════════════════════════════════ + + /** @brief Get current player status */ + PlayerStatus getStatus() const { return _status; } + + /** @brief Check if player is currently playing */ + bool isCurrentlyPlaying() const { return _status == PlayerStatus::PLAYING; } + + /** @brief Check if player is currently paused */ + bool isCurrentlyPaused() const { return _status == PlayerStatus::PAUSED; } + + /** @brief Check if player is in stopping state (soft stop triggered) */ + bool isCurrentlyStopping() const { return _status == PlayerStatus::STOPPING; } + + /** @brief Check if player is completely stopped */ + bool isCurrentlyStopped() const { return _status == PlayerStatus::STOPPED; } + + /** @brief BellEngine callback when melody loop actually completes */ + void onMelodyLoopCompleted(); + + /** @brief Calculate the projected total run time of current playback */ + uint64_t calculateProjectedRunTime() const; + + // ═══════════════════════════════════════════════════════════════════════════════ + // HEALTH CHECK METHOD + // ═══════════════════════════════════════════════════════════════════════════════ + + /** @brief Check if Player is in healthy state */ + bool isHealthy() const; + +private: + // ═══════════════════════════════════════════════════════════════════════════════ + // PRIVATE DEPENDENCIES AND DATA + // ═══════════════════════════════════════════════════════════════════════════════ + Communication* _commManager; // 📡 Communication system reference + FileManager* _fileManager; // 📁 File operations reference + BellEngine* _bellEngine; // 🔥 High-precision timing engine reference + + std::vector _melodySteps; // 🎵 Melody data owned by Player + TimerHandle_t _durationTimerHandle = NULL; // ⏱️ FreeRTOS timer (saves 4KB vs task!) + + // ═══════════════════════════════════════════════════════════════════════════════ + // PRIVATE HELPER METHODS + // ═══════════════════════════════════════════════════════════════════════════════ + + /** @brief Check if it's time to stop based on duration */ + bool timeToStop(unsigned long now); + + /** @brief Check if it's time to pause based on segment timing */ + bool timeToPause(unsigned long now); + + /** @brief Check if it's time to resume from pause */ + bool timeToResume(unsigned long now); + + /** @brief Update player status and notify clients if changed */ + void setStatus(PlayerStatus newStatus); +}; + +// ═══════════════════════════════════════════════════════════════════════════════════ +// END OF PLAYER.HPP +// ═══════════════════════════════════════════════════════════════════════════════════ diff --git a/vesper/src/Telemetry/Telemetry.cpp b/vesper/src/Telemetry/Telemetry.cpp new file mode 100644 index 0000000..04b582b --- /dev/null +++ b/vesper/src/Telemetry/Telemetry.cpp @@ -0,0 +1,227 @@ +#include "Telemetry.hpp" +#include "../Communication/Communication.hpp" + +void Telemetry::begin() { + // Initialize arrays + for (uint8_t i = 0; i < 16; i++) { + strikeCounters[i] = 0; + bellLoad[i] = 0; + bellMaxLoad[i] = 60; // Default max load + } + + coolingActive = false; + + // Create the telemetry task + xTaskCreatePinnedToCore(telemetryTask, "TelemetryTask", 4096, this, 2, &telemetryTaskHandle, 1); + + LOG_INFO("Telemetry initialized"); +} + +void Telemetry::setPlayerReference(bool* isPlayingPtr) { + playerIsPlayingPtr = isPlayingPtr; + LOG_DEBUG("Player reference set"); +} + +void Telemetry::setForceStopCallback(void (*callback)()) { + forceStopCallback = callback; + LOG_DEBUG("Force stop callback set"); +} + +void Telemetry::recordBellStrike(uint8_t bellIndex) { + if (bellIndex >= 16) { + LOG_ERROR("Invalid bell index: %d", bellIndex); + return; + } + + // Critical section - matches your original code + portENTER_CRITICAL(&telemetrySpinlock); + strikeCounters[bellIndex]++; // Count strikes per bell (warranty) + bellLoad[bellIndex]++; // Load per bell (heat simulation) + coolingActive = true; // System needs cooling + portEXIT_CRITICAL(&telemetrySpinlock); + +} + +uint32_t Telemetry::getStrikeCount(uint8_t bellIndex) { + if (bellIndex >= 16) { + LOG_ERROR("Invalid bell index: %d", bellIndex); + return 0; + } + return strikeCounters[bellIndex]; +} + +void Telemetry::resetStrikeCounters() { + portENTER_CRITICAL(&telemetrySpinlock); + for (uint8_t i = 0; i < 16; i++) { + strikeCounters[i] = 0; + } + portEXIT_CRITICAL(&telemetrySpinlock); + + LOG_WARNING("Strike counters reset by user"); +} + +uint16_t Telemetry::getBellLoad(uint8_t bellIndex) { + if (bellIndex >= 16) { + LOG_ERROR("Invalid bell index: %d", bellIndex); + return 0; + } + return bellLoad[bellIndex]; +} + +void Telemetry::setBellMaxLoad(uint8_t bellIndex, uint16_t maxLoad) { + if (bellIndex >= 16) { + LOG_ERROR("Invalid bell index: %d", bellIndex); + return; + } + + bellMaxLoad[bellIndex] = maxLoad; + LOG_INFO("Bell %d max load set to %d", bellIndex, maxLoad); +} + +bool Telemetry::isOverloaded(uint8_t bellIndex) { + if (bellIndex >= 16) { + LOG_ERROR("Invalid bell index: %d", bellIndex); + return false; + } + return bellLoad[bellIndex] > bellMaxLoad[bellIndex]; +} + +bool Telemetry::isCoolingActive() { + return coolingActive; +} + +void Telemetry::logTemperature(float temperature) { + // Future implementation for temperature logging + LOG_INFO("Temperature: %.2f°C", temperature); +} + +void Telemetry::logVibration(float vibration) { + // Future implementation for vibration logging + LOG_INFO("Vibration: %.2f", vibration); +} + +void Telemetry::checkBellLoads() { + coolingActive = false; // Reset cooling flag + + // Collect overloaded bells for batch notification + std::vector criticalBells; + std::vector criticalLoads; + std::vector warningBells; + std::vector warningLoads; + + bool anyOverload = false; + + for (uint8_t i = 0; i < 16; i++) { + if (bellLoad[i] > 0) { + bellLoad[i]--; + coolingActive = true; // Still has heat left + } + + // Check for critical overload (90% of max load) + uint16_t criticalThreshold = (bellMaxLoad[i] * 90) / 100; + // Check for warning overload (60% of max load) + uint16_t warningThreshold = (bellMaxLoad[i] * 60) / 100; + + // Critical overload - protection kicks in + if (bellLoad[i] > bellMaxLoad[i]) { + LOG_ERROR("Bell %d OVERLOADED! load=%d max=%d", + i, bellLoad[i], bellMaxLoad[i]); + + criticalBells.push_back(i); + criticalLoads.push_back(bellLoad[i]); + anyOverload = true; + + } else if (bellLoad[i] > criticalThreshold) { + // Critical warning - approaching overload + LOG_WARNING("Bell %d approaching overload! load=%d (critical threshold=%d)", + i, bellLoad[i], criticalThreshold); + + criticalBells.push_back(i); + criticalLoads.push_back(bellLoad[i]); + + } else if (bellLoad[i] > warningThreshold) { + // Warning - moderate load + LOG_INFO("Bell %d moderate load warning! load=%d (warning threshold=%d)", + i, bellLoad[i], warningThreshold); + + warningBells.push_back(i); + warningLoads.push_back(bellLoad[i]); + } + } + + // Send batch notifications if any bells are overloaded + if (!criticalBells.empty()) { + String severity = anyOverload ? "critical" : "warning"; + if (Communication::_instance) { + Communication::_instance->sendBellOverloadNotification(criticalBells, criticalLoads, severity); + } + } else if (!warningBells.empty()) { + if (Communication::_instance) { + Communication::_instance->sendBellOverloadNotification(warningBells, warningLoads, "warning"); + } + } + + // Trigger force stop if any bell is actually overloaded + if (anyOverload && forceStopCallback != nullptr) { + forceStopCallback(); + } +} + +void Telemetry::telemetryTask(void* parameter) { + Telemetry* telemetry = static_cast(parameter); + + LOG_INFO("Telemetry task started"); + + while(1) { + // Only run if player is playing OR we're still cooling + bool isPlaying = (telemetry->playerIsPlayingPtr != nullptr) ? + *(telemetry->playerIsPlayingPtr) : false; + + if (isPlaying || telemetry->coolingActive) { + telemetry->checkBellLoads(); + } + + vTaskDelay(pdMS_TO_TICKS(1000)); // Run every 1s + } +} + +// ════════════════════════════════════════════════════════════════════════════ +// HEALTH CHECK IMPLEMENTATION +// ════════════════════════════════════════════════════════════════════════════ + +bool Telemetry::isHealthy() const { + // Check if telemetry task is created and running + if (telemetryTaskHandle == NULL) { + LOG_DEBUG("Telemetry: Unhealthy - Task not created"); + return false; + } + + // Check if task is still alive + eTaskState taskState = eTaskGetState(telemetryTaskHandle); + if (taskState == eDeleted || taskState == eInvalid) { + LOG_DEBUG("Telemetry: Unhealthy - Task deleted or invalid"); + return false; + } + + // Check if player reference is set + if (playerIsPlayingPtr == nullptr) { + LOG_DEBUG("Telemetry: Unhealthy - Player reference not set"); + return false; + } + + // Check for any critical overloads that would indicate system stress + bool hasCriticalOverload = false; + for (uint8_t i = 0; i < 16; i++) { + if (bellLoad[i] > bellMaxLoad[i]) { + hasCriticalOverload = true; + break; + } + } + + if (hasCriticalOverload) { + LOG_DEBUG("Telemetry: Unhealthy - Critical bell overload detected"); + return false; + } + + return true; +} diff --git a/vesper/src/Telemetry/Telemetry.hpp b/vesper/src/Telemetry/Telemetry.hpp new file mode 100644 index 0000000..3c6cec7 --- /dev/null +++ b/vesper/src/Telemetry/Telemetry.hpp @@ -0,0 +1,120 @@ +/* + * ═══════════════════════════════════════════════════════════════════════════════════ + * TELEMETRY.HPP - System Monitoring and Analytics Engine + * ═══════════════════════════════════════════════════════════════════════════════════ + * + * 📊 THE SYSTEM WATCHDOG OF VESPER 📊 + * + * This class provides comprehensive system monitoring, performance tracking, + * and safety management. It continuously monitors bell usage, load conditions, + * and system health to ensure safe and optimal operation. + * + * 🏗️ MONITORING ARCHITECTURE: + * • Real-time bell load tracking and thermal management + * • Strike counting for warranty and maintenance tracking + * • Performance metrics collection and analysis + * • System health monitoring and alerting + * • Thread-safe operation with spinlock protection + * + * 🔔 BELL MONITORING: + * • Individual bell strike counting (warranty tracking) + * • Load accumulation and thermal modeling + * • Overload detection and protection + * • Configurable thresholds per bell + * • Automatic cooling period management + * + * 🔥 THERMAL PROTECTION: + * • Real-time load calculation based on activation duration + * • Thermal decay modeling for cooling + * • Overload prevention with automatic cooling + * • Emergency stop capability for safety + * • System-wide thermal state tracking + * + * 📊 ANALYTICS & REPORTING: + * • Performance metrics collection + * • Usage pattern analysis + * • Health status reporting + * • Predictive maintenance indicators + * • Historical data tracking + * + * ⚙️ SAFETY FEATURES: + * • Automatic system protection from overload + * • Force stop callback for emergency situations + * • Comprehensive bounds checking + * • Fail-safe operation modes + * • Graceful degradation under stress + * + * 📋 VERSION: 2.0 (Enhanced monitoring and safety) + * 📅 DATE: 2025 + * 👨‍💻 AUTHOR: Advanced Bell Systems + * ═══════════════════════════════════════════════════════════════════════════════════ + */ + +#ifndef TELEMETRY_HPP +#define TELEMETRY_HPP + +#include +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "../Logging/Logging.hpp" + +class Telemetry { +private: + // Bell tracking + uint32_t strikeCounters[16] = {0}; // Total strikes per bell (warranty tracking) + uint16_t bellLoad[16] = {0}; // Current heat load per bell + uint16_t bellMaxLoad[16] = {60}; // Max load threshold per bell + bool coolingActive = false; // System-wide cooling flag + + // Task handle + TaskHandle_t telemetryTaskHandle = NULL; + + // External references (to be set via setters) + bool* playerIsPlayingPtr = nullptr; + + // Spinlock for critical sections + portMUX_TYPE telemetrySpinlock = portMUX_INITIALIZER_UNLOCKED; + +public: + // Initialization + void begin(); + + // Set external references + void setPlayerReference(bool* isPlayingPtr); + + // Bell strike handling (call this on every hammer strike) + void recordBellStrike(uint8_t bellIndex); + + // Strike counter management (warranty tracking) + uint32_t getStrikeCount(uint8_t bellIndex); + void resetStrikeCounters(); // User-requested reset + + // Bell load management + uint16_t getBellLoad(uint8_t bellIndex); + void setBellMaxLoad(uint8_t bellIndex, uint16_t maxLoad); + bool isOverloaded(uint8_t bellIndex); + bool isCoolingActive(); // Check if system needs cooling + + // Data collection (future expansion) + void logTemperature(float temperature); + void logVibration(float vibration); + + // Force stop callback (to be set by main application) + void setForceStopCallback(void (*callback)()); + + // ═══════════════════════════════════════════════════════════════════════════════ + // HEALTH CHECK METHOD + // ═══════════════════════════════════════════════════════════════════════════════ + + /** @brief Check if Telemetry is in healthy state */ + bool isHealthy() const; + + // Static task function + static void telemetryTask(void* parameter); + +private: + void (*forceStopCallback)() = nullptr; + void checkBellLoads(); +}; + +#endif diff --git a/vesper/src/TimeKeeper/TimeKeeper.cpp b/vesper/src/TimeKeeper/TimeKeeper.cpp new file mode 100644 index 0000000..2c08061 --- /dev/null +++ b/vesper/src/TimeKeeper/TimeKeeper.cpp @@ -0,0 +1,772 @@ +#include "TimeKeeper.hpp" +#include "../OutputManager/OutputManager.hpp" +#include "../ConfigManager/ConfigManager.hpp" +#include "../Networking/Networking.hpp" +#include "SD.h" +#include + +void Timekeeper::begin() { + LOG_INFO("Timekeeper initialized - clock outputs managed by ConfigManager"); + + // Initialize RTC + if (!rtc.begin()) { + LOG_ERROR("Couldn't find RTC"); + // Continue anyway, but log the error + } else { + LOG_INFO("RTC initialized successfully"); + + // Check if RTC lost power + if (!rtc.isrunning()) { + LOG_WARNING("RTC is NOT running! Setting time..."); + // Set to compile time as fallback + rtc.adjust(DateTime(F(__DATE__), F(__TIME__))); + } + } + + // Load today's events + loadTodaysEvents(); + + // Create SINGLE consolidated task (saves 8KB RAM!) + xTaskCreatePinnedToCore(mainTimekeeperTask, "TimeKeeper", 4096, this, 2, &mainTaskHandle, 1); + + LOG_INFO("TimeKeeper initialized with SIMPLE sync approach (like your Arduino code)"); +} + +void Timekeeper::setOutputManager(OutputManager* outputManager) { + _outputManager = outputManager; + LOG_INFO("Timekeeper connected to OutputManager - CLEAN ARCHITECTURE!"); +} + +void Timekeeper::setConfigManager(ConfigManager* configManager) { + _configManager = configManager; + LOG_INFO("Timekeeper connected to ConfigManager"); +} + +void Timekeeper::setNetworking(Networking* networking) { + _networking = networking; + LOG_INFO("Timekeeper connected to Networking"); +} + +void Timekeeper::setRelayWriteFunction(void (*func)(int, int)) { + relayWriteFunc = func; + LOG_WARNING("Using LEGACY relay function - consider upgrading to OutputManager"); +} + +void Timekeeper::setClockOutputs(int relay1, int relay2) { + LOG_WARNING("⚠️ setClockOutputs() is DEPRECATED! Use ConfigManager.setClockOutput1/2() instead"); + LOG_WARNING("⚠️ Clock outputs should be configured via MQTT/WebSocket commands"); + + // For backward compatibility, still set the config if ConfigManager is available + if (_configManager) { + _configManager->setClockOutput1(relay1); + _configManager->setClockOutput2(relay2); + LOG_INFO("Clock outputs updated via legacy method: C1=%d, C2=%d", relay1, relay2); + } else { + LOG_ERROR("ConfigManager not available - cannot set clock outputs"); + } +} + +void Timekeeper::setTime(unsigned long timestamp) { + if (!rtc.begin()) { + LOG_ERROR("RTC not available - cannot set time"); + return; + } + + // Get timezone configuration + auto& timeConfig = _configManager->getTimeConfig(); + + // Apply timezone offset to UTC timestamp + long totalOffset = timeConfig.gmtOffsetSec + timeConfig.daylightOffsetSec; + unsigned long localTimestamp = timestamp + totalOffset; + + // Convert local timestamp to DateTime object + DateTime newTime(localTimestamp); + + // Set the RTC with local time + rtc.adjust(newTime); + + LOG_INFO("RTC time set to LOCAL: %04d-%02d-%02d %02d:%02d:%02d (UTC timestamp: %lu + %ld offset = %lu)", + newTime.year(), newTime.month(), newTime.day(), + newTime.hour(), newTime.minute(), newTime.second(), + timestamp, totalOffset, localTimestamp); + + // Reload today's events since the date might have changed + loadTodaysEvents(); +} + +void Timekeeper::setTimeWithLocalTimestamp(unsigned long localTimestamp) { + if (!rtc.begin()) { + LOG_ERROR("RTC not available - cannot set time"); + return; + } + + // Convert local timestamp directly to DateTime object (no timezone conversion needed) + DateTime newTime(localTimestamp); + + // Set the RTC with local time + rtc.adjust(newTime); + + LOG_INFO("RTC time set to LOCAL: %04d-%02d-%02d %02d:%02d:%02d (local timestamp: %lu)", + newTime.year(), newTime.month(), newTime.day(), + newTime.hour(), newTime.minute(), newTime.second(), + localTimestamp); + + // Reload today's events since the date might have changed + loadTodaysEvents(); +} + +unsigned long Timekeeper::getTime() { + if (!rtc.isrunning()) { + LOG_ERROR("RTC not running - cannot get time"); + return 0; + } + + DateTime now = rtc.now(); + unsigned long timestamp = now.unixtime(); + + LOG_DEBUG("Current RTC time: %04d-%02d-%02d %02d:%02d:%02d (timestamp: %lu)", + now.year(), now.month(), now.day(), + now.hour(), now.minute(), now.second(), + timestamp); + + return timestamp; +} + +void Timekeeper::syncTimeWithNTP() { + // Check if we have network connection and required dependencies + if (!_networking || !_configManager) { + LOG_ERROR("Cannot sync time: Networking or ConfigManager not set"); + return; + } + + if (!_networking->isConnected()) { + LOG_WARNING("Cannot sync time: No network connection"); + return; + } + + LOG_INFO("Syncing time with NTP server..."); + + // Get config from ConfigManager + auto& timeConfig = _configManager->getTimeConfig(); + + // Configure NTP with settings from config + configTime(timeConfig.gmtOffsetSec, timeConfig.daylightOffsetSec, timeConfig.ntpServer.c_str()); + + // Wait for time sync with timeout + struct tm timeInfo; + int attempts = 0; + while (!getLocalTime(&timeInfo) && attempts < 10) { + LOG_DEBUG("Waiting for NTP sync... attempt %d", attempts + 1); + delay(1000); + attempts++; + } + + if (attempts >= 10) { + LOG_ERROR("Failed to obtain time from NTP server after 10 attempts"); + return; + } + + // Update RTC with synchronized time + rtc.adjust(DateTime(timeInfo.tm_year + 1900, timeInfo.tm_mon + 1, timeInfo.tm_mday, + timeInfo.tm_hour, timeInfo.tm_min, timeInfo.tm_sec)); + + LOG_INFO("Time synced successfully: %04d-%02d-%02d %02d:%02d:%02d", + timeInfo.tm_year + 1900, timeInfo.tm_mon + 1, timeInfo.tm_mday, + timeInfo.tm_hour, timeInfo.tm_min, timeInfo.tm_sec); + + // Reload today's events since the time might have changed significantly + loadTodaysEvents(); +} + +// ════════════════════════════════════════════════════════════════════════════ +// CONSOLIDATED TimeKeeper Task - SIMPLE approach like your Arduino code +// ════════════════════════════════════════════════════════════════════════════ + +void Timekeeper::mainTimekeeperTask(void* parameter) { + Timekeeper* keeper = static_cast(parameter); + LOG_INFO("🕒 SIMPLE TimeKeeper task started - based on your Arduino code approach"); + + unsigned long lastRtcCheck = 0; + unsigned long lastScheduleCheck = 0; + + while (true) { + unsigned long now = millis(); + + // 🕐 SIMPLE PHYSICAL CLOCK SYNC (check every loop, fire if needed - your Arduino approach) + keeper->checkAndSyncPhysicalClock(); + + // 🔔 ALERT MANAGEMENT (every second for precise timing) + keeper->checkClockAlerts(); + + // 💡 BACKLIGHT AUTOMATION (every 10 seconds) + if (now - lastRtcCheck >= 10000) { + keeper->checkBacklightAutomation(); + } + + // 📅 SCHEDULE CHECK (every second) + if (now - lastScheduleCheck >= 1000) { + keeper->checkScheduledEvents(); + lastScheduleCheck = now; + } + + // 🔧 RTC MAINTENANCE (every 10 seconds) + if (now - lastRtcCheck >= 10000) { + // RTC health check + DateTime rtcNow = keeper->rtc.now(); + if (keeper->rtc.isrunning()) { + // Check for midnight - reload events for new day + if (rtcNow.hour() == 0 && rtcNow.minute() == 0 && rtcNow.second() < 10) { + LOG_INFO("🌙 Midnight detected - reloading events"); + keeper->loadTodaysEvents(); + keeper->loadNextDayEvents(); + } + + // Hourly maintenance + if (rtcNow.minute() == 0 && rtcNow.second() < 10) { + LOG_DEBUG("🕐 Hourly check at %02d:00", rtcNow.hour()); + } + } else { + static uint8_t rtcWarningCounter = 0; + if (rtcWarningCounter++ % 6 == 0) { // Log every minute + LOG_WARNING("⚠️ RTC not running!"); + } + } + lastRtcCheck = now; + } + + vTaskDelay(pdMS_TO_TICKS(1000)); + } +} + +// ════════════════════════════════════════════════════════════════════════════ +// SIMPLE CLOCK SYNC IMPLEMENTATION - Based on your Arduino code +// ════════════════════════════════════════════════════════════════════════════ + +void Timekeeper::checkAndSyncPhysicalClock() { + // Check if clock is enabled in config + if (!_configManager || !_configManager->getClockEnabled()) { + return; // Clock is disabled - skip all clock functionality + } + + // Check if there is any Time Difference between the Physical Clock and the Actual Time and if yes, run the Motor + + if (!_outputManager || !rtc.isrunning() || clockUpdatesPaused) { + return; + } + + // Get current real time (your updateCurrentTime() equivalent) + DateTime now = rtc.now(); + int8_t realHour = now.hour() % 12; // Convert to 12-hour format for clock face + if (realHour == 0) realHour = 12; // 12 AM/PM shows as 12, not 0 + int8_t realMinute = now.minute(); + + // Get physical clock state (your clock.hour/clock.minute equivalent) + int8_t physicalHour = _configManager->getPhysicalClockHour(); + int8_t physicalMinute = _configManager->getPhysicalClockMinute(); + + // Calculate time difference (your exact logic!) + int16_t timeDifference = (realHour * 60 + realMinute) - (physicalHour * 60 + physicalMinute); + + // Handle 12-hour rollover (if negative, add 12 hours) + if (timeDifference < 0) { + timeDifference += 12 * 60; // Add 12 hours to handle rollover + } + + // If there's a difference, advance the clock by one minute (your runMotor equivalent) + if (timeDifference >= 1) { + advancePhysicalClockOneMinute(); + LOG_DEBUG("⏰ SYNC: Advanced physical clock by 1 minute to %02d:%02d (real: %02d:%02d, diff: %lu mins)", + _configManager->getPhysicalClockHour(), _configManager->getPhysicalClockMinute(), + realHour, realMinute, timeDifference); + } +} + +void Timekeeper::advancePhysicalClockOneMinute() { + + const auto& clockConfig = _configManager->getClockConfig(); + if (clockConfig.c1output == 255 || clockConfig.c2output == 255) { + return; + } + + bool useC1 = _configManager->getNextOutputIsC1(); + uint8_t outputToFire = useC1 ? (clockConfig.c1output - 1) : (clockConfig.c2output - 1); + + LOG_DEBUG("🔥 ADVANCE: Firing %s (output %d) for %dms", + useC1 ? "C1" : "C2", outputToFire + 1, clockConfig.pulseDuration); + + _outputManager->fireOutputForDuration(outputToFire, clockConfig.pulseDuration); + vTaskDelay(pdMS_TO_TICKS(clockConfig.pulseDuration + clockConfig.pauseDuration)); // cool-off motor + _configManager->setNextOutputIsC1(!useC1); + updatePhysicalClockTime(); +} + +void Timekeeper::updatePhysicalClockTime() { + + uint8_t currentHour = _configManager->getPhysicalClockHour(); + uint8_t currentMinute = _configManager->getPhysicalClockMinute(); + + currentMinute++; + if (currentMinute >= 60) { + currentMinute = 0; + currentHour++; + if (currentHour > 12) { // 12-hour clock (your code used 24, but clock face is 12) + currentHour = 0; + } + } + + _configManager->setPhysicalClockHour(currentHour); + _configManager->setPhysicalClockMinute(currentMinute); + _configManager->setLastSyncTime(millis() / 1000); + _configManager->saveClockState(); + + LOG_DEBUG("📅 STATE: Physical clock advanced to %d:%02d", currentHour, currentMinute); +} + + +// ════════════════════════════════════════════════════════════════════════════ +// EVENT MANAGEMENT +// ════════════════════════════════════════════════════════════════════════════ + +void Timekeeper::loadTodaysEvents() { + // Clear existing events + todaysEvents.clear(); + + // Get current date/time from RTC + DateTime now = rtc.now(); + if (!rtc.isrunning()) { + LOG_ERROR("RTC not running - cannot load events"); + return; + } + + int currentYear = now.year(); + int currentMonth = now.month(); + int currentDay = now.day(); + int currentDayOfWeek = now.dayOfTheWeek(); // 0=Sunday, 1=Monday, etc. + + LOG_INFO("Loading events for: %04d-%02d-%02d (day %d)", + currentYear, currentMonth, currentDay, currentDayOfWeek); + + // Open and parse events file + File file = SD.open("/events/events.json"); + if (!file) { + LOG_ERROR("Failed to open events.json"); + return; + } + + // Use static allocation instead of dynamic to avoid heap fragmentation + static StaticJsonDocument<8192> doc; + doc.clear(); + + DeserializationError error = deserializeJson(doc, file); + file.close(); + + if (error) { + LOG_ERROR("JSON parsing failed: %s", error.c_str()); + return; + } + + JsonArray events = doc["events"]; + int eventsLoaded = 0; + + for (JsonObject event : events) { + if (!event["enabled"].as()) { + continue; // Skip disabled events + } + + String type = event["type"].as(); + bool shouldAdd = false; + + if (type == "single") { + // Check if event date matches today + String eventDateTime = event["datetime"].as(); + if (isSameDate(eventDateTime, currentYear, currentMonth, currentDay)) { + shouldAdd = true; + } + } + else if (type == "weekly") { + // Check if today's day of week is in the event's days + JsonArray daysOfWeek = event["days_of_week"]; + for (JsonVariant dayVar : daysOfWeek) { + int day = dayVar.as(); + if (day == currentDayOfWeek) { + shouldAdd = true; + break; + } + } + } + else if (type == "monthly") { + // Check if today's date is in the event's days of month + JsonArray daysOfMonth = event["days_of_month"]; + for (JsonVariant dayVar : daysOfMonth) { + int day = dayVar.as(); + if (day == currentDay) { + shouldAdd = true; + break; + } + } + } + + if (shouldAdd) { + addToTodaysSchedule(event); + eventsLoaded++; + } + } + + // Sort events by time + sortEventsByTime(); + + LOG_INFO("Loaded %d events for today", eventsLoaded); +} + +bool Timekeeper::isSameDate(String eventDateTime, int year, int month, int day) { + // Parse "2025-12-25T09:00:00" format + if (eventDateTime.length() < 10) return false; + + int eventYear = eventDateTime.substring(0, 4).toInt(); + int eventMonth = eventDateTime.substring(5, 7).toInt(); + int eventDay = eventDateTime.substring(8, 10).toInt(); + + return (eventYear == year && eventMonth == month && eventDay == day); +} + +void Timekeeper::addToTodaysSchedule(JsonObject event) { + ScheduledEvent schedEvent; + + // Extract time based on event type + if (event["type"] == "single") { + // Extract time from datetime: "2025-12-25T09:00:00" -> "09:00:00" + String datetime = event["datetime"].as(); + if (datetime.length() >= 19) { + schedEvent.timeStr = datetime.substring(11, 19); + } + } else { + // Weekly/Monthly events have separate time field + schedEvent.timeStr = event["time"].as(); + } + + // Store the entire event data (create a copy) + schedEvent.eventData = event; + + todaysEvents.push_back(schedEvent); + + LOG_DEBUG("Added event '%s' at %s", + event["name"].as().c_str(), + schedEvent.timeStr.c_str()); +} + +void Timekeeper::sortEventsByTime() { + std::sort(todaysEvents.begin(), todaysEvents.end(), + [](const ScheduledEvent& a, const ScheduledEvent& b) { + return a.timeStr < b.timeStr; + }); +} + +String Timekeeper::getCurrentTimeString() { + DateTime now = rtc.now(); + if (!rtc.isrunning()) { + return "00:00:00"; + } + + char timeStr[9]; + sprintf(timeStr, "%02d:%02d:%02d", now.hour(), now.minute(), now.second()); + return String(timeStr); +} + +void Timekeeper::checkScheduledEvents() { + String currentTime = getCurrentTimeString(); + + // Only check the seconds part for exact matching + String currentTimeMinute = currentTime.substring(0, 5); // "HH:MM" + + for (auto& event : todaysEvents) { + String eventTimeMinute = event.timeStr.substring(0, 5); // "HH:MM" + + if (eventTimeMinute == currentTimeMinute) { + // Check if we haven't already triggered this event + if (!event.triggered) { + triggerEvent(event); + event.triggered = true; + } + } + + // Reset trigger flag when we're past the minute + if (eventTimeMinute < currentTimeMinute) { + event.triggered = false; + } + } +} + +void Timekeeper::triggerEvent(ScheduledEvent& event) { + JsonObject eventData = event.eventData; + + LOG_INFO("TRIGGERING EVENT: %s at %s", + eventData["name"].as().c_str(), + event.timeStr.c_str()); + + // Here you would trigger your melody playback + // You might want to call a function from your main program + // or send a message to another task + + // Example of what you might do: + JsonObject melody = eventData["melody"]; + String melodyUID = melody["uid"].as(); + String melodyName = melody["name"].as(); + + LOG_INFO("Playing melody: %s (UID: %s)", + melodyName.c_str(), melodyUID.c_str()); + + // TODO: Add your melody trigger code here + // playMelody(melody); +} + +void Timekeeper::loadNextDayEvents() { + // This function would load tomorrow's events for smooth midnight transition + // Implementation similar to loadTodaysEvents() but for tomorrow's date + LOG_DEBUG("Pre-loading tomorrow's events..."); + // TODO: Implement if needed for smoother transitions +} + +// ════════════════════════════════════════════════════════════════════════════ +// CLOCK ALERTS IMPLEMENTATION +// ════════════════════════════════════════════════════════════════════════════ + +void Timekeeper::checkClockAlerts() { + // Check if clock is enabled in config + if (!_configManager || !_configManager->getClockEnabled()) { + return; // Clock is disabled - skip all alert functionality + } + + // Check if we have required dependencies + if (!_outputManager || !rtc.isrunning()) { + return; + } + + // Get current time + DateTime now = rtc.now(); + int currentHour = now.hour(); + int currentMinute = now.minute(); + int currentSecond = now.second(); + + // Only trigger alerts on exact seconds (0-2) to avoid multiple triggers + if (currentSecond > 2) { + return; + } + + // Get clock configuration + const auto& clockConfig = _configManager->getClockConfig(); + + // Check if alerts are disabled + if (clockConfig.alertType == "OFF") { + return; + } + + // Check if we're in a silence period + if (isInSilencePeriod()) { + return; + } + + // 🕐 HOURLY ALERTS (at xx:00) + if (currentMinute == 0 && currentHour != lastHour) { + triggerHourlyAlert(currentHour); + lastHour = currentHour; + } + + // 🕕 HALF-HOUR ALERTS (at xx:30) + if (currentMinute == 30 && lastMinute != 30) { + if (clockConfig.halfBell != 255) { // 255 = disabled + LOG_INFO("🕕 Half-hour alert at %02d:30", currentHour); + fireAlertBell(clockConfig.halfBell, 1); + } + lastMinute = 30; + } + + // 🕒 QUARTER-HOUR ALERTS (at xx:15 and xx:45) + if ((currentMinute == 15 || currentMinute == 45) && lastMinute != currentMinute) { + if (clockConfig.quarterBell != 255) { // 255 = disabled + LOG_INFO("🕒 Quarter-hour alert at %02d:%02d", currentHour, currentMinute); + fireAlertBell(clockConfig.quarterBell, 1); + } + lastMinute = currentMinute; + } + + // Reset minute tracking for other minutes + if (currentMinute != 0 && currentMinute != 15 && currentMinute != 30 && currentMinute != 45) { + if (lastMinute != currentMinute) { + lastMinute = currentMinute; + } + } +} + +void Timekeeper::triggerHourlyAlert(int hour) { + const auto& clockConfig = _configManager->getClockConfig(); + + // Check if hourly bell is configured + if (clockConfig.hourBell == 255) { // 255 = disabled + return; + } + + if (clockConfig.alertType == "SINGLE") { + // Single ding for any hour + LOG_INFO("🕐 Hourly alert (SINGLE) at %02d:00", hour); + fireAlertBell(clockConfig.hourBell, 1); + } + else if (clockConfig.alertType == "HOURS") { + // Ring the number of times equal to the hour (1-12) + int bellCount = hour; + if (bellCount == 0) bellCount = 12; // Midnight = 12 bells + if (bellCount > 12) bellCount = bellCount - 12; // 24h to 12h conversion + + LOG_INFO("🕐 Hourly alert (HOURS) at %02d:00 - %d rings", hour, bellCount); + fireAlertBell(clockConfig.hourBell, bellCount); + } +} + +void Timekeeper::fireAlertBell(uint8_t bellNumber, int count) { + if (!_outputManager || bellNumber == 255) { + return; + } + + const auto& clockConfig = _configManager->getClockConfig(); + + for (int i = 0; i < count; i++) { + // Get bell duration from bell configuration + uint16_t bellDuration = _configManager->getBellDuration(bellNumber); + + LOG_DEBUG("🔔 Alert bell #%d ring %d/%d (duration: %dms)", + bellNumber + 1, i + 1, count, bellDuration); + + // Fire the bell using OutputManager + _outputManager->fireOutputForDuration(bellNumber, bellDuration); + + // Wait between rings (only if there's more than one ring) + if (i < count - 1) { + vTaskDelay(pdMS_TO_TICKS(clockConfig.alertRingInterval)); + } + } +} + +void Timekeeper::checkBacklightAutomation() { + // Check if clock is enabled in config + if (!_configManager || !_configManager->getClockEnabled()) { + return; // Clock is disabled - skip all backlight functionality + } + + // Check if we have required dependencies + if (!_outputManager || !rtc.isrunning()) { + return; + } + + const auto& clockConfig = _configManager->getClockConfig(); + + // Check if backlight automation is enabled + if (!clockConfig.backlight || clockConfig.backlightOutput == 255) { + return; // Backlight automation disabled + } + + // Get current time + DateTime now = rtc.now(); + char currentTimeStr[6]; + sprintf(currentTimeStr, "%02d:%02d", now.hour(), now.minute()); + String currentTime = String(currentTimeStr); + + // Check if it's time to turn backlight ON + if (currentTime == clockConfig.backlightOnTime && !backlightState) { + LOG_INFO("💡 Turning backlight ON at %s (output #%d)", + currentTime.c_str(), clockConfig.backlightOutput + 1); + _outputManager->fireOutput(clockConfig.backlightOutput); + backlightState = true; + } + // Check if it's time to turn backlight OFF + else if (currentTime == clockConfig.backlightOffTime && backlightState) { + LOG_INFO("💡 Turning backlight OFF at %s (output #%d)", + currentTime.c_str(), clockConfig.backlightOutput + 1); + _outputManager->extinguishOutput(clockConfig.backlightOutput); + backlightState = false; + } +} + +bool Timekeeper::isInSilencePeriod() { + if (!_configManager || !rtc.isrunning()) { + return false; + } + + const auto& clockConfig = _configManager->getClockConfig(); + + // Get current time + DateTime now = rtc.now(); + char currentTimeStr[6]; + sprintf(currentTimeStr, "%02d:%02d", now.hour(), now.minute()); + String currentTime = String(currentTimeStr); + + // Check daytime silence period + if (clockConfig.daytimeSilenceEnabled) { + if (isTimeInRange(currentTime, clockConfig.daytimeSilenceOnTime, clockConfig.daytimeSilenceOffTime)) { + return true; + } + } + + // Check nighttime silence period + if (clockConfig.nighttimeSilenceEnabled) { + if (isTimeInRange(currentTime, clockConfig.nighttimeSilenceOnTime, clockConfig.nighttimeSilenceOffTime)) { + return true; + } + } + + return false; +} + +bool Timekeeper::isTimeInRange(const String& currentTime, const String& startTime, const String& endTime) const { + // Handle the case where the time range crosses midnight (e.g., 22:00 to 07:00) + if (startTime > endTime) { + // Range crosses midnight - current time is in range if it's after start OR before end + return (currentTime >= startTime || currentTime <= endTime); + } else { + // Normal range - current time is in range if it's between start and end + return (currentTime >= startTime && currentTime <= endTime); + } +} + +// ════════════════════════════════════════════════════════════════════════════ +// HEALTH CHECK IMPLEMENTATION +// ════════════════════════════════════════════════════════════════════════════ + +bool Timekeeper::isHealthy() { + // Check if RTC is running + if (!rtc.isrunning()) { + LOG_DEBUG("TimeKeeper: Unhealthy - RTC not running"); + return false; + } + + // Check if main task is created and running + if (mainTaskHandle == NULL) { + LOG_DEBUG("TimeKeeper: Unhealthy - Main task not created"); + return false; + } + + // Check if task is still alive + eTaskState taskState = eTaskGetState(mainTaskHandle); + if (taskState == eDeleted || taskState == eInvalid) { + LOG_DEBUG("TimeKeeper: Unhealthy - Main task deleted or invalid"); + return false; + } + + // Check if required dependencies are set + if (!_configManager) { + LOG_DEBUG("TimeKeeper: Unhealthy - ConfigManager not set"); + return false; + } + + if (!_outputManager) { + LOG_DEBUG("TimeKeeper: Unhealthy - OutputManager not set"); + return false; + } + + // Check if time is reasonable (not stuck at epoch or way in the future) + DateTime now = rtc.now(); + if (now.year() < 2020 || now.year() > 2100) { + LOG_DEBUG("TimeKeeper: Unhealthy - RTC time unreasonable: %d", now.year()); + return false; + } + + return true; +} \ No newline at end of file diff --git a/vesper/src/TimeKeeper/TimeKeeper.hpp b/vesper/src/TimeKeeper/TimeKeeper.hpp new file mode 100644 index 0000000..d1fb376 --- /dev/null +++ b/vesper/src/TimeKeeper/TimeKeeper.hpp @@ -0,0 +1,152 @@ +/* + * ═══════════════════════════════════════════════════════════════════════════════════ + * TIMEKEEPER.HPP - NTP Synchronization and Clock Management + * ═══════════════════════════════════════════════════════════════════════════════════ + * + * ⏰ THE TIME MASTER OF VESPER ⏰ + * + * This class manages all time-related functionality including NTP synchronization, + * timezone handling, and hardware clock signal generation. It ensures accurate + * timekeeping across the entire system. + * + * 🏗️ TIME MANAGEMENT: + * • NTP synchronization with automatic retry + * • Timezone and daylight saving time support + * • Hardware clock signal generation + * • Network-dependent time sync + * • Clean dependency injection architecture + * + * 📋 VERSION: 2.0 (Modular time management) + * 📅 DATE: 2025 + * 👨‍💻 AUTHOR: Advanced Bell Systems + * ═══════════════════════════════════════════════════════════════════════════════════ + */ + +#ifndef TIMEKEEPER_HPP +#define TIMEKEEPER_HPP + +#include +#include +#include +#include +#include +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "../Logging/Logging.hpp" + +// Forward declarations +class OutputManager; +class ConfigManager; +class Networking; + +// Structure to hold scheduled events +struct ScheduledEvent { + String timeStr; // Time in "HH:MM:SS" format + JsonObject eventData; // Complete event data from JSON + bool triggered = false; // Flag to prevent multiple triggers +}; + +class Timekeeper { +private: + // RTC object + RTC_DS1307 rtc; + + // Event storage + std::vector todaysEvents; + std::vector tomorrowsEvents; + + // Clocktower Updating Pause + bool clockUpdatesPaused = false; + + // Alert management - new functionality + int lastHour = -1; // Track last processed hour to avoid duplicate alerts + int lastMinute = -1; // Track last processed minute for quarter/half alerts + + // Backlight management - new functionality + bool backlightState = false; // Track current backlight state + + // Clean dependencies + OutputManager* _outputManager = nullptr; + ConfigManager* _configManager = nullptr; + Networking* _networking = nullptr; + + // Legacy function pointer (DEPRECATED - will be removed) + void (*relayWriteFunc)(int relay, int state) = nullptr; + + // Task handles - CONSOLIDATED! + TaskHandle_t mainTaskHandle = NULL; // Single task handles everything + +public: + // Main initialization (no clock outputs initially) + void begin(); + + // Modern clean interface + void setOutputManager(OutputManager* outputManager); + void setConfigManager(ConfigManager* configManager); + void setNetworking(Networking* networking); + + // Clock Updates Pause Functions + void pauseClockUpdates() { clockUpdatesPaused = true; } + void resumeClockUpdates() { clockUpdatesPaused = false; } + bool areClockUpdatesPaused() const { return clockUpdatesPaused; } + + // Legacy interface (DEPRECATED - will be removed) + void setRelayWriteFunction(void (*func)(int, int)); + + // DEPRECATED: Clock outputs now configured via ConfigManager + void setClockOutputs(int relay1, int relay2) __attribute__((deprecated("Use ConfigManager to set clock outputs"))); + + // Time management functions + void setTime(unsigned long timestamp); + + /** + * @brief Set RTC time with local timestamp (timezone already applied) + * @param localTimestamp Unix timestamp with timezone offset already applied + */ + void setTimeWithLocalTimestamp(unsigned long localTimestamp); // Set RTC time from Unix timestamp + unsigned long getTime(); // Get current time as Unix timestamp + void syncTimeWithNTP(); // Sync RTC time with NTP server + + // Event management + void loadTodaysEvents(); + void loadNextDayEvents(); + + // Static task functions (CONSOLIDATED) + static void mainTimekeeperTask(void* parameter); + + bool isHealthy(); + +private: + // Helper functions + bool isSameDate(String eventDateTime, int year, int month, int day); + void addToTodaysSchedule(JsonObject event); + void sortEventsByTime(); + String getCurrentTimeString(); + + // Core functionality + void checkScheduledEvents(); + void triggerEvent(ScheduledEvent& event); + + // New clock features - comprehensive alert and automation system + void checkClockAlerts(); + void triggerHourlyAlert(int hour); + void checkBacklightAutomation(); + bool isInSilencePeriod(); + bool isTimeInRange(const String& currentTime, const String& startTime, const String& endTime) const; + void fireAlertBell(uint8_t bellNumber, int count = 1); + + // Physical clock synchronization - SIMPLE approach based on your Arduino code + void checkAndSyncPhysicalClock(); + void advancePhysicalClockOneMinute(); + void updatePhysicalClockTime(); + + // ═══════════════════════════════════════════════════════════════════════════════ + // HEALTH CHECK METHOD + // ═══════════════════════════════════════════════════════════════════════════════ + +public: + /** @brief Check if TimeKeeper is in healthy state */ + bool isHealthy() const; +}; + +#endif diff --git a/vesper/temp.hpp b/vesper/temp.hpp deleted file mode 100644 index 8b13789..0000000 --- a/vesper/temp.hpp +++ /dev/null @@ -1 +0,0 @@ - diff --git a/vesper/timekeeper.cpp b/vesper/timekeeper.cpp deleted file mode 100644 index d4a80d5..0000000 --- a/vesper/timekeeper.cpp +++ /dev/null @@ -1,392 +0,0 @@ -#include "timekeeper.hpp" -#include "SD.h" - -void Timekeeper::begin() { - // Clock outputs start as unassigned (-1) - clockRelay1 = -1; - clockRelay2 = -1; - clockEnabled = false; - - Serial.println("Timekeeper initialized - clock outputs not assigned"); - - // Initialize RTC - if (!rtc.begin()) { - Serial.println("Couldn't find RTC"); - // Continue anyway, but log the error - } else { - Serial.println("RTC initialized successfully"); - - // Check if RTC lost power - if (!rtc.isrunning()) { - Serial.println("RTC is NOT running! Setting time..."); - // Set to compile time as fallback - rtc.adjust(DateTime(F(__DATE__), F(__TIME__))); - } - } - - // Load today's events - loadTodaysEvents(); - - // Create tasks with appropriate priorities - // Priority 3 = Highest (clock), 2 = Medium (scheduler), 1 = Lowest (rtc) - xTaskCreate(clockTask, "ClockTask", 2048, this, 3, &clockTaskHandle); - xTaskCreate(schedulerTask, "SchedulerTask", 4096, this, 2, &schedulerTaskHandle); - xTaskCreate(rtcTask, "RTCTask", 4096, this, 1, &rtcTaskHandle); - - Serial.println("Timekeeper initialized with tasks"); -} - -void Timekeeper::setRelayWriteFunction(void (*func)(int, int)) { - relayWriteFunc = func; -} - -void Timekeeper::setClockOutputs(int relay1, int relay2) { - // Validate relay numbers (assuming 0-5 are valid) - if (relay1 < 0 || relay1 > 5 || relay2 < 0 || relay2 > 5) { - Serial.println("Invalid relay numbers! Must be 0-5"); - return; - } - - if (relay1 == relay2) { - Serial.println("Clock relays cannot be the same!"); - return; - } - - clockRelay1 = relay1; - clockRelay2 = relay2; - clockEnabled = true; // Enable clock functionality - - Serial.printf("Clock outputs set to: Relay %d and Relay %d - Clock ENABLED\n", relay1, relay2); -} - -void Timekeeper::setTime(unsigned long timestamp) { - if (!rtc.begin()) { - Serial.println("RTC not available - cannot set time"); - return; - } - - // Convert Unix timestamp to DateTime object - DateTime newTime(timestamp); - - // Set the RTC - rtc.adjust(newTime); - - Serial.printf("RTC time set to: %04d-%02d-%02d %02d:%02d:%02d (timestamp: %lu)\n", - newTime.year(), newTime.month(), newTime.day(), - newTime.hour(), newTime.minute(), newTime.second(), - timestamp); - - // Reload today's events since the date might have changed - loadTodaysEvents(); -} - -unsigned long Timekeeper::getTime() { - if (!rtc.isrunning()) { - Serial.println("RTC not running - cannot get time"); - return 0; - } - - DateTime now = rtc.now(); - unsigned long timestamp = now.unixtime(); - - Serial.printf("Current RTC time: %04d-%02d-%02d %02d:%02d:%02d (timestamp: %lu)\n", - now.year(), now.month(), now.day(), - now.hour(), now.minute(), now.second(), - timestamp); - - return timestamp; -} - -void Timekeeper::loadTodaysEvents() { - // Clear existing events - todaysEvents.clear(); - - // Get current date/time from RTC - DateTime now = rtc.now(); - if (!rtc.isrunning()) { - Serial.println("RTC not running - cannot load events"); - return; - } - - int currentYear = now.year(); - int currentMonth = now.month(); - int currentDay = now.day(); - int currentDayOfWeek = now.dayOfTheWeek(); // 0=Sunday, 1=Monday, etc. - - Serial.printf("Loading events for: %04d-%02d-%02d (day %d)\n", - currentYear, currentMonth, currentDay, currentDayOfWeek); - - // Open and parse events file - File file = SD.open("/events/events.json"); - if (!file) { - Serial.println("Failed to open events.json"); - return; - } - - DynamicJsonDocument doc(8192); - DeserializationError error = deserializeJson(doc, file); - file.close(); - - if (error) { - Serial.print("JSON parsing failed: "); - Serial.println(error.c_str()); - return; - } - - JsonArray events = doc["events"]; - int eventsLoaded = 0; - - for (JsonObject event : events) { - if (!event["enabled"].as()) { - continue; // Skip disabled events - } - - String type = event["type"].as(); - bool shouldAdd = false; - - if (type == "single") { - // Check if event date matches today - String eventDateTime = event["datetime"].as(); - if (isSameDate(eventDateTime, currentYear, currentMonth, currentDay)) { - shouldAdd = true; - } - } - else if (type == "weekly") { - // Check if today's day of week is in the event's days - JsonArray daysOfWeek = event["days_of_week"]; - for (JsonVariant dayVar : daysOfWeek) { - int day = dayVar.as(); - if (day == currentDayOfWeek) { - shouldAdd = true; - break; - } - } - } - else if (type == "monthly") { - // Check if today's date is in the event's days of month - JsonArray daysOfMonth = event["days_of_month"]; - for (JsonVariant dayVar : daysOfMonth) { - int day = dayVar.as(); - if (day == currentDay) { - shouldAdd = true; - break; - } - } - } - - if (shouldAdd) { - addToTodaysSchedule(event); - eventsLoaded++; - } - } - - // Sort events by time - sortEventsByTime(); - - Serial.printf("Loaded %d events for today\n", eventsLoaded); -} - -bool Timekeeper::isSameDate(String eventDateTime, int year, int month, int day) { - // Parse "2025-12-25T09:00:00" format - if (eventDateTime.length() < 10) return false; - - int eventYear = eventDateTime.substring(0, 4).toInt(); - int eventMonth = eventDateTime.substring(5, 7).toInt(); - int eventDay = eventDateTime.substring(8, 10).toInt(); - - return (eventYear == year && eventMonth == month && eventDay == day); -} - -void Timekeeper::addToTodaysSchedule(JsonObject event) { - ScheduledEvent schedEvent; - - // Extract time based on event type - if (event["type"] == "single") { - // Extract time from datetime: "2025-12-25T09:00:00" -> "09:00:00" - String datetime = event["datetime"].as(); - if (datetime.length() >= 19) { - schedEvent.timeStr = datetime.substring(11, 19); - } - } else { - // Weekly/Monthly events have separate time field - schedEvent.timeStr = event["time"].as(); - } - - // Store the entire event data (create a copy) - schedEvent.eventData = event; - - todaysEvents.push_back(schedEvent); - - Serial.printf("Added event '%s' at %s\n", - event["name"].as().c_str(), - schedEvent.timeStr.c_str()); -} - -void Timekeeper::sortEventsByTime() { - std::sort(todaysEvents.begin(), todaysEvents.end(), - [](const ScheduledEvent& a, const ScheduledEvent& b) { - return a.timeStr < b.timeStr; - }); -} - -String Timekeeper::getCurrentTimeString() { - DateTime now = rtc.now(); - if (!rtc.isrunning()) { - return "00:00:00"; - } - - char timeStr[9]; - sprintf(timeStr, "%02d:%02d:%02d", now.hour(), now.minute(), now.second()); - return String(timeStr); -} - -// Static task functions -void Timekeeper::clockTask(void* parameter) { - Timekeeper* keeper = static_cast(parameter); - - Serial.println("Clock task started"); - - while(1) { - unsigned long now = millis(); - - // Check if a minute has passed (60000ms = 60 seconds) - if (now - keeper->lastClockPulse >= 60000) { - keeper->updateClock(); - keeper->lastClockPulse = now; - } - - // Check every 100ms for precision - vTaskDelay(100 / portTICK_PERIOD_MS); - } -} - -void Timekeeper::schedulerTask(void* parameter) { - Timekeeper* keeper = static_cast(parameter); - - Serial.println("Scheduler task started"); - - while(1) { - keeper->checkScheduledEvents(); - - // Check every second - vTaskDelay(1000 / portTICK_PERIOD_MS); - } -} - -void Timekeeper::rtcTask(void* parameter) { - Timekeeper* keeper = static_cast(parameter); - - Serial.println("RTC task started"); - - while(1) { - DateTime now = keeper->rtc.now(); - if (keeper->rtc.isrunning()) { - - // Check if it's midnight - reload events for new day - if (now.hour() == 0 && now.minute() == 0 && now.second() < 10) { - Serial.println("Midnight detected - reloading events"); - keeper->loadTodaysEvents(); - keeper->loadNextDayEvents(); - } - - // Check hourly for maintenance tasks - if (now.minute() == 0 && now.second() < 10) { - Serial.printf("Hourly check at %02d:00\n", now.hour()); - // Add any hourly maintenance here: - // - Log status - // - Check for schedule updates - } - } else { - Serial.println("Warning: RTC not running!"); - } - - // Check every 10 seconds - vTaskDelay(10000 / portTICK_PERIOD_MS); - } -} - -void Timekeeper::updateClock() { - // Check if clock is enabled and outputs are assigned - if (!clockEnabled || clockRelay1 == -1 || clockRelay2 == -1) { - return; // Silently skip if clock not configured - } - - DateTime now = rtc.now(); - if (!rtc.isrunning()) { - Serial.println("RTC not running - cannot update clock"); - return; - } - - if (relayWriteFunc == nullptr) { - Serial.println("Relay write function not set - cannot update clock"); - return; - } - - // Alternate between the two relays - int activeRelay = currentClockRelay ? clockRelay2 : clockRelay1; - - Serial.printf("Clock pulse: Relay %d at %02d:%02d:%02d\n", - activeRelay, now.hour(), now.minute(), now.second()); - - // Pulse for 5 seconds - relayWriteFunc(activeRelay, LOW); // Assuming LOW activates relay - vTaskDelay(5000 / portTICK_PERIOD_MS); - relayWriteFunc(activeRelay, HIGH); // HIGH deactivates relay - - // Switch to other relay for next minute - currentClockRelay = !currentClockRelay; -} - -void Timekeeper::checkScheduledEvents() { - String currentTime = getCurrentTimeString(); - - // Only check the seconds part for exact matching - String currentTimeMinute = currentTime.substring(0, 5); // "HH:MM" - - for (auto& event : todaysEvents) { - String eventTimeMinute = event.timeStr.substring(0, 5); // "HH:MM" - - if (eventTimeMinute == currentTimeMinute) { - // Check if we haven't already triggered this event - if (!event.triggered) { - triggerEvent(event); - event.triggered = true; - } - } - - // Reset trigger flag when we're past the minute - if (eventTimeMinute < currentTimeMinute) { - event.triggered = false; - } - } -} - -void Timekeeper::triggerEvent(ScheduledEvent& event) { - JsonObject eventData = event.eventData; - - Serial.printf("TRIGGERING EVENT: %s at %s\n", - eventData["name"].as().c_str(), - event.timeStr.c_str()); - - // Here you would trigger your melody playback - // You might want to call a function from your main program - // or send a message to another task - - // Example of what you might do: - JsonObject melody = eventData["melody"]; - String melodyUID = melody["uid"].as(); - String melodyName = melody["name"].as(); - - Serial.printf("Playing melody: %s (UID: %s)\n", - melodyName.c_str(), melodyUID.c_str()); - - // TODO: Add your melody trigger code here - // playMelody(melody); -} - -void Timekeeper::loadNextDayEvents() { - // This function would load tomorrow's events for smooth midnight transition - // Implementation similar to loadTodaysEvents() but for tomorrow's date - Serial.println("Pre-loading tomorrow's events..."); - // TODO: Implement if needed for smoother transitions -} diff --git a/vesper/timekeeper.hpp b/vesper/timekeeper.hpp deleted file mode 100644 index 5656411..0000000 --- a/vesper/timekeeper.hpp +++ /dev/null @@ -1,78 +0,0 @@ -#ifndef TIMEKEEPER_HPP -#define TIMEKEEPER_HPP - -#include -#include -#include -#include -#include -#include "freertos/FreeRTOS.h" -#include "freertos/task.h" - -// Structure to hold scheduled events -struct ScheduledEvent { - String timeStr; // Time in "HH:MM:SS" format - JsonObject eventData; // Complete event data from JSON - bool triggered = false; // Flag to prevent multiple triggers -}; - -class Timekeeper { -private: - // RTC object - RTC_DS1307 rtc; - - // Event storage - std::vector todaysEvents; - std::vector tomorrowsEvents; - - // Clock management (using PCF8574 relays) - int clockRelay1 = -1, clockRelay2 = -1; // -1 means not assigned - unsigned long lastClockPulse = 0; - bool currentClockRelay = false; // false = relay1, true = relay2 - bool clockEnabled = false; // Only enable when outputs are set - - // Function pointer for relay control - void (*relayWriteFunc)(int relay, int state) = nullptr; - - // Task handles - TaskHandle_t rtcTaskHandle = NULL; - TaskHandle_t clockTaskHandle = NULL; - TaskHandle_t schedulerTaskHandle = NULL; - -public: - // Main initialization (no clock outputs initially) - void begin(); - - // Set the relay control function - void setRelayWriteFunction(void (*func)(int, int)); - - // Set/change clock relay outputs after initialization - void setClockOutputs(int relay1, int relay2); - - // Time management functions - void setTime(unsigned long timestamp); // Set RTC time from Unix timestamp - unsigned long getTime(); // Get current time as Unix timestamp - - // Event management - void loadTodaysEvents(); - void loadNextDayEvents(); - - // Static task functions (required by FreeRTOS) - static void rtcTask(void* parameter); - static void clockTask(void* parameter); - static void schedulerTask(void* parameter); - -private: - // Helper functions - bool isSameDate(String eventDateTime, int year, int month, int day); - void addToTodaysSchedule(JsonObject event); - void sortEventsByTime(); - String getCurrentTimeString(); - - // Core functionality - void updateClock(); - void checkScheduledEvents(); - void triggerEvent(ScheduledEvent& event); -}; - -#endif diff --git a/vesper/vesper.ino b/vesper/vesper.ino index 8aab6e4..7ce47df 100644 --- a/vesper/vesper.ino +++ b/vesper/vesper.ino @@ -1,140 +1,408 @@ -#include "logging.hpp" -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include +/* -// Custom Classes + █████ █████ ██████████ █████████ ███████████ ██████████ ███████████ +▒▒███ ▒▒███ ▒▒███▒▒▒▒▒█ ███▒▒▒▒▒███▒▒███▒▒▒▒▒███▒▒███▒▒▒▒▒█▒▒███▒▒▒▒▒███ + ▒███ ▒███ ▒███ █ ▒ ▒███ ▒▒▒ ▒███ ▒███ ▒███ █ ▒ ▒███ ▒███ + ▒███ ▒███ ▒██████ ▒▒█████████ ▒██████████ ▒██████ ▒██████████ + ▒▒███ ███ ▒███▒▒█ ▒▒▒▒▒▒▒▒███ ▒███▒▒▒▒▒▒ ▒███▒▒█ ▒███▒▒▒▒▒███ + ▒▒▒█████▒ ▒███ ▒ █ ███ ▒███ ▒███ ▒███ ▒ █ ▒███ ▒███ + ▒▒███ ██████████▒▒█████████ █████ ██████████ █████ █████ + ▒▒▒ ▒▒▒▒▒▒▒▒▒▒ ▒▒▒▒▒▒▒▒▒ ▒▒▒▒▒ ▒▒▒▒▒▒▒▒▒▒ ▒▒▒▒▒ ▒▒▒▒▒ -#include "timekeeper.hpp" + * ═══════════════════════════════════════════════════════════════════════════════════ + * Project VESPER - BELL AUTOMATION SYSTEM - Main Firmware Entry Point + * ═══════════════════════════════════════════════════════════════════════════════════ + * + * 🔔 DESCRIPTION: + * High-precision automated bell control system with multi-protocol communication, + * real-time telemetry, OTA updates, and modular hardware abstraction. + * + * 🏗️ ARCHITECTURE: + * Clean modular design with dependency injection and proper separation of concerns. + * Each major system is encapsulated in its own class with well-defined interfaces. + * + * 🎯 KEY FEATURES: + * ✅ Microsecond-precision bell timing (BellEngine) + * ✅ Multi-hardware support (PCF8574, GPIO, Mock) + * ✅ Dual network connectivity (Ethernet + WiFi) + * ✅ Dual Communication Support (MQTT + WebSocket) + * ✅ Real-time telemetry and load monitoring + * ✅ Over-the-air firmware updates + * ✅ SD card configuration and file management + * ✅ NTP time synchronization + * ✅ Comprehensive logging system + * + * 📡 COMMUNICATION PROTOCOLS: + * • MQTT (Primary control interface) + * • WebSocket (Real-time web interface) + * • UDP Discovery (Auto-discovery service) + * • HTTP/HTTPS (OTA updates) + * + * 🔧 HARDWARE ABSTRACTION: + * OutputManager provides clean interface for different relay systems: + * - PCF8574OutputManager: I2C GPIO expander (8 outputs, 6 on Kincony A6 Board) + * - GPIOOutputManager: Direct ESP32 pins (for DIY projects) + * - MockOutputManager: Testing without hardware + * + * ⚡ PERFORMANCE: + * High-priority FreeRTOS tasks ensure microsecond timing precision. + * Core 1 dedicated to BellEngine for maximum performance. + * + * 📋 VERSION: 1.1 + * 📅 DATE: 2025-09-08 + * 👨‍💻 AUTHOR: Advanced Bell Systems + * ═══════════════════════════════════════════════════════════════════════════════════ + */ -// Hardware Constructors: -Adafruit_PCF8574 relays; -// Wrapper function to connect timekeeper to your relays -void relayWrite(int relayIndex, int state) { - relays.digitalWrite(relayIndex, state); -} +// ═══════════════════════════════════════════════════════════════════════════════════ +// SYSTEM LIBRARIES - Core ESP32 and Arduino functionality +// ═══════════════════════════════════════════════════════════════════════════════════ +#include // SD card file system operations +#include // File system base class +#include // Ethernet connectivity (W5500 support) +#include // SPI communication protocol +#include // Arduino core framework +#include // WiFi connectivity management +#include // HTTP client for OTA updates +#include // Firmware update utilities +#include // I2C communication protocol +#include // Task watchdog timer -// SD Card Chip Select: -#define SD_CS 5 +// ═══════════════════════════════════════════════════════════════════════════════════ +// NETWORKING LIBRARIES - Advanced networking and communication +// ═══════════════════════════════════════════════════════════════════════════════════ +#include // High-performance async MQTT client +#include // WiFi configuration portal +#include // Async web server for WebSocket support +#include // UDP for discovery service -// Include Classes -#include "class_player.hpp" +// ═══════════════════════════════════════════════════════════════════════════════════ +// DATA PROCESSING LIBRARIES - JSON parsing and data structures +// ═══════════════════════════════════════════════════════════════════════════════════ +#include // Efficient JSON processing +#include // STL string support + +// ═══════════════════════════════════════════════════════════════════════════════════ +// HARDWARE LIBRARIES - Peripheral device control +// ═══════════════════════════════════════════════════════════════════════════════════ +#include // I2C GPIO expander for relay control +#include // Real-time clock functionality + +// ═══════════════════════════════════════════════════════════════════════════════════ +// CUSTOM CLASSES - Include Custom Classes and Functions +// ═══════════════════════════════════════════════════════════════════════════════════ +#include "src/ConfigManager/ConfigManager.hpp" +#include "src/FileManager/FileManager.hpp" +#include "src/TimeKeeper/TimeKeeper.hpp" +#include "src/Logging/Logging.hpp" +#include "src/Telemetry/Telemetry.hpp" +#include "src/OTAManager/OTAManager.hpp" +#include "src/Networking/Networking.hpp" +#include "src/Communication/Communication.hpp" +#include "src/ClientManager/ClientManager.hpp" +#include "src/Communication/ResponseBuilder.hpp" +#include "src/Player/Player.hpp" +#include "src/BellEngine/BellEngine.hpp" +#include "src/OutputManager/OutputManager.hpp" +#include "src/HealthMonitor/HealthMonitor.hpp" +#include "src/FirmwareValidator/FirmwareValidator.hpp" +#include "src/InputManager/InputManager.hpp" +#include "src/MqttSSL/MqttSSL.hpp" // Class Constructors +ConfigManager configManager; +FileManager fileManager(&configManager); Timekeeper timekeeper; +Telemetry telemetry; +OTAManager otaManager(configManager); AsyncMqttClient mqttClient; Player player; -std::vector melody_steps; // holds the steps of the melody. Should move into bell Engine. AsyncWebServer server(80); AsyncWebSocket ws("/ws"); -AsyncWebSocketClient* activeClient = nullptr; AsyncUDP udp; -constexpr uint16_t DISCOVERY_PORT = 32101; -uint32_t strikeCounters[16] = {0}; -uint16_t bellLoad[16] = {0}; -bool coolingActive = false; +Networking networking(configManager); +Communication communication(configManager, otaManager, networking, mqttClient, server, ws, udp); +HealthMonitor healthMonitor; +FirmwareValidator firmwareValidator; +InputManager inputManager; -#include "config.h" -#include "ota.hpp" -#include "functions.hpp" -#include "commands_handling.hpp" -#include "MQTT_Functions.hpp" -#include "MQTT_Connection_Handling.hpp" -#include "WebSocket_Functions.hpp" -#include "PlaybackControls.hpp" -#include "bellEngine.hpp" -#include "dataLogging.hpp" +// 🔥 OUTPUT SYSTEM - PCF8574/PCF8575 I2C Expanders Configuration +// Choose one of the following configurations (with active output counts): -TaskHandle_t bellEngineHandle = NULL; +// Option 1: Single PCF8574 (6 active outputs out of 8 max) +PCF8574OutputManager outputManager(0x24, ChipType::PCF8574, 6); + +// Option 2: Single PCF8575 (8 active outputs out of 16 max) +//PCF8574OutputManager outputManager(0x24, ChipType::PCF8575, 8); + +// Option 3: PCF8574 + PCF8575 (6 + 8 = 14 total virtual outputs) +//PCF8574OutputManager outputManager(0x24, ChipType::PCF8574, 6, 0x21, ChipType::PCF8575, 8); + +// Option 4: Dual PCF8575 (8 + 8 = 16 total virtual outputs) +//PCF8574OutputManager outputManager(0x24, ChipType::PCF8575, 8, 0x21, ChipType::PCF8575, 8); + +// Virtual Output Mapping Examples: +// Option 1: Virtual outputs 0-5 → PCF8574[0x20] pins 0-5 +// Option 3: Virtual outputs 0-5 → PCF8574[0x20] pins 0-5, Virtual outputs 6-13 → PCF8575[0x21] pins 0-7 +// Option 4: Virtual outputs 0-7 → PCF8575[0x20] pins 0-7, Virtual outputs 8-15 → PCF8575[0x21] pins 0-7 + +// Legacy backward-compatible (defaults to 8 active outputs): +//PCF8574OutputManager outputManager(0x20, ChipType::PCF8574); // 8/8 active outputs + +BellEngine bellEngine(player, configManager, telemetry, outputManager); // 🔥 THE ULTIMATE BEAST! + +TaskHandle_t bellEngineHandle = NULL; // Legacy - will be removed TimerHandle_t schedulerTimer; +void handleFactoryReset() { + if (configManager.factoryReset()) { + delay(3000); + ESP.restart(); + } +} + + + void setup() { - // Initialize Serial Communications & I2C Bus (for debugging) + // Initialize Serial Communications (for debugging) & I2C Bus (for Hardware Control) Serial.begin(115200); + Serial.println("Hello, VESPER System Initialized! - PontikoTest"); Wire.begin(4,15); - SPI.begin(ETH_SPI_SCK, ETH_SPI_MISO, ETH_SPI_MOSI); + auto& hwConfig = configManager.getHardwareConfig(); + SPI.begin(hwConfig.ethSpiSck, hwConfig.ethSpiMiso, hwConfig.ethSpiMosi); delay(50); - // Initialize PCF8574 and Relays - relays.begin(PCF8574_ADDR, &Wire); - for (uint8_t p=0; p<6; p++){ - relays.pinMode(p, OUTPUT); - relays.digitalWrite(p, HIGH); + // Initialize Configuration (this loads device identity from SD card) + configManager.begin(); + + inputManager.begin(); + inputManager.setFactoryResetLongPressCallback(handleFactoryReset); + + + // Set factory values: + + + configManager.setDeviceUID("PV202508190002"); + configManager.setHwType("BellPlus"); + configManager.setHwVersion("1.0"); + configManager.setFwVersion("1.1"); + LOG_INFO("Device identity initialized"); + + + // Display device information after configuration is loaded + Serial.println("\n=== DEVICE IDENTITY ==="); + Serial.printf("Device UID: %s\n", configManager.getDeviceUID().c_str()); + Serial.printf("Hardware Type: %s\n", configManager.getHwType().c_str()); + Serial.printf("Hardware Version: %s\n", configManager.getHwVersion().c_str()); + Serial.printf("Firmware Version: %s\n", configManager.getFwVersion().c_str()); + Serial.printf("AP SSID: %s\n", configManager.getAPSSID().c_str()); + Serial.println("=====================\n"); + + // 🔥 CRITICAL: Initialize Health Monitor FIRST (required for firmware validation) + healthMonitor.begin(); + // Register all subsystems with health monitor for continuous monitoring + healthMonitor.setConfigManager(&configManager); + healthMonitor.setFileManager(&fileManager); + + // Initialize Output Manager - 🔥 THE NEW WAY! + outputManager.setConfigManager(&configManager); + if (!outputManager.initialize()) { + LOG_ERROR("Failed to initialize OutputManager!"); + // Continue anyway for now } - - - // Initialize SD Card - if (!SD.begin(SD_CS)) { - Serial.println("SD card not found. Using defaults."); - } else { - // do nothing - } + // Register OutputManager with health monitor + healthMonitor.setOutputManager(&outputManager); + + // Initialize BellEngine early for health validation + bellEngine.begin(); + healthMonitor.setBellEngine(&bellEngine); + + delay(100); + + // 🔥 BULLETPROOF: Initialize Firmware Validator and perform startup validation + firmwareValidator.begin(&healthMonitor, &configManager); + delay(100); + + // 💀 CRITICAL SAFETY CHECK: Perform startup validation + // This MUST happen early before initializing other subsystems + if (!firmwareValidator.performStartupValidation()) { + // If we reach here, startup validation failed and rollback was triggered + // The system should reboot automatically to the previous firmware + LOG_ERROR("💀 STARTUP VALIDATION FAILED - SYSTEM HALTED"); + while(1) { delay(1000); } // Should not reach here + } + + LOG_INFO("✅ Firmware startup validation PASSED - proceeding with initialization"); + + // Initialize remaining subsystems... + + // SD Card initialization is now handled by ConfigManager // Initialize timekeeper with NO clock outputs timekeeper.begin(); // No parameters needed - // Connect the timekeeper to your relay controller - timekeeper.setRelayWriteFunction(relayWrite); - timekeeper.setClockOutputs(5, 4); + // Connect the timekeeper to dependencies (CLEAN!) + timekeeper.setOutputManager(&outputManager); + timekeeper.setConfigManager(&configManager); + timekeeper.setNetworking(&networking); + // Clock outputs now configured via ConfigManager/Communication commands + + // Register TimeKeeper with health monitor + healthMonitor.setTimeKeeper(&timekeeper); + + // Initialize Telemetry + telemetry.begin(); + telemetry.setPlayerReference(&player.isPlaying); + // 🚑 CRITICAL: Connect force stop callback for overload protection! + telemetry.setForceStopCallback([]() { player.forceStop(); }); + + // Register Telemetry with health monitor + healthMonitor.setTelemetry(&telemetry); - // Initialize Networking and MQTT - Network.onEvent(NetworkEvent); - ETH.begin(ETH_PHY_TYPE, ETH_PHY_ADDR, ETH_PHY_CS, ETH_PHY_IRQ, ETH_PHY_RST, SPI); - InitMqtt(); - WiFiManager wm; - //wm.resetSettings(); // Only for Debugging. - bool res; - res = wm.autoConnect(ap_ssid.c_str(),ap_pass.c_str()); - if(!res) { - LOG_ERROR("Failed to connect to WiFi"); - } - else { - LOG_INFO("Connected to WiFi"); + // Initialize Networking (handles everything automatically) + networking.begin(); + + // Register Networking with health monitor + healthMonitor.setNetworking(&networking); + + // Initialize Player + player.begin(); + + // Register Player with health monitor + healthMonitor.setPlayer(&player); + + // BellEngine already initialized and registered earlier for health validation + + // Initialize Communication Manager + communication.begin(); + communication.setPlayerReference(&player); + communication.setFileManagerReference(&fileManager); + communication.setTimeKeeperReference(&timekeeper); + communication.setFirmwareValidatorReference(&firmwareValidator); + player.setDependencies(&communication, &fileManager); + player.setBellEngine(&bellEngine); // Connect the beast! + + // Register Communication with health monitor + healthMonitor.setCommunication(&communication); + + // 🔔 CONNECT BELLENGINE TO COMMUNICATION FOR DING NOTIFICATIONS! + bellEngine.setCommunicationManager(&communication); + + // Set up network callbacks + networking.setNetworkCallbacks( + []() { communication.onNetworkConnected(); }, // onConnected + []() { communication.onNetworkDisconnected(); } // onDisconnected + ); + + // If already connected, trigger MQTT connection manually + if (networking.isConnected()) { + LOG_INFO("Network already connected - triggering MQTT connection"); + communication.onNetworkConnected(); } - delay(100); + delay(500); - checkForUpdates(); // checks for updates online - setupUdpDiscovery(); + // Initialize OTA Manager and check for updates + otaManager.begin(); + otaManager.setFileManager(&fileManager); + + // 🔥 CRITICAL: Delay OTA check to avoid UDP socket race with MQTT + // Both MQTT and OTA HTTP use UDP sockets, must sequence them! + delay(2000); + LOG_INFO("Starting OTA update check after network stabilization..."); + otaManager.checkForUpdates(); + communication.setupUdpDiscovery(); - delay(100); + // Register OTA Manager with health monitor + healthMonitor.setOTAManager(&otaManager); - // WebSocket setup - ws.onEvent(onWebSocketEvent); - server.addHandler(&ws); - // Start the server - server.begin(); + // Start the server + server.begin(); - // Tasks and Timers - xTaskCreatePinnedToCore(bellEngine,"bellEngine", 8192, NULL, 1, &bellEngineHandle, 1); - xTaskCreatePinnedToCore(durationTimer, "durationTimer", 8192, NULL, 2, NULL, 1); - xTaskCreatePinnedToCore(relayControlTask, "Relay Control", 2048, NULL, 2, NULL, 1); - //xTaskCreatePinnedToCore(dataLogging, "dataLogging", 2048, NULL, 2, NULL, 1); + // 🔥 START RUNTIME VALIDATION: All subsystems are now initialized + // Begin extended runtime validation if we're in testing mode + if (firmwareValidator.isInTestingMode()) { + LOG_INFO("🏃 Starting runtime validation - firmware will be tested for %lu seconds", + firmwareValidator.getValidationConfig().runtimeTimeoutMs / 1000); + firmwareValidator.startRuntimeValidation(); + } else { + LOG_INFO("✅ Firmware already validated - normal operation mode"); + } - loadRelayTimings(); + // ═══════════════════════════════════════════════════════════════════════════════ + // INITIALIZATION COMPLETE + // ═══════════════════════════════════════════════════════════════════════════════ + // ✅ All automatic task creation handled by individual components: + // • BellEngine creates high-priority timing task on Core 1 + // • Telemetry creates monitoring task for load tracking + // • Player creates duration timer for playback control + // • Communication creates MQTT reconnection timers + // • Networking creates connection management timers + // ✅ Bell configuration automatically loaded by ConfigManager + // ✅ System ready for MQTT commands, WebSocket connections, and UDP discovery } +// ███████████████████████████████████████████████████████████████████████████████████ +// █ MAIN LOOP █ +// ███████████████████████████████████████████████████████████████████████████████████ +// The main loop is intentionally kept minimal in this architecture. All critical +// functionality runs in dedicated FreeRTOS tasks for optimal performance and timing. +// This ensures the main loop doesn't interfere with precision bell timing. +/** + * @brief Main execution loop - Minimal by design + * + * In the new modular architecture, all heavy lifting is done by dedicated tasks: + * • BellEngine: High-priority task on Core 1 for microsecond timing + * • Telemetry: Background monitoring task for system health + * • Player: Timer-based duration control for melody playback + * • Communication: Event-driven MQTT/WebSocket handling + * • Networking: Automatic connection management + * + * The main loop only handles lightweight operations that don't require + * precise timing or could benefit from running on Core 0. + * + * @note This loop runs on Core 0 and should remain lightweight to avoid + * interfering with the precision timing on Core 1. + */ void loop() { -//Serial.printf("b1:%d - b2:%d - Bell 1 Load: %d \n",strikeCounters[0], strikeCounters[1], bellLoad[0]); + // ═══════════════════════════════════════════════════════════════════════════════ + // INTENTIONALLY MINIMAL - ALL WORK DONE BY DEDICATED TASKS + // ═══════════════════════════════════════════════════════════════════════════════ + // + // The loop() function is kept empty by design to ensure maximum + // performance for the high-precision BellEngine running on Core 1. + // + // All system functionality is handled by dedicated FreeRTOS tasks: + // • 🔥 BellEngine: Microsecond-precision timing (Core 1, Priority 6) + // • 📊 Telemetry: System monitoring (Background task) + // • 🎵 Player: Duration management (FreeRTOS timers) + // • 📡 Communication: MQTT/WebSocket (Event-driven) + // • 🌐 Networking: Connection management (Timer-based) + // + // If you need to add periodic functionality, consider creating a new + // dedicated task instead of putting it here. + + // Uncomment the line below for debugging system status: + // Serial.printf("Free heap: %d bytes\n", ESP.getFreeHeap()); + + // Feed watchdog only during firmware validation + if (firmwareValidator.isInTestingMode()) { + esp_task_wdt_reset(); + } else { + // Remove task from watchdog if validation completed + static bool taskRemoved = false; + if (!taskRemoved) { + esp_task_wdt_delete(NULL); // Remove current task + taskRemoved = true; + } + } + + // Keep the loop responsive but not busy + delay(100); // ⏱️ 100ms delay to prevent busy waiting }