#!/usr/bin/env python3 """ Web Server for Scent Diffuser Control Provides a web interface with telephone exchange aesthetic for controlling BLE-connected diffusers """ import asyncio import json import os import threading from pathlib import Path from typing import Dict, Optional, List from flask import Flask, render_template, jsonify, request from flask_socketio import SocketIO, emit from bleak import BleakClient, BleakScanner try: import pygame AUDIO_AVAILABLE = True except ImportError: AUDIO_AVAILABLE = False # Import from existing client SERVICE_UUID = "4fafc201-1fb5-459e-8fcc-c5c9c331914b" CHARACTERISTIC_UUID = "beb5483e-36e1-4688-b7f5-ea07361b26a8" DEVICE_NAME_PATTERN = "ESP32-C3" AUDIO_FOLDER = "Audio" app = Flask(__name__) app.config['SECRET_KEY'] = 'scent-diffuser-secret-key' socketio = SocketIO(app, cors_allowed_origins="*", async_mode='threading') class DeviceConnection: """Represents a connected ESP32-C3 device""" def __init__(self, name: str, address: str, client: BleakClient): self.name = name self.address = address self.client = client self.id = address self.is_online = True self.state = "OFF" # ON or OFF # Global device connections connections: Dict[str, DeviceConnection] = {} # Map dial numbers (0-9, *, #) to device IDs number_to_device: Dict[str, str] = {} # e.g., {'1': 'AA:BB:CC:DD:EE:FF', '2': '11:22:33:44:55:66', ...} active_scents: Dict[str, bool] = {} # Track which numbers are currently active ble_loop = None ble_thread = None scanning = False def create_notification_handler(device_id: str): """Create a notification handler for a specific device""" def notification_handler(sender, data): try: message = data.decode('utf-8') socketio.emit('device_message', { 'device_id': device_id, 'message': message }) except Exception as e: print(f"Error in notification handler: {e}") return notification_handler def play_audio_file(filename: Optional[str] = None): """Play MP3 files from the Audio folder""" if not AUDIO_AVAILABLE: return False, "Audio playback not available. Install pygame: pip install pygame" script_dir = Path(__file__).parent audio_dir = script_dir / AUDIO_FOLDER if not audio_dir.exists(): return False, f"Audio folder not found: {audio_dir}" mp3_files = list(audio_dir.glob("*.mp3")) if not mp3_files: return False, f"No MP3 files found in {audio_dir}" def play_audio(): try: pygame.mixer.init() if filename: audio_file = audio_dir / filename if not audio_file.exists(): audio_file = mp3_files[0] else: audio_file = mp3_files[0] pygame.mixer.music.load(str(audio_file)) pygame.mixer.music.play() while pygame.mixer.music.get_busy(): pygame.time.wait(100) pygame.mixer.quit() socketio.emit('audio_status', {'status': 'finished', 'file': audio_file.name}) except Exception as e: socketio.emit('audio_status', {'status': 'error', 'error': str(e)}) pygame.mixer.quit() audio_thread = threading.Thread(target=play_audio, daemon=True) audio_thread.start() return True, f"Playing: {audio_file.name}" async def scan_for_devices(): """Scan for ESP32-C3 BLE devices""" global scanning scanning = True socketio.emit('scan_status', {'scanning': True}) try: devices = await BleakScanner.discover(timeout=10.0) esp32_devices = [] for device in devices: if device.name and DEVICE_NAME_PATTERN in device.name: esp32_devices.append(device) socketio.emit('scan_status', {'scanning': False, 'found': len(esp32_devices)}) return esp32_devices except Exception as e: socketio.emit('scan_status', {'scanning': False, 'error': str(e)}) return [] finally: scanning = False async def connect_to_device(device): """Connect to a single ESP32-C3 device""" client = BleakClient(device.address) try: await client.connect() if not client.is_connected: return None device_conn = DeviceConnection(device.name, device.address, client) # Subscribe to notifications handler = create_notification_handler(device_conn.id) await client.start_notify(CHARACTERISTIC_UUID, handler) # Read initial value try: value = await client.read_gatt_char(CHARACTERISTIC_UUID) except: pass return device_conn except Exception as e: print(f"Error connecting to {device.name}: {e}") return None async def send_to_device(device_conn: DeviceConnection, message: str) -> bool: """Send a message to a specific device""" if not device_conn.client.is_connected: device_conn.is_online = False return False try: await device_conn.client.write_gatt_char( CHARACTERISTIC_UUID, message.encode('utf-8'), response=False ) # Update state based on command msg_upper = message.upper().strip() if msg_upper in ['ON', '1', 'TRUE']: device_conn.state = "ON" elif msg_upper in ['OFF', '0', 'FALSE']: device_conn.state = "OFF" socketio.emit('device_state', { 'device_id': device_conn.id, 'state': device_conn.state, 'is_online': device_conn.is_online }) return True except Exception as e: device_conn.is_online = False socketio.emit('device_state', { 'device_id': device_conn.id, 'is_online': False, 'error': str(e) }) return False async def manage_devices(): """Main async function to manage BLE devices""" while True: try: # Scan for devices esp32_devices = await scan_for_devices() # Connect to new devices for device in esp32_devices: if device.address not in connections: device_conn = await connect_to_device(device) if device_conn: connections[device_conn.id] = device_conn socketio.emit('device_connected', { 'device_id': device_conn.id, 'name': device_conn.name, 'address': device_conn.address, 'state': device_conn.state, 'is_online': True }) # Auto-assign to next available number if not all mapped valid_numbers = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '#'] if device_conn.id not in number_to_device.values(): for num in valid_numbers: if num not in number_to_device: number_to_device[num] = device_conn.id active_scents[num] = False socketio.emit('dial_mapping', { 'mapping': number_to_device, 'active_scents': active_scents }) break # Check connection status of existing devices for device_id, conn in list(connections.items()): if not conn.client.is_connected: conn.is_online = False socketio.emit('device_disconnected', {'device_id': device_id}) try: await conn.client.disconnect() except: pass del connections[device_id] # Wait before next scan await asyncio.sleep(30) # Rescan every 30 seconds except Exception as e: print(f"Error in device management: {e}") await asyncio.sleep(5) def run_ble_loop(): """Run the BLE event loop in a separate thread""" global ble_loop ble_loop = asyncio.new_event_loop() asyncio.set_event_loop(ble_loop) ble_loop.run_until_complete(manage_devices()) # Flask Routes @app.route('/') def index(): """Serve the main web interface""" return render_template('index.html') @app.route('/api/devices', methods=['GET']) def get_devices(): """Get list of connected devices""" devices = [] for device_id, conn in connections.items(): devices.append({ 'id': device_id, 'name': conn.name, 'address': conn.address, 'state': conn.state, 'is_online': conn.is_online }) return jsonify(devices) @app.route('/api/scan', methods=['POST']) def trigger_scan(): """Manually trigger a device scan""" if not scanning and ble_loop: asyncio.run_coroutine_threadsafe(scan_for_devices(), ble_loop) return jsonify({'status': 'scanning'}) elif scanning: return jsonify({'status': 'already_scanning'}) else: return jsonify({'status': 'error', 'message': 'BLE loop not initialized'}), 500 @app.route('/api/device//control', methods=['POST']) def control_device(device_id): """Control a specific device""" data = request.json command = data.get('command', '') if device_id in connections: device_conn = connections[device_id] # Run async function in event loop if ble_loop: asyncio.run_coroutine_threadsafe( send_to_device(device_conn, command), ble_loop ) return jsonify({'status': 'sent', 'command': command}) return jsonify({'status': 'error', 'message': 'Device not found'}), 404 @app.route('/api/device/all/control', methods=['POST']) def control_all_devices(): """Control all devices""" data = request.json command = data.get('command', '') if ble_loop: for device_conn in connections.values(): asyncio.run_coroutine_threadsafe( send_to_device(device_conn, command), ble_loop ) return jsonify({'status': 'sent', 'command': command, 'devices': len(connections)}) @app.route('/api/audio/play', methods=['POST']) def play_audio(): """Play audio file""" data = request.json filename = data.get('filename', None) success, message = play_audio_file(filename) if success: return jsonify({'status': 'playing', 'message': message}) else: return jsonify({'status': 'error', 'message': message}), 400 @app.route('/api/audio/list', methods=['GET']) def list_audio(): """List available audio files""" script_dir = Path(__file__).parent audio_dir = script_dir / AUDIO_FOLDER if not audio_dir.exists(): return jsonify({'files': []}) mp3_files = [f.name for f in audio_dir.glob("*.mp3")] return jsonify({'files': mp3_files}) @app.route('/api/dial/map', methods=['POST']) def map_dial_number(): """Map a dial number (0-9, *, #) to a device""" data = request.json number = data.get('number', '').upper() device_id = data.get('device_id', '') valid_numbers = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '#'] if number not in valid_numbers: return jsonify({'status': 'error', 'message': 'Invalid number. Must be 0-9, *, or #'}), 400 if device_id and device_id not in connections: return jsonify({'status': 'error', 'message': 'Device not found'}), 404 if device_id: number_to_device[number] = device_id elif number in number_to_device: del number_to_device[number] return jsonify({ 'status': 'success', 'mapping': number_to_device, 'active_scents': active_scents }) @app.route('/api/dial/mapping', methods=['GET']) def get_dial_mapping(): """Get current dial number to device mapping""" return jsonify({ 'mapping': number_to_device, 'active_scents': active_scents }) @app.route('/api/dial/', methods=['POST']) def dial_number(number): """Activate or deactivate a scent by dialing a number""" number = number.upper() data = request.json action = data.get('action', 'toggle') # 'on', 'off', or 'toggle' valid_numbers = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '#'] if number not in valid_numbers: return jsonify({'status': 'error', 'message': 'Invalid number'}), 400 if number not in number_to_device: return jsonify({'status': 'error', 'message': f'Number {number} not mapped to any device'}), 404 device_id = number_to_device[number] if device_id not in connections: return jsonify({'status': 'error', 'message': 'Device not connected'}), 404 # Determine command based on action if action == 'on': command = 'ON' active_scents[number] = True elif action == 'off': command = 'OFF' active_scents[number] = False else: # toggle current_state = active_scents.get(number, False) command = 'OFF' if current_state else 'ON' active_scents[number] = not current_state device_conn = connections[device_id] if ble_loop: asyncio.run_coroutine_threadsafe( send_to_device(device_conn, command), ble_loop ) # Emit update to all clients socketio.emit('scent_state', { 'number': number, 'active': active_scents[number], 'device_id': device_id }) return jsonify({ 'status': 'success', 'number': number, 'action': command, 'active': active_scents[number] }) @app.route('/api/dial/all/off', methods=['POST']) def dial_all_off(): """Turn off all active scents""" turned_off = [] for number, is_active in list(active_scents.items()): if is_active and number in number_to_device: device_id = number_to_device[number] if device_id in connections: device_conn = connections[device_id] if ble_loop: asyncio.run_coroutine_threadsafe( send_to_device(device_conn, 'OFF'), ble_loop ) active_scents[number] = False turned_off.append(number) socketio.emit('all_scents_off', {'turned_off': turned_off}) return jsonify({ 'status': 'success', 'turned_off': turned_off }) # WebSocket Events @socketio.on('connect') def handle_connect(): """Handle client connection""" emit('connected', {'message': 'Connected to scent diffuser server'}) # Send current device list devices = [] for device_id, conn in connections.items(): devices.append({ 'id': device_id, 'name': conn.name, 'address': conn.address, 'state': conn.state, 'is_online': conn.is_online }) emit('device_list', {'devices': devices}) # Send dial mapping emit('dial_mapping', { 'mapping': number_to_device, 'active_scents': active_scents }) @socketio.on('disconnect') def handle_disconnect(): """Handle client disconnection""" print('Client disconnected') @socketio.on('control_device') def handle_control_device(data): """Handle device control via WebSocket""" device_id = data.get('device_id') command = data.get('command', '') if device_id == 'all': if ble_loop: for device_conn in connections.values(): asyncio.run_coroutine_threadsafe( send_to_device(device_conn, command), ble_loop ) elif device_id in connections: device_conn = connections[device_id] if ble_loop: asyncio.run_coroutine_threadsafe( send_to_device(device_conn, command), ble_loop ) @socketio.on('dial_number') def handle_dial_number(data): """Handle dial number activation via WebSocket""" number = str(data.get('number', '')).upper() action = data.get('action', 'toggle') valid_numbers = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '#'] if number not in valid_numbers: emit('dial_error', {'message': 'Invalid number'}) return if number not in number_to_device: emit('dial_error', {'message': f'Number {number} not mapped to any device'}) return device_id = number_to_device[number] if device_id not in connections: emit('dial_error', {'message': 'Device not connected'}) return # Determine command based on action if action == 'on': command = 'ON' active_scents[number] = True elif action == 'off': command = 'OFF' active_scents[number] = False else: # toggle current_state = active_scents.get(number, False) command = 'OFF' if current_state else 'ON' active_scents[number] = not current_state device_conn = connections[device_id] if ble_loop: asyncio.run_coroutine_threadsafe( send_to_device(device_conn, command), ble_loop ) # Emit update to all clients socketio.emit('scent_state', { 'number': number, 'active': active_scents[number], 'device_id': device_id }) if __name__ == '__main__': # Start BLE management in background thread ble_thread = threading.Thread(target=run_ble_loop, daemon=True) ble_thread.start() # Start Flask server print("Starting web server on http://localhost:5000") print("Open your browser to http://localhost:5000") socketio.run(app, host='0.0.0.0', port=5000, debug=True)