import os, threading, hmac from datetime import datetime, timedelta from zoneinfo import ZoneInfo from flask import Flask, render_template, request, jsonify, Response from flask_socketio import SocketIO from dotenv import load_dotenv # Charger la configuration AVANT les imports des services load_dotenv("/opt/alfred/.env") load_dotenv( "/opt/alfred/app/config/control_center.env", override=True ) from services.audio import listen_once, speak_async, tts_backend_detect from services.wakeword import WakeWordListener from services.alfred_duplex import DuplexMonitor # ALFRED_DUPLEX_BARGE_IN_V1 from services.piper_tts import stop_playback as duplex_stop_piper from services import admin_console # ALFRED_ADMIN_PAGES_V1 from services.hermes_client import ask_hermes from services import alfred_timers, alfred_context_images # ALFRED_TIMERS_CONTEXT_V1 from services import alfred_features # ALFRED_FEATURES_V45 from services import audio_settings # ALFRED_AUDIO_SETTINGS_V1 from services.camera import video_stream from services import ha_views, alfred_views_controller from services import camera_settings, ha_weather, weather_outfits load_dotenv() load_dotenv(os.path.join(os.path.dirname(__file__), "config", "control_center.env"), override=True) # ALFRED_ADMIN_PAGES_V1 ## from services.audio import get_vosk_model print("[ALFRED] Préchargement des modèles vocaux...", flush=True) get_vosk_model(os.environ["WAKE_VOSK_MODEL_PATH"]) get_vosk_model(os.environ["VOSK_MODEL_PATH"]) print("[ALFRED] Modèles vocaux prêts.", flush=True) ## app=Flask(__name__); socketio=SocketIO(app,cors_allowed_origins="*") STATE={"mode":"idle","title":"Alfred","content":{"type":"text","text":"À votre écoute."},"partial":""} STATE_LOCK = threading.RLock() IMAGE_REVISION = 0 # Incrémenté à chaque image envoyée par ComfyUI. conversation_lock=threading.Lock() mic_stop_event=threading.Event() # Service temps autonome (survit aux conversations Hermes) alfred_timer_store = alfred_timers.register(app, socketio, speak_async, audio_settings) # ALFRED_TIMERS_CONTEXT_V1 audio_settings.initialize() audio_settings.update_settings({"mic_gain": 30}) # ALFRED_DUPLEX_BARGE_IN_V1: interruption audio sans interruption des tâches Hermes duplex_monitor = DuplexMonitor(muted=audio_settings.mic_muted, on_interrupt=duplex_stop_piper, on_status=lambda event: socketio.emit("alfred_duplex_interrupted" if event.get("interrupted") else "alfred_duplex_activity", event)) # Recharge les réglages V4L2 enregistrés, sans interrompre Alfred si la webcam est absente. camera_settings.restore_saved_settings() def emit_state(mode=None, **extra): with STATE_LOCK: if mode: STATE["mode"] = mode STATE.update(extra) socketio.emit("alfred_state", dict(STATE)) def audio_level(level): socketio.emit("audio_level",{"level":max(0,min(float(level),1))}) def conversation(score=1.0, injected_text=None): # ALFRED_DUPLEX_CONVERSATION_V2 if not conversation_lock.acquire(blocking=False): return pending_duplex_text = None # ALFRED_DUPLEX_CONVERSATION_V2 try: emit_state("listening", partial="", transcript="", wake_score=score) print("[ALFRED] Début écoute STT", flush=True) if injected_text is not None: # ALFRED_DUPLEX_CONVERSATION_V2 from services.audio import STTResult r = STTResult(text=injected_text) else: r = listen_once( stop_event=mic_stop_event, on_partial=lambda p: emit_state("listening", partial=p), on_level=audio_level ) print(f"[ALFRED] Résultat STT : {r.text!r}, erreur : {r.error!r}", flush=True) if audio_settings.mic_muted(): emit_state("idle", partial="") return if r.error: emit_state( "error", content={"type": "text", "text": r.error} ) return if not r.text: emit_state("idle", partial="") return emit_state( "thinking", partial="", transcript=r.text, content={"type": "text", "text": r.text} ) # DONA_SUITE_V9: intents locaux explicites, sinon continuer vers Hermes. if _dona_suite_v9.is_request(r.text): _v9_speech=_dona_suite_v9.handle_and_emit(r.text,socketio) if _v9_speech and not audio_settings.speaker_muted(): emit_state('speaking') _v9_done=threading.Event() _v9_ok=speak_async(_v9_speech,on_end=_v9_done.set) if _v9_ok:_v9_done.wait(timeout=120) emit_state('idle',partial='') return # DONA_NAV_V83: cartes et itinéraires à la demande, sans LLM. if _dona_nav.is_request(r.text): _nav_reply=_dona_nav.handle_and_emit(r.text,socketio,STATE,STATE_LOCK) if _nav_reply and not audio_settings.speaker_muted(): emit_state('speaking') _nav_done=threading.Event() _nav_ok=speak_async(_nav_reply,on_end=lambda: (_nav_done.set(),socketio.emit('alfred_tts_finished',{'source':'voice'}))) if _nav_ok:_nav_done.wait(timeout=120) emit_state('idle',partial='') return # DONA_YOUTUBE_V82 : recherche publique et contrôle du lecteur à la voix. if _dona_youtube.is_search(r.text): _speech=_dona_youtube.search_and_emit(r.text, socketio, STATE, STATE_LOCK) if _speech and not audio_settings.speaker_muted(): emit_state('speaking') _evt=threading.Event() _ok=speak_async(_speech,on_end=_evt.set) if _ok: _evt.wait(timeout=120) emit_state('idle',partial='') return if _dona_youtube.is_control(r.text,_dona_youtube.player_active()): _dona_youtube.control_and_emit(r.text,socketio) emit_state('idle',partial='') return # DONA_V8_DYNAMIC — creation visuelle explicite, source Hermes/MCP ou API directe. if _dona_dynamic.is_dynamic_request(r.text): _board = _dona_dynamic.build_dashboard(r.text, _dona_dashboard_agent.ask_dashboard) _payload = {'type':'dona_dynamic', 'dashboard':_board} with STATE_LOCK: STATE['content'] = _payload socketio.emit('alfred_render', _payload) if not audio_settings.speaker_muted(): emit_state('speaking') _finished = threading.Event() _started = speak_async('Voici ton tableau de bord : ' + _board['title'], on_end=_finished.set) if _started: _finished.wait(timeout=120) emit_state('idle', partial='') return # Vue dynamique : commandes déterministes exécutées sans faire # dépendre l'affichage des capacités ou sessions Hermes. visual = None try: visual = alfred_views_controller.route(r.text, (STATE.get('content') or {}).get('type')) except (ha_views.HAError, ValueError) as exc: print(f"[ALFRED] Vue indisponible : {exc}", flush=True) visual = ({"type":"text", "text":str(exc)}, str(exc)) if visual is not None: content, speech = visual with STATE_LOCK: STATE["content"] = content socketio.emit("alfred_render", content) if speech and not audio_settings.speaker_muted(): emit_state("speaking") done = threading.Event() speak_async(speech, on_end=lambda: (done.set(), socketio.emit('alfred_tts_finished', {'source':'voice'}))) done.wait(timeout=120) emit_state("idle", partial="") return # ALFRED_LISTES_GLASS_V1_VOICE_START # Commandes simples garanties locales ; jamais de confirmation inventée par Hermes. try: _lists_reply = _alfred_handle_list_voice( r.text, notify=lambda: socketio.emit('alfred_lists_changed', {'source':'voice'})) except Exception as _lists_error: print('[ALFRED LISTES] Erreur :', _lists_error, flush=True) _lists_reply = 'Je n’ai pas pu modifier la liste. Vérifie le stockage d’Alfred.' if _lists_reply is not None: _lists_display = {'type':'text', 'text':_lists_reply} with STATE_LOCK: STATE['content'] = _lists_display socketio.emit('alfred_render', _lists_display) if _lists_reply and not audio_settings.speaker_muted(): emit_state('speaking') _lists_done = threading.Event() speak_async(_lists_reply, on_end=lambda: (_lists_done.set(), socketio.emit('alfred_tts_finished', {'source':'voice'}))) _lists_done.wait(timeout=120) emit_state('idle', partial='') return # ALFRED_LISTES_GLASS_V1_VOICE_END print("[ALFRED] Envoi à Hermes", flush=True) # ComfyUI publie l'image pendant cet appel Hermes. with STATE_LOCK: image_revision_before = IMAGE_REVISION # ALFRED_FEATURES_V45 voice opt-in background mode if alfred_features.should_run_in_background(r.text): try: alfred_features.submit(r.text, source='voice') emit_state('idle', partial='', content={ 'type': 'text', 'text': 'Hermes travaille sur la demande. Le résultat apparaîtra dans les cartes.' }) except ValueError as exc: emit_state('error', content={'type': 'text', 'text': str(exc)}) return # DONA_V7_RELEASE: reconnaissance sur ordre vocal explicite, sinon Hermes normal. answer = _dona_face_recognize(ask_hermes) if _dona_face_request(r.text) else ask_hermes(r.text) print(f"[ALFRED] Réponse Hermes : {answer!r}", flush=True) if isinstance(answer, dict): speech = str(answer.get("speech") or answer.get("text") or "") display = answer.get("display") or { "type": "text", "text": speech } else: speech = str(answer or "") display = {"type": "text", "text": speech} # Une illustration contextuelle (Wikipédia) peut compléter la réponse Hermes. # Elle n'interrompt pas la lecture TTS et ne remplace pas la scène centrale. alfred_context_images.schedule_context_image( r.text, speech, socketio, lambda: STATE.get("transcript") == r.text, ) # ALFRED_TIMERS_CONTEXT_V1 # Si le MCP ComfyUI a affiché une image pendant la requête, # ne pas l'écraser par la réponse textuelle d'Hermes. with STATE_LOCK: if IMAGE_REVISION == image_revision_before: STATE["content"] = display alfred_features.publish_voice_display(display) socketio.emit("alfred_render", display) else: print("[ALFRED] Image ComfyUI conservée à l'écran", flush=True) if speech and not audio_settings.speaker_muted(): emit_state("speaking") done = threading.Event() duplex_monitor.start(speech) # ALFRED_DUPLEX_BARGE_IN_V1 try: started = speak_async(speech, on_end=lambda: (done.set(), socketio.emit('alfred_tts_finished', {'source':'voice'}))) if started: done.wait(timeout=120) finally: if not duplex_monitor.interrupted(): duplex_monitor.stop() # ALFRED_DUPLEX_CONVERSATION_V2: stop-only or keep the rest of the spoken follow-up. if duplex_monitor.interrupted(): pending_duplex_text = duplex_monitor.wait_for_followup(timeout=30) duplex_monitor.stop() else: # A few wake-free seconds after the assistant finishes speaking. pending_duplex_text = duplex_monitor.listen_conversation() if pending_duplex_text: print('[ALFRED DUPLEX V2] Question enchaînée :', repr(pending_duplex_text), flush=True) emit_state("idle", partial="") except Exception as exc: print(f"[ALFRED] Erreur conversation : {exc}", flush=True) emit_state( "error", content={"type": "text", "text": str(exc)} ) finally: conversation_lock.release() if pending_duplex_text and not audio_settings.mic_muted(): # ALFRED_DUPLEX_CONVERSATION_V2 # Only the voice is cancelled; the original Hermes job is untouched. wake.pause() threading.Thread(target=conversation, kwargs={'injected_text': pending_duplex_text}, daemon=True, name='dona-duplex-followup').start() elif not audio_settings.mic_muted(): wake.resume() print("[ALFRED] Wake word réactivé", flush=True) def on_wake(score): if audio_settings.mic_muted(): return socketio.emit("wake_detected",{"word":os.getenv("WAKE_WORD","alfred"),"score":score}) threading.Thread(target=conversation,args=(score,),daemon=True).start() wake=WakeWordListener(on_wake=on_wake,on_level=lambda l: audio_level(l if STATE["mode"]!="idle" else l*.18)) admin_console.register_admin_console(app, wake, audio_settings) # ALFRED_ADMIN_PAGES_V1 @app.get('/api/duplex/status') def duplex_status(): return jsonify(duplex_monitor.status()) @app.get('/api/audio/settings') def get_audio_settings(): return jsonify(audio_settings.get_settings()) @app.post('/api/audio/settings') def set_audio_settings(): data = request.get_json(silent=True) try: old = audio_settings.get_settings() new = audio_settings.update_settings(data) except ValueError as exc: return jsonify({'error': str(exc)}), 400 except Exception as exc: return jsonify({'error': f'ALSA : {exc}'}), 500 if new['mic_muted'] != old['mic_muted']: if new['mic_muted']: mic_stop_event.set() wake.pause() else: mic_stop_event.clear() if not conversation_lock.locked(): wake.resume() socketio.emit('audio_settings', new) return jsonify(new) @app.get('/api/camera/settings') def get_camera_settings(): try: return jsonify(camera_settings.get_settings()) except camera_settings.CameraSettingsError as exc: return jsonify({'error': str(exc)}), 503 @app.post('/api/camera/settings') def set_camera_settings(): # Même garde-fou que les commandes calendrier existantes. if not _same_origin_json(): return jsonify({'error': 'Requête non autorisée.'}), 403 data = request.get_json(silent=True) if not isinstance(data, dict): return jsonify({'error': 'JSON invalide.'}), 400 try: result = camera_settings.update_setting(data.get('control'), data.get('value')) socketio.emit('camera_settings', result) return jsonify(result) except ValueError as exc: return jsonify({'error': str(exc)}), 400 except camera_settings.CameraSettingsError as exc: return jsonify({'error': str(exc)}), 503 @app.post('/api/camera/reset') def reset_camera_settings(): if not _same_origin_json(): return jsonify({'error': 'Requête non autorisée.'}), 403 try: result = camera_settings.reset_defaults() socketio.emit('camera_settings', result) return jsonify(result) except camera_settings.CameraSettingsError as exc: return jsonify({'error': str(exc)}), 503 @app.get('/api/camera/stream') def camera_stream(): # Flux MJPEG à la demande ; ne démarre jamais la webcam en arrière-plan. try: frames = video_stream(index=int(os.getenv('CAMERA_INDEX', '0'))) except (ImportError, ValueError, RuntimeError, OSError) as exc: return jsonify({'error': str(exc)}), 503 return Response( frames, mimetype='multipart/x-mixed-replace; boundary=frame', headers={'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff'} ) # ALFRED_FEATURES_V45 init alfred_features.init_app(app, ask_hermes, socketio=socketio) @app.get("/") def index(): return render_template("index.html",name=os.getenv("ALFRED_NAME","Alfred")) @app.get("/api/state") def state(): with STATE_LOCK: snapshot = dict(STATE) return jsonify({**snapshot, "wake": wake.status(), "tts": tts_backend_detect()}) @app.post("/api/state") def set_state(): data = request.get_json(force=True) with STATE_LOCK: STATE.update(data) socketio.emit("alfred_state", dict(STATE)) return jsonify({"ok": True}) @app.post("/api/render") def render_payload(): global IMAGE_REVISION token = os.getenv("ALFRED_RENDER_TOKEN", "").strip() if token and not hmac.compare_digest( request.headers.get("X-Alfred-Render-Token", ""), token ): return jsonify({"ok": False, "error": "Non autorisé"}), 401 data = request.get_json(silent=True) if not isinstance(data, dict): return jsonify({"ok": False, "error": "JSON objet attendu"}), 400 if data.get("type") == "image": image_url = data.get("url") if not isinstance(image_url, str) or not ( image_url.startswith(("http://", "https://", "/")) and not image_url.startswith("//") ): return jsonify({"ok": False, "error": "URL image invalide"}), 400 with STATE_LOCK: if data.get("type") == "image": IMAGE_REVISION += 1 STATE["content"] = data socketio.emit("alfred_render", data) return jsonify({"ok": True, "image_revision": IMAGE_REVISION}) # --- Agenda / capteurs / caméras Home Assistant --- def _calendar_range(): tz = ZoneInfo(os.getenv('ALFRED_TIMEZONE', 'Europe/Paris')) now = datetime.now(tz) def parse(name, default): raw = request.args.get(name) if not raw:return default value = datetime.fromisoformat(raw.replace('Z','+00:00')) if value.tzinfo is None: value = value.replace(tzinfo=tz) return value.astimezone(tz) start = parse('start',now.replace(hour=0,minute=0,second=0,microsecond=0)) end = parse('end',start+timedelta(days=7)) return start,end def _json_error(exc): code = 400 if isinstance(exc,ValueError) else 502 return jsonify({'error':str(exc)}),code def _writes_enabled(): return os.getenv('ALFRED_ALLOW_CALENDAR_WRITES','false').lower() in ('1','true','yes') def _same_origin_json(): # POSTs require a custom header and same-origin browser requests. if request.headers.get('X-Alfred-Request') != '1':return False origin=request.headers.get('Origin') if origin and origin.rstrip('/') != request.host_url.rstrip('/'):return False return True @app.get('/api/calendar/calendars') def calendars_list(): try:return jsonify({'calendars':ha_views.list_calendars(),'writes_enabled':_writes_enabled()}) except (ha_views.HAError,ValueError) as exc:return _json_error(exc) @app.get('/api/calendar/events') def calendar_events(): try: start,end=_calendar_range() selected=request.args.getlist('calendar_id') return jsonify(ha_views.get_events(start,end,selected or None)) except (ha_views.HAError,ValueError) as exc:return _json_error(exc) @app.post('/api/calendar/create') def calendar_create(): if not _same_origin_json():return jsonify({'error':'Requête non autorisée.'}),403 if not _writes_enabled():return jsonify({'error':'Écriture calendrier désactivée (ALFRED_ALLOW_CALENDAR_WRITES).'}),403 data=request.get_json(silent=True) if not isinstance(data,dict):return jsonify({'error':'JSON invalide.'}),400 try: tz=ZoneInfo(os.getenv('ALFRED_TIMEZONE','Europe/Paris')) def read_dt(key): dt=datetime.fromisoformat(str(data[key]).replace('Z','+00:00')) return dt.replace(tzinfo=tz) if dt.tzinfo is None else dt.astimezone(tz) result=ha_views.create_event(str(data.get('calendar_id','')),str(data.get('title','')), read_dt('start'),read_dt('end'),str(data.get('description') or ''), str(data.get('location') or ''),bool(data.get('all_day',False))) from services import alfred_intents alfred_intents.clear_create() socketio.emit('calendar_changed', {'type':'created'}) return jsonify(result) except (ha_views.HAError,ValueError,KeyError) as exc:return _json_error(exc) @app.post('/api/calendar/delete') def calendar_delete(): if not _same_origin_json():return jsonify({'error':'Requête non autorisée.'}),403 if not _writes_enabled():return jsonify({'error':'Écriture calendrier désactivée.'}),403 data=request.get_json(silent=True) if not isinstance(data,dict):return jsonify({'error':'JSON invalide.'}),400 try: eid=str(data.get('calendar_id',''));uid=str(data.get('uid','')) start=datetime.fromisoformat(str(data['start'])) end=datetime.fromisoformat(str(data['end'])) if start.tzinfo is None or end.tzinfo is None:raise ValueError('Date avec fuseau requise.') matches=ha_views.get_events(start-timedelta(days=1),end+timedelta(days=1),[eid])['events'] rid=str(data.get('recurrence_id') or '') if not any(e['id']==uid and e['start']==start.isoformat() and e['recurrence_id']==rid for e in matches): return jsonify({'error':'Événement introuvable ou modifié ; actualise le planning.'}),409 result=ha_views.delete_event(eid,uid,rid or None) from services import alfred_intents alfred_intents.clear_delete() socketio.emit('calendar_changed',{'type':'deleted'}) return jsonify(result) except (ha_views.HAError,ValueError,KeyError) as exc:return _json_error(exc) @app.get('/api/ha/weather') def ha_weather_data(): # Read-only route, token always stays on the Alfred server. try: q = request.args.get('q', '') return jsonify(ha_weather.get_weather(q)) except (ha_views.HAError, ValueError) as exc: return _json_error(exc) @app.get('/api/ha/weather/outfits') def ha_weather_outfits_data(): try: q = request.args.get('q', '') refresh = request.args.get('refresh_illustration', '').lower() in ('1','true','yes','on') return jsonify(weather_outfits.build_payload(q=q, date=request.args.get('date', ''), label=request.args.get('label', ''), refresh=refresh)) except (ha_views.HAError, ValueError, RuntimeError) as exc: return _json_error(exc) @app.get('/api/ha/temperatures') def ha_temperatures(): try:return jsonify({'items':ha_views.get_temperatures()}) except (ha_views.HAError,ValueError) as exc:return _json_error(exc) @app.get('/api/ha/temperature/history') def ha_temperature_history(): try: eid=request.args.get('entity_id','') hours=int(request.args.get('hours','24')) return jsonify({'items':ha_views.temperature_history(eid,hours)}) except (ha_views.HAError,ValueError) as exc:return _json_error(exc) @app.get('/api/ha/cameras') def ha_cameras(): try:return jsonify({'items':ha_views.list_cameras()}) except (ha_views.HAError,ValueError) as exc:return _json_error(exc) @app.get('/api/ha/camera//snapshot') def ha_camera_snapshot(entity_id): try: binary,mime=ha_views.camera_snapshot(entity_id) return Response(binary,mimetype=mime,headers={'Cache-Control':'no-store','X-Content-Type-Options':'nosniff'}) except (ha_views.HAError,ValueError) as exc:return _json_error(exc) @app.post('/api/ui/home') def ui_home(): with STATE_LOCK: STATE['content']={'type':'home','text':'À ton écoute.'} socketio.emit('alfred_render',STATE['content']) return jsonify({'ok':True}) @app.post("/api/audio-level") def api_audio_level(): audio_level((request.get_json(force=True) or {}).get("level",0)); return jsonify({"ok":True}) @app.get("/api/wake/status") def wake_status(): return jsonify(wake.status()) @app.post("/api/wake/start") def wake_start(): wake.start(); return jsonify({"ok":True,**wake.status()}) @app.post("/api/wake/stop") def wake_stop(): wake.stop(); return jsonify({"ok":True,**wake.status()}) @app.post("/api/tts/say") def tts_say(): text=(request.get_json(silent=True) or {}).get("text",""); return jsonify({"ok":speak_async(text)}) # ALFRED_MAIN_VISUAL_V1_BACKEND_START # Raccourcis visuels : même Hermes, même verrou de conversation et même moteur Piper. def _alfred_dashboard_quick_command(prompt): if not conversation_lock.acquire(blocking=False): return False def _run(): try: wake.pause() with STATE_LOCK: _image_revision_before = IMAGE_REVISION emit_state('thinking', transcript=prompt, partial='', content={'type':'text', 'text':prompt}) _answer = ask_hermes(prompt) if isinstance(_answer, dict): _speech = str(_answer.get('speech') or _answer.get('text') or '') _display = _answer.get('display') or {'type':'text','text':_speech} else: _speech = str(_answer or '') _display = {'type':'text','text':_speech} # Une image ComfyUI publiee pendant Hermes ne doit pas etre effacee. with STATE_LOCK: if IMAGE_REVISION == _image_revision_before: STATE['content'] = _display socketio.emit('alfred_render', _display) if _speech and not audio_settings.speaker_muted(): emit_state('speaking') _done = threading.Event() speak_async(_speech, on_end=lambda: (_done.set(), socketio.emit('alfred_tts_finished', {'source':'voice'}))) _done.wait(timeout=120) emit_state('idle', partial='') except Exception as _exc: print('[ALFRED DASHBOARD] Erreur raccourci :', _exc, flush=True) emit_state('error', content={'type':'text', 'text':str(_exc)}) finally: conversation_lock.release() if not audio_settings.mic_muted(): wake.resume() threading.Thread(target=_run, daemon=True, name='alfred-dashboard-command').start() return True from services.alfred_dashboard import register_dashboard as _register_dashboard_visual_v1 _register_dashboard_visual_v1(app, quick_command=_alfred_dashboard_quick_command) # ALFRED_MAIN_VISUAL_V1_BACKEND_END # ALFRED_ADMIN_GLASS_MAISON_V1_START from services.alfred_home_glass import register_home_glass as _alfred_register_home_glass _alfred_register_home_glass(app) # ALFRED_ADMIN_GLASS_MAISON_V1_END # ALFRED_LISTES_GLASS_V1_BACKEND_START from services.alfred_lists import register_lists as _alfred_register_lists from services.alfred_lists import handle_voice as _alfred_handle_list_voice _alfred_register_lists(app, socketio) # ALFRED_LISTES_GLASS_V1_BACKEND_END # ALFRED_GLASS_V2_BACKEND_START from services.alfred_logs_page import register_logs_page as _alfred_register_logs_v2 _alfred_register_logs_v2(app) # ALFRED_GLASS_V2_BACKEND_END # ALFRED_IDENTITY_GLASS_V1: page /identity et synchronisation du nom. from services.alfred_identity_page import register_identity_page as _alfred_identity_register _alfred_identity_register(app) # ALFRED_DEVELOPER_V1 : interface /maintenance (administrateur). from services.alfred_developer_page import register_developer_page as _alfred_developer_register _alfred_developer_register(app) # ALFRED_HERMES_NEW_SESSION_V1 : session Hermes neuve via /config admin. from services.alfred_hermes_session_page import register_hermes_session_page as _register_hermes_new_session _register_hermes_new_session(app) # DONA_FAMILY_UI_V1_START # Text entered in the adult UI uses the existing Hermes session, not microphone/TTS. def _dona_submit_typed(prompt): if not conversation_lock.acquire(blocking=False): return False def _dona_run_typed(): try: wake.pause() with STATE_LOCK: _before_image = IMAGE_REVISION emit_state('thinking', partial='', transcript=prompt, content={'type': 'text', 'text': prompt}) # DONA_SUITE_V9: commandes quotidiennes écrites, sans perturber Hermes. if _dona_suite_v9.is_request(prompt): _dona_suite_v9.handle_and_emit(prompt,socketio) emit_state('idle',partial='') return # DONA_NAV_V83: commande écrite navigation, sans polluer la session Hermes. if _dona_nav.is_request(prompt): _dona_nav.handle_and_emit(prompt,socketio,STATE,STATE_LOCK) emit_state('idle',partial='') return # DONA_YOUTUBE_V82 : vidéos et commandes écrites hors conversation Hermes. if _dona_youtube.is_search(prompt): _dona_youtube.search_and_emit(prompt,socketio,STATE,STATE_LOCK) emit_state('idle',partial='') return if _dona_youtube.is_control(prompt,_dona_youtube.player_active()): _dona_youtube.control_and_emit(prompt,socketio) emit_state('idle',partial='') return # DONA_V8_DYNAMIC — commandes visuelles saisies via le formulaire habituel. if _dona_dynamic.is_dynamic_request(prompt): _board = _dona_dynamic.build_dashboard(prompt, _dona_dashboard_agent.ask_dashboard) _payload = {'type':'dona_dynamic','dashboard':_board} with STATE_LOCK: STATE['content'] = _payload socketio.emit('alfred_render',_payload) emit_state('idle', partial='') return _answer = ask_hermes(prompt) if isinstance(_answer, dict): _speech = str(_answer.get('speech') or _answer.get('text') or '') _display = _answer.get('display') or {'type':'text','text':_speech} else: _speech = str(_answer or '') _display = {'type':'text','text':_speech} with STATE_LOCK: if IMAGE_REVISION == _before_image: STATE['content'] = _display socketio.emit('alfred_render', _display) # Deliberately no speak_async(): typed interaction is silent. emit_state('idle', partial='') except Exception as exc: print('[DONA TEXT] Echec de la demande:', type(exc).__name__, str(exc), flush=True) import traceback traceback.print_exc() emit_state('error', content={'type':'text', 'text':'Hermes n’a pas pu répondre.'}) finally: conversation_lock.release() if not audio_settings.mic_muted(): wake.resume() threading.Thread(target=_dona_run_typed, daemon=True, name='dona-typed-hermes').start() return True from services.dona_family_ui import register_dona_family as _dona_register_family _dona_register_family(app, adult_command=_dona_submit_typed) # DONA_FAMILY_UI_V1_END # DONA_V8_DYNAMIC — tableau de bord choisi, généré et rendu en composants contrôlés. from services import dona_dynamic as _dona_dynamic # DONA_V8_1_ISOLATION : tableaux isolés des conversations normales from services import dona_dashboard_agent as _dona_dashboard_agent def _dona_dynamic_submit(prompt): if not conversation_lock.acquire(blocking=False): return False def _work(): try: wake.pause() emit_state('thinking',transcript=prompt,partial='') _board=_dona_dynamic.build_dashboard(prompt,_dona_dashboard_agent.ask_dashboard) _payload={'type':'dona_dynamic','dashboard':_board} with STATE_LOCK: STATE['content']=_payload socketio.emit('alfred_render',_payload) emit_state('idle',partial='') except Exception as exc: print('[DONA DYNAMIC V8] ',type(exc).__name__,str(exc),flush=True) emit_state('error',content={'type':'text','text':'Impossible de préparer ce tableau de bord.'}) finally: conversation_lock.release() if not audio_settings.mic_muted(): wake.resume() threading.Thread(target=_work,daemon=True,name='dona-dynamic-v8').start() return True _dona_dynamic.register_dynamic(app,_dona_dynamic_submit) # DONA_SUITE_V9 : routeur local, cockpit, mémoire, cuisine, documents, routines. from services import dona_suite_v9 as _dona_suite_v9 _dona_suite_v9.register_suite(app,socketio) # DONA_NAV_V83: géocodage, carte et transfert Waze. from services import dona_navigation as _dona_nav _dona_nav.register_navigation(app,socketio) # DONA_YOUTUBE_V82 : lecture publique, sans clé API, moteur Hermes isolé. from services import dona_youtube as _dona_youtube _dona_youtube.register_youtube(app,socketio) # DONA_SPOTIFY_V6 — player read/control API, reuses Hermes OAuth (no LLM polling). from services.dona_spotify import register_spotify_panel as _register_dona_spotify _register_dona_spotify(app) # DONA_V7_RELEASE : route manuelle caméra + MCP existant; aucune base de visage modifiée. from services.dona_face import (register_face as _dona_register_face, recognize_once as _dona_face_recognize, is_face_request as _dona_face_request) _dona_register_face(app, socketio, ask_hermes, conversation_lock) if __name__ == "__main__": if os.getenv("WAKE_ENABLED","true").lower() in ("1","true","yes","on") and not audio_settings.mic_muted(): wake.start() socketio.run(app,host=os.getenv("FLASK_HOST","0.0.0.0"),port=int(os.getenv("FLASK_PORT","54321")),allow_unsafe_werkzeug=True)