diff --git a/README.md b/README.md index 610f067..35f3ae8 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,9 @@ A Raspberry Pi-based rotary phone system for weddings and events. Guests can pic - **Volume Control**: Adjust playback volume with real-time slider (0-100%) - **Multiple Message Support**: Upload and manage multiple greeting messages - **Active Message Selector**: Choose which greeting plays when the phone is picked up -- **Extra Button Support**: Optional GPIO button to play custom sounds on demand +- **Extra Button Support**: Optional GPIO button to play custom sounds during recording +- **USB Backup**: Automatic backup to multiple USB drives with CRC32 verification +- **Data Integrity**: Every file write is verified with CRC checksums - **HiFiBerry Support**: Optimized for HiFiBerry DAC+ADC Pro audio quality - **Real-time Status**: Monitor phone status (on-hook/off-hook/recording) - **Auto-refresh**: Status updates every 5 seconds @@ -206,6 +208,73 @@ Configure a delay before the greeting plays: - Example: 2 second delay gives time to position the phone - Default: 0 (greeting plays immediately) +### USB Backup (Optional) + +Automatically backup all recordings and greeting files to USB drives: + +**Configuration** (`config.json`): +```json +{ + "backup": { + "enabled": true, + "usb_paths": [ + "/media/usb0", + "/media/usb1" + ], + "verify_crc": true, + "backup_on_write": true + } +} +``` + +**Features:** +- **Multiple USB Drives**: Backup to one or more USB drives simultaneously +- **CRC32 Verification**: Every backup is verified with CRC checksum +- **Automatic Backup**: Files are backed up immediately after recording/upload +- **Integrity Check**: Corrupted backups are automatically deleted +- **Web Monitoring**: View USB drive status, free space, and test backups + +**Web Interface:** +- Monitor USB drive status (mounted, writable, free space) +- Test backup functionality with one click +- View real-time backup results +- Green = Ready, Yellow = Not Writable, Red = Not Mounted + +**Backup Structure:** +``` +/media/usb0/ +└── wedding-phone-backup/ + ├── recordings/ + │ ├── recording_20250124_143022.wav + │ └── recording_20250124_143145.wav + └── sounds/ + ├── dialtone.wav + └── greeting.wav +``` + +**How It Works:** +1. Recording finishes or greeting uploaded +2. File saved to main storage +3. CRC32 checksum calculated for source file +4. File copied to each USB drive +5. CRC32 checksum verified on each copy +6. Corrupted copies deleted automatically +7. Success/failure logged to console + +**Mount USB Drives:** +```bash +# Create mount points +sudo mkdir -p /media/usb0 /media/usb1 + +# Auto-mount in /etc/fstab (example) +UUID=XXXX-XXXX /media/usb0 vfat defaults,nofail 0 0 +UUID=YYYY-YYYY /media/usb1 vfat defaults,nofail 0 0 + +# Or mount manually +sudo mount /dev/sda1 /media/usb0 +sudo mount /dev/sdb1 /media/usb1 +``` + ## File Structure ``` @@ -263,6 +332,12 @@ The `config.json` file contains all system settings: "recordings_dir": "recordings", // Subdirectory for recordings "sounds_dir": "sounds" // Subdirectory for greeting sounds }, + "backup": { + "enabled": true, // Enable USB backup + "usb_paths": ["/media/usb0", "/media/usb1"], // USB mount points + "verify_crc": true, // Verify backups with CRC32 + "backup_on_write": true // Backup immediately after write + }, "web": { "port": 8080, // Web interface port "max_upload_size_mb": 50 // Max upload file size diff --git a/config.example.json b/config.example.json index e9125ff..f86606f 100644 --- a/config.example.json +++ b/config.example.json @@ -19,6 +19,15 @@ "recordings_dir": "recordings", "sounds_dir": "sounds" }, + "backup": { + "enabled": true, + "usb_paths": [ + "/media/usb0", + "/media/usb1" + ], + "verify_crc": true, + "backup_on_write": true + }, "web": { "port": 8080, "max_upload_size_mb": 50 diff --git a/rotary_phone_web.py b/rotary_phone_web.py index 416c058..803970e 100644 --- a/rotary_phone_web.py +++ b/rotary_phone_web.py @@ -16,6 +16,8 @@ from flask import Flask, render_template, request, send_file, jsonify, redirect, from werkzeug.utils import secure_filename import json import sys +import zlib +import shutil # Load configuration def load_system_config(): @@ -65,6 +67,13 @@ SOUNDS_DIR = os.path.join(BASE_DIR, SYS_CONFIG['paths']['sounds_dir']) USER_CONFIG_FILE = os.path.join(BASE_DIR, "user_config.json") # Runtime user settings DIALTONE_FILE = os.path.join(SOUNDS_DIR, "dialtone.wav") +# Backup Configuration +BACKUP_CONFIG = SYS_CONFIG.get('backup', {}) +BACKUP_ENABLED = BACKUP_CONFIG.get('enabled', False) +BACKUP_USB_PATHS = BACKUP_CONFIG.get('usb_paths', []) +BACKUP_VERIFY_CRC = BACKUP_CONFIG.get('verify_crc', True) +BACKUP_ON_WRITE = BACKUP_CONFIG.get('backup_on_write', True) + # Web server settings WEB_PORT = SYS_CONFIG['web']['port'] @@ -72,6 +81,130 @@ WEB_PORT = SYS_CONFIG['web']['port'] app = Flask(__name__) app.config['MAX_CONTENT_LENGTH'] = SYS_CONFIG['web']['max_upload_size_mb'] * 1024 * 1024 +# Backup and CRC Functions +def calculate_crc32(filepath): + """Calculate CRC32 checksum of a file""" + try: + with open(filepath, 'rb') as f: + checksum = 0 + while chunk := f.read(65536): # Read in 64KB chunks + checksum = zlib.crc32(chunk, checksum) + return checksum & 0xFFFFFFFF + except Exception as e: + print(f"Error calculating CRC for {filepath}: {e}") + return None + +def backup_file_to_usb(source_file, relative_path): + """ + Backup a file to all configured USB drives with CRC verification + + Args: + source_file: Full path to source file + relative_path: Relative path from base dir (e.g., 'recordings/file.wav') + + Returns: + dict: Status of each USB backup attempt + """ + if not BACKUP_ENABLED or not BACKUP_ON_WRITE: + return {"enabled": False} + + if not os.path.exists(source_file): + return {"error": f"Source file not found: {source_file}"} + + # Calculate source CRC if verification is enabled + source_crc = None + if BACKUP_VERIFY_CRC: + source_crc = calculate_crc32(source_file) + if source_crc is None: + return {"error": "Failed to calculate source CRC"} + + results = {} + + for usb_path in BACKUP_USB_PATHS: + usb_name = os.path.basename(usb_path) + + # Check if USB is mounted + if not os.path.exists(usb_path) or not os.path.ismount(usb_path): + results[usb_name] = {"status": "not_mounted", "path": usb_path} + continue + + try: + # Create backup directory structure + backup_dir = os.path.join(usb_path, "wedding-phone-backup") + dest_dir = os.path.join(backup_dir, os.path.dirname(relative_path)) + os.makedirs(dest_dir, exist_ok=True) + + # Copy file + dest_file = os.path.join(backup_dir, relative_path) + shutil.copy2(source_file, dest_file) + + # Verify CRC if enabled + if BACKUP_VERIFY_CRC: + dest_crc = calculate_crc32(dest_file) + if dest_crc != source_crc: + results[usb_name] = { + "status": "crc_mismatch", + "path": dest_file, + "source_crc": hex(source_crc), + "dest_crc": hex(dest_crc) if dest_crc else None + } + # Delete corrupted backup + try: + os.remove(dest_file) + except: + pass + continue + + results[usb_name] = { + "status": "success", + "path": dest_file, + "crc": hex(source_crc) if source_crc else None + } + + except Exception as e: + results[usb_name] = {"status": "error", "error": str(e)} + + return results + +def get_usb_backup_status(): + """Get status of all configured USB backup drives""" + status = { + "enabled": BACKUP_ENABLED, + "verify_crc": BACKUP_VERIFY_CRC, + "backup_on_write": BACKUP_ON_WRITE, + "drives": [] + } + + for usb_path in BACKUP_USB_PATHS: + drive_info = { + "path": usb_path, + "name": os.path.basename(usb_path), + "mounted": os.path.exists(usb_path) and os.path.ismount(usb_path), + "writable": False, + "free_space": None + } + + if drive_info["mounted"]: + try: + # Check if writable + test_file = os.path.join(usb_path, ".wedding_phone_test") + with open(test_file, 'w') as f: + f.write("test") + os.remove(test_file) + drive_info["writable"] = True + + # Get free space + stat = os.statvfs(usb_path) + drive_info["free_space"] = stat.f_bavail * stat.f_frsize + drive_info["free_space_mb"] = drive_info["free_space"] / (1024 * 1024) + + except Exception as e: + drive_info["error"] = str(e) + + status["drives"].append(drive_info) + + return status + class RotaryPhone: def __init__(self): self.audio = pyaudio.PyAudio() @@ -306,6 +439,12 @@ class RotaryPhone: wf.writeframes(b''.join(frames)) wf.close() print(f"Recording saved: {filename} ({duration:.1f}s)") + + # Backup to USB drives if enabled + if BACKUP_ENABLED: + relative_path = os.path.join(SYS_CONFIG['paths']['recordings_dir'], os.path.basename(filename)) + backup_results = backup_file_to_usb(filename, relative_path) + print(f"Backup results: {backup_results}") else: # Delete aborted/too short recording if os.path.exists(filename): @@ -513,6 +652,33 @@ def api_set_greeting_delay(): new_delay = phone.set_greeting_delay(delay) return jsonify({"success": True, "delay": new_delay}) +@app.route('/api/backup/status', methods=['GET']) +def api_backup_status(): + """Get USB backup drive status""" + return jsonify(get_usb_backup_status()) + +@app.route('/api/backup/test', methods=['POST']) +def api_backup_test(): + """Test backup to all USB drives""" + try: + # Create a test file + test_file = os.path.join(OUTPUT_DIR, ".backup_test.txt") + with open(test_file, 'w') as f: + f.write(f"Backup test at {datetime.now()}") + + # Try to backup + results = backup_file_to_usb(test_file, os.path.join(SYS_CONFIG['paths']['recordings_dir'], ".backup_test.txt")) + + # Clean up test file + try: + os.remove(test_file) + except: + pass + + return jsonify({"success": True, "results": results}) + except Exception as e: + return jsonify({"success": False, "error": str(e)}), 500 + @app.route('/upload_greeting', methods=['POST']) def upload_greeting(): """Upload a new greeting message""" @@ -537,6 +703,12 @@ def upload_greeting(): filepath = os.path.join(SOUNDS_DIR, filename) file.save(filepath) + # Backup to USB drives if enabled + if BACKUP_ENABLED: + relative_path = os.path.join(SYS_CONFIG['paths']['sounds_dir'], filename) + backup_results = backup_file_to_usb(filepath, relative_path) + print(f"Greeting backup results: {backup_results}") + return jsonify({"success": True, "message": f"Greeting '{filename}' uploaded successfully", "filename": filename}) return jsonify({"error": "Only WAV files are supported"}), 400 @@ -1284,7 +1456,33 @@ if __name__ == "__main__": {% endif %} - + + +
+

💾 USB Backup

+ +
+
+
+

+ Automatic backup to USB drives with CRC verification +

+
+ +
+ +
+

Loading backup status...

+
+ +
+ +
+
+
+

🎙️ Recordings

@@ -1633,16 +1831,116 @@ if __name__ == "__main__": const alertDiv = document.createElement('div'); alertDiv.className = 'alert alert-' + type; alertDiv.textContent = message; - + const container = document.getElementById('alert-container'); container.innerHTML = ''; container.appendChild(alertDiv); - + setTimeout(() => { alertDiv.remove(); }, 5000); } - + + // USB Backup Functions + function refreshBackupStatus() { + fetch('/api/backup/status') + .then(response => response.json()) + .then(data => { + displayBackupStatus(data); + }) + .catch(error => console.error('Error fetching backup status:', error)); + } + + function displayBackupStatus(data) { + const container = document.getElementById('backup-status-container'); + + if (!data.enabled) { + container.innerHTML = ` +
+ ⚠️ USB backup is disabled in configuration +
+ `; + return; + } + + let html = ` +
+

CRC Verification: ${data.verify_crc ? '✅ Enabled' : '❌ Disabled'}

+

Backup on Write: ${data.backup_on_write ? '✅ Enabled' : '❌ Disabled'}

+
+
+ `; + + data.drives.forEach(drive => { + let statusColor = '#dc2626'; // red + let statusIcon = '❌'; + let statusText = 'Not Mounted'; + + if (drive.mounted) { + if (drive.writable) { + statusColor = '#16a34a'; // green + statusIcon = '✅'; + statusText = 'Ready'; + } else { + statusColor = '#ea580c'; // orange + statusIcon = '⚠️'; + statusText = 'Not Writable'; + } + } + + html += ` +
+
+
+

${statusIcon} ${drive.name}

+

${drive.path}

+
+
+

${statusText}

+ ${drive.free_space_mb ? `

${drive.free_space_mb.toFixed(0)} MB free

` : ''} +
+
+ ${drive.error ? `

Error: ${drive.error}

` : ''} +
+ `; + }); + + html += '
'; + container.innerHTML = html; + } + + function testBackup() { + const btn = event.target; + btn.disabled = true; + btn.textContent = '🔄 Testing...'; + + fetch('/api/backup/test', { + method: 'POST' + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + showAlert('Backup test completed. Check console for results.', 'success'); + console.log('Backup test results:', data.results); + } else { + showAlert('Backup test failed: ' + data.error, 'error'); + } + btn.disabled = false; + btn.textContent = '🧪 Test Backup'; + refreshBackupStatus(); + }) + .catch(error => { + showAlert('Backup test error: ' + error, 'error'); + btn.disabled = false; + btn.textContent = '🧪 Test Backup'; + }); + } + + // Load backup status on page load + document.addEventListener('DOMContentLoaded', function() { + refreshBackupStatus(); + }); + // Auto-refresh status every 5 seconds setInterval(() => { fetch('/api/status')