710 lines
30 KiB
Plaintext
710 lines
30 KiB
Plaintext
|
||
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_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, ask_hermes)
|
||
_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/<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})
|
||
_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_V8_DYNAMIC — commandes visuelles saisies via le formulaire habituel.
|
||
if _dona_dynamic.is_dynamic_request(prompt):
|
||
_board = _dona_dynamic.build_dashboard(prompt, ask_hermes)
|
||
_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__, flush=True)
|
||
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
|
||
|
||
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,ask_hermes)
|
||
_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_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)
|
||
|