Compare commits
27 Commits
mqtt_ssl_f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c656835d8e | |||
| 980de08584 | |||
| 9c314d88cf | |||
| 53c55d2726 | |||
| 094b1a9620 | |||
| 11b98166d1 | |||
| 7e279c6e45 | |||
| eb6e0f0e5c | |||
| 7adc1fec34 | |||
| 51b7722e1d | |||
| 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/docs_manual/
|
||||
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
|
||||
828
vesper/documentation/API_Reference.md
Normal file
828
vesper/documentation/API_Reference.md
Normal file
@@ -0,0 +1,828 @@
|
||||
# 🔔 VESPER ESP32 Communication API Reference v3.0
|
||||
|
||||
> **Complete command reference for Vesper Bell Automation System with Grouped Commands**
|
||||
> Version: 3.0 | Updated: 2025-09-15
|
||||
> Supports: MQTT + WebSocket protocols with multi-client support and batch processing
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
### Connection Protocols
|
||||
- **MQTT**: `vesper/{device_id}/control` (commands) → `vesper/{device_id}/data` (responses)
|
||||
- **WebSocket**: `ws://{esp_ip}/ws` (bidirectional)
|
||||
- **UDP Discovery**: Broadcast on configured port for device discovery
|
||||
- **UDP Port**: 32101
|
||||
|
||||
### WebSocket Client Identification
|
||||
**Required for WebSocket clients to receive targeted messages:**
|
||||
|
||||
```json
|
||||
{
|
||||
"cmd": "system",
|
||||
"contents": {
|
||||
"action": "identify",
|
||||
"device_type": "master" // or "secondary"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"status": "SUCCESS",
|
||||
"type": "identify",
|
||||
"payload": "Device identified as master"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 Command Categories (NEW GROUPED ARCHITECTURE)
|
||||
|
||||
- [🖥️ System Commands](#️-system-commands)
|
||||
- [🎵 Playback Control](#-playback-control)
|
||||
- [📁 File Management](#-file-management)
|
||||
- [🔧 Relay Setup](#-relay-setup)
|
||||
- [🕐 Clock Setup](#-clock-setup)
|
||||
- [📢 Information Messages](#-information-messages)
|
||||
- [🌐 Network & Discovery](#-network--discovery)
|
||||
- [🔄 Legacy Command Support](#-legacy-command-support)
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ System Commands
|
||||
|
||||
### 🏓 Ping Test
|
||||
|
||||
**Command:**
|
||||
```json
|
||||
{
|
||||
"cmd": "system",
|
||||
"contents": {
|
||||
"action": "ping"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"status": "SUCCESS",
|
||||
"type": "pong",
|
||||
"payload": ""
|
||||
}
|
||||
```
|
||||
|
||||
### 📊 System Status
|
||||
|
||||
**Command:**
|
||||
```json
|
||||
{
|
||||
"cmd": "system",
|
||||
"contents": {
|
||||
"action": "status"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"status": "SUCCESS",
|
||||
"type": "current_status",
|
||||
"payload": {
|
||||
"player_status": "playing",
|
||||
"time_elapsed": 45230,
|
||||
"projected_run_time": 34598,
|
||||
"timestamp": 1699123456789
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 👤 Device Identification (WebSocket Only)
|
||||
|
||||
**Command:**
|
||||
```json
|
||||
{
|
||||
"cmd": "system",
|
||||
"contents": {
|
||||
"action": "identify",
|
||||
"device_type": "master"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 🔄 Restart Device
|
||||
|
||||
**Command:**
|
||||
```json
|
||||
{
|
||||
"cmd": "system",
|
||||
"contents": {
|
||||
"action": "restart"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"status": "SUCCESS",
|
||||
"type": "restart",
|
||||
"payload": "Device will restart in 2 seconds"
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** Device will reboot after sending the response.
|
||||
|
||||
### 🔄 Force OTA Update
|
||||
|
||||
**Command:**
|
||||
```json
|
||||
{
|
||||
"cmd": "system",
|
||||
"contents": {
|
||||
"action": "force_update",
|
||||
"channel": "stable" // optional: "stable", "beta", or "emergency" (default: "stable")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"status": "SUCCESS",
|
||||
"type": "force_update",
|
||||
"payload": "Starting forced OTA update from channel: stable. Device may reboot."
|
||||
}
|
||||
```
|
||||
|
||||
**Error Response (if player is active):**
|
||||
```json
|
||||
{
|
||||
"status": "ERROR",
|
||||
"type": "force_update",
|
||||
"payload": "Cannot update while playback is active"
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** If update is successful, device will reboot automatically.
|
||||
|
||||
### 🔥 Custom Firmware Update
|
||||
|
||||
**Command:**
|
||||
```json
|
||||
{
|
||||
"cmd": "system",
|
||||
"contents": {
|
||||
"action": "custom_update",
|
||||
"firmware_url": "https://example.com/path/to/firmware.bin",
|
||||
"checksum": "a1b2c3d4e5f6...", // optional: SHA256 checksum for verification
|
||||
"file_size": 1234567, // optional: expected file size in bytes
|
||||
"version": 145 // optional: firmware version number to save in NVS
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"status": "SUCCESS",
|
||||
"type": "custom_update",
|
||||
"payload": "Starting custom OTA update. Device may reboot."
|
||||
}
|
||||
```
|
||||
|
||||
**Error Responses:**
|
||||
```json
|
||||
{
|
||||
"status": "ERROR",
|
||||
"type": "custom_update",
|
||||
"payload": "Missing firmware_url parameter"
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ERROR",
|
||||
"type": "custom_update",
|
||||
"payload": "Cannot update while playback is active"
|
||||
}
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- Download firmware from any URL (bypasses configured update servers)
|
||||
- Optional SHA256 checksum verification
|
||||
- Optional file size validation
|
||||
- Optional version number to update NVS (prevents unwanted auto-downgrades)
|
||||
- Automatically blocks updates during playback
|
||||
- Device reboots on successful installation
|
||||
|
||||
**Version Parameter Behavior:**
|
||||
- If `version` is provided (> 0): NVS firmware version will be updated to this value
|
||||
- If `version` is omitted or 0: NVS firmware version remains unchanged
|
||||
- **Important:** Without version parameter, future OTA checks may detect your custom firmware as "outdated" and trigger auto-updates/downgrades
|
||||
|
||||
**Note:** If update is successful, device will reboot automatically. Use with caution!
|
||||
|
||||
---
|
||||
|
||||
## 🎵 Playback Control
|
||||
|
||||
### ▶️ Start Playback
|
||||
|
||||
**Command:**
|
||||
```json
|
||||
{
|
||||
"cmd": "playback",
|
||||
"contents": {
|
||||
"action": "play",
|
||||
"name": "My Melody",
|
||||
"uid": "01DegzV9FA8tYbQpkIHR",
|
||||
"url": "https://example.com/melody.bin",
|
||||
"speed": 500,
|
||||
"note_assignments": [1, 2, 3, 4, 5, 6, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
"segment_duration": 15000,
|
||||
"pause_duration": 5000,
|
||||
"total_duration": 60000,
|
||||
"continuous_loop": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### ⏹️ Stop Playback
|
||||
|
||||
**Command:**
|
||||
```json
|
||||
{
|
||||
"cmd": "playback",
|
||||
"contents": {
|
||||
"action": "stop"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📁 File Management
|
||||
|
||||
### 📋 List Available Melodies
|
||||
|
||||
**Command:**
|
||||
```json
|
||||
{
|
||||
"cmd": "file_manager",
|
||||
"contents": {
|
||||
"action": "list_melodies"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Success Response:**
|
||||
```json
|
||||
{
|
||||
"status": "SUCCESS",
|
||||
"type": "list_melodies",
|
||||
"payload": ["melody1.bin", "melody2.bin", "melody3.bin"]
|
||||
}
|
||||
```
|
||||
|
||||
### 📥 Download Melody
|
||||
|
||||
**Command:**
|
||||
```json
|
||||
{
|
||||
"cmd": "file_manager",
|
||||
"contents": {
|
||||
"action": "download_melody",
|
||||
"download_url": "https://example.com/melody.bin",
|
||||
"melodys_uid": "01DegzV9FA8tYbQpkIHR",
|
||||
"name": "Optional Display Name"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 🗑️ Delete Melody
|
||||
|
||||
**Command:**
|
||||
```json
|
||||
{
|
||||
"cmd": "file_manager",
|
||||
"contents": {
|
||||
"action": "delete_melody",
|
||||
"name": "01DegzV9FA8tYbQpkIHR"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Relay Setup
|
||||
|
||||
### ⏱️ Set Relay Timers (Single Bell)
|
||||
|
||||
**Command:**
|
||||
```json
|
||||
{
|
||||
"cmd": "relay_setup",
|
||||
"contents": {
|
||||
"action": "set_timers",
|
||||
"b1": 100,
|
||||
"b2": 200,
|
||||
"b3": 150
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### ⏱️ Set Relay Timers (Batch Mode)
|
||||
|
||||
**Command:**
|
||||
```json
|
||||
{
|
||||
"cmd": "relay_setup",
|
||||
"contents": {
|
||||
"action": "set_timers",
|
||||
"timers": {
|
||||
"b1": 100,
|
||||
"b2": 200,
|
||||
"b3": 150,
|
||||
"b4": 300,
|
||||
"b5": 250,
|
||||
"b6": 180
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 🔌 Set Relay Outputs (Single Bell)
|
||||
|
||||
**Command:**
|
||||
```json
|
||||
{
|
||||
"cmd": "relay_setup",
|
||||
"contents": {
|
||||
"action": "set_outputs",
|
||||
"b1": 1,
|
||||
"b2": 2,
|
||||
"b3": 3
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 🔌 Set Relay Outputs (Batch Mode)
|
||||
|
||||
**Command:**
|
||||
```json
|
||||
{
|
||||
"cmd": "relay_setup",
|
||||
"contents": {
|
||||
"action": "set_outputs",
|
||||
"outputs": {
|
||||
"b1": 1,
|
||||
"b2": 2,
|
||||
"b3": 3,
|
||||
"b4": 4,
|
||||
"b5": 5,
|
||||
"b6": 6
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🕐 Clock Setup
|
||||
|
||||
### 🔌 Set Clock Outputs
|
||||
|
||||
**Command:**
|
||||
```json
|
||||
{
|
||||
"cmd": "clock_setup",
|
||||
"contents": {
|
||||
"action": "set_outputs",
|
||||
"c1": 1,
|
||||
"c2": 2
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### ⏰ Set Clock Timings
|
||||
|
||||
**Command:**
|
||||
```json
|
||||
{
|
||||
"cmd": "clock_setup",
|
||||
"contents": {
|
||||
"action": "set_timings",
|
||||
"pulseDuration": 5000,
|
||||
"pauseDuration": 2000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 🔔 Set Clock Alerts
|
||||
|
||||
**Command:**
|
||||
```json
|
||||
{
|
||||
"cmd": "clock_setup",
|
||||
"contents": {
|
||||
"action": "set_alerts",
|
||||
"alertType": "HOURS",
|
||||
"alertRingInterval": 1000,
|
||||
"hourBell": 1,
|
||||
"halfBell": 2,
|
||||
"quarterBell": 3
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 💡 Set Clock Backlight
|
||||
|
||||
**Command:**
|
||||
```json
|
||||
{
|
||||
"cmd": "clock_setup",
|
||||
"contents": {
|
||||
"action": "set_backlight",
|
||||
"enabled": true,
|
||||
"output": 5,
|
||||
"onTime": "18:00",
|
||||
"offTime": "06:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 🔇 Set Clock Silence Periods
|
||||
|
||||
**Command:**
|
||||
```json
|
||||
{
|
||||
"cmd": "clock_setup",
|
||||
"contents": {
|
||||
"action": "set_silence",
|
||||
"daytime": {
|
||||
"enabled": true,
|
||||
"onTime": "13:00",
|
||||
"offTime": "15:00"
|
||||
},
|
||||
"nighttime": {
|
||||
"enabled": true,
|
||||
"onTime": "22:00",
|
||||
"offTime": "07:00"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 🚀 Batch Clock Setup (Multiple Settings at Once)
|
||||
|
||||
**Command:**
|
||||
```json
|
||||
{
|
||||
"cmd": "clock_setup",
|
||||
"contents": {
|
||||
"action": "batch_setup",
|
||||
"outputs": {
|
||||
"c1": 1,
|
||||
"c2": 2
|
||||
},
|
||||
"timings": {
|
||||
"pulseDuration": 5000,
|
||||
"pauseDuration": 2000
|
||||
},
|
||||
"alerts": {
|
||||
"alertType": "HOURS",
|
||||
"hourBell": 1,
|
||||
"halfBell": 2
|
||||
},
|
||||
"backlight": {
|
||||
"enabled": true,
|
||||
"output": 5,
|
||||
"onTime": "18:00",
|
||||
"offTime": "06:00"
|
||||
},
|
||||
"silence": {
|
||||
"daytime": {
|
||||
"enabled": true,
|
||||
"onTime": "13:00",
|
||||
"offTime": "15:00"
|
||||
},
|
||||
"nighttime": {
|
||||
"enabled": true,
|
||||
"onTime": "22:00",
|
||||
"offTime": "07:00"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Success Response:**
|
||||
```json
|
||||
{
|
||||
"status": "SUCCESS",
|
||||
"type": "clock_setup",
|
||||
"payload": "Batch clock setup updated: 5 sections"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📢 Information Messages
|
||||
|
||||
> **Automatic status broadcasts sent to ALL clients**
|
||||
> These messages are initiated by the ESP32 system and broadcast to all connected clients without being requested.
|
||||
|
||||
### 🎵 Playback Status Updates
|
||||
|
||||
**Sent automatically during playback state changes:**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "INFO",
|
||||
"type": "playback",
|
||||
"payload": {
|
||||
"action": "playing",
|
||||
"time_elapsed": 125,
|
||||
"projected_run_time": 5158
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### ⚠️ Bell Overload Warnings
|
||||
|
||||
**Sent automatically when bell load monitoring detects issues:**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "INFO",
|
||||
"type": "bell_overload",
|
||||
"payload": {
|
||||
"bells": [1, 3, 5],
|
||||
"loads": [85, 92, 78],
|
||||
"severity": "warning"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🌐 Network & Discovery
|
||||
|
||||
### 🔍 UDP Discovery
|
||||
|
||||
**UDP Broadcast Request:**
|
||||
```json
|
||||
{
|
||||
"op": "discover",
|
||||
"svc": "vesper"
|
||||
}
|
||||
```
|
||||
|
||||
**UDP Response:**
|
||||
```json
|
||||
{
|
||||
"op": "discover_reply",
|
||||
"svc": "vesper",
|
||||
"ver": 1,
|
||||
"name": "Proj. Vesper v0.5",
|
||||
"id": "ESP32_ABC123",
|
||||
"ip": "192.168.1.100",
|
||||
"ws": "ws://192.168.1.100/ws",
|
||||
"port": 80,
|
||||
"fw": "1.2.3",
|
||||
"clients": 2
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Legacy Command Support
|
||||
|
||||
**For backward compatibility, the following legacy commands are still supported:**
|
||||
|
||||
### Individual Commands (Legacy)
|
||||
- `cmd: "ping"` → Use `system` with `action: "ping"`
|
||||
- `cmd: "report_status"` → Use `system` with `action: "status"`
|
||||
- `cmd: "identify"` → Use `system` with `action: "identify"`
|
||||
- `cmd: "list_melodies"` → Use `file_manager` with `action: "list_melodies"`
|
||||
- `cmd: "download_melody"` → Use `file_manager` with `action: "download_melody"`
|
||||
- `cmd: "delete_melody"` → Use `file_manager` with `action: "delete_melody"`
|
||||
- `cmd: "set_relay_timers"` → Use `relay_setup` with `action: "set_timers"`
|
||||
- `cmd: "set_relay_outputs"` → Use `relay_setup` with `action: "set_outputs"`
|
||||
- `cmd: "set_clock_outputs"` → Use `clock_setup` with `action: "set_outputs"`
|
||||
- `cmd: "set_clock_timings"` → Use `clock_setup` with `action: "set_timings"`
|
||||
- `cmd: "set_clock_alerts"` → Use `clock_setup` with `action: "set_alerts"`
|
||||
- `cmd: "set_clock_backlight"` → Use `clock_setup` with `action: "set_backlight"`
|
||||
- `cmd: "set_clock_silence"` → Use `clock_setup` with `action: "set_silence"`
|
||||
|
||||
**Legacy commands will continue to work but are deprecated. Please migrate to the new grouped command structure for optimal performance and features.**
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Key Advantages of Grouped Commands
|
||||
|
||||
### 🚀 **Batch Processing**
|
||||
- Send multiple settings in a single command
|
||||
- Reduce network overhead and latency
|
||||
- Atomic operations ensure consistency
|
||||
|
||||
### 📊 **Better Organization**
|
||||
- Logical grouping of related commands
|
||||
- Cleaner API structure
|
||||
- Easier to understand and maintain
|
||||
|
||||
### ⚡ **Enhanced Performance**
|
||||
- Fewer round-trips for complex configurations
|
||||
- Optimized ESP32 processing
|
||||
- Improved user experience
|
||||
|
||||
### 🔄 **Backward Compatibility**
|
||||
- Legacy commands still supported
|
||||
- Gradual migration path
|
||||
- No breaking changes for existing implementations
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Integration Examples
|
||||
|
||||
### Dart/Flutter App Integration
|
||||
|
||||
```dart
|
||||
// New grouped command approach
|
||||
await ClockSetup.batchClockSetup(
|
||||
c1Output: 1,
|
||||
c2Output: 2,
|
||||
pulseDuration: 5000,
|
||||
pauseDuration: 2000,
|
||||
alertType: 'HOURS',
|
||||
hourBell: 1,
|
||||
backlightEnabled: true,
|
||||
backlightOutput: 5,
|
||||
);
|
||||
|
||||
// Batch relay setup
|
||||
await RelaySetup.setBatchRelayOutputs({
|
||||
1: 1, 2: 2, 3: 3, 4: 4, 5: 5, 6: 6,
|
||||
});
|
||||
|
||||
// Individual settings still work
|
||||
await ClockSetup.setOddClockOutput(1);
|
||||
await ClockSetup.setEvenClockOutput(2);
|
||||
```
|
||||
|
||||
### JavaScript/WebSocket Integration
|
||||
|
||||
```javascript
|
||||
// Batch clock configuration
|
||||
const clockConfig = {
|
||||
cmd: "clock_setup",
|
||||
contents: {
|
||||
action: "batch_setup",
|
||||
outputs: { c1: 1, c2: 2 },
|
||||
timings: { pulseDuration: 5000, pauseDuration: 2000 },
|
||||
alerts: { alertType: "HOURS", hourBell: 1 },
|
||||
backlight: { enabled: true, output: 5 }
|
||||
}
|
||||
};
|
||||
|
||||
webSocket.send(JSON.stringify(clockConfig));
|
||||
|
||||
// Batch relay configuration
|
||||
const relayConfig = {
|
||||
cmd: "relay_setup",
|
||||
contents: {
|
||||
action: "set_outputs",
|
||||
outputs: {
|
||||
b1: 1, b2: 2, b3: 3, b4: 4, b5: 5, b6: 6
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
webSocket.send(JSON.stringify(relayConfig));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚨 Error Handling
|
||||
|
||||
### Common Error Types
|
||||
|
||||
**Missing Action Parameter:**
|
||||
```json
|
||||
{
|
||||
"status": "ERROR",
|
||||
"type": "relay_setup",
|
||||
"payload": "Missing action parameter"
|
||||
}
|
||||
```
|
||||
|
||||
**Unknown Action:**
|
||||
```json
|
||||
{
|
||||
"status": "ERROR",
|
||||
"type": "clock_setup",
|
||||
"payload": "Unknown action: invalid_action"
|
||||
}
|
||||
```
|
||||
|
||||
**Batch Processing Errors:**
|
||||
```json
|
||||
{
|
||||
"status": "ERROR",
|
||||
"type": "relay_setup",
|
||||
"payload": "No valid relay timers found in batch"
|
||||
}
|
||||
```
|
||||
|
||||
**Success with Count:**
|
||||
```json
|
||||
{
|
||||
"status": "SUCCESS",
|
||||
"type": "relay_setup",
|
||||
"payload": "Batch relay outputs updated: 6 bells"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📡 Message Routing
|
||||
|
||||
### Response Routing Rules
|
||||
|
||||
1. **Command Responses**: Sent only to the originating client/protocol
|
||||
- MQTT command → MQTT response
|
||||
- WebSocket client #3 → WebSocket client #3 only
|
||||
|
||||
2. **Status Broadcasts**: Sent to ALL connected clients
|
||||
- All WebSocket clients receive the message
|
||||
- MQTT subscribers receive the message
|
||||
- Used for system notifications and status updates
|
||||
|
||||
3. **Targeted Messages**: Based on device type
|
||||
- `broadcastToMasterClients()`: Only master devices
|
||||
- `broadcastToSecondaryClients()`: Only secondary devices
|
||||
- `broadcastToAllWebSocketClients()`: All WebSocket clients
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Performance Optimizations
|
||||
|
||||
### Batch Command Benefits
|
||||
|
||||
**Before (Legacy - 6 separate commands):**
|
||||
```javascript
|
||||
// 6 separate network calls
|
||||
await setRelayOutput(1, 1);
|
||||
await setRelayOutput(2, 2);
|
||||
await setRelayOutput(3, 3);
|
||||
await setRelayOutput(4, 4);
|
||||
await setRelayOutput(5, 5);
|
||||
await setRelayOutput(6, 6);
|
||||
```
|
||||
|
||||
**After (Grouped - 1 batch command):**
|
||||
```javascript
|
||||
// 1 network call for all settings
|
||||
await setBatchRelayOutputs({
|
||||
1: 1, 2: 2, 3: 3, 4: 4, 5: 5, 6: 6
|
||||
});
|
||||
```
|
||||
|
||||
### Performance Metrics
|
||||
- **Network Calls**: Reduced by up to 85%
|
||||
- **Configuration Time**: 3-5x faster
|
||||
- **ESP32 Processing**: More efficient batch updates
|
||||
- **Error Handling**: Atomic operations ensure consistency
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Quick Reference
|
||||
|
||||
### Command Groups
|
||||
| Group | Purpose | Batch Support |
|
||||
|-------|---------|---------------|
|
||||
| `system` | Device management, ping, status | No |
|
||||
| `playback` | Music playback control | No |
|
||||
| `file_manager` | Melody file operations | No |
|
||||
| `relay_setup` | Bell configuration | ✅ Yes |
|
||||
| `clock_setup` | Clock mechanism setup | ✅ Yes |
|
||||
|
||||
### Actions by Group
|
||||
|
||||
**System:** `ping`, `status`, `identify`, `restart`, `force_update`, `custom_update`
|
||||
|
||||
**Playback:** `play`, `stop`
|
||||
|
||||
**File Manager:** `list_melodies`, `download_melody`, `delete_melody`
|
||||
|
||||
**Relay Setup:** `set_timers`, `set_outputs`
|
||||
|
||||
**Clock Setup:** `set_outputs`, `set_timings`, `set_alerts`, `set_backlight`, `set_silence`, `batch_setup`
|
||||
|
||||
---
|
||||
|
||||
*Happy Bell Automation with Grouped Commands! 🔔*
|
||||
11
vesper/documentation/BuildProcedure.md
Normal file
11
vesper/documentation/BuildProcedure.md
Normal file
@@ -0,0 +1,11 @@
|
||||
Device Setup Process:
|
||||
|
||||
1. Build device with peripherals.
|
||||
2. Flash Base Firmware
|
||||
3. Set Device Credentials (UID/HWID/Rev) via WebServer on device
|
||||
4. Add Device to BellCloud
|
||||
5. Add Device Credentials to Mosquitto
|
||||
6. Reboot Device to Pull Stable Production Firmware
|
||||
7. Sell the device.
|
||||
- User will bind it to their account
|
||||
- Factory can install App and bind user for convenience
|
||||
0
vesper/documentation/Roadmap.md
Normal file
0
vesper/documentation/Roadmap.md
Normal file
43
vesper/documentation/SentDevices
Normal file
43
vesper/documentation/SentDevices
Normal file
@@ -0,0 +1,43 @@
|
||||
# # # # # # # # # # # # # # # # # # # # # #
|
||||
STAMNA:
|
||||
# # # # # # # # # # # # # # # # # # # # # #
|
||||
|
||||
PV25L22BP01R01
|
||||
Bell Plus
|
||||
HW: 1.0
|
||||
|
||||
u6545309759@gmail.com
|
||||
bellsystems2025
|
||||
aCx!97IEfTiA073^#*Jj
|
||||
|
||||
|
||||
|
||||
# # # # # # # # # # # # # # # # # # # # # #
|
||||
XRISTIANIKH_ELPIS:
|
||||
# # # # # # # # # # # # # # # # # # # # # #
|
||||
|
||||
PV26B02BP01R01
|
||||
Bell Plus
|
||||
HW: 1.0
|
||||
|
||||
mail: christianikielpis@gmail.com
|
||||
pass: bellsystems2025
|
||||
|
||||
|
||||
|
||||
# # # # # # # # # # # # # # # # # # # # # #
|
||||
GREVENA:
|
||||
# # # # # # # # # # # # # # # # # # # # # #
|
||||
|
||||
PA26B06AM01R01
|
||||
Agnus Mini
|
||||
HW: 1.0
|
||||
|
||||
|
||||
# # # # # # # # # # # # # # # # # # # # # #
|
||||
LARISA:
|
||||
# # # # # # # # # # # # # # # # # # # # # #
|
||||
|
||||
PA26B06AM01R02
|
||||
Agnus Mini
|
||||
HW: 1.0
|
||||
42
vesper/documentation/common_commands.txt
Normal file
42
vesper/documentation/common_commands.txt
Normal file
@@ -0,0 +1,42 @@
|
||||
COMMANDS:
|
||||
|
||||
|
||||
PV26A28BC01R01
|
||||
|
||||
|
||||
{
|
||||
"cmd":"system",
|
||||
"contents":
|
||||
{
|
||||
"action":"force_update",
|
||||
"channel":"beta"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
{
|
||||
"cmd":"system",
|
||||
"contents":
|
||||
{
|
||||
"action":"set_mqtt_log_level",
|
||||
"level":3
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
{
|
||||
"cmd":"system",
|
||||
"contents":
|
||||
{
|
||||
"action":"restart"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
{
|
||||
"cmd":"system_info",
|
||||
"contents":
|
||||
{
|
||||
"action":"get_full_settings"
|
||||
}
|
||||
}
|
||||
79
vesper/documentation/features.info
Normal file
79
vesper/documentation/features.info
Normal file
@@ -0,0 +1,79 @@
|
||||
Features:
|
||||
|
||||
// Board Naming Schema:
|
||||
|
||||
eg. PV25K07BC01R01
|
||||
|
||||
PV 25 K 07 BC 01 R 01
|
||||
PV [Y] [M] [D] [BT] [RV] R [BC]
|
||||
|
||||
// SERBIA_OLD: PV25K07BC01R01
|
||||
// SERBIA_NEW: PV26A28BC01R01
|
||||
|
||||
// XRISTIANIKH_ELPIS: PV26B02BP01R01
|
||||
|
||||
|
||||
PV25L22BP01R01
|
||||
|
||||
|
||||
Y: (Year) 2 Digit Year. eg 25 for 2025
|
||||
M: (Month) 1 Letter as Coded Month. eg B for February
|
||||
D: (Day) 2 Digit Date. eg 17 for 17th of the Month
|
||||
BT: (Board Type) 2 letter/digit board Type (custom) eg BC for BellCore
|
||||
RV: (Revision) 2 letter/digit board revision code
|
||||
R: Now, just an R for "Revision" but can change later
|
||||
BC: (Batch Code) 2 digit SerialNumber starting from 01
|
||||
|
||||
|
||||
// mqtt topics:
|
||||
|
||||
vesper/<DEVID>/data // Data sent from the controller
|
||||
vesper/<DEVID>/control // Commands sent to the controller
|
||||
vesper/<DEVID>/kiosk/event // Kiosk Mode Events
|
||||
vesper/<DEVID>/kiosk/info // Kiosk Mode General Info
|
||||
|
||||
|
||||
|
||||
- WiFi Manager (captive portal with hotspot)
|
||||
- MQTT Support (Subscribing and Publishing)
|
||||
- WebSocket Support (Sending and Receiving)
|
||||
- JSON Format Messaging (both MQTT and WS)
|
||||
- SD Card Handling and File Ops
|
||||
- Stand-alone Player/BellEngine Classes, with functions to Play/Pause/Stop etc
|
||||
- NoteAssignments - Effectively mapping Notes to Bells
|
||||
- Independent SubSystems for all Core Functions (Networking/Comms/Scheduling/Logging/etc)
|
||||
- Custom Relay Output Maps and Timings (saved on SD)
|
||||
- Timekeeper with RTC/Clock/Alerts/Scheduling features
|
||||
- OTA Update Functionality with Versioning/Rollbacks/Checksum/Firmware Validation/NTP Sync
|
||||
- Global logger with Mode Selection (None, Error, Warning, Info, Debug, Verbose)
|
||||
- UDP Listener for Auto Device Discovery
|
||||
- Datalogging and Statistics:
|
||||
- Counter for each bell (counts total times the bell ringed)
|
||||
- Counter per bell, beats/minute for reliability and thermal protection. Warranty Void scenario.
|
||||
- Ability to change Log levels (in-app)
|
||||
|
||||
|
||||
|
||||
ToDo Features:
|
||||
|
||||
- (optional) Add Bluetooth support
|
||||
- (optional) Add WiFi Direct AP Support
|
||||
- (optional) Add PCB Temperature Sensor Support
|
||||
|
||||
- (critical) Counters and Statistics:
|
||||
- Counter per playback, to figure out which melody is the most played.
|
||||
This can be implemented on the App itself. Doesn't need to be on the Device.
|
||||
|
||||
- Create a "humanizer" mode that randomizes delays on playback to simulate human ringing.
|
||||
|
||||
|
||||
ToDo Fixes:
|
||||
|
||||
- (small significance) Fix each Log's level Correctly + Fix Log Syntax where needed
|
||||
- (medium significance) BellGuard: Make the buttons functional.
|
||||
|
||||
- Fix IP Settings not applying. More Specifically, Variables inside the Components take long to update. Either Ditch the components, or find another way.
|
||||
- On Very fast playback speeds and small programs that will run for less than a second or so, STOP isn't sent properly. Player keeps indicating "playing".
|
||||
- When a new user is created, set default PINs for both Quick Settings, and Settings.
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ BellEngine::~BellEngine() {
|
||||
*
|
||||
*/
|
||||
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
|
||||
// This ensures maximum performance and timing precision
|
||||
@@ -88,7 +88,7 @@ void BellEngine::begin() {
|
||||
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
|
||||
}
|
||||
|
||||
LOG_INFO("🚀 BellEngine IGNITION - Starting precision playback");
|
||||
LOG_INFO("🚀 BellEngine Ignition - Starting precision playback");
|
||||
_emergencyStop.store(false); // ✅ Clear any emergency stop state
|
||||
_engineRunning.store(true); // ✅ Activate the engine atomically
|
||||
}
|
||||
|
||||
void BellEngine::stop() {
|
||||
LOG_INFO("BellEngine stopping gracefully");
|
||||
LOG_INFO("BellEngine - Stopping Gracefully");
|
||||
_engineRunning.store(false);
|
||||
}
|
||||
|
||||
void BellEngine::emergencyStop() {
|
||||
LOG_INFO("🛑 EMERGENCY STOP ACTIVATED");
|
||||
LOG_INFO("BellEngine - 🛑 Forcing Stop Immediately");
|
||||
_emergencyStop.store(true);
|
||||
_engineRunning.store(false);
|
||||
emergencyShutdown();
|
||||
@@ -142,7 +142,7 @@ void BellEngine::setMelodyData(const std::vector<uint16_t>& melodySteps) {
|
||||
_melodySteps = melodySteps;
|
||||
_melodyDataReady.store(true);
|
||||
portEXIT_CRITICAL(&_melodyMutex);
|
||||
LOG_DEBUG("BellEngine loaded melody: %d steps", melodySteps.size());
|
||||
LOG_DEBUG("BellEngine - Loaded melody: %d steps", melodySteps.size());
|
||||
}
|
||||
|
||||
void BellEngine::clearMelodyData() {
|
||||
@@ -150,7 +150,7 @@ void BellEngine::clearMelodyData() {
|
||||
_melodySteps.clear();
|
||||
_melodyDataReady.store(false);
|
||||
portEXIT_CRITICAL(&_melodyMutex);
|
||||
LOG_DEBUG("BellEngine melody data cleared");
|
||||
LOG_DEBUG("BellEngine - Melody data cleared");
|
||||
}
|
||||
|
||||
// ================== CRITICAL TIMING SECTION ==================
|
||||
@@ -158,7 +158,7 @@ void BellEngine::clearMelodyData() {
|
||||
|
||||
void BellEngine::engineTask(void* 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) {
|
||||
if (engine->_engineRunning.load() && !engine->_emergencyStop.load()) {
|
||||
@@ -186,7 +186,7 @@ void BellEngine::engineLoop() {
|
||||
|
||||
// Pause handling AFTER complete loop - never interrupt mid-melody!
|
||||
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
|
||||
}
|
||||
|
||||
@@ -207,32 +207,44 @@ void BellEngine::playbackLoop() {
|
||||
portEXIT_CRITICAL(&_melodyMutex);
|
||||
|
||||
if (melodySteps.empty()) {
|
||||
LOG_ERROR("Empty melody in playback loop!");
|
||||
LOG_ERROR("BellEngine - ❌ Empty melody in playback loop!");
|
||||
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
|
||||
for (uint16_t note : melodySteps) {
|
||||
// Emergency exit check (only emergency stops can interrupt mid-loop)
|
||||
if (_emergencyStop.load() || _player.hardStop) {
|
||||
LOG_DEBUG("Emergency exit from playback loop");
|
||||
LOG_DEBUG("BellEngine - Emergency exit from playback loop");
|
||||
return;
|
||||
}
|
||||
|
||||
// Activate note with MAXIMUM PRECISION
|
||||
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
|
||||
preciseDelay(tempoMicros);
|
||||
}
|
||||
|
||||
// Mark segment completion and notify Player
|
||||
_player.segmentCmpltTime = millis();
|
||||
_player.onMelodyLoopCompleted(); // 🔥 Notify Player that melody actually finished!
|
||||
LOG_DEBUG("🎵 Melody loop completed with PRECISION");
|
||||
_player.onMelodyLoopCompleted(); // 🔥 Notify Player that melody actually finished!
|
||||
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
|
||||
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;
|
||||
}
|
||||
|
||||
// Bounds check (CRITICAL SAFETY)
|
||||
if (bellIndex >= 16) {
|
||||
LOG_ERROR("🚨 BOUNDS ERROR: bellIndex %d >= 16", bellIndex);
|
||||
LOG_ERROR("BellEngine - 🚨 BOUNDS ERROR: bellIndex %d >= 16", bellIndex);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for duplicate bell firing in this note
|
||||
if (bellFired[bellIndex]) {
|
||||
LOG_DEBUG("⚠️ DUPLICATE BELL: Skipping duplicate firing of bell %d for note %d", bellIndex, noteIndex);
|
||||
LOG_DEBUG("BellEngine - ⚠️ DUPLICATE BELL: Skipping duplicate firing of bell %d for note %d", bellIndex, noteIndex);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if bell is configured (OutputManager will validate this)
|
||||
uint8_t physicalOutput = _outputManager.getPhysicalOutput(bellIndex);
|
||||
if (physicalOutput == 255) {
|
||||
LOG_DEBUG("⚠️ UNCONFIGURED: Bell %d not configured, skipping", bellIndex);
|
||||
LOG_DEBUG("BellEngine - ⚠️ UNCONFIGURED: Bell %d not configured, skipping", bellIndex);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -286,7 +298,7 @@ void BellEngine::activateNote(uint16_t note) {
|
||||
uint16_t durationMs = _configManager.getBellDuration(bellIndex);
|
||||
|
||||
// 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)
|
||||
firedBellIndices.push_back(bellIndex + 1);
|
||||
@@ -294,17 +306,18 @@ void BellEngine::activateNote(uint16_t note) {
|
||||
// Record telemetry
|
||||
_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!
|
||||
if (!bellDurations.empty()) {
|
||||
_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!
|
||||
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() {
|
||||
LOG_INFO("🚨 EMERGENCY SHUTDOWN - Using OutputManager");
|
||||
LOG_INFO("BellEngine - 🚨 Emergency Shutdown - Notifying OutputManager");
|
||||
_outputManager.emergencyShutdown();
|
||||
}
|
||||
|
||||
@@ -351,10 +364,10 @@ void BellEngine::notifyBellsFired(const std::vector<uint8_t>& bellIndices) {
|
||||
// Send notification to WebSocket clients only (not MQTT)
|
||||
_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 (...) {
|
||||
LOG_ERROR("Failed to send ding notification");
|
||||
LOG_WARNING("BellEngine - ❌ Failed to send ding notification");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
579
vesper/src/BuiltInMelodies/BuiltInMelodies.hpp
Normal file
579
vesper/src/BuiltInMelodies/BuiltInMelodies.hpp
Normal file
@@ -0,0 +1,579 @@
|
||||
/*
|
||||
* ═══════════════════════════════════════════════════════════════════════════════════
|
||||
* 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
|
||||
};
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════════
|
||||
// BuiltIn Melodies // More can be added here
|
||||
// ═════════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// 1 Bell Test Melody
|
||||
const uint16_t PROGMEM builtin_1bell_test[] = {
|
||||
0x0001, 0x0000, 0x0001, 0x0000
|
||||
};
|
||||
|
||||
// Doxology Traditional
|
||||
const uint16_t PROGMEM builtin_doxology_traditional[] = {
|
||||
0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0004, 0x0000, 0x0000,
|
||||
0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0008, 0x0000, 0x0000,
|
||||
0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0002,
|
||||
0x0001, 0x0002, 0x0001, 0x0004, 0x0001, 0x0008, 0x0000, 0x0000
|
||||
};
|
||||
|
||||
// Doxology Alternative
|
||||
const uint16_t PROGMEM builtin_doxology_alternative[] = {
|
||||
0x0001, 0x0000, 0x0002, 0x0004, 0x0000, 0x0018, 0x0000, 0x0001,
|
||||
0x0000, 0x0002, 0x0004, 0x0000, 0x0018, 0x0000, 0x0001, 0x0000,
|
||||
0x0002, 0x0004, 0x0000, 0x0018, 0x0000, 0x0001, 0x0002, 0x0001,
|
||||
0x0002, 0x0004, 0x0000, 0x0018, 0x0000
|
||||
};
|
||||
|
||||
// Doxology Festive
|
||||
const uint16_t PROGMEM builtin_doxology_festive[] = {
|
||||
0x0002, 0x0004, 0x0009, 0x0004, 0x0002, 0x0004, 0x0011, 0x0004,
|
||||
0x0002, 0x0004, 0x0021, 0x0004, 0x0002, 0x0004, 0x0011, 0x0004
|
||||
};
|
||||
|
||||
// Vesper Traditional
|
||||
const uint16_t PROGMEM builtin_vesper_traditional[] = {
|
||||
0x0001, 0x0002, 0x0004, 0x0000, 0x0001, 0x0002, 0x0004, 0x0000,
|
||||
0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0004, 0x0000
|
||||
};
|
||||
|
||||
// Vesper Alternative
|
||||
const uint16_t PROGMEM builtin_vesper_alternative[] = {
|
||||
0x0001, 0x0002, 0x0000, 0x0000, 0x0001, 0x0002, 0x0000, 0x0000,
|
||||
0x0001, 0x0004, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000,
|
||||
0x0001, 0x0002, 0x0000, 0x0000, 0x0001, 0x0002, 0x0000, 0x0000,
|
||||
0x0001, 0x0008, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000,
|
||||
0x0001, 0x0002, 0x0000, 0x0000, 0x0001, 0x0002, 0x0000, 0x0000,
|
||||
0x0001, 0x0002, 0x0000, 0x0000, 0x0001, 0x0002, 0x0000, 0x0000,
|
||||
0x0001, 0x0002, 0x0000, 0x0000, 0x0001, 0x0004, 0x0000, 0x0000,
|
||||
0x0001, 0x0008, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000
|
||||
};
|
||||
|
||||
// Catehetical
|
||||
const uint16_t PROGMEM builtin_catehetical[] = {
|
||||
0x0001, 0x0002, 0x0004, 0x0008, 0x0010
|
||||
};
|
||||
|
||||
// Orthros Traditional
|
||||
const uint16_t PROGMEM builtin_orthros_traditional[] = {
|
||||
0x0001, 0x0000, 0x0002, 0x0000, 0x0004, 0x0008, 0x0000, 0x0010,
|
||||
0x0000, 0x0020, 0x0000, 0x0040, 0x0080, 0x0000
|
||||
};
|
||||
|
||||
// Orthros Alternative
|
||||
const uint16_t PROGMEM builtin_orthros_alternative[] = {
|
||||
0x0001, 0x0000, 0x0002, 0x0001, 0x0000, 0x0002, 0x0000, 0x0001,
|
||||
0x0000, 0x0001, 0x0002, 0x0001, 0x0000, 0x0004, 0x0000
|
||||
};
|
||||
|
||||
// Mournfull Toll
|
||||
const uint16_t PROGMEM builtin_mournfull_toll[] = {
|
||||
0x0001, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0001,
|
||||
0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0004, 0x0000,
|
||||
0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0004, 0x0000, 0x0000,
|
||||
0x0000, 0x0000, 0x0000, 0x0000, 0x0002, 0x0000, 0x0000, 0x0000,
|
||||
0x0000, 0x0000, 0x0000, 0x0002, 0x0000, 0x0000, 0x0000, 0x0000,
|
||||
0x0000, 0x0000, 0x0008, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000,
|
||||
0x0000, 0x0008, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000
|
||||
};
|
||||
|
||||
// Mournfull Toll Alternative
|
||||
const uint16_t PROGMEM builtin_mournfull_toll_alternative[] = {
|
||||
0x0001, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0001,
|
||||
0x0001, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0004,
|
||||
0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0004, 0x0004,
|
||||
0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0002, 0x0000,
|
||||
0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0002, 0x0002, 0x0000,
|
||||
0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0008, 0x0000, 0x0000,
|
||||
0x0000, 0x0000, 0x0000, 0x0000, 0x0008, 0x0008, 0x0000, 0x0000,
|
||||
0x0000, 0x0000, 0x0000, 0x0000
|
||||
};
|
||||
|
||||
// Mournfull Toll Meg Par
|
||||
const uint16_t PROGMEM builtin_mournfull_toll_meg_par[] = {
|
||||
0x0001, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0001,
|
||||
0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0001, 0x0001,
|
||||
0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0004, 0x0000,
|
||||
0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0004, 0x0000, 0x0000,
|
||||
0x0000, 0x0000, 0x0000, 0x0000, 0x0004, 0x0004, 0x0000, 0x0000,
|
||||
0x0000, 0x0000, 0x0000, 0x0000, 0x0002, 0x0000, 0x0000, 0x0000,
|
||||
0x0000, 0x0000, 0x0000, 0x0002, 0x0000, 0x0000, 0x0000, 0x0000,
|
||||
0x0000, 0x0000, 0x0002, 0x0002, 0x0000, 0x0000, 0x0000, 0x0000,
|
||||
0x0000, 0x0000, 0x0008, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000,
|
||||
0x0000, 0x0008, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000,
|
||||
0x0008, 0x0008, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000
|
||||
};
|
||||
|
||||
// Sematron
|
||||
const uint16_t PROGMEM builtin_sematron[] = {
|
||||
0x0001, 0x0001, 0x0001, 0x0002, 0x0001, 0x0001, 0x0001, 0x0008,
|
||||
0x0001, 0x0001, 0x0001, 0x0002, 0x0001, 0x0004, 0x0001, 0x0008
|
||||
};
|
||||
|
||||
// Sematron Alternative
|
||||
const uint16_t PROGMEM builtin_sematron_alternative[] = {
|
||||
0x0001, 0x0001, 0x0001, 0x0002, 0x0001, 0x0001, 0x0001, 0x0008,
|
||||
0x0001, 0x0002, 0x0001, 0x0004, 0x0001, 0x0001, 0x0001, 0x0008
|
||||
};
|
||||
|
||||
// Athonite 1 2 Voices
|
||||
const uint16_t PROGMEM builtin_athonite_1_2_voices[] = {
|
||||
0x0001, 0x0002, 0x0001, 0x0001, 0x0002, 0x0001, 0x0001, 0x0002,
|
||||
0x0001, 0x0001, 0x0002, 0x0001, 0x0002
|
||||
};
|
||||
|
||||
// Athonite 3 Voices
|
||||
const uint16_t PROGMEM builtin_athonite_3_voices[] = {
|
||||
0x0002, 0x0001, 0x0000, 0x0000, 0x0002, 0x0001, 0x0000, 0x0000,
|
||||
0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0000, 0x0001,
|
||||
0x0002, 0x0001, 0x0000, 0x0001, 0x0002, 0x0001, 0x0000, 0x0001,
|
||||
0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0000, 0x0001,
|
||||
0x0002, 0x0001, 0x0000, 0x0001, 0x0002, 0x0001, 0x0000, 0x0001,
|
||||
0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0004, 0x0001,
|
||||
0x0002, 0x0001, 0x0004, 0x0001, 0x0002, 0x0001, 0x0004, 0x0001,
|
||||
0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0004, 0x0001,
|
||||
0x0002, 0x0001, 0x0004, 0x0001, 0x0002, 0x0001, 0x0004, 0x0001,
|
||||
0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0004
|
||||
};
|
||||
|
||||
// Athonite 3 4 Voices
|
||||
const uint16_t PROGMEM builtin_athonite_3_4_voices[] = {
|
||||
0x0002, 0x0001, 0x0000, 0x0000, 0x0002, 0x0001, 0x0000, 0x0000,
|
||||
0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0000, 0x0001,
|
||||
0x0002, 0x0001, 0x0000, 0x0001, 0x0002, 0x0001, 0x0000, 0x0001,
|
||||
0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0000, 0x0001,
|
||||
0x0002, 0x0001, 0x0000, 0x0001, 0x0002, 0x0001, 0x0000, 0x0001,
|
||||
0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0004, 0x0001,
|
||||
0x0002, 0x0001, 0x0000, 0x0005, 0x0002, 0x0001, 0x0000, 0x0005,
|
||||
0x0002, 0x0001, 0x0000, 0x0005, 0x0002, 0x0001, 0x0002, 0x0005,
|
||||
0x0002, 0x0001, 0x0008, 0x0005, 0x0002, 0x0001, 0x0000, 0x0005,
|
||||
0x0002, 0x0001, 0x0000, 0x0005, 0x0002, 0x0001, 0x0002, 0x0005,
|
||||
0x0002, 0x0001, 0x0009, 0x0002, 0x0001, 0x0005, 0x0002, 0x0001,
|
||||
0x000A, 0x0002, 0x0001, 0x0006, 0x0002, 0x0001, 0x0009, 0x0002,
|
||||
0x0001, 0x0005, 0x0002, 0x0001, 0x000A, 0x0002, 0x0001, 0x0006,
|
||||
0x0002, 0x0001, 0x0009
|
||||
};
|
||||
|
||||
// Athonite 4 8 Voices
|
||||
const uint16_t PROGMEM builtin_athonite_4_8_voices[] = {
|
||||
0x0002, 0x0001, 0x0000, 0x0000, 0x0002, 0x0001, 0x0000, 0x0000,
|
||||
0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0000, 0x0001,
|
||||
0x0002, 0x0001, 0x0000, 0x0001, 0x0002, 0x0001, 0x0000, 0x0001,
|
||||
0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0000, 0x0001,
|
||||
0x0002, 0x0001, 0x0000, 0x0001, 0x0002, 0x0001, 0x0000, 0x0001,
|
||||
0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0004, 0x0001,
|
||||
0x0002, 0x0001, 0x0000, 0x0005, 0x0002, 0x0001, 0x0000, 0x0005,
|
||||
0x0002, 0x0001, 0x0000, 0x0005, 0x0002, 0x0001, 0x0002, 0x0005,
|
||||
0x0002, 0x0001, 0x0008, 0x0005, 0x0002, 0x0001, 0x0000, 0x0005,
|
||||
0x0002, 0x0001, 0x0000, 0x0005, 0x0002, 0x0001, 0x0002, 0x0005,
|
||||
0x0002, 0x0001, 0x0009, 0x0002, 0x0001, 0x0011, 0x0002, 0x0001,
|
||||
0x0022, 0x0002, 0x0001, 0x0081, 0x0002, 0x0001, 0x000A, 0x0002,
|
||||
0x0001, 0x0041, 0x0002, 0x0001, 0x0012, 0x0002, 0x0001, 0x0021,
|
||||
0x0002, 0x0001, 0x0082, 0x0002, 0x0001, 0x0009, 0x0002, 0x0001,
|
||||
0x0042, 0x0002, 0x0001, 0x0011, 0x0002, 0x0001, 0x0022, 0x0002,
|
||||
0x0001, 0x0081, 0x0002, 0x0001, 0x000A, 0x0002, 0x0001, 0x0041,
|
||||
0x0002, 0x0001, 0x0000, 0x0005, 0x0002, 0x0001, 0x0000, 0x0005,
|
||||
0x0002, 0x0001, 0x0002, 0x0005, 0x0002, 0x0001, 0x0000, 0x0000,
|
||||
0x0000
|
||||
};
|
||||
|
||||
// Onebyone 2 3 Voices
|
||||
const uint16_t PROGMEM builtin_onebyone_2_3_voices[] = {
|
||||
0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000,
|
||||
0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000,
|
||||
0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0002,
|
||||
0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0002,
|
||||
0x0001, 0x0002, 0x0001, 0x0004, 0x0001, 0x0002, 0x0001, 0x0004,
|
||||
0x0001, 0x0002, 0x0001, 0x0004, 0x0001, 0x0002, 0x0001, 0x0004,
|
||||
0x0001, 0x0002, 0x0001, 0x0004, 0x0001, 0x0002, 0x0001, 0x0004,
|
||||
0x0001, 0x0002, 0x0001, 0x0004, 0x0001, 0x0002, 0x0001, 0x0004,
|
||||
0x0001, 0x0002, 0x0001, 0x0004, 0x0001, 0x0002, 0x0001, 0x0004,
|
||||
0x0001, 0x0002, 0x0001, 0x0004, 0x0001, 0x0002, 0x0001, 0x0004,
|
||||
0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0002,
|
||||
0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0002
|
||||
};
|
||||
|
||||
// Onebyone 4 8 Voices
|
||||
const uint16_t PROGMEM builtin_onebyone_4_8_voices[] = {
|
||||
0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000,
|
||||
0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000,
|
||||
0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0002,
|
||||
0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0002,
|
||||
0x0001, 0x0002, 0x0001, 0x0004, 0x0001, 0x0002, 0x0001, 0x0004,
|
||||
0x0001, 0x0002, 0x0001, 0x0004, 0x0001, 0x0002, 0x0001, 0x0004,
|
||||
0x0002, 0x0004, 0x0008, 0x0004, 0x0002, 0x0011, 0x0002, 0x0004,
|
||||
0x0008, 0x0004, 0x0002, 0x0021, 0x0002, 0x0004, 0x0008, 0x0004,
|
||||
0x0002, 0x0041, 0x0002, 0x0004, 0x0008, 0x0004, 0x0002, 0x0081,
|
||||
0x0002, 0x0004, 0x0008, 0x0004, 0x0002, 0x0041, 0x0002, 0x0004,
|
||||
0x0008, 0x0004, 0x0002, 0x0021, 0x0002, 0x0004, 0x0008, 0x0004,
|
||||
0x0002, 0x0041, 0x0002, 0x0004, 0x0008, 0x0004, 0x0002, 0x0081,
|
||||
0x0002, 0x0004, 0x0008, 0x0004, 0x0002, 0x0041, 0x0002, 0x0004,
|
||||
0x0008, 0x0004, 0x0002, 0x0021, 0x0002, 0x0004, 0x0008, 0x0004,
|
||||
0x0002, 0x0001, 0x0002, 0x0001, 0x0004, 0x0001, 0x0002, 0x0001,
|
||||
0x0004, 0x0001, 0x0002, 0x0001, 0x0004, 0x0001, 0x0002, 0x0001,
|
||||
0x0004, 0x0001, 0x0002, 0x0001, 0x0004, 0x0001, 0x0002, 0x0001,
|
||||
0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0001,
|
||||
0x0002, 0x0001, 0x0000
|
||||
};
|
||||
|
||||
// Festive 1Voice
|
||||
const uint16_t PROGMEM builtin_festive_1voice[] = {
|
||||
0x0001, 0x0001, 0x0001, 0x0000, 0x0001, 0x0001, 0x0001, 0x0001,
|
||||
0x0000, 0x0001, 0x0000, 0x0001, 0x0001, 0x0001, 0x0000, 0x0001,
|
||||
0x0001, 0x0001, 0x0001, 0x0001, 0x0001, 0x0000, 0x0001, 0x0000
|
||||
};
|
||||
|
||||
// Festive 4Voices
|
||||
const uint16_t PROGMEM builtin_festive_4voices[] = {
|
||||
0x0001, 0x0002, 0x0004, 0x0009, 0x0002, 0x0001, 0x0004, 0x0009
|
||||
};
|
||||
|
||||
// Festive 5Voices
|
||||
const uint16_t PROGMEM builtin_festive_5voices[] = {
|
||||
0x0001, 0x0002, 0x0004, 0x0002, 0x0001, 0x0002, 0x0001, 0x0002,
|
||||
0x0008, 0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0004, 0x0002,
|
||||
0x0001, 0x0002, 0x0001, 0x0002, 0x0010, 0x0002, 0x0001, 0x0002
|
||||
};
|
||||
|
||||
// Festive 5Voice Alternative
|
||||
const uint16_t PROGMEM builtin_festive_5voice_alternative[] = {
|
||||
0x0004, 0x0002, 0x0008, 0x0001, 0x0004, 0x0004, 0x0002, 0x0008,
|
||||
0x0001, 0x0010, 0x0004, 0x0002, 0x0008, 0x0001, 0x0004, 0x0004,
|
||||
0x0002, 0x0008, 0x0001, 0x0011, 0x0004, 0x0002, 0x0008, 0x0001,
|
||||
0x0004, 0x0004, 0x0002, 0x0008, 0x0001, 0x0011, 0x0004, 0x0002,
|
||||
0x0008, 0x0001, 0x0005, 0x0004, 0x0002, 0x0008, 0x0001, 0x0011,
|
||||
0x0004, 0x0002, 0x0008, 0x0001, 0x0005, 0x0004, 0x0002, 0x0008,
|
||||
0x0001, 0x0011, 0x0004, 0x0002, 0x0008, 0x0001, 0x0004, 0x0004,
|
||||
0x0002, 0x0008, 0x0001, 0x0010, 0x0004, 0x0002, 0x0008, 0x0001,
|
||||
0x0004, 0x0004, 0x0002, 0x0008, 0x0001, 0x0010
|
||||
};
|
||||
|
||||
// Festive 6Voices
|
||||
const uint16_t PROGMEM builtin_festive_6voices[] = {
|
||||
0x0001, 0x0002, 0x0004, 0x0002, 0x0001, 0x0002, 0x0001, 0x0002,
|
||||
0x0008, 0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0004, 0x0002,
|
||||
0x0001, 0x0002, 0x0001, 0x0002, 0x0010, 0x0002, 0x0001, 0x0002,
|
||||
0x0001, 0x0002, 0x0009, 0x0002, 0x0001, 0x0002, 0x0011, 0x0002,
|
||||
0x0001, 0x0002, 0x0005, 0x0002, 0x0001, 0x0002, 0x0021, 0x0002,
|
||||
0x0001, 0x0002, 0x0009, 0x0002, 0x0001, 0x0002, 0x0011, 0x0002,
|
||||
0x0001, 0x0002, 0x0005, 0x0002, 0x0001, 0x0002, 0x0021, 0x0002,
|
||||
0x0001, 0x0002, 0x0009, 0x0002, 0x0001, 0x0002, 0x0011, 0x0002,
|
||||
0x0001, 0x0002, 0x0005, 0x0002, 0x0001, 0x0002, 0x0021, 0x0002,
|
||||
0x0001, 0x0002
|
||||
};
|
||||
|
||||
// Festive 8Voices
|
||||
const uint16_t PROGMEM builtin_festive_8voices[] = {
|
||||
0x0001, 0x0002, 0x0004, 0x0008, 0x0010, 0x0020, 0x0040, 0x0080
|
||||
};
|
||||
|
||||
// Ormilia
|
||||
const uint16_t PROGMEM builtin_ormilia[] = {
|
||||
0x0002, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001,
|
||||
0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001,
|
||||
0x0002, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001,
|
||||
0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001,
|
||||
0x0002, 0x0001, 0x0000, 0x0001, 0x0002, 0x0001, 0x0000, 0x0001,
|
||||
0x0002, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001,
|
||||
0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0001,
|
||||
0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001,
|
||||
0x0002, 0x0009, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001,
|
||||
0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001,
|
||||
0x0002, 0x0005, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001,
|
||||
0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001,
|
||||
0x0002, 0x0009, 0x0000, 0x0001, 0x0002, 0x0005, 0x0000, 0x0001,
|
||||
0x0002, 0x0009, 0x0000, 0x0001, 0x0002, 0x0005, 0x0000, 0x0001,
|
||||
0x0002, 0x0011, 0x0002, 0x0001, 0x0002, 0x0021, 0x0002, 0x0001,
|
||||
0x0002, 0x0011, 0x0002, 0x0001, 0x0002, 0x0021, 0x0002, 0x0041,
|
||||
0x0002, 0x0081, 0x0002, 0x0009, 0x0002, 0x0041, 0x0002, 0x0081,
|
||||
0x0002, 0x0009, 0x0002, 0x0041, 0x0002, 0x0081, 0x0002, 0x0005,
|
||||
0x0002, 0x0001, 0x0000
|
||||
};
|
||||
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════════
|
||||
// MELODY LIBRARY - Array of all built-in melodies
|
||||
// ═════════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const MelodyInfo MELODY_LIBRARY[] = {
|
||||
{
|
||||
"1 Bell Test",
|
||||
"builtin_1bell_test",
|
||||
builtin_1bell_test,
|
||||
sizeof(builtin_1bell_test) / sizeof(uint16_t)
|
||||
},
|
||||
{
|
||||
"Doxology Traditional",
|
||||
"builtin_doxology_traditional",
|
||||
builtin_doxology_traditional,
|
||||
sizeof(builtin_doxology_traditional) / sizeof(uint16_t)
|
||||
},
|
||||
{
|
||||
"Doxology Alternative",
|
||||
"builtin_doxology_alternative",
|
||||
builtin_doxology_alternative,
|
||||
sizeof(builtin_doxology_alternative) / sizeof(uint16_t)
|
||||
},
|
||||
{
|
||||
"Doxology Festive",
|
||||
"builtin_doxology_festive",
|
||||
builtin_doxology_festive,
|
||||
sizeof(builtin_doxology_festive) / sizeof(uint16_t)
|
||||
},
|
||||
{
|
||||
"Vesper Traditional",
|
||||
"builtin_vesper_traditional",
|
||||
builtin_vesper_traditional,
|
||||
sizeof(builtin_vesper_traditional) / sizeof(uint16_t)
|
||||
},
|
||||
{
|
||||
"Vesper Alternative",
|
||||
"builtin_vesper_alternative",
|
||||
builtin_vesper_alternative,
|
||||
sizeof(builtin_vesper_alternative) / sizeof(uint16_t)
|
||||
},
|
||||
{
|
||||
"Catehetical",
|
||||
"builtin_catehetical",
|
||||
builtin_catehetical,
|
||||
sizeof(builtin_catehetical) / sizeof(uint16_t)
|
||||
},
|
||||
{
|
||||
"Orthros Traditional",
|
||||
"builtin_orthros_traditional",
|
||||
builtin_orthros_traditional,
|
||||
sizeof(builtin_orthros_traditional) / sizeof(uint16_t)
|
||||
},
|
||||
{
|
||||
"Orthros Alternative",
|
||||
"builtin_orthros_alternative",
|
||||
builtin_orthros_alternative,
|
||||
sizeof(builtin_orthros_alternative) / sizeof(uint16_t)
|
||||
},
|
||||
{
|
||||
"Mournfull Toll",
|
||||
"builtin_mournfull_toll",
|
||||
builtin_mournfull_toll,
|
||||
sizeof(builtin_mournfull_toll) / sizeof(uint16_t)
|
||||
},
|
||||
{
|
||||
"Mournfull Toll Alternative",
|
||||
"builtin_mournfull_toll_alternative",
|
||||
builtin_mournfull_toll_alternative,
|
||||
sizeof(builtin_mournfull_toll_alternative) / sizeof(uint16_t)
|
||||
},
|
||||
{
|
||||
"Mournfull Toll Meg Par",
|
||||
"builtin_mournfull_toll_meg_par",
|
||||
builtin_mournfull_toll_meg_par,
|
||||
sizeof(builtin_mournfull_toll_meg_par) / sizeof(uint16_t)
|
||||
},
|
||||
{
|
||||
"Sematron",
|
||||
"builtin_sematron",
|
||||
builtin_sematron,
|
||||
sizeof(builtin_sematron) / sizeof(uint16_t)
|
||||
},
|
||||
{
|
||||
"Sematron Alternative",
|
||||
"builtin_sematron_alternative",
|
||||
builtin_sematron_alternative,
|
||||
sizeof(builtin_sematron_alternative) / sizeof(uint16_t)
|
||||
},
|
||||
{
|
||||
"Athonite 1 2 Voices",
|
||||
"builtin_athonite_1_2_voices",
|
||||
builtin_athonite_1_2_voices,
|
||||
sizeof(builtin_athonite_1_2_voices) / sizeof(uint16_t)
|
||||
},
|
||||
{
|
||||
"Athonite 3 Voices",
|
||||
"builtin_athonite_3_voices",
|
||||
builtin_athonite_3_voices,
|
||||
sizeof(builtin_athonite_3_voices) / sizeof(uint16_t)
|
||||
},
|
||||
{
|
||||
"Athonite 3 4 Voices",
|
||||
"builtin_athonite_3_4_voices",
|
||||
builtin_athonite_3_4_voices,
|
||||
sizeof(builtin_athonite_3_4_voices) / sizeof(uint16_t)
|
||||
},
|
||||
{
|
||||
"Athonite 4 8 Voices",
|
||||
"builtin_athonite_4_8_voices",
|
||||
builtin_athonite_4_8_voices,
|
||||
sizeof(builtin_athonite_4_8_voices) / sizeof(uint16_t)
|
||||
},
|
||||
{
|
||||
"Onebyone 2 3 Voices",
|
||||
"builtin_onebyone_2_3_voices",
|
||||
builtin_onebyone_2_3_voices,
|
||||
sizeof(builtin_onebyone_2_3_voices) / sizeof(uint16_t)
|
||||
},
|
||||
{
|
||||
"Onebyone 4 8 Voices",
|
||||
"builtin_onebyone_4_8_voices",
|
||||
builtin_onebyone_4_8_voices,
|
||||
sizeof(builtin_onebyone_4_8_voices) / sizeof(uint16_t)
|
||||
},
|
||||
{
|
||||
"Festive 1Voice",
|
||||
"builtin_festive_1voice",
|
||||
builtin_festive_1voice,
|
||||
sizeof(builtin_festive_1voice) / sizeof(uint16_t)
|
||||
},
|
||||
{
|
||||
"Festive 4Voices",
|
||||
"builtin_festive_4voices",
|
||||
builtin_festive_4voices,
|
||||
sizeof(builtin_festive_4voices) / sizeof(uint16_t)
|
||||
},
|
||||
{
|
||||
"Festive 5Voices",
|
||||
"builtin_festive_5voices",
|
||||
builtin_festive_5voices,
|
||||
sizeof(builtin_festive_5voices) / sizeof(uint16_t)
|
||||
},
|
||||
{
|
||||
"Festive 5Voice Alternative",
|
||||
"builtin_festive_5voice_alternative",
|
||||
builtin_festive_5voice_alternative,
|
||||
sizeof(builtin_festive_5voice_alternative) / sizeof(uint16_t)
|
||||
},
|
||||
{
|
||||
"Festive 6Voices",
|
||||
"builtin_festive_6voices",
|
||||
builtin_festive_6voices,
|
||||
sizeof(builtin_festive_6voices) / sizeof(uint16_t)
|
||||
},
|
||||
{
|
||||
"Festive 8Voices",
|
||||
"builtin_festive_8voices",
|
||||
builtin_festive_8voices,
|
||||
sizeof(builtin_festive_8voices) / sizeof(uint16_t)
|
||||
},
|
||||
{
|
||||
"Ormilia",
|
||||
"builtin_ormilia",
|
||||
builtin_ormilia,
|
||||
sizeof(builtin_ormilia) / sizeof(uint16_t)
|
||||
}
|
||||
};
|
||||
|
||||
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 += "}";
|
||||
}
|
||||
json += "]";
|
||||
return json;
|
||||
}
|
||||
|
||||
}
|
||||
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"
|
||||
|
||||
ClientManager::ClientManager() {
|
||||
LOG_INFO("Client Manager Component - Initialized");
|
||||
LOG_INFO("Client Manager initialized !");
|
||||
}
|
||||
|
||||
ClientManager::~ClientManager() {
|
||||
_clients.clear();
|
||||
LOG_INFO("Client Manager Component - Destroyed");
|
||||
LOG_INFO("Client Manager destroyed");
|
||||
}
|
||||
|
||||
void ClientManager::addClient(AsyncWebSocketClient* client, DeviceType deviceType) {
|
||||
if (!isValidClient(client)) {
|
||||
LOG_ERROR("Cannot add invalid client");
|
||||
LOG_WARNING("Client Manager - Cannot add invalid client");
|
||||
return;
|
||||
}
|
||||
|
||||
uint32_t clientId = client->id();
|
||||
_clients[clientId] = ClientInfo(client, deviceType);
|
||||
|
||||
LOG_INFO("Client #%u added as %s device", clientId, deviceTypeToString(deviceType));
|
||||
LOG_INFO("Client Manager - Client #%u added as %s device", clientId, deviceTypeToString(deviceType));
|
||||
}
|
||||
|
||||
void ClientManager::removeClient(uint32_t clientId) {
|
||||
auto it = _clients.find(clientId);
|
||||
if (it != _clients.end()) {
|
||||
LOG_INFO("Client #%u removed (%s device)", clientId,
|
||||
LOG_INFO("Client Manager - Client #%u removed (%s device)", clientId,
|
||||
deviceTypeToString(it->second.deviceType));
|
||||
_clients.erase(it);
|
||||
}
|
||||
@@ -36,7 +36,7 @@ void ClientManager::updateClientType(uint32_t clientId, DeviceType deviceType) {
|
||||
if (it != _clients.end()) {
|
||||
DeviceType oldType = it->second.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));
|
||||
}
|
||||
}
|
||||
@@ -72,11 +72,11 @@ bool ClientManager::sendToClient(uint32_t clientId, const String& message) {
|
||||
if (it != _clients.end() && isValidClient(it->second.client)) {
|
||||
it->second.client->text(message);
|
||||
updateClientLastSeen(clientId);
|
||||
LOG_DEBUG("Message sent to client #%u: %s", clientId, message.c_str());
|
||||
LOG_DEBUG("Client Manager - Message sent to client #%u: %s", clientId, message.c_str());
|
||||
return true;
|
||||
}
|
||||
|
||||
LOG_WARNING("Failed to send message to client #%u - client not found or invalid", clientId);
|
||||
LOG_WARNING("Client Manager - Failed to send message to client #%u - client not found or invalid", clientId);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ void ClientManager::sendToMasterClients(const String& message) {
|
||||
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) {
|
||||
@@ -103,7 +103,7 @@ void ClientManager::sendToSecondaryClients(const String& message) {
|
||||
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) {
|
||||
@@ -115,14 +115,14 @@ void ClientManager::broadcastToAll(const String& message) {
|
||||
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() {
|
||||
auto it = _clients.begin();
|
||||
while (it != _clients.end()) {
|
||||
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 = _clients.erase(it);
|
||||
} else {
|
||||
|
||||
@@ -10,8 +10,10 @@
|
||||
#include "../../TimeKeeper/TimeKeeper.hpp"
|
||||
#include "../../FirmwareValidator/FirmwareValidator.hpp"
|
||||
#include "../../ClientManager/ClientManager.hpp"
|
||||
#include "../../Telemetry/Telemetry.hpp"
|
||||
#include "../../Logging/Logging.hpp"
|
||||
#include "../ResponseBuilder/ResponseBuilder.hpp"
|
||||
#include "../CommunicationRouter/CommunicationRouter.hpp"
|
||||
|
||||
CommandHandler::CommandHandler(ConfigManager& configManager, OTAManager& otaManager)
|
||||
: _configManager(configManager)
|
||||
@@ -21,6 +23,8 @@ CommandHandler::CommandHandler(ConfigManager& configManager, OTAManager& otaMana
|
||||
, _timeKeeper(nullptr)
|
||||
, _firmwareValidator(nullptr)
|
||||
, _clientManager(nullptr)
|
||||
, _telemetry(nullptr)
|
||||
, _communicationRouter(nullptr)
|
||||
, _responseCallback(nullptr) {}
|
||||
|
||||
CommandHandler::~CommandHandler() {}
|
||||
@@ -45,6 +49,14 @@ void CommandHandler::setClientManagerReference(ClientManager* cm) {
|
||||
_clientManager = cm;
|
||||
}
|
||||
|
||||
void CommandHandler::setTelemetryReference(Telemetry* telemetry) {
|
||||
_telemetry = telemetry;
|
||||
}
|
||||
|
||||
void CommandHandler::setCommunicationRouterReference(CommunicationRouter* comm) {
|
||||
_communicationRouter = comm;
|
||||
}
|
||||
|
||||
void CommandHandler::setResponseCallback(ResponseCallback callback) {
|
||||
_responseCallback = callback;
|
||||
}
|
||||
@@ -114,7 +126,15 @@ void CommandHandler::handleStatusCommand(const MessageContext& context) {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -257,14 +277,14 @@ void CommandHandler::handleSystemInfoCommand(JsonVariant contents, const Message
|
||||
handleGetDeviceTimeCommand(context);
|
||||
} else if (action == "get_clock_time") {
|
||||
handleGetClockTimeCommand(context);
|
||||
} else if (action == "commit_firmware") {
|
||||
handleCommitFirmwareCommand(context);
|
||||
} else if (action == "rollback_firmware") {
|
||||
handleRollbackFirmwareCommand(context);
|
||||
} else if (action == "get_firmware_status") {
|
||||
handleGetFirmwareStatusCommand(context);
|
||||
} else if (action == "network_info") {
|
||||
handleNetworkInfoCommand(context);
|
||||
} else if (action == "get_full_settings") {
|
||||
handleGetFullSettingsCommand(context);
|
||||
} else if (action == "sync_time_to_lcd") {
|
||||
handleSyncTimeToLcdCommand(context);
|
||||
} else {
|
||||
LOG_WARNING("Unknown system info action: %s", action.c_str());
|
||||
sendErrorResponse("system_info", "Unknown action: " + action, context);
|
||||
@@ -356,9 +376,15 @@ void CommandHandler::handleSetRelayTimersCommand(JsonVariant contents, const Mes
|
||||
void CommandHandler::handleSetRelayOutputsCommand(JsonVariant contents, const MessageContext& context) {
|
||||
try {
|
||||
_configManager.updateBellOutputs(contents);
|
||||
// Note: Bell outputs are typically not persisted to SD card as they're more of a mapping configuration
|
||||
sendSuccessResponse("set_relay_outputs", "Relay outputs updated", context);
|
||||
LOG_INFO("Relay outputs updated successfully");
|
||||
// Save bell outputs configuration to SD card for persistence
|
||||
bool saved = _configManager.saveBellOutputs();
|
||||
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 (...) {
|
||||
sendErrorResponse("set_relay_outputs", "Failed to update relay outputs", context);
|
||||
LOG_ERROR("Exception occurred while updating relay outputs");
|
||||
@@ -500,14 +526,14 @@ void CommandHandler::handleSetRtcTimeCommand(JsonVariant contents, const Message
|
||||
|
||||
// Verify the time was set correctly by reading it back
|
||||
unsigned long verifyTime = _timeKeeper->getTime();
|
||||
if (verifyTime > 0 && abs((long)verifyTime - (long)timestamp) < 5) { // Allow 5 second tolerance
|
||||
if (verifyTime > 0 && abs((long)verifyTime - (long)localTimestamp) < 5) { // Allow 5 second tolerance
|
||||
sendSuccessResponse("set_rtc_time",
|
||||
"RTC time and timezone updated successfully", context);
|
||||
LOG_INFO("RTC time set with timezone: UTC %lu + %ld = local %lu",
|
||||
timestamp, totalOffset, localTimestamp);
|
||||
} else {
|
||||
sendErrorResponse("set_rtc_time", "Failed to verify RTC time was set correctly", context);
|
||||
LOG_ERROR("RTC time verification failed - expected: %lu, got: %lu", timestamp, verifyTime);
|
||||
LOG_ERROR("RTC time verification failed - expected: %lu, got: %lu", localTimestamp, verifyTime);
|
||||
}
|
||||
} else {
|
||||
// Legacy method: Use device's existing timezone config
|
||||
@@ -612,19 +638,26 @@ void CommandHandler::handleSetClockEnabledCommand(JsonVariant contents, const Me
|
||||
}
|
||||
|
||||
void CommandHandler::handleGetDeviceTimeCommand(const MessageContext& context) {
|
||||
StaticJsonDocument<256> response;
|
||||
StaticJsonDocument<384> response;
|
||||
response["status"] = "SUCCESS";
|
||||
response["type"] = "device_time";
|
||||
|
||||
if (_timeKeeper) {
|
||||
// Get Unix timestamp from Timekeeper
|
||||
unsigned long timestamp = _timeKeeper->getTime();
|
||||
response["payload"]["timestamp"] = timestamp;
|
||||
// RTC stores LOCAL time (already timezone-adjusted)
|
||||
unsigned long localTimestamp = _timeKeeper->getTime();
|
||||
|
||||
// Get timezone offset to calculate UTC
|
||||
const auto& timeConfig = _configManager.getTimeConfig();
|
||||
long totalOffset = timeConfig.gmtOffsetSec + timeConfig.daylightOffsetSec;
|
||||
unsigned long utcTimestamp = localTimestamp - totalOffset;
|
||||
|
||||
response["payload"]["local_timestamp"] = localTimestamp;
|
||||
response["payload"]["utc_timestamp"] = utcTimestamp;
|
||||
response["payload"]["rtc_available"] = true;
|
||||
|
||||
// Convert to readable format
|
||||
time_t rawTime = (time_t)timestamp;
|
||||
struct tm* timeInfo = localtime(&rawTime);
|
||||
// Convert LOCAL timestamp to readable format using gmtime (no additional offset)
|
||||
time_t rawTime = (time_t)localTimestamp;
|
||||
struct tm* timeInfo = gmtime(&rawTime); // Use gmtime to avoid double-offset
|
||||
response["payload"]["year"] = timeInfo->tm_year + 1900;
|
||||
response["payload"]["month"] = timeInfo->tm_mon + 1;
|
||||
response["payload"]["day"] = timeInfo->tm_mday;
|
||||
@@ -632,7 +665,8 @@ void CommandHandler::handleGetDeviceTimeCommand(const MessageContext& context) {
|
||||
response["payload"]["minute"] = timeInfo->tm_min;
|
||||
response["payload"]["second"] = timeInfo->tm_sec;
|
||||
} else {
|
||||
response["payload"]["timestamp"] = millis() / 1000;
|
||||
response["payload"]["local_timestamp"] = millis() / 1000;
|
||||
response["payload"]["utc_timestamp"] = millis() / 1000;
|
||||
response["payload"]["rtc_available"] = false;
|
||||
LOG_WARNING("TimeKeeper reference not set for device time request");
|
||||
}
|
||||
@@ -789,6 +823,21 @@ void CommandHandler::handleGetFirmwareStatusCommand(const MessageContext& contex
|
||||
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) {
|
||||
LOG_DEBUG("Full settings requested");
|
||||
|
||||
@@ -819,7 +868,37 @@ void CommandHandler::handleGetFullSettingsCommand(const MessageContext& context)
|
||||
LOG_DEBUG("Full settings sent (%d bytes)", responseStr.length());
|
||||
}
|
||||
|
||||
void CommandHandler::handleSyncTimeToLcdCommand(const MessageContext& context) {
|
||||
StaticJsonDocument<256> response;
|
||||
response["status"] = "SUCCESS";
|
||||
response["type"] = "sync_time_to_lcd";
|
||||
|
||||
// Get the local timestamp from TimeKeeper (RTC stores local time)
|
||||
unsigned long localTimestamp = 0;
|
||||
if (_timeKeeper) {
|
||||
localTimestamp = _timeKeeper->getTime();
|
||||
} else {
|
||||
// Fallback to millis if TimeKeeper not available
|
||||
localTimestamp = millis() / 1000;
|
||||
LOG_WARNING("TimeKeeper not available for LCD time sync");
|
||||
}
|
||||
|
||||
// Get timezone offset from ConfigManager (in seconds)
|
||||
const auto& timeConfig = _configManager.getTimeConfig();
|
||||
long totalOffset = timeConfig.gmtOffsetSec + timeConfig.daylightOffsetSec;
|
||||
|
||||
// Calculate UTC timestamp by subtracting the offset from local time
|
||||
unsigned long utcTimestamp = localTimestamp - totalOffset;
|
||||
|
||||
response["payload"]["timestamp"] = utcTimestamp;
|
||||
response["payload"]["offset"] = totalOffset;
|
||||
|
||||
String responseStr;
|
||||
serializeJson(response, responseStr);
|
||||
sendResponse(responseStr, context);
|
||||
|
||||
LOG_DEBUG("LCD time sync: UTC=%lu, offset=%ld", utcTimestamp, totalOffset);
|
||||
}
|
||||
|
||||
void CommandHandler::handleSetNetworkConfigCommand(JsonVariant contents, const MessageContext& context) {
|
||||
// Validate that we have at least one parameter to update
|
||||
@@ -988,9 +1067,257 @@ void CommandHandler::handleSystemCommand(JsonVariant contents, const MessageCont
|
||||
handleGetFirmwareStatusCommand(context);
|
||||
} else if (action == "set_network_config") {
|
||||
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 {
|
||||
LOG_WARNING("Unknown system action: %s", action.c_str());
|
||||
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;
|
||||
uint16_t version = contents.containsKey("version") ?
|
||||
contents["version"].as<uint16_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, Version=%u",
|
||||
firmwareUrl.c_str(),
|
||||
checksum.isEmpty() ? "none" : checksum.c_str(),
|
||||
fileSize,
|
||||
version);
|
||||
|
||||
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, version);
|
||||
|
||||
// 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,13 +33,16 @@ class FileManager;
|
||||
class Timekeeper;
|
||||
class FirmwareValidator;
|
||||
class ClientManager;
|
||||
class Telemetry;
|
||||
class CommunicationRouter;
|
||||
|
||||
class CommandHandler {
|
||||
public:
|
||||
// Message source identification
|
||||
enum class MessageSource {
|
||||
MQTT,
|
||||
WEBSOCKET
|
||||
WEBSOCKET,
|
||||
UART
|
||||
};
|
||||
|
||||
struct MessageContext {
|
||||
@@ -65,6 +68,8 @@ public:
|
||||
void setTimeKeeperReference(Timekeeper* tk);
|
||||
void setFirmwareValidatorReference(FirmwareValidator* fv);
|
||||
void setClientManagerReference(ClientManager* cm);
|
||||
void setTelemetryReference(Telemetry* telemetry);
|
||||
void setCommunicationRouterReference(CommunicationRouter* comm);
|
||||
|
||||
/**
|
||||
* @brief Set response callback for sending responses back
|
||||
@@ -87,6 +92,8 @@ private:
|
||||
Timekeeper* _timeKeeper;
|
||||
FirmwareValidator* _firmwareValidator;
|
||||
ClientManager* _clientManager;
|
||||
Telemetry* _telemetry;
|
||||
CommunicationRouter* _communicationRouter;
|
||||
ResponseCallback _responseCallback;
|
||||
|
||||
// Response helpers
|
||||
@@ -131,11 +138,26 @@ private:
|
||||
void handleCommitFirmwareCommand(const MessageContext& context);
|
||||
void handleRollbackFirmwareCommand(const MessageContext& context);
|
||||
void handleGetFirmwareStatusCommand(const MessageContext& context);
|
||||
void handleNetworkInfoCommand(const MessageContext& context);
|
||||
void handleGetFullSettingsCommand(const MessageContext& context);
|
||||
void handleSyncTimeToLcdCommand(const MessageContext& context);
|
||||
|
||||
// Network configuration
|
||||
void handleSetNetworkConfigCommand(JsonVariant contents, const MessageContext& context);
|
||||
|
||||
// System Config
|
||||
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,10 @@ CommunicationRouter::CommunicationRouter(ConfigManager& configManager,
|
||||
, _mqttClient(configManager, networking)
|
||||
, _clientManager()
|
||||
, _wsServer(webSocket, _clientManager)
|
||||
, _commandHandler(configManager, otaManager) {}
|
||||
, _commandHandler(configManager, otaManager)
|
||||
, _httpHandler(server, configManager)
|
||||
, _uartHandler()
|
||||
, _settingsServer(server, configManager, networking) {}
|
||||
|
||||
CommunicationRouter::~CommunicationRouter() {}
|
||||
|
||||
@@ -60,6 +63,21 @@ void CommunicationRouter::begin() {
|
||||
_mqttClient.setCallback([this](const String& topic, const String& 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");
|
||||
} catch (...) {
|
||||
LOG_ERROR("❌ MQTT initialization failed, but WebSocket is still available");
|
||||
@@ -69,12 +87,45 @@ void CommunicationRouter::begin() {
|
||||
_commandHandler.setClientManagerReference(&_clientManager);
|
||||
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
|
||||
_commandHandler.setResponseCallback([this](const String& response, const CommandHandler::MessageContext& 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");
|
||||
|
||||
// Initialize UART Command Handler
|
||||
LOG_INFO("Setting up UART Command Handler...");
|
||||
_uartHandler.begin();
|
||||
_uartHandler.setCallback([this](JsonDocument& message) {
|
||||
onUartMessage(message);
|
||||
});
|
||||
LOG_INFO("✅ UART Command Handler initialized (TX: GPIO12, RX: GPIO13)");
|
||||
|
||||
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(" • UART: External device control");
|
||||
LOG_INFO(" • Settings Page: /settings");
|
||||
}
|
||||
|
||||
void CommunicationRouter::loop() {
|
||||
// Process UART incoming data
|
||||
_uartHandler.loop();
|
||||
}
|
||||
|
||||
void CommunicationRouter::setPlayerReference(Player* player) {
|
||||
@@ -97,6 +148,10 @@ void CommunicationRouter::setFirmwareValidatorReference(FirmwareValidator* fv) {
|
||||
_commandHandler.setFirmwareValidatorReference(fv);
|
||||
}
|
||||
|
||||
void CommunicationRouter::setTelemetryReference(Telemetry* telemetry) {
|
||||
_commandHandler.setTelemetryReference(telemetry);
|
||||
}
|
||||
|
||||
void CommunicationRouter::setupUdpDiscovery() {
|
||||
uint16_t discoveryPort = _configManager.getNetworkConfig().discoveryPort;
|
||||
if (_udp.listen(discoveryPort)) {
|
||||
@@ -117,7 +172,7 @@ void CommunicationRouter::setupUdpDiscovery() {
|
||||
StaticJsonDocument<128> req;
|
||||
DeserializationError err = deserializeJson(req, msg);
|
||||
if (!err) {
|
||||
shouldReply = (req["op"] == "discover" && req["svc"] == "vesper");
|
||||
shouldReply = (req["op"] == "discover");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,7 +187,7 @@ void CommunicationRouter::setupUdpDiscovery() {
|
||||
doc["id"] = _configManager.getDeviceUID();
|
||||
doc["ip"] = _networking.getLocalIP();
|
||||
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["port"] = 80;
|
||||
doc["fw"] = "2.0";
|
||||
@@ -287,6 +342,40 @@ void CommunicationRouter::onWebSocketMessage(uint32_t clientId, const JsonDocume
|
||||
LOG_DEBUG("WebSocket message from client #%u processed", clientId);
|
||||
}
|
||||
|
||||
void CommunicationRouter::onUartMessage(JsonDocument& message) {
|
||||
// Extract command and action for filtering
|
||||
String cmd = message["cmd"] | "";
|
||||
String action = message["contents"]["action"] | "";
|
||||
|
||||
// UART COMMAND WHITELIST: Only allow specific commands
|
||||
// This prevents feedback loops between devices when bad messages occur.
|
||||
// To re-enable full UART command support, remove this filter.
|
||||
bool allowed = false;
|
||||
|
||||
if (cmd == "system_info" && action == "sync_time_to_lcd") {
|
||||
allowed = true;
|
||||
} else if (cmd == "playback" && (action == "play" || action == "stop")) {
|
||||
allowed = true;
|
||||
}
|
||||
|
||||
if (!allowed) {
|
||||
// Silently ignore - do NOT send error response to avoid feedback loop
|
||||
LOG_DEBUG("UART: Ignoring non-whitelisted command (cmd=%s, action=%s)",
|
||||
cmd.c_str(), action.c_str());
|
||||
return;
|
||||
}
|
||||
|
||||
LOG_INFO("🔌 UART command received: cmd=%s, action=%s", cmd.c_str(), action.c_str());
|
||||
|
||||
// Create message context for UART
|
||||
CommandHandler::MessageContext context(CommandHandler::MessageSource::UART);
|
||||
|
||||
// Forward to command handler
|
||||
_commandHandler.processCommand(message, context);
|
||||
|
||||
LOG_DEBUG("UART message processed");
|
||||
}
|
||||
|
||||
void CommunicationRouter::sendResponse(const String& response, const CommandHandler::MessageContext& context) {
|
||||
if (context.source == CommandHandler::MessageSource::MQTT) {
|
||||
LOG_DEBUG("↗️ Sending response via MQTT: %s", response.c_str());
|
||||
@@ -294,6 +383,9 @@ void CommunicationRouter::sendResponse(const String& response, const CommandHand
|
||||
} else if (context.source == CommandHandler::MessageSource::WEBSOCKET) {
|
||||
LOG_DEBUG("↗️ Sending response to WebSocket client #%u: %s", context.clientId, response.c_str());
|
||||
_wsServer.sendToClient(context.clientId, response);
|
||||
} else if (context.source == CommandHandler::MessageSource::UART) {
|
||||
LOG_DEBUG("↗️ Sending response via UART: %s", response.c_str());
|
||||
_uartHandler.send(response);
|
||||
} else {
|
||||
LOG_ERROR("❌ Unknown message source for response routing!");
|
||||
}
|
||||
|
||||
@@ -38,7 +38,10 @@
|
||||
#include "../WebSocketServer/WebSocketServer.hpp"
|
||||
#include "../CommandHandler/CommandHandler.hpp"
|
||||
#include "../ResponseBuilder/ResponseBuilder.hpp"
|
||||
#include "../HTTPRequestHandler/HTTPRequestHandler.hpp"
|
||||
#include "../UARTCommandHandler/UARTCommandHandler.hpp"
|
||||
#include "../../ClientManager/ClientManager.hpp"
|
||||
#include "../../SettingsWebServer/SettingsWebServer.hpp"
|
||||
|
||||
class ConfigManager;
|
||||
class OTAManager;
|
||||
@@ -47,6 +50,7 @@ class FileManager;
|
||||
class Timekeeper;
|
||||
class Networking;
|
||||
class FirmwareValidator;
|
||||
class Telemetry;
|
||||
|
||||
class CommunicationRouter {
|
||||
public:
|
||||
@@ -60,10 +64,12 @@ public:
|
||||
~CommunicationRouter();
|
||||
|
||||
void begin();
|
||||
void loop(); // Must be called from main loop for UART processing
|
||||
void setPlayerReference(Player* player);
|
||||
void setFileManagerReference(FileManager* fm);
|
||||
void setTimeKeeperReference(Timekeeper* tk);
|
||||
void setFirmwareValidatorReference(FirmwareValidator* fv);
|
||||
void setTelemetryReference(Telemetry* telemetry);
|
||||
void setupUdpDiscovery();
|
||||
|
||||
// Status methods
|
||||
@@ -72,6 +78,10 @@ public:
|
||||
size_t getWebSocketClientCount() const;
|
||||
bool isHealthy() const;
|
||||
|
||||
// Component accessors
|
||||
MQTTAsyncClient& getMQTTClient() { return _mqttClient; }
|
||||
UARTCommandHandler& getUARTHandler() { return _uartHandler; }
|
||||
|
||||
// Broadcast methods
|
||||
void broadcastStatus(const String& statusMessage);
|
||||
void broadcastStatus(const JsonDocument& statusJson);
|
||||
@@ -108,10 +118,14 @@ private:
|
||||
ClientManager _clientManager;
|
||||
WebSocketServer _wsServer;
|
||||
CommandHandler _commandHandler;
|
||||
HTTPRequestHandler _httpHandler;
|
||||
UARTCommandHandler _uartHandler;
|
||||
SettingsWebServer _settingsServer;
|
||||
|
||||
// Message handlers
|
||||
void onMqttMessage(const String& topic, const String& payload);
|
||||
void onWebSocketMessage(uint32_t clientId, const JsonDocument& message);
|
||||
void onUartMessage(JsonDocument& message);
|
||||
|
||||
// Response routing
|
||||
void sendResponse(const String& response, const CommandHandler::MessageContext& context);
|
||||
|
||||
@@ -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)
|
||||
, _networking(networking)
|
||||
, _messageCallback(nullptr)
|
||||
, _mqttReconnectTimer(nullptr) {
|
||||
, _mqttReconnectTimer(nullptr)
|
||||
, _networkStabilizationTimer(nullptr)
|
||||
, _heartbeatTimer(nullptr)
|
||||
, _reconnectAttempts(0)
|
||||
, _lastConnectionAttempt(0) {
|
||||
|
||||
_instance = this; // Set static instance pointer
|
||||
|
||||
// Create reconnection timer
|
||||
// Create reconnection timer (initial delay will be calculated dynamically)
|
||||
_mqttReconnectTimer = xTimerCreate(
|
||||
"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)
|
||||
(void*)0, // Timer ID (can store data)
|
||||
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() {
|
||||
if (_mqttReconnectTimer) {
|
||||
xTimerDelete(_mqttReconnectTimer, portMAX_DELAY);
|
||||
}
|
||||
if (_networkStabilizationTimer) {
|
||||
xTimerStop(_networkStabilizationTimer, 0);
|
||||
xTimerDelete(_networkStabilizationTimer, portMAX_DELAY);
|
||||
}
|
||||
if (_heartbeatTimer) {
|
||||
xTimerStop(_heartbeatTimer, 0);
|
||||
xTimerDelete(_heartbeatTimer, portMAX_DELAY);
|
||||
}
|
||||
_mqttClient.disconnect();
|
||||
}
|
||||
|
||||
@@ -84,12 +114,21 @@ void MQTTAsyncClient::begin() {
|
||||
}
|
||||
|
||||
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()) {
|
||||
LOG_DEBUG("Already connected to MQTT");
|
||||
return;
|
||||
}
|
||||
|
||||
auto& mqttConfig = _configManager.getMqttConfig();
|
||||
// Track connection attempt
|
||||
_lastConnectionAttempt = millis();
|
||||
|
||||
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) {
|
||||
// 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)
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
@@ -127,13 +171,28 @@ bool MQTTAsyncClient::isConnected() const {
|
||||
}
|
||||
|
||||
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
|
||||
delay(2000);
|
||||
// 🔥 Only attempt connection if MQTT is enabled
|
||||
if (!mqttConfig.enabled) {
|
||||
LOG_DEBUG("Network connected but MQTT is disabled - skipping MQTT connection");
|
||||
return;
|
||||
}
|
||||
|
||||
LOG_DEBUG("Network stable - connecting to MQTT");
|
||||
connect();
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
void MQTTAsyncClient::onNetworkDisconnected() {
|
||||
@@ -153,11 +212,19 @@ void MQTTAsyncClient::onMqttConnect(bool sessionPresent) {
|
||||
LOG_INFO("✅ Connected to MQTT broker (session present: %s)", sessionPresent ? "yes" : "no");
|
||||
LOG_INFO("🔍 Free heap AFTER MQTT connect: %d bytes", ESP.getFreeHeap());
|
||||
|
||||
// Reset reconnection attempts on successful connection
|
||||
_reconnectAttempts = 0;
|
||||
|
||||
// Subscribe to control topic
|
||||
subscribe();
|
||||
|
||||
// 🔥 Start heartbeat timer
|
||||
startHeartbeat();
|
||||
}
|
||||
|
||||
void MQTTAsyncClient::onMqttDisconnect(AsyncMqttClientDisconnectReason reason) {
|
||||
auto& mqttConfig = _configManager.getMqttConfig();
|
||||
|
||||
const char* reasonStr;
|
||||
switch(reason) {
|
||||
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));
|
||||
|
||||
if (_networking.isConnected()) {
|
||||
LOG_INFO("Network still connected - scheduling MQTT reconnection in %d seconds", MQTT_RECONNECT_DELAY / 1000);
|
||||
// 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()) {
|
||||
// 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);
|
||||
} else {
|
||||
LOG_INFO("Network is down - waiting for network to reconnect");
|
||||
@@ -238,3 +324,127 @@ void MQTTAsyncClient::mqttReconnectTimerCallback(TimerHandle_t xTimer) {
|
||||
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 onMqttPublish(uint16_t packetId);
|
||||
|
||||
// Reconnection Timer
|
||||
// Reconnection Timer with Exponential Backoff
|
||||
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();
|
||||
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", "");
|
||||
}
|
||||
|
||||
String ResponseBuilder::deviceStatus(PlayerStatus playerStatus, uint32_t timeElapsed, uint64_t projectedRunTime) {
|
||||
StaticJsonDocument<512> statusDoc; // Increased size for additional data
|
||||
String ResponseBuilder::deviceStatus(PlayerStatus playerStatus, uint32_t timeElapsed, uint64_t projectedRunTime, const uint32_t strikeCounters[16]) {
|
||||
DynamicJsonDocument statusDoc(1024); // Increased size for strikeCounters array
|
||||
|
||||
statusDoc["status"] = "SUCCESS";
|
||||
statusDoc["type"] = "current_status";
|
||||
@@ -63,6 +63,12 @@ String ResponseBuilder::deviceStatus(PlayerStatus playerStatus, uint32_t timeEla
|
||||
payload["time_elapsed"] = timeElapsed; // in milliseconds
|
||||
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;
|
||||
serializeJson(statusDoc, result);
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ public:
|
||||
// Specialized response builders for common scenarios
|
||||
static String acknowledgment(const String& commandType);
|
||||
static String pong();
|
||||
static String deviceStatus(PlayerStatus playerStatus, uint32_t timeElapsedMs, uint64_t projectedRunTime = 0);
|
||||
static String deviceStatus(PlayerStatus playerStatus, uint32_t timeElapsedMs, uint64_t projectedRunTime, const uint32_t strikeCounters[16]);
|
||||
static String melodyList(const String& fileListJson);
|
||||
static String downloadResult(bool success, const String& filename = "");
|
||||
static String configUpdate(const String& configType);
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/*
|
||||
* UARTCOMMANDHANDLER.CPP - UART Command Handler Implementation
|
||||
*/
|
||||
|
||||
#include "UARTCommandHandler.hpp"
|
||||
#include "../../Logging/Logging.hpp"
|
||||
|
||||
UARTCommandHandler::UARTCommandHandler(uint8_t txPin, uint8_t rxPin, uint32_t baudRate)
|
||||
: _serial(Serial2)
|
||||
, _txPin(txPin)
|
||||
, _rxPin(rxPin)
|
||||
, _baudRate(baudRate)
|
||||
, _ready(false)
|
||||
, _bufferIndex(0)
|
||||
, _messageCount(0)
|
||||
, _errorCount(0)
|
||||
, _callback(nullptr)
|
||||
{
|
||||
resetBuffer();
|
||||
}
|
||||
|
||||
UARTCommandHandler::~UARTCommandHandler() {
|
||||
_serial.end();
|
||||
}
|
||||
|
||||
void UARTCommandHandler::begin() {
|
||||
LOG_INFO("Initializing UART Command Handler");
|
||||
LOG_INFO(" TX Pin: GPIO%d", _txPin);
|
||||
LOG_INFO(" RX Pin: GPIO%d", _rxPin);
|
||||
LOG_INFO(" Baud Rate: %u", _baudRate);
|
||||
|
||||
// Initialize Serial2 with custom pins
|
||||
_serial.begin(_baudRate, SERIAL_8N1, _rxPin, _txPin);
|
||||
|
||||
// Clear any garbage in the buffer
|
||||
while (_serial.available()) {
|
||||
_serial.read();
|
||||
}
|
||||
|
||||
_ready = true;
|
||||
LOG_INFO("UART Command Handler ready");
|
||||
}
|
||||
|
||||
void UARTCommandHandler::loop() {
|
||||
if (!_ready) return;
|
||||
|
||||
// Process all available bytes
|
||||
while (_serial.available()) {
|
||||
char c = _serial.read();
|
||||
|
||||
// Check for message delimiter (newline)
|
||||
if (c == '\n' || c == '\r') {
|
||||
if (_bufferIndex > 0) {
|
||||
// Null-terminate and process
|
||||
_buffer[_bufferIndex] = '\0';
|
||||
processLine(_buffer);
|
||||
resetBuffer();
|
||||
}
|
||||
// Skip empty lines
|
||||
continue;
|
||||
}
|
||||
|
||||
// Add character to buffer
|
||||
if (_bufferIndex < BUFFER_SIZE - 1) {
|
||||
_buffer[_bufferIndex++] = c;
|
||||
} else {
|
||||
// Buffer overflow - discard and reset
|
||||
LOG_ERROR("UART buffer overflow, discarding message");
|
||||
_errorCount++;
|
||||
resetBuffer();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void UARTCommandHandler::setCallback(MessageCallback callback) {
|
||||
_callback = callback;
|
||||
}
|
||||
|
||||
void UARTCommandHandler::send(const String& response) {
|
||||
if (!_ready) {
|
||||
LOG_ERROR("UART not ready, cannot send response");
|
||||
return;
|
||||
}
|
||||
|
||||
_serial.print(response);
|
||||
_serial.print('\n'); // Newline delimiter
|
||||
_serial.flush(); // Ensure data is sent
|
||||
|
||||
LOG_DEBUG("UART TX: %s", response.c_str());
|
||||
}
|
||||
|
||||
void UARTCommandHandler::processLine(const char* line) {
|
||||
LOG_DEBUG("UART RX: %s", line);
|
||||
|
||||
// Skip empty lines or whitespace-only
|
||||
if (strlen(line) == 0) return;
|
||||
|
||||
// Parse JSON
|
||||
StaticJsonDocument<1024> doc;
|
||||
DeserializationError error = deserializeJson(doc, line);
|
||||
|
||||
if (error) {
|
||||
LOG_ERROR("UART JSON parse error: %s", error.c_str());
|
||||
_errorCount++;
|
||||
|
||||
// Send error response back
|
||||
StaticJsonDocument<256> errorDoc;
|
||||
errorDoc["status"] = "ERROR";
|
||||
errorDoc["type"] = "parse_error";
|
||||
errorDoc["payload"] = error.c_str();
|
||||
|
||||
String errorResponse;
|
||||
serializeJson(errorDoc, errorResponse);
|
||||
send(errorResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
_messageCount++;
|
||||
|
||||
// Invoke callback if set
|
||||
if (_callback) {
|
||||
_callback(doc);
|
||||
} else {
|
||||
LOG_WARNING("UART message received but no callback set");
|
||||
}
|
||||
}
|
||||
|
||||
void UARTCommandHandler::resetBuffer() {
|
||||
_bufferIndex = 0;
|
||||
memset(_buffer, 0, BUFFER_SIZE);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/*
|
||||
* ═══════════════════════════════════════════════════════════════════════════════════
|
||||
* UARTCOMMANDHANDLER.HPP - UART Command Interface for External Control Devices
|
||||
* ═══════════════════════════════════════════════════════════════════════════════════
|
||||
*
|
||||
* 🔌 UART COMMAND HANDLER 🔌
|
||||
*
|
||||
* Enables command input from external devices (LCD panels, button controllers)
|
||||
* via UART serial communication. Uses newline-delimited JSON protocol.
|
||||
*
|
||||
* Pin Configuration:
|
||||
* • TX: GPIO12
|
||||
* • RX: GPIO13
|
||||
* • Baud: 115200 (configurable)
|
||||
*
|
||||
* Protocol:
|
||||
* • Newline-delimited JSON messages
|
||||
* • Same command format as MQTT/WebSocket
|
||||
* • Responses sent back on same UART
|
||||
*
|
||||
* 📋 VERSION: 1.0
|
||||
* 📅 DATE: 2025-01-19
|
||||
* 👨💻 AUTHOR: Advanced Bell Systems
|
||||
* ═══════════════════════════════════════════════════════════════════════════════════
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <ArduinoJson.h>
|
||||
#include <functional>
|
||||
|
||||
class UARTCommandHandler {
|
||||
public:
|
||||
// Default pin configuration
|
||||
static constexpr uint8_t DEFAULT_TX_PIN = 12;
|
||||
static constexpr uint8_t DEFAULT_RX_PIN = 13;
|
||||
static constexpr uint32_t DEFAULT_BAUD_RATE = 115200;
|
||||
static constexpr size_t BUFFER_SIZE = 1024;
|
||||
|
||||
// Message callback type - called when a complete JSON message is received
|
||||
using MessageCallback = std::function<void(JsonDocument& message)>;
|
||||
|
||||
/**
|
||||
* @brief Construct UART handler with custom pins
|
||||
* @param txPin GPIO pin for TX (default: 12)
|
||||
* @param rxPin GPIO pin for RX (default: 13)
|
||||
* @param baudRate Baud rate (default: 115200)
|
||||
*/
|
||||
explicit UARTCommandHandler(uint8_t txPin = DEFAULT_TX_PIN,
|
||||
uint8_t rxPin = DEFAULT_RX_PIN,
|
||||
uint32_t baudRate = DEFAULT_BAUD_RATE);
|
||||
|
||||
~UARTCommandHandler();
|
||||
|
||||
/**
|
||||
* @brief Initialize the UART interface
|
||||
*/
|
||||
void begin();
|
||||
|
||||
/**
|
||||
* @brief Process incoming UART data (call from loop or task)
|
||||
* Non-blocking - processes available bytes and returns
|
||||
*/
|
||||
void loop();
|
||||
|
||||
/**
|
||||
* @brief Set callback for received messages
|
||||
* @param callback Function to call with parsed JSON
|
||||
*/
|
||||
void setCallback(MessageCallback callback);
|
||||
|
||||
/**
|
||||
* @brief Send a response back over UART
|
||||
* @param response JSON string to send (newline appended automatically)
|
||||
*/
|
||||
void send(const String& response);
|
||||
|
||||
/**
|
||||
* @brief Check if UART is initialized and ready
|
||||
*/
|
||||
bool isReady() const { return _ready; }
|
||||
|
||||
/**
|
||||
* @brief Get number of messages received since boot
|
||||
*/
|
||||
uint32_t getMessageCount() const { return _messageCount; }
|
||||
|
||||
/**
|
||||
* @brief Get number of parse errors since boot
|
||||
*/
|
||||
uint32_t getErrorCount() const { return _errorCount; }
|
||||
|
||||
private:
|
||||
HardwareSerial& _serial;
|
||||
uint8_t _txPin;
|
||||
uint8_t _rxPin;
|
||||
uint32_t _baudRate;
|
||||
bool _ready;
|
||||
|
||||
// Receive buffer
|
||||
char _buffer[BUFFER_SIZE];
|
||||
size_t _bufferIndex;
|
||||
|
||||
// Statistics
|
||||
uint32_t _messageCount;
|
||||
uint32_t _errorCount;
|
||||
|
||||
// Callback
|
||||
MessageCallback _callback;
|
||||
|
||||
/**
|
||||
* @brief Process a complete line from the buffer
|
||||
* @param line Null-terminated string containing the message
|
||||
*/
|
||||
void processLine(const char* line);
|
||||
|
||||
/**
|
||||
* @brief Reset the receive buffer
|
||||
*/
|
||||
void resetBuffer();
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -47,7 +47,7 @@ public:
|
||||
String deviceUID = ""; // 🏷️ Factory-set UID (NVS) - NO DEFAULT
|
||||
String hwType = ""; // 🔧 Factory-set hardware type (NVS) - NO DEFAULT
|
||||
String hwVersion = ""; // 📐 Factory-set hardware revision (NVS) - NO DEFAULT
|
||||
String fwVersion = "0.0.0"; // 📋 Current firmware version (SD) - auto-updated
|
||||
String fwVersion = "0"; // 📋 Current firmware version (SD) - auto-updated (integer string)
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -69,6 +69,9 @@ public:
|
||||
String apSsid; // 📡 Auto-generated AP name
|
||||
String apPass; // 🔐 AP is Open. No Password
|
||||
uint16_t discoveryPort = 32101; // 📡 Fixed discovery port
|
||||
bool permanentAPMode = false; // 🔘 Permanent AP mode toggle (stored on SD)
|
||||
String defaultWifiSsid = "BellSystemsInfra"; // 📡 Default WiFi SSID to try on boot
|
||||
String defaultWifiPsk = "v3sp3r_8998!"; // 🔐 Default WiFi password to try on boot
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -79,11 +82,12 @@ public:
|
||||
* Username defaults to deviceUID for unique identification.
|
||||
*/
|
||||
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)
|
||||
String user; // 👤 Auto-set to deviceUID
|
||||
String password = "vesper"; // 🔑 Default password
|
||||
bool useSSL = false; // 🔒 SSL disabled for local broker
|
||||
bool enabled = true; // 🔘 MQTT enabled by default (can be toggled via command)
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -119,7 +123,7 @@ public:
|
||||
*/
|
||||
struct TimeConfig {
|
||||
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
|
||||
};
|
||||
|
||||
@@ -203,6 +207,20 @@ public:
|
||||
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:
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// MEMBER VARIABLES - Clean deployment-ready storage
|
||||
@@ -215,6 +233,7 @@ private:
|
||||
UpdateConfig updateConfig;
|
||||
BellConfig bellConfig;
|
||||
ClockConfig clockConfig;
|
||||
GeneralConfig generalConfig;
|
||||
|
||||
bool sdInitialized = false;
|
||||
std::vector<String> updateServers;
|
||||
@@ -274,7 +293,6 @@ public:
|
||||
*/
|
||||
bool begin();
|
||||
|
||||
void loadFromSD();
|
||||
bool saveToSD();
|
||||
|
||||
// Configuration access (read-only getters)
|
||||
@@ -286,6 +304,7 @@ public:
|
||||
const UpdateConfig& getUpdateConfig() const { return updateConfig; }
|
||||
const BellConfig& getBellConfig() const { return bellConfig; }
|
||||
const ClockConfig& getClockConfig() const { return clockConfig; }
|
||||
const GeneralConfig& getGeneralConfig() const { return generalConfig; }
|
||||
|
||||
// Device identity methods (READ-ONLY - factory set via separate factory firmware)
|
||||
// These values are loaded ONCE at boot from NVS and kept in RAM
|
||||
@@ -303,13 +322,23 @@ public:
|
||||
void updateNetworkConfig(const String& hostname, bool useStaticIP, IPAddress ip, IPAddress gateway,
|
||||
IPAddress subnet, IPAddress dns1, IPAddress dns2);
|
||||
|
||||
// AP Mode configuration
|
||||
bool getPermanentAPMode() const { return networkConfig.permanentAPMode; }
|
||||
void setPermanentAPMode(bool enabled) { networkConfig.permanentAPMode = enabled; }
|
||||
|
||||
// Network configuration persistence
|
||||
bool loadNetworkConfig();
|
||||
bool saveNetworkConfig();
|
||||
|
||||
// Time configuration persistence
|
||||
bool loadTimeConfig();
|
||||
bool saveTimeConfig();
|
||||
|
||||
// Bell and clock configuration methods (unchanged)
|
||||
bool loadBellDurations();
|
||||
bool saveBellDurations();
|
||||
bool loadBellOutputs();
|
||||
bool saveBellOutputs();
|
||||
void updateBellDurations(JsonVariant doc);
|
||||
void updateBellOutputs(JsonVariant doc);
|
||||
uint16_t getBellDuration(uint8_t bellIndex) const;
|
||||
@@ -376,6 +405,16 @@ public:
|
||||
void setNighttimeSilenceOnTime(const String& time) { clockConfig.nighttimeSilenceOnTime = 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)
|
||||
void updateClockAlerts(JsonVariant doc);
|
||||
void updateClockBacklight(JsonVariant doc);
|
||||
@@ -395,6 +434,7 @@ public:
|
||||
String getAPSSID() const { return networkConfig.apSsid; }
|
||||
bool isHealthy() const;
|
||||
|
||||
|
||||
/**
|
||||
* @brief Get all configuration settings as a JSON string
|
||||
* @return JSON string containing all current settings
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "FileManager.hpp"
|
||||
#include "../BuiltInMelodies/BuiltInMelodies.hpp"
|
||||
|
||||
FileManager::FileManager(ConfigManager* config) : configManager(config) {
|
||||
// Constructor - store reference to ConfigManager
|
||||
@@ -23,15 +24,26 @@ bool FileManager::addMelody(JsonVariant doc) {
|
||||
}
|
||||
|
||||
const char* url = doc["download_url"];
|
||||
const char* filename = doc["melodys_uid"];
|
||||
const char* melodyUid = doc["melodys_uid"];
|
||||
|
||||
// Check if this is a built-in melody - skip download if it exists
|
||||
if (BuiltInMelodies::isBuiltInMelody(melodyUid)) {
|
||||
const BuiltInMelodies::MelodyInfo* builtinMelody = BuiltInMelodies::findMelodyByUID(melodyUid);
|
||||
if (builtinMelody != nullptr) {
|
||||
LOG_INFO("Melody '%s' is a built-in melody, skipping download", melodyUid);
|
||||
return true; // Success - no download needed
|
||||
}
|
||||
// If starts with builtin_ but not found, log warning and try download anyway
|
||||
LOG_WARNING("Melody '%s' has builtin_ prefix but not found in library, attempting download", melodyUid);
|
||||
}
|
||||
|
||||
// Download the melody file to /melodies directory
|
||||
if (downloadFile(url, "/melodies", filename)) {
|
||||
LOG_INFO("Melody download successful: %s", filename);
|
||||
if (downloadFile(url, "/melodies", melodyUid)) {
|
||||
LOG_INFO("Melody download successful: %s", melodyUid);
|
||||
return true;
|
||||
}
|
||||
|
||||
LOG_ERROR("Melody download failed: %s", filename);
|
||||
LOG_ERROR("Melody download failed: %s", melodyUid);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -46,6 +58,11 @@ bool FileManager::ensureDirectoryExists(const String& dirPath) {
|
||||
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
|
||||
return SD.mkdir(normalizedPath.c_str());
|
||||
}
|
||||
@@ -53,18 +70,49 @@ bool FileManager::ensureDirectoryExists(const String& dirPath) {
|
||||
bool FileManager::downloadFile(const String& url, const String& directory, const String& filename) {
|
||||
LOG_INFO("Starting download from: %s", url.c_str());
|
||||
|
||||
// Check if URL is HTTPS
|
||||
bool isHttps = url.startsWith("https://");
|
||||
|
||||
HTTPClient http;
|
||||
http.begin(url);
|
||||
WiFiClientSecure* secureClient = nullptr;
|
||||
|
||||
// Configure HTTP client based on protocol
|
||||
if (isHttps) {
|
||||
secureClient = new WiFiClientSecure();
|
||||
secureClient->setInsecure(); // Skip certificate validation for Firebase
|
||||
secureClient->setTimeout(15); // 15 second timeout for TLS operations
|
||||
http.begin(*secureClient, url);
|
||||
LOG_DEBUG("Using HTTPS with secure client");
|
||||
} else {
|
||||
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
|
||||
|
||||
// Disable task watchdog for current task during blocking HTTPS operation
|
||||
// The TLS handshake can take several seconds and would trigger watchdog
|
||||
LOG_DEBUG("Disabling watchdog for download...");
|
||||
esp_task_wdt_delete(NULL);
|
||||
|
||||
LOG_DEBUG("Sending HTTP GET request...");
|
||||
int httpCode = http.GET();
|
||||
|
||||
if (httpCode != HTTP_CODE_OK) {
|
||||
LOG_ERROR("HTTP GET failed, error: %s", http.errorToString(httpCode).c_str());
|
||||
// Re-enable task watchdog after HTTP request completes
|
||||
esp_task_wdt_add(NULL);
|
||||
LOG_DEBUG("Watchdog re-enabled after HTTP request");
|
||||
|
||||
if (httpCode != HTTP_CODE_OK && httpCode != HTTP_CODE_MOVED_PERMANENTLY && httpCode != HTTP_CODE_FOUND) {
|
||||
LOG_ERROR("HTTP GET failed, code: %d, error: %s", httpCode, http.errorToString(httpCode).c_str());
|
||||
http.end();
|
||||
if (secureClient) delete secureClient;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!initializeSD()) {
|
||||
http.end();
|
||||
if (secureClient) delete secureClient;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -72,6 +120,7 @@ bool FileManager::downloadFile(const String& url, const String& directory, const
|
||||
if (!ensureDirectoryExists(directory)) {
|
||||
LOG_ERROR("Failed to create directory: %s", directory.c_str());
|
||||
http.end();
|
||||
if (secureClient) delete secureClient;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -84,20 +133,65 @@ bool FileManager::downloadFile(const String& url, const String& directory, const
|
||||
if (!file) {
|
||||
LOG_ERROR("Failed to open file for writing: %s", fullPath.c_str());
|
||||
http.end();
|
||||
if (secureClient) delete secureClient;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get stream and file size
|
||||
WiFiClient* stream = http.getStreamPtr();
|
||||
uint8_t buffer[1024];
|
||||
int bytesRead;
|
||||
int contentLength = http.getSize();
|
||||
LOG_DEBUG("Content length: %d bytes", contentLength);
|
||||
|
||||
while (http.connected() && (bytesRead = stream->readBytes(buffer, sizeof(buffer))) > 0) {
|
||||
file.write(buffer, bytesRead);
|
||||
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);
|
||||
totalBytes += bytesRead;
|
||||
|
||||
// Log progress every 5 seconds
|
||||
if (millis() - lastLog > 5000) {
|
||||
LOG_DEBUG("Download progress: %u bytes", totalBytes);
|
||||
lastLog = millis();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Aggressive task yielding every 50ms to prevent watchdog timeout
|
||||
if (millis() - lastYield > 50) {
|
||||
yield();
|
||||
vTaskDelay(5 / portTICK_PERIOD_MS); // Let other tasks run (5ms)
|
||||
lastYield = millis();
|
||||
}
|
||||
|
||||
// Exit if no data and connection closed
|
||||
if (!availableSize && !http.connected()) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Yield and small delay if no data available yet
|
||||
if (!availableSize) {
|
||||
yield();
|
||||
vTaskDelay(10 / portTICK_PERIOD_MS);
|
||||
}
|
||||
}
|
||||
|
||||
file.close();
|
||||
http.end();
|
||||
LOG_INFO("Download complete, file saved to: %s", fullPath.c_str());
|
||||
if (secureClient) delete secureClient;
|
||||
LOG_INFO("Download complete, file saved to: %s (%u bytes)", fullPath.c_str(), totalBytes);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -175,6 +269,52 @@ size_t FileManager::getFileSize(const String& filePath) {
|
||||
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
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -22,7 +22,9 @@
|
||||
#include <SD.h>
|
||||
#include <HTTPClient.h>
|
||||
#include <WiFiClient.h>
|
||||
#include <WiFiClientSecure.h>
|
||||
#include <ArduinoJson.h>
|
||||
#include <esp_task_wdt.h>
|
||||
#include "../Logging/Logging.hpp"
|
||||
#include "../ConfigManager/ConfigManager.hpp"
|
||||
|
||||
@@ -45,6 +47,10 @@ public:
|
||||
bool createDirectory(const String& dirPath);
|
||||
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
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
#include "Logging.hpp"
|
||||
|
||||
// Initialize static member
|
||||
Logging::LogLevel Logging::currentLevel = Logging::VERBOSE; // Default to DEBUG
|
||||
// Initialize static members
|
||||
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) {
|
||||
currentLevel = level;
|
||||
@@ -12,13 +15,26 @@ Logging::LogLevel Logging::getLevel() {
|
||||
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) {
|
||||
return currentLevel >= level;
|
||||
}
|
||||
|
||||
void Logging::error(const char* format, ...) {
|
||||
if (!isLevelEnabled(ERROR)) return;
|
||||
|
||||
va_list args;
|
||||
va_start(args, format);
|
||||
log(ERROR, "🔴 EROR", format, args);
|
||||
@@ -26,8 +42,6 @@ void Logging::error(const char* format, ...) {
|
||||
}
|
||||
|
||||
void Logging::warning(const char* format, ...) {
|
||||
if (!isLevelEnabled(WARNING)) return;
|
||||
|
||||
va_list args;
|
||||
va_start(args, format);
|
||||
log(WARNING, "🟡 WARN", format, args);
|
||||
@@ -35,8 +49,6 @@ void Logging::warning(const char* format, ...) {
|
||||
}
|
||||
|
||||
void Logging::info(const char* format, ...) {
|
||||
if (!isLevelEnabled(INFO)) return;
|
||||
|
||||
va_list args;
|
||||
va_start(args, format);
|
||||
log(INFO, "🟢 INFO", format, args);
|
||||
@@ -44,8 +56,6 @@ void Logging::info(const char* format, ...) {
|
||||
}
|
||||
|
||||
void Logging::debug(const char* format, ...) {
|
||||
if (!isLevelEnabled(DEBUG)) return;
|
||||
|
||||
va_list args;
|
||||
va_start(args, format);
|
||||
log(DEBUG, "🐞 DEBG", format, args);
|
||||
@@ -53,8 +63,6 @@ void Logging::debug(const char* format, ...) {
|
||||
}
|
||||
|
||||
void Logging::verbose(const char* format, ...) {
|
||||
if (!isLevelEnabled(VERBOSE)) return;
|
||||
|
||||
va_list args;
|
||||
va_start(args, format);
|
||||
log(VERBOSE, "🧾 VERB", format, args);
|
||||
@@ -62,11 +70,73 @@ void Logging::verbose(const char* format, ...) {
|
||||
}
|
||||
|
||||
void Logging::log(LogLevel level, const char* levelStr, const char* format, va_list args) {
|
||||
Serial.printf("[%s] ", levelStr);
|
||||
// Check if ANY output needs this log level
|
||||
bool serialEnabled = (currentLevel >= level);
|
||||
bool mqttEnabled = (mqttLogLevel >= level && mqttPublishCallback);
|
||||
// bool sdEnabled = (sdLogLevel >= level && sdLogCallback); // Future: SD logging
|
||||
|
||||
// Print the formatted message
|
||||
// Early exit if no outputs need this message
|
||||
if (!serialEnabled && !mqttEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Format the message once (only if at least one output needs it)
|
||||
char buffer[512];
|
||||
vsnprintf(buffer, sizeof(buffer), format, args);
|
||||
Serial.print(buffer);
|
||||
Serial.println();
|
||||
|
||||
// Serial output (independent check)
|
||||
if (serialEnabled) {
|
||||
Serial.printf("[%s] ", levelStr);
|
||||
Serial.print(buffer);
|
||||
Serial.println();
|
||||
}
|
||||
|
||||
// MQTT output (independent check)
|
||||
if (mqttEnabled) {
|
||||
publishToMqtt(level, levelStr, buffer);
|
||||
}
|
||||
|
||||
// Future: SD logging would go here with its own independent check
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
// Forward declaration
|
||||
class MQTTAsyncClient;
|
||||
|
||||
class Logging {
|
||||
public:
|
||||
// Log Levels
|
||||
@@ -31,8 +34,14 @@ public:
|
||||
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:
|
||||
static LogLevel currentLevel;
|
||||
static LogLevel mqttLogLevel;
|
||||
static MqttPublishCallback mqttPublishCallback;
|
||||
static String mqttLogTopic;
|
||||
|
||||
public:
|
||||
// Set the active log level
|
||||
@@ -41,6 +50,15 @@ public:
|
||||
// Get current log level
|
||||
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
|
||||
static void error(const char* format, ...);
|
||||
static void warning(const char* format, ...);
|
||||
@@ -53,6 +71,7 @@ public:
|
||||
|
||||
private:
|
||||
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
|
||||
|
||||
@@ -64,63 +64,104 @@ void Networking::begin() {
|
||||
// Clear Previous Settings, USE once to test.
|
||||
//_wifiManager->resetSettings();
|
||||
|
||||
// Start Ethernet hardware
|
||||
auto& hwConfig = _configManager.getHardwareConfig();
|
||||
ETH.begin(hwConfig.ethPhyType, hwConfig.ethPhyAddr, hwConfig.ethPhyCs,
|
||||
hwConfig.ethPhyIrq, hwConfig.ethPhyRst, SPI);
|
||||
|
||||
// Start connection sequence
|
||||
LOG_INFO("Starting network connection sequence...");
|
||||
startEthernetConnection();
|
||||
}
|
||||
|
||||
void Networking::startEthernetConnection() {
|
||||
LOG_INFO("Attempting Ethernet connection...");
|
||||
setState(NetworkState::CONNECTING_ETHERNET);
|
||||
|
||||
// Check if Ethernet hardware initialization failed
|
||||
if (!ETH.linkUp()) {
|
||||
LOG_WARNING("Ethernet hardware not detected or failed to initialize");
|
||||
LOG_INFO("Falling back to WiFi immediately");
|
||||
startWiFiConnection();
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Ethernet will auto-connect via events
|
||||
// Set timeout for Ethernet attempt (5 seconds)
|
||||
_lastConnectionAttempt = millis();
|
||||
// ETHERNET DISABLED - WiFi only mode
|
||||
// Start Ethernet hardware
|
||||
// auto& hwConfig = _configManager.getHardwareConfig();
|
||||
// ETH.begin(hwConfig.ethPhyType, hwConfig.ethPhyAddr, hwConfig.ethPhyCs,
|
||||
// hwConfig.ethPhyIrq, hwConfig.ethPhyRst, SPI);
|
||||
|
||||
// Start reconnection timer to handle timeout
|
||||
xTimerStart(_reconnectionTimer, 0);
|
||||
// Start connection sequence - Skip Ethernet, go directly to WiFi
|
||||
LOG_INFO("Starting WiFi connection (Ethernet disabled)...");
|
||||
startWiFiConnection();
|
||||
}
|
||||
|
||||
void Networking::startEthernetConnection() {
|
||||
// ETHERNET DISABLED - Skip to WiFi immediately
|
||||
LOG_DEBUG("Ethernet connection disabled - falling back to WiFi");
|
||||
startWiFiConnection();
|
||||
|
||||
// Original Ethernet code (DISABLED):
|
||||
// LOG_INFO("Attempting Ethernet connection...");
|
||||
// setState(NetworkState::CONNECTING_ETHERNET);
|
||||
//
|
||||
// // Check if Ethernet hardware initialization failed
|
||||
// if (!ETH.linkUp()) {
|
||||
// LOG_WARNING("Ethernet hardware not detected or failed to initialize");
|
||||
// LOG_INFO("Falling back to WiFi immediately");
|
||||
// startWiFiConnection();
|
||||
// return;
|
||||
// }
|
||||
//
|
||||
// // Ethernet will auto-connect via events
|
||||
// // Set timeout for Ethernet attempt (5 seconds)
|
||||
// _lastConnectionAttempt = millis();
|
||||
//
|
||||
// // Start reconnection timer to handle timeout
|
||||
// xTimerStart(_reconnectionTimer, 0);
|
||||
}
|
||||
|
||||
void Networking::startWiFiConnection() {
|
||||
LOG_INFO("Attempting WiFi connection...");
|
||||
setState(NetworkState::CONNECTING_WIFI);
|
||||
|
||||
if (!hasValidWiFiCredentials()) {
|
||||
LOG_WARNING("No valid WiFi credentials found");
|
||||
if (!_bootSequenceComplete) {
|
||||
// No credentials during boot - start portal
|
||||
startWiFiPortal();
|
||||
}
|
||||
return;
|
||||
}
|
||||
// ALWAYS try default credentials first (for bundled router deployment)
|
||||
auto& netConfig = _configManager.getNetworkConfig();
|
||||
|
||||
// Get and log saved credentials (for debugging)
|
||||
String savedSSID = _wifiManager->getWiFiSSID(true);
|
||||
LOG_INFO("Using WiFiManager saved credentials - SSID: %s", savedSSID.c_str());
|
||||
LOG_INFO("Using DEFAULT WiFi credentials - SSID: %s", netConfig.defaultWifiSsid.c_str());
|
||||
|
||||
WiFi.mode(WIFI_STA);
|
||||
applyNetworkConfig(false); // false = WiFi config
|
||||
|
||||
// Let WiFiManager handle credentials (uses saved SSID/password)
|
||||
WiFi.begin();
|
||||
WiFi.mode(WIFI_STA);
|
||||
WiFi.begin(netConfig.defaultWifiSsid.c_str(), netConfig.defaultWifiPsk.c_str());
|
||||
|
||||
_lastConnectionAttempt = millis();
|
||||
|
||||
// Start reconnection timer to handle timeout
|
||||
xTimerStart(_reconnectionTimer, 0);
|
||||
|
||||
// Original WiFiManager fallback code (DISABLED for fixed deployment):
|
||||
// // First, try default credentials if this is the first boot attempt
|
||||
// if (!_bootSequenceComplete && !hasValidWiFiCredentials()) {
|
||||
// LOG_INFO("No saved credentials - trying default WiFi credentials");
|
||||
// auto& netConfig = _configManager.getNetworkConfig();
|
||||
//
|
||||
// applyNetworkConfig(false); // false = WiFi config
|
||||
// WiFi.mode(WIFI_STA);
|
||||
// WiFi.begin(netConfig.defaultWifiSsid.c_str(), netConfig.defaultWifiPsk.c_str());
|
||||
//
|
||||
// _lastConnectionAttempt = millis();
|
||||
// xTimerStart(_reconnectionTimer, 0);
|
||||
// return;
|
||||
// }
|
||||
//
|
||||
// // Check if we have valid saved credentials
|
||||
// if (!hasValidWiFiCredentials()) {
|
||||
// LOG_WARNING("No valid WiFi credentials found");
|
||||
// if (!_bootSequenceComplete) {
|
||||
// // No credentials during boot - start portal
|
||||
// startWiFiPortal();
|
||||
// }
|
||||
// return;
|
||||
// }
|
||||
//
|
||||
// // Get and log saved credentials (for debugging)
|
||||
// String savedSSID = _wifiManager->getWiFiSSID(true);
|
||||
// LOG_INFO("Using WiFiManager saved credentials - SSID: %s", savedSSID.c_str());
|
||||
//
|
||||
// applyNetworkConfig(false); // false = WiFi config
|
||||
// WiFi.mode(WIFI_STA);
|
||||
//
|
||||
// // Let WiFiManager handle credentials (uses saved SSID/password)
|
||||
// WiFi.begin();
|
||||
//
|
||||
// _lastConnectionAttempt = millis();
|
||||
//
|
||||
// // Start reconnection timer to handle timeout
|
||||
// xTimerStart(_reconnectionTimer, 0);
|
||||
}
|
||||
|
||||
void Networking::startWiFiPortal() {
|
||||
@@ -134,6 +175,18 @@ void Networking::startWiFiPortal() {
|
||||
|
||||
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())) {
|
||||
LOG_INFO("WiFi configured successfully via portal");
|
||||
onWiFiConnected();
|
||||
@@ -152,16 +205,17 @@ void Networking::handleReconnection() {
|
||||
|
||||
LOG_DEBUG("Attempting reconnection...");
|
||||
|
||||
// ETHERNET DISABLED - Skip Ethernet timeout checks
|
||||
// Check for Ethernet timeout (fall back to WiFi)
|
||||
if (_state == NetworkState::CONNECTING_ETHERNET) {
|
||||
unsigned long now = millis();
|
||||
if (now - _lastConnectionAttempt > 5000) { // 5 second timeout
|
||||
LOG_INFO("Ethernet connection timeout - falling back to WiFi");
|
||||
startWiFiConnection();
|
||||
return;
|
||||
}
|
||||
return; // Still waiting for Ethernet
|
||||
}
|
||||
// if (_state == NetworkState::CONNECTING_ETHERNET) {
|
||||
// unsigned long now = millis();
|
||||
// if (now - _lastConnectionAttempt > 5000) { // 5 second timeout
|
||||
// LOG_INFO("Ethernet connection timeout - falling back to WiFi");
|
||||
// startWiFiConnection();
|
||||
// return;
|
||||
// }
|
||||
// return; // Still waiting for Ethernet
|
||||
// }
|
||||
|
||||
// Check for WiFi timeout
|
||||
if (_state == NetworkState::CONNECTING_WIFI) {
|
||||
@@ -197,20 +251,15 @@ void Networking::handleReconnection() {
|
||||
return; // Still waiting for WiFi
|
||||
}
|
||||
|
||||
// State is DISCONNECTED - decide what to try
|
||||
if (_ethernetCableConnected) {
|
||||
LOG_INFO("Ethernet cable detected - trying Ethernet");
|
||||
startEthernetConnection();
|
||||
// State is DISCONNECTED - WiFi only mode (Ethernet disabled)
|
||||
LOG_INFO("Disconnected - trying WiFi");
|
||||
if (hasValidWiFiCredentials()) {
|
||||
startWiFiConnection();
|
||||
} else if (!_bootSequenceComplete) {
|
||||
// No credentials during boot - start portal
|
||||
startWiFiPortal();
|
||||
} else {
|
||||
LOG_INFO("No Ethernet - trying WiFi");
|
||||
if (hasValidWiFiCredentials()) {
|
||||
startWiFiConnection();
|
||||
} else if (!_bootSequenceComplete) {
|
||||
// No credentials during boot - start portal
|
||||
startWiFiPortal();
|
||||
} else {
|
||||
LOG_WARNING("No WiFi credentials and boot sequence complete - waiting");
|
||||
}
|
||||
LOG_WARNING("No WiFi credentials and boot sequence complete - waiting");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,8 +274,8 @@ bool Networking::isHealthy() const {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check connection state
|
||||
if (_state != NetworkState::CONNECTED_ETHERNET && _state != NetworkState::CONNECTED_WIFI) {
|
||||
// Check connection state (Ethernet disabled, only check WiFi or AP)
|
||||
if (_state != NetworkState::CONNECTED_WIFI && _state != NetworkState::AP_MODE_PERMANENT) {
|
||||
LOG_DEBUG("Networking: Unhealthy - Not in connected state");
|
||||
return false;
|
||||
}
|
||||
@@ -253,13 +302,14 @@ bool Networking::isHealthy() const {
|
||||
}
|
||||
}
|
||||
|
||||
// ETHERNET DISABLED - Removed Ethernet link check
|
||||
// For Ethernet connections, check link status
|
||||
if (_activeConnection == ConnectionType::ETHERNET) {
|
||||
if (!ETH.linkUp()) {
|
||||
LOG_DEBUG("Networking: Unhealthy - Ethernet link down");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// if (_activeConnection == ConnectionType::ETHERNET) {
|
||||
// if (!ETH.linkUp()) {
|
||||
// LOG_DEBUG("Networking: Unhealthy - Ethernet link down");
|
||||
// return false;
|
||||
// }
|
||||
// }
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -286,35 +336,43 @@ void Networking::notifyConnectionChange(bool connected) {
|
||||
}
|
||||
}
|
||||
|
||||
// Event handlers
|
||||
// Event handlers (ETHERNET DISABLED)
|
||||
void Networking::onEthernetConnected() {
|
||||
LOG_INFO("Ethernet connected successfully");
|
||||
setState(NetworkState::CONNECTED_ETHERNET);
|
||||
setActiveConnection(ConnectionType::ETHERNET);
|
||||
// ETHERNET DISABLED - This should never be called
|
||||
LOG_WARNING("Ethernet event received but Ethernet is disabled - ignoring");
|
||||
|
||||
// Stop WiFi if it was running
|
||||
if (WiFi.getMode() != WIFI_OFF) {
|
||||
WiFi.disconnect(true);
|
||||
WiFi.mode(WIFI_OFF);
|
||||
}
|
||||
|
||||
// Stop reconnection timer
|
||||
xTimerStop(_reconnectionTimer, 0);
|
||||
|
||||
notifyConnectionChange(true);
|
||||
// Original code (DISABLED):
|
||||
// LOG_INFO("Ethernet connected successfully");
|
||||
// setState(NetworkState::CONNECTED_ETHERNET);
|
||||
// setActiveConnection(ConnectionType::ETHERNET);
|
||||
//
|
||||
// // Stop WiFi if it was running
|
||||
// if (WiFi.getMode() != WIFI_OFF) {
|
||||
// WiFi.disconnect(true);
|
||||
// WiFi.mode(WIFI_OFF);
|
||||
// }
|
||||
//
|
||||
// // Stop reconnection timer
|
||||
// xTimerStop(_reconnectionTimer, 0);
|
||||
//
|
||||
// notifyConnectionChange(true);
|
||||
}
|
||||
|
||||
void Networking::onEthernetDisconnected() {
|
||||
LOG_WARNING("Ethernet disconnected");
|
||||
// ETHERNET DISABLED - This should never be called
|
||||
LOG_WARNING("Ethernet disconnect event received but Ethernet is disabled - ignoring");
|
||||
|
||||
if (_activeConnection == ConnectionType::ETHERNET) {
|
||||
setState(NetworkState::DISCONNECTED);
|
||||
setActiveConnection(ConnectionType::NONE);
|
||||
notifyConnectionChange(false);
|
||||
|
||||
// Start reconnection attempts
|
||||
xTimerStart(_reconnectionTimer, 0);
|
||||
}
|
||||
// Original code (DISABLED):
|
||||
// LOG_WARNING("Ethernet disconnected");
|
||||
//
|
||||
// if (_activeConnection == ConnectionType::ETHERNET) {
|
||||
// setState(NetworkState::DISCONNECTED);
|
||||
// setActiveConnection(ConnectionType::NONE);
|
||||
// notifyConnectionChange(false);
|
||||
//
|
||||
// // Start reconnection attempts
|
||||
// xTimerStart(_reconnectionTimer, 0);
|
||||
// }
|
||||
}
|
||||
|
||||
void Networking::onWiFiConnected() {
|
||||
@@ -348,35 +406,37 @@ void Networking::onWiFiDisconnected() {
|
||||
}
|
||||
|
||||
void Networking::onEthernetCableChange(bool connected) {
|
||||
_ethernetCableConnected = connected;
|
||||
LOG_INFO("Ethernet cable %s", connected ? "connected" : "disconnected");
|
||||
// ETHERNET DISABLED - Ignore cable events
|
||||
LOG_DEBUG("Ethernet cable event ignored (Ethernet disabled)");
|
||||
|
||||
if (connected && _activeConnection != ConnectionType::ETHERNET) {
|
||||
// Cable connected and we're not using Ethernet - try to connect
|
||||
startEthernetConnection();
|
||||
}
|
||||
// Original code (DISABLED):
|
||||
// _ethernetCableConnected = connected;
|
||||
// LOG_INFO("Ethernet cable %s", connected ? "connected" : "disconnected");
|
||||
//
|
||||
// if (connected && _activeConnection != ConnectionType::ETHERNET) {
|
||||
// // Cable connected and we're not using Ethernet - try to connect
|
||||
// startEthernetConnection();
|
||||
// }
|
||||
}
|
||||
|
||||
// Utility methods
|
||||
void Networking::applyNetworkConfig(bool ethernet) {
|
||||
auto& netConfig = _configManager.getNetworkConfig();
|
||||
|
||||
// ETHERNET DISABLED - Only apply WiFi config
|
||||
if (ethernet) {
|
||||
LOG_WARNING("applyNetworkConfig called with ethernet=true but Ethernet is disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
if (netConfig.useStaticIP) {
|
||||
LOG_INFO("Applying static IP configuration");
|
||||
if (ethernet) {
|
||||
ETH.config(netConfig.ip, netConfig.gateway, netConfig.subnet, netConfig.dns1, netConfig.dns2);
|
||||
} else {
|
||||
WiFi.config(netConfig.ip, netConfig.gateway, netConfig.subnet, netConfig.dns1, netConfig.dns2);
|
||||
}
|
||||
WiFi.config(netConfig.ip, netConfig.gateway, netConfig.subnet, netConfig.dns1, netConfig.dns2);
|
||||
} else {
|
||||
LOG_INFO("Using DHCP configuration");
|
||||
}
|
||||
|
||||
if (ethernet) {
|
||||
ETH.setHostname(netConfig.hostname.c_str());
|
||||
} else {
|
||||
WiFi.setHostname(netConfig.hostname.c_str());
|
||||
}
|
||||
WiFi.setHostname(netConfig.hostname.c_str());
|
||||
}
|
||||
|
||||
bool Networking::hasValidWiFiCredentials() {
|
||||
@@ -394,9 +454,26 @@ bool Networking::isConnected() const {
|
||||
String Networking::getLocalIP() const {
|
||||
switch (_activeConnection) {
|
||||
case ConnectionType::ETHERNET:
|
||||
return ETH.localIP().toString();
|
||||
// ETHERNET DISABLED - Should never reach here
|
||||
LOG_WARNING("getLocalIP called with ETHERNET type but Ethernet is disabled");
|
||||
return "0.0.0.0";
|
||||
case ConnectionType::WIFI:
|
||||
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:
|
||||
// ETHERNET DISABLED - Should never reach here
|
||||
LOG_WARNING("getGateway called with ETHERNET type but Ethernet is disabled");
|
||||
return "0.0.0.0";
|
||||
case ConnectionType::WIFI:
|
||||
return WiFi.gatewayIP().toString();
|
||||
default:
|
||||
return "0.0.0.0";
|
||||
}
|
||||
@@ -413,9 +490,9 @@ void Networking::forceReconnect() {
|
||||
WiFi.mode(WIFI_OFF);
|
||||
}
|
||||
|
||||
// Restart connection sequence
|
||||
// Restart connection sequence - WiFi only (Ethernet disabled)
|
||||
delay(1000);
|
||||
startEthernetConnection();
|
||||
startWiFiConnection();
|
||||
}
|
||||
|
||||
// Static callbacks
|
||||
@@ -425,32 +502,16 @@ void Networking::networkEventHandler(arduino_event_id_t event, arduino_event_inf
|
||||
LOG_DEBUG("Network event: %d", event);
|
||||
|
||||
switch (event) {
|
||||
// ETHERNET EVENTS DISABLED - Ignored
|
||||
case ARDUINO_EVENT_ETH_START:
|
||||
LOG_DEBUG("ETH Started");
|
||||
break;
|
||||
|
||||
case ARDUINO_EVENT_ETH_CONNECTED:
|
||||
LOG_DEBUG("ETH Cable Connected");
|
||||
_instance->onEthernetCableChange(true);
|
||||
break;
|
||||
|
||||
case ARDUINO_EVENT_ETH_GOT_IP:
|
||||
LOG_INFO("ETH Got IP: %s", ETH.localIP().toString().c_str());
|
||||
_instance->applyNetworkConfig(true);
|
||||
_instance->onEthernetConnected();
|
||||
break;
|
||||
|
||||
case ARDUINO_EVENT_ETH_DISCONNECTED:
|
||||
LOG_WARNING("ETH Cable Disconnected");
|
||||
_instance->onEthernetCableChange(false);
|
||||
_instance->onEthernetDisconnected();
|
||||
break;
|
||||
|
||||
case ARDUINO_EVENT_ETH_STOP:
|
||||
LOG_INFO("ETH Stopped");
|
||||
_instance->onEthernetDisconnected();
|
||||
LOG_DEBUG("Ethernet event ignored (Ethernet disabled)");
|
||||
break;
|
||||
|
||||
// WiFi events (ACTIVE)
|
||||
case ARDUINO_EVENT_WIFI_STA_GOT_IP:
|
||||
LOG_INFO("WiFi Got IP: %s", WiFi.localIP().toString().c_str());
|
||||
_instance->onWiFiConnected();
|
||||
@@ -475,3 +536,62 @@ void Networking::reconnectionTimerCallback(TimerHandle_t xTimer) {
|
||||
_instance->handleReconnection();
|
||||
}
|
||||
}
|
||||
|
||||
void Networking::startPermanentAPMode() {
|
||||
LOG_INFO("Starting Permanent AP Mode");
|
||||
setState(NetworkState::AP_MODE_PERMANENT);
|
||||
|
||||
// Stop any existing connections
|
||||
WiFi.disconnect(true);
|
||||
WiFi.mode(WIFI_OFF);
|
||||
delay(100);
|
||||
|
||||
// Configure AP mode with fixed 192.168.4.1 IP
|
||||
WiFi.mode(WIFI_AP);
|
||||
|
||||
auto& netConfig = _configManager.getNetworkConfig();
|
||||
String apName = netConfig.apSsid;
|
||||
String apPass = netConfig.apPass;
|
||||
|
||||
// Configure AP with fixed IP: 192.168.4.1
|
||||
IPAddress local_IP(192, 168, 4, 1);
|
||||
IPAddress gateway(192, 168, 4, 1);
|
||||
IPAddress subnet(255, 255, 255, 0);
|
||||
|
||||
if (!WiFi.softAPConfig(local_IP, gateway, subnet)) {
|
||||
LOG_ERROR("Failed to configure AP IP address");
|
||||
}
|
||||
|
||||
// Start AP
|
||||
bool apStarted;
|
||||
if (apPass.isEmpty()) {
|
||||
apStarted = WiFi.softAP(apName.c_str());
|
||||
LOG_INFO("Starting open AP (no password): %s", apName.c_str());
|
||||
} else {
|
||||
apStarted = WiFi.softAP(apName.c_str(), apPass.c_str());
|
||||
LOG_INFO("Starting AP with password: %s", apName.c_str());
|
||||
}
|
||||
|
||||
if (apStarted) {
|
||||
LOG_INFO("✅ Permanent AP Mode active");
|
||||
LOG_INFO(" SSID: %s", apName.c_str());
|
||||
LOG_INFO(" IP: 192.168.4.1");
|
||||
LOG_INFO(" Subnet: 255.255.255.0");
|
||||
|
||||
setActiveConnection(ConnectionType::AP);
|
||||
|
||||
// Stop reconnection timer (not needed in permanent AP mode)
|
||||
if (_reconnectionTimer) {
|
||||
xTimerStop(_reconnectionTimer, 0);
|
||||
}
|
||||
|
||||
// Mark boot sequence as complete
|
||||
_bootSequenceComplete = true;
|
||||
|
||||
// Notify connected
|
||||
notifyConnectionChange(true);
|
||||
} else {
|
||||
LOG_ERROR("❌ Failed to start AP Mode");
|
||||
setState(NetworkState::DISCONNECTED);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,13 +66,15 @@ enum class NetworkState {
|
||||
WIFI_PORTAL_MODE,
|
||||
CONNECTED_ETHERNET,
|
||||
CONNECTED_WIFI,
|
||||
RECONNECTING
|
||||
RECONNECTING,
|
||||
AP_MODE_PERMANENT
|
||||
};
|
||||
|
||||
enum class ConnectionType {
|
||||
NONE,
|
||||
ETHERNET,
|
||||
WIFI
|
||||
WIFI,
|
||||
AP
|
||||
};
|
||||
|
||||
class Networking {
|
||||
@@ -85,6 +87,7 @@ public:
|
||||
// Returns whether the network is currently connected
|
||||
bool isConnected() const;
|
||||
String getLocalIP() const;
|
||||
String getGateway() const;
|
||||
ConnectionType getActiveConnection() const { return _activeConnection; }
|
||||
NetworkState getState() const { return _state; }
|
||||
|
||||
@@ -97,6 +100,10 @@ public:
|
||||
// Manual connection control (for testing/debugging)
|
||||
void forceReconnect();
|
||||
|
||||
// AP Mode control
|
||||
void startPermanentAPMode();
|
||||
bool isInAPMode() const { return _state == NetworkState::AP_MODE_PERMANENT; }
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// HEALTH CHECK METHOD
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -2,13 +2,17 @@
|
||||
#include "../ConfigManager/ConfigManager.hpp"
|
||||
#include "../Logging/Logging.hpp"
|
||||
#include "../Player/Player.hpp"
|
||||
#include "../SDCardMutex/SDCardMutex.hpp"
|
||||
#include <nvs_flash.h>
|
||||
#include <nvs.h>
|
||||
#include <esp_task_wdt.h>
|
||||
|
||||
OTAManager::OTAManager(ConfigManager& configManager)
|
||||
: _configManager(configManager)
|
||||
, _fileManager(nullptr)
|
||||
, _player(nullptr)
|
||||
, _timeKeeper(nullptr)
|
||||
, _telemetry(nullptr)
|
||||
, _status(Status::IDLE)
|
||||
, _lastError(ErrorCode::NONE)
|
||||
, _availableVersion(0.0f)
|
||||
@@ -21,21 +25,67 @@ OTAManager::OTAManager(ConfigManager& configManager)
|
||||
, _isEmergency(false)
|
||||
, _progressCallback(nullptr)
|
||||
, _statusCallback(nullptr)
|
||||
, _scheduledCheckTimer(NULL) {
|
||||
, _scheduledCheckTimer(NULL)
|
||||
, _initialCheckTimer(NULL)
|
||||
, _otaWorkerTask(NULL)
|
||||
, _otaWorkSignal(NULL)
|
||||
, _pendingWork(OTAWorkType::NONE) {
|
||||
}
|
||||
|
||||
OTAManager::~OTAManager() {
|
||||
// Clean up worker task and semaphore
|
||||
if (_otaWorkerTask != NULL) {
|
||||
vTaskDelete(_otaWorkerTask);
|
||||
_otaWorkerTask = NULL;
|
||||
}
|
||||
if (_otaWorkSignal != NULL) {
|
||||
vSemaphoreDelete(_otaWorkSignal);
|
||||
_otaWorkSignal = NULL;
|
||||
}
|
||||
|
||||
if (_scheduledCheckTimer != NULL) {
|
||||
xTimerStop(_scheduledCheckTimer, 0);
|
||||
xTimerDelete(_scheduledCheckTimer, portMAX_DELAY);
|
||||
_scheduledCheckTimer = NULL;
|
||||
}
|
||||
if (_initialCheckTimer != NULL) {
|
||||
xTimerStop(_initialCheckTimer, 0);
|
||||
xTimerDelete(_initialCheckTimer, portMAX_DELAY);
|
||||
_initialCheckTimer = NULL;
|
||||
}
|
||||
}
|
||||
|
||||
void OTAManager::begin() {
|
||||
LOG_INFO("OTA Manager initialized");
|
||||
setStatus(Status::IDLE);
|
||||
|
||||
// Create semaphore for worker task signaling
|
||||
_otaWorkSignal = xSemaphoreCreateBinary();
|
||||
if (_otaWorkSignal == NULL) {
|
||||
LOG_ERROR("Failed to create OTA work semaphore!");
|
||||
return;
|
||||
}
|
||||
|
||||
// Create dedicated worker task with 8KB stack (prevents timer task overflow)
|
||||
BaseType_t taskCreated = xTaskCreatePinnedToCore(
|
||||
otaWorkerTaskFunction,
|
||||
"OTA_Worker",
|
||||
8192, // 8KB stack - plenty for HTTP and JSON operations
|
||||
this, // Pass OTAManager instance
|
||||
2, // Priority 2 (lower than critical tasks)
|
||||
&_otaWorkerTask,
|
||||
0 // Core 0
|
||||
);
|
||||
|
||||
if (taskCreated != pdPASS) {
|
||||
LOG_ERROR("Failed to create OTA worker task!");
|
||||
vSemaphoreDelete(_otaWorkSignal);
|
||||
_otaWorkSignal = NULL;
|
||||
return;
|
||||
}
|
||||
|
||||
LOG_INFO("OTA worker task created with 8KB stack on Core 0");
|
||||
|
||||
// Create timer for scheduled checks (checks every minute if it's 3:00 AM)
|
||||
_scheduledCheckTimer = xTimerCreate(
|
||||
"OTA_Schedule",
|
||||
@@ -51,6 +101,23 @@ void OTAManager::begin() {
|
||||
} else {
|
||||
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) {
|
||||
@@ -61,7 +128,35 @@ void OTAManager::setPlayer(Player* player) {
|
||||
_player = player;
|
||||
}
|
||||
|
||||
void OTAManager::setTimeKeeper(Timekeeper* tk) {
|
||||
_timeKeeper = tk;
|
||||
}
|
||||
|
||||
void OTAManager::setTelemetry(Telemetry* telemetry) {
|
||||
_telemetry = telemetry;
|
||||
}
|
||||
|
||||
// ✅ NEW: Static timer callback for initial boot check
|
||||
// CRITICAL: Timer callbacks run in Timer Service task with limited stack!
|
||||
// We ONLY set a flag here - actual work is done by dedicated worker task
|
||||
void OTAManager::initialCheckCallback(TimerHandle_t xTimer) {
|
||||
OTAManager* ota = static_cast<OTAManager*>(pvTimerGetTimerID(xTimer));
|
||||
if (ota && ota->_otaWorkSignal) {
|
||||
// Signal worker task to perform initial check
|
||||
ota->_pendingWork = OTAWorkType::INITIAL_CHECK;
|
||||
xSemaphoreGive(ota->_otaWorkSignal);
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ NEW: Perform initial OTA check (async, non-blocking)
|
||||
void OTAManager::performInitialCheck() {
|
||||
// This runs asynchronously in worker task, won't block WebSocket/UDP/MQTT
|
||||
checkForUpdates();
|
||||
}
|
||||
|
||||
// ✅ NEW: Static timer callback for scheduled checks
|
||||
// CRITICAL: Timer callbacks run in Timer Service task with limited stack!
|
||||
// We ONLY set a flag here - actual work is done by dedicated worker task
|
||||
void OTAManager::scheduledCheckCallback(TimerHandle_t xTimer) {
|
||||
OTAManager* ota = static_cast<OTAManager*>(pvTimerGetTimerID(xTimer));
|
||||
|
||||
@@ -71,13 +166,12 @@ void OTAManager::scheduledCheckCallback(TimerHandle_t xTimer) {
|
||||
|
||||
// Only proceed if it's exactly 3:00 AM
|
||||
if (timeinfo->tm_hour == 3 && timeinfo->tm_min == 0) {
|
||||
LOG_INFO("🕒 3:00 AM - Running scheduled OTA check");
|
||||
|
||||
// Check if player is idle before proceeding
|
||||
if (!ota->isPlayerActive()) {
|
||||
LOG_INFO("✅ Player is idle - checking for emergency updates");
|
||||
ota->checkForEmergencyUpdates();
|
||||
} else {
|
||||
// Check if player is idle before signaling worker
|
||||
if (!ota->isPlayerActive() && ota->_otaWorkSignal) {
|
||||
// Signal worker task to perform scheduled check
|
||||
ota->_pendingWork = OTAWorkType::SCHEDULED_CHECK;
|
||||
xSemaphoreGive(ota->_otaWorkSignal);
|
||||
} else if (ota->isPlayerActive()) {
|
||||
LOG_WARNING("⚠️ Player is active - skipping scheduled update check");
|
||||
}
|
||||
}
|
||||
@@ -126,8 +220,8 @@ void OTAManager::checkForUpdates(const String& channel) {
|
||||
channel.c_str(), _configManager.getHardwareVariant().c_str());
|
||||
|
||||
if (checkVersion(channel)) {
|
||||
float currentVersion = getCurrentVersion();
|
||||
LOG_INFO("Current version: %.1f, Available version: %.1f (Channel: %s)",
|
||||
uint16_t currentVersion = getCurrentVersion();
|
||||
LOG_INFO("Current version: %u, Available version: %u (Channel: %s)",
|
||||
currentVersion, _availableVersion, channel.c_str());
|
||||
|
||||
if (_availableVersion > currentVersion) {
|
||||
@@ -180,9 +274,10 @@ void OTAManager::update(const String& channel) {
|
||||
}
|
||||
}
|
||||
|
||||
float OTAManager::getCurrentVersion() const {
|
||||
uint16_t OTAManager::getCurrentVersion() const {
|
||||
String fwVersionStr = _configManager.getFwVersion();
|
||||
return fwVersionStr.toFloat();
|
||||
// Parse integer directly: "130" -> 130
|
||||
return fwVersionStr.toInt();
|
||||
}
|
||||
|
||||
void OTAManager::setStatus(Status status, ErrorCode error) {
|
||||
@@ -243,14 +338,14 @@ bool OTAManager::checkVersion(const String& channel) {
|
||||
continue; // Try next server
|
||||
}
|
||||
|
||||
// Extract metadata
|
||||
_availableVersion = doc["version"].as<float>();
|
||||
// Extract metadata - all integers now
|
||||
_availableVersion = doc["version"].as<uint16_t>();
|
||||
_availableChecksum = doc["checksum"].as<String>();
|
||||
_updateChannel = doc["channel"].as<String>();
|
||||
_isMandatory = doc["mandatory"].as<bool>();
|
||||
_isEmergency = doc["emergency"].as<bool>();
|
||||
_minVersion = doc["minVersion"].as<float>(); // ✅ NEW
|
||||
_expectedFileSize = doc["fileSize"].as<size_t>(); // ✅ NEW
|
||||
_minVersion = doc["minVersion"].as<uint16_t>();
|
||||
_expectedFileSize = doc["fileSize"].as<size_t>();
|
||||
|
||||
// ✅ NEW: Validate channel matches requested
|
||||
if (_updateChannel != channel) {
|
||||
@@ -270,16 +365,16 @@ bool OTAManager::checkVersion(const String& channel) {
|
||||
}
|
||||
|
||||
// ✅ NEW: Check minVersion compatibility
|
||||
float currentVersion = getCurrentVersion();
|
||||
if (_minVersion > 0.0f && currentVersion < _minVersion) {
|
||||
LOG_ERROR("OTA: Current version %.1f is below minimum required %.1f",
|
||||
uint16_t currentVersion = getCurrentVersion();
|
||||
if (_minVersion > 0 && currentVersion < _minVersion) {
|
||||
LOG_ERROR("OTA: Current version %u is below minimum required %u",
|
||||
currentVersion, _minVersion);
|
||||
LOG_ERROR("OTA: Intermediate update required first - cannot proceed");
|
||||
_lastError = ErrorCode::VERSION_TOO_LOW;
|
||||
continue; // Try next server
|
||||
}
|
||||
|
||||
if (_availableVersion == 0.0f) {
|
||||
if (_availableVersion == 0) {
|
||||
LOG_ERROR("OTA: Invalid version in metadata from %s", baseUrl.c_str());
|
||||
continue; // Try next server
|
||||
}
|
||||
@@ -290,7 +385,7 @@ bool OTAManager::checkVersion(const String& channel) {
|
||||
}
|
||||
|
||||
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);
|
||||
return true; // Success!
|
||||
} else {
|
||||
@@ -317,9 +412,10 @@ bool OTAManager::downloadAndInstall(const String& channel) {
|
||||
LOG_INFO("OTA: Trying firmware download from server %d/%d: %s",
|
||||
serverIndex + 1, servers.size(), baseUrl.c_str());
|
||||
|
||||
if (downloadToSD(firmwareUrl, _availableChecksum, _expectedFileSize)) {
|
||||
// Success! Now install from SD
|
||||
return installFromSD("/firmware/staged_update.bin");
|
||||
// 🔥 Download directly to flash (bypassing problematic SD card writes)
|
||||
if (downloadDirectToFlash(firmwareUrl, _expectedFileSize)) {
|
||||
LOG_INFO("✅ OTA update successful!");
|
||||
return true;
|
||||
} else {
|
||||
LOG_WARNING("OTA: Firmware download failed from %s, trying next server", baseUrl.c_str());
|
||||
}
|
||||
@@ -330,6 +426,173 @@ bool OTAManager::downloadAndInstall(const String& channel) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 🔥 NEW: Download directly to flash memory, bypassing SD card
|
||||
bool OTAManager::downloadDirectToFlash(const String& url, size_t expectedSize) {
|
||||
LOG_INFO("OTA: Starting direct-to-flash download (bypassing SD card)");
|
||||
|
||||
HTTPClient http;
|
||||
http.begin(url.c_str());
|
||||
http.setTimeout(30000); // 30 seconds
|
||||
|
||||
int httpCode = http.GET();
|
||||
|
||||
if (httpCode != HTTP_CODE_OK) {
|
||||
LOG_ERROR("Download HTTP error code: %d", httpCode);
|
||||
setStatus(Status::FAILED, ErrorCode::HTTP_ERROR);
|
||||
http.end();
|
||||
return false;
|
||||
}
|
||||
|
||||
int contentLength = http.getSize();
|
||||
|
||||
LOG_INFO("OTA: HTTP Response Code: %d", httpCode);
|
||||
LOG_INFO("OTA: Content-Length: %d bytes", contentLength);
|
||||
|
||||
if (contentLength <= 0) {
|
||||
LOG_ERROR("Invalid content length");
|
||||
setStatus(Status::FAILED, ErrorCode::DOWNLOAD_FAILED);
|
||||
http.end();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate file size
|
||||
if (expectedSize > 0 && (size_t)contentLength != expectedSize) {
|
||||
LOG_ERROR("OTA: File size mismatch! Expected: %u, Got: %d", expectedSize, contentLength);
|
||||
setStatus(Status::FAILED, ErrorCode::SIZE_MISMATCH);
|
||||
http.end();
|
||||
return false;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// ENTER OTA FREEZE MODE - Pause all non-critical systems
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
LOG_INFO("OTA: Entering freeze mode - pausing TimeKeeper and Telemetry");
|
||||
|
||||
if (_timeKeeper) {
|
||||
_timeKeeper->pauseClockUpdates();
|
||||
}
|
||||
|
||||
if (_telemetry) {
|
||||
_telemetry->pause();
|
||||
}
|
||||
|
||||
// Begin OTA update to flash with MD5 validation enabled
|
||||
if (!Update.begin(contentLength)) {
|
||||
LOG_ERROR("Not enough space to begin OTA update");
|
||||
setStatus(Status::FAILED, ErrorCode::INSUFFICIENT_SPACE);
|
||||
http.end();
|
||||
|
||||
// Resume systems
|
||||
if (_timeKeeper) _timeKeeper->resumeClockUpdates();
|
||||
if (_telemetry) _telemetry->resume();
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
LOG_INFO("OTA: Update partition ready, starting stream write...");
|
||||
LOG_INFO("OTA: Checksum validation will be performed by ESP32 bootloader");
|
||||
setStatus(Status::INSTALLING);
|
||||
|
||||
// Stream directly to flash with periodic watchdog feeding
|
||||
WiFiClient* stream = http.getStreamPtr();
|
||||
uint8_t buffer[4096]; // 4KB buffer for efficient transfer
|
||||
size_t written = 0;
|
||||
size_t lastLoggedPercent = 0;
|
||||
unsigned long lastWatchdogReset = millis();
|
||||
|
||||
while (http.connected() && written < (size_t)contentLength) {
|
||||
size_t available = stream->available();
|
||||
if (available) {
|
||||
size_t toRead = min(available, sizeof(buffer));
|
||||
size_t bytesRead = stream->readBytes(buffer, toRead);
|
||||
|
||||
if (bytesRead > 0) {
|
||||
// Write to flash
|
||||
size_t bytesWritten = Update.write(buffer, bytesRead);
|
||||
|
||||
if (bytesWritten != bytesRead) {
|
||||
LOG_ERROR("OTA: Flash write failed at offset %u (%u/%u bytes written)",
|
||||
written, bytesWritten, bytesRead);
|
||||
http.end();
|
||||
|
||||
// Resume systems
|
||||
if (_timeKeeper) _timeKeeper->resumeClockUpdates();
|
||||
if (_telemetry) _telemetry->resume();
|
||||
|
||||
setStatus(Status::FAILED, ErrorCode::WRITE_FAILED);
|
||||
return false;
|
||||
}
|
||||
|
||||
written += bytesWritten;
|
||||
|
||||
// Log progress every 20%
|
||||
size_t currentPercent = (written * 100) / contentLength;
|
||||
if (currentPercent >= lastLoggedPercent + 20) {
|
||||
LOG_INFO("OTA: Flash write progress: %u%% (%u/%u bytes)",
|
||||
currentPercent, written, contentLength);
|
||||
lastLoggedPercent = currentPercent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Feed watchdog every 500ms to prevent timeout
|
||||
if (millis() - lastWatchdogReset > 500) {
|
||||
esp_task_wdt_reset();
|
||||
lastWatchdogReset = millis();
|
||||
}
|
||||
|
||||
// Small yield to prevent tight loop
|
||||
yield();
|
||||
}
|
||||
|
||||
http.end();
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// EXIT OTA FREEZE MODE - Resume all paused systems
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
LOG_INFO("OTA: Exiting freeze mode - resuming TimeKeeper and Telemetry");
|
||||
|
||||
if (_timeKeeper) {
|
||||
_timeKeeper->resumeClockUpdates();
|
||||
}
|
||||
|
||||
if (_telemetry) {
|
||||
_telemetry->resume();
|
||||
}
|
||||
|
||||
if (written == (size_t)contentLength) {
|
||||
LOG_INFO("OTA: Successfully written %u bytes to flash", written);
|
||||
} else {
|
||||
LOG_ERROR("OTA: Written only %u/%d bytes", written, contentLength);
|
||||
setStatus(Status::FAILED, ErrorCode::WRITE_FAILED);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Update.end(true)) { // true = set new boot partition
|
||||
LOG_INFO("OTA: Update complete!");
|
||||
if (Update.isFinished()) {
|
||||
setStatus(Status::SUCCESS);
|
||||
LOG_INFO("OTA: Firmware update successful. Rebooting...");
|
||||
|
||||
// Update version in config
|
||||
_configManager.setFwVersion(String(_availableVersion));
|
||||
_configManager.saveDeviceConfig();
|
||||
|
||||
delay(1000);
|
||||
ESP.restart();
|
||||
return true;
|
||||
} else {
|
||||
LOG_ERROR("OTA: Update not finished");
|
||||
setStatus(Status::FAILED, ErrorCode::VERIFICATION_FAILED);
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
LOG_ERROR("OTA: Update error: %s", Update.errorString());
|
||||
setStatus(Status::FAILED, ErrorCode::VERIFICATION_FAILED);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool OTAManager::downloadToSD(const String& url, const String& expectedChecksum, size_t expectedSize) {
|
||||
if (!_fileManager) {
|
||||
LOG_ERROR("FileManager not set!");
|
||||
@@ -345,6 +608,10 @@ bool OTAManager::downloadToSD(const String& url, const String& expectedChecksum,
|
||||
|
||||
HTTPClient http;
|
||||
http.begin(url.c_str());
|
||||
|
||||
// Set timeout to prevent hanging
|
||||
http.setTimeout(30000); // 30 seconds
|
||||
|
||||
int httpCode = http.GET();
|
||||
|
||||
if (httpCode != HTTP_CODE_OK) {
|
||||
@@ -355,6 +622,10 @@ bool OTAManager::downloadToSD(const String& url, const String& expectedChecksum,
|
||||
}
|
||||
|
||||
int contentLength = http.getSize();
|
||||
|
||||
// Log HTTP response headers for debugging
|
||||
LOG_INFO("OTA: HTTP Response Code: %d", httpCode);
|
||||
LOG_INFO("OTA: Content-Length header: %d bytes", contentLength);
|
||||
if (contentLength <= 0) {
|
||||
LOG_ERROR("Invalid content length");
|
||||
setStatus(Status::FAILED, ErrorCode::DOWNLOAD_FAILED);
|
||||
@@ -380,55 +651,142 @@ bool OTAManager::downloadToSD(const String& url, const String& expectedChecksum,
|
||||
|
||||
LOG_INFO("OTA: Starting download of %d bytes...", contentLength);
|
||||
|
||||
// Open file for writing
|
||||
File file = SD.open(tempPath.c_str(), FILE_WRITE);
|
||||
if (!file) {
|
||||
LOG_ERROR("Failed to create temporary update file");
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// ENTER OTA FREEZE MODE - Pause all non-critical systems to prevent SD contention
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
LOG_INFO("OTA: Entering freeze mode - pausing TimeKeeper and Telemetry");
|
||||
|
||||
if (_timeKeeper) {
|
||||
_timeKeeper->pauseClockUpdates();
|
||||
}
|
||||
|
||||
if (_telemetry) {
|
||||
_telemetry->pause();
|
||||
}
|
||||
|
||||
// 🔒 ACQUIRE SD CARD MUTEX - Prevents concurrent SD access
|
||||
LOG_INFO("OTA: Acquiring SD card mutex for safe file operations");
|
||||
if (!SDCardMutex::getInstance().lock(10000)) { // 10 second timeout
|
||||
LOG_ERROR("OTA: Failed to acquire SD card mutex!");
|
||||
if (_timeKeeper) _timeKeeper->resumeClockUpdates();
|
||||
if (_telemetry) _telemetry->resume();
|
||||
setStatus(Status::FAILED, ErrorCode::DOWNLOAD_FAILED);
|
||||
http.end();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Delete file if it exists, before opening
|
||||
if (SD.exists(tempPath.c_str())) {
|
||||
LOG_INFO("OTA: Removing existing staged update file");
|
||||
if (!SD.remove(tempPath.c_str())) {
|
||||
LOG_ERROR("OTA: Failed to remove existing file!");
|
||||
}
|
||||
delay(200); // Give SD card time to complete deletion
|
||||
}
|
||||
|
||||
// Open file for writing
|
||||
File file = SD.open(tempPath.c_str(), FILE_WRITE);
|
||||
if (!file) {
|
||||
LOG_ERROR("Failed to create temporary update file");
|
||||
|
||||
// Release mutex and resume systems before returning
|
||||
SDCardMutex::getInstance().unlock();
|
||||
if (_timeKeeper) _timeKeeper->resumeClockUpdates();
|
||||
if (_telemetry) _telemetry->resume();
|
||||
|
||||
setStatus(Status::FAILED, ErrorCode::DOWNLOAD_FAILED);
|
||||
http.end();
|
||||
return false;
|
||||
}
|
||||
|
||||
LOG_INFO("OTA: File opened successfully for writing (mutex locked)");
|
||||
|
||||
WiFiClient* stream = http.getStreamPtr();
|
||||
uint8_t buffer[1024];
|
||||
uint8_t buffer[4096]; // ✅ Increased to 4KB for better performance
|
||||
size_t written = 0;
|
||||
size_t lastLoggedPercent = 0;
|
||||
unsigned long lastYield = millis();
|
||||
int loopsWithoutData = 0;
|
||||
|
||||
while (http.connected() && written < (size_t)contentLength) {
|
||||
size_t available = stream->available();
|
||||
if (available) {
|
||||
loopsWithoutData = 0; // Reset counter when we have data
|
||||
size_t toRead = min(available, sizeof(buffer));
|
||||
size_t bytesRead = stream->readBytes(buffer, toRead);
|
||||
|
||||
if (bytesRead > 0) {
|
||||
// Write to SD card
|
||||
size_t bytesWritten = file.write(buffer, bytesRead);
|
||||
|
||||
// Check if write succeeded
|
||||
if (bytesWritten != bytesRead) {
|
||||
LOG_ERROR("SD write failed");
|
||||
LOG_ERROR("SD write failed at offset %u (%u/%u bytes written)", written, bytesWritten, bytesRead);
|
||||
file.close();
|
||||
SDCardMutex::getInstance().unlock();
|
||||
http.end();
|
||||
|
||||
if (_timeKeeper) _timeKeeper->resumeClockUpdates();
|
||||
if (_telemetry) _telemetry->resume();
|
||||
|
||||
setStatus(Status::FAILED, ErrorCode::WRITE_FAILED);
|
||||
return false;
|
||||
}
|
||||
|
||||
written += bytesWritten;
|
||||
|
||||
// ✅ IMPROVED: Progress reporting with percentage
|
||||
// Progress reporting
|
||||
notifyProgress(written, contentLength);
|
||||
|
||||
// Log progress every 10%
|
||||
// Log progress every 20%
|
||||
size_t currentPercent = (written * 100) / contentLength;
|
||||
if (currentPercent >= lastLoggedPercent + 10) {
|
||||
if (currentPercent >= lastLoggedPercent + 20) {
|
||||
LOG_INFO("OTA: Download progress: %u%% (%u/%u bytes)",
|
||||
currentPercent, written, contentLength);
|
||||
lastLoggedPercent = currentPercent;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No data available - yield to prevent tight loop
|
||||
loopsWithoutData++;
|
||||
if (loopsWithoutData > 10) {
|
||||
// If we've waited a while with no data, give longer yield
|
||||
vTaskDelay(pdMS_TO_TICKS(1)); // 1ms delay
|
||||
loopsWithoutData = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 🐕 Yield every 100ms to allow other tasks (including IDLE) to run
|
||||
if (millis() - lastYield > 100) {
|
||||
vTaskDelay(pdMS_TO_TICKS(1)); // Just 1ms is enough
|
||||
lastYield = millis();
|
||||
}
|
||||
yield();
|
||||
}
|
||||
|
||||
// 🔥 CRITICAL: Flush file buffer before closing to ensure all data is written
|
||||
file.flush();
|
||||
delay(100); // Brief delay to ensure SD card completes write
|
||||
file.close();
|
||||
|
||||
// 🔓 RELEASE SD CARD MUTEX - Other tasks can now access SD
|
||||
SDCardMutex::getInstance().unlock();
|
||||
LOG_INFO("OTA: SD card mutex released");
|
||||
|
||||
http.end();
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// EXIT OTA FREEZE MODE - Resume all paused systems
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
LOG_INFO("OTA: Exiting freeze mode - resuming TimeKeeper and Telemetry");
|
||||
|
||||
if (_timeKeeper) {
|
||||
_timeKeeper->resumeClockUpdates();
|
||||
}
|
||||
|
||||
if (_telemetry) {
|
||||
_telemetry->resume();
|
||||
}
|
||||
|
||||
if (written != (size_t)contentLength) {
|
||||
LOG_ERROR("Download incomplete: %u/%d bytes", written, contentLength);
|
||||
setStatus(Status::FAILED, ErrorCode::DOWNLOAD_FAILED);
|
||||
@@ -437,7 +795,43 @@ bool OTAManager::downloadToSD(const String& url, const String& expectedChecksum,
|
||||
|
||||
LOG_INFO("Download complete (%u bytes)", written);
|
||||
|
||||
// Verify checksum
|
||||
// 🔒 Acquire mutex for file verification operations
|
||||
if (!SDCardMutex::getInstance().lock(5000)) {
|
||||
LOG_ERROR("OTA: Failed to acquire SD mutex for verification");
|
||||
setStatus(Status::FAILED, ErrorCode::DOWNLOAD_FAILED);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 🔍 DEBUG: Check actual file size on SD card
|
||||
size_t actualFileSize = _fileManager->getFileSize(tempPath);
|
||||
LOG_INFO("OTA: File size on SD card: %u bytes (expected: %u)", actualFileSize, written);
|
||||
|
||||
if (actualFileSize != written) {
|
||||
LOG_ERROR("OTA: FILE SIZE MISMATCH ON SD CARD! Expected %u, got %u", written, actualFileSize);
|
||||
SDCardMutex::getInstance().unlock();
|
||||
setStatus(Status::FAILED, ErrorCode::WRITE_FAILED);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 🔍 DEBUG: Read first 32 bytes for inspection
|
||||
File debugFile = SD.open(tempPath.c_str());
|
||||
if (debugFile) {
|
||||
uint8_t debugBuffer[32];
|
||||
size_t debugRead = debugFile.readBytes((char*)debugBuffer, 32);
|
||||
debugFile.close();
|
||||
|
||||
String hexDump = "OTA: First 32 bytes (hex): ";
|
||||
for (size_t i = 0; i < debugRead && i < 32; i++) {
|
||||
char hex[4];
|
||||
sprintf(hex, "%02X ", debugBuffer[i]);
|
||||
hexDump += hex;
|
||||
}
|
||||
LOG_INFO("%s", hexDump.c_str());
|
||||
}
|
||||
|
||||
SDCardMutex::getInstance().unlock(); // Release before checksum (checksum will acquire its own)
|
||||
|
||||
// Verify checksum (verifyChecksum acquires its own mutex)
|
||||
if (!verifyChecksum(tempPath, expectedChecksum)) {
|
||||
LOG_ERROR("Checksum verification failed after download");
|
||||
_fileManager->deleteFile(tempPath);
|
||||
@@ -471,9 +865,16 @@ bool OTAManager::verifyChecksum(const String& filePath, const String& expectedCh
|
||||
}
|
||||
|
||||
String OTAManager::calculateSHA256(const String& filePath) {
|
||||
// 🔒 Acquire SD mutex for file reading
|
||||
if (!SDCardMutex::getInstance().lock(5000)) {
|
||||
LOG_ERROR("Failed to acquire SD mutex for checksum calculation");
|
||||
return "";
|
||||
}
|
||||
|
||||
File file = SD.open(filePath.c_str());
|
||||
if (!file) {
|
||||
LOG_ERROR("Failed to open file for checksum calculation: %s", filePath.c_str());
|
||||
SDCardMutex::getInstance().unlock();
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -497,6 +898,9 @@ String OTAManager::calculateSHA256(const String& filePath) {
|
||||
|
||||
file.close();
|
||||
|
||||
// 🔓 Release SD mutex
|
||||
SDCardMutex::getInstance().unlock();
|
||||
|
||||
// Convert to hex string
|
||||
String hashString = "";
|
||||
for (int i = 0; i < 32; i++) {
|
||||
@@ -511,7 +915,17 @@ String OTAManager::calculateSHA256(const String& filePath) {
|
||||
}
|
||||
|
||||
bool OTAManager::installFromSD(const String& filePath) {
|
||||
// 🔒 Acquire SD mutex for file size check
|
||||
if (!SDCardMutex::getInstance().lock(5000)) {
|
||||
LOG_ERROR("Failed to acquire SD mutex for installation");
|
||||
setStatus(Status::FAILED, ErrorCode::DOWNLOAD_FAILED);
|
||||
return false;
|
||||
}
|
||||
|
||||
size_t updateSize = _fileManager->getFileSize(filePath);
|
||||
|
||||
SDCardMutex::getInstance().unlock(); // Release after size check
|
||||
|
||||
if (updateSize == 0) {
|
||||
LOG_ERROR("Empty update file");
|
||||
setStatus(Status::FAILED, ErrorCode::DOWNLOAD_FAILED);
|
||||
@@ -527,9 +941,17 @@ bool OTAManager::installFromSD(const String& filePath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 🔒 Acquire SD mutex for file reading during flash
|
||||
if (!SDCardMutex::getInstance().lock(30000)) { // 30 second timeout for flash operation
|
||||
LOG_ERROR("Failed to acquire SD mutex for firmware flash");
|
||||
setStatus(Status::FAILED, ErrorCode::DOWNLOAD_FAILED);
|
||||
return false;
|
||||
}
|
||||
|
||||
File updateBin = SD.open(filePath.c_str());
|
||||
if (!updateBin) {
|
||||
LOG_ERROR("Failed to open update file: %s", filePath.c_str());
|
||||
SDCardMutex::getInstance().unlock();
|
||||
setStatus(Status::FAILED, ErrorCode::DOWNLOAD_FAILED);
|
||||
return false;
|
||||
}
|
||||
@@ -537,6 +959,9 @@ bool OTAManager::installFromSD(const String& filePath) {
|
||||
size_t written = Update.writeStream(updateBin);
|
||||
updateBin.close();
|
||||
|
||||
// 🔓 Release SD mutex after reading file
|
||||
SDCardMutex::getInstance().unlock();
|
||||
|
||||
if (written == updateSize) {
|
||||
LOG_INFO("Update written successfully (%u bytes)", written);
|
||||
} else {
|
||||
@@ -571,7 +996,8 @@ bool OTAManager::installFromSD(const String& filePath) {
|
||||
}
|
||||
|
||||
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();
|
||||
delay(500);
|
||||
ESP.restart();
|
||||
@@ -647,18 +1073,65 @@ bool OTAManager::performManualUpdate(const String& channel) {
|
||||
return false;
|
||||
}
|
||||
|
||||
LOG_INFO("Starting manual OTA update from %s channel via SD staging...", channel.c_str());
|
||||
LOG_INFO("Starting manual OTA update from %s channel (direct-to-flash)...", channel.c_str());
|
||||
setStatus(Status::DOWNLOADING);
|
||||
|
||||
String firmwareUrl = buildFirmwareUrl(channel);
|
||||
|
||||
// Download to SD first
|
||||
if (!downloadToSD(firmwareUrl, _availableChecksum, _expectedFileSize)) {
|
||||
// Download directly to flash
|
||||
return downloadDirectToFlash(firmwareUrl, _expectedFileSize);
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// CUSTOM FIRMWARE UPDATE
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
bool OTAManager::performCustomUpdate(const String& firmwareUrl, const String& checksum, size_t fileSize, uint16_t version) {
|
||||
if (_status != Status::IDLE) {
|
||||
LOG_WARNING("OTA update already in progress");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Install from SD
|
||||
return installFromSD("/firmware/staged_update.bin");
|
||||
// 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 (direct-to-flash)...");
|
||||
LOG_INFO(" URL: %s", firmwareUrl.c_str());
|
||||
LOG_INFO(" File Size: %u bytes", fileSize);
|
||||
|
||||
if (!checksum.isEmpty()) {
|
||||
LOG_INFO(" Checksum: %s (NOTE: ESP32 will validate after flash)", checksum.c_str());
|
||||
}
|
||||
|
||||
if (version > 0) {
|
||||
LOG_INFO(" Target Version: %u", version);
|
||||
}
|
||||
|
||||
setStatus(Status::DOWNLOADING);
|
||||
|
||||
// Download directly to flash
|
||||
bool result = downloadDirectToFlash(firmwareUrl, fileSize);
|
||||
|
||||
if (result) {
|
||||
// Update version in config if provided
|
||||
if (version > 0) {
|
||||
_configManager.setFwVersion(String(version));
|
||||
_configManager.saveDeviceConfig();
|
||||
LOG_INFO("✅ Custom firmware version %u saved to NVS", version);
|
||||
} else {
|
||||
LOG_WARNING("⚠️ No version provided - NVS version unchanged");
|
||||
}
|
||||
|
||||
LOG_INFO("🚀 Custom firmware installed - device will reboot");
|
||||
} else {
|
||||
LOG_ERROR("❌ Custom firmware installation failed");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Hardware variant management
|
||||
@@ -778,3 +1251,49 @@ bool OTAManager::isHealthy() const {
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// WORKER TASK IMPLEMENTATION
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// Static entry point for worker task
|
||||
void OTAManager::otaWorkerTaskFunction(void* parameter) {
|
||||
OTAManager* ota = static_cast<OTAManager*>(parameter);
|
||||
LOG_INFO("🔧 OTA Worker task started on Core %d with 8KB stack", xPortGetCoreID());
|
||||
|
||||
// Run the worker loop
|
||||
ota->otaWorkerLoop();
|
||||
|
||||
// Should not reach here
|
||||
LOG_ERROR("❌ OTA Worker task ended unexpectedly!");
|
||||
vTaskDelete(NULL);
|
||||
}
|
||||
|
||||
// Worker task loop - waits for signals and performs OTA operations
|
||||
void OTAManager::otaWorkerLoop() {
|
||||
while (true) {
|
||||
// Wait for work signal (blocks until semaphore is given)
|
||||
if (xSemaphoreTake(_otaWorkSignal, portMAX_DELAY) == pdTRUE) {
|
||||
// Check what type of work to perform
|
||||
switch (_pendingWork) {
|
||||
case OTAWorkType::INITIAL_CHECK:
|
||||
LOG_INFO("🚀 Worker: Performing initial OTA check");
|
||||
performInitialCheck();
|
||||
break;
|
||||
|
||||
case OTAWorkType::SCHEDULED_CHECK:
|
||||
LOG_INFO("🕒 Worker: Performing scheduled emergency check");
|
||||
checkForEmergencyUpdates();
|
||||
break;
|
||||
|
||||
case OTAWorkType::NONE:
|
||||
default:
|
||||
LOG_WARNING("⚠️ Worker: Received signal but no work pending");
|
||||
break;
|
||||
}
|
||||
|
||||
// Clear pending work
|
||||
_pendingWork = OTAWorkType::NONE;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,9 +26,13 @@
|
||||
#include <functional>
|
||||
#include <time.h>
|
||||
#include "../FileManager/FileManager.hpp"
|
||||
#include "../Telemetry/Telemetry.hpp"
|
||||
#include "../TimeKeeper/TimeKeeper.hpp"
|
||||
|
||||
class ConfigManager; // Forward declaration
|
||||
class Player; // Forward declaration for idle check
|
||||
class ConfigManager; // Forward declaration
|
||||
class Player; // Forward declaration for idle check
|
||||
class Timekeeper; // Forward declaration for freeze mode
|
||||
class Telemetry; // Forward declaration for freeze mode
|
||||
|
||||
class OTAManager {
|
||||
public:
|
||||
@@ -66,7 +70,9 @@ public:
|
||||
|
||||
void begin();
|
||||
void setFileManager(FileManager* fm);
|
||||
void setPlayer(Player* player); // NEW: Set player reference for idle check
|
||||
void setPlayer(Player* player); // Set player reference for idle check
|
||||
void setTimeKeeper(Timekeeper* tk); // Set timekeeper reference for freeze mode
|
||||
void setTelemetry(Telemetry* telemetry); // Set telemetry reference for freeze mode
|
||||
|
||||
void checkForUpdates();
|
||||
void checkForUpdates(const String& channel); // Check specific channel
|
||||
@@ -77,6 +83,7 @@ public:
|
||||
void checkFirmwareUpdateFromSD(); // Check SD for firmware update
|
||||
bool performManualUpdate(); // Manual update triggered by app
|
||||
bool performManualUpdate(const String& channel); // Manual update from specific channel
|
||||
bool performCustomUpdate(const String& firmwareUrl, const String& checksum = "", size_t fileSize = 0, uint16_t version = 0); // Custom firmware update
|
||||
|
||||
// Hardware identification
|
||||
String getHardwareVariant() const;
|
||||
@@ -85,8 +92,8 @@ public:
|
||||
// Status and info
|
||||
Status getStatus() const { return _status; }
|
||||
ErrorCode getLastError() const { return _lastError; }
|
||||
float getCurrentVersion() const;
|
||||
float getAvailableVersion() const { return _availableVersion; }
|
||||
uint16_t getCurrentVersion() const;
|
||||
uint16_t getAvailableVersion() const { return _availableVersion; }
|
||||
bool isUpdateAvailable() const { return _updateAvailable; }
|
||||
|
||||
// Callbacks
|
||||
@@ -103,11 +110,13 @@ public:
|
||||
private:
|
||||
ConfigManager& _configManager;
|
||||
FileManager* _fileManager;
|
||||
Player* _player; // NEW: Player reference for idle check
|
||||
Player* _player; // Player reference for idle check
|
||||
Timekeeper* _timeKeeper; // TimeKeeper reference for freeze mode
|
||||
Telemetry* _telemetry; // Telemetry reference for freeze mode
|
||||
Status _status;
|
||||
ErrorCode _lastError;
|
||||
float _availableVersion;
|
||||
float _minVersion; // NEW: Minimum required version
|
||||
uint16_t _availableVersion;
|
||||
uint16_t _minVersion; // NEW: Minimum required version
|
||||
size_t _expectedFileSize; // NEW: Expected firmware file size
|
||||
bool _updateAvailable;
|
||||
String _availableChecksum;
|
||||
@@ -122,6 +131,24 @@ private:
|
||||
TimerHandle_t _scheduledCheckTimer;
|
||||
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
|
||||
|
||||
// Worker task for OTA operations (prevents stack overflow in timer callbacks)
|
||||
TaskHandle_t _otaWorkerTask;
|
||||
SemaphoreHandle_t _otaWorkSignal;
|
||||
static void otaWorkerTaskFunction(void* parameter);
|
||||
void otaWorkerLoop();
|
||||
|
||||
enum class OTAWorkType {
|
||||
NONE,
|
||||
INITIAL_CHECK,
|
||||
SCHEDULED_CHECK
|
||||
};
|
||||
OTAWorkType _pendingWork;
|
||||
|
||||
void setStatus(Status status, ErrorCode error = ErrorCode::NONE);
|
||||
void notifyProgress(size_t current, size_t total);
|
||||
bool checkVersion();
|
||||
@@ -129,7 +156,8 @@ private:
|
||||
bool checkChannelsMetadata();
|
||||
bool downloadAndInstall();
|
||||
bool downloadAndInstall(const String& channel);
|
||||
bool downloadToSD(const String& url, const String& expectedChecksum, size_t expectedSize); // NEW: Added size param
|
||||
bool downloadDirectToFlash(const String& url, size_t expectedSize); // NEW: Direct to flash (bypasses SD)
|
||||
bool downloadToSD(const String& url, const String& expectedChecksum, size_t expectedSize); // OLD: Via SD card
|
||||
bool verifyChecksum(const String& filePath, const String& expectedChecksum);
|
||||
String calculateSHA256(const String& filePath);
|
||||
bool installFromSD(const String& filePath);
|
||||
|
||||
@@ -75,26 +75,20 @@ uint8_t OutputManager::getPhysicalOutput(uint8_t virtualOutput) const {
|
||||
return virtualOutput;
|
||||
}
|
||||
|
||||
// Get 1-indexed bell output from config
|
||||
uint16_t bellOutput1Indexed = _configManager->getBellOutput(virtualOutput);
|
||||
// Get 0-indexed bell output from config
|
||||
uint16_t bellOutput = _configManager->getBellOutput(virtualOutput);
|
||||
|
||||
// Handle unconfigured bells (255 = disabled)
|
||||
if (bellOutput1Indexed == 255) {
|
||||
if (bellOutput == 255) {
|
||||
LOG_WARNING("⚠️ Bell %d not configured (255)", virtualOutput);
|
||||
return 255; // Return invalid to prevent firing
|
||||
}
|
||||
|
||||
// Handle invalid 0 configuration
|
||||
if (bellOutput1Indexed == 0) {
|
||||
LOG_ERROR("❌ Bell %d configured as 0 (invalid - should be 1-indexed)", virtualOutput);
|
||||
return 255;
|
||||
}
|
||||
// Physical output is already 0-indexed from config
|
||||
uint8_t physicalOutput = (uint8_t)bellOutput;
|
||||
|
||||
// Convert 1-indexed config to 0-indexed physical output
|
||||
uint8_t physicalOutput = (uint8_t)(bellOutput1Indexed - 1);
|
||||
|
||||
LOG_DEBUG("🔗 Bell %d → 1-indexed config %d → 0-indexed output %d",
|
||||
virtualOutput, bellOutput1Indexed, physicalOutput);
|
||||
LOG_DEBUG("🔗 Bell %d → 0-indexed output %d",
|
||||
virtualOutput, physicalOutput);
|
||||
|
||||
return physicalOutput;
|
||||
}
|
||||
@@ -140,25 +134,18 @@ void OutputManager::fireClockOutput(uint8_t virtualOutput, uint16_t durationMs)
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert 1-indexed config value to 0-indexed physical output
|
||||
if (physicalOutput == 0) {
|
||||
LOG_ERROR("❌ Clock output configured as 0 (invalid - should be 1-indexed)");
|
||||
return;
|
||||
}
|
||||
|
||||
uint8_t zeroIndexedOutput = physicalOutput - 1; // Convert 1-indexed to 0-indexed
|
||||
|
||||
if (!isValidPhysicalOutput(zeroIndexedOutput)) {
|
||||
LOG_ERROR("❌ Invalid physical output for clock: %d (1-indexed config: %d, max outputs: %d)",
|
||||
zeroIndexedOutput, physicalOutput, getMaxOutputs());
|
||||
// Physical output is already 0-indexed from config
|
||||
if (!isValidPhysicalOutput(physicalOutput)) {
|
||||
LOG_ERROR("❌ Invalid physical output for clock: %d (max outputs: %d)",
|
||||
physicalOutput, getMaxOutputs());
|
||||
return;
|
||||
}
|
||||
|
||||
// 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",
|
||||
virtualOutput, virtualOutput + 1, physicalOutput, zeroIndexedOutput, durationMs);
|
||||
LOG_DEBUG("🕐 FIRE Clock Virtual %d (C%d) → 0-indexed output %d for %dms",
|
||||
virtualOutput, virtualOutput + 1, physicalOutput, durationMs);
|
||||
}
|
||||
|
||||
// ==================== PCF8574/PCF8575 MULTI-CHIP IMPLEMENTATION ====================
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
#include "Player.hpp"
|
||||
#include "../Communication/CommunicationRouter/CommunicationRouter.hpp"
|
||||
#include "../BellEngine/BellEngine.hpp"
|
||||
#include "../Telemetry/Telemetry.hpp"
|
||||
#include "../TimeKeeper/TimeKeeper.hpp" // 🔥 Include for Timekeeper class definition
|
||||
#include "../BuiltInMelodies/BuiltInMelodies.hpp"
|
||||
|
||||
// Note: Removed global melody_steps dependency for cleaner architecture
|
||||
|
||||
@@ -10,7 +13,7 @@ Player::Player(CommunicationRouter* comm, FileManager* fm)
|
||||
, name("melody1")
|
||||
, uid("x")
|
||||
, 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)
|
||||
, segment_duration(15000)
|
||||
, pause_duration(0)
|
||||
@@ -28,6 +31,8 @@ Player::Player(CommunicationRouter* comm, FileManager* fm)
|
||||
, _commManager(comm)
|
||||
, _fileManager(fm)
|
||||
, _bellEngine(nullptr)
|
||||
, _telemetry(nullptr)
|
||||
, _timekeeper(nullptr)
|
||||
, _durationTimerHandle(NULL) {
|
||||
}
|
||||
|
||||
@@ -37,7 +42,7 @@ Player::Player()
|
||||
, name("melody1")
|
||||
, uid("x")
|
||||
, 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)
|
||||
, segment_duration(15000)
|
||||
, pause_duration(0)
|
||||
@@ -55,6 +60,8 @@ Player::Player()
|
||||
, _commManager(nullptr)
|
||||
, _fileManager(nullptr)
|
||||
, _bellEngine(nullptr)
|
||||
, _telemetry(nullptr)
|
||||
, _timekeeper(nullptr)
|
||||
, _durationTimerHandle(NULL) {
|
||||
}
|
||||
|
||||
@@ -103,6 +110,12 @@ void Player::play() {
|
||||
return;
|
||||
}
|
||||
|
||||
// 🔥 CRITICAL: Interrupt any active clock alerts - user playback has priority!
|
||||
if (_timekeeper) {
|
||||
_timekeeper->interruptActiveAlert();
|
||||
LOG_DEBUG("Player: Interrupted any active clock alerts");
|
||||
}
|
||||
|
||||
if (_bellEngine) {
|
||||
_bellEngine->setMelodyData(_melodySteps);
|
||||
_bellEngine->start();
|
||||
@@ -123,6 +136,12 @@ void Player::forceStop() {
|
||||
hardStop = true;
|
||||
isPlaying = false;
|
||||
setStatus(PlayerStatus::STOPPED); // Immediate stop, notify clients
|
||||
|
||||
// Save strike counters after melody stops
|
||||
if (_telemetry) {
|
||||
_telemetry->saveStrikeCounters();
|
||||
}
|
||||
|
||||
LOG_DEBUG("Plbck: FORCE STOP");
|
||||
}
|
||||
|
||||
@@ -135,9 +154,19 @@ void Player::stop() {
|
||||
isPlaying = false;
|
||||
|
||||
// Set STOPPING status - actual stop message will be sent when BellEngine finishes
|
||||
setStatus(PlayerStatus::STOPPING);
|
||||
LOG_DEBUG("Plbck: SOFT STOP (waiting for melody to complete)");
|
||||
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);
|
||||
LOG_DEBUG("Plbck: SOFT STOP (waiting for melody to complete)");
|
||||
}
|
||||
// NOTE: The actual "stop" message is now sent in onMelodyLoopCompleted()
|
||||
// when the BellEngine actually finishes the current loop
|
||||
}
|
||||
@@ -208,9 +237,8 @@ void Player::setMelodyAttributes(JsonVariant doc) {
|
||||
continuous_loop = doc["continuous_loop"].as<bool>();
|
||||
}
|
||||
|
||||
if (continuous_loop && total_duration == 0) {
|
||||
infinite_play = true;
|
||||
}
|
||||
// Recalculate infinite_play based on current values (reset first!)
|
||||
infinite_play = (continuous_loop && total_duration == 0);
|
||||
|
||||
if (!continuous_loop) {
|
||||
total_duration = segment_duration;
|
||||
@@ -226,7 +254,23 @@ void Player::setMelodyAttributes(JsonVariant doc) {
|
||||
}
|
||||
|
||||
void Player::loadMelodyInRAM() {
|
||||
String filePath = "/melodies/" + String(uid.c_str());
|
||||
String uidStr = String(uid.c_str());
|
||||
|
||||
// 🎵 PRIORITY 1: Check if this is a built-in melody
|
||||
if (BuiltInMelodies::isBuiltInMelody(uidStr)) {
|
||||
LOG_INFO("Loading built-in melody: %s", uidStr.c_str());
|
||||
|
||||
if (BuiltInMelodies::loadBuiltInMelody(uidStr, _melodySteps)) {
|
||||
LOG_INFO("✅ Built-in melody loaded successfully: %d steps", _melodySteps.size());
|
||||
return;
|
||||
} else {
|
||||
LOG_ERROR("Failed to load built-in melody: %s", uidStr.c_str());
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 🎵 PRIORITY 2: Load from SD card
|
||||
String filePath = "/melodies/" + uidStr;
|
||||
|
||||
File bin_file = SD.open(filePath.c_str(), FILE_READ);
|
||||
if (!bin_file) {
|
||||
@@ -267,7 +311,7 @@ void Player::loadMelodyInRAM() {
|
||||
_melodySteps[i] = (high << 8) | low;
|
||||
}
|
||||
|
||||
LOG_INFO("Melody loaded successfully: %d steps", _melodySteps.size());
|
||||
LOG_INFO("Melody loaded successfully from SD: %d steps", _melodySteps.size());
|
||||
bin_file.close();
|
||||
}
|
||||
|
||||
@@ -385,6 +429,7 @@ bool Player::timeToResume(unsigned long now) {
|
||||
uint64_t timeToResume = segmentCmpltTime + pause_duration;
|
||||
if (now >= timeToResume) {
|
||||
LOG_DEBUG("(TimerFunction) Pause Duration Reached. Resuming");
|
||||
segmentStartTime = now; // Reset segment start time for next cycle
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
// ═════════════════════════════════════════════════════════════════════════════════
|
||||
class CommunicationRouter; // Command handling and communication
|
||||
class BellEngine; // High-precision timing engine
|
||||
class Telemetry; // System telemetry and monitoring
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════════
|
||||
// PLAYER STATUS ENUMERATION
|
||||
@@ -126,6 +127,18 @@ public:
|
||||
*/
|
||||
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; }
|
||||
|
||||
/**
|
||||
* @brief Set Timekeeper reference for alert coordination
|
||||
* @param timekeeper Pointer to Timekeeper instance
|
||||
*/
|
||||
void setTimekeeper(class Timekeeper* timekeeper) { _timekeeper = timekeeper; }
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// MELODY METADATA - Public access for compatibility
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -241,6 +254,8 @@ private:
|
||||
CommunicationRouter* _commManager; // 📡 Communication system reference
|
||||
FileManager* _fileManager; // 📁 File operations reference
|
||||
BellEngine* _bellEngine; // 🔥 High-precision timing engine reference
|
||||
Telemetry* _telemetry; // 📄 Telemetry system reference
|
||||
class Timekeeper* _timekeeper; // ⏰ Timekeeper reference for alert coordination
|
||||
|
||||
std::vector<uint16_t> _melodySteps; // 🎵 Melody data owned by Player
|
||||
TimerHandle_t _durationTimerHandle = NULL; // ⏱️ FreeRTOS timer (saves 4KB vs task!)
|
||||
|
||||
227
vesper/src/SDCardMutex/SDCardMutex.hpp
Normal file
227
vesper/src/SDCardMutex/SDCardMutex.hpp
Normal file
@@ -0,0 +1,227 @@
|
||||
/*
|
||||
* ═══════════════════════════════════════════════════════════════════════════════
|
||||
* SDCARDMUTEX.HPP - Thread-Safe SD Card Access Manager
|
||||
* ═══════════════════════════════════════════════════════════════════════════════
|
||||
*
|
||||
* 🔒 THE SD CARD CONCURRENCY GUARDIAN OF VESPER 🔒
|
||||
*
|
||||
* This singleton class provides thread-safe access to the SD card by managing
|
||||
* a FreeRTOS mutex. All SD card operations MUST acquire this mutex to prevent
|
||||
* concurrent access that can lead to file corruption and write failures.
|
||||
*
|
||||
* CRITICAL: The ESP32 SD library is NOT thread-safe. Without this mutex,
|
||||
* simultaneous SD access from multiple FreeRTOS tasks will cause:
|
||||
* - File corruption
|
||||
* - Write failures
|
||||
* - SD card "not recognized" errors
|
||||
* - Random intermittent failures
|
||||
*
|
||||
* USAGE:
|
||||
*
|
||||
* // Lock before ANY SD operation
|
||||
* if (SDCardMutex::getInstance().lock()) {
|
||||
* File file = SD.open("/myfile.txt", FILE_WRITE);
|
||||
* file.println("data");
|
||||
* file.close();
|
||||
* SDCardMutex::getInstance().unlock();
|
||||
* }
|
||||
*
|
||||
* // Or use RAII helper for automatic unlock
|
||||
* {
|
||||
* SDCardLock lock; // Acquires mutex
|
||||
* File file = SD.open("/myfile.txt", FILE_WRITE);
|
||||
* file.println("data");
|
||||
* file.close();
|
||||
* } // Automatically releases mutex when going out of scope
|
||||
*
|
||||
* 📋 VERSION: 1.0
|
||||
* 📅 DATE: 2025-01-07
|
||||
* 👨💻 AUTHOR: Advanced Bell Systems
|
||||
* ═══════════════════════════════════════════════════════════════════════════════
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/semphr.h>
|
||||
#include "../Logging/Logging.hpp"
|
||||
|
||||
/**
|
||||
* @brief Singleton class for thread-safe SD card access
|
||||
*
|
||||
* Manages a global mutex that all SD card operations must acquire
|
||||
* to prevent concurrent access from multiple FreeRTOS tasks.
|
||||
*/
|
||||
class SDCardMutex {
|
||||
public:
|
||||
/**
|
||||
* @brief Get the singleton instance
|
||||
* @return Reference to the singleton instance
|
||||
*/
|
||||
static SDCardMutex& getInstance() {
|
||||
static SDCardMutex instance;
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Initialize the mutex (call once during setup)
|
||||
* @return true if initialization succeeded, false otherwise
|
||||
*/
|
||||
bool begin() {
|
||||
if (_mutex != NULL) {
|
||||
LOG_WARNING("SDCardMutex already initialized");
|
||||
return true;
|
||||
}
|
||||
|
||||
_mutex = xSemaphoreCreateMutex();
|
||||
|
||||
if (_mutex == NULL) {
|
||||
LOG_ERROR("Failed to create SD card mutex!");
|
||||
return false;
|
||||
}
|
||||
|
||||
LOG_INFO("SD card mutex initialized");
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Acquire the SD card mutex
|
||||
* @param timeoutMs Maximum time to wait for mutex (default: 5 seconds)
|
||||
* @return true if mutex acquired, false if timeout
|
||||
*/
|
||||
bool lock(uint32_t timeoutMs = 5000) {
|
||||
if (_mutex == NULL) {
|
||||
LOG_ERROR("SDCardMutex not initialized!");
|
||||
return false;
|
||||
}
|
||||
|
||||
TickType_t timeout = (timeoutMs == portMAX_DELAY)
|
||||
? portMAX_DELAY
|
||||
: pdMS_TO_TICKS(timeoutMs);
|
||||
|
||||
if (xSemaphoreTake(_mutex, timeout) == pdTRUE) {
|
||||
_lockCount++;
|
||||
return true;
|
||||
} else {
|
||||
LOG_ERROR("SD card mutex timeout after %u ms!", timeoutMs);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Release the SD card mutex
|
||||
*/
|
||||
void unlock() {
|
||||
if (_mutex == NULL) {
|
||||
LOG_ERROR("SDCardMutex not initialized!");
|
||||
return;
|
||||
}
|
||||
|
||||
xSemaphoreGive(_mutex);
|
||||
_unlockCount++;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Get mutex lock statistics
|
||||
* @param locks Reference to store lock count
|
||||
* @param unlocks Reference to store unlock count
|
||||
*/
|
||||
void getStats(uint32_t& locks, uint32_t& unlocks) const {
|
||||
locks = _lockCount;
|
||||
unlocks = _unlockCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Check if mutex is currently locked by THIS task
|
||||
* @return true if current task holds the mutex
|
||||
*/
|
||||
bool isLockedByMe() const {
|
||||
if (_mutex == NULL) {
|
||||
return false;
|
||||
}
|
||||
return xSemaphoreGetMutexHolder(_mutex) == xTaskGetCurrentTaskHandle();
|
||||
}
|
||||
|
||||
// Delete copy constructor and assignment operator (singleton)
|
||||
SDCardMutex(const SDCardMutex&) = delete;
|
||||
SDCardMutex& operator=(const SDCardMutex&) = delete;
|
||||
|
||||
private:
|
||||
SDCardMutex()
|
||||
: _mutex(NULL)
|
||||
, _lockCount(0)
|
||||
, _unlockCount(0) {
|
||||
}
|
||||
|
||||
~SDCardMutex() {
|
||||
if (_mutex != NULL) {
|
||||
vSemaphoreDelete(_mutex);
|
||||
_mutex = NULL;
|
||||
}
|
||||
}
|
||||
|
||||
SemaphoreHandle_t _mutex;
|
||||
uint32_t _lockCount;
|
||||
uint32_t _unlockCount;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief RAII helper class for automatic mutex lock/unlock
|
||||
*
|
||||
* Acquires SD card mutex on construction and releases on destruction.
|
||||
* Use this for automatic cleanup when going out of scope.
|
||||
*
|
||||
* Example:
|
||||
* {
|
||||
* SDCardLock lock;
|
||||
* File file = SD.open("/test.txt", FILE_WRITE);
|
||||
* file.println("data");
|
||||
* file.close();
|
||||
* } // Mutex automatically released here
|
||||
*/
|
||||
class SDCardLock {
|
||||
public:
|
||||
/**
|
||||
* @brief Constructor - acquires mutex
|
||||
* @param timeoutMs Maximum time to wait for mutex
|
||||
*/
|
||||
explicit SDCardLock(uint32_t timeoutMs = 5000)
|
||||
: _locked(false) {
|
||||
_locked = SDCardMutex::getInstance().lock(timeoutMs);
|
||||
if (!_locked) {
|
||||
LOG_ERROR("SDCardLock failed to acquire mutex!");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Destructor - releases mutex
|
||||
*/
|
||||
~SDCardLock() {
|
||||
if (_locked) {
|
||||
SDCardMutex::getInstance().unlock();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Check if lock was successfully acquired
|
||||
* @return true if mutex is locked
|
||||
*/
|
||||
bool isLocked() const {
|
||||
return _locked;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Explicit conversion to bool for easy checking
|
||||
*/
|
||||
explicit operator bool() const {
|
||||
return _locked;
|
||||
}
|
||||
|
||||
// Delete copy constructor and assignment operator
|
||||
SDCardLock(const SDCardLock&) = delete;
|
||||
SDCardLock& operator=(const SDCardLock&) = delete;
|
||||
|
||||
private:
|
||||
bool _locked;
|
||||
};
|
||||
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 <ArduinoJson.h>
|
||||
|
||||
void Telemetry::begin() {
|
||||
// Initialize arrays
|
||||
for (uint8_t i = 0; i < 16; i++) {
|
||||
strikeCounters[i] = 0;
|
||||
bellLoad[i] = 0;
|
||||
bellMaxLoad[i] = 60; // Default max load
|
||||
bellMaxLoad[i] = 200; // Default max load
|
||||
}
|
||||
|
||||
coolingActive = false;
|
||||
|
||||
// Load strike counters from SD if available
|
||||
loadStrikeCounters();
|
||||
|
||||
// Create the telemetry task
|
||||
xTaskCreatePinnedToCore(telemetryTask, "TelemetryTask", 4096, this, 2, &telemetryTaskHandle, 1);
|
||||
|
||||
@@ -21,6 +25,11 @@ void Telemetry::setPlayerReference(bool* isPlayingPtr) {
|
||||
LOG_DEBUG("Player reference set");
|
||||
}
|
||||
|
||||
void Telemetry::setFileManager(FileManager* fm) {
|
||||
fileManager = fm;
|
||||
LOG_DEBUG("FileManager reference set");
|
||||
}
|
||||
|
||||
void Telemetry::setForceStopCallback(void (*callback)()) {
|
||||
forceStopCallback = callback;
|
||||
LOG_DEBUG("Force stop callback set");
|
||||
@@ -163,18 +172,77 @@ void Telemetry::telemetryTask(void* parameter) {
|
||||
LOG_INFO("Telemetry task started");
|
||||
|
||||
while(1) {
|
||||
// Only run if player is playing OR we're still cooling
|
||||
bool isPlaying = (telemetry->playerIsPlayingPtr != nullptr) ?
|
||||
*(telemetry->playerIsPlayingPtr) : false;
|
||||
// Skip processing if paused (OTA freeze mode)
|
||||
if (!telemetry->isPaused) {
|
||||
// Only run if player is playing OR we're still cooling
|
||||
bool isPlaying = (telemetry->playerIsPlayingPtr != nullptr) ?
|
||||
*(telemetry->playerIsPlayingPtr) : false;
|
||||
|
||||
if (isPlaying || telemetry->coolingActive) {
|
||||
telemetry->checkBellLoads();
|
||||
if (isPlaying || telemetry->coolingActive) {
|
||||
telemetry->checkBellLoads();
|
||||
}
|
||||
}
|
||||
|
||||
vTaskDelay(pdMS_TO_TICKS(1000)); // Run every 1s
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// 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
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "../Logging/Logging.hpp"
|
||||
#include "../FileManager/FileManager.hpp"
|
||||
|
||||
class Telemetry {
|
||||
private:
|
||||
@@ -71,16 +72,21 @@ private:
|
||||
|
||||
// External references (to be set via setters)
|
||||
bool* playerIsPlayingPtr = nullptr;
|
||||
FileManager* fileManager = nullptr;
|
||||
|
||||
// Spinlock for critical sections
|
||||
portMUX_TYPE telemetrySpinlock = portMUX_INITIALIZER_UNLOCKED;
|
||||
|
||||
// Pause flag for OTA freeze mode
|
||||
volatile bool isPaused = false;
|
||||
|
||||
public:
|
||||
// Initialization
|
||||
void begin();
|
||||
|
||||
// Set external references
|
||||
void setPlayerReference(bool* isPlayingPtr);
|
||||
void setFileManager(FileManager* fm);
|
||||
|
||||
// Bell strike handling (call this on every hammer strike)
|
||||
void recordBellStrike(uint8_t bellIndex);
|
||||
@@ -89,6 +95,10 @@ public:
|
||||
uint32_t getStrikeCount(uint8_t bellIndex);
|
||||
void resetStrikeCounters(); // User-requested reset
|
||||
|
||||
// Persistence methods
|
||||
void saveStrikeCounters();
|
||||
void loadStrikeCounters();
|
||||
|
||||
// Bell load management
|
||||
uint16_t getBellLoad(uint8_t bellIndex);
|
||||
void setBellMaxLoad(uint8_t bellIndex, uint16_t maxLoad);
|
||||
@@ -102,6 +112,10 @@ public:
|
||||
// Force stop callback (to be set by main application)
|
||||
void setForceStopCallback(void (*callback)());
|
||||
|
||||
// Pause/Resume for OTA freeze mode (stops SD writes during firmware update)
|
||||
void pause() { isPaused = true; }
|
||||
void resume() { isPaused = false; }
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// HEALTH CHECK METHOD
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
#include "../OutputManager/OutputManager.hpp"
|
||||
#include "../ConfigManager/ConfigManager.hpp"
|
||||
#include "../Networking/Networking.hpp"
|
||||
#include "../Player/Player.hpp" // 🔥 Include for Player class definition
|
||||
#include "SD.h"
|
||||
#include <time.h>
|
||||
|
||||
@@ -47,6 +48,19 @@ void Timekeeper::setNetworking(Networking* networking) {
|
||||
LOG_INFO("Timekeeper connected to Networking");
|
||||
}
|
||||
|
||||
void Timekeeper::setPlayer(Player* player) {
|
||||
_player = player;
|
||||
LOG_INFO("Timekeeper connected to Player for playback coordination");
|
||||
}
|
||||
|
||||
void Timekeeper::interruptActiveAlert() {
|
||||
if (alertInProgress.load()) {
|
||||
LOG_INFO("⚡ ALERT INTERRUPTED by user playback - marking as complete");
|
||||
alertInProgress.store(false);
|
||||
// Alert will stop naturally on next check in fireAlertBell loop
|
||||
}
|
||||
}
|
||||
|
||||
void Timekeeper::setRelayWriteFunction(void (*func)(int, int)) {
|
||||
relayWriteFunc = func;
|
||||
LOG_WARNING("Using LEGACY relay function - consider upgrading to OutputManager");
|
||||
@@ -135,16 +149,16 @@ unsigned long Timekeeper::getTime() {
|
||||
void Timekeeper::syncTimeWithNTP() {
|
||||
// Check if we have network connection and required dependencies
|
||||
if (!_networking || !_configManager) {
|
||||
LOG_ERROR("Cannot sync time: Networking or ConfigManager not set");
|
||||
LOG_WARNING("Cannot sync time: Networking or ConfigManager not set - using RTC time");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_networking->isConnected()) {
|
||||
LOG_WARNING("Cannot sync time: No network connection");
|
||||
LOG_INFO("No network connection - skipping NTP sync, using RTC time");
|
||||
return;
|
||||
}
|
||||
|
||||
LOG_INFO("Syncing time with NTP server...");
|
||||
LOG_INFO("⏰ Starting non-blocking NTP sync...");
|
||||
|
||||
// Get config from ConfigManager
|
||||
auto& timeConfig = _configManager->getTimeConfig();
|
||||
@@ -152,30 +166,23 @@ void Timekeeper::syncTimeWithNTP() {
|
||||
// Configure NTP with settings from config
|
||||
configTime(timeConfig.gmtOffsetSec, timeConfig.daylightOffsetSec, timeConfig.ntpServer.c_str());
|
||||
|
||||
// Wait for time sync with timeout
|
||||
// 🔥 NON-BLOCKING: Try to get time with reasonable timeout for network response
|
||||
struct tm timeInfo;
|
||||
int attempts = 0;
|
||||
while (!getLocalTime(&timeInfo) && attempts < 10) {
|
||||
LOG_DEBUG("Waiting for NTP sync... attempt %d", attempts + 1);
|
||||
delay(1000);
|
||||
attempts++;
|
||||
if (getLocalTime(&timeInfo, 5000)) { // 5 second timeout for NTP response
|
||||
// Success! Update RTC with synchronized time
|
||||
rtc.adjust(DateTime(timeInfo.tm_year + 1900, timeInfo.tm_mon + 1, timeInfo.tm_mday,
|
||||
timeInfo.tm_hour, timeInfo.tm_min, timeInfo.tm_sec));
|
||||
|
||||
LOG_INFO("✅ NTP sync successful: %04d-%02d-%02d %02d:%02d:%02d",
|
||||
timeInfo.tm_year + 1900, timeInfo.tm_mon + 1, timeInfo.tm_mday,
|
||||
timeInfo.tm_hour, timeInfo.tm_min, timeInfo.tm_sec);
|
||||
|
||||
// Reload today's events since the time might have changed significantly
|
||||
loadTodaysEvents();
|
||||
} 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.");
|
||||
}
|
||||
|
||||
if (attempts >= 10) {
|
||||
LOG_ERROR("Failed to obtain time from NTP server after 10 attempts");
|
||||
return;
|
||||
}
|
||||
|
||||
// Update RTC with synchronized time
|
||||
rtc.adjust(DateTime(timeInfo.tm_year + 1900, timeInfo.tm_mon + 1, timeInfo.tm_mday,
|
||||
timeInfo.tm_hour, timeInfo.tm_min, timeInfo.tm_sec));
|
||||
|
||||
LOG_INFO("Time synced successfully: %04d-%02d-%02d %02d:%02d:%02d",
|
||||
timeInfo.tm_year + 1900, timeInfo.tm_mon + 1, timeInfo.tm_mday,
|
||||
timeInfo.tm_hour, timeInfo.tm_min, timeInfo.tm_sec);
|
||||
|
||||
// Reload today's events since the time might have changed significantly
|
||||
loadTodaysEvents();
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
@@ -545,14 +552,34 @@ void Timekeeper::checkClockAlerts() {
|
||||
return;
|
||||
}
|
||||
|
||||
// 🔥 CRITICAL: Check if Player is busy - if so, SKIP alert completely
|
||||
if (_player && _player->isPlaying) {
|
||||
// Player is active (playing, paused, stopping, etc.) - skip alert entirely
|
||||
// Mark this alert as processed to prevent it from firing when playback ends
|
||||
DateTime now = rtc.now();
|
||||
int currentMinute = now.minute();
|
||||
|
||||
if (currentMinute == 0) {
|
||||
lastHour = now.hour(); // Mark hour as processed
|
||||
} else if (currentMinute == 30) {
|
||||
lastMinute = 30; // Mark half-hour as processed
|
||||
} else if (currentMinute == 15 || currentMinute == 45) {
|
||||
lastMinute = currentMinute; // Mark quarter-hour as processed
|
||||
}
|
||||
|
||||
LOG_DEBUG("⏭️ SKIPPING clock alert - Player is busy (playing/paused)");
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current time
|
||||
DateTime now = rtc.now();
|
||||
int currentHour = now.hour();
|
||||
int currentMinute = now.minute();
|
||||
int currentSecond = now.second();
|
||||
|
||||
// Only trigger alerts on exact seconds (0-2) to avoid multiple triggers
|
||||
if (currentSecond > 2) {
|
||||
// Only trigger alerts in first 30 seconds of the minute
|
||||
// The lastHour/lastMinute tracking prevents duplicate triggers
|
||||
if (currentSecond > 30) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -632,7 +659,16 @@ void Timekeeper::fireAlertBell(uint8_t bellNumber, int count) {
|
||||
|
||||
const auto& clockConfig = _configManager->getClockConfig();
|
||||
|
||||
// Mark alert as in progress
|
||||
alertInProgress.store(true);
|
||||
|
||||
for (int i = 0; i < count; i++) {
|
||||
// 🔥 Check for interruption by user playback
|
||||
if (!alertInProgress.load()) {
|
||||
LOG_INFO("⚡ Alert interrupted at ring %d/%d - stopping immediately", i + 1, count);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get bell duration from bell configuration
|
||||
uint16_t bellDuration = _configManager->getBellDuration(bellNumber);
|
||||
|
||||
@@ -647,6 +683,9 @@ void Timekeeper::fireAlertBell(uint8_t bellNumber, int count) {
|
||||
vTaskDelay(pdMS_TO_TICKS(clockConfig.alertRingInterval));
|
||||
}
|
||||
}
|
||||
|
||||
// Mark alert as complete
|
||||
alertInProgress.store(false);
|
||||
}
|
||||
|
||||
void Timekeeper::checkBacklightAutomation() {
|
||||
@@ -704,14 +743,22 @@ bool Timekeeper::isInSilencePeriod() {
|
||||
|
||||
// Check daytime silence period
|
||||
if (clockConfig.daytimeSilenceEnabled) {
|
||||
if (isTimeInRange(currentTime, clockConfig.daytimeSilenceOnTime, clockConfig.daytimeSilenceOffTime)) {
|
||||
bool inDaytime = isTimeInRange(currentTime, clockConfig.daytimeSilenceOnTime, clockConfig.daytimeSilenceOffTime);
|
||||
LOG_DEBUG("🔇 Daytime silence check: current=%s, range=%s-%s, inRange=%s",
|
||||
currentTime.c_str(), clockConfig.daytimeSilenceOnTime.c_str(),
|
||||
clockConfig.daytimeSilenceOffTime.c_str(), inDaytime ? "YES" : "NO");
|
||||
if (inDaytime) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check nighttime silence period
|
||||
if (clockConfig.nighttimeSilenceEnabled) {
|
||||
if (isTimeInRange(currentTime, clockConfig.nighttimeSilenceOnTime, clockConfig.nighttimeSilenceOffTime)) {
|
||||
bool inNighttime = isTimeInRange(currentTime, clockConfig.nighttimeSilenceOnTime, clockConfig.nighttimeSilenceOffTime);
|
||||
LOG_DEBUG("🌙 Nighttime silence check: current=%s, range=%s-%s, inRange=%s",
|
||||
currentTime.c_str(), clockConfig.nighttimeSilenceOnTime.c_str(),
|
||||
clockConfig.nighttimeSilenceOffTime.c_str(), inNighttime ? "YES" : "NO");
|
||||
if (inNighttime) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
#include <Arduino.h>
|
||||
#include <vector>
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <ArduinoJson.h>
|
||||
#include <RTClib.h>
|
||||
#include "freertos/FreeRTOS.h"
|
||||
@@ -61,6 +62,7 @@ private:
|
||||
// Alert management - new functionality
|
||||
int lastHour = -1; // Track last processed hour to avoid duplicate alerts
|
||||
int lastMinute = -1; // Track last processed minute for quarter/half alerts
|
||||
std::atomic<bool> alertInProgress{false}; // Flag to track if alert is currently playing
|
||||
|
||||
// Backlight management - new functionality
|
||||
bool backlightState = false; // Track current backlight state
|
||||
@@ -69,6 +71,7 @@ private:
|
||||
OutputManager* _outputManager = nullptr;
|
||||
ConfigManager* _configManager = nullptr;
|
||||
Networking* _networking = nullptr;
|
||||
class Player* _player = nullptr; // Reference to Player for playback status checks
|
||||
|
||||
// Legacy function pointer (DEPRECATED - will be removed)
|
||||
void (*relayWriteFunc)(int relay, int state) = nullptr;
|
||||
@@ -84,12 +87,16 @@ public:
|
||||
void setOutputManager(OutputManager* outputManager);
|
||||
void setConfigManager(ConfigManager* configManager);
|
||||
void setNetworking(Networking* networking);
|
||||
void setPlayer(class Player* player); // Set Player reference for playback coordination
|
||||
|
||||
// Clock Updates Pause Functions
|
||||
void pauseClockUpdates() { clockUpdatesPaused = true; }
|
||||
void resumeClockUpdates() { clockUpdatesPaused = false; }
|
||||
bool areClockUpdatesPaused() const { return clockUpdatesPaused; }
|
||||
|
||||
// Alert interruption - called by Player when starting playback
|
||||
void interruptActiveAlert();
|
||||
|
||||
// Legacy interface (DEPRECATED - will be removed)
|
||||
void setRelayWriteFunction(void (*func)(int, int));
|
||||
|
||||
|
||||
@@ -24,8 +24,9 @@
|
||||
* 🎯 KEY FEATURES:
|
||||
* ✅ Microsecond-precision bell timing (BellEngine)
|
||||
* ✅ Multi-hardware support (PCF8574, GPIO, Mock)
|
||||
* ✅ Dual network connectivity (Ethernet + WiFi)
|
||||
* ✅ Dual Communication Support (MQTT + WebSocket)
|
||||
* ✅ Dual network connectivity (Ethernet + WiFi + Permanent AP Mode)
|
||||
* ✅ Multi-protocol communication (MQTT + WebSocket + HTTP REST API)
|
||||
* ✅ Web settings interface for network mode switching
|
||||
* ✅ Real-time telemetry and load monitoring
|
||||
* ✅ Over-the-air firmware updates
|
||||
* ✅ SD card configuration and file management
|
||||
@@ -33,8 +34,9 @@
|
||||
* ✅ Comprehensive logging system
|
||||
*
|
||||
* 📡 COMMUNICATION PROTOCOLS:
|
||||
* • MQTT (SSL/TLS via PubSubClient on Core 0)
|
||||
* • MQTT (SSL/TLS via AsyncMqttClient on Core 0)
|
||||
* • WebSocket (Real-time web interface)
|
||||
* • HTTP REST API (Command execution via HTTP)
|
||||
* • UDP Discovery (Auto-discovery service)
|
||||
* • HTTP/HTTPS (OTA updates)
|
||||
*
|
||||
@@ -62,14 +64,23 @@
|
||||
* 👨💻 AUTHOR: BellSystems bonamin
|
||||
*/
|
||||
|
||||
#define FW_VERSION "0.1"
|
||||
#define FW_VERSION "154"
|
||||
|
||||
|
||||
/*
|
||||
* ═══════════════════════════════════════════════════════════════════════════════
|
||||
* 📅 VERSION HISTORY:
|
||||
* NOTE: Versions are now stored as integers (v1.3 = 130)
|
||||
* ═══════════════════════════════════════════════════════════════════════════════
|
||||
* 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
|
||||
* v138 - Removed Ethernet, added default WiFi creds (Mikrotik AP) and fixed various Clock issues
|
||||
* v140 - Changed FW Updates to Direct-to-Flash and added manual update functionality with version check
|
||||
* v151 - Fixed Clock Alerts not running properly
|
||||
* v152 - Fix RTC Time Reports, added sync_time_to_LCD functionality
|
||||
* v153 - Fix Infinite Loop Bug and Melody Download crashes.
|
||||
* ═══════════════════════════════════════════════════════════════════════════════
|
||||
*/
|
||||
|
||||
@@ -79,8 +90,6 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════════
|
||||
// SYSTEM LIBRARIES - Core ESP32 and Arduino functionality
|
||||
// ═══════════════════════════════════════════════════════════════════════════════════
|
||||
@@ -117,6 +126,7 @@
|
||||
// ═══════════════════════════════════════════════════════════════════════════════════
|
||||
// CUSTOM CLASSES - Include Custom Classes and Functions
|
||||
// ═══════════════════════════════════════════════════════════════════════════════════
|
||||
#include "src/SDCardMutex/SDCardMutex.hpp" // ⚠️ MUST be included before any SD-using classes
|
||||
#include "src/ConfigManager/ConfigManager.hpp"
|
||||
#include "src/FileManager/FileManager.hpp"
|
||||
#include "src/TimeKeeper/TimeKeeper.hpp"
|
||||
@@ -178,6 +188,7 @@ BellEngine bellEngine(player, configManager, telemetry, outputManager); // 🔥
|
||||
|
||||
TaskHandle_t bellEngineHandle = NULL; // Legacy - will be removed
|
||||
TimerHandle_t schedulerTimer;
|
||||
TimerHandle_t ntpSyncTimer; // Non-blocking delayed NTP sync timer
|
||||
|
||||
|
||||
|
||||
@@ -188,21 +199,44 @@ void handleFactoryReset() {
|
||||
}
|
||||
}
|
||||
|
||||
// Non-blocking NTP sync timer callback
|
||||
void ntpSyncTimerCallback(TimerHandle_t xTimer) {
|
||||
LOG_DEBUG("Network stabilization complete - starting NTP sync");
|
||||
if (!networking.isInAPMode()) {
|
||||
timekeeper.syncTimeWithNTP();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
void setup()
|
||||
{
|
||||
// Initialize Serial Communications (for debugging) & I2C Bus (for Hardware Control)
|
||||
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);
|
||||
auto& hwConfig = configManager.getHardwareConfig();
|
||||
SPI.begin(hwConfig.ethSpiSck, hwConfig.ethSpiMiso, hwConfig.ethSpiMosi);
|
||||
delay(50);
|
||||
|
||||
// 🔒 CRITICAL: Initialize SD Card Mutex BEFORE any SD operations
|
||||
// This prevents concurrent SD access from multiple FreeRTOS tasks
|
||||
if (!SDCardMutex::getInstance().begin()) {
|
||||
Serial.println("❌ FATAL: Failed to initialize SD card mutex!");
|
||||
Serial.println(" System cannot continue safely - entering infinite loop");
|
||||
while(1) { delay(1000); } // Halt system - unsafe to proceed
|
||||
}
|
||||
Serial.println("✅ SD card mutex initialized");
|
||||
|
||||
// Initialize Configuration (loads factory identity from NVS + user settings from SD)
|
||||
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.setFactoryResetLongPressCallback(handleFactoryReset);
|
||||
|
||||
@@ -214,6 +248,18 @@ void setup()
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// 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);
|
||||
LOG_INFO("Firmware version: %s", FW_VERSION);
|
||||
|
||||
@@ -273,16 +319,18 @@ void setup()
|
||||
timekeeper.setOutputManager(&outputManager);
|
||||
timekeeper.setConfigManager(&configManager);
|
||||
timekeeper.setNetworking(&networking);
|
||||
timekeeper.setPlayer(&player); // 🔥 Connect for playback coordination
|
||||
// Clock outputs now configured via ConfigManager/Communication commands
|
||||
|
||||
// Register TimeKeeper with health monitor
|
||||
healthMonitor.setTimeKeeper(&timekeeper);
|
||||
|
||||
// Initialize Telemetry
|
||||
telemetry.begin();
|
||||
telemetry.setPlayerReference(&player.isPlaying);
|
||||
// 🚑 CRITICAL: Connect force stop callback for overload protection!
|
||||
telemetry.setForceStopCallback([]() { player.forceStop(); });
|
||||
telemetry.setFileManager(&fileManager);
|
||||
telemetry.begin();
|
||||
|
||||
// Register Telemetry with health monitor
|
||||
healthMonitor.setTelemetry(&telemetry);
|
||||
@@ -308,9 +356,12 @@ void setup()
|
||||
communication.setFileManagerReference(&fileManager);
|
||||
communication.setTimeKeeperReference(&timekeeper);
|
||||
communication.setFirmwareValidatorReference(&firmwareValidator);
|
||||
communication.setTelemetryReference(&telemetry);
|
||||
|
||||
player.setDependencies(&communication, &fileManager);
|
||||
player.setBellEngine(&bellEngine); // Connect the beast!
|
||||
player.setTelemetry(&telemetry);
|
||||
player.setTimekeeper(&timekeeper); // 🔥 Connect for alert coordination
|
||||
|
||||
// Register Communication with health monitor
|
||||
healthMonitor.setCommunication(&communication);
|
||||
@@ -318,46 +369,74 @@ void setup()
|
||||
// 🔔 CONNECT BELLENGINE TO COMMUNICATION FOR DING NOTIFICATIONS!
|
||||
bellEngine.setCommunicationManager(&communication);
|
||||
|
||||
// Track if AsyncWebServer has been started to prevent duplicates
|
||||
static bool webServerStarted = false;
|
||||
|
||||
// Create NTP sync timer (one-shot, 3 second delay for network stabilization)
|
||||
ntpSyncTimer = xTimerCreate(
|
||||
"NTPSync", // Timer name
|
||||
pdMS_TO_TICKS(3000), // 3 second delay (network stabilization)
|
||||
pdFALSE, // One-shot timer (not auto-reload)
|
||||
NULL, // Timer ID (not used)
|
||||
ntpSyncTimerCallback // Callback function
|
||||
);
|
||||
|
||||
// Set up network callbacks
|
||||
networking.setNetworkCallbacks(
|
||||
[]() {
|
||||
[&webServerStarted]() {
|
||||
communication.onNetworkConnected();
|
||||
// Start AsyncWebServer when network becomes available
|
||||
if (networking.getState() != NetworkState::WIFI_PORTAL_MODE) {
|
||||
|
||||
// Schedule non-blocking NTP sync after 3s network stabilization (like MQTT)
|
||||
// Skip NTP sync in AP mode (no internet connection)
|
||||
if (!networking.isInAPMode() && ntpSyncTimer) {
|
||||
LOG_DEBUG("Network connected - scheduling NTP sync after 3s stabilization (non-blocking)");
|
||||
xTimerStart(ntpSyncTimer, 0);
|
||||
}
|
||||
|
||||
// Start AsyncWebServer when network becomes available (only once!)
|
||||
if (!webServerStarted && networking.getState() != NetworkState::WIFI_PORTAL_MODE) {
|
||||
LOG_INFO("🚀 Starting AsyncWebServer on port 80...");
|
||||
server.begin();
|
||||
LOG_INFO("✅ AsyncWebServer started on http://%s", networking.getLocalIP().c_str());
|
||||
webServerStarted = true;
|
||||
}
|
||||
}, // onConnected
|
||||
[]() { communication.onNetworkDisconnected(); } // onDisconnected
|
||||
);
|
||||
|
||||
// If already connected, trigger MQTT connection manually
|
||||
// If already connected, trigger MQTT connection and setup manually
|
||||
if (networking.isConnected()) {
|
||||
LOG_INFO("Network already connected - triggering MQTT connection");
|
||||
LOG_INFO("Network already connected - initializing services");
|
||||
communication.onNetworkConnected();
|
||||
|
||||
// Schedule non-blocking NTP sync after 3s network stabilization (like MQTT)
|
||||
// Skip NTP sync in AP mode (no internet connection)
|
||||
if (!networking.isInAPMode() && ntpSyncTimer) {
|
||||
LOG_DEBUG("Network already connected - scheduling NTP sync after 3s stabilization (non-blocking)");
|
||||
xTimerStart(ntpSyncTimer, 0);
|
||||
}
|
||||
|
||||
// 🔥 CRITICAL: Start AsyncWebServer ONLY when network is ready
|
||||
// Do NOT start if WiFiManager portal is active (port 80 conflict!)
|
||||
LOG_INFO("🚀 Starting AsyncWebServer on port 80...");
|
||||
server.begin();
|
||||
LOG_INFO("✅ AsyncWebServer started and listening on http://%s", networking.getLocalIP().c_str());
|
||||
if (!webServerStarted && networking.getState() != NetworkState::WIFI_PORTAL_MODE) {
|
||||
LOG_INFO("🚀 Starting AsyncWebServer on port 80...");
|
||||
server.begin();
|
||||
LOG_INFO("✅ AsyncWebServer started on http://%s", networking.getLocalIP().c_str());
|
||||
webServerStarted = true;
|
||||
}
|
||||
} 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 and check for updates
|
||||
// Initialize OTA Manager
|
||||
otaManager.begin();
|
||||
otaManager.setFileManager(&fileManager);
|
||||
otaManager.setPlayer(&player); // Set player reference for idle check
|
||||
otaManager.setPlayer(&player); // Set player reference for idle check
|
||||
otaManager.setTimeKeeper(&timekeeper); // Set timekeeper reference for freeze mode
|
||||
otaManager.setTelemetry(&telemetry); // Set telemetry reference for freeze mode
|
||||
|
||||
// 🔥 CRITICAL: Delay OTA check to avoid UDP socket race with MQTT
|
||||
// Both MQTT and OTA HTTP use UDP sockets, must sequence them!
|
||||
delay(2000);
|
||||
LOG_INFO("Starting OTA update check after network stabilization...");
|
||||
otaManager.checkForUpdates();
|
||||
// 🔥 FIX: OTA check will happen asynchronously via scheduled timer (no blocking delay)
|
||||
// UDP discovery setup can happen immediately without conflicts
|
||||
communication.setupUdpDiscovery();
|
||||
|
||||
// Register OTA Manager with health monitor
|
||||
@@ -428,10 +507,25 @@ 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();
|
||||
}
|
||||
|
||||
// Process UART command input from external devices (LCD panel, buttons)
|
||||
communication.loop();
|
||||
|
||||
// 🔥 DEBUG: Log every 10 seconds to verify we're still running
|
||||
static unsigned long lastLog = 0;
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user