Compare commits
17 Commits
mqtt_ssl_f
...
9f07e9ea39
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f07e9ea39 | |||
| 3d184773c1 | |||
| 953b5bd07d | |||
| db57b355b9 | |||
| 0f0b67cab9 | |||
| 8d397c6dd5 | |||
| 7d9bc42078 | |||
| b04590d270 | |||
| a7f1bd1667 | |||
| f286abb023 | |||
| c9f1e8e4ae | |||
| 06891e8d82 | |||
| d1835beff5 | |||
| 470d7bfacc | |||
| cc0bec97b5 | |||
| d6f105319d | |||
| fce760ebe0 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -7,3 +7,4 @@ vesper/CLAUDE.md
|
|||||||
vesper/flutter/
|
vesper/flutter/
|
||||||
vesper/docs_manual/
|
vesper/docs_manual/
|
||||||
Doxyfile
|
Doxyfile
|
||||||
|
vesper/.claude/
|
||||||
139
vesper/HEARTBEAT_FEATURE.md
Normal file
139
vesper/HEARTBEAT_FEATURE.md
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
# 💓 MQTT Heartbeat Feature
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Implemented a **retained MQTT heartbeat** system that sends periodic status updates every 30 seconds when the controller is connected to MQTT.
|
||||||
|
|
||||||
|
## What It Does
|
||||||
|
|
||||||
|
### Heartbeat Message
|
||||||
|
Every 30 seconds, the controller publishes a **retained** message to:
|
||||||
|
```
|
||||||
|
vesper/{deviceID}/status/heartbeat
|
||||||
|
```
|
||||||
|
|
||||||
|
### Message Format
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "INFO",
|
||||||
|
"type": "heartbeat",
|
||||||
|
"payload": {
|
||||||
|
"device_id": "VESPER-ABC123",
|
||||||
|
"firmware_version": "130",
|
||||||
|
"timestamp": "Uptime: 5h 23m 45s",
|
||||||
|
"ip_address": "192.168.1.100",
|
||||||
|
"gateway": "192.168.1.1",
|
||||||
|
"uptime_ms": 19425000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Features
|
||||||
|
✅ **Retained Message** - Only the LAST heartbeat stays on the broker
|
||||||
|
✅ **Auto-Start** - Begins when MQTT connects
|
||||||
|
✅ **Auto-Stop** - Stops when MQTT disconnects
|
||||||
|
✅ **30-Second Interval** - Periodic updates
|
||||||
|
✅ **First Beat Immediate** - Sends first heartbeat right after connecting
|
||||||
|
✅ **QoS 1** - Reliable delivery
|
||||||
|
|
||||||
|
## Why This is Awesome
|
||||||
|
|
||||||
|
### For Your Flutter App
|
||||||
|
1. **Immediate Status** - Any new connection gets the last known status instantly
|
||||||
|
2. **Stale Detection** - Can detect if controller went offline (timestamp too old)
|
||||||
|
3. **Device Discovery** - Apps can subscribe to `vesper/+/status/heartbeat` to find all controllers
|
||||||
|
4. **No Polling** - Just subscribe once and get automatic updates
|
||||||
|
|
||||||
|
### Example App Logic
|
||||||
|
```dart
|
||||||
|
// Subscribe to heartbeat
|
||||||
|
mqtt.subscribe('vesper/DEVICE-123/status/heartbeat');
|
||||||
|
|
||||||
|
// On message received
|
||||||
|
if (heartbeat.uptime_ms > lastSeen.uptime_ms + 120000) {
|
||||||
|
// No heartbeat for 2+ minutes = controller offline
|
||||||
|
showOfflineWarning();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
### Files Modified
|
||||||
|
1. **MQTTAsyncClient.hpp** - Added heartbeat timer and methods
|
||||||
|
2. **MQTTAsyncClient.cpp** - Implemented heartbeat logic
|
||||||
|
3. **Networking.hpp** - Added `getGateway()` method
|
||||||
|
4. **Networking.cpp** - Implemented `getGateway()` method
|
||||||
|
|
||||||
|
### New Methods Added
|
||||||
|
```cpp
|
||||||
|
void startHeartbeat(); // Start 30s periodic timer
|
||||||
|
void stopHeartbeat(); // Stop timer
|
||||||
|
void publishHeartbeat(); // Build and publish message
|
||||||
|
void heartbeatTimerCallback(); // Timer callback handler
|
||||||
|
```
|
||||||
|
|
||||||
|
### Timer Configuration
|
||||||
|
- **Type**: FreeRTOS Software Timer
|
||||||
|
- **Mode**: Auto-reload (repeating)
|
||||||
|
- **Period**: 30,000 ms (30 seconds)
|
||||||
|
- **Core**: Runs on Core 0 (MQTT task core)
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### How to Test
|
||||||
|
1. Flash the firmware
|
||||||
|
2. Subscribe to the heartbeat topic:
|
||||||
|
```bash
|
||||||
|
mosquitto_sub -h YOUR_BROKER -t "vesper/+/status/heartbeat" -v
|
||||||
|
```
|
||||||
|
3. You should see heartbeats every 30 seconds
|
||||||
|
4. Disconnect the controller - the last message stays retained
|
||||||
|
5. Reconnect - you'll immediately see the last retained message, then new ones every 30s
|
||||||
|
|
||||||
|
### Expected Serial Output
|
||||||
|
```
|
||||||
|
💓 Starting MQTT heartbeat (every 30 seconds)
|
||||||
|
💓 Published heartbeat (retained) - IP: 192.168.1.100, Uptime: 45000ms
|
||||||
|
💓 Published heartbeat (retained) - IP: 192.168.1.100, Uptime: 75000ms
|
||||||
|
❤️ Stopped MQTT heartbeat (when MQTT disconnects)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Future Enhancements (Optional)
|
||||||
|
|
||||||
|
### Possible Additions:
|
||||||
|
- Add actual RTC timestamp (instead of just uptime)
|
||||||
|
- Add WiFi signal strength (RSSI) for WiFi connections
|
||||||
|
- Add free heap memory
|
||||||
|
- Add current playback status
|
||||||
|
- Add bell configuration version/hash
|
||||||
|
|
||||||
|
### Implementation Example:
|
||||||
|
```cpp
|
||||||
|
// In publishHeartbeat()
|
||||||
|
payload["rssi"] = WiFi.RSSI(); // WiFi signal strength
|
||||||
|
payload["free_heap"] = ESP.getFreeHeap();
|
||||||
|
payload["playback_active"] = player.isPlaying;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Current Settings (can be changed in MQTTAsyncClient.hpp):
|
||||||
|
```cpp
|
||||||
|
static const unsigned long HEARTBEAT_INTERVAL = 30000; // 30 seconds
|
||||||
|
```
|
||||||
|
|
||||||
|
To change interval to 60 seconds:
|
||||||
|
```cpp
|
||||||
|
static const unsigned long HEARTBEAT_INTERVAL = 60000; // 60 seconds
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- Message is published with **QoS 1** (at least once delivery)
|
||||||
|
- Message is **retained** (broker keeps last message)
|
||||||
|
- Timer starts automatically when MQTT connects
|
||||||
|
- Timer stops automatically when MQTT disconnects
|
||||||
|
- First heartbeat is sent immediately upon connection (no 30s wait)
|
||||||
|
|
||||||
|
---
|
||||||
|
**Feature Implemented**: January 2025
|
||||||
|
**Version**: Firmware v130+
|
||||||
|
**Status**: ✅ Production Ready
|
||||||
@@ -74,7 +74,7 @@ BellEngine::~BellEngine() {
|
|||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
void BellEngine::begin() {
|
void BellEngine::begin() {
|
||||||
LOG_DEBUG("Initializing BellEngine with high-precision timing");
|
LOG_DEBUG("Initializing BellEngine...");
|
||||||
|
|
||||||
// Create engine task with HIGHEST priority on dedicated Core 1
|
// Create engine task with HIGHEST priority on dedicated Core 1
|
||||||
// This ensures maximum performance and timing precision
|
// This ensures maximum performance and timing precision
|
||||||
@@ -88,7 +88,7 @@ void BellEngine::begin() {
|
|||||||
1 // 💻 Pin to Core 1 (dedicated)
|
1 // 💻 Pin to Core 1 (dedicated)
|
||||||
);
|
);
|
||||||
|
|
||||||
LOG_INFO("BellEngine initialized - Ready for MAXIMUM PRECISION! 🎯");
|
LOG_INFO("BellEngine initialized !");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -120,18 +120,18 @@ void BellEngine::start() {
|
|||||||
return; // ⛔ Early exit if no melody data
|
return; // ⛔ Early exit if no melody data
|
||||||
}
|
}
|
||||||
|
|
||||||
LOG_INFO("🚀 BellEngine IGNITION - Starting precision playback");
|
LOG_INFO("🚀 BellEngine Ignition - Starting precision playback");
|
||||||
_emergencyStop.store(false); // ✅ Clear any emergency stop state
|
_emergencyStop.store(false); // ✅ Clear any emergency stop state
|
||||||
_engineRunning.store(true); // ✅ Activate the engine atomically
|
_engineRunning.store(true); // ✅ Activate the engine atomically
|
||||||
}
|
}
|
||||||
|
|
||||||
void BellEngine::stop() {
|
void BellEngine::stop() {
|
||||||
LOG_INFO("BellEngine stopping gracefully");
|
LOG_INFO("BellEngine - Stopping Gracefully");
|
||||||
_engineRunning.store(false);
|
_engineRunning.store(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
void BellEngine::emergencyStop() {
|
void BellEngine::emergencyStop() {
|
||||||
LOG_INFO("🛑 EMERGENCY STOP ACTIVATED");
|
LOG_INFO("BellEngine - 🛑 Forcing Stop Immediately");
|
||||||
_emergencyStop.store(true);
|
_emergencyStop.store(true);
|
||||||
_engineRunning.store(false);
|
_engineRunning.store(false);
|
||||||
emergencyShutdown();
|
emergencyShutdown();
|
||||||
@@ -142,7 +142,7 @@ void BellEngine::setMelodyData(const std::vector<uint16_t>& melodySteps) {
|
|||||||
_melodySteps = melodySteps;
|
_melodySteps = melodySteps;
|
||||||
_melodyDataReady.store(true);
|
_melodyDataReady.store(true);
|
||||||
portEXIT_CRITICAL(&_melodyMutex);
|
portEXIT_CRITICAL(&_melodyMutex);
|
||||||
LOG_DEBUG("BellEngine loaded melody: %d steps", melodySteps.size());
|
LOG_DEBUG("BellEngine - Loaded melody: %d steps", melodySteps.size());
|
||||||
}
|
}
|
||||||
|
|
||||||
void BellEngine::clearMelodyData() {
|
void BellEngine::clearMelodyData() {
|
||||||
@@ -150,7 +150,7 @@ void BellEngine::clearMelodyData() {
|
|||||||
_melodySteps.clear();
|
_melodySteps.clear();
|
||||||
_melodyDataReady.store(false);
|
_melodyDataReady.store(false);
|
||||||
portEXIT_CRITICAL(&_melodyMutex);
|
portEXIT_CRITICAL(&_melodyMutex);
|
||||||
LOG_DEBUG("BellEngine melody data cleared");
|
LOG_DEBUG("BellEngine - Melody data cleared");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ================== CRITICAL TIMING SECTION ==================
|
// ================== CRITICAL TIMING SECTION ==================
|
||||||
@@ -158,7 +158,7 @@ void BellEngine::clearMelodyData() {
|
|||||||
|
|
||||||
void BellEngine::engineTask(void* parameter) {
|
void BellEngine::engineTask(void* parameter) {
|
||||||
BellEngine* engine = static_cast<BellEngine*>(parameter);
|
BellEngine* engine = static_cast<BellEngine*>(parameter);
|
||||||
LOG_DEBUG("🔥 BellEngine task started on Core %d with MAXIMUM priority", xPortGetCoreID());
|
LOG_DEBUG("BellEngine - 🔥 Engine task started on Core %d with MAXIMUM priority", xPortGetCoreID());
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
if (engine->_engineRunning.load() && !engine->_emergencyStop.load()) {
|
if (engine->_engineRunning.load() && !engine->_emergencyStop.load()) {
|
||||||
@@ -186,7 +186,7 @@ void BellEngine::engineLoop() {
|
|||||||
|
|
||||||
// Pause handling AFTER complete loop - never interrupt mid-melody!
|
// Pause handling AFTER complete loop - never interrupt mid-melody!
|
||||||
while (_player.isPaused && _player.isPlaying && !_player.hardStop) {
|
while (_player.isPaused && _player.isPlaying && !_player.hardStop) {
|
||||||
LOG_DEBUG("⏸️ Pausing between melody loops");
|
LOG_VERBOSE("BellEngine - ⏸️ Pausing between melody loops");
|
||||||
vTaskDelay(pdMS_TO_TICKS(10)); // Wait during pause
|
vTaskDelay(pdMS_TO_TICKS(10)); // Wait during pause
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,24 +207,32 @@ void BellEngine::playbackLoop() {
|
|||||||
portEXIT_CRITICAL(&_melodyMutex);
|
portEXIT_CRITICAL(&_melodyMutex);
|
||||||
|
|
||||||
if (melodySteps.empty()) {
|
if (melodySteps.empty()) {
|
||||||
LOG_ERROR("Empty melody in playback loop!");
|
LOG_ERROR("BellEngine - ❌ Empty melody in playback loop!");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
LOG_DEBUG("🎵 Starting melody loop (%d steps)", melodySteps.size());
|
LOG_DEBUG("BellEngine - 🎵 Starting melody loop (%d steps)", melodySteps.size());
|
||||||
|
|
||||||
// CRITICAL TIMING LOOP - Complete the entire melody without interruption
|
// CRITICAL TIMING LOOP - Complete the entire melody without interruption
|
||||||
for (uint16_t note : melodySteps) {
|
for (uint16_t note : melodySteps) {
|
||||||
// Emergency exit check (only emergency stops can interrupt mid-loop)
|
// Emergency exit check (only emergency stops can interrupt mid-loop)
|
||||||
if (_emergencyStop.load() || _player.hardStop) {
|
if (_emergencyStop.load() || _player.hardStop) {
|
||||||
LOG_DEBUG("Emergency exit from playback loop");
|
LOG_DEBUG("BellEngine - Emergency exit from playback loop");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Activate note with MAXIMUM PRECISION
|
// Activate note with MAXIMUM PRECISION
|
||||||
activateNote(note);
|
activateNote(note);
|
||||||
|
|
||||||
// Precise timing delay
|
// Precise timing delay - validate speed to prevent division by zero
|
||||||
|
// I THINK this should be moved outside the Bell Engine
|
||||||
|
if (_player.speed == 0) {
|
||||||
|
LOG_ERROR("BellEngine - ❌ Invalid Speed (0) detected, stopping playback");
|
||||||
|
_player.hardStop = true;
|
||||||
|
_engineRunning.store(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
uint32_t tempoMicros = _player.speed * 1000; // Convert ms to microseconds
|
uint32_t tempoMicros = _player.speed * 1000; // Convert ms to microseconds
|
||||||
preciseDelay(tempoMicros);
|
preciseDelay(tempoMicros);
|
||||||
}
|
}
|
||||||
@@ -232,7 +240,11 @@ void BellEngine::playbackLoop() {
|
|||||||
// Mark segment completion and notify Player
|
// Mark segment completion and notify Player
|
||||||
_player.segmentCmpltTime = millis();
|
_player.segmentCmpltTime = millis();
|
||||||
_player.onMelodyLoopCompleted(); // 🔥 Notify Player that melody actually finished!
|
_player.onMelodyLoopCompleted(); // 🔥 Notify Player that melody actually finished!
|
||||||
LOG_DEBUG("🎵 Melody loop completed with PRECISION");
|
if ((_player.continuous_loop && _player.segment_duration == 0) || _player.total_duration == 0) {
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(500)); //Give Player time to pause/stop
|
||||||
|
LOG_VERBOSE("BellEngine - Loop completed in SINGLE Mode - waiting for Player to handle pause/stop");
|
||||||
|
}
|
||||||
|
LOG_DEBUG("BellEngine - 🎵 Melody loop completed with PRECISION");
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,26 +268,26 @@ void BellEngine::activateNote(uint16_t note) {
|
|||||||
|
|
||||||
// Additional safety check to prevent underflow crashes
|
// Additional safety check to prevent underflow crashes
|
||||||
if (bellIndex >= 255) {
|
if (bellIndex >= 255) {
|
||||||
LOG_ERROR("🚨 UNDERFLOW ERROR: bellIndex underflow for noteIndex %d", noteIndex);
|
LOG_ERROR("BellEngine - 🚨 UNDERFLOW ERROR: bellIndex underflow for noteIndex %d", noteIndex);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bounds check (CRITICAL SAFETY)
|
// Bounds check (CRITICAL SAFETY)
|
||||||
if (bellIndex >= 16) {
|
if (bellIndex >= 16) {
|
||||||
LOG_ERROR("🚨 BOUNDS ERROR: bellIndex %d >= 16", bellIndex);
|
LOG_ERROR("BellEngine - 🚨 BOUNDS ERROR: bellIndex %d >= 16", bellIndex);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for duplicate bell firing in this note
|
// Check for duplicate bell firing in this note
|
||||||
if (bellFired[bellIndex]) {
|
if (bellFired[bellIndex]) {
|
||||||
LOG_DEBUG("⚠️ DUPLICATE BELL: Skipping duplicate firing of bell %d for note %d", bellIndex, noteIndex);
|
LOG_DEBUG("BellEngine - ⚠️ DUPLICATE BELL: Skipping duplicate firing of bell %d for note %d", bellIndex, noteIndex);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if bell is configured (OutputManager will validate this)
|
// Check if bell is configured (OutputManager will validate this)
|
||||||
uint8_t physicalOutput = _outputManager.getPhysicalOutput(bellIndex);
|
uint8_t physicalOutput = _outputManager.getPhysicalOutput(bellIndex);
|
||||||
if (physicalOutput == 255) {
|
if (physicalOutput == 255) {
|
||||||
LOG_DEBUG("⚠️ UNCONFIGURED: Bell %d not configured, skipping", bellIndex);
|
LOG_DEBUG("BellEngine - ⚠️ UNCONFIGURED: Bell %d not configured, skipping", bellIndex);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,7 +298,7 @@ void BellEngine::activateNote(uint16_t note) {
|
|||||||
uint16_t durationMs = _configManager.getBellDuration(bellIndex);
|
uint16_t durationMs = _configManager.getBellDuration(bellIndex);
|
||||||
|
|
||||||
// Add to batch firing list
|
// Add to batch firing list
|
||||||
bellDurations.push_back({bellIndex, durationMs});
|
bellDurations.push_back({physicalOutput, durationMs});
|
||||||
|
|
||||||
// Add to notification list (convert to 1-indexed for display)
|
// Add to notification list (convert to 1-indexed for display)
|
||||||
firedBellIndices.push_back(bellIndex + 1);
|
firedBellIndices.push_back(bellIndex + 1);
|
||||||
@@ -294,17 +306,18 @@ void BellEngine::activateNote(uint16_t note) {
|
|||||||
// Record telemetry
|
// Record telemetry
|
||||||
_telemetry.recordBellStrike(bellIndex);
|
_telemetry.recordBellStrike(bellIndex);
|
||||||
|
|
||||||
LOG_VERBOSE("🔨 STRIKE! Note:%d → Bell:%d for %dms", noteIndex, bellIndex, durationMs);
|
LOG_VERBOSE("BellEngine - 🔨 STRIKE! Note:%d → Bell:%d for %dms", noteIndex, bellIndex, durationMs);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🚀 FIRE ALL BELLS SIMULTANEOUSLY!
|
// 🚀 FIRE ALL BELLS SIMULTANEOUSLY!
|
||||||
if (!bellDurations.empty()) {
|
if (!bellDurations.empty()) {
|
||||||
_outputManager.fireOutputsBatchForDuration(bellDurations);
|
_outputManager.fireOutputsBatchForDuration(bellDurations);
|
||||||
LOG_VERBOSE("🔥🔥 BATCH FIRED %d bells SIMULTANEOUSLY!", bellDurations.size());
|
LOG_VERBOSE("BellEngine - 🔥 Batch Fired %d bells Simultaneously !", bellDurations.size());
|
||||||
|
|
||||||
// 🔔 NOTIFY WEBSOCKET CLIENTS OF BELL DINGS!
|
// 🔔 NOTIFY WEBSOCKET CLIENTS OF BELL DINGS!
|
||||||
notifyBellsFired(firedBellIndices);
|
// * deactivated currently, since unstable and causes performance issues *
|
||||||
|
// notifyBellsFired(firedBellIndices);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,7 +339,7 @@ void BellEngine::preciseDelay(uint32_t microseconds) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void BellEngine::emergencyShutdown() {
|
void BellEngine::emergencyShutdown() {
|
||||||
LOG_INFO("🚨 EMERGENCY SHUTDOWN - Using OutputManager");
|
LOG_INFO("BellEngine - 🚨 Emergency Shutdown - Notifying OutputManager");
|
||||||
_outputManager.emergencyShutdown();
|
_outputManager.emergencyShutdown();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,10 +364,10 @@ void BellEngine::notifyBellsFired(const std::vector<uint8_t>& bellIndices) {
|
|||||||
// Send notification to WebSocket clients only (not MQTT)
|
// Send notification to WebSocket clients only (not MQTT)
|
||||||
_communicationManager->broadcastToAllWebSocketClients(dingMsg);
|
_communicationManager->broadcastToAllWebSocketClients(dingMsg);
|
||||||
|
|
||||||
LOG_DEBUG("🔔 DING notification sent for %d bells", bellIndices.size());
|
LOG_DEBUG("BellEngine - 🔔 DING notification sent for %d bells", bellIndices.size());
|
||||||
|
|
||||||
} catch (...) {
|
} catch (...) {
|
||||||
LOG_ERROR("Failed to send ding notification");
|
LOG_WARNING("BellEngine - ❌ Failed to send ding notification");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
253
vesper/src/BuiltInMelodies/BuiltInMelodies.hpp
Normal file
253
vesper/src/BuiltInMelodies/BuiltInMelodies.hpp
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
/*
|
||||||
|
* ═══════════════════════════════════════════════════════════════════════════════════
|
||||||
|
* BUILTINMELODIES.HPP - Firmware-Baked Melody Library
|
||||||
|
* ═══════════════════════════════════════════════════════════════════════════════════
|
||||||
|
*
|
||||||
|
* 🎵 BUILT-IN MELODY LIBRARY FOR VESPER 🎵
|
||||||
|
*
|
||||||
|
* This file contains melodies baked directly into the firmware, eliminating
|
||||||
|
* the need for SD card downloads. Each melody is stored in PROGMEM to save RAM.
|
||||||
|
*
|
||||||
|
* 🏗️ ARCHITECTURE:
|
||||||
|
* • Melodies stored in PROGMEM (Flash memory, not RAM)
|
||||||
|
* • Each melody step is 2 bytes (uint16_t bitmask)
|
||||||
|
* • Metadata includes name, UID, default speed
|
||||||
|
* • Easy to add new melodies
|
||||||
|
*
|
||||||
|
* 📦 STORAGE EFFICIENCY:
|
||||||
|
* • Small melodies (~30 steps = 60 bytes)
|
||||||
|
* • Large melodies (~200 steps = 400 bytes)
|
||||||
|
* • 40 melodies average = ~6-10KB total (Flash, not RAM!)
|
||||||
|
*
|
||||||
|
* 🎶 MELODY FORMAT:
|
||||||
|
* Each uint16_t is a bitmask:
|
||||||
|
* - Bit 0-15: Which bells/notes to activate
|
||||||
|
* - Example: 0x0001 = Bell 0, 0x0003 = Bells 0+1, 0x8000 = Bell 15
|
||||||
|
*
|
||||||
|
* 📋 VERSION: 1.0
|
||||||
|
* 📅 DATE: 2025-12-28
|
||||||
|
* 👨💻 AUTHOR: Advanced Bell Systems
|
||||||
|
* ═══════════════════════════════════════════════════════════════════════════════════
|
||||||
|
*/
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include <vector>
|
||||||
|
#include <pgmspace.h>
|
||||||
|
|
||||||
|
namespace BuiltInMelodies {
|
||||||
|
|
||||||
|
// ═════════════════════════════════════════════════════════════════════════════════
|
||||||
|
// MELODY METADATA STRUCTURE
|
||||||
|
// ═════════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
struct MelodyInfo {
|
||||||
|
const char* name; // Display name
|
||||||
|
const char* uid; // Unique identifier
|
||||||
|
const uint16_t* data; // Pointer to melody data in PROGMEM
|
||||||
|
uint16_t stepCount; // Number of steps
|
||||||
|
uint16_t defaultSpeed; // Default speed in milliseconds per beat
|
||||||
|
};
|
||||||
|
|
||||||
|
// ═════════════════════════════════════════════════════════════════════════════════
|
||||||
|
// EXAMPLE MELODIES - Add your melodies here!
|
||||||
|
// ═════════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// Example: Simple Scale (C-D-E-F-G-A-B-C)
|
||||||
|
const uint16_t PROGMEM melody_simple_scale[] = {
|
||||||
|
0x0001, 0x0002, 0x0004, 0x0008,
|
||||||
|
0x0010, 0x0020, 0x0040, 0x0080
|
||||||
|
};
|
||||||
|
|
||||||
|
// Example: Happy Birthday (simplified)
|
||||||
|
const uint16_t PROGMEM melody_happy_birthday[] = {
|
||||||
|
0x0001, 0x0001, 0x0002, 0x0001,
|
||||||
|
0x0008, 0x0004, 0x0001, 0x0001,
|
||||||
|
0x0002, 0x0001, 0x0010, 0x0008,
|
||||||
|
0x0001, 0x0001, 0x0080, 0x0008,
|
||||||
|
0x0004, 0x0002, 0x0040, 0x0040,
|
||||||
|
0x0008, 0x0004, 0x0002
|
||||||
|
};
|
||||||
|
|
||||||
|
// Example: Jingle Bells (simplified)
|
||||||
|
const uint16_t PROGMEM melody_jingle_bells[] = {
|
||||||
|
0x0004, 0x0004, 0x0004, 0x0000,
|
||||||
|
0x0004, 0x0004, 0x0004, 0x0000,
|
||||||
|
0x0004, 0x0008, 0x0001, 0x0002,
|
||||||
|
0x0004, 0x0000, 0x0000, 0x0000,
|
||||||
|
0x0008, 0x0008, 0x0008, 0x0008,
|
||||||
|
0x0008, 0x0004, 0x0004, 0x0004,
|
||||||
|
0x0002, 0x0002, 0x0004, 0x0002,
|
||||||
|
0x0008, 0x0000, 0x0000, 0x0000
|
||||||
|
};
|
||||||
|
|
||||||
|
// Example: Westminster Chimes
|
||||||
|
const uint16_t PROGMEM melody_westminster_chimes[] = {
|
||||||
|
0x0008, 0x0004, 0x0002, 0x0001,
|
||||||
|
0x0001, 0x0002, 0x0008, 0x0004,
|
||||||
|
0x0008, 0x0001, 0x0002, 0x0004,
|
||||||
|
0x0002, 0x0008, 0x0004, 0x0001
|
||||||
|
};
|
||||||
|
|
||||||
|
// Example: Alarm Pattern
|
||||||
|
const uint16_t PROGMEM melody_alarm[] = {
|
||||||
|
0x0001, 0x0080, 0x0001, 0x0080,
|
||||||
|
0x0001, 0x0080, 0x0001, 0x0080,
|
||||||
|
0x0000, 0x0000, 0x0001, 0x0080,
|
||||||
|
0x0001, 0x0080, 0x0001, 0x0080
|
||||||
|
};
|
||||||
|
|
||||||
|
// Example: Doorbell
|
||||||
|
const uint16_t PROGMEM melody_doorbell[] = {
|
||||||
|
0x0004, 0x0008, 0x0004, 0x0008
|
||||||
|
};
|
||||||
|
|
||||||
|
// Example: Single Bell Test
|
||||||
|
const uint16_t PROGMEM melody_single_bell[] = {
|
||||||
|
0x0001
|
||||||
|
};
|
||||||
|
|
||||||
|
// ═════════════════════════════════════════════════════════════════════════════════
|
||||||
|
// MELODY LIBRARY - Array of all built-in melodies
|
||||||
|
// ═════════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
const MelodyInfo MELODY_LIBRARY[] = {
|
||||||
|
{
|
||||||
|
"Simple Scale",
|
||||||
|
"builtin_scale",
|
||||||
|
melody_simple_scale,
|
||||||
|
sizeof(melody_simple_scale) / sizeof(uint16_t),
|
||||||
|
200 // 200ms per beat
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Happy Birthday",
|
||||||
|
"builtin_happy_birthday",
|
||||||
|
melody_happy_birthday,
|
||||||
|
sizeof(melody_happy_birthday) / sizeof(uint16_t),
|
||||||
|
250
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Jingle Bells",
|
||||||
|
"builtin_jingle_bells",
|
||||||
|
melody_jingle_bells,
|
||||||
|
sizeof(melody_jingle_bells) / sizeof(uint16_t),
|
||||||
|
180
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Westminster Chimes",
|
||||||
|
"builtin_westminster",
|
||||||
|
melody_westminster_chimes,
|
||||||
|
sizeof(melody_westminster_chimes) / sizeof(uint16_t),
|
||||||
|
400
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Alarm",
|
||||||
|
"builtin_alarm",
|
||||||
|
melody_alarm,
|
||||||
|
sizeof(melody_alarm) / sizeof(uint16_t),
|
||||||
|
150
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Doorbell",
|
||||||
|
"builtin_doorbell",
|
||||||
|
melody_doorbell,
|
||||||
|
sizeof(melody_doorbell) / sizeof(uint16_t),
|
||||||
|
300
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Single Bell Test",
|
||||||
|
"builtin_single_bell",
|
||||||
|
melody_single_bell,
|
||||||
|
sizeof(melody_single_bell) / sizeof(uint16_t),
|
||||||
|
100
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const uint16_t MELODY_COUNT = sizeof(MELODY_LIBRARY) / sizeof(MelodyInfo);
|
||||||
|
|
||||||
|
// ═════════════════════════════════════════════════════════════════════════════════
|
||||||
|
// HELPER FUNCTIONS
|
||||||
|
// ═════════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Check if a UID is a built-in melody
|
||||||
|
* @param uid The melody UID to check
|
||||||
|
* @return true if it's a built-in melody (starts with "builtin_")
|
||||||
|
*/
|
||||||
|
inline bool isBuiltInMelody(const String& uid) {
|
||||||
|
return uid.startsWith("builtin_");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Find a built-in melody by UID
|
||||||
|
* @param uid The melody UID to find
|
||||||
|
* @return Pointer to MelodyInfo if found, nullptr otherwise
|
||||||
|
*/
|
||||||
|
inline const MelodyInfo* findMelodyByUID(const String& uid) {
|
||||||
|
for (uint16_t i = 0; i < MELODY_COUNT; i++) {
|
||||||
|
if (uid == MELODY_LIBRARY[i].uid) {
|
||||||
|
return &MELODY_LIBRARY[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Load a built-in melody into a vector
|
||||||
|
* @param uid The melody UID to load
|
||||||
|
* @param melodySteps Vector to fill with melody data
|
||||||
|
* @return true if melody was found and loaded, false otherwise
|
||||||
|
*/
|
||||||
|
inline bool loadBuiltInMelody(const String& uid, std::vector<uint16_t>& melodySteps) {
|
||||||
|
const MelodyInfo* melody = findMelodyByUID(uid);
|
||||||
|
if (!melody) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resize vector and copy data from PROGMEM
|
||||||
|
melodySteps.resize(melody->stepCount);
|
||||||
|
for (uint16_t i = 0; i < melody->stepCount; i++) {
|
||||||
|
melodySteps[i] = pgm_read_word(&(melody->data[i]));
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Get list of all built-in melodies as JSON string
|
||||||
|
* @return JSON array string of melody names and UIDs
|
||||||
|
*/
|
||||||
|
inline String getBuiltInMelodiesJSON() {
|
||||||
|
String json = "[";
|
||||||
|
for (uint16_t i = 0; i < MELODY_COUNT; i++) {
|
||||||
|
if (i > 0) json += ",";
|
||||||
|
json += "{";
|
||||||
|
json += "\"name\":\"" + String(MELODY_LIBRARY[i].name) + "\",";
|
||||||
|
json += "\"uid\":\"" + String(MELODY_LIBRARY[i].uid) + "\",";
|
||||||
|
json += "\"steps\":" + String(MELODY_LIBRARY[i].stepCount) + ",";
|
||||||
|
json += "\"speed\":" + String(MELODY_LIBRARY[i].defaultSpeed);
|
||||||
|
json += "}";
|
||||||
|
}
|
||||||
|
json += "]";
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace BuiltInMelodies
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════════
|
||||||
|
// USAGE EXAMPLE:
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════════
|
||||||
|
/*
|
||||||
|
// Check if melody is built-in
|
||||||
|
if (BuiltInMelodies::isBuiltInMelody(uid)) {
|
||||||
|
// Load it from firmware
|
||||||
|
std::vector<uint16_t> melodyData;
|
||||||
|
if (BuiltInMelodies::loadBuiltInMelody(uid, melodyData)) {
|
||||||
|
// Use melodyData...
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Load from SD card as usual
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════════
|
||||||
187
vesper/src/BuiltInMelodies/README.md
Normal file
187
vesper/src/BuiltInMelodies/README.md
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
# Built-In Melodies System
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The built-in melodies system allows you to bake melodies directly into the firmware, eliminating the need for SD card downloads. Melodies are stored in **PROGMEM** (Flash memory), so they don't consume precious RAM.
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
1. **Check**: When a melody is requested, the Player first checks if the UID starts with `builtin_`
|
||||||
|
2. **Load**: If it's built-in, the melody is loaded from Flash memory (PROGMEM)
|
||||||
|
3. **Fallback**: If not built-in, it loads from SD card as usual
|
||||||
|
|
||||||
|
## Adding New Melodies
|
||||||
|
|
||||||
|
### Step 1: Create Your Melody Data
|
||||||
|
|
||||||
|
Each melody step is a **2-byte (uint16_t) bitmask** representing which bells to activate:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// Example: Simple pattern
|
||||||
|
const uint16_t PROGMEM melody_my_tune[] = {
|
||||||
|
0x0001, // Bell 0
|
||||||
|
0x0002, // Bell 1
|
||||||
|
0x0004, // Bell 2
|
||||||
|
0x0008, // Bell 3
|
||||||
|
0x0003, // Bells 0+1 together
|
||||||
|
0x000F // Bells 0+1+2+3 together
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Bitmask Reference:**
|
||||||
|
- `0x0001` = Bell 0 (bit 0)
|
||||||
|
- `0x0002` = Bell 1 (bit 1)
|
||||||
|
- `0x0004` = Bell 2 (bit 2)
|
||||||
|
- `0x0008` = Bell 3 (bit 3)
|
||||||
|
- `0x0010` = Bell 4 (bit 4)
|
||||||
|
- `0x0020` = Bell 5 (bit 5)
|
||||||
|
- `0x0040` = Bell 6 (bit 6)
|
||||||
|
- `0x0080` = Bell 7 (bit 7)
|
||||||
|
- `0x0100` = Bell 8 (bit 8)
|
||||||
|
- ... up to `0x8000` = Bell 15 (bit 15)
|
||||||
|
- `0x0000` = Silence/rest
|
||||||
|
|
||||||
|
**Combining Bells:**
|
||||||
|
- `0x0003` = Bells 0+1 (0x0001 | 0x0002)
|
||||||
|
- `0x0005` = Bells 0+2 (0x0001 | 0x0004)
|
||||||
|
- `0x000F` = Bells 0+1+2+3
|
||||||
|
- `0xFFFF` = All 16 bells
|
||||||
|
|
||||||
|
### Step 2: Add to BuiltInMelodies.hpp
|
||||||
|
|
||||||
|
Open `src/BuiltInMelodies/BuiltInMelodies.hpp` and:
|
||||||
|
|
||||||
|
1. **Add your melody array:**
|
||||||
|
```cpp
|
||||||
|
// Your new melody
|
||||||
|
const uint16_t PROGMEM melody_my_awesome_tune[] = {
|
||||||
|
0x0001, 0x0002, 0x0004, 0x0008,
|
||||||
|
0x0010, 0x0020, 0x0040, 0x0080,
|
||||||
|
// ... up to 200 steps
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Add to MELODY_LIBRARY array:**
|
||||||
|
```cpp
|
||||||
|
const MelodyInfo MELODY_LIBRARY[] = {
|
||||||
|
// ... existing melodies ...
|
||||||
|
|
||||||
|
// Your new melody
|
||||||
|
{
|
||||||
|
"My Awesome Tune", // Display name
|
||||||
|
"builtin_my_awesome_tune", // UID (must start with "builtin_")
|
||||||
|
melody_my_awesome_tune, // Data array
|
||||||
|
sizeof(melody_my_awesome_tune) / sizeof(uint16_t), // Step count
|
||||||
|
200 // Default speed in milliseconds per beat
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Use Your Melody
|
||||||
|
|
||||||
|
Send a play command with the built-in melody UID:
|
||||||
|
|
||||||
|
**MQTT:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"group": "playback",
|
||||||
|
"action": "play",
|
||||||
|
"uid": "builtin_my_awesome_tune",
|
||||||
|
"name": "My Awesome Tune",
|
||||||
|
"speed": 200
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**WebSocket/HTTP:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"group": "playback",
|
||||||
|
"action": "play",
|
||||||
|
"uid": "builtin_my_awesome_tune",
|
||||||
|
"name": "My Awesome Tune",
|
||||||
|
"speed": 200
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pre-Loaded Melodies
|
||||||
|
|
||||||
|
The following melodies are already built-in:
|
||||||
|
|
||||||
|
| UID | Name | Steps | Default Speed |
|
||||||
|
|-----|------|-------|---------------|
|
||||||
|
| `builtin_scale` | Simple Scale | 8 | 200ms |
|
||||||
|
| `builtin_happy_birthday` | Happy Birthday | 23 | 250ms |
|
||||||
|
| `builtin_jingle_bells` | Jingle Bells | 32 | 180ms |
|
||||||
|
| `builtin_westminster` | Westminster Chimes | 16 | 400ms |
|
||||||
|
| `builtin_alarm` | Alarm | 16 | 150ms |
|
||||||
|
| `builtin_doorbell` | Doorbell | 4 | 300ms |
|
||||||
|
| `builtin_single_bell` | Single Bell Test | 1 | 100ms |
|
||||||
|
|
||||||
|
## Memory Usage
|
||||||
|
|
||||||
|
### Flash Memory (PROGMEM)
|
||||||
|
- Small melody (~30 steps): **60 bytes**
|
||||||
|
- Large melody (~200 steps): **400 bytes**
|
||||||
|
- 40 melodies average: **~6-10KB** (stored in Flash, not RAM!)
|
||||||
|
|
||||||
|
### RAM Usage
|
||||||
|
Only the **currently playing melody** is loaded into RAM. Built-in melodies are copied from Flash when needed.
|
||||||
|
|
||||||
|
## Tips
|
||||||
|
|
||||||
|
1. **Always use `builtin_` prefix** for UIDs to identify them as built-in
|
||||||
|
2. **Test with small melodies first** before adding large ones
|
||||||
|
3. **Use hex calculator** for complex bell combinations: `0x0001 | 0x0004 = 0x0005`
|
||||||
|
4. **Add rests** with `0x0000` for silence between notes
|
||||||
|
5. **Keep it simple** - most melodies work great with 30-50 steps
|
||||||
|
|
||||||
|
## Converting Binary Files to Code
|
||||||
|
|
||||||
|
If you have existing binary melody files and want to convert them to built-in format:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Python script to convert binary file to C++ array
|
||||||
|
with open('melody.bin', 'rb') as f:
|
||||||
|
data = f.read()
|
||||||
|
|
||||||
|
print("const uint16_t PROGMEM melody_name[] = {")
|
||||||
|
for i in range(0, len(data), 2):
|
||||||
|
if i % 16 == 0:
|
||||||
|
print(" ", end="")
|
||||||
|
high = data[i]
|
||||||
|
low = data[i+1]
|
||||||
|
value = (high << 8) | low
|
||||||
|
print(f"0x{value:04X}", end="")
|
||||||
|
if i < len(data) - 2:
|
||||||
|
print(", ", end="")
|
||||||
|
if (i + 2) % 16 == 0:
|
||||||
|
print()
|
||||||
|
print("\n};")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example: Creating a Custom Melody
|
||||||
|
|
||||||
|
Let's create "Mary Had a Little Lamb":
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// Mary Had a Little Lamb
|
||||||
|
// Notes: E D C D E E E, D D D, E G G
|
||||||
|
// Mapping: E=0, D=1, C=2, G=3
|
||||||
|
const uint16_t PROGMEM melody_mary_lamb[] = {
|
||||||
|
0x0001, 0x0002, 0x0004, 0x0002, // E D C D
|
||||||
|
0x0001, 0x0001, 0x0001, 0x0000, // E E E (rest)
|
||||||
|
0x0002, 0x0002, 0x0002, 0x0000, // D D D (rest)
|
||||||
|
0x0001, 0x0008, 0x0008 // E G G
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add to MELODY_LIBRARY:
|
||||||
|
{
|
||||||
|
"Mary Had a Little Lamb",
|
||||||
|
"builtin_mary_lamb",
|
||||||
|
melody_mary_lamb,
|
||||||
|
sizeof(melody_mary_lamb) / sizeof(uint16_t),
|
||||||
|
300 // 300ms per beat
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Now you can play it with UID `builtin_mary_lamb`!
|
||||||
@@ -2,30 +2,30 @@
|
|||||||
#include "../Logging/Logging.hpp"
|
#include "../Logging/Logging.hpp"
|
||||||
|
|
||||||
ClientManager::ClientManager() {
|
ClientManager::ClientManager() {
|
||||||
LOG_INFO("Client Manager Component - Initialized");
|
LOG_INFO("Client Manager initialized !");
|
||||||
}
|
}
|
||||||
|
|
||||||
ClientManager::~ClientManager() {
|
ClientManager::~ClientManager() {
|
||||||
_clients.clear();
|
_clients.clear();
|
||||||
LOG_INFO("Client Manager Component - Destroyed");
|
LOG_INFO("Client Manager destroyed");
|
||||||
}
|
}
|
||||||
|
|
||||||
void ClientManager::addClient(AsyncWebSocketClient* client, DeviceType deviceType) {
|
void ClientManager::addClient(AsyncWebSocketClient* client, DeviceType deviceType) {
|
||||||
if (!isValidClient(client)) {
|
if (!isValidClient(client)) {
|
||||||
LOG_ERROR("Cannot add invalid client");
|
LOG_WARNING("Client Manager - Cannot add invalid client");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
uint32_t clientId = client->id();
|
uint32_t clientId = client->id();
|
||||||
_clients[clientId] = ClientInfo(client, deviceType);
|
_clients[clientId] = ClientInfo(client, deviceType);
|
||||||
|
|
||||||
LOG_INFO("Client #%u added as %s device", clientId, deviceTypeToString(deviceType));
|
LOG_INFO("Client Manager - Client #%u added as %s device", clientId, deviceTypeToString(deviceType));
|
||||||
}
|
}
|
||||||
|
|
||||||
void ClientManager::removeClient(uint32_t clientId) {
|
void ClientManager::removeClient(uint32_t clientId) {
|
||||||
auto it = _clients.find(clientId);
|
auto it = _clients.find(clientId);
|
||||||
if (it != _clients.end()) {
|
if (it != _clients.end()) {
|
||||||
LOG_INFO("Client #%u removed (%s device)", clientId,
|
LOG_INFO("Client Manager - Client #%u removed (%s device)", clientId,
|
||||||
deviceTypeToString(it->second.deviceType));
|
deviceTypeToString(it->second.deviceType));
|
||||||
_clients.erase(it);
|
_clients.erase(it);
|
||||||
}
|
}
|
||||||
@@ -36,7 +36,7 @@ void ClientManager::updateClientType(uint32_t clientId, DeviceType deviceType) {
|
|||||||
if (it != _clients.end()) {
|
if (it != _clients.end()) {
|
||||||
DeviceType oldType = it->second.deviceType;
|
DeviceType oldType = it->second.deviceType;
|
||||||
it->second.deviceType = deviceType;
|
it->second.deviceType = deviceType;
|
||||||
LOG_INFO("Client #%u type updated from %s to %s", clientId,
|
LOG_INFO("Client Manager - Client #%u type updated from %s to %s", clientId,
|
||||||
deviceTypeToString(oldType), deviceTypeToString(deviceType));
|
deviceTypeToString(oldType), deviceTypeToString(deviceType));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -72,11 +72,11 @@ bool ClientManager::sendToClient(uint32_t clientId, const String& message) {
|
|||||||
if (it != _clients.end() && isValidClient(it->second.client)) {
|
if (it != _clients.end() && isValidClient(it->second.client)) {
|
||||||
it->second.client->text(message);
|
it->second.client->text(message);
|
||||||
updateClientLastSeen(clientId);
|
updateClientLastSeen(clientId);
|
||||||
LOG_DEBUG("Message sent to client #%u: %s", clientId, message.c_str());
|
LOG_DEBUG("Client Manager - Message sent to client #%u: %s", clientId, message.c_str());
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
LOG_WARNING("Failed to send message to client #%u - client not found or invalid", clientId);
|
LOG_WARNING("Client Manager - Failed to send message to client #%u - client not found or invalid", clientId);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,7 +90,7 @@ void ClientManager::sendToMasterClients(const String& message) {
|
|||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LOG_DEBUG("Message sent to %d master client(s): %s", count, message.c_str());
|
LOG_DEBUG("Client Manager - Message sent to %d master client(s): %s", count, message.c_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
void ClientManager::sendToSecondaryClients(const String& message) {
|
void ClientManager::sendToSecondaryClients(const String& message) {
|
||||||
@@ -103,7 +103,7 @@ void ClientManager::sendToSecondaryClients(const String& message) {
|
|||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LOG_DEBUG("Message sent to %d secondary client(s): %s", count, message.c_str());
|
LOG_DEBUG("Client Manager - Message sent to %d secondary client(s): %s", count, message.c_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
void ClientManager::broadcastToAll(const String& message) {
|
void ClientManager::broadcastToAll(const String& message) {
|
||||||
@@ -115,14 +115,14 @@ void ClientManager::broadcastToAll(const String& message) {
|
|||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LOG_DEBUG("Message broadcasted to %d client(s): %s", count, message.c_str());
|
LOG_DEBUG("Client Manager - Message broadcasted to %d client(s): %s", count, message.c_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
void ClientManager::cleanupDisconnectedClients() {
|
void ClientManager::cleanupDisconnectedClients() {
|
||||||
auto it = _clients.begin();
|
auto it = _clients.begin();
|
||||||
while (it != _clients.end()) {
|
while (it != _clients.end()) {
|
||||||
if (!isValidClient(it->second.client)) {
|
if (!isValidClient(it->second.client)) {
|
||||||
LOG_DEBUG("Cleaning up disconnected client #%u", it->first);
|
LOG_DEBUG("Client Manager - Cleaning up disconnected client #%u", it->first);
|
||||||
it->second.isConnected = false;
|
it->second.isConnected = false;
|
||||||
it = _clients.erase(it);
|
it = _clients.erase(it);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -10,8 +10,10 @@
|
|||||||
#include "../../TimeKeeper/TimeKeeper.hpp"
|
#include "../../TimeKeeper/TimeKeeper.hpp"
|
||||||
#include "../../FirmwareValidator/FirmwareValidator.hpp"
|
#include "../../FirmwareValidator/FirmwareValidator.hpp"
|
||||||
#include "../../ClientManager/ClientManager.hpp"
|
#include "../../ClientManager/ClientManager.hpp"
|
||||||
|
#include "../../Telemetry/Telemetry.hpp"
|
||||||
#include "../../Logging/Logging.hpp"
|
#include "../../Logging/Logging.hpp"
|
||||||
#include "../ResponseBuilder/ResponseBuilder.hpp"
|
#include "../ResponseBuilder/ResponseBuilder.hpp"
|
||||||
|
#include "../CommunicationRouter/CommunicationRouter.hpp"
|
||||||
|
|
||||||
CommandHandler::CommandHandler(ConfigManager& configManager, OTAManager& otaManager)
|
CommandHandler::CommandHandler(ConfigManager& configManager, OTAManager& otaManager)
|
||||||
: _configManager(configManager)
|
: _configManager(configManager)
|
||||||
@@ -21,6 +23,8 @@ CommandHandler::CommandHandler(ConfigManager& configManager, OTAManager& otaMana
|
|||||||
, _timeKeeper(nullptr)
|
, _timeKeeper(nullptr)
|
||||||
, _firmwareValidator(nullptr)
|
, _firmwareValidator(nullptr)
|
||||||
, _clientManager(nullptr)
|
, _clientManager(nullptr)
|
||||||
|
, _telemetry(nullptr)
|
||||||
|
, _communicationRouter(nullptr)
|
||||||
, _responseCallback(nullptr) {}
|
, _responseCallback(nullptr) {}
|
||||||
|
|
||||||
CommandHandler::~CommandHandler() {}
|
CommandHandler::~CommandHandler() {}
|
||||||
@@ -45,6 +49,14 @@ void CommandHandler::setClientManagerReference(ClientManager* cm) {
|
|||||||
_clientManager = cm;
|
_clientManager = cm;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void CommandHandler::setTelemetryReference(Telemetry* telemetry) {
|
||||||
|
_telemetry = telemetry;
|
||||||
|
}
|
||||||
|
|
||||||
|
void CommandHandler::setCommunicationRouterReference(CommunicationRouter* comm) {
|
||||||
|
_communicationRouter = comm;
|
||||||
|
}
|
||||||
|
|
||||||
void CommandHandler::setResponseCallback(ResponseCallback callback) {
|
void CommandHandler::setResponseCallback(ResponseCallback callback) {
|
||||||
_responseCallback = callback;
|
_responseCallback = callback;
|
||||||
}
|
}
|
||||||
@@ -114,7 +126,15 @@ void CommandHandler::handleStatusCommand(const MessageContext& context) {
|
|||||||
projectedRunTime = _player->calculateProjectedRunTime();
|
projectedRunTime = _player->calculateProjectedRunTime();
|
||||||
}
|
}
|
||||||
|
|
||||||
String response = ResponseBuilder::deviceStatus(playerStatus, timeElapsedMs, projectedRunTime);
|
// Collect strike counters from Telemetry
|
||||||
|
uint32_t strikeCounters[16] = {0};
|
||||||
|
if (_telemetry) {
|
||||||
|
for (uint8_t i = 0; i < 16; i++) {
|
||||||
|
strikeCounters[i] = _telemetry->getStrikeCount(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String response = ResponseBuilder::deviceStatus(playerStatus, timeElapsedMs, projectedRunTime, strikeCounters);
|
||||||
sendResponse(response, context);
|
sendResponse(response, context);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,12 +277,10 @@ void CommandHandler::handleSystemInfoCommand(JsonVariant contents, const Message
|
|||||||
handleGetDeviceTimeCommand(context);
|
handleGetDeviceTimeCommand(context);
|
||||||
} else if (action == "get_clock_time") {
|
} else if (action == "get_clock_time") {
|
||||||
handleGetClockTimeCommand(context);
|
handleGetClockTimeCommand(context);
|
||||||
} else if (action == "commit_firmware") {
|
|
||||||
handleCommitFirmwareCommand(context);
|
|
||||||
} else if (action == "rollback_firmware") {
|
|
||||||
handleRollbackFirmwareCommand(context);
|
|
||||||
} else if (action == "get_firmware_status") {
|
} else if (action == "get_firmware_status") {
|
||||||
handleGetFirmwareStatusCommand(context);
|
handleGetFirmwareStatusCommand(context);
|
||||||
|
} else if (action == "network_info") {
|
||||||
|
handleNetworkInfoCommand(context);
|
||||||
} else if (action == "get_full_settings") {
|
} else if (action == "get_full_settings") {
|
||||||
handleGetFullSettingsCommand(context);
|
handleGetFullSettingsCommand(context);
|
||||||
} else {
|
} else {
|
||||||
@@ -356,9 +374,15 @@ void CommandHandler::handleSetRelayTimersCommand(JsonVariant contents, const Mes
|
|||||||
void CommandHandler::handleSetRelayOutputsCommand(JsonVariant contents, const MessageContext& context) {
|
void CommandHandler::handleSetRelayOutputsCommand(JsonVariant contents, const MessageContext& context) {
|
||||||
try {
|
try {
|
||||||
_configManager.updateBellOutputs(contents);
|
_configManager.updateBellOutputs(contents);
|
||||||
// Note: Bell outputs are typically not persisted to SD card as they're more of a mapping configuration
|
// Save bell outputs configuration to SD card for persistence
|
||||||
sendSuccessResponse("set_relay_outputs", "Relay outputs updated", context);
|
bool saved = _configManager.saveBellOutputs();
|
||||||
LOG_INFO("Relay outputs updated successfully");
|
if (saved) {
|
||||||
|
sendSuccessResponse("set_relay_outputs", "Relay outputs updated and saved", context);
|
||||||
|
LOG_INFO("Relay outputs updated and saved successfully");
|
||||||
|
} else {
|
||||||
|
sendErrorResponse("set_relay_outputs", "Failed to save relay outputs to SD card", context);
|
||||||
|
LOG_ERROR("Failed to save relay outputs configuration");
|
||||||
|
}
|
||||||
} catch (...) {
|
} catch (...) {
|
||||||
sendErrorResponse("set_relay_outputs", "Failed to update relay outputs", context);
|
sendErrorResponse("set_relay_outputs", "Failed to update relay outputs", context);
|
||||||
LOG_ERROR("Exception occurred while updating relay outputs");
|
LOG_ERROR("Exception occurred while updating relay outputs");
|
||||||
@@ -789,6 +813,21 @@ void CommandHandler::handleGetFirmwareStatusCommand(const MessageContext& contex
|
|||||||
LOG_DEBUG("Firmware status requested: %s", stateStr.c_str());
|
LOG_DEBUG("Firmware status requested: %s", stateStr.c_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void CommandHandler::handleNetworkInfoCommand(const MessageContext& context) {
|
||||||
|
StaticJsonDocument<256> response;
|
||||||
|
response["status"] = "SUCCESS";
|
||||||
|
response["type"] = "network_info";
|
||||||
|
|
||||||
|
JsonObject payload = response.createNestedObject("payload");
|
||||||
|
payload["ip"] = WiFi.localIP().toString();
|
||||||
|
payload["gateway"] = WiFi.gatewayIP().toString();
|
||||||
|
payload["dns"] = WiFi.dnsIP().toString();
|
||||||
|
|
||||||
|
String responseStr;
|
||||||
|
serializeJson(response, responseStr);
|
||||||
|
sendResponse(responseStr, context);
|
||||||
|
}
|
||||||
|
|
||||||
void CommandHandler::handleGetFullSettingsCommand(const MessageContext& context) {
|
void CommandHandler::handleGetFullSettingsCommand(const MessageContext& context) {
|
||||||
LOG_DEBUG("Full settings requested");
|
LOG_DEBUG("Full settings requested");
|
||||||
|
|
||||||
@@ -988,9 +1027,254 @@ void CommandHandler::handleSystemCommand(JsonVariant contents, const MessageCont
|
|||||||
handleGetFirmwareStatusCommand(context);
|
handleGetFirmwareStatusCommand(context);
|
||||||
} else if (action == "set_network_config") {
|
} else if (action == "set_network_config") {
|
||||||
handleSetNetworkConfigCommand(contents, context);
|
handleSetNetworkConfigCommand(contents, context);
|
||||||
|
} else if (action == "set_serial_log_level") {
|
||||||
|
handleSetSerialLogLevelCommand(contents, context);
|
||||||
|
} else if (action == "set_sd_log_level") {
|
||||||
|
handleSetSdLogLevelCommand(contents, context);
|
||||||
|
} else if (action == "set_mqtt_log_level") {
|
||||||
|
handleSetMqttLogLevelCommand(contents, context);
|
||||||
|
} else if (action == "set_mqtt_enabled") {
|
||||||
|
handleSetMqttEnabledCommand(contents, context);
|
||||||
|
} else if (action == "restart" || action == "reboot") {
|
||||||
|
handleRestartCommand(context);
|
||||||
|
} else if (action == "force_update") {
|
||||||
|
handleForceUpdateCommand(contents, context);
|
||||||
|
} else if (action == "custom_update") {
|
||||||
|
handleCustomUpdateCommand(contents, context);
|
||||||
} else {
|
} else {
|
||||||
LOG_WARNING("Unknown system action: %s", action.c_str());
|
LOG_WARNING("Unknown system action: %s", action.c_str());
|
||||||
sendErrorResponse("system", "Unknown action: " + action, context);
|
sendErrorResponse("system", "Unknown action: " + action, context);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
// LOG LEVEL COMMANDS
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
void CommandHandler::handleSetSerialLogLevelCommand(JsonVariant contents, const MessageContext& context) {
|
||||||
|
if (!contents.containsKey("level")) {
|
||||||
|
sendErrorResponse("set_serial_log_level", "Missing level parameter", context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t level = contents["level"].as<uint8_t>();
|
||||||
|
|
||||||
|
// Set the level in ConfigManager
|
||||||
|
if (_configManager.setSerialLogLevel(level)) {
|
||||||
|
// Apply the level to Logging immediately
|
||||||
|
Logging::setLevel((Logging::LogLevel)level);
|
||||||
|
|
||||||
|
// Save to SD card
|
||||||
|
bool saved = _configManager.saveGeneralConfig();
|
||||||
|
|
||||||
|
if (saved) {
|
||||||
|
sendSuccessResponse("set_serial_log_level",
|
||||||
|
"Serial log level set to " + String(level) + " and saved", context);
|
||||||
|
LOG_INFO("Serial log level updated to %d", level);
|
||||||
|
} else {
|
||||||
|
sendErrorResponse("set_serial_log_level",
|
||||||
|
"Log level set but failed to save to SD card", context);
|
||||||
|
LOG_ERROR("Failed to save serial log level to SD card");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sendErrorResponse("set_serial_log_level",
|
||||||
|
"Invalid log level (must be 0-5)", context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void CommandHandler::handleSetSdLogLevelCommand(JsonVariant contents, const MessageContext& context) {
|
||||||
|
if (!contents.containsKey("level")) {
|
||||||
|
sendErrorResponse("set_sd_log_level", "Missing level parameter", context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t level = contents["level"].as<uint8_t>();
|
||||||
|
|
||||||
|
// Set the level in ConfigManager
|
||||||
|
if (_configManager.setSdLogLevel(level)) {
|
||||||
|
// Save to SD card
|
||||||
|
bool saved = _configManager.saveGeneralConfig();
|
||||||
|
|
||||||
|
if (saved) {
|
||||||
|
sendSuccessResponse("set_sd_log_level",
|
||||||
|
"SD log level set to " + String(level) + " and saved", context);
|
||||||
|
LOG_INFO("SD log level updated to %d (not yet implemented)", level);
|
||||||
|
} else {
|
||||||
|
sendErrorResponse("set_sd_log_level",
|
||||||
|
"Log level set but failed to save to SD card", context);
|
||||||
|
LOG_ERROR("Failed to save SD log level to SD card");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sendErrorResponse("set_sd_log_level",
|
||||||
|
"Invalid log level (must be 0-5)", context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void CommandHandler::handleSetMqttLogLevelCommand(JsonVariant contents, const MessageContext& context) {
|
||||||
|
if (!contents.containsKey("level")) {
|
||||||
|
sendErrorResponse("set_mqtt_log_level", "Missing level parameter", context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t level = contents["level"].as<uint8_t>();
|
||||||
|
|
||||||
|
// Set the level in ConfigManager
|
||||||
|
if (_configManager.setMqttLogLevel(level)) {
|
||||||
|
// Apply the level to Logging immediately
|
||||||
|
Logging::setMqttLogLevel((Logging::LogLevel)level);
|
||||||
|
|
||||||
|
// Save to SD card
|
||||||
|
bool saved = _configManager.saveGeneralConfig();
|
||||||
|
|
||||||
|
if (saved) {
|
||||||
|
sendSuccessResponse("set_mqtt_log_level",
|
||||||
|
"MQTT log level set to " + String(level) + " and saved", context);
|
||||||
|
LOG_INFO("MQTT log level updated to %d", level);
|
||||||
|
} else {
|
||||||
|
sendErrorResponse("set_mqtt_log_level",
|
||||||
|
"Log level set but failed to save to SD card", context);
|
||||||
|
LOG_ERROR("Failed to save MQTT log level to SD card");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sendErrorResponse("set_mqtt_log_level",
|
||||||
|
"Invalid log level (must be 0-5)", context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void CommandHandler::handleSetMqttEnabledCommand(JsonVariant contents, const MessageContext& context) {
|
||||||
|
if (!contents.containsKey("enabled")) {
|
||||||
|
sendErrorResponse("set_mqtt_enabled", "Missing enabled parameter", context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool enabled = contents["enabled"].as<bool>();
|
||||||
|
|
||||||
|
// Set MQTT enabled state in ConfigManager
|
||||||
|
_configManager.setMqttEnabled(enabled);
|
||||||
|
|
||||||
|
// Save to SD card
|
||||||
|
bool saved = _configManager.saveGeneralConfig();
|
||||||
|
|
||||||
|
if (saved) {
|
||||||
|
sendSuccessResponse("set_mqtt_enabled",
|
||||||
|
String("MQTT ") + (enabled ? "enabled" : "disabled") + " and saved", context);
|
||||||
|
LOG_INFO("MQTT %s by user command", enabled ? "enabled" : "disabled");
|
||||||
|
|
||||||
|
// If disabling, disconnect MQTT immediately
|
||||||
|
// If enabling, trigger connection attempt
|
||||||
|
if (_communicationRouter) {
|
||||||
|
if (!enabled) {
|
||||||
|
_communicationRouter->getMQTTClient().disconnect();
|
||||||
|
} else {
|
||||||
|
_communicationRouter->getMQTTClient().connect();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LOG_WARNING("CommunicationRouter reference not set - cannot control MQTT");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sendErrorResponse("set_mqtt_enabled",
|
||||||
|
"MQTT state changed but failed to save to SD card", context);
|
||||||
|
LOG_ERROR("Failed to save MQTT enabled state to SD card");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
// RESTART COMMAND
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
void CommandHandler::handleRestartCommand(const MessageContext& context) {
|
||||||
|
LOG_WARNING("🔄 Device restart requested via command");
|
||||||
|
sendSuccessResponse("restart", "Device will restart in 2 seconds", context);
|
||||||
|
|
||||||
|
// Small delay to ensure response is sent
|
||||||
|
delay(2000);
|
||||||
|
|
||||||
|
// Restart the ESP32
|
||||||
|
ESP.restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
// FORCE UPDATE COMMAND
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
void CommandHandler::handleForceUpdateCommand(JsonVariant contents, const MessageContext& context) {
|
||||||
|
LOG_WARNING("🔄 Force OTA update requested via command");
|
||||||
|
|
||||||
|
// Check if player is active
|
||||||
|
if (_player && _player->isCurrentlyPlaying()) {
|
||||||
|
sendErrorResponse("force_update", "Cannot update while playback is active", context);
|
||||||
|
LOG_WARNING("Force update rejected - player is active");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get optional channel parameter (defaults to "stable")
|
||||||
|
String channel = "stable";
|
||||||
|
if (contents.containsKey("channel")) {
|
||||||
|
channel = contents["channel"].as<String>();
|
||||||
|
}
|
||||||
|
|
||||||
|
sendSuccessResponse("force_update",
|
||||||
|
"Starting forced OTA update from channel: " + channel + ". Device may reboot.", context);
|
||||||
|
|
||||||
|
// Small delay to ensure response is sent
|
||||||
|
delay(1000);
|
||||||
|
|
||||||
|
// Perform the update
|
||||||
|
bool result = _otaManager.performManualUpdate(channel);
|
||||||
|
|
||||||
|
// Note: If update succeeds, device will reboot and this won't be reached
|
||||||
|
if (!result) {
|
||||||
|
LOG_ERROR("Force update failed");
|
||||||
|
// Error response may not be received if we already restarted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
// CUSTOM UPDATE COMMAND
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
void CommandHandler::handleCustomUpdateCommand(JsonVariant contents, const MessageContext& context) {
|
||||||
|
LOG_WARNING("🔥 Custom OTA update requested via command");
|
||||||
|
|
||||||
|
// Validate required parameters
|
||||||
|
if (!contents.containsKey("firmware_url")) {
|
||||||
|
sendErrorResponse("custom_update", "Missing firmware_url parameter", context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String firmwareUrl = contents["firmware_url"].as<String>();
|
||||||
|
|
||||||
|
// Optional parameters
|
||||||
|
String checksum = contents.containsKey("checksum") ?
|
||||||
|
contents["checksum"].as<String>() : "";
|
||||||
|
size_t fileSize = contents.containsKey("file_size") ?
|
||||||
|
contents["file_size"].as<size_t>() : 0;
|
||||||
|
|
||||||
|
// Check if player is active
|
||||||
|
if (_player && _player->isCurrentlyPlaying()) {
|
||||||
|
sendErrorResponse("custom_update", "Cannot update while playback is active", context);
|
||||||
|
LOG_WARNING("Custom update rejected - player is active");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
LOG_INFO("Custom update: URL=%s, Checksum=%s, Size=%u",
|
||||||
|
firmwareUrl.c_str(),
|
||||||
|
checksum.isEmpty() ? "none" : checksum.c_str(),
|
||||||
|
fileSize);
|
||||||
|
|
||||||
|
sendSuccessResponse("custom_update",
|
||||||
|
"Starting custom OTA update. Device may reboot.", context);
|
||||||
|
|
||||||
|
// Small delay to ensure response is sent
|
||||||
|
delay(1000);
|
||||||
|
|
||||||
|
// Perform the custom update
|
||||||
|
bool result = _otaManager.performCustomUpdate(firmwareUrl, checksum, fileSize);
|
||||||
|
|
||||||
|
// Note: If update succeeds, device will reboot and this won't be reached
|
||||||
|
if (!result) {
|
||||||
|
LOG_ERROR("Custom update failed");
|
||||||
|
// Error response may not be received if we already restarted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ class FileManager;
|
|||||||
class Timekeeper;
|
class Timekeeper;
|
||||||
class FirmwareValidator;
|
class FirmwareValidator;
|
||||||
class ClientManager;
|
class ClientManager;
|
||||||
|
class Telemetry;
|
||||||
|
class CommunicationRouter;
|
||||||
|
|
||||||
class CommandHandler {
|
class CommandHandler {
|
||||||
public:
|
public:
|
||||||
@@ -65,6 +67,8 @@ public:
|
|||||||
void setTimeKeeperReference(Timekeeper* tk);
|
void setTimeKeeperReference(Timekeeper* tk);
|
||||||
void setFirmwareValidatorReference(FirmwareValidator* fv);
|
void setFirmwareValidatorReference(FirmwareValidator* fv);
|
||||||
void setClientManagerReference(ClientManager* cm);
|
void setClientManagerReference(ClientManager* cm);
|
||||||
|
void setTelemetryReference(Telemetry* telemetry);
|
||||||
|
void setCommunicationRouterReference(CommunicationRouter* comm);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Set response callback for sending responses back
|
* @brief Set response callback for sending responses back
|
||||||
@@ -87,6 +91,8 @@ private:
|
|||||||
Timekeeper* _timeKeeper;
|
Timekeeper* _timeKeeper;
|
||||||
FirmwareValidator* _firmwareValidator;
|
FirmwareValidator* _firmwareValidator;
|
||||||
ClientManager* _clientManager;
|
ClientManager* _clientManager;
|
||||||
|
Telemetry* _telemetry;
|
||||||
|
CommunicationRouter* _communicationRouter;
|
||||||
ResponseCallback _responseCallback;
|
ResponseCallback _responseCallback;
|
||||||
|
|
||||||
// Response helpers
|
// Response helpers
|
||||||
@@ -131,6 +137,7 @@ private:
|
|||||||
void handleCommitFirmwareCommand(const MessageContext& context);
|
void handleCommitFirmwareCommand(const MessageContext& context);
|
||||||
void handleRollbackFirmwareCommand(const MessageContext& context);
|
void handleRollbackFirmwareCommand(const MessageContext& context);
|
||||||
void handleGetFirmwareStatusCommand(const MessageContext& context);
|
void handleGetFirmwareStatusCommand(const MessageContext& context);
|
||||||
|
void handleNetworkInfoCommand(const MessageContext& context);
|
||||||
void handleGetFullSettingsCommand(const MessageContext& context);
|
void handleGetFullSettingsCommand(const MessageContext& context);
|
||||||
|
|
||||||
// Network configuration
|
// Network configuration
|
||||||
@@ -138,4 +145,17 @@ private:
|
|||||||
|
|
||||||
// System Config
|
// System Config
|
||||||
void handleResetDefaultsCommand(const MessageContext& context);
|
void handleResetDefaultsCommand(const MessageContext& context);
|
||||||
|
|
||||||
|
// Log Level Commands
|
||||||
|
void handleSetSerialLogLevelCommand(JsonVariant contents, const MessageContext& context);
|
||||||
|
void handleSetSdLogLevelCommand(JsonVariant contents, const MessageContext& context);
|
||||||
|
void handleSetMqttLogLevelCommand(JsonVariant contents, const MessageContext& context);
|
||||||
|
|
||||||
|
// MQTT Control Commands
|
||||||
|
void handleSetMqttEnabledCommand(JsonVariant contents, const MessageContext& context);
|
||||||
|
|
||||||
|
// Device Control Commands
|
||||||
|
void handleRestartCommand(const MessageContext& context);
|
||||||
|
void handleForceUpdateCommand(JsonVariant contents, const MessageContext& context);
|
||||||
|
void handleCustomUpdateCommand(JsonVariant contents, const MessageContext& context);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ CommunicationRouter::CommunicationRouter(ConfigManager& configManager,
|
|||||||
, _mqttClient(configManager, networking)
|
, _mqttClient(configManager, networking)
|
||||||
, _clientManager()
|
, _clientManager()
|
||||||
, _wsServer(webSocket, _clientManager)
|
, _wsServer(webSocket, _clientManager)
|
||||||
, _commandHandler(configManager, otaManager) {}
|
, _commandHandler(configManager, otaManager)
|
||||||
|
, _httpHandler(server, configManager)
|
||||||
|
, _settingsServer(server, configManager, networking) {}
|
||||||
|
|
||||||
CommunicationRouter::~CommunicationRouter() {}
|
CommunicationRouter::~CommunicationRouter() {}
|
||||||
|
|
||||||
@@ -60,6 +62,21 @@ void CommunicationRouter::begin() {
|
|||||||
_mqttClient.setCallback([this](const String& topic, const String& payload) {
|
_mqttClient.setCallback([this](const String& topic, const String& payload) {
|
||||||
onMqttMessage(topic, payload);
|
onMqttMessage(topic, payload);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Setup MQTT logging callback
|
||||||
|
String logTopic = "vesper/" + _configManager.getDeviceUID() + "/logs";
|
||||||
|
Logging::setMqttPublishCallback(
|
||||||
|
[this](const String& topic, const String& payload, int qos) {
|
||||||
|
_mqttClient.publish(topic, payload, qos, false);
|
||||||
|
},
|
||||||
|
logTopic
|
||||||
|
);
|
||||||
|
|
||||||
|
// Apply MQTT log level from config
|
||||||
|
uint8_t mqttLogLevel = _configManager.getMqttLogLevel();
|
||||||
|
Logging::setMqttLogLevel((Logging::LogLevel)mqttLogLevel);
|
||||||
|
LOG_INFO("MQTT logging enabled with level %d on topic: %s", mqttLogLevel, logTopic.c_str());
|
||||||
|
|
||||||
LOG_INFO("✅ MQTT client initialized");
|
LOG_INFO("✅ MQTT client initialized");
|
||||||
} catch (...) {
|
} catch (...) {
|
||||||
LOG_ERROR("❌ MQTT initialization failed, but WebSocket is still available");
|
LOG_ERROR("❌ MQTT initialization failed, but WebSocket is still available");
|
||||||
@@ -69,12 +86,31 @@ void CommunicationRouter::begin() {
|
|||||||
_commandHandler.setClientManagerReference(&_clientManager);
|
_commandHandler.setClientManagerReference(&_clientManager);
|
||||||
LOG_INFO("ClientManager reference set for CommandHandler");
|
LOG_INFO("ClientManager reference set for CommandHandler");
|
||||||
|
|
||||||
|
// 🔥 Set CommunicationRouter reference for MQTT control commands
|
||||||
|
_commandHandler.setCommunicationRouterReference(this);
|
||||||
|
LOG_INFO("CommunicationRouter reference set for CommandHandler");
|
||||||
|
|
||||||
// Setup command handler response callback
|
// Setup command handler response callback
|
||||||
_commandHandler.setResponseCallback([this](const String& response, const CommandHandler::MessageContext& context) {
|
_commandHandler.setResponseCallback([this](const String& response, const CommandHandler::MessageContext& context) {
|
||||||
sendResponse(response, context);
|
sendResponse(response, context);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Initialize HTTP Request Handler
|
||||||
|
LOG_INFO("Setting up HTTP REST API...");
|
||||||
|
_httpHandler.begin();
|
||||||
|
_httpHandler.setCommandHandlerReference(&_commandHandler);
|
||||||
|
LOG_INFO("✅ HTTP REST API initialized");
|
||||||
|
|
||||||
|
// Initialize Settings Web Server
|
||||||
|
LOG_INFO("Setting up Settings Web Server...");
|
||||||
|
_settingsServer.begin();
|
||||||
|
LOG_INFO("✅ Settings Web Server initialized at /settings");
|
||||||
|
|
||||||
LOG_INFO("Communication Router initialized with modular architecture");
|
LOG_INFO("Communication Router initialized with modular architecture");
|
||||||
|
LOG_INFO(" • MQTT: AsyncMqttClient");
|
||||||
|
LOG_INFO(" • WebSocket: Multi-client support");
|
||||||
|
LOG_INFO(" • HTTP REST API: /api endpoints");
|
||||||
|
LOG_INFO(" • Settings Page: /settings");
|
||||||
}
|
}
|
||||||
|
|
||||||
void CommunicationRouter::setPlayerReference(Player* player) {
|
void CommunicationRouter::setPlayerReference(Player* player) {
|
||||||
@@ -97,6 +133,10 @@ void CommunicationRouter::setFirmwareValidatorReference(FirmwareValidator* fv) {
|
|||||||
_commandHandler.setFirmwareValidatorReference(fv);
|
_commandHandler.setFirmwareValidatorReference(fv);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void CommunicationRouter::setTelemetryReference(Telemetry* telemetry) {
|
||||||
|
_commandHandler.setTelemetryReference(telemetry);
|
||||||
|
}
|
||||||
|
|
||||||
void CommunicationRouter::setupUdpDiscovery() {
|
void CommunicationRouter::setupUdpDiscovery() {
|
||||||
uint16_t discoveryPort = _configManager.getNetworkConfig().discoveryPort;
|
uint16_t discoveryPort = _configManager.getNetworkConfig().discoveryPort;
|
||||||
if (_udp.listen(discoveryPort)) {
|
if (_udp.listen(discoveryPort)) {
|
||||||
@@ -117,7 +157,7 @@ void CommunicationRouter::setupUdpDiscovery() {
|
|||||||
StaticJsonDocument<128> req;
|
StaticJsonDocument<128> req;
|
||||||
DeserializationError err = deserializeJson(req, msg);
|
DeserializationError err = deserializeJson(req, msg);
|
||||||
if (!err) {
|
if (!err) {
|
||||||
shouldReply = (req["op"] == "discover" && req["svc"] == "vesper");
|
shouldReply = (req["op"] == "discover");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,7 +172,7 @@ void CommunicationRouter::setupUdpDiscovery() {
|
|||||||
doc["id"] = _configManager.getDeviceUID();
|
doc["id"] = _configManager.getDeviceUID();
|
||||||
doc["ip"] = _networking.getLocalIP();
|
doc["ip"] = _networking.getLocalIP();
|
||||||
char wsUrl[64];
|
char wsUrl[64];
|
||||||
snprintf(wsUrl, sizeof(wsUrl), "ws://%s/ws", _networking.getLocalIP().c_str());
|
snprintf(wsUrl, sizeof(wsUrl), "ws://%s:80/ws", _networking.getLocalIP().c_str());
|
||||||
doc["ws"] = wsUrl;
|
doc["ws"] = wsUrl;
|
||||||
doc["port"] = 80;
|
doc["port"] = 80;
|
||||||
doc["fw"] = "2.0";
|
doc["fw"] = "2.0";
|
||||||
|
|||||||
@@ -38,7 +38,9 @@
|
|||||||
#include "../WebSocketServer/WebSocketServer.hpp"
|
#include "../WebSocketServer/WebSocketServer.hpp"
|
||||||
#include "../CommandHandler/CommandHandler.hpp"
|
#include "../CommandHandler/CommandHandler.hpp"
|
||||||
#include "../ResponseBuilder/ResponseBuilder.hpp"
|
#include "../ResponseBuilder/ResponseBuilder.hpp"
|
||||||
|
#include "../HTTPRequestHandler/HTTPRequestHandler.hpp"
|
||||||
#include "../../ClientManager/ClientManager.hpp"
|
#include "../../ClientManager/ClientManager.hpp"
|
||||||
|
#include "../../SettingsWebServer/SettingsWebServer.hpp"
|
||||||
|
|
||||||
class ConfigManager;
|
class ConfigManager;
|
||||||
class OTAManager;
|
class OTAManager;
|
||||||
@@ -47,6 +49,7 @@ class FileManager;
|
|||||||
class Timekeeper;
|
class Timekeeper;
|
||||||
class Networking;
|
class Networking;
|
||||||
class FirmwareValidator;
|
class FirmwareValidator;
|
||||||
|
class Telemetry;
|
||||||
|
|
||||||
class CommunicationRouter {
|
class CommunicationRouter {
|
||||||
public:
|
public:
|
||||||
@@ -64,6 +67,7 @@ public:
|
|||||||
void setFileManagerReference(FileManager* fm);
|
void setFileManagerReference(FileManager* fm);
|
||||||
void setTimeKeeperReference(Timekeeper* tk);
|
void setTimeKeeperReference(Timekeeper* tk);
|
||||||
void setFirmwareValidatorReference(FirmwareValidator* fv);
|
void setFirmwareValidatorReference(FirmwareValidator* fv);
|
||||||
|
void setTelemetryReference(Telemetry* telemetry);
|
||||||
void setupUdpDiscovery();
|
void setupUdpDiscovery();
|
||||||
|
|
||||||
// Status methods
|
// Status methods
|
||||||
@@ -72,6 +76,9 @@ public:
|
|||||||
size_t getWebSocketClientCount() const;
|
size_t getWebSocketClientCount() const;
|
||||||
bool isHealthy() const;
|
bool isHealthy() const;
|
||||||
|
|
||||||
|
// Component accessors
|
||||||
|
MQTTAsyncClient& getMQTTClient() { return _mqttClient; }
|
||||||
|
|
||||||
// Broadcast methods
|
// Broadcast methods
|
||||||
void broadcastStatus(const String& statusMessage);
|
void broadcastStatus(const String& statusMessage);
|
||||||
void broadcastStatus(const JsonDocument& statusJson);
|
void broadcastStatus(const JsonDocument& statusJson);
|
||||||
@@ -108,6 +115,8 @@ private:
|
|||||||
ClientManager _clientManager;
|
ClientManager _clientManager;
|
||||||
WebSocketServer _wsServer;
|
WebSocketServer _wsServer;
|
||||||
CommandHandler _commandHandler;
|
CommandHandler _commandHandler;
|
||||||
|
HTTPRequestHandler _httpHandler;
|
||||||
|
SettingsWebServer _settingsServer;
|
||||||
|
|
||||||
// Message handlers
|
// Message handlers
|
||||||
void onMqttMessage(const String& topic, const String& payload);
|
void onMqttMessage(const String& topic, const String& payload);
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
/*
|
||||||
|
* ═══════════════════════════════════════════════════════════════════════════════════
|
||||||
|
* HTTPREQUESTHANDLER.CPP - HTTP REST API Request Handler Implementation
|
||||||
|
* ═══════════════════════════════════════════════════════════════════════════════════
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "HTTPRequestHandler.hpp"
|
||||||
|
#include "../CommandHandler/CommandHandler.hpp"
|
||||||
|
#include "../../ConfigManager/ConfigManager.hpp"
|
||||||
|
#include "../../Logging/Logging.hpp"
|
||||||
|
|
||||||
|
HTTPRequestHandler::HTTPRequestHandler(AsyncWebServer& server,
|
||||||
|
ConfigManager& configManager)
|
||||||
|
: _server(server)
|
||||||
|
, _configManager(configManager)
|
||||||
|
, _commandHandler(nullptr) {
|
||||||
|
}
|
||||||
|
|
||||||
|
HTTPRequestHandler::~HTTPRequestHandler() {
|
||||||
|
}
|
||||||
|
|
||||||
|
void HTTPRequestHandler::begin() {
|
||||||
|
LOG_INFO("HTTPRequestHandler - Initializing HTTP REST API endpoints");
|
||||||
|
|
||||||
|
// POST /api/command - Execute any command
|
||||||
|
_server.on("/api/command", HTTP_POST,
|
||||||
|
[](AsyncWebServerRequest* request) {
|
||||||
|
// This is called when request is complete but body is empty
|
||||||
|
request->send(400, "application/json", "{\"error\":\"No body provided\"}");
|
||||||
|
},
|
||||||
|
nullptr, // No file upload handler
|
||||||
|
[this](AsyncWebServerRequest* request, uint8_t* data, size_t len, size_t index, size_t total) {
|
||||||
|
// This is called for body data
|
||||||
|
if (index == 0) {
|
||||||
|
// First chunk - could allocate buffers if needed
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index + len == total) {
|
||||||
|
// Last chunk - process the complete request
|
||||||
|
handleCommandRequest(request, data, len);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// GET /api/status - Get system status
|
||||||
|
_server.on("/api/status", HTTP_GET,
|
||||||
|
[this](AsyncWebServerRequest* request) {
|
||||||
|
handleStatusRequest(request);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// GET /api/ping - Health check
|
||||||
|
_server.on("/api/ping", HTTP_GET,
|
||||||
|
[this](AsyncWebServerRequest* request) {
|
||||||
|
handlePingRequest(request);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Enable CORS for API endpoints (allows web apps to call the API)
|
||||||
|
DefaultHeaders::Instance().addHeader("Access-Control-Allow-Origin", "*");
|
||||||
|
DefaultHeaders::Instance().addHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
||||||
|
DefaultHeaders::Instance().addHeader("Access-Control-Allow-Headers", "Content-Type");
|
||||||
|
|
||||||
|
LOG_INFO("HTTPRequestHandler - REST API endpoints registered");
|
||||||
|
LOG_INFO(" POST /api/command - Execute commands");
|
||||||
|
LOG_INFO(" GET /api/status - System status");
|
||||||
|
LOG_INFO(" GET /api/ping - Health check");
|
||||||
|
}
|
||||||
|
|
||||||
|
void HTTPRequestHandler::setCommandHandlerReference(CommandHandler* handler) {
|
||||||
|
_commandHandler = handler;
|
||||||
|
LOG_DEBUG("HTTPRequestHandler - CommandHandler reference set");
|
||||||
|
}
|
||||||
|
|
||||||
|
bool HTTPRequestHandler::isHealthy() const {
|
||||||
|
// HTTP handler is healthy if it has been initialized with dependencies
|
||||||
|
return _commandHandler != nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
void HTTPRequestHandler::handleCommandRequest(AsyncWebServerRequest* request, uint8_t* data, size_t len) {
|
||||||
|
if (!_commandHandler) {
|
||||||
|
sendErrorResponse(request, 503, "Command handler not initialized");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse JSON from body
|
||||||
|
JsonDocument doc;
|
||||||
|
DeserializationError error = deserializeJson(doc, data, len);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
LOG_WARNING("HTTPRequestHandler - JSON parse error: %s", error.c_str());
|
||||||
|
sendErrorResponse(request, 400, "Invalid JSON");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
LOG_DEBUG("HTTPRequestHandler - Processing command via HTTP");
|
||||||
|
|
||||||
|
// Create message context for HTTP (treat as WebSocket with special ID)
|
||||||
|
CommandHandler::MessageContext context(CommandHandler::MessageSource::WEBSOCKET, 0xFFFFFFFF);
|
||||||
|
|
||||||
|
// Capture request pointer for response
|
||||||
|
AsyncWebServerRequest* capturedRequest = request;
|
||||||
|
bool responseSent = false;
|
||||||
|
|
||||||
|
// Set temporary response callback to capture the response
|
||||||
|
auto originalCallback = [capturedRequest, &responseSent](const String& response, const CommandHandler::MessageContext& ctx) {
|
||||||
|
if (!responseSent && capturedRequest != nullptr) {
|
||||||
|
capturedRequest->send(200, "application/json", response);
|
||||||
|
responseSent = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Temporarily override the command handler's response callback
|
||||||
|
// Note: This requires the CommandHandler to support callback override
|
||||||
|
// For now, we'll process and let the normal flow handle it
|
||||||
|
|
||||||
|
// Process the command
|
||||||
|
_commandHandler->processCommand(doc, context);
|
||||||
|
|
||||||
|
// If no response was sent by the callback, send a generic success
|
||||||
|
if (!responseSent) {
|
||||||
|
sendJsonResponse(request, 200, "{\"status\":\"ok\",\"message\":\"Command processed\"}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void HTTPRequestHandler::handleStatusRequest(AsyncWebServerRequest* request) {
|
||||||
|
if (!_commandHandler) {
|
||||||
|
sendErrorResponse(request, 503, "Command handler not initialized");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
LOG_DEBUG("HTTPRequestHandler - Status request via HTTP");
|
||||||
|
|
||||||
|
// Create a status command
|
||||||
|
JsonDocument doc;
|
||||||
|
doc["group"] = "system";
|
||||||
|
doc["action"] = "status";
|
||||||
|
|
||||||
|
// Create message context
|
||||||
|
CommandHandler::MessageContext context(CommandHandler::MessageSource::WEBSOCKET, 0xFFFFFFFF);
|
||||||
|
|
||||||
|
// Capture request for response
|
||||||
|
AsyncWebServerRequest* capturedRequest = request;
|
||||||
|
bool responseSent = false;
|
||||||
|
|
||||||
|
// Process via command handler
|
||||||
|
_commandHandler->processCommand(doc, context);
|
||||||
|
|
||||||
|
// Fallback response if needed
|
||||||
|
if (!responseSent) {
|
||||||
|
JsonDocument response;
|
||||||
|
response["status"] = "ok";
|
||||||
|
response["device_uid"] = _configManager.getDeviceUID();
|
||||||
|
response["fw_version"] = _configManager.getFwVersion();
|
||||||
|
|
||||||
|
String output;
|
||||||
|
serializeJson(response, output);
|
||||||
|
sendJsonResponse(request, 200, output);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void HTTPRequestHandler::handlePingRequest(AsyncWebServerRequest* request) {
|
||||||
|
LOG_DEBUG("HTTPRequestHandler - Ping request via HTTP");
|
||||||
|
|
||||||
|
JsonDocument response;
|
||||||
|
response["status"] = "ok";
|
||||||
|
response["message"] = "pong";
|
||||||
|
response["uptime"] = millis();
|
||||||
|
|
||||||
|
String output;
|
||||||
|
serializeJson(response, output);
|
||||||
|
sendJsonResponse(request, 200, output);
|
||||||
|
}
|
||||||
|
|
||||||
|
void HTTPRequestHandler::sendJsonResponse(AsyncWebServerRequest* request, int code, const String& json) {
|
||||||
|
request->send(code, "application/json", json);
|
||||||
|
}
|
||||||
|
|
||||||
|
void HTTPRequestHandler::sendErrorResponse(AsyncWebServerRequest* request, int code, const String& error) {
|
||||||
|
JsonDocument doc;
|
||||||
|
doc["status"] = "error";
|
||||||
|
doc["error"] = error;
|
||||||
|
|
||||||
|
String output;
|
||||||
|
serializeJson(doc, output);
|
||||||
|
sendJsonResponse(request, code, output);
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
/*
|
||||||
|
* ═══════════════════════════════════════════════════════════════════════════════════
|
||||||
|
* HTTPREQUESTHANDLER.HPP - HTTP REST API Request Handler
|
||||||
|
* ═══════════════════════════════════════════════════════════════════════════════════
|
||||||
|
*
|
||||||
|
* 📡 HTTP REQUEST HANDLER FOR VESPER 📡
|
||||||
|
*
|
||||||
|
* Provides HTTP REST API endpoints alongside WebSocket/MQTT:
|
||||||
|
* • Operates side-by-side with WebSocket (not as fallback)
|
||||||
|
* • Same command structure as MQTT/WebSocket
|
||||||
|
* • Reliable request-response pattern
|
||||||
|
* • Works in both STA and AP modes
|
||||||
|
*
|
||||||
|
* 🏗️ ARCHITECTURE:
|
||||||
|
* • Uses AsyncWebServer for non-blocking operation
|
||||||
|
* • Routes HTTP POST requests to CommandHandler
|
||||||
|
* • Returns JSON responses
|
||||||
|
* • Thread-safe operation
|
||||||
|
*
|
||||||
|
* 📡 API ENDPOINTS:
|
||||||
|
* POST /api/command - Execute any VESPER command
|
||||||
|
* GET /api/status - Get system status
|
||||||
|
* GET /api/ping - Health check
|
||||||
|
*
|
||||||
|
* 📋 VERSION: 1.0
|
||||||
|
* 📅 DATE: 2025-12-28
|
||||||
|
* 👨💻 AUTHOR: Advanced Bell Systems
|
||||||
|
* ═══════════════════════════════════════════════════════════════════════════════════
|
||||||
|
*/
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include <ESPAsyncWebServer.h>
|
||||||
|
#include <ArduinoJson.h>
|
||||||
|
|
||||||
|
// Forward declarations
|
||||||
|
class CommandHandler;
|
||||||
|
class ConfigManager;
|
||||||
|
|
||||||
|
class HTTPRequestHandler {
|
||||||
|
public:
|
||||||
|
explicit HTTPRequestHandler(AsyncWebServer& server,
|
||||||
|
ConfigManager& configManager);
|
||||||
|
~HTTPRequestHandler();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Initialize HTTP request handler and register endpoints
|
||||||
|
*/
|
||||||
|
void begin();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Set CommandHandler reference for processing commands
|
||||||
|
*/
|
||||||
|
void setCommandHandlerReference(CommandHandler* handler);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Check if HTTP handler is healthy
|
||||||
|
*/
|
||||||
|
bool isHealthy() const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
// Dependencies
|
||||||
|
AsyncWebServer& _server;
|
||||||
|
ConfigManager& _configManager;
|
||||||
|
CommandHandler* _commandHandler;
|
||||||
|
|
||||||
|
// Endpoint handlers
|
||||||
|
void handleCommandRequest(AsyncWebServerRequest* request, uint8_t* data, size_t len);
|
||||||
|
void handleStatusRequest(AsyncWebServerRequest* request);
|
||||||
|
void handlePingRequest(AsyncWebServerRequest* request);
|
||||||
|
|
||||||
|
// Helper methods
|
||||||
|
void sendJsonResponse(AsyncWebServerRequest* request, int code, const String& json);
|
||||||
|
void sendErrorResponse(AsyncWebServerRequest* request, int code, const String& error);
|
||||||
|
};
|
||||||
@@ -13,25 +13,55 @@ MQTTAsyncClient::MQTTAsyncClient(ConfigManager& configManager, Networking& netwo
|
|||||||
: _configManager(configManager)
|
: _configManager(configManager)
|
||||||
, _networking(networking)
|
, _networking(networking)
|
||||||
, _messageCallback(nullptr)
|
, _messageCallback(nullptr)
|
||||||
, _mqttReconnectTimer(nullptr) {
|
, _mqttReconnectTimer(nullptr)
|
||||||
|
, _networkStabilizationTimer(nullptr)
|
||||||
|
, _heartbeatTimer(nullptr)
|
||||||
|
, _reconnectAttempts(0)
|
||||||
|
, _lastConnectionAttempt(0) {
|
||||||
|
|
||||||
_instance = this; // Set static instance pointer
|
_instance = this; // Set static instance pointer
|
||||||
|
|
||||||
// Create reconnection timer
|
// Create reconnection timer (initial delay will be calculated dynamically)
|
||||||
_mqttReconnectTimer = xTimerCreate(
|
_mqttReconnectTimer = xTimerCreate(
|
||||||
"mqttReconnect", // Timer name (for debugging)
|
"mqttReconnect", // Timer name (for debugging)
|
||||||
pdMS_TO_TICKS(MQTT_RECONNECT_DELAY), // Period: 5000ms = 5 seconds
|
pdMS_TO_TICKS(MQTT_RECONNECT_BASE_DELAY), // Initial period: 5000ms = 5 seconds
|
||||||
pdFALSE, // One-shot (false) or Auto-reload (true)
|
pdFALSE, // One-shot (false) or Auto-reload (true)
|
||||||
(void*)0, // Timer ID (can store data)
|
(void*)0, // Timer ID (can store data)
|
||||||
mqttReconnectTimerCallback // Callback function when timer expires
|
mqttReconnectTimerCallback // Callback function when timer expires
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Create network stabilization timer (one-shot, 2 seconds)
|
||||||
|
_networkStabilizationTimer = xTimerCreate(
|
||||||
|
"mqttNetStable", // Timer name
|
||||||
|
pdMS_TO_TICKS(NETWORK_STABILIZATION_DELAY), // Period: 2000ms = 2 seconds
|
||||||
|
pdFALSE, // One-shot timer
|
||||||
|
(void*)0, // Timer ID
|
||||||
|
networkStabilizationTimerCallback // Callback function
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create heartbeat timer (auto-reload every 30 seconds)
|
||||||
|
_heartbeatTimer = xTimerCreate(
|
||||||
|
"mqttHeartbeat", // Timer name
|
||||||
|
pdMS_TO_TICKS(HEARTBEAT_INTERVAL), // Period: 30000ms = 30 seconds
|
||||||
|
pdTRUE, // Auto-reload (true) - repeating timer
|
||||||
|
(void*)0, // Timer ID
|
||||||
|
heartbeatTimerCallback // Callback function
|
||||||
|
);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
MQTTAsyncClient::~MQTTAsyncClient() {
|
MQTTAsyncClient::~MQTTAsyncClient() {
|
||||||
if (_mqttReconnectTimer) {
|
if (_mqttReconnectTimer) {
|
||||||
xTimerDelete(_mqttReconnectTimer, portMAX_DELAY);
|
xTimerDelete(_mqttReconnectTimer, portMAX_DELAY);
|
||||||
}
|
}
|
||||||
|
if (_networkStabilizationTimer) {
|
||||||
|
xTimerStop(_networkStabilizationTimer, 0);
|
||||||
|
xTimerDelete(_networkStabilizationTimer, portMAX_DELAY);
|
||||||
|
}
|
||||||
|
if (_heartbeatTimer) {
|
||||||
|
xTimerStop(_heartbeatTimer, 0);
|
||||||
|
xTimerDelete(_heartbeatTimer, portMAX_DELAY);
|
||||||
|
}
|
||||||
_mqttClient.disconnect();
|
_mqttClient.disconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,12 +114,21 @@ void MQTTAsyncClient::begin() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void MQTTAsyncClient::connect() {
|
void MQTTAsyncClient::connect() {
|
||||||
|
auto& mqttConfig = _configManager.getMqttConfig();
|
||||||
|
|
||||||
|
// 🔥 Check if MQTT is enabled
|
||||||
|
if (!mqttConfig.enabled) {
|
||||||
|
LOG_DEBUG("MQTT is disabled in configuration - skipping connection");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (_mqttClient.connected()) {
|
if (_mqttClient.connected()) {
|
||||||
LOG_DEBUG("Already connected to MQTT");
|
LOG_DEBUG("Already connected to MQTT");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
auto& mqttConfig = _configManager.getMqttConfig();
|
// Track connection attempt
|
||||||
|
_lastConnectionAttempt = millis();
|
||||||
|
|
||||||
LOG_INFO("Free heap BEFORE MQTT connect: %d bytes", ESP.getFreeHeap());
|
LOG_INFO("Free heap BEFORE MQTT connect: %d bytes", ESP.getFreeHeap());
|
||||||
|
|
||||||
@@ -104,6 +143,12 @@ void MQTTAsyncClient::disconnect() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
uint16_t MQTTAsyncClient::publish(const String& topic, const String& payload, int qos, bool retain) {
|
uint16_t MQTTAsyncClient::publish(const String& topic, const String& payload, int qos, bool retain) {
|
||||||
|
// Check if connected before attempting to publish
|
||||||
|
if (!_mqttClient.connected()) {
|
||||||
|
// Don't log error here - would cause infinite loop with MQTT logging
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
// Build full topic (if relative)
|
// Build full topic (if relative)
|
||||||
String fullTopic = topic.startsWith("vesper/") ? topic : _dataTopic;
|
String fullTopic = topic.startsWith("vesper/") ? topic : _dataTopic;
|
||||||
|
|
||||||
@@ -111,9 +156,8 @@ uint16_t MQTTAsyncClient::publish(const String& topic, const String& payload, in
|
|||||||
|
|
||||||
if (packetId > 0) {
|
if (packetId > 0) {
|
||||||
LOG_DEBUG("Published to %s: %s (packetId=%d)", fullTopic.c_str(), payload.c_str(), packetId);
|
LOG_DEBUG("Published to %s: %s (packetId=%d)", fullTopic.c_str(), payload.c_str(), packetId);
|
||||||
} else {
|
|
||||||
LOG_ERROR("Failed to publish to %s", fullTopic.c_str());
|
|
||||||
}
|
}
|
||||||
|
// REMOVED: Error logging here to prevent infinite recursion with MQTT logs
|
||||||
|
|
||||||
return packetId;
|
return packetId;
|
||||||
}
|
}
|
||||||
@@ -127,14 +171,29 @@ bool MQTTAsyncClient::isConnected() const {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void MQTTAsyncClient::onNetworkConnected() {
|
void MQTTAsyncClient::onNetworkConnected() {
|
||||||
LOG_DEBUG("Network connected - waiting 2 seconds for network stack to stabilize...");
|
auto& mqttConfig = _configManager.getMqttConfig();
|
||||||
|
|
||||||
// Small delay to ensure network stack is fully ready
|
// 🔥 Only attempt connection if MQTT is enabled
|
||||||
delay(2000);
|
if (!mqttConfig.enabled) {
|
||||||
|
LOG_DEBUG("Network connected but MQTT is disabled - skipping MQTT connection");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
LOG_DEBUG("Network stable - connecting to MQTT");
|
LOG_DEBUG("Network connected - scheduling MQTT connection after 2s stabilization (non-blocking)");
|
||||||
|
|
||||||
|
// Reset reconnect attempts on fresh network connection
|
||||||
|
_reconnectAttempts = 0;
|
||||||
|
|
||||||
|
// 🔥 CRITICAL FIX: Use non-blocking timer instead of delay()
|
||||||
|
// This prevents blocking UDP discovery, WebSocket connections, and async operations
|
||||||
|
if (_networkStabilizationTimer) {
|
||||||
|
xTimerStart(_networkStabilizationTimer, 0);
|
||||||
|
} else {
|
||||||
|
LOG_ERROR("Network stabilization timer not initialized!");
|
||||||
|
// Fallback to immediate connection (better than blocking)
|
||||||
connect();
|
connect();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void MQTTAsyncClient::onNetworkDisconnected() {
|
void MQTTAsyncClient::onNetworkDisconnected() {
|
||||||
LOG_DEBUG("Network disconnected - MQTT will auto-reconnect when network returns");
|
LOG_DEBUG("Network disconnected - MQTT will auto-reconnect when network returns");
|
||||||
@@ -153,11 +212,19 @@ void MQTTAsyncClient::onMqttConnect(bool sessionPresent) {
|
|||||||
LOG_INFO("✅ Connected to MQTT broker (session present: %s)", sessionPresent ? "yes" : "no");
|
LOG_INFO("✅ Connected to MQTT broker (session present: %s)", sessionPresent ? "yes" : "no");
|
||||||
LOG_INFO("🔍 Free heap AFTER MQTT connect: %d bytes", ESP.getFreeHeap());
|
LOG_INFO("🔍 Free heap AFTER MQTT connect: %d bytes", ESP.getFreeHeap());
|
||||||
|
|
||||||
|
// Reset reconnection attempts on successful connection
|
||||||
|
_reconnectAttempts = 0;
|
||||||
|
|
||||||
// Subscribe to control topic
|
// Subscribe to control topic
|
||||||
subscribe();
|
subscribe();
|
||||||
|
|
||||||
|
// 🔥 Start heartbeat timer
|
||||||
|
startHeartbeat();
|
||||||
}
|
}
|
||||||
|
|
||||||
void MQTTAsyncClient::onMqttDisconnect(AsyncMqttClientDisconnectReason reason) {
|
void MQTTAsyncClient::onMqttDisconnect(AsyncMqttClientDisconnectReason reason) {
|
||||||
|
auto& mqttConfig = _configManager.getMqttConfig();
|
||||||
|
|
||||||
const char* reasonStr;
|
const char* reasonStr;
|
||||||
switch(reason) {
|
switch(reason) {
|
||||||
case AsyncMqttClientDisconnectReason::TCP_DISCONNECTED:
|
case AsyncMqttClientDisconnectReason::TCP_DISCONNECTED:
|
||||||
@@ -185,8 +252,27 @@ void MQTTAsyncClient::onMqttDisconnect(AsyncMqttClientDisconnectReason reason) {
|
|||||||
|
|
||||||
LOG_ERROR("❌ Disconnected from MQTT broker - Reason: %s (%d)", reasonStr, static_cast<int>(reason));
|
LOG_ERROR("❌ Disconnected from MQTT broker - Reason: %s (%d)", reasonStr, static_cast<int>(reason));
|
||||||
|
|
||||||
|
// Stop heartbeat timer when disconnected
|
||||||
|
stopHeartbeat();
|
||||||
|
|
||||||
|
// 🔥 Don't attempt reconnection if MQTT is disabled
|
||||||
|
if (!mqttConfig.enabled) {
|
||||||
|
LOG_INFO("MQTT is disabled - not attempting reconnection");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (_networking.isConnected()) {
|
if (_networking.isConnected()) {
|
||||||
LOG_INFO("Network still connected - scheduling MQTT reconnection in %d seconds", MQTT_RECONNECT_DELAY / 1000);
|
// Increment reconnection attempts
|
||||||
|
_reconnectAttempts++;
|
||||||
|
|
||||||
|
// Calculate backoff delay
|
||||||
|
unsigned long reconnectDelay = getReconnectDelay();
|
||||||
|
|
||||||
|
LOG_INFO("Network still connected - scheduling MQTT reconnection #%d in %lu seconds (backoff active)",
|
||||||
|
_reconnectAttempts, reconnectDelay / 1000);
|
||||||
|
|
||||||
|
// Update timer period with new delay
|
||||||
|
xTimerChangePeriod(_mqttReconnectTimer, pdMS_TO_TICKS(reconnectDelay), 0);
|
||||||
xTimerStart(_mqttReconnectTimer, 0);
|
xTimerStart(_mqttReconnectTimer, 0);
|
||||||
} else {
|
} else {
|
||||||
LOG_INFO("Network is down - waiting for network to reconnect");
|
LOG_INFO("Network is down - waiting for network to reconnect");
|
||||||
@@ -238,3 +324,127 @@ void MQTTAsyncClient::mqttReconnectTimerCallback(TimerHandle_t xTimer) {
|
|||||||
MQTTAsyncClient::_instance->attemptReconnection();
|
MQTTAsyncClient::_instance->attemptReconnection();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════════
|
||||||
|
// HEARTBEAT FUNCTIONALITY
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
void MQTTAsyncClient::startHeartbeat() {
|
||||||
|
if (_heartbeatTimer) {
|
||||||
|
LOG_INFO("💓 Starting MQTT heartbeat (every %d seconds)", HEARTBEAT_INTERVAL / 1000);
|
||||||
|
|
||||||
|
// Publish first heartbeat immediately
|
||||||
|
publishHeartbeat();
|
||||||
|
|
||||||
|
// Start periodic timer
|
||||||
|
xTimerStart(_heartbeatTimer, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MQTTAsyncClient::stopHeartbeat() {
|
||||||
|
if (_heartbeatTimer) {
|
||||||
|
xTimerStop(_heartbeatTimer, 0);
|
||||||
|
LOG_INFO("❤️ Stopped MQTT heartbeat");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MQTTAsyncClient::publishHeartbeat() {
|
||||||
|
if (!_mqttClient.connected()) {
|
||||||
|
LOG_WARNING("⚠️ Cannot publish heartbeat - MQTT not connected");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build heartbeat JSON message
|
||||||
|
StaticJsonDocument<512> doc;
|
||||||
|
doc["status"] = "INFO";
|
||||||
|
doc["type"] = "heartbeat";
|
||||||
|
|
||||||
|
JsonObject payload = doc.createNestedObject("payload");
|
||||||
|
|
||||||
|
// Device ID from NVS
|
||||||
|
payload["device_id"] = _configManager.getDeviceUID();
|
||||||
|
|
||||||
|
// Firmware version
|
||||||
|
payload["firmware_version"] = _configManager.getFwVersion();
|
||||||
|
|
||||||
|
// Current date/time (from TimeKeeper if available, else uptime-based)
|
||||||
|
// For now, we'll use a simple timestamp format
|
||||||
|
unsigned long uptimeMs = millis();
|
||||||
|
unsigned long uptimeSec = uptimeMs / 1000;
|
||||||
|
unsigned long hours = uptimeSec / 3600;
|
||||||
|
unsigned long minutes = (uptimeSec % 3600) / 60;
|
||||||
|
unsigned long seconds = uptimeSec % 60;
|
||||||
|
|
||||||
|
char timestampStr[64];
|
||||||
|
snprintf(timestampStr, sizeof(timestampStr), "Uptime: %luh %lum %lus", hours, minutes, seconds);
|
||||||
|
payload["timestamp"] = timestampStr;
|
||||||
|
|
||||||
|
// IP address
|
||||||
|
payload["ip_address"] = _networking.getLocalIP();
|
||||||
|
|
||||||
|
// Gateway address
|
||||||
|
payload["gateway"] = _networking.getGateway();
|
||||||
|
|
||||||
|
// Uptime in milliseconds
|
||||||
|
payload["uptime_ms"] = uptimeMs;
|
||||||
|
|
||||||
|
// Serialize to string
|
||||||
|
String heartbeatMessage;
|
||||||
|
serializeJson(doc, heartbeatMessage);
|
||||||
|
|
||||||
|
// Publish to heartbeat topic with RETAIN flag
|
||||||
|
String heartbeatTopic = "vesper/" + _configManager.getDeviceUID() + "/status/heartbeat";
|
||||||
|
uint16_t packetId = _mqttClient.publish(heartbeatTopic.c_str(), 1, true, heartbeatMessage.c_str());
|
||||||
|
|
||||||
|
if (packetId > 0) {
|
||||||
|
LOG_DEBUG("💓 Published heartbeat (retained) - IP: %s, Uptime: %lums",
|
||||||
|
_networking.getLocalIP().c_str(), uptimeMs);
|
||||||
|
} else {
|
||||||
|
LOG_ERROR("❌ Failed to publish heartbeat");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MQTTAsyncClient::heartbeatTimerCallback(TimerHandle_t xTimer) {
|
||||||
|
if (MQTTAsyncClient::_instance) {
|
||||||
|
MQTTAsyncClient::_instance->publishHeartbeat();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════════
|
||||||
|
// NETWORK STABILIZATION - NON-BLOCKING TIMER APPROACH
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
void MQTTAsyncClient::connectAfterStabilization() {
|
||||||
|
LOG_DEBUG("Network stabilization complete - connecting to MQTT");
|
||||||
|
connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
void MQTTAsyncClient::networkStabilizationTimerCallback(TimerHandle_t xTimer) {
|
||||||
|
if (MQTTAsyncClient::_instance) {
|
||||||
|
MQTTAsyncClient::_instance->connectAfterStabilization();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════════
|
||||||
|
// EXPONENTIAL BACKOFF CALCULATION
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
unsigned long MQTTAsyncClient::getReconnectDelay() {
|
||||||
|
// First 3 attempts: Quick retries (5 seconds each)
|
||||||
|
if (_reconnectAttempts <= MQTT_MAX_QUICK_RETRIES) {
|
||||||
|
return MQTT_RECONNECT_BASE_DELAY;
|
||||||
|
}
|
||||||
|
|
||||||
|
// After quick retries: Exponential backoff
|
||||||
|
// Formula: base_delay * 2^(attempts - quick_retries)
|
||||||
|
// Examples: 10s, 20s, 40s, 80s, 160s, 300s (capped at 5 minutes)
|
||||||
|
uint8_t backoffPower = _reconnectAttempts - MQTT_MAX_QUICK_RETRIES;
|
||||||
|
unsigned long delay = MQTT_RECONNECT_BASE_DELAY * (1 << backoffPower); // 2^backoffPower
|
||||||
|
|
||||||
|
// Cap at maximum delay (5 minutes)
|
||||||
|
if (delay > MQTT_RECONNECT_MAX_DELAY) {
|
||||||
|
delay = MQTT_RECONNECT_MAX_DELAY;
|
||||||
|
}
|
||||||
|
|
||||||
|
return delay;
|
||||||
|
}
|
||||||
@@ -108,9 +108,28 @@ private:
|
|||||||
void onMqttMessage(char* topic, char* payload, AsyncMqttClientMessageProperties properties, size_t len, size_t index, size_t total);
|
void onMqttMessage(char* topic, char* payload, AsyncMqttClientMessageProperties properties, size_t len, size_t index, size_t total);
|
||||||
void onMqttPublish(uint16_t packetId);
|
void onMqttPublish(uint16_t packetId);
|
||||||
|
|
||||||
// Reconnection Timer
|
// Reconnection Timer with Exponential Backoff
|
||||||
TimerHandle_t _mqttReconnectTimer;
|
TimerHandle_t _mqttReconnectTimer;
|
||||||
static const unsigned long MQTT_RECONNECT_DELAY = 5000; // 5 seconds
|
static const unsigned long MQTT_RECONNECT_BASE_DELAY = 5000; // 5 seconds base
|
||||||
|
static const unsigned long MQTT_RECONNECT_MAX_DELAY = 300000; // 5 minutes max
|
||||||
|
static const uint8_t MQTT_MAX_QUICK_RETRIES = 3; // Try 3 times quickly
|
||||||
|
uint8_t _reconnectAttempts; // Track failed attempts
|
||||||
|
unsigned long _lastConnectionAttempt; // Track last attempt time
|
||||||
void attemptReconnection();
|
void attemptReconnection();
|
||||||
static void mqttReconnectTimerCallback(TimerHandle_t xTimer);
|
static void mqttReconnectTimerCallback(TimerHandle_t xTimer);
|
||||||
|
unsigned long getReconnectDelay(); // Calculate backoff delay
|
||||||
|
|
||||||
|
// Network Stabilization Timer (non-blocking replacement for delay)
|
||||||
|
TimerHandle_t _networkStabilizationTimer;
|
||||||
|
static const unsigned long NETWORK_STABILIZATION_DELAY = 2000; // 2 seconds
|
||||||
|
void connectAfterStabilization();
|
||||||
|
static void networkStabilizationTimerCallback(TimerHandle_t xTimer);
|
||||||
|
|
||||||
|
// Heartbeat Timer (30 seconds)
|
||||||
|
TimerHandle_t _heartbeatTimer;
|
||||||
|
static const unsigned long HEARTBEAT_INTERVAL = 30000; // 30 seconds
|
||||||
|
void publishHeartbeat();
|
||||||
|
static void heartbeatTimerCallback(TimerHandle_t xTimer);
|
||||||
|
void startHeartbeat();
|
||||||
|
void stopHeartbeat();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ String ResponseBuilder::pong() {
|
|||||||
return success("pong", "");
|
return success("pong", "");
|
||||||
}
|
}
|
||||||
|
|
||||||
String ResponseBuilder::deviceStatus(PlayerStatus playerStatus, uint32_t timeElapsed, uint64_t projectedRunTime) {
|
String ResponseBuilder::deviceStatus(PlayerStatus playerStatus, uint32_t timeElapsed, uint64_t projectedRunTime, const uint32_t strikeCounters[16]) {
|
||||||
StaticJsonDocument<512> statusDoc; // Increased size for additional data
|
DynamicJsonDocument statusDoc(1024); // Increased size for strikeCounters array
|
||||||
|
|
||||||
statusDoc["status"] = "SUCCESS";
|
statusDoc["status"] = "SUCCESS";
|
||||||
statusDoc["type"] = "current_status";
|
statusDoc["type"] = "current_status";
|
||||||
@@ -63,6 +63,12 @@ String ResponseBuilder::deviceStatus(PlayerStatus playerStatus, uint32_t timeEla
|
|||||||
payload["time_elapsed"] = timeElapsed; // in milliseconds
|
payload["time_elapsed"] = timeElapsed; // in milliseconds
|
||||||
payload["projected_run_time"] = projectedRunTime; // NEW: total projected duration
|
payload["projected_run_time"] = projectedRunTime; // NEW: total projected duration
|
||||||
|
|
||||||
|
// Add strike counters array
|
||||||
|
JsonArray strikeCountersArray = payload.createNestedArray("strike_counters");
|
||||||
|
for (uint8_t i = 0; i < 16; i++) {
|
||||||
|
strikeCountersArray.add(strikeCounters[i]);
|
||||||
|
}
|
||||||
|
|
||||||
String result;
|
String result;
|
||||||
serializeJson(statusDoc, result);
|
serializeJson(statusDoc, result);
|
||||||
|
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ public:
|
|||||||
// Specialized response builders for common scenarios
|
// Specialized response builders for common scenarios
|
||||||
static String acknowledgment(const String& commandType);
|
static String acknowledgment(const String& commandType);
|
||||||
static String pong();
|
static String pong();
|
||||||
static String deviceStatus(PlayerStatus playerStatus, uint32_t timeElapsedMs, uint64_t projectedRunTime = 0);
|
static String deviceStatus(PlayerStatus playerStatus, uint32_t timeElapsedMs, uint64_t projectedRunTime, const uint32_t strikeCounters[16]);
|
||||||
static String melodyList(const String& fileListJson);
|
static String melodyList(const String& fileListJson);
|
||||||
static String downloadResult(bool success, const String& filename = "");
|
static String downloadResult(bool success, const String& filename = "");
|
||||||
static String configUpdate(const String& configType);
|
static String configUpdate(const String& configType);
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ void ConfigManager::initializeCleanDefaults() {
|
|||||||
// Set MQTT user to deviceUID for unique identification
|
// Set MQTT user to deviceUID for unique identification
|
||||||
mqttConfig.user = deviceConfig.deviceUID;
|
mqttConfig.user = deviceConfig.deviceUID;
|
||||||
|
|
||||||
LOG_INFO("ConfigManager: Clean defaults initialized with auto-generated identifiers");
|
LOG_DEBUG("ConfigManager - Clean defaults initialized with auto-generated identifiers");
|
||||||
}
|
}
|
||||||
|
|
||||||
void ConfigManager::generateNetworkIdentifiers() {
|
void ConfigManager::generateNetworkIdentifiers() {
|
||||||
@@ -36,7 +36,7 @@ void ConfigManager::generateNetworkIdentifiers() {
|
|||||||
networkConfig.apSsid = "BellSystems-Setup-" + deviceConfig.deviceUID;
|
networkConfig.apSsid = "BellSystems-Setup-" + deviceConfig.deviceUID;
|
||||||
|
|
||||||
|
|
||||||
LOG_INFO("ConfigManager: Generated hostname: %s, AP SSID: %s",
|
LOG_DEBUG("ConfigManager - Generated hostname: %s, AP SSID: %s",
|
||||||
networkConfig.hostname.c_str(), networkConfig.apSsid.c_str());
|
networkConfig.hostname.c_str(), networkConfig.apSsid.c_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,16 +44,16 @@ void ConfigManager::createDefaultBellConfig() {
|
|||||||
// Initialize default durations (90ms for all bells)
|
// Initialize default durations (90ms for all bells)
|
||||||
for (uint8_t i = 0; i < 16; i++) {
|
for (uint8_t i = 0; i < 16; i++) {
|
||||||
bellConfig.durations[i] = 90;
|
bellConfig.durations[i] = 90;
|
||||||
bellConfig.outputs[i] = i + 1; // 1-indexed mapping by default
|
bellConfig.outputs[i] = i; // 0-indexed mapping
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bool ConfigManager::begin() {
|
bool ConfigManager::begin() {
|
||||||
LOG_INFO("ConfigManager: Starting clean deployment-ready initialization");
|
LOG_INFO("ConfigManager - ✅ Initializing...");
|
||||||
|
|
||||||
// Step 1: Initialize NVS for device identity (factory-set, permanent)
|
// Step 1: Initialize NVS for device identity (factory-set, permanent)
|
||||||
if (!initializeNVS()) {
|
if (!initializeNVS()) {
|
||||||
LOG_ERROR("ConfigManager: NVS initialization failed, using empty defaults");
|
LOG_ERROR("ConfigManager - ❌ NVS initialization failed, using empty defaults");
|
||||||
} else {
|
} else {
|
||||||
// Load device identity from NVS (deviceUID, hwType, hwVersion)
|
// Load device identity from NVS (deviceUID, hwType, hwVersion)
|
||||||
loadDeviceIdentityFromNVS();
|
loadDeviceIdentityFromNVS();
|
||||||
@@ -64,49 +64,58 @@ bool ConfigManager::begin() {
|
|||||||
|
|
||||||
// Step 3: Initialize SD card for user-configurable settings
|
// Step 3: Initialize SD card for user-configurable settings
|
||||||
if (!ensureSDCard()) {
|
if (!ensureSDCard()) {
|
||||||
LOG_ERROR("ConfigManager: SD Card initialization failed, using defaults");
|
LOG_ERROR("ConfigManager - ❌ SD Card initialization failed, using defaults");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 4: Load device configuration from SD card (firmware version only)
|
|
||||||
if (!loadDeviceConfig()) {
|
|
||||||
LOG_INFO("ConfigManager: Creating default device config file");
|
|
||||||
saveDeviceConfig();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 5: Load update servers list
|
// Step 5: Load update servers list
|
||||||
if (!loadUpdateServers()) {
|
if (!loadUpdateServers()) {
|
||||||
LOG_WARNING("ConfigManager: Could not load update servers - using fallback only");
|
LOG_WARNING("ConfigManager - ⚠️ Could not load update servers - using fallback only");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 6: Load user-configurable settings from SD (and create if missing)
|
|
||||||
loadFromSD();
|
|
||||||
|
|
||||||
// Load network config, save defaults if not found
|
// Load network config, save defaults if not found
|
||||||
if (!loadNetworkConfig()) {
|
if (!loadNetworkConfig()) {
|
||||||
LOG_INFO("ConfigManager: Creating default network config file");
|
LOG_WARNING("ConfigManager - ⚠️ Creating default network config file");
|
||||||
saveNetworkConfig();
|
saveNetworkConfig();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load time config, save defaults if not found
|
||||||
|
if (!loadTimeConfig()) {
|
||||||
|
LOG_WARNING("ConfigManager - ⚠️ Creating default time config file (GMT+2)");
|
||||||
|
saveTimeConfig();
|
||||||
|
}
|
||||||
|
|
||||||
// Load bell durations, save defaults if not found
|
// Load bell durations, save defaults if not found
|
||||||
if (!loadBellDurations()) {
|
if (!loadBellDurations()) {
|
||||||
LOG_INFO("ConfigManager: Creating default bell durations file");
|
LOG_WARNING("ConfigManager - ⚠️ Creating default bell durations file");
|
||||||
saveBellDurations();
|
saveBellDurations();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load bell outputs, save defaults if not found
|
||||||
|
if (!loadBellOutputs()) {
|
||||||
|
LOG_WARNING("ConfigManager - ⚠️ Creating default bell outputs file");
|
||||||
|
saveBellOutputs();
|
||||||
|
}
|
||||||
|
|
||||||
// Load clock config, save defaults if not found
|
// Load clock config, save defaults if not found
|
||||||
if (!loadClockConfig()) {
|
if (!loadClockConfig()) {
|
||||||
LOG_INFO("ConfigManager: Creating default clock config file");
|
LOG_WARNING("ConfigManager - ⚠️ Creating default clock config file");
|
||||||
saveClockConfig();
|
saveClockConfig();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load clock state, save defaults if not found
|
// Load clock state, save defaults if not found
|
||||||
if (!loadClockState()) {
|
if (!loadClockState()) {
|
||||||
LOG_INFO("ConfigManager: Creating default clock state file");
|
LOG_WARNING("ConfigManager - ⚠️ Creating default clock state file");
|
||||||
saveClockState();
|
saveClockState();
|
||||||
}
|
}
|
||||||
|
|
||||||
LOG_INFO("ConfigManager: Initialization complete - UID: %s, Hostname: %s",
|
if (!loadGeneralConfig()) {
|
||||||
|
LOG_WARNING("ConfigManager - ⚠️ Creating default general config file");
|
||||||
|
saveGeneralConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
LOG_INFO("ConfigManager - ✅ Initialization Complete ! UID: %s, Hostname: %s",
|
||||||
deviceConfig.deviceUID.c_str(), networkConfig.hostname.c_str());
|
deviceConfig.deviceUID.c_str(), networkConfig.hostname.c_str());
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -118,29 +127,29 @@ bool ConfigManager::begin() {
|
|||||||
bool ConfigManager::initializeNVS() {
|
bool ConfigManager::initializeNVS() {
|
||||||
esp_err_t err = nvs_flash_init();
|
esp_err_t err = nvs_flash_init();
|
||||||
if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) {
|
if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) {
|
||||||
LOG_WARNING("ConfigManager: NVS partition truncated, erasing...");
|
LOG_WARNING("ConfigManager - ⚠️ NVS partition truncated, erasing...");
|
||||||
ESP_ERROR_CHECK(nvs_flash_erase());
|
ESP_ERROR_CHECK(nvs_flash_erase());
|
||||||
err = nvs_flash_init();
|
err = nvs_flash_init();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (err != ESP_OK) {
|
if (err != ESP_OK) {
|
||||||
LOG_ERROR("ConfigManager: Failed to initialize NVS flash: %s", esp_err_to_name(err));
|
LOG_ERROR("ConfigManager - ❌ Failed to initialize NVS flash: %s", esp_err_to_name(err));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
err = nvs_open(NVS_NAMESPACE, NVS_READWRITE, &nvsHandle);
|
err = nvs_open(NVS_NAMESPACE, NVS_READWRITE, &nvsHandle);
|
||||||
if (err != ESP_OK) {
|
if (err != ESP_OK) {
|
||||||
LOG_ERROR("ConfigManager: Failed to open NVS handle: %s", esp_err_to_name(err));
|
LOG_ERROR("ConfigManager - ❌ Failed to open NVS handle: %s", esp_err_to_name(err));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
LOG_INFO("ConfigManager: NVS initialized successfully");
|
LOG_DEBUG("ConfigManager - NVS initialized successfully");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool ConfigManager::loadDeviceIdentityFromNVS() {
|
bool ConfigManager::loadDeviceIdentityFromNVS() {
|
||||||
if (nvsHandle == 0) {
|
if (nvsHandle == 0) {
|
||||||
LOG_ERROR("ConfigManager: NVS not initialized, cannot load device identity");
|
LOG_ERROR("ConfigManager - ❌ NVS not initialized, cannot load device identity");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,7 +185,7 @@ bool ConfigManager::loadDeviceIdentityFromNVS() {
|
|||||||
|
|
||||||
String ConfigManager::readNVSString(const char* key, const String& defaultValue) {
|
String ConfigManager::readNVSString(const char* key, const String& defaultValue) {
|
||||||
if (nvsHandle == 0) {
|
if (nvsHandle == 0) {
|
||||||
LOG_WARNING("ConfigManager: NVS not initialized, returning default for key: %s", key);
|
LOG_ERROR("ConfigManager - ❌ NVS not initialized, returning default for key: %s", key);
|
||||||
return defaultValue;
|
return defaultValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,12 +193,12 @@ String ConfigManager::readNVSString(const char* key, const String& defaultValue)
|
|||||||
esp_err_t err = nvs_get_str(nvsHandle, key, NULL, &required_size);
|
esp_err_t err = nvs_get_str(nvsHandle, key, NULL, &required_size);
|
||||||
|
|
||||||
if (err == ESP_ERR_NVS_NOT_FOUND) {
|
if (err == ESP_ERR_NVS_NOT_FOUND) {
|
||||||
LOG_DEBUG("ConfigManager: NVS key '%s' not found, using default: %s", key, defaultValue.c_str());
|
LOG_DEBUG("ConfigManager - NVS key '%s' not found, using default: %s", key, defaultValue.c_str());
|
||||||
return defaultValue;
|
return defaultValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (err != ESP_OK) {
|
if (err != ESP_OK) {
|
||||||
LOG_ERROR("ConfigManager: Error reading NVS key '%s': %s", key, esp_err_to_name(err));
|
LOG_ERROR("ConfigManager - ❌ Error reading NVS key '%s': %s", key, esp_err_to_name(err));
|
||||||
return defaultValue;
|
return defaultValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,7 +206,7 @@ String ConfigManager::readNVSString(const char* key, const String& defaultValue)
|
|||||||
err = nvs_get_str(nvsHandle, key, buffer, &required_size);
|
err = nvs_get_str(nvsHandle, key, buffer, &required_size);
|
||||||
|
|
||||||
if (err != ESP_OK) {
|
if (err != ESP_OK) {
|
||||||
LOG_ERROR("ConfigManager: Error reading NVS value for key '%s': %s", key, esp_err_to_name(err));
|
LOG_ERROR("ConfigManager - ❌ Error reading NVS value for key '%s': %s", key, esp_err_to_name(err));
|
||||||
delete[] buffer;
|
delete[] buffer;
|
||||||
return defaultValue;
|
return defaultValue;
|
||||||
}
|
}
|
||||||
@@ -205,13 +214,10 @@ String ConfigManager::readNVSString(const char* key, const String& defaultValue)
|
|||||||
String result = String(buffer);
|
String result = String(buffer);
|
||||||
delete[] buffer;
|
delete[] buffer;
|
||||||
|
|
||||||
LOG_DEBUG("ConfigManager: Read NVS key '%s': %s", key, result.c_str());
|
LOG_VERBOSE("ConfigManager - Read NVS key '%s': %s", key, result.c_str());
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// REMOVED: writeNVSString() - Production firmware MUST NOT write to NVS
|
|
||||||
// All device identity is factory-set and read-only in production firmware
|
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
// STANDARD SD CARD FUNCTIONALITY
|
// STANDARD SD CARD FUNCTIONALITY
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
@@ -219,21 +225,16 @@ String ConfigManager::readNVSString(const char* key, const String& defaultValue)
|
|||||||
bool ConfigManager::ensureSDCard() {
|
bool ConfigManager::ensureSDCard() {
|
||||||
if (!sdInitialized) {
|
if (!sdInitialized) {
|
||||||
sdInitialized = SD.begin(hardwareConfig.sdChipSelect);
|
sdInitialized = SD.begin(hardwareConfig.sdChipSelect);
|
||||||
|
if (!sdInitialized) {
|
||||||
|
LOG_ERROR("ConfigManager - ❌ SD Card not available");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return sdInitialized;
|
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() {
|
bool ConfigManager::saveToSD() {
|
||||||
if (!ensureSDCard()) {
|
if (!ensureSDCard()) {
|
||||||
LOG_ERROR("ConfigManager: Cannot save to SD - SD not available");
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,7 +248,6 @@ bool ConfigManager::saveToSD() {
|
|||||||
// Device configuration now only handles firmware version (identity is in NVS)
|
// Device configuration now only handles firmware version (identity is in NVS)
|
||||||
bool ConfigManager::saveDeviceConfig() {
|
bool ConfigManager::saveDeviceConfig() {
|
||||||
if (!ensureSDCard()) {
|
if (!ensureSDCard()) {
|
||||||
LOG_ERROR("ConfigManager: Cannot save device config - SD not available");
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,12 +258,12 @@ bool ConfigManager::saveDeviceConfig() {
|
|||||||
size_t len = serializeJson(doc, buffer, sizeof(buffer));
|
size_t len = serializeJson(doc, buffer, sizeof(buffer));
|
||||||
|
|
||||||
if (len == 0 || len >= sizeof(buffer)) {
|
if (len == 0 || len >= sizeof(buffer)) {
|
||||||
LOG_ERROR("ConfigManager: Failed to serialize device config JSON");
|
LOG_ERROR("ConfigManager - ❌ Failed to serialize device config JSON");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
saveFileToSD("/settings", "deviceConfig.json", buffer);
|
saveFileToSD("/settings", "deviceConfig.json", buffer);
|
||||||
LOG_INFO("ConfigManager: Device config saved - FwVer: %s", deviceConfig.fwVersion.c_str());
|
LOG_DEBUG("ConfigManager - Device config saved - FwVer: %s", deviceConfig.fwVersion.c_str());
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,7 +272,7 @@ bool ConfigManager::loadDeviceConfig() {
|
|||||||
|
|
||||||
File file = SD.open("/settings/deviceConfig.json", FILE_READ);
|
File file = SD.open("/settings/deviceConfig.json", FILE_READ);
|
||||||
if (!file) {
|
if (!file) {
|
||||||
LOG_WARNING("ConfigManager: Device config file not found - using firmware version default");
|
LOG_WARNING("ConfigManager - ⚠️ Device config file not found - using firmware version default");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,13 +281,13 @@ bool ConfigManager::loadDeviceConfig() {
|
|||||||
file.close();
|
file.close();
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
LOG_ERROR("ConfigManager: Failed to parse device config from SD: %s", error.c_str());
|
LOG_ERROR("ConfigManager - ❌ Failed to parse device config from SD: %s", error.c_str());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (doc.containsKey("fwVersion")) {
|
if (doc.containsKey("fwVersion")) {
|
||||||
deviceConfig.fwVersion = doc["fwVersion"].as<String>();
|
deviceConfig.fwVersion = doc["fwVersion"].as<String>();
|
||||||
LOG_INFO("ConfigManager: Firmware version loaded from SD: %s", deviceConfig.fwVersion.c_str());
|
LOG_VERBOSE("ConfigManager - Firmware version loaded from SD: %s", deviceConfig.fwVersion.c_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -295,22 +295,22 @@ bool ConfigManager::loadDeviceConfig() {
|
|||||||
|
|
||||||
bool ConfigManager::isHealthy() const {
|
bool ConfigManager::isHealthy() const {
|
||||||
if (!sdInitialized) {
|
if (!sdInitialized) {
|
||||||
LOG_DEBUG("ConfigManager: Unhealthy - SD card not initialized");
|
LOG_VERBOSE("ConfigManager - ⚠️ Unhealthy - SD card not initialized");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (deviceConfig.deviceUID.isEmpty()) {
|
if (deviceConfig.deviceUID.isEmpty()) {
|
||||||
LOG_DEBUG("ConfigManager: Unhealthy - Device UID not set (factory configuration required)");
|
LOG_VERBOSE("ConfigManager - ⚠️ Unhealthy - Device UID not set (factory configuration required)");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (deviceConfig.hwType.isEmpty()) {
|
if (deviceConfig.hwType.isEmpty()) {
|
||||||
LOG_DEBUG("ConfigManager: Unhealthy - Hardware type not set (factory configuration required)");
|
LOG_VERBOSE("ConfigManager - ⚠️ Unhealthy - Hardware type not set (factory configuration required)");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (networkConfig.hostname.isEmpty()) {
|
if (networkConfig.hostname.isEmpty()) {
|
||||||
LOG_DEBUG("ConfigManager: Unhealthy - Hostname not generated (initialization issue)");
|
LOG_VERBOSE("ConfigManager - ⚠️ Unhealthy - Hostname not generated (initialization issue)");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,13 +322,12 @@ bool ConfigManager::isHealthy() const {
|
|||||||
// Bell configuration methods remain unchanged...
|
// Bell configuration methods remain unchanged...
|
||||||
bool ConfigManager::loadBellDurations() {
|
bool ConfigManager::loadBellDurations() {
|
||||||
if (!ensureSDCard()) {
|
if (!ensureSDCard()) {
|
||||||
LOG_ERROR("ConfigManager: SD Card not initialized. Using default bell durations.");
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
File file = SD.open("/settings/relayTimings.json", FILE_READ);
|
File file = SD.open("/settings/relayTimings.json", FILE_READ);
|
||||||
if (!file) {
|
if (!file) {
|
||||||
LOG_WARNING("ConfigManager: Settings file not found on SD. Using default bell durations.");
|
LOG_WARNING("ConfigManager - ⚠️ Settings file not found on SD. Using default bell durations.");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,7 +336,7 @@ bool ConfigManager::loadBellDurations() {
|
|||||||
file.close();
|
file.close();
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
LOG_ERROR("ConfigManager: Failed to parse settings from SD. Using default bell durations.");
|
LOG_ERROR("ConfigManager - ❌ Failed to parse settings from SD. Using default bell durations.");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,7 +347,7 @@ bool ConfigManager::loadBellDurations() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LOG_INFO("ConfigManager: Bell durations loaded from SD");
|
LOG_DEBUG("ConfigManager - Bell durations loaded from SD");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -365,12 +364,65 @@ bool ConfigManager::saveBellDurations() {
|
|||||||
size_t len = serializeJson(doc, buffer, sizeof(buffer));
|
size_t len = serializeJson(doc, buffer, sizeof(buffer));
|
||||||
|
|
||||||
if (len == 0 || len >= sizeof(buffer)) {
|
if (len == 0 || len >= sizeof(buffer)) {
|
||||||
LOG_ERROR("ConfigManager: Failed to serialize bell durations JSON");
|
LOG_ERROR("ConfigManager - ❌ Failed to serialize bell durations JSON");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
saveFileToSD("/settings", "relayTimings.json", buffer);
|
saveFileToSD("/settings", "relayTimings.json", buffer);
|
||||||
LOG_INFO("ConfigManager: Bell durations saved to SD");
|
LOG_DEBUG("ConfigManager - Bell durations saved to SD");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ConfigManager::loadBellOutputs() {
|
||||||
|
if (!ensureSDCard()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
File file = SD.open("/settings/bellOutputs.json", FILE_READ);
|
||||||
|
if (!file) {
|
||||||
|
LOG_WARNING("ConfigManager - ⚠️ Bell outputs file not found on SD. Using default 1:1 mapping.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
StaticJsonDocument<512> doc;
|
||||||
|
DeserializationError error = deserializeJson(doc, file);
|
||||||
|
file.close();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
LOG_ERROR("ConfigManager - ❌ Failed to parse bell outputs from SD. Using defaults.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (uint8_t i = 0; i < 16; i++) {
|
||||||
|
String key = String("b") + (i + 1);
|
||||||
|
if (doc.containsKey(key)) {
|
||||||
|
bellConfig.outputs[i] = doc[key].as<uint16_t>(); // Already 0-indexed in file
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LOG_DEBUG("ConfigManager - Bell outputs loaded from SD");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ConfigManager::saveBellOutputs() {
|
||||||
|
if (!ensureSDCard()) return false;
|
||||||
|
|
||||||
|
StaticJsonDocument<512> doc;
|
||||||
|
for (uint8_t i = 0; i < 16; i++) {
|
||||||
|
String key = String("b") + (i + 1);
|
||||||
|
doc[key] = bellConfig.outputs[i]; // Save 0-indexed outputs
|
||||||
|
}
|
||||||
|
|
||||||
|
char buffer[512];
|
||||||
|
size_t len = serializeJson(doc, buffer, sizeof(buffer));
|
||||||
|
|
||||||
|
if (len == 0 || len >= sizeof(buffer)) {
|
||||||
|
LOG_ERROR("ConfigManager - ❌ Failed to serialize bell outputs JSON");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
saveFileToSD("/settings", "bellOutputs.json", buffer);
|
||||||
|
LOG_DEBUG("ConfigManager - Bell outputs saved to SD");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -381,7 +433,7 @@ void ConfigManager::updateBellDurations(JsonVariant doc) {
|
|||||||
bellConfig.durations[i] = doc[key].as<uint16_t>();
|
bellConfig.durations[i] = doc[key].as<uint16_t>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LOG_INFO("ConfigManager: Updated bell durations");
|
LOG_DEBUG("ConfigManager - Updated bell durations");
|
||||||
}
|
}
|
||||||
|
|
||||||
void ConfigManager::updateBellOutputs(JsonVariant doc) {
|
void ConfigManager::updateBellOutputs(JsonVariant doc) {
|
||||||
@@ -389,11 +441,16 @@ void ConfigManager::updateBellOutputs(JsonVariant doc) {
|
|||||||
String key = String("b") + (i + 1);
|
String key = String("b") + (i + 1);
|
||||||
if (doc.containsKey(key)) {
|
if (doc.containsKey(key)) {
|
||||||
bellConfig.outputs[i] = doc[key].as<uint16_t>() - 1;
|
bellConfig.outputs[i] = doc[key].as<uint16_t>() - 1;
|
||||||
|
LOG_VERBOSE("ConfigManager - Bell %d output set to %d", i + 1, bellConfig.outputs[i]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LOG_INFO("ConfigManager: Updated bell outputs");
|
LOG_DEBUG("ConfigManager - Updated bell outputs");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
uint16_t ConfigManager::getBellDuration(uint8_t bellIndex) const {
|
uint16_t ConfigManager::getBellDuration(uint8_t bellIndex) const {
|
||||||
if (bellIndex >= 16) return 90;
|
if (bellIndex >= 16) return 90;
|
||||||
return bellConfig.durations[bellIndex];
|
return bellConfig.durations[bellIndex];
|
||||||
@@ -418,7 +475,6 @@ void ConfigManager::setBellOutput(uint8_t bellIndex, uint16_t output) {
|
|||||||
|
|
||||||
void ConfigManager::saveFileToSD(const char* dirPath, const char* filename, const char* data) {
|
void ConfigManager::saveFileToSD(const char* dirPath, const char* filename, const char* data) {
|
||||||
if (!ensureSDCard()) {
|
if (!ensureSDCard()) {
|
||||||
LOG_ERROR("ConfigManager: SD Card not initialized!");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -432,13 +488,13 @@ void ConfigManager::saveFileToSD(const char* dirPath, const char* filename, cons
|
|||||||
|
|
||||||
File file = SD.open(fullPath.c_str(), FILE_WRITE);
|
File file = SD.open(fullPath.c_str(), FILE_WRITE);
|
||||||
if (!file) {
|
if (!file) {
|
||||||
LOG_ERROR("ConfigManager: Failed to open file: %s", fullPath.c_str());
|
LOG_ERROR("ConfigManager - ❌ Failed to open file: %s", fullPath.c_str());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
file.print(data);
|
file.print(data);
|
||||||
file.close();
|
file.close();
|
||||||
LOG_INFO("ConfigManager: File %s saved successfully", fullPath.c_str());
|
LOG_VERBOSE("ConfigManager - File %s saved successfully", fullPath.c_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clock configuration methods and other remaining methods follow the same pattern...
|
// Clock configuration methods and other remaining methods follow the same pattern...
|
||||||
@@ -461,7 +517,7 @@ void ConfigManager::updateClockOutputs(JsonVariant doc) {
|
|||||||
if (doc.containsKey("pauseDuration")) {
|
if (doc.containsKey("pauseDuration")) {
|
||||||
clockConfig.pauseDuration = doc["pauseDuration"].as<uint16_t>();
|
clockConfig.pauseDuration = doc["pauseDuration"].as<uint16_t>();
|
||||||
}
|
}
|
||||||
LOG_INFO("ConfigManager: Updated Clock outputs to: C1: %d / C2: %d, Pulse: %dms, Pause: %dms",
|
LOG_DEBUG("ConfigManager - Updated Clock outputs to: C1: %d / C2: %d, Pulse: %dms, Pause: %dms",
|
||||||
clockConfig.c1output, clockConfig.c2output, clockConfig.pulseDuration, clockConfig.pauseDuration);
|
clockConfig.c1output, clockConfig.c2output, clockConfig.pulseDuration, clockConfig.pauseDuration);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -481,7 +537,7 @@ void ConfigManager::updateClockAlerts(JsonVariant doc) {
|
|||||||
if (doc.containsKey("quarterBell")) {
|
if (doc.containsKey("quarterBell")) {
|
||||||
clockConfig.quarterBell = doc["quarterBell"].as<uint8_t>();
|
clockConfig.quarterBell = doc["quarterBell"].as<uint8_t>();
|
||||||
}
|
}
|
||||||
LOG_INFO("ConfigManager: Updated Clock alerts");
|
LOG_DEBUG("ConfigManager - Updated Clock alerts");
|
||||||
}
|
}
|
||||||
|
|
||||||
void ConfigManager::updateClockBacklight(JsonVariant doc) {
|
void ConfigManager::updateClockBacklight(JsonVariant doc) {
|
||||||
@@ -497,7 +553,7 @@ void ConfigManager::updateClockBacklight(JsonVariant doc) {
|
|||||||
if (doc.containsKey("offTime")) {
|
if (doc.containsKey("offTime")) {
|
||||||
clockConfig.backlightOffTime = doc["offTime"].as<String>();
|
clockConfig.backlightOffTime = doc["offTime"].as<String>();
|
||||||
}
|
}
|
||||||
LOG_INFO("ConfigManager: Updated Clock backlight");
|
LOG_DEBUG("ConfigManager - Updated Clock backlight");
|
||||||
}
|
}
|
||||||
|
|
||||||
void ConfigManager::updateClockSilence(JsonVariant doc) {
|
void ConfigManager::updateClockSilence(JsonVariant doc) {
|
||||||
@@ -525,7 +581,7 @@ void ConfigManager::updateClockSilence(JsonVariant doc) {
|
|||||||
clockConfig.nighttimeSilenceOffTime = nighttime["offTime"].as<String>();
|
clockConfig.nighttimeSilenceOffTime = nighttime["offTime"].as<String>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LOG_INFO("ConfigManager: Updated Clock silence");
|
LOG_DEBUG("ConfigManager - Updated Clock silence");
|
||||||
}
|
}
|
||||||
|
|
||||||
bool ConfigManager::loadClockConfig() {
|
bool ConfigManager::loadClockConfig() {
|
||||||
@@ -533,7 +589,7 @@ bool ConfigManager::loadClockConfig() {
|
|||||||
|
|
||||||
File file = SD.open("/settings/clockConfig.json", FILE_READ);
|
File file = SD.open("/settings/clockConfig.json", FILE_READ);
|
||||||
if (!file) {
|
if (!file) {
|
||||||
LOG_WARNING("ConfigManager: Clock config file not found - using defaults");
|
LOG_WARNING("ConfigManager - ⚠️ Clock config file not found - using defaults");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -542,16 +598,17 @@ bool ConfigManager::loadClockConfig() {
|
|||||||
file.close();
|
file.close();
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
LOG_ERROR("ConfigManager: Failed to parse clock config from SD: %s", error.c_str());
|
LOG_ERROR("ConfigManager - ❌ Failed to parse clock config from SD: %s", error.c_str());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (doc.containsKey("enabled")) clockConfig.enabled = doc["enabled"].as<bool>();
|
||||||
if (doc.containsKey("c1output")) clockConfig.c1output = doc["c1output"].as<uint8_t>();
|
if (doc.containsKey("c1output")) clockConfig.c1output = doc["c1output"].as<uint8_t>();
|
||||||
if (doc.containsKey("c2output")) clockConfig.c2output = doc["c2output"].as<uint8_t>();
|
if (doc.containsKey("c2output")) clockConfig.c2output = doc["c2output"].as<uint8_t>();
|
||||||
if (doc.containsKey("pulseDuration")) clockConfig.pulseDuration = doc["pulseDuration"].as<uint16_t>();
|
if (doc.containsKey("pulseDuration")) clockConfig.pulseDuration = doc["pulseDuration"].as<uint16_t>();
|
||||||
if (doc.containsKey("pauseDuration")) clockConfig.pauseDuration = doc["pauseDuration"].as<uint16_t>();
|
if (doc.containsKey("pauseDuration")) clockConfig.pauseDuration = doc["pauseDuration"].as<uint16_t>();
|
||||||
|
|
||||||
LOG_INFO("ConfigManager: Clock config loaded");
|
LOG_DEBUG("ConfigManager - Clock config loaded");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -559,6 +616,7 @@ bool ConfigManager::saveClockConfig() {
|
|||||||
if (!ensureSDCard()) return false;
|
if (!ensureSDCard()) return false;
|
||||||
|
|
||||||
StaticJsonDocument<512> doc;
|
StaticJsonDocument<512> doc;
|
||||||
|
doc["enabled"] = clockConfig.enabled;
|
||||||
doc["c1output"] = clockConfig.c1output;
|
doc["c1output"] = clockConfig.c1output;
|
||||||
doc["c2output"] = clockConfig.c2output;
|
doc["c2output"] = clockConfig.c2output;
|
||||||
doc["pulseDuration"] = clockConfig.pulseDuration;
|
doc["pulseDuration"] = clockConfig.pulseDuration;
|
||||||
@@ -568,12 +626,12 @@ bool ConfigManager::saveClockConfig() {
|
|||||||
size_t len = serializeJson(doc, buffer, sizeof(buffer));
|
size_t len = serializeJson(doc, buffer, sizeof(buffer));
|
||||||
|
|
||||||
if (len == 0 || len >= sizeof(buffer)) {
|
if (len == 0 || len >= sizeof(buffer)) {
|
||||||
LOG_ERROR("ConfigManager: Failed to serialize clock config JSON");
|
LOG_ERROR("ConfigManager - ❌ Failed to serialize clock config JSON");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
saveFileToSD("/settings", "clockConfig.json", buffer);
|
saveFileToSD("/settings", "clockConfig.json", buffer);
|
||||||
LOG_INFO("ConfigManager: Clock config saved");
|
LOG_DEBUG("ConfigManager - Clock config saved");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -582,7 +640,7 @@ bool ConfigManager::loadClockState() {
|
|||||||
|
|
||||||
File file = SD.open("/settings/clockState.json", FILE_READ);
|
File file = SD.open("/settings/clockState.json", FILE_READ);
|
||||||
if (!file) {
|
if (!file) {
|
||||||
LOG_WARNING("ConfigManager: Clock state file not found - using defaults");
|
LOG_WARNING("ConfigManager - ⚠️ Clock state file not found - using defaults");
|
||||||
clockConfig.physicalHour = 0;
|
clockConfig.physicalHour = 0;
|
||||||
clockConfig.physicalMinute = 0;
|
clockConfig.physicalMinute = 0;
|
||||||
clockConfig.nextOutputIsC1 = true;
|
clockConfig.nextOutputIsC1 = true;
|
||||||
@@ -595,7 +653,7 @@ bool ConfigManager::loadClockState() {
|
|||||||
file.close();
|
file.close();
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
LOG_ERROR("ConfigManager: Failed to parse clock state from SD: %s", error.c_str());
|
LOG_ERROR("ConfigManager - ❌ Failed to parse clock state from SD: %s", error.c_str());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -604,7 +662,7 @@ bool ConfigManager::loadClockState() {
|
|||||||
clockConfig.nextOutputIsC1 = doc["nextIsC1"].as<bool>();
|
clockConfig.nextOutputIsC1 = doc["nextIsC1"].as<bool>();
|
||||||
clockConfig.lastSyncTime = doc["lastSyncTime"].as<uint32_t>();
|
clockConfig.lastSyncTime = doc["lastSyncTime"].as<uint32_t>();
|
||||||
|
|
||||||
LOG_INFO("ConfigManager: Clock state loaded");
|
LOG_DEBUG("ConfigManager - Clock state loaded");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -621,12 +679,12 @@ bool ConfigManager::saveClockState() {
|
|||||||
size_t len = serializeJson(doc, buffer, sizeof(buffer));
|
size_t len = serializeJson(doc, buffer, sizeof(buffer));
|
||||||
|
|
||||||
if (len == 0 || len >= sizeof(buffer)) {
|
if (len == 0 || len >= sizeof(buffer)) {
|
||||||
LOG_ERROR("ConfigManager: Failed to serialize clock state JSON");
|
LOG_ERROR("ConfigManager - ❌ Failed to serialize clock state JSON");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
saveFileToSD("/settings", "clockState.json", buffer);
|
saveFileToSD("/settings", "clockState.json", buffer);
|
||||||
LOG_DEBUG("ConfigManager: Clock state saved");
|
LOG_VERBOSE("ConfigManager - Clock state saved");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -635,7 +693,7 @@ bool ConfigManager::loadUpdateServers() {
|
|||||||
|
|
||||||
File file = SD.open("/settings/updateServers.json", FILE_READ);
|
File file = SD.open("/settings/updateServers.json", FILE_READ);
|
||||||
if (!file) {
|
if (!file) {
|
||||||
LOG_INFO("ConfigManager: Update servers file not found - using fallback only");
|
LOG_DEBUG("ConfigManager - Update servers file not found - using fallback only");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -644,7 +702,7 @@ bool ConfigManager::loadUpdateServers() {
|
|||||||
file.close();
|
file.close();
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
LOG_ERROR("ConfigManager: Failed to parse update servers JSON: %s", error.c_str());
|
LOG_ERROR("ConfigManager - ❌ Failed to parse update servers JSON: %s", error.c_str());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -661,7 +719,7 @@ bool ConfigManager::loadUpdateServers() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LOG_INFO("ConfigManager: Loaded %d update servers from SD card", updateServers.size());
|
LOG_DEBUG("ConfigManager - Loaded %d update servers from SD card", updateServers.size());
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -677,8 +735,8 @@ std::vector<String> ConfigManager::getUpdateServers() const {
|
|||||||
void ConfigManager::updateTimeConfig(long gmtOffsetSec, int daylightOffsetSec) {
|
void ConfigManager::updateTimeConfig(long gmtOffsetSec, int daylightOffsetSec) {
|
||||||
timeConfig.gmtOffsetSec = gmtOffsetSec;
|
timeConfig.gmtOffsetSec = gmtOffsetSec;
|
||||||
timeConfig.daylightOffsetSec = daylightOffsetSec;
|
timeConfig.daylightOffsetSec = daylightOffsetSec;
|
||||||
saveToSD();
|
saveTimeConfig(); // Save time config specifically
|
||||||
LOG_INFO("ConfigManager: TimeConfig updated - GMT offset %ld sec, DST offset %d sec",
|
LOG_DEBUG("ConfigManager - TimeConfig updated - GMT offset %ld sec, DST offset %d sec",
|
||||||
gmtOffsetSec, daylightOffsetSec);
|
gmtOffsetSec, daylightOffsetSec);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -692,7 +750,7 @@ void ConfigManager::updateNetworkConfig(const String& hostname, bool useStaticIP
|
|||||||
networkConfig.dns1 = dns1;
|
networkConfig.dns1 = dns1;
|
||||||
networkConfig.dns2 = dns2;
|
networkConfig.dns2 = dns2;
|
||||||
saveNetworkConfig(); // Save immediately to SD
|
saveNetworkConfig(); // Save immediately to SD
|
||||||
LOG_INFO("ConfigManager: NetworkConfig updated - Hostname: %s, Static IP: %s, IP: %s",
|
LOG_DEBUG("ConfigManager - NetworkConfig updated - Hostname: %s, Static IP: %s, IP: %s",
|
||||||
hostname.c_str(), useStaticIP ? "enabled" : "disabled", ip.toString().c_str());
|
hostname.c_str(), useStaticIP ? "enabled" : "disabled", ip.toString().c_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -705,7 +763,7 @@ bool ConfigManager::loadNetworkConfig() {
|
|||||||
|
|
||||||
File file = SD.open("/settings/networkConfig.json", FILE_READ);
|
File file = SD.open("/settings/networkConfig.json", FILE_READ);
|
||||||
if (!file) {
|
if (!file) {
|
||||||
LOG_INFO("ConfigManager: Network config file not found - using auto-generated hostname and DHCP");
|
LOG_DEBUG("ConfigManager - Network config file not found - using auto-generated hostname and DHCP");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -714,7 +772,7 @@ bool ConfigManager::loadNetworkConfig() {
|
|||||||
file.close();
|
file.close();
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
LOG_ERROR("ConfigManager: Failed to parse network config from SD: %s", error.c_str());
|
LOG_ERROR("ConfigManager - ❌ Failed to parse network config from SD: %s", error.c_str());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -723,7 +781,7 @@ bool ConfigManager::loadNetworkConfig() {
|
|||||||
String customHostname = doc["hostname"].as<String>();
|
String customHostname = doc["hostname"].as<String>();
|
||||||
if (!customHostname.isEmpty()) {
|
if (!customHostname.isEmpty()) {
|
||||||
networkConfig.hostname = customHostname;
|
networkConfig.hostname = customHostname;
|
||||||
LOG_INFO("ConfigManager: Custom hostname loaded from SD: %s", customHostname.c_str());
|
LOG_DEBUG("ConfigManager - Custom hostname loaded from SD: %s", customHostname.c_str());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -732,6 +790,12 @@ bool ConfigManager::loadNetworkConfig() {
|
|||||||
networkConfig.useStaticIP = doc["useStaticIP"].as<bool>();
|
networkConfig.useStaticIP = doc["useStaticIP"].as<bool>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load permanent AP mode setting
|
||||||
|
if (doc.containsKey("permanentAPMode")) {
|
||||||
|
networkConfig.permanentAPMode = doc["permanentAPMode"].as<bool>();
|
||||||
|
LOG_DEBUG("ConfigManager - Permanent AP mode: %s", networkConfig.permanentAPMode ? "enabled" : "disabled");
|
||||||
|
}
|
||||||
|
|
||||||
if (doc.containsKey("ip")) {
|
if (doc.containsKey("ip")) {
|
||||||
String ipStr = doc["ip"].as<String>();
|
String ipStr = doc["ip"].as<String>();
|
||||||
if (!ipStr.isEmpty() && ipStr != "0.0.0.0") {
|
if (!ipStr.isEmpty() && ipStr != "0.0.0.0") {
|
||||||
@@ -767,7 +831,7 @@ bool ConfigManager::loadNetworkConfig() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LOG_INFO("ConfigManager: Network config loaded - Hostname: %s, Static IP: %s",
|
LOG_DEBUG("ConfigManager - Network config loaded - Hostname: %s, Static IP: %s",
|
||||||
networkConfig.hostname.c_str(),
|
networkConfig.hostname.c_str(),
|
||||||
networkConfig.useStaticIP ? "enabled" : "disabled");
|
networkConfig.useStaticIP ? "enabled" : "disabled");
|
||||||
|
|
||||||
@@ -790,16 +854,92 @@ bool ConfigManager::saveNetworkConfig() {
|
|||||||
doc["dns1"] = networkConfig.dns1.toString();
|
doc["dns1"] = networkConfig.dns1.toString();
|
||||||
doc["dns2"] = networkConfig.dns2.toString();
|
doc["dns2"] = networkConfig.dns2.toString();
|
||||||
|
|
||||||
|
// Save permanent AP mode setting
|
||||||
|
doc["permanentAPMode"] = networkConfig.permanentAPMode;
|
||||||
|
|
||||||
char buffer[512];
|
char buffer[512];
|
||||||
size_t len = serializeJson(doc, buffer, sizeof(buffer));
|
size_t len = serializeJson(doc, buffer, sizeof(buffer));
|
||||||
|
|
||||||
if (len == 0 || len >= sizeof(buffer)) {
|
if (len == 0 || len >= sizeof(buffer)) {
|
||||||
LOG_ERROR("ConfigManager: Failed to serialize network config JSON");
|
LOG_ERROR("ConfigManager - ❌ Failed to serialize network config JSON");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
saveFileToSD("/settings", "networkConfig.json", buffer);
|
saveFileToSD("/settings", "networkConfig.json", buffer);
|
||||||
LOG_INFO("ConfigManager: Network config saved to SD");
|
LOG_DEBUG("ConfigManager - Network config saved to SD");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
// TIME CONFIGURATION PERSISTENCE
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
bool ConfigManager::loadTimeConfig() {
|
||||||
|
if (!ensureSDCard()) return false;
|
||||||
|
|
||||||
|
File file = SD.open("/settings/timeConfig.json", FILE_READ);
|
||||||
|
if (!file) {
|
||||||
|
LOG_DEBUG("ConfigManager - Time config file not found - using defaults (GMT+2)");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
StaticJsonDocument<256> doc;
|
||||||
|
DeserializationError error = deserializeJson(doc, file);
|
||||||
|
file.close();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
LOG_ERROR("ConfigManager - ❌ Failed to parse time config from SD: %s", error.c_str());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load NTP server if present
|
||||||
|
if (doc.containsKey("ntpServer")) {
|
||||||
|
String ntpServer = doc["ntpServer"].as<String>();
|
||||||
|
if (!ntpServer.isEmpty()) {
|
||||||
|
timeConfig.ntpServer = ntpServer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load GMT offset
|
||||||
|
if (doc.containsKey("gmtOffsetSec")) {
|
||||||
|
timeConfig.gmtOffsetSec = doc["gmtOffsetSec"].as<long>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load daylight saving offset
|
||||||
|
if (doc.containsKey("daylightOffsetSec")) {
|
||||||
|
timeConfig.daylightOffsetSec = doc["daylightOffsetSec"].as<int>();
|
||||||
|
}
|
||||||
|
|
||||||
|
LOG_DEBUG("ConfigManager - Time config loaded - NTP: %s, GMT offset: %ld, DST offset: %d",
|
||||||
|
timeConfig.ntpServer.c_str(),
|
||||||
|
timeConfig.gmtOffsetSec,
|
||||||
|
timeConfig.daylightOffsetSec);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ConfigManager::saveTimeConfig() {
|
||||||
|
if (!ensureSDCard()) return false;
|
||||||
|
|
||||||
|
StaticJsonDocument<256> doc;
|
||||||
|
|
||||||
|
// Save NTP server
|
||||||
|
doc["ntpServer"] = timeConfig.ntpServer;
|
||||||
|
|
||||||
|
// Save timezone offsets
|
||||||
|
doc["gmtOffsetSec"] = timeConfig.gmtOffsetSec;
|
||||||
|
doc["daylightOffsetSec"] = timeConfig.daylightOffsetSec;
|
||||||
|
|
||||||
|
char buffer[256];
|
||||||
|
size_t len = serializeJson(doc, buffer, sizeof(buffer));
|
||||||
|
|
||||||
|
if (len == 0 || len >= sizeof(buffer)) {
|
||||||
|
LOG_ERROR("ConfigManager - ❌ Failed to serialize time config JSON");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
saveFileToSD("/settings", "timeConfig.json", buffer);
|
||||||
|
LOG_DEBUG("ConfigManager - Time config saved to SD");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -809,12 +949,11 @@ bool ConfigManager::saveNetworkConfig() {
|
|||||||
|
|
||||||
bool ConfigManager::resetAllToDefaults() {
|
bool ConfigManager::resetAllToDefaults() {
|
||||||
|
|
||||||
LOG_WARNING("═══════════════════════════════════════════════════════════════════════════");
|
LOG_INFO("═══════════════════════════════════════════════════════════════════════════");
|
||||||
LOG_WARNING(" 🏭 RESET SETTINGS TO DEFAULTS INITIATED");
|
LOG_INFO(" 🏭 RESET SETTINGS TO DEFAULTS INITIATED");
|
||||||
LOG_WARNING("═══════════════════════════════════════════════════════════════════════════");
|
LOG_INFO("═══════════════════════════════════════════════════════════════════════════");
|
||||||
|
|
||||||
if (!ensureSDCard()) {
|
if (!ensureSDCard()) {
|
||||||
LOG_ERROR("❌ ConfigManager: Cannot perform reset - SD card not available");
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -829,7 +968,9 @@ bool ConfigManager::resetAllToDefaults() {
|
|||||||
const char* settingsFiles[] = {
|
const char* settingsFiles[] = {
|
||||||
"/settings/deviceConfig.json",
|
"/settings/deviceConfig.json",
|
||||||
"/settings/networkConfig.json",
|
"/settings/networkConfig.json",
|
||||||
|
"/settings/timeConfig.json",
|
||||||
"/settings/relayTimings.json",
|
"/settings/relayTimings.json",
|
||||||
|
"/settings/bellOutputs.json",
|
||||||
"/settings/clockConfig.json",
|
"/settings/clockConfig.json",
|
||||||
"/settings/clockState.json",
|
"/settings/clockState.json",
|
||||||
"/settings/updateServers.json"
|
"/settings/updateServers.json"
|
||||||
@@ -837,22 +978,22 @@ bool ConfigManager::resetAllToDefaults() {
|
|||||||
|
|
||||||
int numFiles = sizeof(settingsFiles) / sizeof(settingsFiles[0]);
|
int numFiles = sizeof(settingsFiles) / sizeof(settingsFiles[0]);
|
||||||
|
|
||||||
LOG_WARNING("🗑️ Step 1: Deleting %d configuration files...", numFiles);
|
LOG_DEBUG("ConfigManager - Step 1: Deleting %d configuration files...", numFiles);
|
||||||
|
|
||||||
for (int i = 0; i < numFiles; i++) {
|
for (int i = 0; i < numFiles; i++) {
|
||||||
const char* filepath = settingsFiles[i];
|
const char* filepath = settingsFiles[i];
|
||||||
|
|
||||||
if (SD.exists(filepath)) {
|
if (SD.exists(filepath)) {
|
||||||
if (SD.remove(filepath)) {
|
if (SD.remove(filepath)) {
|
||||||
LOG_DEBUG("✅ Deleted: %s", filepath);
|
LOG_VERBOSE("ConfigManager - ✅ Deleted: %s", filepath);
|
||||||
filesDeleted++;
|
filesDeleted++;
|
||||||
} else {
|
} else {
|
||||||
LOG_ERROR("❌ Failed to delete: %s", filepath);
|
LOG_ERROR("ConfigManager - ❌ Failed to delete: %s", filepath);
|
||||||
filesFailed++;
|
filesFailed++;
|
||||||
allDeleted = false;
|
allDeleted = false;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
LOG_DEBUG("⏩ Skip (not found): %s", filepath);
|
LOG_VERBOSE("ConfigManager - Skip (not found): %s", filepath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -861,7 +1002,7 @@ bool ConfigManager::resetAllToDefaults() {
|
|||||||
// ════════════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
if (SD.exists("/melodies")) {
|
if (SD.exists("/melodies")) {
|
||||||
LOG_WARNING("🗑️ Step 2: Deleting melody files...");
|
LOG_DEBUG("ConfigManager - Step 2: Deleting melody files...");
|
||||||
|
|
||||||
File melodiesDir = SD.open("/melodies");
|
File melodiesDir = SD.open("/melodies");
|
||||||
if (melodiesDir && melodiesDir.isDirectory()) {
|
if (melodiesDir && melodiesDir.isDirectory()) {
|
||||||
@@ -874,10 +1015,10 @@ bool ConfigManager::resetAllToDefaults() {
|
|||||||
|
|
||||||
if (!entry.isDirectory()) {
|
if (!entry.isDirectory()) {
|
||||||
if (SD.remove(entryPath.c_str())) {
|
if (SD.remove(entryPath.c_str())) {
|
||||||
LOG_DEBUG("✅ Deleted melody: %s", entryPath.c_str());
|
LOG_VERBOSE("ConfigManager - ✅ Deleted melody: %s", entryPath.c_str());
|
||||||
melodiesDeleted++;
|
melodiesDeleted++;
|
||||||
} else {
|
} else {
|
||||||
LOG_ERROR("❌ Failed to delete melody: %s", entryPath.c_str());
|
LOG_ERROR("ConfigManager - ❌ Failed to delete melody: %s", entryPath.c_str());
|
||||||
melodiesFailed++;
|
melodiesFailed++;
|
||||||
allDeleted = false;
|
allDeleted = false;
|
||||||
}
|
}
|
||||||
@@ -891,33 +1032,33 @@ bool ConfigManager::resetAllToDefaults() {
|
|||||||
|
|
||||||
// Try to remove the empty directory
|
// Try to remove the empty directory
|
||||||
if (SD.rmdir("/melodies")) {
|
if (SD.rmdir("/melodies")) {
|
||||||
LOG_DEBUG("✅ Deleted /melodies directory");
|
LOG_VERBOSE("ConfigManager - ✅ Deleted /melodies directory");
|
||||||
} else {
|
} else {
|
||||||
LOG_WARNING("⚠️ Could not delete /melodies directory (may not be empty)");
|
LOG_WARNING("ConfigManager - ⚠️ Could not delete /melodies directory (may not be empty)");
|
||||||
}
|
}
|
||||||
|
|
||||||
LOG_WARNING(" 🎵 Melodies deleted: %d, failed: %d", melodiesDeleted, melodiesFailed);
|
LOG_DEBUG("ConfigManager - Melodies deleted: %d, failed: %d", melodiesDeleted, melodiesFailed);
|
||||||
filesDeleted += melodiesDeleted;
|
filesDeleted += melodiesDeleted;
|
||||||
filesFailed += melodiesFailed;
|
filesFailed += melodiesFailed;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
LOG_DEBUG("⏩ /melodies directory not found");
|
LOG_VERBOSE("ConfigManager - /melodies directory not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
// SUMMARY
|
// SUMMARY
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
LOG_WARNING("═══════════════════════════════════════════════════════════════════════════");
|
LOG_INFO("═══════════════════════════════════════════════════════════════════════════");
|
||||||
LOG_WARNING("📄 Full reset summary:");
|
LOG_INFO("ConfigManager - Full reset summary:");
|
||||||
LOG_WARNING(" ✅ Files deleted: %d", filesDeleted);
|
LOG_INFO("ConfigManager - ✅ Files deleted: %d", filesDeleted);
|
||||||
LOG_WARNING(" ❌ Files failed: %d", filesFailed);
|
LOG_INFO("ConfigManager - ❌ Files failed: %d", filesFailed);
|
||||||
LOG_WARNING(" 🔄 Total processed: %d", filesDeleted + filesFailed);
|
LOG_INFO("ConfigManager - 🔄 Total processed: %d", filesDeleted + filesFailed);
|
||||||
LOG_WARNING("═══════════════════════════════════════════════════════════════════════════");
|
LOG_INFO("═══════════════════════════════════════════════════════════════════════════");
|
||||||
|
|
||||||
LOG_WARNING("✅ RESET TO DEFAULT COMPLETE");
|
LOG_INFO("ConfigManager - ✅ RESET TO DEFAULT COMPLETE");
|
||||||
LOG_WARNING("🔄 Device will boot with default settings on next restart");
|
LOG_INFO("ConfigManager - 🔄 Device will boot with default settings on next restart");
|
||||||
LOG_WARNING("🆔 Device identity (UID) preserved");
|
LOG_INFO("ConfigManager - 🆔 Device identity (UID) preserved");
|
||||||
|
|
||||||
return allDeleted;
|
return allDeleted;
|
||||||
}
|
}
|
||||||
@@ -1006,3 +1147,97 @@ String ConfigManager::getAllSettingsAsJson() const {
|
|||||||
serializeJson(doc, output);
|
serializeJson(doc, output);
|
||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
// GENERAL CONFIGURATION - LOG LEVELS
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
bool ConfigManager::setSerialLogLevel(uint8_t level) {
|
||||||
|
if (level > 5) { // Max level is VERBOSE (5)
|
||||||
|
LOG_WARNING("ConfigManager - ⚠️ Invalid serial log level %d, valid range is 0-5", level);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
generalConfig.serialLogLevel = level;
|
||||||
|
LOG_DEBUG("ConfigManager - Serial log level set to %d", level);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ConfigManager::setSdLogLevel(uint8_t level) {
|
||||||
|
if (level > 5) { // Max level is VERBOSE (5)
|
||||||
|
LOG_WARNING("ConfigManager - ⚠️ Invalid SD log level %d, valid range is 0-5", level);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
generalConfig.sdLogLevel = level;
|
||||||
|
LOG_DEBUG("ConfigManager - SD log level set to %d", level);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ConfigManager::setMqttLogLevel(uint8_t level) {
|
||||||
|
if (level > 5) { // Max level is VERBOSE (5)
|
||||||
|
LOG_WARNING("ConfigManager - ⚠️ Invalid MQTT log level %d, valid range is 0-5", level);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
generalConfig.mqttLogLevel = level;
|
||||||
|
LOG_DEBUG("ConfigManager - MQTT log level set to %d", level);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ConfigManager::loadGeneralConfig() {
|
||||||
|
if (!ensureSDCard()) return false;
|
||||||
|
|
||||||
|
File file = SD.open("/settings/generalConfig.json", FILE_READ);
|
||||||
|
if (!file) {
|
||||||
|
LOG_WARNING("ConfigManager - ⚠️ General config file not found - using defaults");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
StaticJsonDocument<256> doc;
|
||||||
|
DeserializationError error = deserializeJson(doc, file);
|
||||||
|
file.close();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
LOG_ERROR("ConfigManager - ❌ Failed to parse general config from SD: %s", error.c_str());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (doc.containsKey("serialLogLevel")) {
|
||||||
|
generalConfig.serialLogLevel = doc["serialLogLevel"].as<uint8_t>();
|
||||||
|
}
|
||||||
|
if (doc.containsKey("sdLogLevel")) {
|
||||||
|
generalConfig.sdLogLevel = doc["sdLogLevel"].as<uint8_t>();
|
||||||
|
}
|
||||||
|
if (doc.containsKey("mqttLogLevel")) {
|
||||||
|
generalConfig.mqttLogLevel = doc["mqttLogLevel"].as<uint8_t>();
|
||||||
|
}
|
||||||
|
if (doc.containsKey("mqttEnabled")) {
|
||||||
|
generalConfig.mqttEnabled = doc["mqttEnabled"].as<bool>();
|
||||||
|
mqttConfig.enabled = generalConfig.mqttEnabled; // Sync with mqttConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
LOG_DEBUG("ConfigManager - General config loaded - Serial log level: %d, SD log level: %d, MQTT log level: %d, MQTT enabled: %s",
|
||||||
|
generalConfig.serialLogLevel, generalConfig.sdLogLevel, generalConfig.mqttLogLevel,
|
||||||
|
generalConfig.mqttEnabled ? "true" : "false");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ConfigManager::saveGeneralConfig() {
|
||||||
|
if (!ensureSDCard()) return false;
|
||||||
|
|
||||||
|
StaticJsonDocument<256> doc;
|
||||||
|
doc["serialLogLevel"] = generalConfig.serialLogLevel;
|
||||||
|
doc["sdLogLevel"] = generalConfig.sdLogLevel;
|
||||||
|
doc["mqttLogLevel"] = generalConfig.mqttLogLevel;
|
||||||
|
doc["mqttEnabled"] = generalConfig.mqttEnabled;
|
||||||
|
|
||||||
|
char buffer[256];
|
||||||
|
size_t len = serializeJson(doc, buffer, sizeof(buffer));
|
||||||
|
|
||||||
|
if (len == 0 || len >= sizeof(buffer)) {
|
||||||
|
LOG_ERROR("ConfigManager - ❌ Failed to serialize general config JSON");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
saveFileToSD("/settings", "generalConfig.json", buffer);
|
||||||
|
LOG_DEBUG("ConfigManager - General config saved (MQTT enabled: %s)", generalConfig.mqttEnabled ? "true" : "false");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ public:
|
|||||||
String deviceUID = ""; // 🏷️ Factory-set UID (NVS) - NO DEFAULT
|
String deviceUID = ""; // 🏷️ Factory-set UID (NVS) - NO DEFAULT
|
||||||
String hwType = ""; // 🔧 Factory-set hardware type (NVS) - NO DEFAULT
|
String hwType = ""; // 🔧 Factory-set hardware type (NVS) - NO DEFAULT
|
||||||
String hwVersion = ""; // 📐 Factory-set hardware revision (NVS) - NO DEFAULT
|
String hwVersion = ""; // 📐 Factory-set hardware revision (NVS) - NO DEFAULT
|
||||||
String fwVersion = "0.0.0"; // 📋 Current firmware version (SD) - auto-updated
|
String fwVersion = "0"; // 📋 Current firmware version (SD) - auto-updated (integer string)
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -69,6 +69,7 @@ public:
|
|||||||
String apSsid; // 📡 Auto-generated AP name
|
String apSsid; // 📡 Auto-generated AP name
|
||||||
String apPass; // 🔐 AP is Open. No Password
|
String apPass; // 🔐 AP is Open. No Password
|
||||||
uint16_t discoveryPort = 32101; // 📡 Fixed discovery port
|
uint16_t discoveryPort = 32101; // 📡 Fixed discovery port
|
||||||
|
bool permanentAPMode = false; // 🔘 Permanent AP mode toggle (stored on SD)
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -79,11 +80,12 @@ public:
|
|||||||
* Username defaults to deviceUID for unique identification.
|
* Username defaults to deviceUID for unique identification.
|
||||||
*/
|
*/
|
||||||
struct MqttConfig {
|
struct MqttConfig {
|
||||||
IPAddress host = IPAddress(145, 223, 96, 251); // 📡 Local Mosquitto broker
|
IPAddress host = IPAddress(72,61,191,197); // 📡 MQTT broker (default cloud broker)
|
||||||
int port = 1883; // 🔌 Standard MQTT port (non-SSL)
|
int port = 1883; // 🔌 Standard MQTT port (non-SSL)
|
||||||
String user; // 👤 Auto-set to deviceUID
|
String user; // 👤 Auto-set to deviceUID
|
||||||
String password = "vesper"; // 🔑 Default password
|
String password = "vesper"; // 🔑 Default password
|
||||||
bool useSSL = false; // 🔒 SSL disabled for local broker
|
bool useSSL = false; // 🔒 SSL disabled for local broker
|
||||||
|
bool enabled = true; // 🔘 MQTT enabled by default (can be toggled via command)
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -119,7 +121,7 @@ public:
|
|||||||
*/
|
*/
|
||||||
struct TimeConfig {
|
struct TimeConfig {
|
||||||
String ntpServer = "pool.ntp.org"; // ⏰ Universal NTP - OK as is
|
String ntpServer = "pool.ntp.org"; // ⏰ Universal NTP - OK as is
|
||||||
long gmtOffsetSec = 0; // 🌍 Default UTC, app-configurable via SD
|
long gmtOffsetSec = 7200; // 🌍 Default GMT+2 (Greek Time), app-configurable via SD
|
||||||
int daylightOffsetSec = 0; // ☀️ Default no DST, app-configurable via SD
|
int daylightOffsetSec = 0; // ☀️ Default no DST, app-configurable via SD
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -203,6 +205,20 @@ public:
|
|||||||
String nighttimeSilenceOffTime = "07:00"; // 🌙 End of nighttime silence
|
String nighttimeSilenceOffTime = "07:00"; // 🌙 End of nighttime silence
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @struct General Config
|
||||||
|
* @brief General configuration (loaded from SD)
|
||||||
|
*
|
||||||
|
* All clock settings are loaded from SD card at startup.
|
||||||
|
*/
|
||||||
|
struct GeneralConfig {
|
||||||
|
uint8_t serialLogLevel = 5;
|
||||||
|
uint8_t sdLogLevel = 0;
|
||||||
|
uint8_t mqttLogLevel = 0;
|
||||||
|
bool mqttEnabled = true; // MQTT enabled by default
|
||||||
|
};
|
||||||
|
|
||||||
private:
|
private:
|
||||||
// ═══════════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
// MEMBER VARIABLES - Clean deployment-ready storage
|
// MEMBER VARIABLES - Clean deployment-ready storage
|
||||||
@@ -215,6 +231,7 @@ private:
|
|||||||
UpdateConfig updateConfig;
|
UpdateConfig updateConfig;
|
||||||
BellConfig bellConfig;
|
BellConfig bellConfig;
|
||||||
ClockConfig clockConfig;
|
ClockConfig clockConfig;
|
||||||
|
GeneralConfig generalConfig;
|
||||||
|
|
||||||
bool sdInitialized = false;
|
bool sdInitialized = false;
|
||||||
std::vector<String> updateServers;
|
std::vector<String> updateServers;
|
||||||
@@ -274,7 +291,6 @@ public:
|
|||||||
*/
|
*/
|
||||||
bool begin();
|
bool begin();
|
||||||
|
|
||||||
void loadFromSD();
|
|
||||||
bool saveToSD();
|
bool saveToSD();
|
||||||
|
|
||||||
// Configuration access (read-only getters)
|
// Configuration access (read-only getters)
|
||||||
@@ -286,6 +302,7 @@ public:
|
|||||||
const UpdateConfig& getUpdateConfig() const { return updateConfig; }
|
const UpdateConfig& getUpdateConfig() const { return updateConfig; }
|
||||||
const BellConfig& getBellConfig() const { return bellConfig; }
|
const BellConfig& getBellConfig() const { return bellConfig; }
|
||||||
const ClockConfig& getClockConfig() const { return clockConfig; }
|
const ClockConfig& getClockConfig() const { return clockConfig; }
|
||||||
|
const GeneralConfig& getGeneralConfig() const { return generalConfig; }
|
||||||
|
|
||||||
// Device identity methods (READ-ONLY - factory set via separate factory firmware)
|
// Device identity methods (READ-ONLY - factory set via separate factory firmware)
|
||||||
// These values are loaded ONCE at boot from NVS and kept in RAM
|
// These values are loaded ONCE at boot from NVS and kept in RAM
|
||||||
@@ -303,13 +320,23 @@ public:
|
|||||||
void updateNetworkConfig(const String& hostname, bool useStaticIP, IPAddress ip, IPAddress gateway,
|
void updateNetworkConfig(const String& hostname, bool useStaticIP, IPAddress ip, IPAddress gateway,
|
||||||
IPAddress subnet, IPAddress dns1, IPAddress dns2);
|
IPAddress subnet, IPAddress dns1, IPAddress dns2);
|
||||||
|
|
||||||
|
// AP Mode configuration
|
||||||
|
bool getPermanentAPMode() const { return networkConfig.permanentAPMode; }
|
||||||
|
void setPermanentAPMode(bool enabled) { networkConfig.permanentAPMode = enabled; }
|
||||||
|
|
||||||
// Network configuration persistence
|
// Network configuration persistence
|
||||||
bool loadNetworkConfig();
|
bool loadNetworkConfig();
|
||||||
bool saveNetworkConfig();
|
bool saveNetworkConfig();
|
||||||
|
|
||||||
|
// Time configuration persistence
|
||||||
|
bool loadTimeConfig();
|
||||||
|
bool saveTimeConfig();
|
||||||
|
|
||||||
// Bell and clock configuration methods (unchanged)
|
// Bell and clock configuration methods (unchanged)
|
||||||
bool loadBellDurations();
|
bool loadBellDurations();
|
||||||
bool saveBellDurations();
|
bool saveBellDurations();
|
||||||
|
bool loadBellOutputs();
|
||||||
|
bool saveBellOutputs();
|
||||||
void updateBellDurations(JsonVariant doc);
|
void updateBellDurations(JsonVariant doc);
|
||||||
void updateBellOutputs(JsonVariant doc);
|
void updateBellOutputs(JsonVariant doc);
|
||||||
uint16_t getBellDuration(uint8_t bellIndex) const;
|
uint16_t getBellDuration(uint8_t bellIndex) const;
|
||||||
@@ -376,6 +403,16 @@ public:
|
|||||||
void setNighttimeSilenceOnTime(const String& time) { clockConfig.nighttimeSilenceOnTime = time; }
|
void setNighttimeSilenceOnTime(const String& time) { clockConfig.nighttimeSilenceOnTime = time; }
|
||||||
void setNighttimeSilenceOffTime(const String& time) { clockConfig.nighttimeSilenceOffTime = time; }
|
void setNighttimeSilenceOffTime(const String& time) { clockConfig.nighttimeSilenceOffTime = time; }
|
||||||
|
|
||||||
|
// General Config methods
|
||||||
|
bool setSerialLogLevel(uint8_t level);
|
||||||
|
bool setSdLogLevel(uint8_t level);
|
||||||
|
bool setMqttLogLevel(uint8_t level);
|
||||||
|
uint8_t getMqttLogLevel() const { return generalConfig.mqttLogLevel; }
|
||||||
|
void setMqttEnabled(bool enabled) { generalConfig.mqttEnabled = enabled; mqttConfig.enabled = enabled; }
|
||||||
|
bool getMqttEnabled() const { return generalConfig.mqttEnabled; }
|
||||||
|
bool loadGeneralConfig();
|
||||||
|
bool saveGeneralConfig();
|
||||||
|
|
||||||
// Other methods (unchanged)
|
// Other methods (unchanged)
|
||||||
void updateClockAlerts(JsonVariant doc);
|
void updateClockAlerts(JsonVariant doc);
|
||||||
void updateClockBacklight(JsonVariant doc);
|
void updateClockBacklight(JsonVariant doc);
|
||||||
@@ -395,6 +432,7 @@ public:
|
|||||||
String getAPSSID() const { return networkConfig.apSsid; }
|
String getAPSSID() const { return networkConfig.apSsid; }
|
||||||
bool isHealthy() const;
|
bool isHealthy() const;
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Get all configuration settings as a JSON string
|
* @brief Get all configuration settings as a JSON string
|
||||||
* @return JSON string containing all current settings
|
* @return JSON string containing all current settings
|
||||||
|
|||||||
@@ -46,6 +46,11 @@ bool FileManager::ensureDirectoryExists(const String& dirPath) {
|
|||||||
normalizedPath += "/";
|
normalizedPath += "/";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if directory already exists
|
||||||
|
if (SD.exists(normalizedPath.c_str())) {
|
||||||
|
return true; // Directory already exists, success
|
||||||
|
}
|
||||||
|
|
||||||
// Create directory if it doesn't exist
|
// Create directory if it doesn't exist
|
||||||
return SD.mkdir(normalizedPath.c_str());
|
return SD.mkdir(normalizedPath.c_str());
|
||||||
}
|
}
|
||||||
@@ -53,12 +58,30 @@ bool FileManager::ensureDirectoryExists(const String& dirPath) {
|
|||||||
bool FileManager::downloadFile(const String& url, const String& directory, const String& filename) {
|
bool FileManager::downloadFile(const String& url, const String& directory, const String& filename) {
|
||||||
LOG_INFO("Starting download from: %s", url.c_str());
|
LOG_INFO("Starting download from: %s", url.c_str());
|
||||||
|
|
||||||
|
// Check if URL is HTTPS
|
||||||
|
bool isHttps = url.startsWith("https://");
|
||||||
|
|
||||||
HTTPClient http;
|
HTTPClient http;
|
||||||
|
|
||||||
|
// Configure HTTP client based on protocol
|
||||||
|
if (isHttps) {
|
||||||
|
WiFiClientSecure* secureClient = new WiFiClientSecure();
|
||||||
|
secureClient->setInsecure(); // Skip certificate validation for Firebase
|
||||||
|
http.begin(*secureClient, url);
|
||||||
|
LOG_DEBUG("Using HTTPS with secure client");
|
||||||
|
} else {
|
||||||
http.begin(url);
|
http.begin(url);
|
||||||
|
LOG_DEBUG("Using HTTP");
|
||||||
|
}
|
||||||
|
|
||||||
|
http.setTimeout(30000); // 30 second timeout for large files
|
||||||
|
http.setFollowRedirects(HTTPC_FORCE_FOLLOW_REDIRECTS); // Follow redirects automatically
|
||||||
|
|
||||||
|
LOG_DEBUG("Sending HTTP GET request...");
|
||||||
int httpCode = http.GET();
|
int httpCode = http.GET();
|
||||||
|
|
||||||
if (httpCode != HTTP_CODE_OK) {
|
if (httpCode != HTTP_CODE_OK && httpCode != HTTP_CODE_MOVED_PERMANENTLY && httpCode != HTTP_CODE_FOUND) {
|
||||||
LOG_ERROR("HTTP GET failed, error: %s", http.errorToString(httpCode).c_str());
|
LOG_ERROR("HTTP GET failed, code: %d, error: %s", httpCode, http.errorToString(httpCode).c_str());
|
||||||
http.end();
|
http.end();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -87,17 +110,59 @@ bool FileManager::downloadFile(const String& url, const String& directory, const
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get stream and file size
|
||||||
WiFiClient* stream = http.getStreamPtr();
|
WiFiClient* stream = http.getStreamPtr();
|
||||||
uint8_t buffer[1024];
|
int contentLength = http.getSize();
|
||||||
int bytesRead;
|
LOG_DEBUG("Content length: %d bytes", contentLength);
|
||||||
|
|
||||||
while (http.connected() && (bytesRead = stream->readBytes(buffer, sizeof(buffer))) > 0) {
|
uint8_t buffer[512]; // Smaller buffer for better responsiveness
|
||||||
|
size_t totalBytes = 0;
|
||||||
|
unsigned long lastYield = millis();
|
||||||
|
unsigned long lastLog = millis();
|
||||||
|
|
||||||
|
// Download with aggressive watchdog feeding
|
||||||
|
while (http.connected() && (contentLength <= 0 || totalBytes < contentLength)) {
|
||||||
|
// Check available data
|
||||||
|
size_t availableSize = stream->available();
|
||||||
|
|
||||||
|
if (availableSize) {
|
||||||
|
// Read available data (up to buffer size)
|
||||||
|
size_t readSize = availableSize > sizeof(buffer) ? sizeof(buffer) : availableSize;
|
||||||
|
int bytesRead = stream->readBytes(buffer, readSize);
|
||||||
|
|
||||||
|
if (bytesRead > 0) {
|
||||||
file.write(buffer, bytesRead);
|
file.write(buffer, bytesRead);
|
||||||
|
totalBytes += bytesRead;
|
||||||
|
|
||||||
|
// Log progress every 5KB
|
||||||
|
if (millis() - lastLog > 5000) {
|
||||||
|
LOG_DEBUG("Download progress: %u bytes", totalBytes);
|
||||||
|
lastLog = millis();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aggressive task yielding every 100ms to prevent watchdog timeout
|
||||||
|
if (millis() - lastYield > 100) {
|
||||||
|
yield();
|
||||||
|
vTaskDelay(1 / portTICK_PERIOD_MS); // Let other tasks run
|
||||||
|
lastYield = millis();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exit if no data and connection closed
|
||||||
|
if (!availableSize && !http.connected()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small delay if no data available yet
|
||||||
|
if (!availableSize) {
|
||||||
|
delay(10);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
file.close();
|
file.close();
|
||||||
http.end();
|
http.end();
|
||||||
LOG_INFO("Download complete, file saved to: %s", fullPath.c_str());
|
LOG_INFO("Download complete, file saved to: %s (%u bytes)", fullPath.c_str(), totalBytes);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,6 +240,52 @@ size_t FileManager::getFileSize(const String& filePath) {
|
|||||||
return size;
|
return size;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool FileManager::writeJsonFile(const String& filePath, JsonDocument& doc) {
|
||||||
|
if (!initializeSD()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
File file = SD.open(filePath.c_str(), FILE_WRITE);
|
||||||
|
if (!file) {
|
||||||
|
LOG_ERROR("Failed to open file for writing: %s", filePath.c_str());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (serializeJson(doc, file) == 0) {
|
||||||
|
LOG_ERROR("Failed to write JSON to file: %s", filePath.c_str());
|
||||||
|
file.close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
file.close();
|
||||||
|
LOG_DEBUG("JSON file written successfully: %s", filePath.c_str());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool FileManager::readJsonFile(const String& filePath, JsonDocument& doc) {
|
||||||
|
if (!initializeSD()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
File file = SD.open(filePath.c_str(), FILE_READ);
|
||||||
|
if (!file) {
|
||||||
|
LOG_ERROR("Failed to open file for reading: %s", filePath.c_str());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
DeserializationError error = deserializeJson(doc, file);
|
||||||
|
file.close();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
LOG_ERROR("Failed to parse JSON from file: %s, error: %s",
|
||||||
|
filePath.c_str(), error.c_str());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
LOG_DEBUG("JSON file read successfully: %s", filePath.c_str());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
// HEALTH CHECK IMPLEMENTATION
|
// HEALTH CHECK IMPLEMENTATION
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
#include <SD.h>
|
#include <SD.h>
|
||||||
#include <HTTPClient.h>
|
#include <HTTPClient.h>
|
||||||
#include <WiFiClient.h>
|
#include <WiFiClient.h>
|
||||||
|
#include <WiFiClientSecure.h>
|
||||||
#include <ArduinoJson.h>
|
#include <ArduinoJson.h>
|
||||||
#include "../Logging/Logging.hpp"
|
#include "../Logging/Logging.hpp"
|
||||||
#include "../ConfigManager/ConfigManager.hpp"
|
#include "../ConfigManager/ConfigManager.hpp"
|
||||||
@@ -45,6 +46,10 @@ public:
|
|||||||
bool createDirectory(const String& dirPath);
|
bool createDirectory(const String& dirPath);
|
||||||
size_t getFileSize(const String& filePath);
|
size_t getFileSize(const String& filePath);
|
||||||
|
|
||||||
|
// Generic read/write for JSON data
|
||||||
|
bool writeJsonFile(const String& filePath, JsonDocument& doc);
|
||||||
|
bool readJsonFile(const String& filePath, JsonDocument& doc);
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
// HEALTH CHECK METHOD
|
// HEALTH CHECK METHOD
|
||||||
// ═══════════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
#include "Logging.hpp"
|
#include "Logging.hpp"
|
||||||
|
|
||||||
// Initialize static member
|
// Initialize static members
|
||||||
Logging::LogLevel Logging::currentLevel = Logging::VERBOSE; // Default to DEBUG
|
Logging::LogLevel Logging::currentLevel = Logging::VERBOSE; // Default to VERBOSE
|
||||||
|
Logging::LogLevel Logging::mqttLogLevel = Logging::NONE; // Default MQTT logs OFF
|
||||||
|
Logging::MqttPublishCallback Logging::mqttPublishCallback = nullptr;
|
||||||
|
String Logging::mqttLogTopic = "";
|
||||||
|
|
||||||
void Logging::setLevel(LogLevel level) {
|
void Logging::setLevel(LogLevel level) {
|
||||||
currentLevel = level;
|
currentLevel = level;
|
||||||
@@ -12,6 +15,21 @@ Logging::LogLevel Logging::getLevel() {
|
|||||||
return currentLevel;
|
return currentLevel;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void Logging::setMqttLogLevel(LogLevel level) {
|
||||||
|
mqttLogLevel = level;
|
||||||
|
Serial.printf("[LOGGING] MQTT log level set to %d\n", level);
|
||||||
|
}
|
||||||
|
|
||||||
|
Logging::LogLevel Logging::getMqttLogLevel() {
|
||||||
|
return mqttLogLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Logging::setMqttPublishCallback(MqttPublishCallback callback, const String& logTopic) {
|
||||||
|
mqttPublishCallback = callback;
|
||||||
|
mqttLogTopic = logTopic;
|
||||||
|
Serial.printf("[LOGGING] MQTT publish callback registered for topic: %s\n", logTopic.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
bool Logging::isLevelEnabled(LogLevel level) {
|
bool Logging::isLevelEnabled(LogLevel level) {
|
||||||
return currentLevel >= level;
|
return currentLevel >= level;
|
||||||
}
|
}
|
||||||
@@ -62,11 +80,59 @@ void Logging::verbose(const char* format, ...) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void Logging::log(LogLevel level, const char* levelStr, const char* format, va_list args) {
|
void Logging::log(LogLevel level, const char* levelStr, const char* format, va_list args) {
|
||||||
Serial.printf("[%s] ", levelStr);
|
|
||||||
|
|
||||||
// Print the formatted message
|
// Print the formatted message
|
||||||
char buffer[512];
|
char buffer[512];
|
||||||
vsnprintf(buffer, sizeof(buffer), format, args);
|
vsnprintf(buffer, sizeof(buffer), format, args);
|
||||||
|
|
||||||
|
// Serial output
|
||||||
|
Serial.printf("[%s] ", levelStr);
|
||||||
Serial.print(buffer);
|
Serial.print(buffer);
|
||||||
Serial.println();
|
Serial.println();
|
||||||
|
|
||||||
|
// MQTT output (if enabled and callback is set)
|
||||||
|
if (mqttLogLevel >= level && mqttPublishCallback) {
|
||||||
|
publishToMqtt(level, levelStr, buffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void Logging::publishToMqtt(LogLevel level, const char* levelStr, const char* message) {
|
||||||
|
if (!mqttPublishCallback || mqttLogTopic.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL: Prevent infinite recursion if MQTT publish fails
|
||||||
|
// Temporarily disable MQTT logging during publish to avoid cascading errors
|
||||||
|
static bool isPublishing = false;
|
||||||
|
if (isPublishing) {
|
||||||
|
return; // Already publishing, don't create recursive log loop
|
||||||
|
}
|
||||||
|
|
||||||
|
isPublishing = true;
|
||||||
|
|
||||||
|
// Build JSON manually to minimize stack usage (no StaticJsonDocument)
|
||||||
|
// Format: {"level":"🟢 INFO","message":"text","timestamp":12345}
|
||||||
|
String payload;
|
||||||
|
payload.reserve(600); // Pre-allocate to avoid fragmentation
|
||||||
|
|
||||||
|
payload = "{\"level\":\"";
|
||||||
|
payload += levelStr;
|
||||||
|
payload += "\",\"message\":\"";
|
||||||
|
|
||||||
|
// Escape special JSON characters in message
|
||||||
|
String escapedMsg = message;
|
||||||
|
escapedMsg.replace("\\", "\\\\");
|
||||||
|
escapedMsg.replace("\"", "\\\"");
|
||||||
|
escapedMsg.replace("\n", "\\n");
|
||||||
|
escapedMsg.replace("\r", "\\r");
|
||||||
|
|
||||||
|
payload += escapedMsg;
|
||||||
|
payload += "\",\"timestamp\":";
|
||||||
|
payload += millis();
|
||||||
|
payload += "}";
|
||||||
|
|
||||||
|
// Publish with QoS 1 (guaranteed delivery)
|
||||||
|
// Note: If this fails, it won't trigger another MQTT log due to isPublishing flag
|
||||||
|
mqttPublishCallback(mqttLogTopic, payload, 1);
|
||||||
|
|
||||||
|
isPublishing = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,9 @@
|
|||||||
|
|
||||||
#include <Arduino.h>
|
#include <Arduino.h>
|
||||||
|
|
||||||
|
// Forward declaration
|
||||||
|
class MQTTAsyncClient;
|
||||||
|
|
||||||
class Logging {
|
class Logging {
|
||||||
public:
|
public:
|
||||||
// Log Levels
|
// Log Levels
|
||||||
@@ -31,8 +34,14 @@ public:
|
|||||||
VERBOSE = 5 // Nearly every command gets printed
|
VERBOSE = 5 // Nearly every command gets printed
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// MQTT Log Publishing Callback
|
||||||
|
using MqttPublishCallback = std::function<void(const String& topic, const String& payload, int qos)>;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
static LogLevel currentLevel;
|
static LogLevel currentLevel;
|
||||||
|
static LogLevel mqttLogLevel;
|
||||||
|
static MqttPublishCallback mqttPublishCallback;
|
||||||
|
static String mqttLogTopic;
|
||||||
|
|
||||||
public:
|
public:
|
||||||
// Set the active log level
|
// Set the active log level
|
||||||
@@ -41,6 +50,15 @@ public:
|
|||||||
// Get current log level
|
// Get current log level
|
||||||
static LogLevel getLevel();
|
static LogLevel getLevel();
|
||||||
|
|
||||||
|
// Set MQTT log level (independent from serial logging)
|
||||||
|
static void setMqttLogLevel(LogLevel level);
|
||||||
|
|
||||||
|
// Get MQTT log level
|
||||||
|
static LogLevel getMqttLogLevel();
|
||||||
|
|
||||||
|
// Set MQTT callback for publishing logs
|
||||||
|
static void setMqttPublishCallback(MqttPublishCallback callback, const String& logTopic);
|
||||||
|
|
||||||
// Logging functions
|
// Logging functions
|
||||||
static void error(const char* format, ...);
|
static void error(const char* format, ...);
|
||||||
static void warning(const char* format, ...);
|
static void warning(const char* format, ...);
|
||||||
@@ -53,6 +71,7 @@ public:
|
|||||||
|
|
||||||
private:
|
private:
|
||||||
static void log(LogLevel level, const char* levelStr, const char* format, va_list args);
|
static void log(LogLevel level, const char* levelStr, const char* format, va_list args);
|
||||||
|
static void publishToMqtt(LogLevel level, const char* levelStr, const char* message);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Convenience macros for easier use
|
// Convenience macros for easier use
|
||||||
|
|||||||
@@ -64,6 +64,13 @@ void Networking::begin() {
|
|||||||
// Clear Previous Settings, USE once to test.
|
// Clear Previous Settings, USE once to test.
|
||||||
//_wifiManager->resetSettings();
|
//_wifiManager->resetSettings();
|
||||||
|
|
||||||
|
// Check if permanent AP mode is enabled
|
||||||
|
if (_configManager.getPermanentAPMode()) {
|
||||||
|
LOG_INFO("Permanent AP mode enabled - starting AP mode on 192.168.4.1");
|
||||||
|
startPermanentAPMode();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Start Ethernet hardware
|
// Start Ethernet hardware
|
||||||
auto& hwConfig = _configManager.getHardwareConfig();
|
auto& hwConfig = _configManager.getHardwareConfig();
|
||||||
ETH.begin(hwConfig.ethPhyType, hwConfig.ethPhyAddr, hwConfig.ethPhyCs,
|
ETH.begin(hwConfig.ethPhyType, hwConfig.ethPhyAddr, hwConfig.ethPhyCs,
|
||||||
@@ -111,8 +118,8 @@ void Networking::startWiFiConnection() {
|
|||||||
String savedSSID = _wifiManager->getWiFiSSID(true);
|
String savedSSID = _wifiManager->getWiFiSSID(true);
|
||||||
LOG_INFO("Using WiFiManager saved credentials - SSID: %s", savedSSID.c_str());
|
LOG_INFO("Using WiFiManager saved credentials - SSID: %s", savedSSID.c_str());
|
||||||
|
|
||||||
WiFi.mode(WIFI_STA);
|
|
||||||
applyNetworkConfig(false); // false = WiFi config
|
applyNetworkConfig(false); // false = WiFi config
|
||||||
|
WiFi.mode(WIFI_STA);
|
||||||
|
|
||||||
// Let WiFiManager handle credentials (uses saved SSID/password)
|
// Let WiFiManager handle credentials (uses saved SSID/password)
|
||||||
WiFi.begin();
|
WiFi.begin();
|
||||||
@@ -134,6 +141,18 @@ void Networking::startWiFiPortal() {
|
|||||||
|
|
||||||
LOG_INFO("WiFi Portal: SSID='%s', Password='%s'", apName.c_str(), netConfig.apPass.c_str());
|
LOG_INFO("WiFi Portal: SSID='%s', Password='%s'", apName.c_str(), netConfig.apPass.c_str());
|
||||||
|
|
||||||
|
// Add custom HTML to WiFiManager portal for permanent AP mode toggle
|
||||||
|
String customHTML = "<br/><br/><h3>Network Mode</h3>";
|
||||||
|
customHTML += "<p>Choose how to operate this device:</p>";
|
||||||
|
customHTML += "<form action='/settings' method='get'>";
|
||||||
|
customHTML += "<button type='submit' style='width:100%; padding:15px; margin:10px 0; background:#667eea; color:white; border:none; border-radius:8px; cursor:pointer; font-size:16px;'>";
|
||||||
|
customHTML += "Open Settings (Switch to Permanent AP Mode)";
|
||||||
|
customHTML += "</button>";
|
||||||
|
customHTML += "</form>";
|
||||||
|
customHTML += "<br/><p style='font-size:12px; color:#666;'>Note: You can configure network mode later at <b>http://192.168.4.1/settings</b> (AP mode) or <b>http://{device-ip}/settings</b> (Router mode)</p>";
|
||||||
|
|
||||||
|
_wifiManager->setCustomHeadElement(customHTML.c_str());
|
||||||
|
|
||||||
if (_wifiManager->autoConnect(apName.c_str(), netConfig.apPass.c_str())) {
|
if (_wifiManager->autoConnect(apName.c_str(), netConfig.apPass.c_str())) {
|
||||||
LOG_INFO("WiFi configured successfully via portal");
|
LOG_INFO("WiFi configured successfully via portal");
|
||||||
onWiFiConnected();
|
onWiFiConnected();
|
||||||
@@ -397,6 +416,19 @@ String Networking::getLocalIP() const {
|
|||||||
return ETH.localIP().toString();
|
return ETH.localIP().toString();
|
||||||
case ConnectionType::WIFI:
|
case ConnectionType::WIFI:
|
||||||
return WiFi.localIP().toString();
|
return WiFi.localIP().toString();
|
||||||
|
case ConnectionType::AP:
|
||||||
|
return WiFi.softAPIP().toString();
|
||||||
|
default:
|
||||||
|
return "0.0.0.0";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String Networking::getGateway() const {
|
||||||
|
switch (_activeConnection) {
|
||||||
|
case ConnectionType::ETHERNET:
|
||||||
|
return ETH.gatewayIP().toString();
|
||||||
|
case ConnectionType::WIFI:
|
||||||
|
return WiFi.gatewayIP().toString();
|
||||||
default:
|
default:
|
||||||
return "0.0.0.0";
|
return "0.0.0.0";
|
||||||
}
|
}
|
||||||
@@ -475,3 +507,62 @@ void Networking::reconnectionTimerCallback(TimerHandle_t xTimer) {
|
|||||||
_instance->handleReconnection();
|
_instance->handleReconnection();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void Networking::startPermanentAPMode() {
|
||||||
|
LOG_INFO("Starting Permanent AP Mode");
|
||||||
|
setState(NetworkState::AP_MODE_PERMANENT);
|
||||||
|
|
||||||
|
// Stop any existing connections
|
||||||
|
WiFi.disconnect(true);
|
||||||
|
WiFi.mode(WIFI_OFF);
|
||||||
|
delay(100);
|
||||||
|
|
||||||
|
// Configure AP mode with fixed 192.168.4.1 IP
|
||||||
|
WiFi.mode(WIFI_AP);
|
||||||
|
|
||||||
|
auto& netConfig = _configManager.getNetworkConfig();
|
||||||
|
String apName = netConfig.apSsid;
|
||||||
|
String apPass = netConfig.apPass;
|
||||||
|
|
||||||
|
// Configure AP with fixed IP: 192.168.4.1
|
||||||
|
IPAddress local_IP(192, 168, 4, 1);
|
||||||
|
IPAddress gateway(192, 168, 4, 1);
|
||||||
|
IPAddress subnet(255, 255, 255, 0);
|
||||||
|
|
||||||
|
if (!WiFi.softAPConfig(local_IP, gateway, subnet)) {
|
||||||
|
LOG_ERROR("Failed to configure AP IP address");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start AP
|
||||||
|
bool apStarted;
|
||||||
|
if (apPass.isEmpty()) {
|
||||||
|
apStarted = WiFi.softAP(apName.c_str());
|
||||||
|
LOG_INFO("Starting open AP (no password): %s", apName.c_str());
|
||||||
|
} else {
|
||||||
|
apStarted = WiFi.softAP(apName.c_str(), apPass.c_str());
|
||||||
|
LOG_INFO("Starting AP with password: %s", apName.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (apStarted) {
|
||||||
|
LOG_INFO("✅ Permanent AP Mode active");
|
||||||
|
LOG_INFO(" SSID: %s", apName.c_str());
|
||||||
|
LOG_INFO(" IP: 192.168.4.1");
|
||||||
|
LOG_INFO(" Subnet: 255.255.255.0");
|
||||||
|
|
||||||
|
setActiveConnection(ConnectionType::AP);
|
||||||
|
|
||||||
|
// Stop reconnection timer (not needed in permanent AP mode)
|
||||||
|
if (_reconnectionTimer) {
|
||||||
|
xTimerStop(_reconnectionTimer, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark boot sequence as complete
|
||||||
|
_bootSequenceComplete = true;
|
||||||
|
|
||||||
|
// Notify connected
|
||||||
|
notifyConnectionChange(true);
|
||||||
|
} else {
|
||||||
|
LOG_ERROR("❌ Failed to start AP Mode");
|
||||||
|
setState(NetworkState::DISCONNECTED);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -66,13 +66,15 @@ enum class NetworkState {
|
|||||||
WIFI_PORTAL_MODE,
|
WIFI_PORTAL_MODE,
|
||||||
CONNECTED_ETHERNET,
|
CONNECTED_ETHERNET,
|
||||||
CONNECTED_WIFI,
|
CONNECTED_WIFI,
|
||||||
RECONNECTING
|
RECONNECTING,
|
||||||
|
AP_MODE_PERMANENT
|
||||||
};
|
};
|
||||||
|
|
||||||
enum class ConnectionType {
|
enum class ConnectionType {
|
||||||
NONE,
|
NONE,
|
||||||
ETHERNET,
|
ETHERNET,
|
||||||
WIFI
|
WIFI,
|
||||||
|
AP
|
||||||
};
|
};
|
||||||
|
|
||||||
class Networking {
|
class Networking {
|
||||||
@@ -85,6 +87,7 @@ public:
|
|||||||
// Returns whether the network is currently connected
|
// Returns whether the network is currently connected
|
||||||
bool isConnected() const;
|
bool isConnected() const;
|
||||||
String getLocalIP() const;
|
String getLocalIP() const;
|
||||||
|
String getGateway() const;
|
||||||
ConnectionType getActiveConnection() const { return _activeConnection; }
|
ConnectionType getActiveConnection() const { return _activeConnection; }
|
||||||
NetworkState getState() const { return _state; }
|
NetworkState getState() const { return _state; }
|
||||||
|
|
||||||
@@ -97,6 +100,10 @@ public:
|
|||||||
// Manual connection control (for testing/debugging)
|
// Manual connection control (for testing/debugging)
|
||||||
void forceReconnect();
|
void forceReconnect();
|
||||||
|
|
||||||
|
// AP Mode control
|
||||||
|
void startPermanentAPMode();
|
||||||
|
bool isInAPMode() const { return _state == NetworkState::AP_MODE_PERMANENT; }
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
// HEALTH CHECK METHOD
|
// HEALTH CHECK METHOD
|
||||||
// ═══════════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ OTAManager::OTAManager(ConfigManager& configManager)
|
|||||||
, _isEmergency(false)
|
, _isEmergency(false)
|
||||||
, _progressCallback(nullptr)
|
, _progressCallback(nullptr)
|
||||||
, _statusCallback(nullptr)
|
, _statusCallback(nullptr)
|
||||||
, _scheduledCheckTimer(NULL) {
|
, _scheduledCheckTimer(NULL)
|
||||||
|
, _initialCheckTimer(NULL) {
|
||||||
}
|
}
|
||||||
|
|
||||||
OTAManager::~OTAManager() {
|
OTAManager::~OTAManager() {
|
||||||
@@ -30,6 +31,11 @@ OTAManager::~OTAManager() {
|
|||||||
xTimerDelete(_scheduledCheckTimer, portMAX_DELAY);
|
xTimerDelete(_scheduledCheckTimer, portMAX_DELAY);
|
||||||
_scheduledCheckTimer = NULL;
|
_scheduledCheckTimer = NULL;
|
||||||
}
|
}
|
||||||
|
if (_initialCheckTimer != NULL) {
|
||||||
|
xTimerStop(_initialCheckTimer, 0);
|
||||||
|
xTimerDelete(_initialCheckTimer, portMAX_DELAY);
|
||||||
|
_initialCheckTimer = NULL;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void OTAManager::begin() {
|
void OTAManager::begin() {
|
||||||
@@ -51,6 +57,23 @@ void OTAManager::begin() {
|
|||||||
} else {
|
} else {
|
||||||
LOG_ERROR("Failed to create OTA scheduled check timer!");
|
LOG_ERROR("Failed to create OTA scheduled check timer!");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔥 NEW: Create one-shot timer for initial boot check (5 seconds after boot)
|
||||||
|
// This prevents blocking during critical connection phase
|
||||||
|
_initialCheckTimer = xTimerCreate(
|
||||||
|
"OTA_InitCheck",
|
||||||
|
pdMS_TO_TICKS(5000), // 5 seconds delay
|
||||||
|
pdFALSE, // One-shot timer
|
||||||
|
this, // Timer ID (pass OTAManager instance)
|
||||||
|
initialCheckCallback
|
||||||
|
);
|
||||||
|
|
||||||
|
if (_initialCheckTimer != NULL) {
|
||||||
|
xTimerStart(_initialCheckTimer, 0);
|
||||||
|
LOG_INFO("OTA initial check scheduled for 5 seconds after boot (non-blocking)");
|
||||||
|
} else {
|
||||||
|
LOG_ERROR("Failed to create OTA initial check timer!");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void OTAManager::setFileManager(FileManager* fm) {
|
void OTAManager::setFileManager(FileManager* fm) {
|
||||||
@@ -61,6 +84,21 @@ void OTAManager::setPlayer(Player* player) {
|
|||||||
_player = player;
|
_player = player;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ✅ NEW: Static timer callback for initial boot check
|
||||||
|
void OTAManager::initialCheckCallback(TimerHandle_t xTimer) {
|
||||||
|
OTAManager* ota = static_cast<OTAManager*>(pvTimerGetTimerID(xTimer));
|
||||||
|
if (ota) {
|
||||||
|
LOG_INFO("🚀 Running initial OTA check (non-blocking, async)");
|
||||||
|
ota->performInitialCheck();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ NEW: Perform initial OTA check (async, non-blocking)
|
||||||
|
void OTAManager::performInitialCheck() {
|
||||||
|
// This runs asynchronously, won't block WebSocket/UDP/MQTT
|
||||||
|
checkForUpdates();
|
||||||
|
}
|
||||||
|
|
||||||
// ✅ NEW: Static timer callback for scheduled checks
|
// ✅ NEW: Static timer callback for scheduled checks
|
||||||
void OTAManager::scheduledCheckCallback(TimerHandle_t xTimer) {
|
void OTAManager::scheduledCheckCallback(TimerHandle_t xTimer) {
|
||||||
OTAManager* ota = static_cast<OTAManager*>(pvTimerGetTimerID(xTimer));
|
OTAManager* ota = static_cast<OTAManager*>(pvTimerGetTimerID(xTimer));
|
||||||
@@ -126,8 +164,8 @@ void OTAManager::checkForUpdates(const String& channel) {
|
|||||||
channel.c_str(), _configManager.getHardwareVariant().c_str());
|
channel.c_str(), _configManager.getHardwareVariant().c_str());
|
||||||
|
|
||||||
if (checkVersion(channel)) {
|
if (checkVersion(channel)) {
|
||||||
float currentVersion = getCurrentVersion();
|
uint16_t currentVersion = getCurrentVersion();
|
||||||
LOG_INFO("Current version: %.1f, Available version: %.1f (Channel: %s)",
|
LOG_INFO("Current version: %u, Available version: %u (Channel: %s)",
|
||||||
currentVersion, _availableVersion, channel.c_str());
|
currentVersion, _availableVersion, channel.c_str());
|
||||||
|
|
||||||
if (_availableVersion > currentVersion) {
|
if (_availableVersion > currentVersion) {
|
||||||
@@ -180,9 +218,10 @@ void OTAManager::update(const String& channel) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
float OTAManager::getCurrentVersion() const {
|
uint16_t OTAManager::getCurrentVersion() const {
|
||||||
String fwVersionStr = _configManager.getFwVersion();
|
String fwVersionStr = _configManager.getFwVersion();
|
||||||
return fwVersionStr.toFloat();
|
// Parse integer directly: "130" -> 130
|
||||||
|
return fwVersionStr.toInt();
|
||||||
}
|
}
|
||||||
|
|
||||||
void OTAManager::setStatus(Status status, ErrorCode error) {
|
void OTAManager::setStatus(Status status, ErrorCode error) {
|
||||||
@@ -243,14 +282,14 @@ bool OTAManager::checkVersion(const String& channel) {
|
|||||||
continue; // Try next server
|
continue; // Try next server
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract metadata
|
// Extract metadata - all integers now
|
||||||
_availableVersion = doc["version"].as<float>();
|
_availableVersion = doc["version"].as<uint16_t>();
|
||||||
_availableChecksum = doc["checksum"].as<String>();
|
_availableChecksum = doc["checksum"].as<String>();
|
||||||
_updateChannel = doc["channel"].as<String>();
|
_updateChannel = doc["channel"].as<String>();
|
||||||
_isMandatory = doc["mandatory"].as<bool>();
|
_isMandatory = doc["mandatory"].as<bool>();
|
||||||
_isEmergency = doc["emergency"].as<bool>();
|
_isEmergency = doc["emergency"].as<bool>();
|
||||||
_minVersion = doc["minVersion"].as<float>(); // ✅ NEW
|
_minVersion = doc["minVersion"].as<uint16_t>();
|
||||||
_expectedFileSize = doc["fileSize"].as<size_t>(); // ✅ NEW
|
_expectedFileSize = doc["fileSize"].as<size_t>();
|
||||||
|
|
||||||
// ✅ NEW: Validate channel matches requested
|
// ✅ NEW: Validate channel matches requested
|
||||||
if (_updateChannel != channel) {
|
if (_updateChannel != channel) {
|
||||||
@@ -270,16 +309,16 @@ bool OTAManager::checkVersion(const String& channel) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ✅ NEW: Check minVersion compatibility
|
// ✅ NEW: Check minVersion compatibility
|
||||||
float currentVersion = getCurrentVersion();
|
uint16_t currentVersion = getCurrentVersion();
|
||||||
if (_minVersion > 0.0f && currentVersion < _minVersion) {
|
if (_minVersion > 0 && currentVersion < _minVersion) {
|
||||||
LOG_ERROR("OTA: Current version %.1f is below minimum required %.1f",
|
LOG_ERROR("OTA: Current version %u is below minimum required %u",
|
||||||
currentVersion, _minVersion);
|
currentVersion, _minVersion);
|
||||||
LOG_ERROR("OTA: Intermediate update required first - cannot proceed");
|
LOG_ERROR("OTA: Intermediate update required first - cannot proceed");
|
||||||
_lastError = ErrorCode::VERSION_TOO_LOW;
|
_lastError = ErrorCode::VERSION_TOO_LOW;
|
||||||
continue; // Try next server
|
continue; // Try next server
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_availableVersion == 0.0f) {
|
if (_availableVersion == 0) {
|
||||||
LOG_ERROR("OTA: Invalid version in metadata from %s", baseUrl.c_str());
|
LOG_ERROR("OTA: Invalid version in metadata from %s", baseUrl.c_str());
|
||||||
continue; // Try next server
|
continue; // Try next server
|
||||||
}
|
}
|
||||||
@@ -290,7 +329,7 @@ bool OTAManager::checkVersion(const String& channel) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
LOG_INFO("OTA: Successfully got metadata from %s", baseUrl.c_str());
|
LOG_INFO("OTA: Successfully got metadata from %s", baseUrl.c_str());
|
||||||
LOG_INFO("OTA: Expected file size: %u bytes, Min version: %.1f",
|
LOG_INFO("OTA: Expected file size: %u bytes, Min version: %u",
|
||||||
_expectedFileSize, _minVersion);
|
_expectedFileSize, _minVersion);
|
||||||
return true; // Success!
|
return true; // Success!
|
||||||
} else {
|
} else {
|
||||||
@@ -571,7 +610,8 @@ bool OTAManager::installFromSD(const String& filePath) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
delay(1000);
|
delay(1000);
|
||||||
_configManager.setFwVersion(String(_availableVersion, 1)); // 1 decimal place
|
// Version is already an integer - just convert to string: 130 -> "130"
|
||||||
|
_configManager.setFwVersion(String(_availableVersion));
|
||||||
_configManager.saveDeviceConfig();
|
_configManager.saveDeviceConfig();
|
||||||
delay(500);
|
delay(500);
|
||||||
ESP.restart();
|
ESP.restart();
|
||||||
@@ -661,6 +701,54 @@ bool OTAManager::performManualUpdate(const String& channel) {
|
|||||||
return installFromSD("/firmware/staged_update.bin");
|
return installFromSD("/firmware/staged_update.bin");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
// CUSTOM FIRMWARE UPDATE
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
bool OTAManager::performCustomUpdate(const String& firmwareUrl, const String& checksum, size_t fileSize) {
|
||||||
|
if (_status != Status::IDLE) {
|
||||||
|
LOG_WARNING("OTA update already in progress");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if player is active
|
||||||
|
if (isPlayerActive()) {
|
||||||
|
LOG_ERROR("Cannot perform custom update: Player is active");
|
||||||
|
setStatus(Status::FAILED, ErrorCode::PLAYER_ACTIVE);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
LOG_INFO("🔥 Starting CUSTOM firmware update...");
|
||||||
|
LOG_INFO(" URL: %s", firmwareUrl.c_str());
|
||||||
|
LOG_INFO(" Checksum: %s", checksum.isEmpty() ? "NOT PROVIDED" : checksum.c_str());
|
||||||
|
LOG_INFO(" File Size: %u bytes", fileSize);
|
||||||
|
|
||||||
|
if (checksum.isEmpty()) {
|
||||||
|
LOG_WARNING("⚠️ No checksum provided - update will proceed without verification!");
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus(Status::DOWNLOADING);
|
||||||
|
|
||||||
|
// Download firmware from custom URL to SD
|
||||||
|
if (!downloadToSD(firmwareUrl, checksum, fileSize)) {
|
||||||
|
LOG_ERROR("Custom firmware download failed");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
LOG_INFO("✅ Custom firmware downloaded successfully");
|
||||||
|
|
||||||
|
// Install from SD
|
||||||
|
bool result = installFromSD("/firmware/staged_update.bin");
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
LOG_INFO("🚀 Custom firmware installed - device will reboot");
|
||||||
|
} else {
|
||||||
|
LOG_ERROR("❌ Custom firmware installation failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
// Hardware variant management
|
// Hardware variant management
|
||||||
String OTAManager::getHardwareVariant() const {
|
String OTAManager::getHardwareVariant() const {
|
||||||
return _configManager.getHardwareVariant();
|
return _configManager.getHardwareVariant();
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ public:
|
|||||||
void checkFirmwareUpdateFromSD(); // Check SD for firmware update
|
void checkFirmwareUpdateFromSD(); // Check SD for firmware update
|
||||||
bool performManualUpdate(); // Manual update triggered by app
|
bool performManualUpdate(); // Manual update triggered by app
|
||||||
bool performManualUpdate(const String& channel); // Manual update from specific channel
|
bool performManualUpdate(const String& channel); // Manual update from specific channel
|
||||||
|
bool performCustomUpdate(const String& firmwareUrl, const String& checksum = "", size_t fileSize = 0); // Custom firmware update
|
||||||
|
|
||||||
// Hardware identification
|
// Hardware identification
|
||||||
String getHardwareVariant() const;
|
String getHardwareVariant() const;
|
||||||
@@ -85,8 +86,8 @@ public:
|
|||||||
// Status and info
|
// Status and info
|
||||||
Status getStatus() const { return _status; }
|
Status getStatus() const { return _status; }
|
||||||
ErrorCode getLastError() const { return _lastError; }
|
ErrorCode getLastError() const { return _lastError; }
|
||||||
float getCurrentVersion() const;
|
uint16_t getCurrentVersion() const;
|
||||||
float getAvailableVersion() const { return _availableVersion; }
|
uint16_t getAvailableVersion() const { return _availableVersion; }
|
||||||
bool isUpdateAvailable() const { return _updateAvailable; }
|
bool isUpdateAvailable() const { return _updateAvailable; }
|
||||||
|
|
||||||
// Callbacks
|
// Callbacks
|
||||||
@@ -106,8 +107,8 @@ private:
|
|||||||
Player* _player; // NEW: Player reference for idle check
|
Player* _player; // NEW: Player reference for idle check
|
||||||
Status _status;
|
Status _status;
|
||||||
ErrorCode _lastError;
|
ErrorCode _lastError;
|
||||||
float _availableVersion;
|
uint16_t _availableVersion;
|
||||||
float _minVersion; // NEW: Minimum required version
|
uint16_t _minVersion; // NEW: Minimum required version
|
||||||
size_t _expectedFileSize; // NEW: Expected firmware file size
|
size_t _expectedFileSize; // NEW: Expected firmware file size
|
||||||
bool _updateAvailable;
|
bool _updateAvailable;
|
||||||
String _availableChecksum;
|
String _availableChecksum;
|
||||||
@@ -122,6 +123,11 @@ private:
|
|||||||
TimerHandle_t _scheduledCheckTimer;
|
TimerHandle_t _scheduledCheckTimer;
|
||||||
static void scheduledCheckCallback(TimerHandle_t xTimer);
|
static void scheduledCheckCallback(TimerHandle_t xTimer);
|
||||||
|
|
||||||
|
// Initial boot check timer (non-blocking delayed check)
|
||||||
|
TimerHandle_t _initialCheckTimer;
|
||||||
|
static void initialCheckCallback(TimerHandle_t xTimer);
|
||||||
|
void performInitialCheck(); // Async initial check after boot
|
||||||
|
|
||||||
void setStatus(Status status, ErrorCode error = ErrorCode::NONE);
|
void setStatus(Status status, ErrorCode error = ErrorCode::NONE);
|
||||||
void notifyProgress(size_t current, size_t total);
|
void notifyProgress(size_t current, size_t total);
|
||||||
bool checkVersion();
|
bool checkVersion();
|
||||||
|
|||||||
@@ -75,26 +75,20 @@ uint8_t OutputManager::getPhysicalOutput(uint8_t virtualOutput) const {
|
|||||||
return virtualOutput;
|
return virtualOutput;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get 1-indexed bell output from config
|
// Get 0-indexed bell output from config
|
||||||
uint16_t bellOutput1Indexed = _configManager->getBellOutput(virtualOutput);
|
uint16_t bellOutput = _configManager->getBellOutput(virtualOutput);
|
||||||
|
|
||||||
// Handle unconfigured bells (255 = disabled)
|
// Handle unconfigured bells (255 = disabled)
|
||||||
if (bellOutput1Indexed == 255) {
|
if (bellOutput == 255) {
|
||||||
LOG_WARNING("⚠️ Bell %d not configured (255)", virtualOutput);
|
LOG_WARNING("⚠️ Bell %d not configured (255)", virtualOutput);
|
||||||
return 255; // Return invalid to prevent firing
|
return 255; // Return invalid to prevent firing
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle invalid 0 configuration
|
// Physical output is already 0-indexed from config
|
||||||
if (bellOutput1Indexed == 0) {
|
uint8_t physicalOutput = (uint8_t)bellOutput;
|
||||||
LOG_ERROR("❌ Bell %d configured as 0 (invalid - should be 1-indexed)", virtualOutput);
|
|
||||||
return 255;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert 1-indexed config to 0-indexed physical output
|
LOG_DEBUG("🔗 Bell %d → 0-indexed output %d",
|
||||||
uint8_t physicalOutput = (uint8_t)(bellOutput1Indexed - 1);
|
virtualOutput, physicalOutput);
|
||||||
|
|
||||||
LOG_DEBUG("🔗 Bell %d → 1-indexed config %d → 0-indexed output %d",
|
|
||||||
virtualOutput, bellOutput1Indexed, physicalOutput);
|
|
||||||
|
|
||||||
return physicalOutput;
|
return physicalOutput;
|
||||||
}
|
}
|
||||||
@@ -140,25 +134,18 @@ void OutputManager::fireClockOutput(uint8_t virtualOutput, uint16_t durationMs)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert 1-indexed config value to 0-indexed physical output
|
// Physical output is already 0-indexed from config
|
||||||
if (physicalOutput == 0) {
|
if (!isValidPhysicalOutput(physicalOutput)) {
|
||||||
LOG_ERROR("❌ Clock output configured as 0 (invalid - should be 1-indexed)");
|
LOG_ERROR("❌ Invalid physical output for clock: %d (max outputs: %d)",
|
||||||
return;
|
physicalOutput, getMaxOutputs());
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fire the physical output directly
|
// Fire the physical output directly
|
||||||
fireOutputForDuration(zeroIndexedOutput, durationMs);
|
fireOutputForDuration(physicalOutput, durationMs);
|
||||||
|
|
||||||
LOG_DEBUG("🕐 FIRE Clock Virtual %d (C%d) → 1-indexed config %d → 0-indexed output %d for %dms",
|
LOG_DEBUG("🕐 FIRE Clock Virtual %d (C%d) → 0-indexed output %d for %dms",
|
||||||
virtualOutput, virtualOutput + 1, physicalOutput, zeroIndexedOutput, durationMs);
|
virtualOutput, virtualOutput + 1, physicalOutput, durationMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== PCF8574/PCF8575 MULTI-CHIP IMPLEMENTATION ====================
|
// ==================== PCF8574/PCF8575 MULTI-CHIP IMPLEMENTATION ====================
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
#include "Player.hpp"
|
#include "Player.hpp"
|
||||||
#include "../Communication/CommunicationRouter/CommunicationRouter.hpp"
|
#include "../Communication/CommunicationRouter/CommunicationRouter.hpp"
|
||||||
#include "../BellEngine/BellEngine.hpp"
|
#include "../BellEngine/BellEngine.hpp"
|
||||||
|
#include "../Telemetry/Telemetry.hpp"
|
||||||
|
#include "../BuiltInMelodies/BuiltInMelodies.hpp"
|
||||||
|
|
||||||
// Note: Removed global melody_steps dependency for cleaner architecture
|
// Note: Removed global melody_steps dependency for cleaner architecture
|
||||||
|
|
||||||
@@ -10,7 +12,7 @@ Player::Player(CommunicationRouter* comm, FileManager* fm)
|
|||||||
, name("melody1")
|
, name("melody1")
|
||||||
, uid("x")
|
, uid("x")
|
||||||
, url("-")
|
, url("-")
|
||||||
, noteAssignments{1,2,3,4,5,6,0,0,0,0,0,0,0,0,0,0}
|
, noteAssignments{1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16}
|
||||||
, speed(500)
|
, speed(500)
|
||||||
, segment_duration(15000)
|
, segment_duration(15000)
|
||||||
, pause_duration(0)
|
, pause_duration(0)
|
||||||
@@ -28,6 +30,7 @@ Player::Player(CommunicationRouter* comm, FileManager* fm)
|
|||||||
, _commManager(comm)
|
, _commManager(comm)
|
||||||
, _fileManager(fm)
|
, _fileManager(fm)
|
||||||
, _bellEngine(nullptr)
|
, _bellEngine(nullptr)
|
||||||
|
, _telemetry(nullptr)
|
||||||
, _durationTimerHandle(NULL) {
|
, _durationTimerHandle(NULL) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,7 +40,7 @@ Player::Player()
|
|||||||
, name("melody1")
|
, name("melody1")
|
||||||
, uid("x")
|
, uid("x")
|
||||||
, url("-")
|
, url("-")
|
||||||
, noteAssignments{1,2,3,4,5,6,0,0,0,0,0,0,0,0,0,0}
|
, noteAssignments{1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16}
|
||||||
, speed(500)
|
, speed(500)
|
||||||
, segment_duration(15000)
|
, segment_duration(15000)
|
||||||
, pause_duration(0)
|
, pause_duration(0)
|
||||||
@@ -55,6 +58,7 @@ Player::Player()
|
|||||||
, _commManager(nullptr)
|
, _commManager(nullptr)
|
||||||
, _fileManager(nullptr)
|
, _fileManager(nullptr)
|
||||||
, _bellEngine(nullptr)
|
, _bellEngine(nullptr)
|
||||||
|
, _telemetry(nullptr)
|
||||||
, _durationTimerHandle(NULL) {
|
, _durationTimerHandle(NULL) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,6 +127,12 @@ void Player::forceStop() {
|
|||||||
hardStop = true;
|
hardStop = true;
|
||||||
isPlaying = false;
|
isPlaying = false;
|
||||||
setStatus(PlayerStatus::STOPPED); // Immediate stop, notify clients
|
setStatus(PlayerStatus::STOPPED); // Immediate stop, notify clients
|
||||||
|
|
||||||
|
// Save strike counters after melody stops
|
||||||
|
if (_telemetry) {
|
||||||
|
_telemetry->saveStrikeCounters();
|
||||||
|
}
|
||||||
|
|
||||||
LOG_DEBUG("Plbck: FORCE STOP");
|
LOG_DEBUG("Plbck: FORCE STOP");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,9 +145,19 @@ void Player::stop() {
|
|||||||
isPlaying = false;
|
isPlaying = false;
|
||||||
|
|
||||||
// Set STOPPING status - actual stop message will be sent when BellEngine finishes
|
// Set STOPPING status - actual stop message will be sent when BellEngine finishes
|
||||||
|
if (isPaused) {
|
||||||
|
setStatus(PlayerStatus::STOPPED);
|
||||||
|
|
||||||
|
// Save strike counters after melody stops
|
||||||
|
if (_telemetry) {
|
||||||
|
_telemetry->saveStrikeCounters();
|
||||||
|
}
|
||||||
|
|
||||||
|
LOG_DEBUG("Plbck: STOP from PAUSED state");
|
||||||
|
} else {
|
||||||
setStatus(PlayerStatus::STOPPING);
|
setStatus(PlayerStatus::STOPPING);
|
||||||
LOG_DEBUG("Plbck: SOFT STOP (waiting for melody to complete)");
|
LOG_DEBUG("Plbck: SOFT STOP (waiting for melody to complete)");
|
||||||
|
}
|
||||||
// NOTE: The actual "stop" message is now sent in onMelodyLoopCompleted()
|
// NOTE: The actual "stop" message is now sent in onMelodyLoopCompleted()
|
||||||
// when the BellEngine actually finishes the current loop
|
// when the BellEngine actually finishes the current loop
|
||||||
}
|
}
|
||||||
@@ -226,7 +246,30 @@ void Player::setMelodyAttributes(JsonVariant doc) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void Player::loadMelodyInRAM() {
|
void Player::loadMelodyInRAM() {
|
||||||
String filePath = "/melodies/" + String(uid.c_str());
|
String uidStr = String(uid.c_str());
|
||||||
|
|
||||||
|
// 🎵 PRIORITY 1: Check if this is a built-in melody
|
||||||
|
if (BuiltInMelodies::isBuiltInMelody(uidStr)) {
|
||||||
|
LOG_INFO("Loading built-in melody: %s", uidStr.c_str());
|
||||||
|
|
||||||
|
if (BuiltInMelodies::loadBuiltInMelody(uidStr, _melodySteps)) {
|
||||||
|
LOG_INFO("✅ Built-in melody loaded successfully: %d steps", _melodySteps.size());
|
||||||
|
|
||||||
|
// Set default speed from built-in melody info
|
||||||
|
const BuiltInMelodies::MelodyInfo* melodyInfo = BuiltInMelodies::findMelodyByUID(uidStr);
|
||||||
|
if (melodyInfo && speed == 0) {
|
||||||
|
speed = melodyInfo->defaultSpeed;
|
||||||
|
LOG_DEBUG("Using default speed: %d ms/beat", speed);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
LOG_ERROR("Failed to load built-in melody: %s", uidStr.c_str());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🎵 PRIORITY 2: Load from SD card
|
||||||
|
String filePath = "/melodies/" + uidStr;
|
||||||
|
|
||||||
File bin_file = SD.open(filePath.c_str(), FILE_READ);
|
File bin_file = SD.open(filePath.c_str(), FILE_READ);
|
||||||
if (!bin_file) {
|
if (!bin_file) {
|
||||||
@@ -267,7 +310,7 @@ void Player::loadMelodyInRAM() {
|
|||||||
_melodySteps[i] = (high << 8) | low;
|
_melodySteps[i] = (high << 8) | low;
|
||||||
}
|
}
|
||||||
|
|
||||||
LOG_INFO("Melody loaded successfully: %d steps", _melodySteps.size());
|
LOG_INFO("Melody loaded successfully from SD: %d steps", _melodySteps.size());
|
||||||
bin_file.close();
|
bin_file.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,6 +428,7 @@ bool Player::timeToResume(unsigned long now) {
|
|||||||
uint64_t timeToResume = segmentCmpltTime + pause_duration;
|
uint64_t timeToResume = segmentCmpltTime + pause_duration;
|
||||||
if (now >= timeToResume) {
|
if (now >= timeToResume) {
|
||||||
LOG_DEBUG("(TimerFunction) Pause Duration Reached. Resuming");
|
LOG_DEBUG("(TimerFunction) Pause Duration Reached. Resuming");
|
||||||
|
segmentStartTime = now; // Reset segment start time for next cycle
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,7 @@
|
|||||||
// ═════════════════════════════════════════════════════════════════════════════════
|
// ═════════════════════════════════════════════════════════════════════════════════
|
||||||
class CommunicationRouter; // Command handling and communication
|
class CommunicationRouter; // Command handling and communication
|
||||||
class BellEngine; // High-precision timing engine
|
class BellEngine; // High-precision timing engine
|
||||||
|
class Telemetry; // System telemetry and monitoring
|
||||||
|
|
||||||
// ═════════════════════════════════════════════════════════════════════════════════
|
// ═════════════════════════════════════════════════════════════════════════════════
|
||||||
// PLAYER STATUS ENUMERATION
|
// PLAYER STATUS ENUMERATION
|
||||||
@@ -126,6 +127,12 @@ public:
|
|||||||
*/
|
*/
|
||||||
void setBellEngine(BellEngine* engine) { _bellEngine = engine; }
|
void setBellEngine(BellEngine* engine) { _bellEngine = engine; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Set Telemetry reference for strike counter persistence
|
||||||
|
* @param telemetry Pointer to Telemetry instance
|
||||||
|
*/
|
||||||
|
void setTelemetry(Telemetry* telemetry) { _telemetry = telemetry; }
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
// MELODY METADATA - Public access for compatibility
|
// MELODY METADATA - Public access for compatibility
|
||||||
// ═══════════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
@@ -241,6 +248,7 @@ private:
|
|||||||
CommunicationRouter* _commManager; // 📡 Communication system reference
|
CommunicationRouter* _commManager; // 📡 Communication system reference
|
||||||
FileManager* _fileManager; // 📁 File operations reference
|
FileManager* _fileManager; // 📁 File operations reference
|
||||||
BellEngine* _bellEngine; // 🔥 High-precision timing engine reference
|
BellEngine* _bellEngine; // 🔥 High-precision timing engine reference
|
||||||
|
Telemetry* _telemetry; // 📄 Telemetry system reference
|
||||||
|
|
||||||
std::vector<uint16_t> _melodySteps; // 🎵 Melody data owned by Player
|
std::vector<uint16_t> _melodySteps; // 🎵 Melody data owned by Player
|
||||||
TimerHandle_t _durationTimerHandle = NULL; // ⏱️ FreeRTOS timer (saves 4KB vs task!)
|
TimerHandle_t _durationTimerHandle = NULL; // ⏱️ FreeRTOS timer (saves 4KB vs task!)
|
||||||
|
|||||||
281
vesper/src/SettingsWebServer/SettingsPage.h
Normal file
281
vesper/src/SettingsWebServer/SettingsPage.h
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
/*
|
||||||
|
* ═══════════════════════════════════════════════════════════════════════════════════
|
||||||
|
* SETTINGSPAGE.H - HTML Content for Settings Web Interface
|
||||||
|
* ═══════════════════════════════════════════════════════════════════════════════════
|
||||||
|
*
|
||||||
|
* This file contains the HTML/CSS/JavaScript for the VESPER network settings page.
|
||||||
|
* Separated from the main implementation for better maintainability.
|
||||||
|
*
|
||||||
|
* 📋 VERSION: 1.0
|
||||||
|
* 📅 DATE: 2025-12-29
|
||||||
|
* 👨💻 AUTHOR: Advanced Bell Systems
|
||||||
|
* ═══════════════════════════════════════════════════════════════════════════════════
|
||||||
|
*/
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
// HTML template for the settings page
|
||||||
|
// Use placeholders for dynamic content:
|
||||||
|
// %MODE_BADGE_CLASS% - "ap" or "station"
|
||||||
|
// %MODE_TEXT% - "AP Mode" or "Station Mode"
|
||||||
|
// %CURRENT_IP% - Current IP address
|
||||||
|
// %DEVICE_UID% - Device unique ID
|
||||||
|
// %FW_VERSION% - Firmware version
|
||||||
|
// %AP_ACTIVE_CLASS% - "active" if in AP mode, "" otherwise
|
||||||
|
// %STATION_ACTIVE_CLASS% - "active" if in station mode, "" otherwise
|
||||||
|
// %SELECTED_MODE% - "ap" or "station"
|
||||||
|
|
||||||
|
const char SETTINGS_PAGE_HTML[] PROGMEM = R"rawliteral(
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>VESPER Network Settings</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
background: white;
|
||||||
|
border-radius: 20px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
|
||||||
|
padding: 40px;
|
||||||
|
max-width: 500px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
.header h1 {
|
||||||
|
color: #667eea;
|
||||||
|
font-size: 32px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.header p {
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.status-card {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
.status-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.status-item:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.status-label {
|
||||||
|
color: #666;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.status-value {
|
||||||
|
color: #333;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.mode-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.mode-badge.ap {
|
||||||
|
background: #e3f2fd;
|
||||||
|
color: #1976d2;
|
||||||
|
}
|
||||||
|
.mode-badge.station {
|
||||||
|
background: #e8f5e9;
|
||||||
|
color: #388e3c;
|
||||||
|
}
|
||||||
|
.section-title {
|
||||||
|
font-size: 18px;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.mode-selector {
|
||||||
|
display: flex;
|
||||||
|
gap: 15px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
.mode-option {
|
||||||
|
flex: 1;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border: 2px solid #e0e0e0;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.mode-option:hover {
|
||||||
|
border-color: #667eea;
|
||||||
|
background: #f0f4ff;
|
||||||
|
}
|
||||||
|
.mode-option.active {
|
||||||
|
border-color: #667eea;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.mode-option h3 {
|
||||||
|
font-size: 16px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.mode-option p {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 15px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.btn-primary:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 10px 20px rgba(102, 126, 234, 0.3);
|
||||||
|
}
|
||||||
|
.btn-secondary {
|
||||||
|
background: #f8f9fa;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: #e9ecef;
|
||||||
|
}
|
||||||
|
.info-box {
|
||||||
|
background: #fff3cd;
|
||||||
|
border-left: 4px solid #ffc107;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-top: 20px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #856404;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 30px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid #e0e0e0;
|
||||||
|
color: #999;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>VESPER Settings</h1>
|
||||||
|
<p>Network Configuration</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="status-card">
|
||||||
|
<div class="status-item">
|
||||||
|
<span class="status-label">Current Mode:</span>
|
||||||
|
<span class="status-value">
|
||||||
|
<span class="mode-badge %MODE_BADGE_CLASS%">
|
||||||
|
%MODE_TEXT%
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="status-item">
|
||||||
|
<span class="status-label">IP Address:</span>
|
||||||
|
<span class="status-value">%CURRENT_IP%</span>
|
||||||
|
</div>
|
||||||
|
<div class="status-item">
|
||||||
|
<span class="status-label">Device UID:</span>
|
||||||
|
<span class="status-value">%DEVICE_UID%</span>
|
||||||
|
</div>
|
||||||
|
<div class="status-item">
|
||||||
|
<span class="status-label">Firmware:</span>
|
||||||
|
<span class="status-value">v%FW_VERSION%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-title">Select Network Mode</div>
|
||||||
|
|
||||||
|
<div class="mode-selector">
|
||||||
|
<div class="mode-option %AP_ACTIVE_CLASS%" onclick="selectMode('ap')">
|
||||||
|
<h3>AP Mode</h3>
|
||||||
|
<p>Direct Connection<br>192.168.4.1</p>
|
||||||
|
</div>
|
||||||
|
<div class="mode-option %STATION_ACTIVE_CLASS%" onclick="selectMode('station')">
|
||||||
|
<h3>Router Mode</h3>
|
||||||
|
<p>Connect via Router<br>WiFi/Ethernet</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn btn-primary" onclick="applyMode()">Apply & Reboot</button>
|
||||||
|
<button class="btn btn-secondary" onclick="rebootDevice()">Reboot Device</button>
|
||||||
|
|
||||||
|
<div class="info-box">
|
||||||
|
Device will reboot after applying changes. Make sure to reconnect to the correct network after reboot.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
VESPER Bell Automation System<br>
|
||||||
|
Advanced Bell Systems
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let selectedMode = '%SELECTED_MODE%';
|
||||||
|
|
||||||
|
function selectMode(mode) {
|
||||||
|
selectedMode = mode;
|
||||||
|
document.querySelectorAll('.mode-option').forEach(el => {
|
||||||
|
el.classList.remove('active');
|
||||||
|
});
|
||||||
|
event.target.closest('.mode-option').classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyMode() {
|
||||||
|
if (confirm('Device will reboot and switch to ' + (selectedMode === 'ap' ? 'AP Mode' : 'Router Mode') + '. Continue?')) {
|
||||||
|
fetch('/api/set-mode', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||||
|
body: 'mode=' + selectedMode
|
||||||
|
}).then(response => {
|
||||||
|
alert('Rebooting... Please wait 10 seconds and reconnect.');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rebootDevice() {
|
||||||
|
if (confirm('Reboot device now?')) {
|
||||||
|
fetch('/api/reboot', {method: 'POST'}).then(() => {
|
||||||
|
alert('Rebooting... Please wait 10 seconds.');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
)rawliteral";
|
||||||
124
vesper/src/SettingsWebServer/SettingsWebServer.cpp
Normal file
124
vesper/src/SettingsWebServer/SettingsWebServer.cpp
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
/*
|
||||||
|
* ═══════════════════════════════════════════════════════════════════════════════════
|
||||||
|
* SETTINGSWEBSERVER.CPP - Network Mode Settings Web Interface Implementation
|
||||||
|
* ═══════════════════════════════════════════════════════════════════════════════════
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "SettingsWebServer.hpp"
|
||||||
|
#include "SettingsPage.h"
|
||||||
|
#include "../ConfigManager/ConfigManager.hpp"
|
||||||
|
#include "../Networking/Networking.hpp"
|
||||||
|
#include "../Logging/Logging.hpp"
|
||||||
|
|
||||||
|
SettingsWebServer::SettingsWebServer(AsyncWebServer& server,
|
||||||
|
ConfigManager& configManager,
|
||||||
|
Networking& networking)
|
||||||
|
: _server(server)
|
||||||
|
, _configManager(configManager)
|
||||||
|
, _networking(networking) {
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsWebServer::~SettingsWebServer() {
|
||||||
|
}
|
||||||
|
|
||||||
|
void SettingsWebServer::begin() {
|
||||||
|
LOG_INFO("SettingsWebServer - Initializing settings web interface");
|
||||||
|
|
||||||
|
// GET /settings - Main settings page
|
||||||
|
_server.on("/settings", HTTP_GET,
|
||||||
|
[this](AsyncWebServerRequest* request) {
|
||||||
|
handleSettingsPage(request);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// POST /api/set-mode - Set network mode
|
||||||
|
_server.on("/api/set-mode", HTTP_POST,
|
||||||
|
[this](AsyncWebServerRequest* request) {
|
||||||
|
handleSetMode(request);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// POST /api/reboot - Reboot device
|
||||||
|
_server.on("/api/reboot", HTTP_POST,
|
||||||
|
[this](AsyncWebServerRequest* request) {
|
||||||
|
handleReboot(request);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
LOG_INFO("SettingsWebServer - Endpoints registered");
|
||||||
|
LOG_INFO(" GET /settings - Settings page");
|
||||||
|
LOG_INFO(" POST /api/set-mode - Set network mode");
|
||||||
|
LOG_INFO(" POST /api/reboot - Reboot device");
|
||||||
|
}
|
||||||
|
|
||||||
|
void SettingsWebServer::handleSettingsPage(AsyncWebServerRequest* request) {
|
||||||
|
LOG_DEBUG("SettingsWebServer - Settings page requested");
|
||||||
|
String html = generateSettingsHTML();
|
||||||
|
request->send(200, "text/html", html);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SettingsWebServer::handleSetMode(AsyncWebServerRequest* request) {
|
||||||
|
if (!request->hasParam("mode", true)) {
|
||||||
|
request->send(400, "text/plain", "Missing mode parameter");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String mode = request->getParam("mode", true)->value();
|
||||||
|
LOG_INFO("SettingsWebServer - Mode change requested: %s", mode.c_str());
|
||||||
|
|
||||||
|
if (mode == "ap") {
|
||||||
|
// Switch to permanent AP mode
|
||||||
|
_configManager.setPermanentAPMode(true);
|
||||||
|
_configManager.saveNetworkConfig();
|
||||||
|
LOG_INFO("✅ Permanent AP mode enabled - will activate on reboot");
|
||||||
|
request->send(200, "text/plain", "AP mode enabled. Device will reboot in 3 seconds.");
|
||||||
|
|
||||||
|
// Reboot after 3 seconds
|
||||||
|
delay(3000);
|
||||||
|
ESP.restart();
|
||||||
|
|
||||||
|
} else if (mode == "station") {
|
||||||
|
// Switch to station mode (router mode)
|
||||||
|
_configManager.setPermanentAPMode(false);
|
||||||
|
_configManager.saveNetworkConfig();
|
||||||
|
LOG_INFO("✅ Station mode enabled - will activate on reboot");
|
||||||
|
request->send(200, "text/plain", "Station mode enabled. Device will reboot in 3 seconds.");
|
||||||
|
|
||||||
|
// Reboot after 3 seconds
|
||||||
|
delay(3000);
|
||||||
|
ESP.restart();
|
||||||
|
|
||||||
|
} else {
|
||||||
|
request->send(400, "text/plain", "Invalid mode. Use 'ap' or 'station'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void SettingsWebServer::handleReboot(AsyncWebServerRequest* request) {
|
||||||
|
LOG_INFO("SettingsWebServer - Reboot requested");
|
||||||
|
request->send(200, "text/plain", "Rebooting device in 2 seconds...");
|
||||||
|
|
||||||
|
delay(2000);
|
||||||
|
ESP.restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
String SettingsWebServer::generateSettingsHTML() {
|
||||||
|
bool isAPMode = _networking.isInAPMode();
|
||||||
|
String currentIP = _networking.getLocalIP();
|
||||||
|
String deviceUID = _configManager.getDeviceUID();
|
||||||
|
String fwVersion = _configManager.getFwVersion();
|
||||||
|
|
||||||
|
// Load HTML template from PROGMEM
|
||||||
|
String html = String(FPSTR(SETTINGS_PAGE_HTML));
|
||||||
|
|
||||||
|
// Replace placeholders with dynamic values
|
||||||
|
html.replace("%MODE_BADGE_CLASS%", isAPMode ? "ap" : "station");
|
||||||
|
html.replace("%MODE_TEXT%", isAPMode ? "AP Mode" : "Station Mode");
|
||||||
|
html.replace("%CURRENT_IP%", currentIP);
|
||||||
|
html.replace("%DEVICE_UID%", deviceUID);
|
||||||
|
html.replace("%FW_VERSION%", fwVersion);
|
||||||
|
html.replace("%AP_ACTIVE_CLASS%", isAPMode ? "active" : "");
|
||||||
|
html.replace("%STATION_ACTIVE_CLASS%", !isAPMode ? "active" : "");
|
||||||
|
html.replace("%SELECTED_MODE%", isAPMode ? "ap" : "station");
|
||||||
|
|
||||||
|
return html;
|
||||||
|
}
|
||||||
66
vesper/src/SettingsWebServer/SettingsWebServer.hpp
Normal file
66
vesper/src/SettingsWebServer/SettingsWebServer.hpp
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
/*
|
||||||
|
* ═══════════════════════════════════════════════════════════════════════════════════
|
||||||
|
* SETTINGSWEBSERVER.HPP - Network Mode Settings Web Interface
|
||||||
|
* ═══════════════════════════════════════════════════════════════════════════════════
|
||||||
|
*
|
||||||
|
* 🌐 SETTINGS WEB INTERFACE FOR VESPER 🌐
|
||||||
|
*
|
||||||
|
* Provides web interface for switching between AP and Station modes:
|
||||||
|
* • Accessible at http://192.168.4.1/settings (AP mode)
|
||||||
|
* • Accessible at http://{device-ip}/settings (Station mode)
|
||||||
|
* • Toggle between AP mode and Router mode
|
||||||
|
* • Configure WiFi credentials for router connection
|
||||||
|
* • Reboot device with new settings
|
||||||
|
*
|
||||||
|
* 🏗️ ARCHITECTURE:
|
||||||
|
* • Uses AsyncWebServer for non-blocking operation
|
||||||
|
* • HTML interface with toggle switch
|
||||||
|
* • Updates ConfigManager and triggers reboot
|
||||||
|
* • Works in both AP and Station modes
|
||||||
|
*
|
||||||
|
* 📡 ENDPOINTS:
|
||||||
|
* GET /settings - Settings page with mode toggle
|
||||||
|
* POST /api/set-mode - Set network mode (AP or STA)
|
||||||
|
* POST /api/reboot - Reboot device
|
||||||
|
*
|
||||||
|
* 📋 VERSION: 1.0
|
||||||
|
* 📅 DATE: 2025-12-28
|
||||||
|
* 👨💻 AUTHOR: Advanced Bell Systems
|
||||||
|
* ═══════════════════════════════════════════════════════════════════════════════════
|
||||||
|
*/
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include <ESPAsyncWebServer.h>
|
||||||
|
|
||||||
|
// Forward declarations
|
||||||
|
class ConfigManager;
|
||||||
|
class Networking;
|
||||||
|
|
||||||
|
class SettingsWebServer {
|
||||||
|
public:
|
||||||
|
explicit SettingsWebServer(AsyncWebServer& server,
|
||||||
|
ConfigManager& configManager,
|
||||||
|
Networking& networking);
|
||||||
|
~SettingsWebServer();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Initialize settings web server and register endpoints
|
||||||
|
*/
|
||||||
|
void begin();
|
||||||
|
|
||||||
|
private:
|
||||||
|
// Dependencies
|
||||||
|
AsyncWebServer& _server;
|
||||||
|
ConfigManager& _configManager;
|
||||||
|
Networking& _networking;
|
||||||
|
|
||||||
|
// Endpoint handlers
|
||||||
|
void handleSettingsPage(AsyncWebServerRequest* request);
|
||||||
|
void handleSetMode(AsyncWebServerRequest* request);
|
||||||
|
void handleReboot(AsyncWebServerRequest* request);
|
||||||
|
|
||||||
|
// HTML generation
|
||||||
|
String generateSettingsHTML();
|
||||||
|
};
|
||||||
@@ -1,15 +1,19 @@
|
|||||||
#include "Telemetry.hpp"
|
#include "Telemetry.hpp"
|
||||||
|
#include <ArduinoJson.h>
|
||||||
|
|
||||||
void Telemetry::begin() {
|
void Telemetry::begin() {
|
||||||
// Initialize arrays
|
// Initialize arrays
|
||||||
for (uint8_t i = 0; i < 16; i++) {
|
for (uint8_t i = 0; i < 16; i++) {
|
||||||
strikeCounters[i] = 0;
|
strikeCounters[i] = 0;
|
||||||
bellLoad[i] = 0;
|
bellLoad[i] = 0;
|
||||||
bellMaxLoad[i] = 60; // Default max load
|
bellMaxLoad[i] = 200; // Default max load
|
||||||
}
|
}
|
||||||
|
|
||||||
coolingActive = false;
|
coolingActive = false;
|
||||||
|
|
||||||
|
// Load strike counters from SD if available
|
||||||
|
loadStrikeCounters();
|
||||||
|
|
||||||
// Create the telemetry task
|
// Create the telemetry task
|
||||||
xTaskCreatePinnedToCore(telemetryTask, "TelemetryTask", 4096, this, 2, &telemetryTaskHandle, 1);
|
xTaskCreatePinnedToCore(telemetryTask, "TelemetryTask", 4096, this, 2, &telemetryTaskHandle, 1);
|
||||||
|
|
||||||
@@ -21,6 +25,11 @@ void Telemetry::setPlayerReference(bool* isPlayingPtr) {
|
|||||||
LOG_DEBUG("Player reference set");
|
LOG_DEBUG("Player reference set");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void Telemetry::setFileManager(FileManager* fm) {
|
||||||
|
fileManager = fm;
|
||||||
|
LOG_DEBUG("FileManager reference set");
|
||||||
|
}
|
||||||
|
|
||||||
void Telemetry::setForceStopCallback(void (*callback)()) {
|
void Telemetry::setForceStopCallback(void (*callback)()) {
|
||||||
forceStopCallback = callback;
|
forceStopCallback = callback;
|
||||||
LOG_DEBUG("Force stop callback set");
|
LOG_DEBUG("Force stop callback set");
|
||||||
@@ -175,6 +184,62 @@ void Telemetry::telemetryTask(void* parameter) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
// STRIKE COUNTER PERSISTENCE
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
void Telemetry::saveStrikeCounters() {
|
||||||
|
if (!fileManager) {
|
||||||
|
LOG_WARNING("Cannot save strike counters: FileManager not set");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
StaticJsonDocument<512> doc;
|
||||||
|
JsonArray counters = doc.createNestedArray("strikeCounters");
|
||||||
|
|
||||||
|
// Thread-safe read of strike counters
|
||||||
|
portENTER_CRITICAL(&telemetrySpinlock);
|
||||||
|
for (uint8_t i = 0; i < 16; i++) {
|
||||||
|
counters.add(strikeCounters[i]);
|
||||||
|
}
|
||||||
|
portEXIT_CRITICAL(&telemetrySpinlock);
|
||||||
|
|
||||||
|
if (fileManager->writeJsonFile("/telemetry_data.json", doc)) {
|
||||||
|
LOG_INFO("Strike counters saved to SD card");
|
||||||
|
} else {
|
||||||
|
LOG_ERROR("Failed to save strike counters to SD card");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void Telemetry::loadStrikeCounters() {
|
||||||
|
if (!fileManager) {
|
||||||
|
LOG_WARNING("Cannot load strike counters: FileManager not set");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
StaticJsonDocument<512> doc;
|
||||||
|
|
||||||
|
if (!fileManager->readJsonFile("/telemetry_data.json", doc)) {
|
||||||
|
LOG_INFO("No previous strike counter data found, starting fresh");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonArray counters = doc["strikeCounters"];
|
||||||
|
if (counters.isNull()) {
|
||||||
|
LOG_WARNING("Invalid telemetry data format");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thread-safe write of strike counters
|
||||||
|
portENTER_CRITICAL(&telemetrySpinlock);
|
||||||
|
for (uint8_t i = 0; i < 16 && i < counters.size(); i++) {
|
||||||
|
strikeCounters[i] = counters[i].as<uint32_t>();
|
||||||
|
}
|
||||||
|
portEXIT_CRITICAL(&telemetrySpinlock);
|
||||||
|
|
||||||
|
LOG_INFO("Strike counters loaded from SD card");
|
||||||
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
// HEALTH CHECK IMPLEMENTATION
|
// HEALTH CHECK IMPLEMENTATION
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -57,6 +57,7 @@
|
|||||||
#include "freertos/FreeRTOS.h"
|
#include "freertos/FreeRTOS.h"
|
||||||
#include "freertos/task.h"
|
#include "freertos/task.h"
|
||||||
#include "../Logging/Logging.hpp"
|
#include "../Logging/Logging.hpp"
|
||||||
|
#include "../FileManager/FileManager.hpp"
|
||||||
|
|
||||||
class Telemetry {
|
class Telemetry {
|
||||||
private:
|
private:
|
||||||
@@ -71,6 +72,7 @@ private:
|
|||||||
|
|
||||||
// External references (to be set via setters)
|
// External references (to be set via setters)
|
||||||
bool* playerIsPlayingPtr = nullptr;
|
bool* playerIsPlayingPtr = nullptr;
|
||||||
|
FileManager* fileManager = nullptr;
|
||||||
|
|
||||||
// Spinlock for critical sections
|
// Spinlock for critical sections
|
||||||
portMUX_TYPE telemetrySpinlock = portMUX_INITIALIZER_UNLOCKED;
|
portMUX_TYPE telemetrySpinlock = portMUX_INITIALIZER_UNLOCKED;
|
||||||
@@ -81,6 +83,7 @@ public:
|
|||||||
|
|
||||||
// Set external references
|
// Set external references
|
||||||
void setPlayerReference(bool* isPlayingPtr);
|
void setPlayerReference(bool* isPlayingPtr);
|
||||||
|
void setFileManager(FileManager* fm);
|
||||||
|
|
||||||
// Bell strike handling (call this on every hammer strike)
|
// Bell strike handling (call this on every hammer strike)
|
||||||
void recordBellStrike(uint8_t bellIndex);
|
void recordBellStrike(uint8_t bellIndex);
|
||||||
@@ -89,6 +92,10 @@ public:
|
|||||||
uint32_t getStrikeCount(uint8_t bellIndex);
|
uint32_t getStrikeCount(uint8_t bellIndex);
|
||||||
void resetStrikeCounters(); // User-requested reset
|
void resetStrikeCounters(); // User-requested reset
|
||||||
|
|
||||||
|
// Persistence methods
|
||||||
|
void saveStrikeCounters();
|
||||||
|
void loadStrikeCounters();
|
||||||
|
|
||||||
// Bell load management
|
// Bell load management
|
||||||
uint16_t getBellLoad(uint8_t bellIndex);
|
uint16_t getBellLoad(uint8_t bellIndex);
|
||||||
void setBellMaxLoad(uint8_t bellIndex, uint16_t maxLoad);
|
void setBellMaxLoad(uint8_t bellIndex, uint16_t maxLoad);
|
||||||
|
|||||||
@@ -135,16 +135,16 @@ unsigned long Timekeeper::getTime() {
|
|||||||
void Timekeeper::syncTimeWithNTP() {
|
void Timekeeper::syncTimeWithNTP() {
|
||||||
// Check if we have network connection and required dependencies
|
// Check if we have network connection and required dependencies
|
||||||
if (!_networking || !_configManager) {
|
if (!_networking || !_configManager) {
|
||||||
LOG_ERROR("Cannot sync time: Networking or ConfigManager not set");
|
LOG_WARNING("Cannot sync time: Networking or ConfigManager not set - using RTC time");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!_networking->isConnected()) {
|
if (!_networking->isConnected()) {
|
||||||
LOG_WARNING("Cannot sync time: No network connection");
|
LOG_INFO("No network connection - skipping NTP sync, using RTC time");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
LOG_INFO("Syncing time with NTP server...");
|
LOG_INFO("⏰ Starting non-blocking NTP sync...");
|
||||||
|
|
||||||
// Get config from ConfigManager
|
// Get config from ConfigManager
|
||||||
auto& timeConfig = _configManager->getTimeConfig();
|
auto& timeConfig = _configManager->getTimeConfig();
|
||||||
@@ -152,30 +152,23 @@ void Timekeeper::syncTimeWithNTP() {
|
|||||||
// Configure NTP with settings from config
|
// Configure NTP with settings from config
|
||||||
configTime(timeConfig.gmtOffsetSec, timeConfig.daylightOffsetSec, timeConfig.ntpServer.c_str());
|
configTime(timeConfig.gmtOffsetSec, timeConfig.daylightOffsetSec, timeConfig.ntpServer.c_str());
|
||||||
|
|
||||||
// Wait for time sync with timeout
|
// 🔥 NON-BLOCKING: Try to get time immediately without waiting
|
||||||
struct tm timeInfo;
|
struct tm timeInfo;
|
||||||
int attempts = 0;
|
if (getLocalTime(&timeInfo, 100)) { // 100ms timeout instead of blocking
|
||||||
while (!getLocalTime(&timeInfo) && attempts < 10) {
|
// Success! Update RTC with synchronized time
|
||||||
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,
|
rtc.adjust(DateTime(timeInfo.tm_year + 1900, timeInfo.tm_mon + 1, timeInfo.tm_mday,
|
||||||
timeInfo.tm_hour, timeInfo.tm_min, timeInfo.tm_sec));
|
timeInfo.tm_hour, timeInfo.tm_min, timeInfo.tm_sec));
|
||||||
|
|
||||||
LOG_INFO("Time synced successfully: %04d-%02d-%02d %02d:%02d:%02d",
|
LOG_INFO("✅ NTP sync successful: %04d-%02d-%02d %02d:%02d:%02d",
|
||||||
timeInfo.tm_year + 1900, timeInfo.tm_mon + 1, timeInfo.tm_mday,
|
timeInfo.tm_year + 1900, timeInfo.tm_mon + 1, timeInfo.tm_mday,
|
||||||
timeInfo.tm_hour, timeInfo.tm_min, timeInfo.tm_sec);
|
timeInfo.tm_hour, timeInfo.tm_min, timeInfo.tm_sec);
|
||||||
|
|
||||||
// Reload today's events since the time might have changed significantly
|
// Reload today's events since the time might have changed significantly
|
||||||
loadTodaysEvents();
|
loadTodaysEvents();
|
||||||
|
} else {
|
||||||
|
// No internet or NTP server unreachable - this is NORMAL for local networks
|
||||||
|
LOG_INFO("⚠️ NTP sync skipped (no internet) - using RTC time. This is normal for local networks.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -24,8 +24,9 @@
|
|||||||
* 🎯 KEY FEATURES:
|
* 🎯 KEY FEATURES:
|
||||||
* ✅ Microsecond-precision bell timing (BellEngine)
|
* ✅ Microsecond-precision bell timing (BellEngine)
|
||||||
* ✅ Multi-hardware support (PCF8574, GPIO, Mock)
|
* ✅ Multi-hardware support (PCF8574, GPIO, Mock)
|
||||||
* ✅ Dual network connectivity (Ethernet + WiFi)
|
* ✅ Dual network connectivity (Ethernet + WiFi + Permanent AP Mode)
|
||||||
* ✅ Dual Communication Support (MQTT + WebSocket)
|
* ✅ Multi-protocol communication (MQTT + WebSocket + HTTP REST API)
|
||||||
|
* ✅ Web settings interface for network mode switching
|
||||||
* ✅ Real-time telemetry and load monitoring
|
* ✅ Real-time telemetry and load monitoring
|
||||||
* ✅ Over-the-air firmware updates
|
* ✅ Over-the-air firmware updates
|
||||||
* ✅ SD card configuration and file management
|
* ✅ SD card configuration and file management
|
||||||
@@ -33,8 +34,9 @@
|
|||||||
* ✅ Comprehensive logging system
|
* ✅ Comprehensive logging system
|
||||||
*
|
*
|
||||||
* 📡 COMMUNICATION PROTOCOLS:
|
* 📡 COMMUNICATION PROTOCOLS:
|
||||||
* • MQTT (SSL/TLS via PubSubClient on Core 0)
|
* • MQTT (SSL/TLS via AsyncMqttClient on Core 0)
|
||||||
* • WebSocket (Real-time web interface)
|
* • WebSocket (Real-time web interface)
|
||||||
|
* • HTTP REST API (Command execution via HTTP)
|
||||||
* • UDP Discovery (Auto-discovery service)
|
* • UDP Discovery (Auto-discovery service)
|
||||||
* • HTTP/HTTPS (OTA updates)
|
* • HTTP/HTTPS (OTA updates)
|
||||||
*
|
*
|
||||||
@@ -62,14 +64,19 @@
|
|||||||
* 👨💻 AUTHOR: BellSystems bonamin
|
* 👨💻 AUTHOR: BellSystems bonamin
|
||||||
*/
|
*/
|
||||||
|
|
||||||
#define FW_VERSION "0.1"
|
#define FW_VERSION "137"
|
||||||
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* ═══════════════════════════════════════════════════════════════════════════════
|
* ═══════════════════════════════════════════════════════════════════════════════
|
||||||
* 📅 VERSION HISTORY:
|
* 📅 VERSION HISTORY:
|
||||||
* ═══════════════════════════════════════════════════════════════════════════════
|
* ═══════════════════════════════════════════════════════════════════════════════
|
||||||
* v0.1 - Vesper Launch Beta
|
* v0.1 (100) - Vesper Launch Beta
|
||||||
|
* v1.2 (120) - Added Log Level Configuration via App/MQTT
|
||||||
|
* v1.3 (130) - Added Telemetry Reports to App, Various Playback Fixes
|
||||||
|
* v137 - Made OTA and MQTT delays Async
|
||||||
|
* ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
* NOTE: Versions are now stored as integers (v1.3 = 130)
|
||||||
* ═══════════════════════════════════════════════════════════════════════════════
|
* ═══════════════════════════════════════════════════════════════════════════════
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -79,8 +86,6 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════════════
|
||||||
// SYSTEM LIBRARIES - Core ESP32 and Arduino functionality
|
// SYSTEM LIBRARIES - Core ESP32 and Arduino functionality
|
||||||
// ═══════════════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════════════
|
||||||
@@ -194,7 +199,8 @@ void setup()
|
|||||||
{
|
{
|
||||||
// Initialize Serial Communications (for debugging) & I2C Bus (for Hardware Control)
|
// Initialize Serial Communications (for debugging) & I2C Bus (for Hardware Control)
|
||||||
Serial.begin(115200);
|
Serial.begin(115200);
|
||||||
Serial.println("Hello, VESPER System Initialized! - PontikoTest");
|
Serial.print("VESPER System Booting UP! - Version ");
|
||||||
|
Serial.println(FW_VERSION);
|
||||||
Wire.begin(4,15);
|
Wire.begin(4,15);
|
||||||
auto& hwConfig = configManager.getHardwareConfig();
|
auto& hwConfig = configManager.getHardwareConfig();
|
||||||
SPI.begin(hwConfig.ethSpiSck, hwConfig.ethSpiMiso, hwConfig.ethSpiMosi);
|
SPI.begin(hwConfig.ethSpiSck, hwConfig.ethSpiMiso, hwConfig.ethSpiMosi);
|
||||||
@@ -203,6 +209,11 @@ void setup()
|
|||||||
// Initialize Configuration (loads factory identity from NVS + user settings from SD)
|
// Initialize Configuration (loads factory identity from NVS + user settings from SD)
|
||||||
configManager.begin();
|
configManager.begin();
|
||||||
|
|
||||||
|
// Apply log level from config (loaded from SD)
|
||||||
|
uint8_t logLevel = configManager.getGeneralConfig().serialLogLevel;
|
||||||
|
Logging::setLevel((Logging::LogLevel)logLevel);
|
||||||
|
LOG_INFO("Log level set to %d from configuration", logLevel);
|
||||||
|
|
||||||
inputManager.begin();
|
inputManager.begin();
|
||||||
inputManager.setFactoryResetLongPressCallback(handleFactoryReset);
|
inputManager.setFactoryResetLongPressCallback(handleFactoryReset);
|
||||||
|
|
||||||
@@ -214,6 +225,18 @@ void setup()
|
|||||||
// ═══════════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
// Update firmware version (this is the ONLY identity field that can be set)
|
// Update firmware version (this is the ONLY identity field that can be set)
|
||||||
|
|
||||||
|
// 🔥 MIGRATION: Convert old float-style version to integer format
|
||||||
|
String currentVersion = configManager.getFwVersion();
|
||||||
|
if (currentVersion.indexOf('.') != -1) {
|
||||||
|
// Old format detected (e.g., "1.3"), convert to integer ("130")
|
||||||
|
float versionFloat = currentVersion.toFloat();
|
||||||
|
uint16_t versionInt = (uint16_t)(versionFloat * 100.0f);
|
||||||
|
configManager.setFwVersion(String(versionInt));
|
||||||
|
configManager.saveDeviceConfig();
|
||||||
|
LOG_INFO("⚠️ Migrated version format: %s -> %u", currentVersion.c_str(), versionInt);
|
||||||
|
}
|
||||||
|
|
||||||
configManager.setFwVersion(FW_VERSION);
|
configManager.setFwVersion(FW_VERSION);
|
||||||
LOG_INFO("Firmware version: %s", FW_VERSION);
|
LOG_INFO("Firmware version: %s", FW_VERSION);
|
||||||
|
|
||||||
@@ -279,10 +302,11 @@ void setup()
|
|||||||
healthMonitor.setTimeKeeper(&timekeeper);
|
healthMonitor.setTimeKeeper(&timekeeper);
|
||||||
|
|
||||||
// Initialize Telemetry
|
// Initialize Telemetry
|
||||||
telemetry.begin();
|
|
||||||
telemetry.setPlayerReference(&player.isPlaying);
|
telemetry.setPlayerReference(&player.isPlaying);
|
||||||
// 🚑 CRITICAL: Connect force stop callback for overload protection!
|
// 🚑 CRITICAL: Connect force stop callback for overload protection!
|
||||||
telemetry.setForceStopCallback([]() { player.forceStop(); });
|
telemetry.setForceStopCallback([]() { player.forceStop(); });
|
||||||
|
telemetry.setFileManager(&fileManager);
|
||||||
|
telemetry.begin();
|
||||||
|
|
||||||
// Register Telemetry with health monitor
|
// Register Telemetry with health monitor
|
||||||
healthMonitor.setTelemetry(&telemetry);
|
healthMonitor.setTelemetry(&telemetry);
|
||||||
@@ -308,9 +332,11 @@ void setup()
|
|||||||
communication.setFileManagerReference(&fileManager);
|
communication.setFileManagerReference(&fileManager);
|
||||||
communication.setTimeKeeperReference(&timekeeper);
|
communication.setTimeKeeperReference(&timekeeper);
|
||||||
communication.setFirmwareValidatorReference(&firmwareValidator);
|
communication.setFirmwareValidatorReference(&firmwareValidator);
|
||||||
|
communication.setTelemetryReference(&telemetry);
|
||||||
|
|
||||||
player.setDependencies(&communication, &fileManager);
|
player.setDependencies(&communication, &fileManager);
|
||||||
player.setBellEngine(&bellEngine); // Connect the beast!
|
player.setBellEngine(&bellEngine); // Connect the beast!
|
||||||
|
player.setTelemetry(&telemetry);
|
||||||
|
|
||||||
// Register Communication with health monitor
|
// Register Communication with health monitor
|
||||||
healthMonitor.setCommunication(&communication);
|
healthMonitor.setCommunication(&communication);
|
||||||
@@ -318,46 +344,61 @@ void setup()
|
|||||||
// 🔔 CONNECT BELLENGINE TO COMMUNICATION FOR DING NOTIFICATIONS!
|
// 🔔 CONNECT BELLENGINE TO COMMUNICATION FOR DING NOTIFICATIONS!
|
||||||
bellEngine.setCommunicationManager(&communication);
|
bellEngine.setCommunicationManager(&communication);
|
||||||
|
|
||||||
|
// Track if AsyncWebServer has been started to prevent duplicates
|
||||||
|
static bool webServerStarted = false;
|
||||||
|
|
||||||
// Set up network callbacks
|
// Set up network callbacks
|
||||||
networking.setNetworkCallbacks(
|
networking.setNetworkCallbacks(
|
||||||
[]() {
|
[&webServerStarted]() {
|
||||||
communication.onNetworkConnected();
|
communication.onNetworkConnected();
|
||||||
// Start AsyncWebServer when network becomes available
|
|
||||||
if (networking.getState() != NetworkState::WIFI_PORTAL_MODE) {
|
// Non-blocking NTP sync (graceful without internet)
|
||||||
|
// Skip NTP sync in AP mode (no internet connection)
|
||||||
|
if (!networking.isInAPMode()) {
|
||||||
|
timekeeper.syncTimeWithNTP();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start AsyncWebServer when network becomes available (only once!)
|
||||||
|
if (!webServerStarted && networking.getState() != NetworkState::WIFI_PORTAL_MODE) {
|
||||||
LOG_INFO("🚀 Starting AsyncWebServer on port 80...");
|
LOG_INFO("🚀 Starting AsyncWebServer on port 80...");
|
||||||
server.begin();
|
server.begin();
|
||||||
LOG_INFO("✅ AsyncWebServer started on http://%s", networking.getLocalIP().c_str());
|
LOG_INFO("✅ AsyncWebServer started on http://%s", networking.getLocalIP().c_str());
|
||||||
|
webServerStarted = true;
|
||||||
}
|
}
|
||||||
}, // onConnected
|
}, // onConnected
|
||||||
[]() { communication.onNetworkDisconnected(); } // onDisconnected
|
[]() { communication.onNetworkDisconnected(); } // onDisconnected
|
||||||
);
|
);
|
||||||
|
|
||||||
// If already connected, trigger MQTT connection manually
|
// If already connected, trigger MQTT connection and setup manually
|
||||||
if (networking.isConnected()) {
|
if (networking.isConnected()) {
|
||||||
LOG_INFO("Network already connected - triggering MQTT connection");
|
LOG_INFO("Network already connected - initializing services");
|
||||||
communication.onNetworkConnected();
|
communication.onNetworkConnected();
|
||||||
|
|
||||||
|
// Non-blocking NTP sync (graceful without internet)
|
||||||
|
// Skip NTP sync in AP mode (no internet connection)
|
||||||
|
if (!networking.isInAPMode()) {
|
||||||
|
timekeeper.syncTimeWithNTP();
|
||||||
|
}
|
||||||
|
|
||||||
// 🔥 CRITICAL: Start AsyncWebServer ONLY when network is ready
|
// 🔥 CRITICAL: Start AsyncWebServer ONLY when network is ready
|
||||||
// Do NOT start if WiFiManager portal is active (port 80 conflict!)
|
// Do NOT start if WiFiManager portal is active (port 80 conflict!)
|
||||||
|
if (!webServerStarted && networking.getState() != NetworkState::WIFI_PORTAL_MODE) {
|
||||||
LOG_INFO("🚀 Starting AsyncWebServer on port 80...");
|
LOG_INFO("🚀 Starting AsyncWebServer on port 80...");
|
||||||
server.begin();
|
server.begin();
|
||||||
LOG_INFO("✅ AsyncWebServer started and listening on http://%s", networking.getLocalIP().c_str());
|
LOG_INFO("✅ AsyncWebServer started on http://%s", networking.getLocalIP().c_str());
|
||||||
|
webServerStarted = true;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
LOG_WARNING("⚠️ Network not ready - AsyncWebServer will start after connection");
|
LOG_WARNING("⚠️ Network not ready - services will start after connection");
|
||||||
}
|
}
|
||||||
|
|
||||||
delay(500);
|
// Initialize OTA Manager
|
||||||
|
|
||||||
// Initialize OTA Manager and check for updates
|
|
||||||
otaManager.begin();
|
otaManager.begin();
|
||||||
otaManager.setFileManager(&fileManager);
|
otaManager.setFileManager(&fileManager);
|
||||||
otaManager.setPlayer(&player); // Set player reference for idle check
|
otaManager.setPlayer(&player); // Set player reference for idle check
|
||||||
|
|
||||||
// 🔥 CRITICAL: Delay OTA check to avoid UDP socket race with MQTT
|
// 🔥 FIX: OTA check will happen asynchronously via scheduled timer (no blocking delay)
|
||||||
// Both MQTT and OTA HTTP use UDP sockets, must sequence them!
|
// UDP discovery setup can happen immediately without conflicts
|
||||||
delay(2000);
|
|
||||||
LOG_INFO("Starting OTA update check after network stabilization...");
|
|
||||||
otaManager.checkForUpdates();
|
|
||||||
communication.setupUdpDiscovery();
|
communication.setupUdpDiscovery();
|
||||||
|
|
||||||
// Register OTA Manager with health monitor
|
// Register OTA Manager with health monitor
|
||||||
@@ -428,10 +469,22 @@ void loop()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔥 CRITICAL: Clean up dead WebSocket connections every 2 seconds
|
||||||
|
// This prevents ghost connections from blocking new clients
|
||||||
|
static unsigned long lastWsCleanup = 0;
|
||||||
|
if (millis() - lastWsCleanup > 2000) {
|
||||||
|
ws.cleanupClients();
|
||||||
|
lastWsCleanup = millis();
|
||||||
|
}
|
||||||
|
|
||||||
// 🔥 DEBUG: Log every 10 seconds to verify we're still running
|
// 🔥 DEBUG: Log every 10 seconds to verify we're still running
|
||||||
static unsigned long lastLog = 0;
|
static unsigned long lastLog = 0;
|
||||||
if (millis() - lastLog > 10000) {
|
if (millis() - lastLog > 10000) {
|
||||||
LOG_DEBUG("❤️ Loop alive, free heap: %d", ESP.getFreeHeap());
|
LOG_DEBUG("❤️ Loop alive | Free heap: %d bytes (%.1f KB) | Min free: %d | Largest block: %d",
|
||||||
|
ESP.getFreeHeap(),
|
||||||
|
ESP.getFreeHeap() / 1024.0,
|
||||||
|
ESP.getMinFreeHeap(),
|
||||||
|
ESP.getMaxAllocHeap());
|
||||||
lastLog = millis();
|
lastLog = millis();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user