Files
wedding-phone/rotary_phone_web.py
grabowski 4282b0f7ee Add automatic template versioning system
- Add TEMPLATE_VERSION constant (1.3.0) to track UI changes
- Create check_template_version() to compare embedded vs current version
- Embed version marker as HTML comment in generated template
- Auto-regenerate template when version mismatch detected
- Show clear status messages: "Template up-to-date" or "regenerating"
- Document versioning system in CLAUDE.md with usage guidelines

Benefits:
- No manual template deletion required when code updates
- Users automatically get latest UI features on restart
- Clear version tracking for template changes
- Prevents stale template issues

To update template: increment TEMPLATE_VERSION when HTML/CSS/JS changes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 12:28:34 +07:00

2080 lines
74 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
Rotary Phone Audio Handler with Web Interface
Detects handset pickup, plays custom sound, records audio, and provides web UI
"""
import pyaudio
import wave
import time
import RPi.GPIO as GPIO
from datetime import datetime
import os
import numpy as np
import threading
from flask import Flask, render_template, request, send_file, jsonify, redirect, url_for
from werkzeug.utils import secure_filename
from waitress import serve
import json
import sys
import zlib
import shutil
import zipfile
import io
# Load configuration
def load_system_config():
"""Load system configuration from config.json"""
config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'config.json')
# Check if config exists, otherwise use example
if not os.path.exists(config_path):
example_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'config.example.json')
if os.path.exists(example_path):
print(f"Config file not found. Please copy {example_path} to {config_path}")
print("Command: cp config.example.json config.json")
sys.exit(1)
else:
print("ERROR: No configuration file found!")
sys.exit(1)
with open(config_path, 'r') as f:
return json.load(f)
# Load system configuration
SYS_CONFIG = load_system_config()
# GPIO Configuration
HOOK_PIN = SYS_CONFIG['gpio']['hook_pin']
HOOK_PRESSED = GPIO.LOW if SYS_CONFIG['gpio']['hook_pressed_state'] == 'LOW' else GPIO.HIGH
# Extra Button Configuration
EXTRA_BUTTON_ENABLED = SYS_CONFIG['gpio'].get('extra_button_enabled', False)
EXTRA_BUTTON_PIN = SYS_CONFIG['gpio'].get('extra_button_pin', 27)
EXTRA_BUTTON_PRESSED = GPIO.LOW if SYS_CONFIG['gpio'].get('extra_button_pressed_state', 'LOW') == 'LOW' else GPIO.HIGH
# Audio settings
AUDIO_DEVICE_INDEX = SYS_CONFIG['audio']['device_index']
CHUNK = SYS_CONFIG['audio']['chunk_size']
FORMAT = pyaudio.paInt16 # Fixed format
CHANNELS = SYS_CONFIG['audio']['channels']
RATE = SYS_CONFIG['audio']['sample_rate']
RECORD_SECONDS = SYS_CONFIG['audio']['max_record_seconds']
# Directories
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
BASE_DIR = os.path.join(SCRIPT_DIR, SYS_CONFIG['paths']['base_dir']) if SYS_CONFIG['paths']['base_dir'].startswith('.') else SYS_CONFIG['paths']['base_dir']
BASE_DIR = os.path.abspath(BASE_DIR)
OUTPUT_DIR = os.path.join(BASE_DIR, SYS_CONFIG['paths']['recordings_dir'])
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']
# Template version - increment this when HTML template changes
TEMPLATE_VERSION = "1.3.0" # Updated: Added download all recordings feature
# Flask app
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:
# Try to create backup directory first
backup_dir = os.path.join(usb_path, "wedding-phone-backup")
try:
os.makedirs(backup_dir, exist_ok=True)
test_location = backup_dir
except PermissionError:
# Can't create backup dir, try root
test_location = usb_path
# Check if writable in backup directory
test_file = os.path.join(test_location, ".wedding_phone_test")
with open(test_file, 'w') as f:
f.write("test")
os.remove(test_file)
drive_info["writable"] = True
drive_info["backup_dir"] = backup_dir if test_location == backup_dir else None
# 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 PermissionError as e:
drive_info["error"] = f"Permission denied. Mount with proper permissions or run: sudo chown -R $USER {usb_path}"
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()
self.recording = False
self.phone_status = "on_hook"
self.current_recording = None
# Setup GPIO
GPIO.setmode(GPIO.BCM)
GPIO.setup(HOOK_PIN, GPIO.IN, pull_up_down=GPIO.PUD_UP)
# Setup extra button if enabled
if EXTRA_BUTTON_ENABLED:
GPIO.setup(EXTRA_BUTTON_PIN, GPIO.IN, pull_up_down=GPIO.PUD_UP)
self.extra_button_playing = False
# Create directories
os.makedirs(OUTPUT_DIR, exist_ok=True)
os.makedirs(SOUNDS_DIR, exist_ok=True)
# Initialize configuration
self.config = self.load_config()
# Generate default dial tone if none exists
if not os.path.exists(DIALTONE_FILE):
self.generate_default_dialtone()
def load_config(self):
"""Load user runtime configuration from JSON file"""
default_config = {
"active_greeting": SYS_CONFIG['system']['active_greeting'],
"extra_button_sound": SYS_CONFIG['system'].get('extra_button_sound', 'button_sound.wav'),
"greetings": [],
"volume": SYS_CONFIG['system']['volume'],
"greeting_delay": SYS_CONFIG['system'].get('greeting_delay_seconds', 0)
}
if os.path.exists(USER_CONFIG_FILE):
try:
with open(USER_CONFIG_FILE, 'r') as f:
config = json.load(f)
# Ensure required keys exist
if "volume" not in config:
config["volume"] = SYS_CONFIG['system']['volume']
if "greeting_delay" not in config:
config["greeting_delay"] = SYS_CONFIG['system'].get('greeting_delay_seconds', 0)
return config
except:
pass
return default_config
def save_config(self):
"""Save user runtime configuration to JSON file"""
with open(USER_CONFIG_FILE, 'w') as f:
json.dump(self.config, f, indent=2)
def get_active_greeting_path(self):
"""Get the full path to the active greeting file"""
active = self.config.get("active_greeting", "dialtone.wav")
return os.path.join(SOUNDS_DIR, active)
def set_active_greeting(self, filename):
"""Set which greeting message to play"""
self.config["active_greeting"] = filename
self.save_config()
def get_extra_button_sound_path(self):
"""Get the full path to the extra button sound file"""
sound = self.config.get("extra_button_sound", "button_sound.wav")
return os.path.join(SOUNDS_DIR, sound)
def set_extra_button_sound(self, filename):
"""Set which sound plays for extra button"""
self.config["extra_button_sound"] = filename
self.save_config()
def set_volume(self, volume):
"""Set playback volume (0-100)"""
volume = max(0, min(100, int(volume))) # Clamp between 0-100
self.config["volume"] = volume
self.save_config()
return volume
def get_volume(self):
"""Get current volume setting"""
return self.config.get("volume", 70)
def set_greeting_delay(self, delay):
"""Set greeting delay in seconds (0-10)"""
delay = max(0, min(10, int(delay))) # Clamp between 0-10
self.config["greeting_delay"] = delay
self.save_config()
return delay
def get_greeting_delay(self):
"""Get current greeting delay"""
return self.config.get("greeting_delay", 0)
def generate_default_dialtone(self):
"""Generate a classic dial tone (350Hz + 440Hz) and save as default"""
print("Generating default dial tone...")
duration = 3
sample_rate = 44100
t = np.linspace(0, duration, int(sample_rate * duration), False)
# Generate two frequencies and combine them
tone1 = np.sin(2 * np.pi * 350 * t)
tone2 = np.sin(2 * np.pi * 440 * t)
tone = (tone1 + tone2) / 2
# Convert to 16-bit PCM
tone = (tone * 32767).astype(np.int16)
# Save as WAV file
wf = wave.open(DIALTONE_FILE, 'wb')
wf.setnchannels(1)
wf.setsampwidth(2) # 16-bit
wf.setframerate(sample_rate)
wf.writeframes(tone.tobytes())
wf.close()
print(f"Default dial tone saved to {DIALTONE_FILE}")
def play_sound_file(self, filepath, check_hook_status=True):
"""Play a WAV file with volume control
Args:
filepath: Path to WAV file
check_hook_status: If True, only play while off-hook (for greeting).
If False, play completely regardless of hook (for button)
"""
if not os.path.exists(filepath):
print(f"Sound file not found: {filepath}")
return False
print(f"Playing sound: {filepath}")
try:
wf = wave.open(filepath, 'rb')
# Use configured audio device
stream = self.audio.open(
format=self.audio.get_format_from_width(wf.getsampwidth()),
channels=wf.getnchannels(),
rate=wf.getframerate(),
output=True,
output_device_index=AUDIO_DEVICE_INDEX,
frames_per_buffer=CHUNK
)
# Get volume multiplier (0.0 to 1.0)
volume = self.get_volume() / 100.0
# Play the sound with volume control
data = wf.readframes(CHUNK)
while data:
# For greeting sounds, stop immediately if handset is hung up
if check_hook_status and GPIO.input(HOOK_PIN) != HOOK_PRESSED:
print("Handset hung up, stopping playback immediately")
break
# Apply volume by converting to numpy array and scaling
if volume < 1.0:
audio_data = np.frombuffer(data, dtype=np.int16)
audio_data = (audio_data * volume).astype(np.int16)
data = audio_data.tobytes()
stream.write(data)
data = wf.readframes(CHUNK)
stream.stop_stream()
stream.close()
wf.close()
print("Sound playback finished")
return True
except Exception as e:
print(f"Error playing sound: {e}")
return False
def record_audio(self):
"""Record audio from the microphone"""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = os.path.join(OUTPUT_DIR, f"recording_{timestamp}.wav")
self.current_recording = filename
print(f"Recording to {filename}")
MIN_RECORDING_DURATION = 1.0 # Minimum 1 second to save recording
try:
# Use configured audio device
stream = self.audio.open(
format=FORMAT,
channels=CHANNELS,
rate=RATE,
input=True,
input_device_index=AUDIO_DEVICE_INDEX,
frames_per_buffer=CHUNK
)
frames = []
self.recording = True
# Record until handset is hung up or max time reached
start_time = time.time()
while self.recording and (time.time() - start_time) < RECORD_SECONDS:
# Check if handset is still off hook - immediate detection
if GPIO.input(HOOK_PIN) != HOOK_PRESSED:
print("Handset hung up, stopping recording immediately")
break
try:
data = stream.read(CHUNK, exception_on_overflow=False)
frames.append(data)
except Exception as e:
print(f"Error reading audio: {e}")
break
stream.stop_stream()
stream.close()
# Calculate recording duration
duration = len(frames) * CHUNK / RATE if frames else 0
# Only save if recording is long enough
if frames and duration >= MIN_RECORDING_DURATION:
wf = wave.open(filename, 'wb')
wf.setnchannels(CHANNELS)
wf.setsampwidth(self.audio.get_sample_size(FORMAT))
wf.setframerate(RATE)
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):
os.remove(filename)
print(f"Recording aborted or too short ({duration:.1f}s), not saved")
except Exception as e:
print(f"Recording error: {e}")
# Clean up failed recording file
if os.path.exists(filename):
try:
os.remove(filename)
print(f"Cleaned up failed recording: {filename}")
except:
pass
self.recording = False
self.current_recording = None
def get_status(self):
"""Get current phone status"""
return {
"status": self.phone_status,
"recording": self.recording,
"current_recording": self.current_recording,
"extra_button_playing": self.extra_button_playing if EXTRA_BUTTON_ENABLED else False
}
def play_extra_button_sound(self):
"""Play sound when extra button is pressed (only during recording)"""
if not EXTRA_BUTTON_ENABLED:
return
# Only play if currently recording
if not self.recording:
print("Extra button ignored - not recording")
return
button_sound = self.get_extra_button_sound_path()
if not os.path.exists(button_sound):
print(f"Extra button sound not found: {button_sound}")
return
print(f"\n=== Extra button pressed ===")
self.extra_button_playing = True
try:
# Use a separate PyAudio instance for playback during recording
# This allows simultaneous input (recording) and output (button sound)
audio_playback = pyaudio.PyAudio()
wf = wave.open(button_sound, 'rb')
stream = audio_playback.open(
format=audio_playback.get_format_from_width(wf.getsampwidth()),
channels=wf.getnchannels(),
rate=wf.getframerate(),
output=True,
output_device_index=AUDIO_DEVICE_INDEX,
frames_per_buffer=CHUNK
)
# Get volume multiplier
volume = self.get_volume() / 100.0
# Play the sound
data = wf.readframes(CHUNK)
while data:
# Apply volume
if volume < 1.0:
audio_data = np.frombuffer(data, dtype=np.int16)
audio_data = (audio_data * volume).astype(np.int16)
data = audio_data.tobytes()
stream.write(data)
data = wf.readframes(CHUNK)
stream.stop_stream()
stream.close()
wf.close()
audio_playback.terminate()
print("Extra button sound playback finished")
except Exception as e:
print(f"Error playing button sound: {e}")
finally:
self.extra_button_playing = False
def phone_loop(self):
"""Main phone handling loop"""
print("Rotary Phone System Started")
print(f"Hook pin: GPIO {HOOK_PIN}")
if EXTRA_BUTTON_ENABLED:
print(f"Extra button: GPIO {EXTRA_BUTTON_PIN}")
print(f"Recordings will be saved to: {OUTPUT_DIR}")
try:
while True:
# Check extra button first (higher priority)
if EXTRA_BUTTON_ENABLED and GPIO.input(EXTRA_BUTTON_PIN) == EXTRA_BUTTON_PRESSED:
if not self.extra_button_playing:
# Play extra button sound in a separate thread to not block
button_thread = threading.Thread(target=self.play_extra_button_sound, daemon=True)
button_thread.start()
time.sleep(0.5) # Debounce
# Wait for handset pickup
if GPIO.input(HOOK_PIN) == HOOK_PRESSED and self.phone_status == "on_hook":
self.phone_status = "off_hook"
print("\n=== Handset picked up ===")
# Apply greeting delay if configured
greeting_delay = self.get_greeting_delay()
if greeting_delay > 0:
print(f"Waiting {greeting_delay} seconds before greeting...")
time.sleep(greeting_delay)
# Play active greeting message
greeting_file = self.get_active_greeting_path()
self.play_sound_file(greeting_file)
# Start recording
if self.phone_status == "off_hook": # Still off hook after sound
self.record_audio()
self.phone_status = "on_hook"
time.sleep(0.1)
except KeyboardInterrupt:
print("\nShutting down phone system...")
finally:
self.cleanup()
def cleanup(self):
"""Clean up resources"""
self.audio.terminate()
GPIO.cleanup()
print("Cleanup complete")
# Global phone instance
phone = RotaryPhone()
# Flask Routes
@app.route('/')
def index():
"""Main page"""
recordings = get_recordings()
greetings = get_greetings()
status = phone.get_status()
active_greeting = phone.config.get("active_greeting", "dialtone.wav")
extra_button_sound = phone.config.get("extra_button_sound", "button_sound.wav")
volume = phone.get_volume()
greeting_delay = phone.get_greeting_delay()
return render_template('index.html',
recordings=recordings,
greetings=greetings,
active_greeting=active_greeting,
extra_button_sound=extra_button_sound,
extra_button_enabled=EXTRA_BUTTON_ENABLED,
status=status,
volume=volume,
greeting_delay=greeting_delay)
@app.route('/api/status')
def api_status():
"""API endpoint for phone status"""
return jsonify(phone.get_status())
@app.route('/api/recordings')
def api_recordings():
"""API endpoint for recordings list"""
return jsonify(get_recordings())
@app.route('/api/greetings')
def api_greetings():
"""API endpoint for greetings list"""
return jsonify(get_greetings())
@app.route('/api/volume', methods=['GET'])
def api_get_volume():
"""Get current volume setting"""
return jsonify({"volume": phone.get_volume()})
@app.route('/api/volume', methods=['POST'])
def api_set_volume():
"""Set volume level"""
data = request.get_json()
volume = data.get('volume', 70)
new_volume = phone.set_volume(volume)
return jsonify({"success": True, "volume": new_volume})
@app.route('/api/greeting_delay', methods=['GET'])
def api_get_greeting_delay():
"""Get current greeting delay setting"""
return jsonify({"delay": phone.get_greeting_delay()})
@app.route('/api/greeting_delay', methods=['POST'])
def api_set_greeting_delay():
"""Set greeting delay in seconds"""
data = request.get_json()
delay = data.get('delay', 0)
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"""
if 'soundfile' not in request.files:
return jsonify({"error": "No file provided"}), 400
file = request.files['soundfile']
if file.filename == '':
return jsonify({"error": "No file selected"}), 400
if file and file.filename.lower().endswith('.wav'):
filename = secure_filename(file.filename)
# Ensure unique filename
counter = 1
base_name = os.path.splitext(filename)[0]
while os.path.exists(os.path.join(SOUNDS_DIR, filename)):
filename = f"{base_name}_{counter}.wav"
counter += 1
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
@app.route('/set_active_greeting', methods=['POST'])
def set_active_greeting():
"""Set which greeting to play"""
data = request.get_json()
filename = data.get('filename')
if not filename:
return jsonify({"error": "No filename provided"}), 400
filepath = os.path.join(SOUNDS_DIR, filename)
if not os.path.exists(filepath):
return jsonify({"error": "Greeting file not found"}), 404
phone.set_active_greeting(filename)
return jsonify({"success": True, "message": f"Active greeting set to '{filename}'"})
@app.route('/set_extra_button_sound', methods=['POST'])
def set_extra_button_sound():
"""Set which sound plays for extra button"""
data = request.get_json()
filename = data.get('filename')
if not filename:
return jsonify({"error": "No filename provided"}), 400
filepath = os.path.join(SOUNDS_DIR, filename)
if not os.path.exists(filepath):
return jsonify({"error": "Sound file not found"}), 404
phone.set_extra_button_sound(filename)
return jsonify({"success": True, "message": f"Extra button sound set to '{filename}'"})
@app.route('/delete_greeting/<filename>', methods=['POST'])
def delete_greeting(filename):
"""Delete a greeting file"""
filename = secure_filename(filename)
filepath = os.path.join(SOUNDS_DIR, filename)
# Don't delete the active greeting
if filename == phone.config.get("active_greeting"):
return jsonify({"error": "Cannot delete the active greeting. Please select a different greeting first."}), 400
if os.path.exists(filepath):
os.remove(filepath)
return jsonify({"success": True})
return jsonify({"error": "File not found"}), 404
@app.route('/play_audio/<audio_type>/<filename>')
def play_audio(audio_type, filename):
"""Serve audio file for web playback"""
filename = secure_filename(filename)
if audio_type == 'recording':
filepath = os.path.join(OUTPUT_DIR, filename)
elif audio_type == 'greeting':
filepath = os.path.join(SOUNDS_DIR, filename)
else:
return "Invalid audio type", 400
if os.path.exists(filepath):
return send_file(filepath, mimetype='audio/wav')
return "File not found", 404
@app.route('/download/<filename>')
def download_recording(filename):
"""Download a recording"""
filepath = os.path.join(OUTPUT_DIR, secure_filename(filename))
if os.path.exists(filepath):
return send_file(filepath, as_attachment=True)
return "File not found", 404
@app.route('/download_all')
def download_all_recordings():
"""Download all recordings as a ZIP file"""
if not os.path.exists(OUTPUT_DIR):
return "No recordings found", 404
# Get all recording files
recordings = [f for f in os.listdir(OUTPUT_DIR)
if f.endswith('.wav') and os.path.isfile(os.path.join(OUTPUT_DIR, f))]
if not recordings:
return "No recordings found", 404
# Create ZIP file in memory
memory_file = io.BytesIO()
with zipfile.ZipFile(memory_file, 'w', zipfile.ZIP_DEFLATED) as zf:
for filename in recordings:
filepath = os.path.join(OUTPUT_DIR, filename)
# Add file to ZIP with just the filename (no path)
zf.write(filepath, arcname=filename)
# Seek to beginning of file
memory_file.seek(0)
# Generate filename with timestamp
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
zip_filename = f"wedding_recordings_{timestamp}.zip"
return send_file(
memory_file,
mimetype='application/zip',
as_attachment=True,
download_name=zip_filename
)
@app.route('/delete/<filename>', methods=['POST'])
def delete_recording(filename):
"""Delete a recording"""
filepath = os.path.join(OUTPUT_DIR, secure_filename(filename))
if os.path.exists(filepath):
os.remove(filepath)
return jsonify({"success": True})
return jsonify({"error": "File not found"}), 404
@app.route('/restore_default_sound', methods=['POST'])
def restore_default_sound():
"""Restore default dial tone"""
if os.path.exists(DIALTONE_FILE):
os.remove(DIALTONE_FILE)
phone.generate_default_dialtone()
phone.set_active_greeting("dialtone.wav")
return jsonify({"success": True, "message": "Default dial tone restored"})
def get_greetings():
"""Get list of all greeting sound files"""
greetings = []
if os.path.exists(SOUNDS_DIR):
for filename in sorted(os.listdir(SOUNDS_DIR)):
if filename.endswith('.wav'):
filepath = os.path.join(SOUNDS_DIR, filename)
stat = os.stat(filepath)
# Get duration from WAV file
try:
wf = wave.open(filepath, 'rb')
frames = wf.getnframes()
rate = wf.getframerate()
duration = frames / float(rate)
wf.close()
except:
duration = 0
greetings.append({
"filename": filename,
"size": stat.st_size,
"size_mb": stat.st_size / (1024 * 1024),
"date": datetime.fromtimestamp(stat.st_mtime).strftime('%Y-%m-%d %H:%M:%S'),
"duration": duration,
"is_active": filename == phone.config.get("active_greeting", "dialtone.wav"),
"is_button_sound": filename == phone.config.get("extra_button_sound", "button_sound.wav")
})
return greetings
def get_recordings():
"""Get list of all recordings with metadata"""
recordings = []
if os.path.exists(OUTPUT_DIR):
for filename in sorted(os.listdir(OUTPUT_DIR), reverse=True):
if filename.endswith('.wav'):
filepath = os.path.join(OUTPUT_DIR, filename)
stat = os.stat(filepath)
# Get duration from WAV file
try:
wf = wave.open(filepath, 'rb')
frames = wf.getnframes()
rate = wf.getframerate()
duration = frames / float(rate)
wf.close()
except:
duration = 0
recordings.append({
"filename": filename,
"size": stat.st_size,
"size_mb": stat.st_size / (1024 * 1024),
"date": datetime.fromtimestamp(stat.st_mtime).strftime('%Y-%m-%d %H:%M:%S'),
"duration": duration
})
return recordings
def get_local_ip():
"""Get local IP address"""
import socket
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
ip = s.getsockname()[0]
s.close()
return ip
except:
return "127.0.0.1"
def check_template_version(template_file):
"""Check if template needs regeneration based on version"""
if not os.path.exists(template_file):
return False # Template doesn't exist, needs creation
try:
with open(template_file, 'r') as f:
content = f.read()
# Look for version comment in template
if f'<!-- TEMPLATE_VERSION: {TEMPLATE_VERSION} -->' in content:
return True # Version matches, no regeneration needed
else:
return False # Version mismatch or missing, needs regeneration
except:
return False # Error reading, regenerate to be safe
def main():
"""Main entry point for the wedding phone application"""
# Create templates directory and HTML template
script_dir = os.path.dirname(os.path.abspath(__file__))
templates_dir = os.path.join(script_dir, 'templates')
os.makedirs(templates_dir, exist_ok=True)
# Check if template needs regeneration
template_file = os.path.join(templates_dir, 'index.html')
template_up_to_date = check_template_version(template_file)
if not template_up_to_date:
if os.path.exists(template_file):
print(f"Template version mismatch - regenerating template at {template_file}")
else:
print(f"Creating new template at {template_file}")
with open(template_file, 'w') as f:
f.write(f'''<!DOCTYPE html>
<html lang="en">
<head>
<!-- TEMPLATE_VERSION: {TEMPLATE_VERSION} -->
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Rotary Phone Control Panel</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;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
.header {
text-align: center;
color: white;
margin-bottom: 30px;
}
.header h1 {
font-size: 2.5em;
margin-bottom: 10px;
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
}
.header p {
font-size: 1.1em;
opacity: 0.9;
}
.card {
background: white;
border-radius: 15px;
padding: 25px;
margin-bottom: 20px;
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
}
.status-card {
display: flex;
align-items: center;
justify-content: space-between;
}
.status-indicator {
display: flex;
align-items: center;
gap: 10px;
}
.status-dot {
width: 20px;
height: 20px;
border-radius: 50%;
animation: pulse 2s infinite;
}
.status-dot.on-hook {
background: #10b981;
}
.status-dot.off-hook {
background: #ef4444;
}
.status-dot.recording {
background: #f59e0b;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.card h2 {
color: #333;
margin-bottom: 20px;
font-size: 1.5em;
border-bottom: 2px solid #667eea;
padding-bottom: 10px;
}
.upload-section {
border: 2px dashed #667eea;
border-radius: 10px;
padding: 30px;
text-align: center;
margin-bottom: 20px;
background: #f8f9fa;
}
.upload-section input[type="file"] {
display: none;
}
.upload-label {
display: inline-block;
padding: 12px 30px;
background: #667eea;
color: white;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
transition: background 0.3s;
}
.upload-label:hover {
background: #5568d3;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
transition: all 0.3s;
font-size: 0.95em;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover {
background: #5568d3;
transform: translateY(-2px);
}
.btn-success {
background: #10b981;
color: white;
}
.btn-success:hover {
background: #059669;
}
.btn-danger {
background: #ef4444;
color: white;
}
.btn-danger:hover {
background: #dc2626;
}
.btn-secondary {
background: #6b7280;
color: white;
}
.btn-secondary:hover {
background: #4b5563;
}
.recordings-grid {
display: grid;
gap: 15px;
}
.recording-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 15px;
background: #f8f9fa;
border-radius: 10px;
transition: all 0.3s;
}
.recording-item:hover {
background: #e9ecef;
transform: translateX(5px);
}
.recording-info {
flex-grow: 1;
}
.recording-info h3 {
color: #333;
font-size: 1em;
margin-bottom: 5px;
}
.recording-meta {
color: #6b7280;
font-size: 0.85em;
}
.recording-actions {
display: flex;
gap: 10px;
}
.no-recordings {
text-align: center;
padding: 40px;
color: #6b7280;
}
.alert {
padding: 15px;
border-radius: 8px;
margin-bottom: 20px;
}
.alert-success {
background: #d1fae5;
color: #065f46;
border-left: 4px solid #10b981;
}
.alert-error {
background: #fee2e2;
color: #991b1b;
border-left: 4px solid #ef4444;
}
.alert-info {
background: #dbeafe;
color: #1e40af;
border-left: 4px solid #3b82f6;
}
.filename-display {
font-family: 'Courier New', monospace;
background: #e9ecef;
padding: 5px 10px;
border-radius: 5px;
margin-top: 10px;
font-size: 0.9em;
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-top: 20px;
}
.stat-box {
background: #667eea;
color: white;
padding: 20px;
border-radius: 10px;
text-align: center;
}
.stat-value {
font-size: 2em;
font-weight: bold;
margin-bottom: 5px;
}
.stat-label {
font-size: 0.9em;
opacity: 0.9;
}
.button-group {
display: flex;
gap: 10px;
margin-top: 15px;
}
.active-greeting {
border: 2px solid #667eea;
background: #f0f4ff !important;
}
.active-greeting:hover {
background: #e5edff !important;
}
/* Audio player modal */
.audio-modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
align-items: center;
justify-content: center;
}
.audio-modal.show {
display: flex;
}
.audio-modal-content {
background: white;
padding: 30px;
border-radius: 15px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
max-width: 500px;
width: 90%;
}
.audio-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.audio-modal-header h3 {
margin: 0;
color: #333;
}
.close-modal {
font-size: 28px;
font-weight: bold;
color: #6b7280;
cursor: pointer;
border: none;
background: none;
padding: 0;
width: 30px;
height: 30px;
line-height: 1;
}
.close-modal:hover {
color: #333;
}
.audio-player-container {
text-align: center;
}
.audio-player-container audio {
width: 100%;
margin-top: 20px;
}
.audio-filename {
font-family: 'Courier New', monospace;
background: #f8f9fa;
padding: 10px;
border-radius: 5px;
margin-bottom: 10px;
word-break: break-all;
}
/* Volume slider */
input[type="range"] {
-webkit-appearance: none;
appearance: none;
cursor: pointer;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: #667eea;
cursor: pointer;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
transition: all 0.2s;
}
input[type="range"]::-webkit-slider-thumb:hover {
background: #5568d3;
transform: scale(1.2);
}
input[type="range"]::-moz-range-thumb {
width: 20px;
height: 20px;
border-radius: 50%;
background: #667eea;
cursor: pointer;
border: none;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
transition: all 0.2s;
}
input[type="range"]::-moz-range-thumb:hover {
background: #5568d3;
transform: scale(1.2);
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>📞 Rotary Phone Control Panel</h1>
<p>Manage your vintage phone system</p>
</div>
<div id="alert-container"></div>
<!-- Status Card -->
<div class="card status-card">
<div>
<h2>Phone Status</h2>
<div class="status-indicator">
<div class="status-dot {{ 'recording' if status.recording else ('off-hook' if status.status == 'off_hook' else 'on-hook') }}" id="status-dot"></div>
<span id="status-text">
{% if status.recording %}
🔴 Recording in progress...
{% elif status.status == 'off_hook' %}
📞 Handset off hook
{% else %}
✅ Ready (handset on hook)
{% endif %}
</span>
</div>
{% if status.current_recording %}
<p style="margin-top: 10px; color: #6b7280; font-size: 0.9em;">
Recording to: {{ status.current_recording.split('/')[-1] }}
</p>
{% endif %}
{% if extra_button_enabled %}
<div id="button-status" style="margin-top: 10px; padding: 8px; border-radius: 5px; background: {{ '#fef3c7' if status.extra_button_playing else '#f3f4f6' }}; transition: background 0.3s;">
<span style="font-size: 0.9em;">
{% if status.extra_button_playing %}
🔘 Button sound playing...
{% else %}
🔘 Button ready
{% endif %}
</span>
</div>
{% endif %}
</div>
<button class="btn btn-secondary" onclick="refreshStatus()">🔄 Refresh</button>
</div>
<!-- Volume & Delay Control Card -->
<div class="card">
<h2>🔊 Volume & Delay Control</h2>
<!-- Volume Control -->
<div style="padding: 20px; border-bottom: 1px solid #e5e7eb;">
<h3 style="margin: 0 0 15px 0; font-size: 1.1em; color: #333;">Volume</h3>
<div style="display: flex; align-items: center; gap: 20px;">
<span style="font-size: 1.5em;">🔇</span>
<input type="range" id="volume-slider" min="0" max="100" value="{{ volume }}"
style="flex: 1; height: 8px; border-radius: 5px; outline: none; background: linear-gradient(to right, #667eea 0%, #667eea {{ volume }}%, #ddd {{ volume }}%, #ddd 100%);">
<span style="font-size: 1.5em;">🔊</span>
<span id="volume-display" style="font-weight: bold; min-width: 50px; text-align: center; font-size: 1.2em;">{{ volume }}%</span>
</div>
<p style="margin-top: 10px; color: #6b7280; font-size: 0.85em; text-align: center;">
Playback volume for greeting messages
</p>
</div>
<!-- Greeting Delay Control -->
<div style="padding: 20px;">
<h3 style="margin: 0 0 15px 0; font-size: 1.1em; color: #333;">Greeting Delay</h3>
<div style="display: flex; align-items: center; gap: 20px;">
<span style="font-size: 1.5em;">⏱️</span>
<input type="range" id="delay-slider" min="0" max="10" value="{{ greeting_delay }}"
style="flex: 1; height: 8px; border-radius: 5px; outline: none; background: linear-gradient(to right, #667eea 0%, #667eea {{ greeting_delay * 10 }}%, #ddd {{ greeting_delay * 10 }}%, #ddd 100%);">
<span style="font-size: 1.5em;">⏰</span>
<span id="delay-display" style="font-weight: bold; min-width: 50px; text-align: center; font-size: 1.2em;">{{ greeting_delay }}s</span>
</div>
<p style="margin-top: 10px; color: #6b7280; font-size: 0.85em; text-align: center;">
Delay before greeting plays after pickup (0-10 seconds)
</p>
</div>
</div>
<!-- Greeting Messages Card -->
<div class="card">
<h2>🎵 Greeting Messages</h2>
<div class="alert alert-info">
Active greeting: <strong>{{ active_greeting }}</strong>
{% if extra_button_enabled %}
<br>🔘 Extra button sound: <strong>{{ extra_button_sound }}</strong>
{% endif %}
</div>
<div class="upload-section">
<p style="margin-bottom: 15px; color: #6b7280;">
Upload WAV files to play when the handset is picked up
</p>
<form id="upload-form" enctype="multipart/form-data">
<label for="soundfile" class="upload-label">
📁 Choose WAV File(s)
</label>
<input type="file" id="soundfile" name="soundfile" accept=".wav" multiple onchange="handleFileSelect(this)">
<div id="filename-display" class="filename-display" style="display: none;"></div>
</form>
<div class="button-group">
<button class="btn btn-primary" onclick="uploadGreeting()" id="upload-btn" disabled>
⬆️ Upload Greeting(s)
</button>
<button class="btn btn-secondary" onclick="restoreDefault()">
🔄 Generate Default Dial Tone
</button>
</div>
</div>
<!-- Greetings List -->
{% if greetings %}
<h3 style="margin-top: 30px; margin-bottom: 15px; color: #333;">Available Greetings</h3>
<div class="recordings-grid">
{% for greeting in greetings %}
<div class="recording-item {% if greeting.is_active %}active-greeting{% endif %}" id="greeting-{{ loop.index }}">
<div class="recording-info">
<h3>
{% if greeting.is_active %}{% endif %}
{% if greeting.is_button_sound and extra_button_enabled %}🔘{% endif %}
{{ greeting.filename }}
</h3>
<div class="recording-meta">
📅 {{ greeting.date }} |
⏱️ {{ "%.1f"|format(greeting.duration) }}s |
💾 {{ "%.2f"|format(greeting.size_mb) }} MB
</div>
</div>
<div class="recording-actions">
<button class="btn btn-success" onclick="playAudio('greeting', '{{ greeting.filename }}')">
▶️ Play
</button>
{% if not greeting.is_active %}
<button class="btn btn-primary" onclick="setActiveGreeting('{{ greeting.filename }}')">
⭐ Set Active
</button>
{% if extra_button_enabled and not greeting.is_button_sound %}
<button class="btn btn-primary" onclick="setExtraButtonSound('{{ greeting.filename }}')">
🔘 Set Button
</button>
{% endif %}
<button class="btn btn-danger" onclick="deleteGreeting('{{ greeting.filename }}', {{ loop.index }})">
🗑️ Delete
</button>
{% else %}
<button class="btn btn-secondary" disabled>
✓ Active
</button>
{% if extra_button_enabled and not greeting.is_button_sound %}
<button class="btn btn-primary" onclick="setExtraButtonSound('{{ greeting.filename }}')">
🔘 Set Button
</button>
{% endif %}
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="no-recordings" style="margin-top: 20px;">
<p style="font-size: 2em; margin-bottom: 10px;">🎵</p>
<p>No greeting messages uploaded yet. Upload your first greeting!</p>
</div>
{% endif %}
</div>
<!-- USB Backup Card -->
<div class="card">
<h2>💾 USB Backup</h2>
<div style="padding: 20px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
<div>
<p style="margin: 0; color: #6b7280; font-size: 0.9em;">
Automatic backup to USB drives with CRC verification
</p>
</div>
<button class="btn btn-primary" onclick="refreshBackupStatus()">🔄 Refresh</button>
</div>
<div id="backup-status-container">
<p style="text-align: center; color: #6b7280;">Loading backup status...</p>
</div>
<div style="margin-top: 20px; text-align: center;">
<button class="btn btn-success" onclick="testBackup()">
🧪 Test Backup
</button>
</div>
</div>
</div>
<!-- Recordings Card -->
<div class="card">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h2 style="margin: 0;">🎙️ Recordings</h2>
{% if recordings %}
<button class="btn btn-primary" onclick="downloadAllRecordings()" style="font-size: 0.9em;">
📦 Download All as ZIP
</button>
{% endif %}
</div>
{% if recordings %}
<div class="stats">
<div class="stat-box">
<div class="stat-value">{{ recordings|length }}</div>
<div class="stat-label">Total Recordings</div>
</div>
<div class="stat-box">
<div class="stat-value">{{ "%.1f"|format(recordings|sum(attribute='size_mb')) }} MB</div>
<div class="stat-label">Total Size</div>
</div>
<div class="stat-box">
<div class="stat-value">{{ "%.1f"|format(recordings|sum(attribute='duration')/60) }} min</div>
<div class="stat-label">Total Duration</div>
</div>
</div>
<div class="recordings-grid" style="margin-top: 30px;">
{% for recording in recordings %}
<div class="recording-item" id="recording-{{ loop.index }}">
<div class="recording-info">
<h3>{{ recording.filename }}</h3>
<div class="recording-meta">
📅 {{ recording.date }} |
⏱️ {{ "%.1f"|format(recording.duration) }}s |
💾 {{ "%.2f"|format(recording.size_mb) }} MB
</div>
</div>
<div class="recording-actions">
<button class="btn btn-success" onclick="playAudio('recording', '{{ recording.filename }}')">
▶️ Play
</button>
<button class="btn btn-primary" onclick="downloadRecording('{{ recording.filename }}')">
⬇️ Download
</button>
<button class="btn btn-danger" onclick="deleteRecording('{{ recording.filename }}', {{ loop.index }})">
🗑️ Delete
</button>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="no-recordings">
<p style="font-size: 3em; margin-bottom: 10px;">📭</p>
<p>No recordings yet. Pick up the phone to start recording!</p>
</div>
{% endif %}
</div>
</div>
<!-- Audio Player Modal -->
<div id="audio-modal" class="audio-modal" onclick="closeAudioModal(event)">
<div class="audio-modal-content" onclick="event.stopPropagation()">
<div class="audio-modal-header">
<h3>🎵 Audio Player</h3>
<button class="close-modal" onclick="closeAudioModal()">&times;</button>
</div>
<div class="audio-player-container">
<div class="audio-filename" id="audio-filename"></div>
<audio id="audio-player" controls autoplay>
Your browser does not support the audio element.
</audio>
</div>
</div>
</div>
<script>
let selectedFiles = null;
let volumeUpdateTimer = null;
let delayUpdateTimer = null;
// Volume control
const volumeSlider = document.getElementById('volume-slider');
const volumeDisplay = document.getElementById('volume-display');
volumeSlider.addEventListener('input', function() {
const value = this.value;
volumeDisplay.textContent = value + '%';
// Update slider background gradient
this.style.background = `linear-gradient(to right, #667eea 0%, #667eea ${value}%, #ddd ${value}%, #ddd 100%)`;
// Debounce API call
clearTimeout(volumeUpdateTimer);
volumeUpdateTimer = setTimeout(() => {
updateVolume(value);
}, 300);
});
function updateVolume(volume) {
fetch('/api/volume', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ volume: parseInt(volume) })
})
.then(response => response.json())
.then(data => {
if (data.success) {
console.log('Volume updated to ' + data.volume + '%');
}
})
.catch(error => console.error('Error updating volume:', error));
}
// Greeting Delay control
const delaySlider = document.getElementById('delay-slider');
const delayDisplay = document.getElementById('delay-display');
delaySlider.addEventListener('input', function() {
const value = this.value;
delayDisplay.textContent = value + 's';
// Update slider background gradient (0-10 range = 0-100%)
const percent = (value / 10) * 100;
this.style.background = `linear-gradient(to right, #667eea 0%, #667eea ${percent}%, #ddd ${percent}%, #ddd 100%)`;
// Debounce API call
clearTimeout(delayUpdateTimer);
delayUpdateTimer = setTimeout(() => {
updateGreetingDelay(value);
}, 300);
});
function updateGreetingDelay(delay) {
fetch('/api/greeting_delay', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ delay: parseInt(delay) })
})
.then(response => response.json())
.then(data => {
if (data.success) {
console.log('Greeting delay updated to ' + data.delay + 's');
}
})
.catch(error => console.error('Error updating delay:', error));
}
function handleFileSelect(input) {
if (input.files && input.files.length > 0) {
selectedFiles = input.files;
const fileCount = selectedFiles.length;
const displayText = fileCount === 1
? '📄 ' + selectedFiles[0].name
: `📄 ${fileCount} files selected`;
document.getElementById('filename-display').textContent = displayText;
document.getElementById('filename-display').style.display = 'block';
document.getElementById('upload-btn').disabled = false;
}
}
async function uploadGreeting() {
if (!selectedFiles || selectedFiles.length === 0) {
showAlert('Please select file(s) first', 'error');
return;
}
document.getElementById('upload-btn').disabled = true;
document.getElementById('upload-btn').textContent = '⏳ Uploading...';
let successCount = 0;
let errorCount = 0;
for (let i = 0; i < selectedFiles.length; i++) {
const formData = new FormData();
formData.append('soundfile', selectedFiles[i]);
try {
const response = await fetch('/upload_greeting', {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.success) {
successCount++;
} else {
errorCount++;
}
} catch (error) {
errorCount++;
}
}
if (errorCount === 0) {
showAlert(`${successCount} greeting(s) uploaded successfully! `, 'success');
} else {
showAlert(`${successCount} succeeded, ${errorCount} failed`, 'error');
}
setTimeout(() => location.reload(), 1500);
}
function restoreDefault() {
if (confirm('Restore the default dial tone sound?')) {
fetch('/restore_default_sound', { method: 'POST' })
.then(response => response.json())
.then(data => {
if (data.success) {
showAlert('Default dial tone restored! ✓', 'success');
setTimeout(() => location.reload(), 1500);
}
})
.catch(error => showAlert('Error: ' + error, 'error'));
}
}
function downloadRecording(filename) {
window.location.href = '/download/' + filename;
}
function downloadAllRecordings() {
window.location.href = '/download_all';
}
function deleteRecording(filename, index) {
if (confirm('Delete this recording?')) {
fetch('/delete/' + filename, { method: 'POST' })
.then(response => response.json())
.then(data => {
if (data.success) {
document.getElementById('recording-' + index).remove();
showAlert('Recording deleted! ✓', 'success');
setTimeout(() => location.reload(), 1500);
}
})
.catch(error => showAlert('Error: ' + error, 'error'));
}
}
function playAudio(type, filename) {
const modal = document.getElementById('audio-modal');
const player = document.getElementById('audio-player');
const filenameDisplay = document.getElementById('audio-filename');
filenameDisplay.textContent = filename;
player.src = '/play_audio/' + type + '/' + filename;
modal.classList.add('show');
}
function closeAudioModal(event) {
const modal = document.getElementById('audio-modal');
const player = document.getElementById('audio-player');
player.pause();
player.src = '';
modal.classList.remove('show');
}
function setActiveGreeting(filename) {
fetch('/set_active_greeting', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ filename: filename })
})
.then(response => response.json())
.then(data => {
if (data.success) {
showAlert(`Greeting set to '${filename}'! `, 'success');
setTimeout(() => location.reload(), 1500);
} else {
showAlert('Error: ' + data.error, 'error');
}
})
.catch(error => showAlert('Error: ' + error, 'error'));
}
function setExtraButtonSound(filename) {
fetch('/set_extra_button_sound', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ filename: filename })
})
.then(response => response.json())
.then(data => {
if (data.success) {
showAlert(`Button sound set to '${filename}'! `, 'success');
setTimeout(() => location.reload(), 1500);
} else {
showAlert('Error: ' + data.error, 'error');
}
})
.catch(error => showAlert('Error: ' + error, 'error'));
}
function deleteGreeting(filename, index) {
if (confirm(`Delete greeting '${filename}'?`)) {
fetch('/delete_greeting/' + filename, { method: 'POST' })
.then(response => response.json())
.then(data => {
if (data.success) {
document.getElementById('greeting-' + index).remove();
showAlert('Greeting deleted! ✓', 'success');
setTimeout(() => location.reload(), 1500);
} else {
showAlert('Error: ' + data.error, 'error');
}
})
.catch(error => showAlert('Error: ' + error, 'error'));
}
}
function refreshStatus() {
fetch('/api/status')
.then(response => response.json())
.then(data => {
// Update status text
const statusText = document.getElementById('status-text');
const statusDot = document.getElementById('status-dot');
if (data.recording) {
statusText.textContent = '🔴 Recording in progress...';
statusDot.className = 'status-dot recording';
} else if (data.status === 'off_hook') {
statusText.textContent = '📞 Handset off hook';
statusDot.className = 'status-dot off-hook';
} else {
statusText.textContent = '✅ Ready (handset on hook)';
statusDot.className = 'status-dot on-hook';
}
// Update extra button status
const buttonStatus = document.getElementById('button-status');
if (buttonStatus) {
if (data.extra_button_playing) {
buttonStatus.style.background = '#fef3c7';
buttonStatus.querySelector('span').textContent = '🔘 Button sound playing...';
} else {
buttonStatus.style.background = '#f3f4f6';
buttonStatus.querySelector('span').textContent = '🔘 Button ready';
}
}
})
.catch(error => console.error('Error refreshing status:', error));
}
function showAlert(message, type) {
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 = `
<div class="alert alert-warning">
USB backup is disabled in configuration
</div>
`;
return;
}
let html = `
<div style="margin-bottom: 15px;">
<p style="margin: 5px 0;"><strong>CRC Verification:</strong> ${data.verify_crc ? '✅ Enabled' : '❌ Disabled'}</p>
<p style="margin: 5px 0;"><strong>Backup on Write:</strong> ${data.backup_on_write ? '✅ Enabled' : '❌ Disabled'}</p>
</div>
<div style="display: grid; gap: 15px;">
`;
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 += `
<div style="border: 2px solid ${statusColor}; border-radius: 8px; padding: 15px; background: ${statusColor}15;">
<div style="display: flex; justify-content: space-between; align-items: center;">
<div>
<p style="margin: 0; font-weight: bold; font-size: 1.1em;">${statusIcon} ${drive.name}</p>
<p style="margin: 5px 0 0 0; color: #6b7280; font-size: 0.85em;">${drive.path}</p>
</div>
<div style="text-align: right;">
<p style="margin: 0; font-weight: bold; color: ${statusColor};">${statusText}</p>
${drive.free_space_mb ? `<p style="margin: 5px 0 0 0; color: #6b7280; font-size: 0.85em;">${drive.free_space_mb.toFixed(0)} MB free</p>` : ''}
</div>
</div>
${drive.error ? `<p style="margin: 10px 0 0 0; color: #dc2626; font-size: 0.85em;">Error: ${drive.error}</p>` : ''}
</div>
`;
});
html += '</div>';
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')
.then(response => response.json())
.then(data => {
const statusDot = document.getElementById('status-dot');
const statusText = document.getElementById('status-text');
statusDot.className = 'status-dot';
if (data.recording) {
statusDot.classList.add('recording');
statusText.textContent = '🔴 Recording in progress...';
} else if (data.status === 'off_hook') {
statusDot.classList.add('off-hook');
statusText.textContent = '📞 Handset off hook';
} else {
statusDot.classList.add('on-hook');
statusText.textContent = '✅ Ready (handset on hook)';
}
});
}, 5000);
</script>
</body>
</html>''')
else:
print(f"Template up-to-date (v{TEMPLATE_VERSION}) at {template_file}")
# Start phone handling in separate thread
phone_thread = threading.Thread(target=phone.phone_loop, daemon=True)
phone_thread.start()
# Get and display local IP
local_ip = get_local_ip()
print("\n" + "="*60)
print(f"🚀 Wedding Phone System Starting...")
print(f"Web Interface Available At:")
print(f" http://{local_ip}:{WEB_PORT}")
print(f" http://localhost:{WEB_PORT}")
print("="*60 + "\n")
# Start production WSGI server (waitress)
print(f"Starting production server on 0.0.0.0:{WEB_PORT}")
serve(app, host='0.0.0.0', port=WEB_PORT, threads=4)
if __name__ == "__main__":
main()