Files
Alfredv2/alfred/backups/dona-visual-context/20261102T071929Z-373243/app.py
T
2026-11-02 08:50:05 +01:00

937 lines
43 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
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.
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 dona_clock_local as _dona_clock_local # DONA_CLOCK_SCULPTURE_V11
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="*")
# DONA_HERMES_PERF_V1: progression non bloquante des réponses Hermes
from services.dona_hermes_stream import configure_emitter as _dona_stream_emitter
_dona_stream_emitter(lambda event: socketio.emit("dona_hermes_stream", event))
# DONA_UNIFIED_ROUTING_V4 — tableaux standards depuis les requêtes vocales/écrites
from services.dona_unified_router_v4 import configure_emitter as _dona_v4_emitter
_dona_v4_emitter(lambda event: socketio.emit('alfred_render', event))
# DONA_WAVES_V1 : événement léger, pas de flux audio transmis au navigateur.
from services import piper_tts as _dona_waves_piper
_dona_waves_piper.dona_set_level_callback(
lambda level: socketio.emit('dona_piper_level', {'level': round(level, 4)})
)
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
# DONA_LEARNING_V93 : évaluation vocale explicite, puis correction Vosk approuvée.
_dona_v93_original_vosk = r.text
_dona_v93_ack = _dona_learning_v93.voice_feedback(r.text)
if _dona_v93_ack is not None:
socketio.emit('alfred_render', {'type':'text','text':_dona_v93_ack})
if not audio_settings.speaker_muted():
_dona_v93_done = threading.Event()
if speak_async(_dona_v93_ack,on_end=_dona_v93_done.set):
_dona_v93_done.wait(timeout=120)
emit_state('idle',partial='')
return
r.text = _dona_learning_v93.correct_transcript(r.text)
if r.text != _dona_v93_original_vosk:
socketio.emit('dona_learning_stt', {'raw':_dona_v93_original_vosk,'used':r.text})
emit_state(
"thinking",
partial="",
transcript=r.text,
content={"type": "text", "text": r.text}
)
# DONA_CLOCK_SCULPTURE_V11 : heure/date locales AVANT Hermes.
_dona_local_clock_reply = _dona_clock_local.answer(r.text)
if _dona_local_clock_reply is not None:
with STATE_LOCK:
STATE['content']={'type':'text','text':_dona_local_clock_reply}
socketio.emit('alfred_render',STATE['content'])
socketio.emit('dona_learning_turn',
_dona_learning_v93.track('voice',_dona_v93_original_vosk,_dona_local_clock_reply))
if not audio_settings.speaker_muted():
emit_state('speaking')
_dona_clock_done=threading.Event()
_dona_clock_started=speak_async(_dona_local_clock_reply,
on_end=lambda: (_dona_clock_done.set(),
socketio.emit('alfred_tts_finished', {'source':'voice'})))
if _dona_clock_started:_dona_clock_done.wait(timeout=120)
emit_state('idle',partial='')
return
# DONA_VISION_V95 : photo locale et compréhension par Hermes sur demande.
if _dona_vision_v95.is_camera_question(r.text):
try:
_vision_answer = _dona_vision_monitor.analyze(r.text)
except RuntimeError as _vision_exc:
_vision_answer = str(_vision_exc)
except Exception as _vision_exc:
print('[DONA VISION] Analyse vocale :',type(_vision_exc).__name__,flush=True)
_vision_answer = 'Analyse visuelle indisponible. Vérifie le modèle vision Hermes.'
with STATE_LOCK:
STATE['content']={'type':'text','text':_vision_answer}
socketio.emit('alfred_render',STATE['content'])
socketio.emit('dona_learning_turn',_dona_learning_v93.track('voice',_dona_v93_original_vosk,_vision_answer))
if _vision_answer and not audio_settings.speaker_muted():
emit_state('speaking')
_vision_done=threading.Event()
if speak_async(_vision_answer[:850],on_end=_vision_done.set):_vision_done.wait(timeout=120)
emit_state('idle',partial='')
return
# DONA_FILES_V91 : questions explicites sur la pièce jointe active.
if _dona_files_v91.is_file_question(r.text):
_file_speech = _dona_files_v91.handle_voice(r.text, socketio, STATE, STATE_LOCK)
socketio.emit('dona_learning_turn', _dona_learning_v93.track('voice',_dona_v93_original_vosk,_file_speech))
if _file_speech and not audio_settings.speaker_muted():
emit_state('speaking')
_file_done = threading.Event()
if speak_async(_file_speech, on_end=_file_done.set): _file_done.wait(timeout=120)
emit_state('idle', partial='')
return
# 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)
socketio.emit('dona_learning_turn', _dona_learning_v93.track('voice',_dona_v93_original_vosk,_v9_speech))
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)
socketio.emit('dona_learning_turn', _dona_learning_v93.track('voice',_dona_v93_original_vosk,_nav_reply))
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)
socketio.emit('dona_learning_turn', _dona_learning_v93.track('voice',_dona_v93_original_vosk,_speech))
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) or _dona_json_autorender.is_monitor_request(r.text):
_board = _dona_dynamic.build_dashboard(r.text, _dona_dashboard_agent.ask_dashboard)
socketio.emit('dona_learning_turn',_dona_learning_v93.track('voice',_dona_v93_original_vosk,_board.get('title','Tableau de bord')))
_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
socketio.emit('dona_learning_turn', _dona_learning_v93.track('voice',_dona_v93_original_vosk,speech))
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:
socketio.emit('dona_learning_turn', _dona_learning_v93.track('voice',_dona_v93_original_vosk,_lists_reply))
_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(_dona_learning_v93.enhance_prompt(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}
display, speech = _dona_json_autorender.convert(answer, r.text, display, speech)
# DONA_LEARNING_V93 : évaluation du tour Hermes, RAM seulement avant 👍 / 👎.
socketio.emit('dona_learning_turn',
_dona_learning_v93.track('voice',_dona_v93_original_vosk,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/<path:entity_id>/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})
# DONA_JSON_AUTORENDER_V1: operational shortcuts use the isolated
# dashboard session; ordinary shortcuts remain regular Hermes calls.
if _dona_dynamic.is_dynamic_request(prompt) or _dona_json_autorender.is_monitor_request(prompt):
_board = _dona_dynamic.build_dashboard(prompt, _dona_dashboard_agent.ask_dashboard)
_display = {'type':'dona_dynamic','dashboard':_board}
_speech = 'Voici le tableau de bord : ' + _board['title']
else:
_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}
_display, _speech = _dona_json_autorender.convert(_answer, prompt, _display, _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_CLOCK_SCULPTURE_V11 : même traitement depuis Écrire à Dona / raccourcis.
_dona_local_clock_reply = _dona_clock_local.answer(prompt)
if _dona_local_clock_reply is not None:
with STATE_LOCK:
STATE['content']={'type':'text','text':_dona_local_clock_reply}
socketio.emit('alfred_render',STATE['content'])
socketio.emit('dona_learning_turn',
_dona_learning_v93.track('typed',prompt,_dona_local_clock_reply))
emit_state('idle',partial='')
return
# DONA_VISION_V95 : demande écrite de vision depuis la caméra réelle.
if _dona_vision_v95.is_camera_question(prompt):
try:
_vision_answer=_dona_vision_monitor.analyze(prompt)
except Exception as _vision_exc:
_vision_answer=str(_vision_exc)[:190] if isinstance(_vision_exc,RuntimeError) else 'Analyse visuelle indisponible.'
with STATE_LOCK:
STATE['content']={'type':'text','text':_vision_answer}
socketio.emit('alfred_render',STATE['content'])
socketio.emit('dona_learning_turn',_dona_learning_v93.track('typed',prompt,_vision_answer))
emit_state('idle',partial='')
return
# DONA_FILES_V91 : question écrite à propos de la dernière pièce jointe.
if _dona_files_v91.is_file_question(prompt):
_dona_files_v91.handle_voice(prompt, socketio, STATE, STATE_LOCK)
emit_state('idle',partial='')
return
# DONA_SUITE_V9: commandes quotidiennes écrites, sans perturber Hermes.
if _dona_suite_v9.is_request(prompt):
_v93_local=_dona_suite_v9.handle_and_emit(prompt,socketio)
socketio.emit('dona_learning_turn',_dona_learning_v93.track('typed',prompt,_v93_local))
emit_state('idle',partial='')
return
# DONA_NAV_V83: commande écrite navigation, sans polluer la session Hermes.
if _dona_nav.is_request(prompt):
_v93_local=_dona_nav.handle_and_emit(prompt,socketio,STATE,STATE_LOCK)
socketio.emit('dona_learning_turn',_dona_learning_v93.track('typed',prompt,_v93_local))
emit_state('idle',partial='')
return
# DONA_YOUTUBE_V82 : vidéos et commandes écrites hors conversation Hermes.
if _dona_youtube.is_search(prompt):
_v93_local=_dona_youtube.search_and_emit(prompt,socketio,STATE,STATE_LOCK)
socketio.emit('dona_learning_turn',_dona_learning_v93.track('typed',prompt,_v93_local))
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) or _dona_json_autorender.is_monitor_request(prompt):
_board = _dona_dynamic.build_dashboard(prompt, _dona_dashboard_agent.ask_dashboard)
socketio.emit('dona_learning_turn',_dona_learning_v93.track('typed',prompt,_board.get('title','Tableau de bord')))
_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(_dona_learning_v93.enhance_prompt(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}
_display, _speech = _dona_json_autorender.convert(_answer, prompt, _display, _speech)
with STATE_LOCK:
if IMAGE_REVISION == _before_image:
STATE['content'] = _display
socketio.emit('alfred_render', _display)
# DONA_LEARNING_V93 : évaluation de l’échange écrit.
socketio.emit('dona_learning_turn',
_dona_learning_v93.track('typed',prompt,_speech))
# 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_JSON_AUTORENDER_V1 : rendu automatique JSON Hermes, demandes et raccourcis
from services import dona_json_autorender_v1 as _dona_json_autorender
# 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_VISION_V95 : API vision/YOLOv8, surveillance opt-in.
from services import dona_vision_v95 as _dona_vision_v95
_dona_vision_monitor = _dona_vision_v95.register(app, socketio)
# DONA_LEARNING_V93 : retours explicites et corrections approuvées.
from services import dona_learning_v93 as _dona_learning_v93
_dona_learning_v93.register(app)
# DONA_UI_V92 : préférences de raccourcis persistantes, menus gérés côté navigateur.
from services import dona_shortcuts_v92 as _dona_shortcuts_v92
_dona_shortcuts_v92.register(app)
# DONA_FILES_V91 : fichiers temporaires et analyse Hermes isolée.
from services import dona_files_v91 as _dona_files_v91
_dona_files_v91.register_files(app)
# 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)