Files
Alfredv2/alfred/app/app.py.bak.timers_context.20261027_123527
T

442 lines
18 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
from services.audio import listen_once, speak_async, tts_backend_detect
from services.wakeword import WakeWordListener
from services import admin_console # ALFRED_ADMIN_PAGES_V1
from services.hermes_client import ask_hermes
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()
audio_settings.initialize()
audio_settings.update_settings({"mic_gain": 30})
# 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):
if not conversation_lock.acquire(blocking=False):
return
try:
emit_state("listening", partial="", transcript="", wake_score=score)
print("[ALFRED] Début écoute STT", flush=True)
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}
)
# 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=done.set)
done.wait(timeout=120)
emit_state("idle", partial="")
return
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
answer = 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}
# 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()
speak_async(speech, on_end=done.set)
done.wait(timeout=120)
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 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/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)})
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)