MAJOR update. More like a Backup before things get Crazy

Added Websocket Support
Added Universal Message Handling for both MQTT and WS
Added Timekeeper Class, that handles Physical Clock and Scheduling
Added Bell Assignment Settings, Note to Bell mapping
This commit is contained in:
2025-09-05 19:27:13 +03:00
parent c1fa1d5e57
commit 101f9e7135
20 changed files with 10746 additions and 9766 deletions

40
vesper/MQTT_Functions.hpp Normal file
View File

@@ -0,0 +1,40 @@
#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);
}
}

View File

@@ -1,102 +0,0 @@
#pragma once
void setRelayDurations(JsonDocument& doc);
void updateRelayTimings();
// - - - - - - - - - - - - - - - - - - - - - - - - - - -
// Handles the incoming payload. Returns it into a "JsonDocument" format.
JsonDocument handleJSON(char * payload) {
JsonDocument doc;
DeserializationError error = deserializeJson(doc, payload);
if (error) {
Serial.print("deserializeJson() failed: ");
Serial.println(error.c_str());
}
return doc;
}
void replyOnWebSocket(AsyncWebSocketClient *client, String list) {
client->text(list);
}
// Handles the JSON Commands
void handleCommand(JsonDocument json, AsyncWebSocketClient *client = nullptr){
String cmd = json["cmd"];
JsonVariant contents = json["contents"];
if (cmd == "playback") {
player.command(contents);
} else if (cmd == "set_melody") {
player.setMelodyAttributes(contents);
player.loadMelodyInRAM(melody_steps);
} 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
}
} else if (cmd == "set_relay_timers") {
updateRelayTimings(contents);
} else if (cmd == "add_melody"){
addMelody(contents);
} else {
LOG_WARNING("Unknown Command Received");
}
}
// Subscribes to certain topics on the MQTT Server.
void SuscribeMqtt() {
String command = String("vesper/") + DEV_ID + "/control";
uint16_t command_id = mqttClient.subscribe(command.c_str(), 2);
LOG_INFO("Subscribing to Command topic, QoS 2, packetId: %d", command_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 command = 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) == command){
JsonDocument json = handleJSON(payload);
handleCommand(json);
}
}
// 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 = handleJSON((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);
}
}
// Publishes a message on the MQTT server. Message passed as an argument.
void PublishMqtt(const char * data) {
String topicData = String("vesper/") + DEV_ID + "/data";
mqttClient.publish(topicData.c_str(), 0, true, data);
}

View File

@@ -12,7 +12,6 @@ void durationTimer(void *param) {
// Task Setup // Task Setup
// Task Loop // Task Loop
while (true) { while (true) {
@@ -26,16 +25,16 @@ void durationTimer(void *param) {
player.unpause(); player.unpause();
} }
vTaskDelay(pdMS_TO_TICKS(1000)); // Check every 100ms vTaskDelay(pdMS_TO_TICKS(500)); // Check every 500ms
} }
} }
// Check if it's time to stop playback // Check if it's time to stop playback
bool timeToStop(unsigned long now) { bool timeToStop(unsigned long now) {
if (player.isPlaying) { if (player.isPlaying && !player.infinite_play) {
uint64_t stopTime = player.startTime + player.duration; uint64_t stopTime = player.startTime + player.total_duration;
if (now >= stopTime) { if (now >= stopTime) {
LOG_DEBUG("TIMER: Total Duration Reached"); LOG_DEBUG("(TimerFunction) Total Run Duration Reached. Soft Stopping.");
return true; return true;
} }
} }
@@ -44,11 +43,11 @@ bool timeToStop(unsigned long now) {
// Check if it's time to pause playback // Check if it's time to pause playback
bool timeToPause(unsigned long now) { bool timeToPause(unsigned long now) {
if (player.isPlaying && player.loop_duration > 0) { if (player.isPlaying && player.continuous_loop) {
uint64_t pauseTimeLimit = player.loopStartTime + player.loop_duration; uint64_t timeToPause = player.segmentStartTime + player.segment_duration;
LOG_DEBUG("PTL: %lu // NOW: %d",pauseTimeLimit, now); LOG_DEBUG("PTL: %llu // NOW: %lu", timeToPause, now);
if (now >= pauseTimeLimit && !player.isPaused) { if (now >= timeToPause && !player.isPaused) {
LOG_DEBUG("TIMER: Segment Duration Reached"); LOG_DEBUG("(TimerFunction) Segment Duration Reached. Pausing.");
player.pauseTime = now; player.pauseTime = now;
return true; return true;
} }
@@ -59,9 +58,9 @@ bool timeToPause(unsigned long now) {
// Check if it's time to resume playback // Check if it's time to resume playback
bool timeToResume(unsigned long now) { bool timeToResume(unsigned long now) {
if (player.isPaused) { if (player.isPaused) {
uint64_t resumeTime = player.pauseTime + player.interval; uint64_t timeToResume = player.segmentCmpltTime + player.pause_duration;
if (now >= resumeTime) { if (now >= timeToResume) {
LOG_DEBUG("TIMER: Pause Duration Reached"); LOG_DEBUG("(TimerFunction) Pause Duration Reached. Resuming");
return true; return true;
} }
} }

View File

@@ -0,0 +1,49 @@
#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);
}
}

View File

@@ -1,21 +1,27 @@
// MELODY PLAYBACK WILL BE HANDLED HERE // MELODY PLAYBACK WILL BE HANDLED HERE
#include <vector> #include <vector>
extern Player player; extern Player player;
// Define a structure to track active solenoids // Define a structure to track active solenoids
struct ActiveRelay { struct ActiveRelay {
uint8_t relayIndex; // Index of the relay uint8_t relayIndex; // Physical relay index (0-15)
uint8_t bellIndex; // Bell index for duration lookup
uint64_t activationTime; // Activation start time uint64_t activationTime; // Activation start time
uint16_t duration; // Duration for which it should remain active uint16_t duration; // Duration for which it should remain active
}; };
// Array of durations for each relay (configure remotely) // Duration per BELL (not per relay output)
uint16_t relayDurations[16] = {90, 90, 90, 90, 90, 90, 90, 90, 90, 90, 90, 90, 90, 90, 90, 90}; 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 // Vector to track active solenoids
std::vector<ActiveRelay> activeRelays; std::vector<ActiveRelay> activeRelays;
// Locks for Writing the Counters
portMUX_TYPE mySpinlock = portMUX_INITIALIZER_UNLOCKED;
void loop_playback(std::vector<uint16_t> &melody_steps); void loop_playback(std::vector<uint16_t> &melody_steps);
void bellEngine(void *parameter); void bellEngine(void *parameter);
@@ -23,6 +29,7 @@ void relayControlTask(void *param);
void itsHammerTime(uint16_t note); void itsHammerTime(uint16_t note);
void turnOffRelays(uint64_t now); void turnOffRelays(uint64_t now);
// Main Bell Engine. Activates Relays on the exact timing required.
void bellEngine(void *parameter) { void bellEngine(void *parameter) {
// SETUP TASK // SETUP TASK
for (;;) { for (;;) {
@@ -36,7 +43,7 @@ void bellEngine(void *parameter) {
} }
} }
// Task to deactivate relays dynamically // Task to deactivate relays dynamically after set timers
void relayControlTask(void *param) { void relayControlTask(void *param) {
while (true) { while (true) {
uint64_t now = millis(); uint64_t now = millis();
@@ -49,14 +56,15 @@ void relayControlTask(void *param) {
++it; // Move to the next relay ++it; // Move to the next relay
} }
} }
vTaskDelay(pdMS_TO_TICKS(10)); // Check every 10ms vTaskDelay(pdMS_TO_TICKS(10)); // Check every 10ms
} }
} }
// Function to wait for tempo, then loop to the next beat.
void loop_playback(std::vector<uint16_t> &melody_steps) { void loop_playback(std::vector<uint16_t> &melody_steps) {
while(player.isPlaying && !player.isPaused){ while(player.isPlaying && !player.isPaused){
LOG_DEBUG("(BellEngine) Single Loop Starting.");
// iterate through the beats and call the bell mechanism on each beat // iterate through the beats and call the bell mechanism on each beat
for (uint16_t note : melody_steps) { for (uint16_t note : melody_steps) {
@@ -66,21 +74,92 @@ void loop_playback(std::vector<uint16_t> &melody_steps) {
vTaskDelay(pdMS_TO_TICKS(tempo)); vTaskDelay(pdMS_TO_TICKS(tempo));
} }
LOG_DEBUG("Single Loop Over."); player.segmentCmpltTime = millis();
//if (!player.isPlaying) break; // Stop playback only after completing the loop LOG_DEBUG("(BellEngine) Single Full Loop Complete");
} }
} }
// Function to activate relays for a specific note // Function to activate relays for a specific note
void itsHammerTime(uint16_t note) { void itsHammerTime(uint16_t note) {
uint64_t now = millis(); uint64_t now = millis();
for (uint8_t i = 0; i < 16; i++) { // First, determine which bells should ring based on the note pattern
if (note & (1 << i)) { // Check if this relay needs to activate for (uint8_t noteIndex = 0; noteIndex < 16; noteIndex++) {
relays.digitalWrite(i, LOW); // Activate the relay if (note & (1 << noteIndex)) { // This note should be played
// Add to the activeRelays list // Level 2: Map note to bell using noteAssignments
activeRelays.push_back({i, now, relayDurations[i]}); 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<uint16_t>()-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<uint16_t>();
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
}

File diff suppressed because one or more lines are too long

View File

@@ -8,7 +8,7 @@ void setRelayDurations(JsonDocument& doc);
// - - - - - - - - - - - - - - - - - - - - - - - - - - - // - - - - - - - - - - - - - - - - - - - - - - - - - - -
// Handles the incoming payload. Returns it into a "JsonDocument" format. // Handles the incoming payload. Returns it into a "JsonDocument" format.
JsonDocument handleJSON(char * payload) { JsonDocument payload2json(char * payload) {
JsonDocument doc; JsonDocument doc;
DeserializationError error = deserializeJson(doc, payload); DeserializationError error = deserializeJson(doc, payload);
if (error) { if (error) {
@@ -62,7 +62,7 @@ void OnMqttReceived(char * topic, char * payload, AsyncMqttClientMessageProperti
} }
else if (String(topic) == topicSetMelody) { else if (String(topic) == topicSetMelody) {
setMelodyAttributes(handleJSON(payload)); setMelodyAttributes(payload2json(payload));
loadMelodyInRAM(melody_steps); loadMelodyInRAM(melody_steps);
} }
@@ -73,7 +73,7 @@ void OnMqttReceived(char * topic, char * payload, AsyncMqttClientMessageProperti
} }
else if (String(topic) == topicRelayTimers) { else if (String(topic) == topicRelayTimers) {
setRelayDurations(handleJSON(payload)); setRelayDurations(payload2json(payload));
} }
else { else {

View File

@@ -1,4 +1,3 @@
#line 1 "C:\\Users\\espi_\\Documents\\Arduino\\4. Bell Systems\\1. Main Projects\\Project - Vesper\\PlaybackControls.hpp"
#pragma once #pragma once
extern melody_attributes melody; extern melody_attributes melody;
@@ -12,6 +11,8 @@ void durationTimer(void *param);
void durationTimer(void *param) { void durationTimer(void *param) {
// Task Setup // Task Setup
Serial.print("Main millis: ");
Serial.println(millis());
// Task Loop // Task Loop
@@ -27,7 +28,7 @@ void durationTimer(void *param) {
melody.unpause(); melody.unpause();
} }
vTaskDelay(pdMS_TO_TICKS(1000)); // Check every 100ms vTaskDelay(pdMS_TO_TICKS(100)); // Check every 100ms
} }
} }
@@ -47,7 +48,9 @@ bool timeToStop(unsigned long now) {
bool timeToPause(unsigned long now) { bool timeToPause(unsigned long now) {
if (melody.isPlaying && melody.loop_duration > 0) { if (melody.isPlaying && melody.loop_duration > 0) {
uint64_t pauseTimeLimit = melody.loopStartTime + melody.loop_duration; uint64_t pauseTimeLimit = melody.loopStartTime + melody.loop_duration;
Serial.printf("PTL: %lu // NOW: ",pauseTimeLimit); Serial.print("PTL: ");
Serial.print(pauseTimeLimit);
Serial.print(" // NOW: ");
Serial.println(now); Serial.println(now);
if (now >= pauseTimeLimit && !melody.isPaused) { if (now >= pauseTimeLimit && !melody.isPaused) {
Serial.println("TIMER: Segment Duration Reached"); Serial.println("TIMER: Segment Duration Reached");

213
vesper/class_player.hpp Normal file
View File

@@ -0,0 +1,213 @@
#pragma once
extern std::vector<uint16_t> 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<const char*>();
}
if (doc.containsKey("uid")) {
uid = doc["uid"].as<const char*>();
}
if (doc.containsKey("url")) {
url = doc["url"].as<const char*>();
}
if (doc.containsKey("speed")) {
speed = doc["speed"].as<uint16_t>();
}
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<uint32_t>();
}
if (doc.containsKey("pause_duration")) {
pause_duration = doc["pause_duration"].as<uint32_t>();
}
if (doc.containsKey("total_duration")) {
total_duration = doc["total_duration"].as<uint32_t>();
}
if (doc.containsKey("continuous_loop")) {
continuous_loop = doc["continuous_loop"].as<bool>();
}
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<uint16_t> &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();
}
};

View File

@@ -1,133 +0,0 @@
#pragma once
extern std::vector<uint16_t> melody_steps;
void PublishMqtt(const char * data);
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
uint16_t speed = 500; // Time to wait per beat. (In Miliseconds)
uint32_t duration = 15000; // Total Duration that program will run (In Miliseconds)
uint32_t loop_duration = 0; // Duration of the playback per segment
uint32_t interval = 0; // Indicates the Duration of the Interval between finished segments, IF "inf" is true
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
uint64_t startTime = 0; // The time-point the Melody started Playing
uint64_t loopStartTime = 0; // The time-point the current segment started Playing
bool hardStop = false; // Flags a hardstop, immediately.
uint64_t pauseTime = 0; // The time-point the melody paused
void play() {
isPlaying = true;
hardStop = false;
startTime = loopStartTime = 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");
}
void pause() {
isPaused = true;
LOG_DEBUG("Plbck: PAUSE");
}
void unpause() {
isPaused = false;
loopStartTime = millis();
LOG_DEBUG("Plbck: RESUME");
}
// Handles Incoming Commands to PLAY or STOP
void command(JsonVariant content){
String action = content["action"];
LOG_DEBUG("Incoming Command: %s", action);
if (action == "play") {
play();
PublishMqtt("OK - PLAY");
} else if (action == "stop") {
forceStop();
PublishMqtt("OK - STOP");
}
}
// Sets incoming Attributes for the Melody, into the class' variables.
void setMelodyAttributes(JsonVariant doc){
if (doc.containsKey("name")) {
name = doc["name"].as<const char*>();
}
if (doc.containsKey("id")) {
id = doc["id"].as<uint16_t>();
}
if (doc.containsKey("duration")) {
duration = doc["duration"].as<uint32_t>();
}
if (doc.containsKey("infinite")) {
infinite_play = doc["infinite"].as<bool>();
}
if (doc.containsKey("interval")) {
interval = doc["interval"].as<uint32_t>();
}
if (doc.containsKey("speed")) {
speed = doc["speed"].as<uint16_t>();
}
if (doc.containsKey("loop_dur")) {
loop_duration = doc["loop_dur"].as<uint32_t>();
}
// Print Just for Debugging Purposes
LOG_DEBUG("Name: %s, ID: %d, Total Duration: %lu, Loop Duration: %lu, Interval: %d, Speed: %d, Inf: %s\n",
name.c_str(),
id,
duration,
loop_duration,
interval,
speed,
infinite_play ? "true" : "false"
);
}
// Loads the Selected melody from a .bin file on SD into RAM
void loadMelodyInRAM(std::vector<uint16_t> &melody_steps) {
String filePath = "/melodies/" + String(name.c_str()) + ".bin";
File bin_file = SD.open(filePath.c_str(), FILE_READ);
if (!bin_file) {
LOG_ERROR("Failed to open file: %s", filePath.c_str());
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;
}
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();
}
};

View File

@@ -1,33 +1,92 @@
ADD MELODY: ####################################
## DOWNLOAD MELODY ON DEVICE:
####################################
{ {
"cmd": "add_melody", "cmd": "download_melody",
"contents": { "contents": {
"url": "URL", "download_url": "http://url.com/SDFJDLS9043FM3RM1/binaries/melody.bin",
"filename": "NAME.EXT" "melodys_uid": "SDFJDLS9043FM3RM1"
} }
} }
melodys_uid >> The Reference UID of the Melody in the database
SET RELAY TIMERS:
{ {
"cmd": "set_relay_timers", "status": "OK",
"contents": {"b1":100, "b2":200} "type": "Download",
"payload": "null"
} }
START PLAYBACK:
####################################
## 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", "cmd": "playback",
"contents": { "contents": {
"action": "play" "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:
####################################
## STOP PLAYBACK:
####################################
{ {
"cmd": "playback", "cmd": "playback",
@@ -36,27 +95,108 @@ STOP PLAYBACK:
} }
} }
{
"status": "OK",
"type": "stop",
"payload": "null"
}
####################################
## LIST MELODIES
####################################
SET MELODY:
{ {
"cmd": "set_melody", "cmd": "list_melodies"
}
{
"status": "OK",
"type": "list_melodies",
"payload": ["jdslkfj09823jvcm", "cj2309jcvscv32", "csdvn02dsffv48nvm"]
}
####################################
## SYNC TIME
####################################
{
"cmd": "sync_time",
"contents": { "contents": {
"name": "esperinos", "timestamp": 16546416
"id": "10",
"duration": 5000,
"infinite": true,
"interval": 1000,
"speed": 200,
"loop_dur": 2000
} }
} }
{
LIST MELODIES "status": "OK",
"type": "time_set_response",
"payload": "Time updated successfully"
}
{ {
"cmd": "list_melodies", "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
}

View File

@@ -0,0 +1,120 @@
#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");
}
}

View File

@@ -1,6 +1,6 @@
#pragma once #pragma once
#define DEV_ID "PC201504180001" #define DEV_ID "PV202508190001"
// Network Config // Network Config
const char* ssid = "SmartNet"; //Not used with WiFi Manager const char* ssid = "SmartNet"; //Not used with WiFi Manager

35
vesper/dataLogging.hpp Normal file
View File

@@ -0,0 +1,35 @@
#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
}
}

47
vesper/features.nfo Normal file
View File

@@ -0,0 +1,47 @@
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.

View File

@@ -1,27 +1,13 @@
#pragma once #pragma once
extern uint16_t relayDurations[16]; extern uint16_t bellDurations[16];
void loadRelayTimings(); void loadRelayTimings();
void saveRelayTimings(); void saveRelayTimings();
// - - - - - - - - - - - - - - - - - - - - - - - - - - - // - - - - - - - - - - - - - - - - - - - - - - - - - - -
// 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)) {
relayDurations[i] = doc[key].as<uint16_t>();
LOG_DEBUG("Relay %d duration s1et to %d ms\n", i + 1, relayDurations[i]);
} else {
LOG_DEBUG("Relay %d not found in JSON payload. Keeping previous duration: %d ms\n", i + 1, relayDurations[i]);
}
}
saveRelayTimings();
LOG_INFO("Updated Relay Timings.")
}
// Save file "filename" with data: "data" to the "dirPath" directory of the SD card // 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) { void saveFileToSD(const char* dirPath, const char* filename, const char* data) {
@@ -59,7 +45,7 @@ void saveRelayTimings() {
// Populate the JSON object with relay durations // Populate the JSON object with relay durations
for (uint8_t i = 0; i < 16; i++) { for (uint8_t i = 0; i < 16; i++) {
String key = String("b") + (i + 1); String key = String("b") + (i + 1);
doc[key] = relayDurations[i]; doc[key] = bellDurations[i];
} }
char buffer[512]; char buffer[512];
@@ -100,14 +86,14 @@ void loadRelayTimings() {
for (uint8_t i = 0; i < 16; i++) { for (uint8_t i = 0; i < 16; i++) {
String key = String("b") + (i + 1); String key = String("b") + (i + 1);
if (doc.containsKey(key)) { if (doc.containsKey(key)) {
relayDurations[i] = doc[key].as<uint16_t>(); bellDurations[i] = doc[key].as<uint16_t>();
LOG_DEBUG("Loaded relay %d duration: %d ms\n", i + 1, relayDurations[i]); LOG_DEBUG("Loaded relay %d duration: %d ms", i + 1, bellDurations[i]);
} }
} }
} }
// Function to sync time with NTP server and update RTC /* Function to sync time with NTP server and update RTC
void syncTimeWithNTP() { void syncTimeWithNTP() {
// Connect to Wi-Fi if not already connected // Connect to Wi-Fi if not already connected
if (WiFi.status() != WL_CONNECTED) { if (WiFi.status() != WL_CONNECTED) {
@@ -142,6 +128,8 @@ void syncTimeWithNTP() {
timeInfo.tm_mday, timeInfo.tm_mon + 1, timeInfo.tm_year + 1900); timeInfo.tm_mday, timeInfo.tm_mon + 1, timeInfo.tm_year + 1900);
} }
*/
// Call this function with the Firebase URL and desired local filename // Call this function with the Firebase URL and desired local filename
bool downloadFileToSD(const String& url, const String& directory, const String& filename) { bool downloadFileToSD(const String& url, const String& directory, const String& filename) {
LOG_INFO("HTTP Starting download..."); LOG_INFO("HTTP Starting download...");
@@ -192,7 +180,6 @@ bool downloadFileToSD(const String& url, const String& directory, const String&
return true; return true;
} }
// Returns the list of melodies (the filenames) currently inside the SD Card. // Returns the list of melodies (the filenames) currently inside the SD Card.
String listFilesAsJson(const char* dirPath) { String listFilesAsJson(const char* dirPath) {
if (!SD.begin(SD_CS)) { if (!SD.begin(SD_CS)) {
@@ -280,14 +267,14 @@ void printFileAsText(const String& path, const String& filename) {
} }
// Downloads a new melody from HTTP // Downloads a new melody from HTTP
void addMelody(JsonVariant doc) { bool addMelody(JsonVariant doc) {
LOG_INFO("Trying Download of Melody...");
LOG_INFO("Trying Saving..."); const char* url = doc["download_url"];
const char* url = doc["url"]; const char* filename = doc["melodys_uid"];
const char* filename = doc["filename"]; if (downloadFileToSD(url, "/melodies", filename)) {
downloadFileToSD(url, "/melodies", filename); return true;
}
return false;
} }
// Checks the onboard SD Card for new firmware // Checks the onboard SD Card for new firmware
@@ -340,6 +327,59 @@ void checkFirmwareUpdate() {
updateBin.close(); 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 senders 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. // UNSUSED FUNCTIONS.

392
vesper/timekeeper.cpp Normal file
View File

@@ -0,0 +1,392 @@
#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<bool>()) {
continue; // Skip disabled events
}
String type = event["type"].as<String>();
bool shouldAdd = false;
if (type == "single") {
// Check if event date matches today
String eventDateTime = event["datetime"].as<String>();
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<int>();
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<int>();
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<String>();
if (datetime.length() >= 19) {
schedEvent.timeStr = datetime.substring(11, 19);
}
} else {
// Weekly/Monthly events have separate time field
schedEvent.timeStr = event["time"].as<String>();
}
// 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<String>().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<Timekeeper*>(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<Timekeeper*>(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<Timekeeper*>(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<String>().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>();
String melodyName = melody["name"].as<String>();
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
}

78
vesper/timekeeper.hpp Normal file
View File

@@ -0,0 +1,78 @@
#ifndef TIMEKEEPER_HPP
#define TIMEKEEPER_HPP
#include <Arduino.h>
#include <vector>
#include <algorithm>
#include <ArduinoJson.h>
#include <RTClib.h>
#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<ScheduledEvent> todaysEvents;
std::vector<ScheduledEvent> 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

View File

@@ -1,43 +1,4 @@
/*
Done Features:
- Initiate General Structure
- Add WiFi Support
- Add MQTT Support both for Subscribing and Publishing
- Add JSON support to handle MQTT messaging
- Add File Handling
- Add Melody Class with functions to Play/Pause/Stop etc.
- Add Main BellEngine
- Add custom Relay Timings (saved on-board)
- Add RTC support
- Add Timekeeper class, with functions to track time and call schedules
- Add OTA Update Functionality
- Add global logger with Mode Selection (None, Error, Warning, Info, Debug)
- Add Captive Portal / WiFi HotSpot
- Add NTP Time Sync
ToDo Features:
- Add reset to Factory Defaults button
- Add manual Sync-Time (for No-Connectivity Setups)
- Add the ability to report the list of melodies
- Add the ability to report a month's ScheduleEntry
- Add the ability to report the free space in SPIFFS.
- Add Bluetooth support
- Add WiFi Direct AP Support
- Add PCB Temperature Sensor
- 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.
- Counter per playback, to figure out which melody is the most played.
- Counter of items per Scheduler
*/
#include "logging.hpp" #include "logging.hpp"
#include <SD.h> #include <SD.h>
#include <FS.h> #include <FS.h>
#include <ETH.h> #include <ETH.h>
@@ -51,37 +12,54 @@ ToDo Features:
#include <string> #include <string>
#include <Wire.h> #include <Wire.h>
#include <Adafruit_PCF8574.h> #include <Adafruit_PCF8574.h>
#include "RTClib.h"
#include <WebServer.h> #include <WebServer.h>
#include <ESPAsyncWebServer.h> #include <ESPAsyncWebServer.h>
#include <WiFiManager.h> #include <WiFiManager.h>
#include <AsyncUDP.h>
#include <RTClib.h>
// Custom Classes
#include "timekeeper.hpp"
// Hardware Constructors: // Hardware Constructors:
Adafruit_PCF8574 relays; Adafruit_PCF8574 relays;
RTC_DS1307 rtc; // Wrapper function to connect timekeeper to your relays
void relayWrite(int relayIndex, int state) {
relays.digitalWrite(relayIndex, state);
}
// SD Card Chip Select: // SD Card Chip Select:
#define SD_CS 5 #define SD_CS 5
// Include Classes // Include Classes
#include "classes.hpp" #include "class_player.hpp"
// Class Constructors // Class Constructors
Timekeeper timekeeper;
AsyncMqttClient mqttClient; AsyncMqttClient mqttClient;
Player player; Player player;
std::vector<uint16_t> melody_steps; // holds the steps of the melody. Should move into bell Engine. std::vector<uint16_t> melody_steps; // holds the steps of the melody. Should move into bell Engine.
AsyncWebServer server(80); AsyncWebServer server(80);
AsyncWebSocket ws("/ws"); 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;
#include "config.h" #include "config.h"
#include "ota.hpp" #include "ota.hpp"
#include "functions.hpp" #include "functions.hpp"
#include "MQTT_Message_Handling.hpp" #include "commands_handling.hpp"
#include "MQTT_WiFi_Utilities.hpp" #include "MQTT_Functions.hpp"
#include "MQTT_Connection_Handling.hpp"
#include "WebSocket_Functions.hpp"
#include "PlaybackControls.hpp" #include "PlaybackControls.hpp"
#include "bellEngine.hpp" #include "bellEngine.hpp"
#include "dataLogging.hpp"
TaskHandle_t bellEngineHandle = NULL; TaskHandle_t bellEngineHandle = NULL;
TimerHandle_t schedulerTimer; TimerHandle_t schedulerTimer;
@@ -111,11 +89,12 @@ void setup()
// do nothing // do nothing
} }
// Initialize RTC // Initialize timekeeper with NO clock outputs
if (!rtc.begin()) { timekeeper.begin(); // No parameters needed
LOG_ERROR("Couldn't find RTC"); // Connect the timekeeper to your relay controller
while(true) delay(10); timekeeper.setRelayWriteFunction(relayWrite);
} timekeeper.setClockOutputs(5, 4);
// Initialize Networking and MQTT // Initialize Networking and MQTT
Network.onEvent(NetworkEvent); Network.onEvent(NetworkEvent);
@@ -135,7 +114,7 @@ void setup()
delay(100); delay(100);
checkForUpdates(); // checks for updates online checkForUpdates(); // checks for updates online
syncTimeWithNTP(); // syncs time from NTP Server setupUdpDiscovery();
delay(100); delay(100);
@@ -149,6 +128,7 @@ void setup()
xTaskCreatePinnedToCore(bellEngine,"bellEngine", 8192, NULL, 1, &bellEngineHandle, 1); xTaskCreatePinnedToCore(bellEngine,"bellEngine", 8192, NULL, 1, &bellEngineHandle, 1);
xTaskCreatePinnedToCore(durationTimer, "durationTimer", 8192, NULL, 2, NULL, 1); xTaskCreatePinnedToCore(durationTimer, "durationTimer", 8192, NULL, 2, NULL, 1);
xTaskCreatePinnedToCore(relayControlTask, "Relay Control", 2048, NULL, 2, NULL, 1); xTaskCreatePinnedToCore(relayControlTask, "Relay Control", 2048, NULL, 2, NULL, 1);
//xTaskCreatePinnedToCore(dataLogging, "dataLogging", 2048, NULL, 2, NULL, 1);
loadRelayTimings(); loadRelayTimings();
} }
@@ -156,5 +136,5 @@ void setup()
void loop() void loop()
{ {
//Serial.printf("b1:%d - b2:%d - Bell 1 Load: %d \n",strikeCounters[0], strikeCounters[1], bellLoad[0]);
} }