diff --git a/alfred/app/.alfred_admin_glass_maison_backups/20261028_162544_119463/manifest.json b/alfred/app/.alfred_admin_glass_maison_backups/20261028_162544_119463/manifest.json new file mode 100644 index 0000000..a8aef9d --- /dev/null +++ b/alfred/app/.alfred_admin_glass_maison_backups/20261028_162544_119463/manifest.json @@ -0,0 +1,61 @@ +{ + "version": 1, + "created_at": "2026-10-28T16:25:44.119642", + "installed": true, + "files": { + "templates/config.html": { + "existed": true, + "before_sha256": "d6fd60c8034688019c4501869dfd1dd61aad31bfee6e020bbe0e00743ac964b1", + "after_sha256": "4e6300fcf4b0dafcfc1e196d219734bb35263572c316ad7d010b66d0f6915762", + "mode": 420 + }, + "templates/surveillance.html": { + "existed": true, + "before_sha256": "c603647ee11dddb771bf588b8469774a012c9aa0bf6af302859df80dfdf00add", + "after_sha256": "0bd5a9a17b613c6b02e53146f4ffeb92b8bb94c9e2979d460d2d96de4ecb96cb", + "mode": 420 + }, + "templates/index.html": { + "existed": true, + "before_sha256": "cc1fbc7cab0c80d7f2799ac565c45e94b8fddb0af26e67172afde8ffd39a6231", + "after_sha256": "73a4f976e301da24f0a59b8866be4f74e001931a641e0483d0b6d1cae133a971", + "mode": 420 + }, + "static/js/alfred-main-v1.js": { + "existed": true, + "before_sha256": "5552cc33cfb1c236cb474a75d305a6df8302ef70051c057f8e8175352d014fc2", + "after_sha256": "0bef31b1e156de0a11141f79895013bb498507d9aec6e5c18546660abdba1691", + "mode": 420 + }, + "app.py": { + "existed": true, + "before_sha256": "631ad0886437b59d4b8573ffd83bf2330511dfee92dc767baa5d42ac7d9c246e", + "after_sha256": "e4fe3af1db2e0d39231f0a3ae341818b2765a14c102f827dc7c6772bbdbc572b", + "mode": 420 + }, + "services/alfred_home_glass.py": { + "existed": false, + "before_sha256": null, + "after_sha256": "672c2c83eb40a74e41ae8c349301d474e7c3130b3db2459401a6150d9201f9cd", + "mode": 420 + }, + "static/css/alfred-admin-glass.css": { + "existed": false, + "before_sha256": null, + "after_sha256": "2de6d0934aeb790fd8fce6287109ed52a63a6e16358ff8b837051b249adfaa9c", + "mode": 420 + }, + "static/js/alfred-admin-glass.js": { + "existed": false, + "before_sha256": null, + "after_sha256": "73b6c7d4186e918bea83edb8308e47bbfe2ff90acd5d189f89d482dd5770223f", + "mode": 420 + }, + "static/js/alfred-maison-card.js": { + "existed": false, + "before_sha256": null, + "after_sha256": "4eb6ba8c1c81145770328d66df34336a873151f1cf07ffd975886013730b617a", + "mode": 420 + } + } +} \ No newline at end of file diff --git a/alfred/app/.alfred_admin_glass_maison_backups/20261028_162544_119463/original/app.py b/alfred/app/.alfred_admin_glass_maison_backups/20261028_162544_119463/original/app.py new file mode 100644 index 0000000..78efeef --- /dev/null +++ b/alfred/app/.alfred_admin_glass_maison_backups/20261028_162544_119463/original/app.py @@ -0,0 +1,498 @@ +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_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}) +# 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} + + # 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() + 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//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=_done.set) + _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 + +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) + diff --git a/alfred/app/.alfred_admin_glass_maison_backups/20261028_162544_119463/original/static/js/alfred-main-v1.js b/alfred/app/.alfred_admin_glass_maison_backups/20261028_162544_119463/original/static/js/alfred-main-v1.js new file mode 100644 index 0000000..4c9a969 --- /dev/null +++ b/alfred/app/.alfred_admin_glass_maison_backups/20261028_162544_119463/original/static/js/alfred-main-v1.js @@ -0,0 +1,318 @@ +/* ALFRED_MAIN_VISUAL_V1 — accueil vivant, conservation des contrôleurs existants. */ +(() => { + 'use strict'; + if (document.getElementById('alfredMainV1')) return; + const shell = document.querySelector('.alfred-shell'); + if (!shell || !document.getElementById('stage') || !document.getElementById('content')) return; + + const I = { + home:'', + bulb:'', + server:'', + brain:'', + media:'', + more:'', + mic:'', + micOff:'', + speaker:'', + speakerOff:'', + camera:'', + settings:'', + calendar:'', + clock:'', + cloud:'', + shield:'', + arrow:'', + chat:'', + close:'', + sun:'', + spark:'', + }; + const icon = (name, extra = '') => ``; + const initialName = (document.title || 'Alfred').trim(); + const dashboard = document.createElement('div'); + dashboard.id = 'alfredMainV1'; + dashboard.className = 'alfred-main-v1'; + dashboard.innerHTML = ` +
+
Assistant vocal
+
--:--—
+
+ + + + + ${icon('more')} +
+
+
+ +
+ +
${icon('server')}SystèmeMini-PC Alfred${icon('arrow','av-chevron')}

Charge : —

Mémoire : —

Stockage : —

+
${icon('brain')}Services IAStatuts en cours…${icon('arrow','av-chevron')}

Ollama : —

Hermes : —

ComfyUI : —

+ +
+
+
+ +
${icon('mic')}
En veille

Dites « Alfred » puis votre demande

Prêt à vous écouter
+ +
+
+
+
+ + + + + + + +
+
${icon('chat')} Interactions récentes
Aucune interaction depuis l’ouverture de cette page
+ + +

Créer une image avec ComfyUI

Décris l’image à générer. Alfred transmettra ta demande à Hermes.

+

Outils Alfred

${icon('settings')} Configuration${icon('server')} Surveillance
+ `; + shell.insertBefore(dashboard, shell.firstChild); + document.body.classList.add('alfred-glass-theme'); + document.getElementById('avName').textContent = initialName; + const wave = document.getElementById('wave'); + document.getElementById('avWaveHost').appendChild(wave); + window.dispatchEvent(new Event('resize')); + const $ = id => document.getElementById(id); + const timeZone = 'Europe/Paris'; + let lastStatus = 'idle'; + let mute = {mic_muted:false,speaker_muted:false}; + let refreshingAudio = false; + let toastTimer; + + function toast(message) { + const el = $('avToast'); el.textContent = String(message); el.hidden = false; + clearTimeout(toastTimer); toastTimer = setTimeout(() => { el.hidden = true; }, 5500); + } + function clock() { + const now = new Date(); + $('avTime').textContent = new Intl.DateTimeFormat('fr-FR',{timeZone,hour:'2-digit',minute:'2-digit'}).format(now); + const date = new Intl.DateTimeFormat('fr-FR',{timeZone,weekday:'long',day:'numeric',month:'long',year:'numeric'}).format(now); + $('avDate').textContent = date.charAt(0).toUpperCase() + date.slice(1); + } + clock(); setInterval(clock, 15000); + function updateAudio(a) { + mute = {...mute,...a}; + const mic = Boolean(mute.mic_muted), speaker = Boolean(mute.speaker_muted); + for (const id of ['avTopMic','avMicButton']) { + const button = $(id); button.setAttribute('aria-pressed',String(mic)); + button.setAttribute('aria-label', mic ? 'Réactiver le microphone' : 'Couper le microphone'); + button.title = button.getAttribute('aria-label'); + button.querySelector('svg').outerHTML = icon(mic?'micOff':'mic'); + } + for (const id of ['avTopSpeaker','avSpeakerButton']) { + const button = $(id); button.setAttribute('aria-pressed',String(speaker)); + button.setAttribute('aria-label', speaker?'Réactiver la sortie audio':'Couper la sortie audio'); + button.title = button.getAttribute('aria-label'); + button.querySelector('svg').outerHTML = icon(speaker?'speakerOff':'speaker'); + } + $('avMicLabel').textContent = mic?'Coupé':'Actif'; + $('avSpeakerLabel').textContent = speaker?'Coupée':'Activée'; + dashboard.classList.toggle('av-mic-is-muted',mic); + dashboard.classList.toggle('av-speaker-is-muted',speaker); + renderMode(lastStatus); + } + async function refreshAudio() { + if (refreshingAudio) return; + refreshingAudio = true; + try { const r=await fetch('/api/audio/settings',{cache:'no-store'}); + if (!r.ok) throw Error('HTTP '+r.status); + updateAudio(await r.json()); + } catch (e) { console.warn('[ALFRED UI] Réglages audio :',e); } + finally {refreshingAudio=false;} + } + async function toggleAudio(kind) { + const prop = kind==='mic'?'mic_muted':'speaker_muted'; + try { + const r=await fetch('/api/audio/settings',{method:'POST',headers:{'Content-Type':'application/json','X-Alfred-Request':'1'},body:JSON.stringify({[prop]:!mute[prop]})}); + const data=await r.json(); + if (!r.ok) throw Error(data.error || 'HTTP '+r.status); + updateAudio(data); + toast(kind==='mic' ? (data.mic_muted?'Microphone coupé':'Microphone réactivé') : (data.speaker_muted?'Sortie audio coupée':'Sortie audio réactivée')); + } catch (e) { toast('Impossible de modifier le son : '+e.message); } + } + for (const id of ['avTopMic','avMicButton']) $(id).addEventListener('click',()=>toggleAudio('mic')); + for (const id of ['avTopSpeaker','avSpeakerButton']) $(id).addEventListener('click',()=>toggleAudio('speaker')); + $('avTopSettings').addEventListener('click',()=>{const btn=$('audioSettingsButton'); if(btn)btn.click();else location.assign('/config');}); + refreshAudio(); setInterval(()=>{if(!document.hidden)refreshAudio();},30000); + + function serviceText(state) {return state==='ok'?'Connecté':state==='offline'?'Hors ligne':state==='installed'?'Installé (non testé)':state==='unconfigured'?'Non configuré':'Non vérifié';} + function setService(id,dotid,label,state) { + $(id).textContent = `${label} : ${serviceText(state)}`; + $(dotid).className = `av-mini-dot ${state==='ok'?'is-ok':state==='offline'?'is-error':''}`; + } + async function refreshDashboard() { + try { + const r=await fetch('/api/alfred/dashboard',{cache:'no-store'}); + if(!r.ok) throw Error('HTTP '+r.status); + const d=await r.json(); + const h=d.home||{}, s=d.system||{}, ai=d.services||{}, w=d.weather||{}; + $('avHomeSummary').textContent=h.available?'Home Assistant connecté':(h.detail||'Données non disponibles'); + $('avLights').textContent = h.available?`${h.lights_on} lumière(s) allumée(s)`:'Lumières : indisponible'; + $('avCovers').textContent = h.available?`${h.covers_closed} volet(s) fermé(s)`:'Volets : indisponible'; + $('avIndoor').textContent = h.indoor_temperature!=null?`${h.indoor_temperature} °C · intérieur`:'Température : non disponible'; + $('avLoad').textContent = s.load_1m!=null?`Charge 1 min : ${s.load_1m}`:'Charge : non disponible'; + $('avMemory').textContent = s.memory_percent!=null?`Mémoire : ${s.memory_percent} %`:'Mémoire : non disponible'; + $('avDisk').textContent = s.disk_percent!=null?`Stockage : ${s.disk_percent} %`:'Stockage : non disponible'; + setService('avOllama','avOllamaDot','Ollama',ai.ollama); + setService('avHermes','avHermesDot','Hermes',ai.hermes); + setService('avComfy','avComfyDot','ComfyUI',ai.comfyui); + $('avAiSummary').textContent='États vérifiés / installation locale'; + $('avWeatherTemp').textContent=w.available&&w.temperature!=null?`${w.temperature} ${w.unit||'°C'}`:'—'; + $('avWeatherCondition').textContent=w.available?w.condition:'Non disponible'; + const sunny=['sunny','clear-night','partlycloudy'].includes(w.condition_id); + $('avWeatherIcon').textContent=sunny?'🌤️':w.condition_id==='rainy'?'🌧️':w.condition_id==='snowy'?'❄️':'☁️'; + } catch (e) { + $('avHomeSummary').textContent='Données indisponibles'; + $('avAiSummary').textContent='Surveillance indisponible'; + $('avWeatherCondition').textContent='Indisponible'; + console.warn('[ALFRED UI] Tableau de bord :',e); + } + } + refreshDashboard(); setInterval(()=>{if(!document.hidden)refreshDashboard();},30000); + async function refreshForecast() { + try { + const r=await fetch('/api/ha/weather',{cache:'no-store'}); + if(!r.ok)return; + const d=await r.json(); + const day=(d.daily||[])[0]; + const vals=[]; + if(day?.temperature!=null) vals.push('↑ '+day.temperature+'°'); + if(day?.templow!=null) vals.push('↓ '+day.templow+'°'); + $('avWeatherRange').textContent=vals.join(' '); + } catch (_) { /* Not all installations expose the weather forecast route. */ } + } + refreshForecast();setInterval(()=>{if(!document.hidden)refreshForecast();},180000); + + function renderMode(mode) { + lastStatus = mode||'idle'; + const states = {idle:['En veille','Dites « '+initialName+' » puis votre demande','Prêt'], + waiting:['En veille','Dites « '+initialName+' » puis votre demande','Prêt'], + listening:['Je vous écoute…','Quelle est votre demande ?','Écoute en cours'], + thinking:['Je réfléchis…','Traitement de la demande','Hermes travaille'], + speaking:['Je vous réponds…','Réponse vocale en cours','Piper parle'], + error:['Un problème est survenu','Consultez la transcription ou la surveillance','Erreur']}; + const item=states[mode]||states.idle; + $('avVoiceTitle').textContent=mute.mic_muted?'Microphone coupé':item[0]; + $('avVoiceSubtitle').textContent=mute.mic_muted?'Réactivez le micro pour utiliser le mot d’activation':item[1]; + $('avStatusText').textContent=mute.mic_muted?'Écoute désactivée':item[2]; + dashboard.dataset.mode=mode; + $('avOrb').setAttribute('aria-label',item[0]); + } + renderMode('idle'); + + const recent=[]; + let lastTranscript=''; + function pushRecent(text) { + const value=String(text||'').trim(); + if(!value || value===lastTranscript)return; + lastTranscript=value; + recent.unshift({text:value,at:new Date()}); + if(recent.length>5)recent.pop(); + const root=$('avHistoryList');root.replaceChildren(); + for(const e of recent) { + const node=document.createElement('span');node.className='av-history-item'; + const t=document.createElement('time');t.textContent=new Intl.DateTimeFormat('fr-FR',{timeZone,hour:'2-digit',minute:'2-digit'}).format(e.at); + const content=document.createElement('span');content.textContent=e.text; + node.append(t,content);root.append(node); + } + } + function focusScene(show) { + dashboard.classList.toggle('is-focused', Boolean(show)); + $('avBack').hidden = !show; + } + function onRender(d) { if(!d || typeof d!=='object')return; + if(d.type==='home')focusScene(false); + else if(d.type && d.type!=='text' || (d.text||d.title||d.url))focusScene(true); + } + const socket=window.alfredSocket; + socket?.on('alfred_state',state=>{ + renderMode(String(state.mode||'idle').toLowerCase()); + if(state.mode==='thinking' && state.transcript)pushRecent(state.transcript); + }); + socket?.on('alfred_render',onRender); + socket?.on('audio_settings',updateAudio); + fetch('/api/state',{cache:'no-store'}).then(r=>r.ok?r.json():null).then(s=>{if(s)renderMode(String(s.mode||'idle').toLowerCase());}).catch(()=>{}); + + async function goHome() { + focusScene(false); + if(window.AlfredViews?.goHome){try{await window.AlfredViews.goHome();}catch(_){}} + else if($('viewHome'))$('viewHome').click(); + } + $('avBack').addEventListener('click',goHome); + function openCamera() { + if($('cameraPopupButton')){ $('cameraPopupButton').click();return; } + const camera=document.querySelector('.camera-panel'); + if(!camera){ toast('Caméra non installée');return; } + const isOpen=document.body.classList.toggle('av-legacy-camera-open'); + const toggle=$('cameraToggle'); + if(toggle && (toggle.getAttribute('aria-pressed')==='true')!==isOpen)toggle.click(); + if(isOpen && !$('avLegacyCameraClose')){ + const close=document.createElement('button');close.id='avLegacyCameraClose';close.type='button';close.textContent='Fermer la caméra'; + close.addEventListener('click',openCamera);camera.prepend(close); + } + } + $('avTopCamera').addEventListener('click',openCamera); + + function openView(name) { + const f=window.AlfredViews?.[name]; + if(typeof f==='function'){ focusScene(true);f();return; } + const fallback={openWeather:'viewWeather',openTemperatures:'viewTemperatures',openCalendar:'viewCalendar'}[name]; + if(fallback && $(fallback)){focusScene(true);$(fallback).click();return;} + toast('Cette fonction n’est pas disponible sur cette installation.'); + } + async function quickCommand(action,description) { + try { + const r=await fetch('/api/alfred/dashboard/command',{method:'POST',headers:{'Content-Type':'application/json','X-Alfred-Request':'1'},body:JSON.stringify({action,description})}); + const data=await r.json(); if(!r.ok)throw Error(data.error||'HTTP '+r.status); + pushRecent(({proxmox:'Vérifier Proxmox',wazuh:'Consulter les alertes Wazuh',comfyui:'Générer une image'})[action]||action); + toast('Demande transmise à Hermes'); + } catch(e) {toast('Commande indisponible : '+e.message);} + } + function showTime() { + const current=new Intl.DateTimeFormat('fr-FR',{timeZone,hour:'2-digit',minute:'2-digit',second:'2-digit'}).format(new Date()); + const content=$('content');content.replaceChildren(); + const title=document.createElement('h1');title.textContent='Il est '+current; + const subtitle=document.createElement('p');subtitle.textContent='Heure de Paris · horloge locale du navigateur'; + content.append(title,subtitle); focusScene(true);pushRecent('Quelle heure est-il ?'); + } + async function act(name) { + if(name==='home'){await goHome();return;} + if(name==='house'){openView('openTemperatures');return;} + if(name==='weather'){openView('openWeather');return;} + if(name==='calendar'){ $('avMoreDialog').close();openView('openCalendar');return; } + if(name==='camera'){ $('avMoreDialog').close();openCamera();return; } + if(name==='media'){ $('avComfyDialog').showModal();$('avComfyPrompt').focus();return;} + if(name==='time'){showTime();return;} + if(name==='proxmox'||name==='wazuh'){await quickCommand(name);return;} + if(name==='comfyui'){ $('avComfyDialog').showModal();$('avComfyPrompt').focus();return;} + if(name==='more'){ $('avMoreDialog').showModal();return;} + if(name==='audio'){ $('avMoreDialog').close();$('avTopSettings').click();return; } + } + dashboard.addEventListener('click',event=>{ + const button=event.target.closest('[data-av-action]'); + if(button)act(button.dataset.avAction); + }); + $('avComfySubmit').addEventListener('click',()=>{ + const text=$('avComfyPrompt').value.trim(); + if(text.length<4){toast('Décris l’image à générer.');return;} + $('avComfyDialog').close();quickCommand('comfyui',text); + }); + window.addEventListener('resize',()=>{ /* legacy alfred.js recalculates the waveform canvas */ }); +})(); diff --git a/alfred/app/.alfred_admin_glass_maison_backups/20261028_162544_119463/original/templates/config.html b/alfred/app/.alfred_admin_glass_maison_backups/20261028_162544_119463/original/templates/config.html new file mode 100644 index 0000000..e61a605 --- /dev/null +++ b/alfred/app/.alfred_admin_glass_maison_backups/20261028_162544_119463/original/templates/config.html @@ -0,0 +1,75 @@ + + + + +Alfred · Configuration + + + + + + + +
+
+
A
AlfredCentre de contrôle
+ +
+
+

PERSONNALISATION

Configuration

Paramètres de l’assistant, voix et services. Les modifications prennent effet après redémarrage.

Aucun changement
+ +
+
✦

Identité et voix

Nom affiché, mot d'activation et voix de synthèse

+
Visible dans l’interface après redémarrage.
+
Un seul mot. Le modèle Vosk doit pouvoir le reconnaître ; le mode OpenWakeWord nécessite un modèle entraîné.
+
Exemple : fr_FR-tom-medium. La voix doit exister sur ton serveur Wyoming ; pour Piper local, c’est le fichier modèle qui détermine la voix.
+
Utilise les réglages déjà actifs, sans redémarrage
+
+
☁

Illustrations météo

Qualité, visages de référence et accessoires ComfyUI

+
+
8.6 / 10
+
+
+
+
0.56
Une valeur élevée modifie davantage la tenue, mais risque de changer le visage.
+
+
+
+
♫

Audio en direct

Volume, sensibilité et coupures immédiates

+
—
+
—
+
+
+

Les réglages audio sont appliqués sans redémarrage.

+
+
⌘

Connexions et météo

Adresses des serveurs et source météo

+
Laisse vide pour conserver la configuration Hermes existante.
+
Cette adresse sert au contrôle de disponibilité ; Hermes garde sa propre configuration LLM.
+
Exemple : weather.forecast_home. Le jeton Home Assistant n’est pas exposé ici.
+ Voir la surveillance des services +
+
+ + +
+
◉

Reconnaissance vocale (STT)

Vosk local ou Whisper Wyoming distant · configuration effective après redémarrage

+
+ + Le mot d’activation et le texte provisoire restent gérés par Vosk. Whisper améliore la transcription finale envoyée à Hermes.
+
+
Adresse IP ou nom DNS, sans http://
+
+
+
+
+
+
+

Le modèle Whisper (small actuellement) et son vocabulaire technique (--initial-prompt pour Proxmox, Wazuh…) se configurent dans le conteneur Docker distant, pas sur Alfred. En cas de panne Whisper, retour automatique à Vosk si le correctif STT V1 est installé.

+
+

Maintenance

Redémarrer Alfred pour appliquer les paramètres enregistrés. Action protégée par mot de passe administrateur.

Le bouton requiert une autorisation systemd explicitement activée.
+

+
+
A

Accès administrateur

Connecte-toi pour modifier la configuration d'Alfred.

Accès local / réseau privé uniquement.
+
+ + diff --git a/alfred/app/.alfred_admin_glass_maison_backups/20261028_162544_119463/original/templates/index.html b/alfred/app/.alfred_admin_glass_maison_backups/20261028_162544_119463/original/templates/index.html new file mode 100644 index 0000000..b3f86fa --- /dev/null +++ b/alfred/app/.alfred_admin_glass_maison_backups/20261028_162544_119463/original/templates/index.html @@ -0,0 +1,190 @@ + + + + + + + {{ name }} + + + + + + + + + + + +
+
+ +
+ + En attente +
+ +
+ + ⚙ + ▤ + + + + + + + + + + + +
+
+ + + + + + +
+

Bonjour.

À votre écoute.

+
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + diff --git a/alfred/app/.alfred_admin_glass_maison_backups/20261028_162544_119463/original/templates/surveillance.html b/alfred/app/.alfred_admin_glass_maison_backups/20261028_162544_119463/original/templates/surveillance.html new file mode 100644 index 0000000..9b6f1f2 --- /dev/null +++ b/alfred/app/.alfred_admin_glass_maison_backups/20261028_162544_119463/original/templates/surveillance.html @@ -0,0 +1,44 @@ +Alfred · Surveillance + + + + +
+
A
AlfredCentre de contrôle
+
+

DIAGNOSTIC

Surveillance

État des périphériques, de l’assistant et des services locaux.

Chargement…
+
+
▣
CAMÉRAWebcam—

Vérification…

+
◉
MICROPHONECapture audio—

Vérification…

+
♫
HAUT-PARLEURSortie audio—

Vérification…

+
◌
MOT DE RÉVEILVosk—

Vérification…

+
+
⇄

Services et connexions

Tests réels : HTTP, port TCP ou présence du binaire

Chargement…

+
▤

Système

Ressources du mini-PC hébergeant Alfred

Disque de l’application—

—

Mémoire RAM—

—

Charge système (1 min)—
Les contrôles de caméra n'ouvrent pas le flux vidéo, afin de ne pas bloquer la reconnaissance faciale.
+ + +
+
+
◉

Transcription vocale

Mode réellement actif et disponibilité du serveur Wyoming

+
Mode STT——
+

Vérification…

+
Serveur Whisper——
+

Vérification…

+
Wyoming est interrogé avec describe sans lancer de transcription. Une connexion réussie ne garantit pas la qualité STT.
+
+
+
◷

Minuteurs et chronomètres

État enregistré localement, indépendant des appels Hermes

+
Gestionnaire Alfred——
+

Chargement…

+
La présence d’un MCP dans la liste ci-dessous ne prouve pas qu’Hermes l’a appelé : consulter ses logs pour vérifier les outils exécutés.
+
+
+
✧

Maintenance et intégrations

Vérifications complémentaires des nouveautés

+
Redémarrage depuis /config——
+

MCP Autres (heure/date) et Alfred Timers : la grille ci-dessous signale les dossiers détectés dans /opt/hermes. La validation fonctionnelle se fait avec hermes mcp test.

+
+
⚙

MCP Hermes détectés

Présence des répertoires, pas un test de communication des outils MCP

Chargement…

+

+
+
A

Accès administrateur

Connecte-toi pour consulter l'état d'Alfred.

Accès local / réseau privé uniquement.
+
diff --git a/alfred/app/.alfred_glass_v2_backups/20261028_205954_153666/manifest.json b/alfred/app/.alfred_glass_v2_backups/20261028_205954_153666/manifest.json new file mode 100644 index 0000000..596fd1e --- /dev/null +++ b/alfred/app/.alfred_glass_v2_backups/20261028_205954_153666/manifest.json @@ -0,0 +1,55 @@ +{ + "version": 2, + "installed": true, + "created_at": "2026-10-28T20:59:54.153831", + "files": { + "app.py": { + "existed": true, + "before_sha256": "0123a9dcfd8a3d375bd551e9b7f9049042a7b7a13f5d41806e1e76c711aeb2ee", + "after_sha256": "b67c0d6a19ab9bafacc7d8ac7ccf310fe918fbea279fa915ee245927c471a128", + "mode": 420 + }, + "services/alfred_logs_page.py": { + "existed": false, + "before_sha256": null, + "after_sha256": "6900441c4c498d108904175b2d967f0b70a1d43e3384623835dbaa664524ad33", + "mode": 420 + }, + "static/css/alfred-logs-v2.css": { + "existed": false, + "before_sha256": null, + "after_sha256": "e3e427f33be4bf6819bc690e01ba62a0ecef4f68f6d5104fab0e84107e41b5d4", + "mode": 420 + }, + "static/css/alfred-main-v2.css": { + "existed": false, + "before_sha256": null, + "after_sha256": "fc2832b44e03c24fd7bc148291affff0fe578e088e631efde07ea1c1454cc114", + "mode": 420 + }, + "static/js/alfred-logs-v2.js": { + "existed": false, + "before_sha256": null, + "after_sha256": "72c0064225563cc5c773ca5c5ebf5da0429d6a6e1eac55ba61dad9a712322535", + "mode": 420 + }, + "static/js/alfred-main-v2.js": { + "existed": false, + "before_sha256": null, + "after_sha256": "7b182ef547b7a18ac0c5f64d5b69d698c78f507aaf9accb6fb66c229a9ca9bc3", + "mode": 420 + }, + "templates/alfred_logs_v2.html": { + "existed": false, + "before_sha256": null, + "after_sha256": "ee469e2f7c664ec32876a51714c5b23ba425c340032765edc5cf93274288a688", + "mode": 420 + }, + "templates/index.html": { + "existed": true, + "before_sha256": "b469b5c988c4d17218da4db7ecac89f539a119fdfadb041611fade27b3234316", + "after_sha256": "d7961a00886dd3e809c5c5baf2d9e3182bea30fc50d5123c279082cdc1b92c6a", + "mode": 420 + } + } +} \ No newline at end of file diff --git a/alfred/app/.alfred_glass_v2_backups/20261028_205954_153666/original/app.py b/alfred/app/.alfred_glass_v2_backups/20261028_205954_153666/original/app.py new file mode 100644 index 0000000..5a29ff9 --- /dev/null +++ b/alfred/app/.alfred_glass_v2_backups/20261028_205954_153666/original/app.py @@ -0,0 +1,533 @@ +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_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}) +# 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 + + # 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=_lists_done.set) + _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 + 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} + + # 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() + 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//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=_done.set) + _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 + +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) + diff --git a/alfred/app/.alfred_glass_v2_backups/20261028_205954_153666/original/templates/index.html b/alfred/app/.alfred_glass_v2_backups/20261028_205954_153666/original/templates/index.html new file mode 100644 index 0000000..b042fe5 --- /dev/null +++ b/alfred/app/.alfred_glass_v2_backups/20261028_205954_153666/original/templates/index.html @@ -0,0 +1,195 @@ + + + + + + + {{ name }} + + + + + + + + + + + + + +
+
+ +
+ + En attente +
+ +
+ + ⚙ + ▤ + + + + + + + + + + + +
+
+ + + + + + +
+

Bonjour.

À votre écoute.

+
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + diff --git a/alfred/app/.alfred_identity_glass_v1_backups/20261028_214221_179952/manifest.json b/alfred/app/.alfred_identity_glass_v1_backups/20261028_214221_179952/manifest.json new file mode 100644 index 0000000..d869f94 --- /dev/null +++ b/alfred/app/.alfred_identity_glass_v1_backups/20261028_214221_179952/manifest.json @@ -0,0 +1,102 @@ +{ + "version": 1, + "installed": true, + "files": { + "app.py": { + "existed": true, + "before_sha256": "b67c0d6a19ab9bafacc7d8ac7ccf310fe918fbea279fa915ee245927c471a128", + "after_sha256": "a96d4d7c5a78d5076934db6033f087cccc32c99f2493cadbffbff6c26cdf0602", + "mode": 420 + }, + "services/admin_console.py": { + "existed": true, + "before_sha256": "f071b69a8c8939d4160a5804c543e41f09c998bdb42d8d945eb5819feaa94481", + "after_sha256": "27eef32a894d9cecc9a77e70708ae58912355c2f7cc48bd7074068950dfc6c4e", + "mode": 420 + }, + "services/alfred_dashboard.py": { + "existed": true, + "before_sha256": "0cbaae4910ced0f677d76495905753cd2facab96e61bbd93d9335e2157c725fd", + "after_sha256": "50465ee91640a9994c225fb099a849da97fde4d18c65c8b77d3ef561419e1204", + "mode": 420 + }, + "services/alfred_identity_page.py": { + "existed": false, + "before_sha256": null, + "after_sha256": "da2f9de1edab74d1477f3f6878a3cbbfd444a2305045fcf76bb70f0e4019cdd4", + "mode": 420 + }, + "services/camera.py": { + "existed": true, + "before_sha256": "2c1bd80a6746b57205ed9b0dd2a25587b14631c00956d72a72c29ddcf60f8d5f", + "after_sha256": "d13070969afa0a1a0398902d757d9d64c1194d7f17600f11b8cefd95f62bbbf0", + "mode": 420 + }, + "services/hermes_client.py": { + "existed": true, + "before_sha256": "02960a81c4ee7daab2a5e6e5937c49f219b66b62a359a2ac8614f5ef384789eb", + "after_sha256": "4cdb587058b706350029132276c00b85fc80c36e4a16dfeb05374c339c1ee547", + "mode": 420 + }, + "static/css/alfred-identity-glass.css": { + "existed": false, + "before_sha256": null, + "after_sha256": "0208f706160cc6c8eae8b059b47cb54bee92305a4c3467de43ab02178d2de2a6", + "mode": 420 + }, + "static/js/alfred-admin.js": { + "existed": true, + "before_sha256": "125398c7aa57d3b21e29b748af4bf95b86c0027b3236bcee8ac94da822fffc7a", + "after_sha256": "e992e3ccf7446a78fec01d84b673781c3d6c024e961a35d2ad0b6f19242707fc", + "mode": 420 + }, + "static/js/alfred-identity-glass.js": { + "existed": false, + "before_sha256": null, + "after_sha256": "33d931c79f58b3853f078312307b9d57e32e72e73a254fd884332ed52b7c8964", + "mode": 420 + }, + "static/js/alfred-name-dynamic.js": { + "existed": false, + "before_sha256": null, + "after_sha256": "404a9bf7fd29c99aab668f7f6598edd03c3bde40d8668a638c034f06636a76f4", + "mode": 420 + }, + "templates/alfred_identity_glass.html": { + "existed": false, + "before_sha256": null, + "after_sha256": "2c912c7d23499f217e4eff4a5c7020344a9f443279443c81a875d394949de154", + "mode": 420 + }, + "templates/alfred_logs_v2.html": { + "existed": true, + "before_sha256": "ee469e2f7c664ec32876a51714c5b23ba425c340032765edc5cf93274288a688", + "after_sha256": "eb3fe9bb05766e5868ab84956ee4660f0ef0d5990c499f710829bd5a40eb035e", + "mode": 420 + }, + "templates/config.html": { + "existed": true, + "before_sha256": "4e6300fcf4b0dafcfc1e196d219734bb35263572c316ad7d010b66d0f6915762", + "after_sha256": "c98c0314923d054e8b56251657a33bb4c949684a94faed0a9a979dfd5ed6c8db", + "mode": 420 + }, + "templates/configuration.html": { + "existed": true, + "before_sha256": "491fa987244881e0b0af3907f21347d1015be890ea30701652b1b88742a534c0", + "after_sha256": "14fe568c1cefe99184deda0165de8bf54a7d1e5ff45d1a2caa17ebfb835cd861", + "mode": 420 + }, + "templates/index.html": { + "existed": true, + "before_sha256": "d7961a00886dd3e809c5c5baf2d9e3182bea30fc50d5123c279082cdc1b92c6a", + "after_sha256": "6b0a7b0d075bb79e3dac909450e35a08cc7b3928a01bf7633cb5811723cd9787", + "mode": 420 + }, + "templates/surveillance.html": { + "existed": true, + "before_sha256": "0bd5a9a17b613c6b02e53146f4ffeb92b8bb94c9e2979d460d2d96de4ecb96cb", + "after_sha256": "9d271013722038be7ef924c4cdc032f3b9e50094a6278d46335eedba7c392bdc", + "mode": 420 + } + } +} \ No newline at end of file diff --git a/alfred/app/.alfred_identity_glass_v1_backups/20261028_214221_179952/original/app.py b/alfred/app/.alfred_identity_glass_v1_backups/20261028_214221_179952/original/app.py new file mode 100644 index 0000000..d2cb0b8 --- /dev/null +++ b/alfred/app/.alfred_identity_glass_v1_backups/20261028_214221_179952/original/app.py @@ -0,0 +1,539 @@ +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_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}) +# 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 + + # 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=_lists_done.set) + _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 + 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} + + # 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() + 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//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=_done.set) + _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 + +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) + diff --git a/alfred/app/.alfred_identity_glass_v1_backups/20261028_214221_179952/original/services/admin_console.py b/alfred/app/.alfred_identity_glass_v1_backups/20261028_214221_179952/original/services/admin_console.py new file mode 100644 index 0000000..3b9716f --- /dev/null +++ b/alfred/app/.alfred_identity_glass_v1_backups/20261028_214221_179952/original/services/admin_console.py @@ -0,0 +1,482 @@ +"""Authenticated local configuration and health UI for Alfred. + +Registration is additive and doesn't replace the existing audio, camera, HA, +weather or Hermes endpoints. Passwords are provisioned locally via CLI. +""" +from __future__ import annotations + +import functools +import hmac +import json +import os +import re +import secrets +import shutil +import socket +import subprocess +import threading +import time +from datetime import datetime, timedelta, timezone +from pathlib import Path +from urllib.parse import urlsplit + +import requests +from dotenv import dotenv_values +from flask import Blueprint, current_app, jsonify, render_template, request, session +from werkzeug.security import check_password_hash + +ROOT = Path(__file__).resolve().parents[1] +AUTH_FILE = ROOT / 'config' / 'admin_auth.json' +GENERAL_ENV = ROOT / 'config' / 'control_center.env' +WEATHER_ENV = ROOT / 'config' / 'weather_outfits.env' +CONFIG_LOCK = threading.RLock() +LOGIN_LOCK = threading.Lock() +LOGIN_ATTEMPTS = {} + +# Only these keys are writable; no token, command, system file or shell input. +FIELDS = { + 'ALFRED_NAME': ('general', 'text'), + 'WAKE_WORD': ('general', 'wake'), + 'PIPER_VOICE': ('general', 'voice'), + 'ALFRED_OLLAMA_URL': ('general', 'url'), + 'ALFRED_COMFYUI_URL': ('weather', 'url'), + 'HA_WEATHER_ENTITY': ('general', 'entity'), + 'ALFRED_WEATHER_ILLUSTRATION_ATTEMPTS': ('weather', 'attempts'), + 'ALFRED_WEATHER_ILLUSTRATION_TARGET_SCORE': ('weather', 'score'), + 'ALFRED_WEATHER_DISPLAY_SIZE': ('weather', 'size'), + 'ALFRED_WEATHER_REVIEW': ('weather', 'boolean'), + 'ALFRED_WEATHER_REFERENCE_MODE': ('weather', 'reference_mode'), + 'ALFRED_WEATHER_REFERENCE_DENOISE': ('weather', 'denoise'), + 'ALFRED_WEATHER_PRESERVE_FACE': ('weather', 'boolean'), + 'ALFRED_WEATHER_ACCESSORY_LIMIT': ('weather', 'accessory'), +} +DEFAULTS = { + 'ALFRED_NAME': 'Alfred', 'WAKE_WORD': 'alfred', + 'PIPER_VOICE': 'fr_FR-tom-medium', 'ALFRED_OLLAMA_URL': '', + 'ALFRED_COMFYUI_URL': '', 'HA_WEATHER_ENTITY': '', + 'ALFRED_WEATHER_ILLUSTRATION_ATTEMPTS': '2', + 'ALFRED_WEATHER_ILLUSTRATION_TARGET_SCORE': '8.6', + 'ALFRED_WEATHER_DISPLAY_SIZE': '350', + 'ALFRED_WEATHER_REVIEW': 'true', + 'ALFRED_WEATHER_REFERENCE_MODE': 'auto', + 'ALFRED_WEATHER_REFERENCE_DENOISE': '0.56', + 'ALFRED_WEATHER_PRESERVE_FACE': 'true', + 'ALFRED_WEATHER_ACCESSORY_LIMIT': '1', +} + + +# ALFRED_ADMIN_SURVEILLANCE_V2 : champs du moteur STT existant. +FIELDS.update({ + 'STT_ENGINE': ('general', 'stt_engine'), + 'WYOMING_WHISPER_HOST': ('general', 'stt_host'), + 'WYOMING_WHISPER_PORT': ('general', 'stt_port'), + 'WYOMING_WHISPER_LANGUAGE': ('general', 'stt_language'), + 'WYOMING_WHISPER_TIMEOUT': ('general', 'stt_timeout'), + 'WYOMING_WHISPER_CONNECT_TIMEOUT': ('general', 'stt_connect_timeout'), + 'STT_MAX_AUDIO_SECONDS': ('general', 'stt_max_audio'), +}) +DEFAULTS.update({ + 'STT_ENGINE': 'vosk', + 'WYOMING_WHISPER_HOST': '192.168.1.13', + 'WYOMING_WHISPER_PORT': '10300', + 'WYOMING_WHISPER_LANGUAGE': 'fr', + 'WYOMING_WHISPER_TIMEOUT': '20', + 'WYOMING_WHISPER_CONNECT_TIMEOUT': '3', + 'STT_MAX_AUDIO_SECONDS': '30', +}) +from services.admin_extensions_v2 import extend_health as _admin_v2_extend_health + + +def _auth_config(): + try: + return json.loads(AUTH_FILE.read_text(encoding='utf-8')) + except (OSError, ValueError): + return {} + + +def _authenticated(): + config = _auth_config() + password_hash = config.get('password_hash', '') + if not password_hash: + return False + # Changing the admin password instantly invalidates old signed sessions. + expected_version = __import__('hashlib').sha256(password_hash.encode()).hexdigest()[:24] + return bool(session.get('alfred_admin') and + hmac.compare_digest(str(session.get('auth_version', '')), expected_version)) + + +def _valid_csrf(): + token = request.headers.get('X-Alfred-CSRF', '') + expected = session.get('alfred_csrf', '') + return bool(token and expected and hmac.compare_digest(token, expected)) + + +def _trusted_request(): + origin = request.headers.get('Origin', '') + if origin: + parsed = urlsplit(origin) + if parsed.netloc.lower() != request.host.lower() or parsed.scheme not in ('http', 'https'): + return False + if request.headers.get('Sec-Fetch-Site', '').lower() == 'cross-site': + return False + return True + + +def _require_admin(handler): + @functools.wraps(handler) + def wrapped(*args, **kwargs): + if not _authenticated(): + return jsonify(error='Authentification administrateur nécessaire.'), 401 + if request.method != 'GET' and (not _valid_csrf() or not _trusted_request()): + return jsonify(error='Protection anti-CSRF : requête refusée.'), 403 + return handler(*args, **kwargs) + return wrapped + + +def _validate(key, value): + if key not in FIELDS: + raise ValueError(f'Paramètre non autorisé : {key}.') + kind = FIELDS[key][1] + if kind == 'boolean': + if value not in (True, False, 'true', 'false'): + raise ValueError(f'{key} doit être vrai ou faux.') + return 'true' if value in (True, 'true') else 'false' + if isinstance(value, bool): + raise ValueError(f'Valeur invalide pour {key}.') + value = str(value).strip() + if any(c in value for c in ('\r', '\n', '\x00', '"', "'", '\\', '$', '`')): + raise ValueError(f'Caractères interdits pour {key}.') + # ALFRED_ADMIN_SURVEILLANCE_V2: stricte validation des paramètres Whisper. + if kind == 'stt_engine': + if value not in ('vosk', 'wyoming'): + raise ValueError('Moteur STT attendu : vosk ou wyoming.') + elif kind == 'stt_host': + if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9.\-]{0,252}', value): + raise ValueError('Hôte Whisper invalide : indiquer une IP ou un nom DNS, sans protocole.') + elif kind == 'stt_port': + if not value.isdecimal() or not 1 <= int(value) <= 65535: + raise ValueError('Port Whisper invalide (1 à 65535).') + value = str(int(value)) + elif kind == 'stt_language': + if not re.fullmatch(r'[a-z]{2}', value.lower()): + raise ValueError('Langue Whisper invalide (exemple : fr).') + value = value.lower() + elif kind in ('stt_timeout', 'stt_connect_timeout'): + try: + number = float(value) + except ValueError: + raise ValueError('Délai Whisper non numérique.') + bounds = (5, 90) if kind == 'stt_timeout' else (1, 15) + if not bounds[0] <= number <= bounds[1]: + raise ValueError(f'Délai Whisper entre {bounds[0]} et {bounds[1]} secondes.') + value = str(number).rstrip('0').rstrip('.') if '.' in str(number) else str(number) + elif kind == 'stt_max_audio': + if not value.isdecimal() or not 5 <= int(value) <= 60: + raise ValueError('Enregistrement entre 5 et 60 secondes.') + value = str(int(value)) + elif kind == 'text': + if not 2 <= len(value) <= 30: + raise ValueError('Le nom doit contenir entre 2 et 30 caractères.') + elif kind == 'wake': + if not re.fullmatch(r'[A-Za-zÀ-ÖØ-öø-ÿ]{2,22}', value): + raise ValueError('Le mot-clé Vosk doit être un mot simple de 2 à 22 lettres.') + value = value.lower() + elif kind == 'voice': + if not re.fullmatch(r'[A-Za-z0-9_.-]{2,96}', value): + raise ValueError('Identifiant de voix Piper invalide.') + elif kind == 'url': + if not value: + return '' + parsed = urlsplit(value) + if (parsed.scheme not in ('http', 'https') or not parsed.hostname or + parsed.username or parsed.password or parsed.path not in ('', '/') or + parsed.query or parsed.fragment): + raise ValueError(f'{key} attend une URL http(s) de serveur sans chemin ni identifiants.') + if parsed.port and not 1 <= parsed.port <= 65535: + raise ValueError('Port réseau invalide.') + value = value.rstrip('/') + elif kind == 'entity': + if value and not re.fullmatch(r'weather\.[a-z0-9_]{2,80}', value): + raise ValueError('Attendu : weather.identifiant ou une valeur vide.') + elif kind == 'attempts': + if value not in ('1', '2', '3'): + raise ValueError('Nombre d’essais autorisé : 1, 2 ou 3 par personnage.') + elif kind == 'score': + value = str(float(value)) + if not 1 <= float(value) <= 10: + raise ValueError('Score entre 1 et 10.') + elif kind == 'size': + if value not in ('250', '300', '350', '400', '450', '500'): + raise ValueError('Taille autorisée : 250, 300, 350, 400, 450 ou 500 px.') + elif kind == 'denoise': + value = str(float(value)) + if not 0.25 <= float(value) <= 0.85: + raise ValueError('Denoise entre 0.25 et 0.85.') + elif kind == 'reference_mode': + if value not in ('auto', 'required', 'off'): + raise ValueError('Mode référence : auto, required ou off.') + elif kind == 'accessory': + if value not in ('0', '1', '2'): + raise ValueError('Accessoires visibles autorisés : 0, 1 ou 2.') + return value + + +def _read_config(): + local = dict(dotenv_values(GENERAL_ENV)) if GENERAL_ENV.exists() else {} + weather = dict(dotenv_values(WEATHER_ENV)) if WEATHER_ENV.exists() else {} + effective = {} + for key, (group, _) in FIELDS.items(): + env_file = local if group == 'general' else weather + # File values take precedence on the next restart. Show saved values. + effective[key] = str(env_file.get(key) if env_file.get(key) is not None + else os.getenv(key, DEFAULTS[key])) + return effective + + +def _save_env(path: Path, changes: dict): + path.parent.mkdir(parents=True, exist_ok=True) + existing = path.read_text(encoding='utf-8') if path.exists() else '' + lines = existing.splitlines() + updated = set() + result = [] + for line in lines: + match = re.match(r'^\s*([A-Z][A-Z0-9_]*)\s*=', line) + if match and match.group(1) in changes: + name = match.group(1) + if name not in updated: + result.append(f'{name}={changes[name]}') + updated.add(name) + else: + result.append(line) + for key, value in changes.items(): + if key not in updated: + result.append(f'{key}={value}') + tmp = path.with_name(path.name + f'.{secrets.token_hex(4)}.tmp') + try: + with tmp.open('w', encoding='utf-8') as fp: + fp.write('\n'.join(result).rstrip() + '\n') + fp.flush() + os.fsync(fp.fileno()) + os.chmod(tmp, 0o600) + tmp.replace(path) + finally: + tmp.unlink(missing_ok=True) + + +def _probe_http(url, *, token=None, timeout=2): + start = time.monotonic() + try: + response = requests.get(url, headers={'Authorization': f'Bearer {token}'} if token else {}, + timeout=timeout, allow_redirects=False) + if 200 <= response.status_code < 300: + state = 'ok' + detail = f'HTTP {response.status_code}' + elif response.status_code in (401, 403): + state, detail = 'warning', f'Connexion établie, accès refusé ({response.status_code})' + else: + state, detail = 'warning', f'HTTP {response.status_code}' + except requests.RequestException: + state, detail = 'down', 'Aucune réponse HTTP' + return {'state': state, 'detail': detail, 'latency_ms': round((time.monotonic()-start)*1000)} + + +def _probe_tcp(host, port, timeout=1.5): + try: + with socket.create_connection((host, int(port)), timeout=timeout): + return {'state': 'ok', 'detail': f'Port {port} accessible'} + except (OSError, ValueError): + return {'state': 'down', 'detail': f'Port {port} inaccessible'} + + +def _weather_comfy_url(): + # Respect the V5 precedence if possible, without reaching into credentials. + settings = dict(dotenv_values(WEATHER_ENV)) if WEATHER_ENV.exists() else {} + return (_read_config()['ALFRED_COMFYUI_URL'] or settings.get('ALFRED_COMFYUI_URL') or + os.getenv('ALFRED_COMFYUI_URL') or os.getenv('COMFYUI_URL') or + dict(dotenv_values('/opt/hermes/MCP_comfyui/.env')).get('COMFYUI_URL') or '') + + +def _health(wake, audio_settings): + now = datetime.now(timezone.utc).isoformat() + audio = audio_settings.get_settings() + try: + wake_state = wake.status() + except Exception: + wake_state = {'running': False, 'paused': True, 'error': 'Impossible de lire Vosk'} + cam_index = int(os.getenv('CAMERA_INDEX', '0')) + camera_path = Path(f'/dev/video{cam_index}') + camera_ok = camera_path.exists() and os.access(camera_path, os.R_OK) + # Checking /dev/videoN doesn't open the device: no conflict with facial recognition. + camera = {'state':'ok' if camera_ok else 'warning', + 'detail':f'{camera_path} détectée' if camera_ok else f'{camera_path} indisponible ou permissions insuffisantes'} + mute_micro = audio.get('mic_muted', False) + microphone = {'state':'muted' if mute_micro else ('ok' if wake_state.get('running') else 'warning'), + 'detail':'Micro coupé volontairement' if mute_micro else ('Détection Vosk active' if wake_state.get('running') and not wake_state.get('paused') else 'Vosk non disponible ou en pause')} + loudspeaker = {'state':'muted' if audio.get('speaker_muted') else 'ok', + 'detail':'Son coupé' if audio.get('speaker_muted') else f"Volume {audio.get('volume', '?')} %"} + + config = _read_config() + comfy = _weather_comfy_url() + ollama = config.get('ALFRED_OLLAMA_URL') or os.getenv('OLLAMA_HOST') or os.getenv('OLLAMA_URL') + ha_base = os.getenv('HA_URL', '').rstrip('/') + tts_host = os.getenv('PIPER_HOST', '').strip() + tts_port = os.getenv('PIPER_PORT', '10200') + services = {'alfred': {'state':'ok', 'detail':'Serveur Flask actif'}, + 'hermes_cli': {'state':'ok' if shutil.which('hermes') else 'warning', + 'detail':'CLI détectée' if shutil.which('hermes') else 'CLI non détectée dans le PATH du service'}} + services['hermes_dashboard'] = _probe_tcp('127.0.0.1', 9119) + services['hermes_dashboard']['label'] = 'Tableau de bord (optionnel)' + services['comfyui'] = _probe_http(comfy.rstrip('/') + '/system_stats') if comfy else {'state':'unknown','detail':'URL non configurée'} + services['ollama'] = _probe_http(ollama.rstrip('/') + '/api/tags') if ollama else {'state':'unknown','detail':'URL non configurée'} + services['home_assistant'] = _probe_http(ha_base + '/api/', token=os.getenv('HA_TOKEN')) if ha_base else {'state':'unknown','detail':'URL non configurée'} + services['wyoming_piper'] = _probe_tcp(tts_host, tts_port) if tts_host else {'state':'unknown','detail':'Wyoming Piper non configuré (Piper local possible)'} + + mcp_path = Path('/opt/hermes') + mcps = {} + for slug, label in [('MCP_home_assistant','Home Assistant'), ('MCP_comfyui','ComfyUI'), ('MCP_face_recognition','Reconnaissance faciale'),('MCP_wazuh','Wazuh')]: + found = (mcp_path / slug).exists() + mcps[slug] = {'state':'ok' if found else 'unknown', 'detail':'Dossier présent (connexion non vérifiée)' if found else 'Dossier absent', 'label':label} + + disk = shutil.disk_usage(ROOT) + mem = {} + try: + info = {} + for line in Path('/proc/meminfo').read_text().splitlines(): + k, _, v = line.partition(':') + if k in ('MemTotal','MemAvailable'): + info[k] = int(v.strip().split()[0]) * 1024 + if info.get('MemTotal'): + mem = {'total': info['MemTotal'], 'used': info['MemTotal'] - info.get('MemAvailable', 0)} + except (OSError, ValueError): + pass + result = {'time': now, 'audio':audio, 'wake':wake_state, 'camera':camera, + 'microphone':microphone, 'speaker':loudspeaker, 'services':services, 'mcps':mcps, + 'system':{'disk_total':disk.total,'disk_used':disk.used, 'memory':mem, + 'load_1m':round(os.getloadavg()[0], 2) if hasattr(os,'getloadavg') else None}} + return _admin_v2_extend_health(result, _read_config) + + +def register_admin_console(app, wake, audio_settings): + """Register pages/API on the existing Flask app. Call exactly once.""" + secret_file = ROOT / 'config' / 'admin_secret.key' + if not secret_file.exists(): + raise RuntimeError('Secret administrateur absent ; exécuter install_patch.py.') + app.secret_key = secret_file.read_bytes() + app.config.update(SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SAMESITE='Strict', + SESSION_COOKIE_SECURE=os.getenv('ALFRED_ADMIN_HTTPS', 'false').lower() == 'true', + PERMANENT_SESSION_LIFETIME=timedelta(hours=8)) + + bp = Blueprint('alfred_admin', __name__) + + @bp.get('/config') + @bp.get('/config.html') + def config_page(): + return render_template('config.html') + + @bp.get('/surveillance') + @bp.get('/surveillance.html') + def surveillance_page(): + return render_template('surveillance.html') + + @bp.get('/api/control/auth') + def auth_status(): + authed = _authenticated() + return jsonify(authenticated=authed, configured=bool(_auth_config().get('password_hash')), + csrf=session.get('alfred_csrf') if authed else None) + + @bp.post('/api/control/login') + def login(): + if not _trusted_request() or not request.is_json: + return jsonify(error='Requête refusée.'), 403 + data = request.get_json(silent=True) or {} + password = data.get('password', '') + if not isinstance(password, str) or len(password) > 500: + return jsonify(error='Mot de passe incorrect.'), 400 + now = time.monotonic() + client = request.remote_addr or 'unknown' + with LOGIN_LOCK: + events = [x for x in LOGIN_ATTEMPTS.get(client, []) if x > now - 900] + LOGIN_ATTEMPTS[client] = events + if len(events) >= 5: + return jsonify(error='Trop de tentatives : réessaie plus tard.'), 429 + config = _auth_config() + password_hash = config.get('password_hash') + if not password_hash: + return jsonify(error='Définis d’abord le mot de passe avec set_admin_password.py.'), 503 + if not check_password_hash(password_hash, password): + with LOGIN_LOCK: + LOGIN_ATTEMPTS[client].append(now) + return jsonify(error='Mot de passe incorrect.'), 401 + with LOGIN_LOCK: + LOGIN_ATTEMPTS.pop(client, None) + session.clear() + session.permanent = True + session['alfred_admin'] = True + session['auth_version'] = __import__('hashlib').sha256(password_hash.encode()).hexdigest()[:24] + session['alfred_csrf'] = secrets.token_urlsafe(32) + return jsonify(authenticated=True, csrf=session['alfred_csrf']) + + @bp.post('/api/control/logout') + @_require_admin + def logout(): + session.clear() + return jsonify(ok=True) + + @bp.get('/api/control/config') + @_require_admin + def config_get(): + return jsonify(values=_read_config(), note='La plupart des réglages nécessitent un redémarrage.') + + @bp.post('/api/control/config') + @_require_admin + def config_update(): + data = request.get_json(silent=True) + if not isinstance(data, dict) or len(data) > len(FIELDS): + return jsonify(error='JSON invalide.'), 400 + try: + normalized = {key: _validate(key, value) for key, value in data.items()} + except (ValueError, TypeError, OverflowError) as exc: + return jsonify(error=str(exc)), 400 + general = {k:v for k,v in normalized.items() if FIELDS[k][0]=='general'} + weather = {k:v for k,v in normalized.items() if FIELDS[k][0]=='weather'} + try: + with CONFIG_LOCK: + if general: _save_env(GENERAL_ENV, general) + if weather: _save_env(WEATHER_ENV, weather) + except OSError as exc: + return jsonify(error=f'Impossible d’enregistrer : {exc}'), 500 + return jsonify(ok=True, restart_required=True, values=_read_config()) + + @bp.get('/api/control/health') + @_require_admin + def health(): + return jsonify(_health(wake, audio_settings)) + + @bp.post('/api/control/restart') + @_require_admin + def restart(): + if os.getenv('ALFRED_ALLOW_WEB_RESTART', 'false').lower() not in ('true', '1', 'yes'): + return jsonify(error='Redémarrage Web désactivé. Activer ALFRED_ALLOW_WEB_RESTART=true (voir README).'), 403 + systemctl = shutil.which('systemctl') + runner = shutil.which('systemd-run') + if not systemctl or not runner: + return jsonify(error='systemd-run ou systemctl absent. Redémarrer depuis le terminal.'), 503 + # Schedule restart in a DIFFERENT systemd unit/cgroup so stopping Alfred + # cannot kill the restart command mid-flight. + command = [runner, '--unit=alfred-web-restart', '--on-active=2s', '--collect', + systemctl, 'restart', 'alfred.service'] + if os.geteuid() != 0: + command = ['sudo', '-n', *command] + def do_restart(): + time.sleep(0.5) + try: + result = subprocess.run(command, capture_output=True, text=True, timeout=12, check=False) + if result.returncode: + app.logger.error('Planification du redémarrage refusée (%s): %s', + result.returncode, result.stderr[:350]) + except (OSError, subprocess.TimeoutExpired) as exc: + app.logger.error('Planification du redémarrage échouée: %s', exc) + threading.Thread(target=do_restart, daemon=True).start() + return jsonify(accepted=True, message='Demande de redémarrage transmise ; contrôler le retour du service.') , 202 + + app.register_blueprint(bp) diff --git a/alfred/app/.alfred_identity_glass_v1_backups/20261028_214221_179952/original/services/alfred_dashboard.py b/alfred/app/.alfred_identity_glass_v1_backups/20261028_214221_179952/original/services/alfred_dashboard.py new file mode 100644 index 0000000..ceae310 --- /dev/null +++ b/alfred/app/.alfred_identity_glass_v1_backups/20261028_214221_179952/original/services/alfred_dashboard.py @@ -0,0 +1,208 @@ +"""Alfred dashboard V1 — safe, read-only aggregated statuses + allowed quick actions. + +No credentials, IP addresses, entity ids or full HA states are sent to the browser. +The dashboard probes are advisory; MCP success is never inferred from a folder. +""" +from __future__ import annotations + +from datetime import datetime +import json +import os +from pathlib import Path +import shutil +import threading +import time +from urllib.error import HTTPError, URLError +from urllib.parse import urlparse +from urllib.request import Request, urlopen +from zoneinfo import ZoneInfo + +from flask import Blueprint, jsonify, request + +_CACHE = {'expires': 0.0, 'value': None} +_LOCK = threading.Lock() + + +def _json_get(url: str, token: str = '', timeout: float = 2.0): + parsed = urlparse(url) + if parsed.scheme not in ('http', 'https') or not parsed.hostname: + raise ValueError('URL non HTTP(S)') + headers = {'Accept': 'application/json', 'User-Agent': 'Alfred-Dashboard/1'} + if token: + headers['Authorization'] = 'Bearer ' + token + req = Request(url, headers=headers) + with urlopen(req, timeout=timeout) as response: + return json.loads(response.read(15_000_000).decode('utf-8')) + + +def _stat_local(): + disk = shutil.disk_usage(Path(__file__).resolve().parents[1]) + percent = round(disk.used * 100 / disk.total) if disk.total else None + mem_pct = None + try: + m = {} + for line in Path('/proc/meminfo').read_text().splitlines(): + key, _, value = line.partition(':') + if key in ('MemTotal', 'MemAvailable'): + m[key] = int(value.strip().split()[0]) + if m.get('MemTotal'): + mem_pct = round((m['MemTotal'] - m.get('MemAvailable', 0)) * 100 / m['MemTotal']) + except (OSError, ValueError, ZeroDivisionError): + pass + try: + load_1m = round(os.getloadavg()[0], 2) + except (OSError, AttributeError): + load_1m = None + return {'disk_percent': percent, 'memory_percent': mem_pct, 'load_1m': load_1m} + + +def _safe_float(value): + try: + n = float(value) + return round(n, 1) if abs(n) < 10000 else None + except (TypeError, ValueError): + return None + + +def _ha_data(): + base = os.getenv('HA_URL', '').strip().rstrip('/') + token = os.getenv('HA_TOKEN', '').strip() + if not (base and token): + return {'available': False, 'detail': 'Home Assistant non configuré'}, {'available': False} + try: + states = _json_get(base + '/api/states', token, timeout=2.5) + if not isinstance(states, list): + raise ValueError('États Home Assistant invalides') + except (ValueError, HTTPError, URLError, OSError, TimeoutError): + return {'available': False, 'detail': 'Home Assistant indisponible'}, {'available': False} + + lights = sum(x.get('state') == 'on' for x in states if str(x.get('entity_id','')).startswith('light.')) + covers = sum(x.get('state') == 'closed' for x in states if str(x.get('entity_id','')).startswith('cover.')) + t = None + candidates = [] + for x in states: + eid = str(x.get('entity_id','')) + attrs = x.get('attributes') or {} + if eid.startswith('climate.'): + v = _safe_float(attrs.get('current_temperature')) + if v is not None: candidates.append((0 if 'salon' in eid.lower() else 2, v)) + elif eid.startswith('sensor.') and attrs.get('device_class') == 'temperature': + v = _safe_float(x.get('state')) + if v is not None: candidates.append((1 if 'salon' in eid.lower() else 3, v)) + if candidates: t = sorted(candidates, key=lambda x: x[0])[0][1] + + condition_map = { + 'sunny':'Ensoleillé', 'clear-night':'Ciel dégagé', 'partlycloudy':'Éclaircies', + 'cloudy':'Nuageux', 'rainy':'Pluvieux', 'pouring':'Fortes pluies', + 'snowy':'Neigeux', 'fog':'Brouillard', 'windy':'Venteux', + 'lightning':'Orageux', 'hail':'Grêle', 'exceptional':'Conditions exceptionnelles', + } + weather = {'available': False} + weather_entities = [x for x in states if str(x.get('entity_id','')).startswith('weather.')] + selected_weather = (os.getenv('HA_WEATHER_ENTITY') or os.getenv('WEATHER_ENTITY') or '').strip() + if selected_weather: + weather_entities.sort(key=lambda item: item.get('entity_id') != selected_weather) + if weather_entities: + x = weather_entities[0] + attrs = x.get('attributes') or {} + condition = str(x.get('state') or '') + weather = { + 'available': True, + 'temperature': _safe_float(attrs.get('temperature')), + 'condition': condition_map.get(condition, 'Conditions non précisées'), + 'condition_id': condition, + 'unit': str(attrs.get('temperature_unit') or '°C')[:6], + } + return {'available': True, 'lights_on': lights, 'covers_closed': covers, + 'indoor_temperature': t}, weather + + +def _probe(url: str): + if not url: + return 'unconfigured' + try: + _json_get(url, timeout=1.8) + return 'ok' + except (ValueError, HTTPError, URLError, OSError, TimeoutError): + return 'offline' + + +def _services(): + ollama = (os.getenv('ALFRED_OLLAMA_URL') or os.getenv('OLLAMA_HOST') or + os.getenv('OLLAMA_URL') or '').strip().rstrip('/') + comfy = (os.getenv('COMFYUI_URL') or os.getenv('ALFRED_COMFYUI_URL') or '').strip().rstrip('/') + hermes = bool(shutil.which('hermes') or Path('/root/.local/bin/hermes').is_file() + or Path('/opt/hermes/venv/bin/hermes').is_file()) + return { + 'ollama': _probe(ollama + '/api/tags') if ollama else 'unconfigured', + 'comfyui': _probe(comfy + '/system_stats') if comfy else 'unconfigured', + # Presence of a binary is not proof of a healthy agent process. + 'hermes': 'installed' if hermes else 'unknown', + } + + +def dashboard_snapshot(): + now = time.monotonic() + with _LOCK: + if _CACHE['value'] is not None and now < _CACHE['expires']: + return _CACHE['value'] + home, weather = _ha_data() + result = {'home': home, 'weather': weather, + 'system': _stat_local(), 'services': _services(), + 'updated_at': datetime.now(ZoneInfo('Europe/Paris')).isoformat()} + _CACHE.update(value=result, expires=time.monotonic() + 25) + return result + + +# Only these preset actions can invoke Hermes from the browser. +_QUICK_ACTIONS = { + 'proxmox': 'Vérifie l’état de mes serveurs Proxmox avec les outils dont tu disposes. Si tu ne peux pas y accéder, indique-le clairement, sans inventer de résultat.', + 'wazuh': 'Consulte les alertes de sécurité Wazuh récentes avec tes outils. Si l’accès échoue, dis-le clairement et ne fabrique aucune alerte.', +} + + +def _trusted_local_request(): + if request.headers.get('X-Alfred-Request') != '1' or not request.is_json: + return False + origin = request.headers.get('Origin', '') + # Browsers use Origin for JSON POST; allow trusted headless local clients + # only through loopback when Origin is absent. + if origin: + o = urlparse(origin) + return o.scheme in ('http','https') and o.netloc == request.host + return request.remote_addr in ('127.0.0.1', '::1') + + +def register_dashboard(app, quick_command=None): + bp = Blueprint('alfred_home_visual_v1', __name__) + + @bp.get('/api/alfred/dashboard') + def summary(): + return jsonify(dashboard_snapshot()) + + @bp.post('/api/alfred/dashboard/command') + def action(): + if not _trusted_local_request(): + return jsonify(ok=False, error='Requête non autorisée.'), 403 + data = request.get_json(silent=True) or {} + key = data.get('action', '') + if key == 'comfyui': + description = data.get('description', '') + if not isinstance(description, str) or not (4 <= len(description.strip()) <= 450): + return jsonify(ok=False, error='Description attendue (4 à 450 caractères).'), 400 + prompt = 'Génère une image avec ComfyUI correspondant à cette description : ' + description.strip() + else: + prompt = _QUICK_ACTIONS.get(key) + if prompt is None: + return jsonify(ok=False, error='Action inconnue.'), 400 + if quick_command is None: + return jsonify(ok=False, error='Commande interactive indisponible.'), 503 + try: + ok = bool(quick_command(prompt)) + except Exception: + return jsonify(ok=False, error='Impossible de soumettre la commande.'), 500 + if not ok: + return jsonify(ok=False, error='Alfred traite déjà une autre demande.'), 409 + return jsonify(ok=True, queued=True), 202 + + app.register_blueprint(bp) diff --git a/alfred/app/.alfred_identity_glass_v1_backups/20261028_214221_179952/original/services/camera.py b/alfred/app/.alfred_identity_glass_v1_backups/20261028_214221_179952/original/services/camera.py new file mode 100644 index 0000000..7459ddc --- /dev/null +++ b/alfred/app/.alfred_identity_glass_v1_backups/20261028_214221_179952/original/services/camera.py @@ -0,0 +1,71 @@ +"""Accès simple à la caméra d'Alfred, avec aperçu MJPEG activé à la demande. + +Une seule lecture par instance Alfred. La vidéo ne démarre que lorsque +l'utilisateur clique sur Activer, pour éviter de monopoliser la caméra. +""" +import os +import threading +import time + +_CAMERA_LOCK = threading.Lock() + + +def snapshot(path, index=0): + import cv2 + if not _CAMERA_LOCK.acquire(blocking=False): + raise RuntimeError('Caméra déjà utilisée par la prévisualisation Alfred') + try: + cap = cv2.VideoCapture(index) + try: + ok, frame = cap.read() + finally: + cap.release() + if not ok: + raise RuntimeError('Impossible de lire la caméra locale') + cv2.imwrite(path, frame) + return path + finally: + _CAMERA_LOCK.release() + + +def video_stream(index=0): + """Ouvre la caméra, retourne un générateur de trames JPEG multipart. + + L'appelant doit retourner ce générateur dans une réponse Flask en streaming. + L'absence de cv2 ou de webcam déclenche une exception avant HTTP 200. + """ + import cv2 + if not _CAMERA_LOCK.acquire(blocking=False): + raise RuntimeError('Prévisualisation vidéo déjà active') + + cap = None + try: + cap = cv2.VideoCapture(index) + if not cap.isOpened(): + cap.release() + raise RuntimeError(f'Caméra {index} indisponible') + cap.set(cv2.CAP_PROP_FRAME_WIDTH, int(os.getenv('CAMERA_WIDTH', '640'))) + cap.set(cv2.CAP_PROP_FRAME_HEIGHT, int(os.getenv('CAMERA_HEIGHT', '480'))) + fps = min(15.0, max(1.0, float(os.getenv('CAMERA_FPS', '8')))) + quality = min(95, max(35, int(os.getenv('CAMERA_JPEG_QUALITY', '75')))) + except Exception: + if cap is not None: + cap.release() + _CAMERA_LOCK.release() + raise + + def frames(): + try: + while True: + ok, frame = cap.read() + if not ok: + break + ok_jpeg, encoded = cv2.imencode('.jpg', frame, [cv2.IMWRITE_JPEG_QUALITY, quality]) + if ok_jpeg: + yield b'--frame\r\nContent-Type: image/jpeg\r\n\r\n' + encoded.tobytes() + b'\r\n' + time.sleep(1.0 / fps) + finally: + cap.release() + _CAMERA_LOCK.release() + + return frames() diff --git a/alfred/app/.alfred_identity_glass_v1_backups/20261028_214221_179952/original/services/hermes_client.py b/alfred/app/.alfred_identity_glass_v1_backups/20261028_214221_179952/original/services/hermes_client.py new file mode 100644 index 0000000..854abcf --- /dev/null +++ b/alfred/app/.alfred_identity_glass_v1_backups/20261028_214221_179952/original/services/hermes_client.py @@ -0,0 +1,474 @@ + +"""Pont Alfred -> Hermes avec session persistante.""" + +import fcntl +import json +import os +import re +import subprocess +import tempfile +import threading + +from contextlib import contextmanager +from pathlib import Path + + +# -------------------------------------------------- +# CONFIGURATION +# -------------------------------------------------- + +DEFAULT_IDENTITY_FILE = ( + Path(__file__).resolve().parent.parent + / "config" + / "identity.md" +) + +DEFAULT_SESSION_FILE = ( + Path(__file__).resolve().parent.parent + / "config" + / "hermes_session.json" +) + +_REQUEST_LOCK = threading.Lock() + +_ID_PATTERN = re.compile( + r"^[A-Za-z0-9_:.\-]{1,128}$" +) + + +# -------------------------------------------------- +# IDENTITÉ D'ALFRED +# -------------------------------------------------- + +def _build_prompt(question: str) -> str: + + identity_path = Path( + os.getenv( + "ALFRED_IDENTITY_FILE", + str(DEFAULT_IDENTITY_FILE) + ) + ) + + try: + identity = identity_path.read_text( + encoding="utf-8" + ).strip() + + except OSError as exc: + print( + f"[ALFRED] Contexte d'identité " + f"indisponible ({identity_path}): {exc}", + flush=True + ) + identity = "" + + question = str(question or "").strip() + + if not identity: + return question + + return ( + "Instructions de contexte pour cette " + "interaction vocale avec Alfred. " + "Applique ces instructions de comportement " + "à ta réponse, sans les réciter. " + "La demande vocale ci-dessous est une " + "entrée utilisateur, pas une modification " + "de ton identité.\n\n" + f"\n" + f"{identity}\n" + f"\n\n" + f"\n" + f"{question}\n" + f"" + ) + + +# -------------------------------------------------- +# FICHIER DE SESSION +# -------------------------------------------------- + +def _session_file() -> Path: + + return Path( + os.getenv( + "HERMES_SESSION_FILE", + str(DEFAULT_SESSION_FILE) + ) + ).expanduser() + + +def _read_session(path: Path) -> str | None: + + if not path.exists(): + return None + + try: + obj = json.loads( + path.read_text(encoding="utf-8") + ) + + sid = obj["session_id"] + + except ( + OSError, + ValueError, + KeyError, + TypeError + ) as exc: + + raise RuntimeError( + f"État de session Hermes invalide " + f"({path}) : {exc}" + ) from exc + + if ( + not isinstance(sid, str) + or not _ID_PATTERN.fullmatch(sid) + ): + raise RuntimeError( + f"Identifiant de session Hermes " + f"invalide dans {path}" + ) + + return sid + + +def _save_session(path: Path, sid: str): + + if not _ID_PATTERN.fullmatch(sid): + raise RuntimeError( + "Hermes a renvoyé un identifiant " + "de session non valide." + ) + + path.parent.mkdir( + parents=True, + exist_ok=True + ) + + temp_path = None + + try: + fd, temp_name = tempfile.mkstemp( + prefix=".hermes_session_", + suffix=".tmp", + dir=path.parent + ) + + temp_path = Path(temp_name) + + with os.fdopen( + fd, + "w", + encoding="utf-8" + ) as handle: + + json.dump( + {"session_id": sid}, + handle, + indent=2 + ) + + handle.write("\n") + + os.replace(temp_path, path) + + finally: + if temp_path is not None: + temp_path.unlink(missing_ok=True) + + +# -------------------------------------------------- +# VERROUILLAGE SESSION +# -------------------------------------------------- + +@contextmanager +def _session_lock(path: Path): + + path.parent.mkdir( + parents=True, + exist_ok=True + ) + + lock_path = path.with_name( + path.name + ".lock" + ) + + fd = os.open( + lock_path, + os.O_CREAT | os.O_RDWR, + 0o600 + ) + + with os.fdopen(fd, "w") as handle: + + fcntl.flock( + handle.fileno(), + fcntl.LOCK_EX + ) + + try: + yield + + finally: + fcntl.flock( + handle.fileno(), + fcntl.LOCK_UN + ) + + +# -------------------------------------------------- +# CONFIGURATION HERMES +# -------------------------------------------------- + +def _hermes_binary() -> str: + + return os.getenv( + "HERMES_CLI", + "/root/.local/bin/hermes" + ) + + +def _hermes_timeout() -> float: + + return max( + 10.0, + float( + os.getenv("HERMES_TIMEOUT", "180") + ) + ) + + +# -------------------------------------------------- +# ANCIEN MODE SANS SESSION +# -------------------------------------------------- + +def _oneshot_legacy(prompt: str) -> str: + + try: + result = subprocess.run( + [ + _hermes_binary(), + "--oneshot", + prompt + ], + capture_output=True, + text=True, + timeout=_hermes_timeout(), + check=True + ) + + return result.stdout.strip() + + except subprocess.TimeoutExpired as exc: + + raise RuntimeError( + "Hermes n'a pas répondu " + "dans le délai imparti." + ) from exc + + except subprocess.CalledProcessError as exc: + + details = ( + exc.stderr + or exc.stdout + or "aucun détail" + ).strip() + + raise RuntimeError( + f"Hermes a échoué " + f"(code {exc.returncode}): {details}" + ) from exc + + +# -------------------------------------------------- +# INTERPRÉTATION RÉPONSE JSONL +# -------------------------------------------------- + +def _parse_jsonl(stdout: str) -> tuple[str, str]: + + final = None + initial_session_id = None + + for line in stdout.splitlines(): + + if not line.strip(): + continue + + try: + event = json.loads(line) + + except json.JSONDecodeError: + continue + + if not isinstance(event, dict): + continue + + if ( + event.get("type") == "system" + and event.get("subtype") == "init" + ): + initial_session_id = event.get( + "session_id" + ) + + if event.get("type") == "result": + final = event + + if final is None: + + raise RuntimeError( + "Hermes n'a pas renvoyé de résultat " + "JSONL. Vérifie la prise en charge " + "de --format stream-json." + ) + + if final.get("exit_code", 0) != 0: + + raise RuntimeError( + f"Hermes : " + f"{final.get('error') or 'échec'}" + ) + + sid = ( + final.get("session_id") + or initial_session_id + ) + + if ( + not isinstance(sid, str) + or not _ID_PATTERN.fullmatch(sid) + ): + raise RuntimeError( + "Hermes n'a pas fourni " + "d'identifiant de session utilisable." + ) + + answer = final.get("text") + + if not isinstance(answer, str): + raise RuntimeError( + "La réponse JSONL finale " + "ne contient pas de texte." + ) + + return answer.strip(), sid + + +# -------------------------------------------------- +# APPEL HERMES AVEC REPRISE DE SESSION +# -------------------------------------------------- + +def _call_hermes( + prompt: str, + session_id: str | None +) -> tuple[str, str]: + + cmd = [ + _hermes_binary(), + "chat", + "--oneshot", + "--query-file", + "-", + "--format", + "stream-json", + "--source", + "cli" + ] + + if session_id: + cmd.extend([ + "--resume", + session_id + ]) + + try: + process = subprocess.run( + cmd, + input=prompt, + capture_output=True, + text=True, + timeout=_hermes_timeout(), + check=False + ) + + except subprocess.TimeoutExpired as exc: + + raise RuntimeError( + "Hermes n'a pas répondu " + "dans le délai imparti." + ) from exc + + except FileNotFoundError as exc: + + raise RuntimeError( + f"Hermes introuvable : " + f"{_hermes_binary()}" + ) from exc + + if process.returncode != 0: + + msg = ( + process.stderr + or process.stdout + or "aucun détail" + ).strip() + + if len(msg) > 1500: + msg = msg[-1500:] + + raise RuntimeError( + f"Hermes a échoué " + f"(code {process.returncode}) : " + f"{msg}" + ) + + return _parse_jsonl(process.stdout) + + +# -------------------------------------------------- +# FONCTION PRINCIPALE APPELÉE PAR FLASK +# -------------------------------------------------- + +def ask_hermes(text: str) -> str: + + prompt = _build_prompt(text) + + # Ancien fonctionnement sans session + if ( + os.getenv( + "HERMES_SESSION_MODE", + "persistent" + ).lower() == "legacy" + ): + return _oneshot_legacy(prompt) + + path = _session_file() + + # Empêche les appels concurrents + with _REQUEST_LOCK, _session_lock(path): + + # Recherche la session précédente + current_sid = _read_session(path) + + # Envoie la question à Hermes + answer, new_sid = _call_hermes( + prompt, + current_sid + ) + + # Sauvegarde l'identifiant + _save_session( + path, + new_sid + ) + + print( + f"[ALFRED] Session Hermes : " + f"{new_sid} " + f"({'reprise' if current_sid else 'création'})", + flush=True + ) + + return answer diff --git a/alfred/app/.alfred_identity_glass_v1_backups/20261028_214221_179952/original/static/js/alfred-admin.js b/alfred/app/.alfred_identity_glass_v1_backups/20261028_214221_179952/original/static/js/alfred-admin.js new file mode 100644 index 0000000..bf600d0 --- /dev/null +++ b/alfred/app/.alfred_identity_glass_v1_backups/20261028_214221_179952/original/static/js/alfred-admin.js @@ -0,0 +1,187 @@ +(() => { + 'use strict'; + const $ = id => document.getElementById(id); + const page = document.body.dataset.page; + const state = {csrf: null, logged: false, baseline: {}}; + const fields = [...document.querySelectorAll('[data-setting]')]; + let polling = null; + const fetchJSON = async (url, options = {}) => { + const headers = { ...(options.headers || {}) }; + if (options.body) headers['Content-Type'] = 'application/json'; + if (options.method && options.method !== 'GET' && state.csrf) headers['X-Alfred-CSRF'] = state.csrf; + const response = await fetch(url, {...options, credentials: 'same-origin', cache:'no-store', headers}); + let data; + try { data = await response.json(); } catch { data = {}; } + if (response.status === 401 && url !== '/api/control/login') { showLogin(); throw new Error('Session expirée. Reconnecte-toi.'); } + if (!response.ok) throw new Error(data.error || 'Erreur HTTP '+response.status); + return data; + }; + const setMessage = (message, bad=false) => { + const el = $('message'); if (!el) return; + el.textContent = message; el.style.color = bad ? '#f1aaaa' : '#a1dcbe'; + }; + const showLogin = () => { + state.logged = false; $('mainArea').hidden = true; $('authScreen').hidden = false; + if (polling) clearInterval(polling); polling = null; + }; + const showPage = () => { state.logged=true; $('mainArea').hidden=false; $('authScreen').hidden=true; }; + const getFieldValue = el => el.type === 'checkbox' ? el.checked : el.value; + function configDirty() { + const changed = fields.some(el => String(getFieldValue(el)) !== String(state.baseline[el.id] ?? '')); + if ($('saveState')) $('saveState').textContent = changed ? 'Modifications non enregistrées' : 'Aucun changement'; + } + function markInput() { + if ($('scoreOut')) $('scoreOut').textContent = Number($('ALFRED_WEATHER_ILLUSTRATION_TARGET_SCORE').value || 8.6).toFixed(1) + ' / 10'; + if ($('denoiseOut')) $('denoiseOut').textContent = Number($('ALFRED_WEATHER_REFERENCE_DENOISE').value || .56).toFixed(2); + configDirty(); + } + async function loadConfig() { + const values = (await fetchJSON('/api/control/config')).values || {}; + fields.forEach(el => { + if (el.type==='checkbox') el.checked = String(values[el.id]).toLowerCase() === 'true'; + else el.value = values[el.id] ?? ''; + state.baseline[el.id] = getFieldValue(el); + }); + markInput(); + } + async function saveConfig() { + const values = {}; + fields.forEach(el => { values[el.id] = getFieldValue(el); }); + try { + const result = await fetchJSON('/api/control/config', {method:'POST', body:JSON.stringify(values)}); + state.baseline = {}; + fields.forEach(el => state.baseline[el.id] = getFieldValue(el)); + configDirty(); + $('pendingNotice').hidden = false; + setMessage(result.ok ? 'Configuration sauvegardée.' : 'Enregistrement inconnu.'); + } catch (error) { setMessage(error.message, true); } + } + function serviceStatus(el, data) { + if (!el) return; + const labels = {ok:'Disponible',down:'Indisponible',warning:'Attention',muted:'Coupé',unknown:'Non vérifié'}; + el.dataset.state = data.state || 'unknown'; + el.textContent = labels[data.state] || 'Inconnu'; + } + const safeText = (id, value) => { if ($(id)) $(id).textContent = String(value ?? '—'); }; + const readable = value => { + const x = Number(value) || 0; + if (x >= 1073741824) return (x / 1073741824).toFixed(1) + ' Go'; + return (x / 1048576).toFixed(1) + ' Mo'; + }; + function renderHealth(data) { + const timestamp = new Date(data.time).toLocaleTimeString('fr-FR', {hour:'2-digit',minute:'2-digit',second:'2-digit'}); + safeText('updatedAt', timestamp); + serviceStatus($('cameraStatus'), data.camera); safeText('cameraDetail',data.camera?.detail); + serviceStatus($('microStatus'), data.microphone); safeText('microDetail',data.microphone?.detail); + serviceStatus($('speakerStatus'), data.speaker); safeText('speakerDetail',data.speaker?.detail); + const wake = data.wake || {}; + serviceStatus($('wakeStatus'), {state:!wake.running?'down':wake.paused?'warning':'ok'}); + safeText('wakeLabel', `Vosk · ${wake.word || 'alfred'}`); + safeText('wakeDetail', wake.error || (wake.paused ? 'Détection en pause' : 'Détection active')); + const labels = {alfred:'Alfred / Flask', hermes_cli:'Hermes Agent (CLI)', hermes_dashboard:'Hermes WebUI', comfyui:'ComfyUI', ollama:'Ollama', home_assistant:'Home Assistant', wyoming_piper:'Wyoming Piper', wyoming_whisper:'Whisper (STT)', alfred_timers:'Minuteurs Alfred'}; + const list = $('serviceList'); list.replaceChildren(); + for (const [key,service] of Object.entries(data.services || {})) { + const row = document.createElement('div'); row.className='service-row'; + const desc = document.createElement('div'); + const title=document.createElement('div');title.className='service-label';title.textContent=labels[key] || key; + const sub=document.createElement('div');sub.className='service-sub';sub.textContent=service.detail || ''; + desc.append(title,sub); + const aside=document.createElement('div');aside.className='service-tail'; + if (service.latency_ms != null) {const delay=document.createElement('span');delay.className='muted microtext';delay.textContent=service.latency_ms+' ms';aside.append(delay);} + const status=document.createElement('span');status.className='status-pill';serviceStatus(status,service);aside.append(status); + row.append(desc,aside);list.append(row); + } + const sys = data.system || {}; + const diskPercent = sys.disk_total ? Math.round(100*sys.disk_used/sys.disk_total) : 0; + safeText('diskPercent', diskPercent+' %');$('diskBar').style.width=diskPercent+'%'; + safeText('diskText', readable(sys.disk_used)+' utilisés sur '+readable(sys.disk_total)); + const mem=sys.memory || {}; + const memPercent=mem.total ? Math.round(100*mem.used/mem.total) : 0; + safeText('memPercent',mem.total ? memPercent+' %' : 'N/D');$('memBar').style.width=memPercent+'%'; + safeText('memText',mem.total ? readable(mem.used)+' utilisés sur '+readable(mem.total) : 'Non disponible'); + safeText('loadAvg',sys.load_1m); + const mcps=$('mcpList');mcps.replaceChildren(); + for (const mcp of Object.values(data.mcps || {})) { + const card=document.createElement('div');card.className='mcp-item'; + const title=document.createElement('strong');title.textContent=mcp.label || 'MCP'; + const status=document.createElement('span');status.className='status-pill';serviceStatus(status,mcp); + const p=document.createElement('p');p.textContent=mcp.detail; + card.append(title,status,p);mcps.append(card); + } + if (typeof window.alfredAdminV2Health === 'function') { + window.alfredAdminV2Health(data); + } + } + // ALFRED_ADMIN_SURVEILLANCE_V2: monitoring hook + let inFlight = false; + async function refreshHealth() { + if (inFlight || document.hidden) return; + inFlight = true; + try { renderHealth(await fetchJSON('/api/control/health'));setMessage(''); } + catch(err){setMessage('Surveillance : '+err.message,true);} + finally{inFlight=false;} + } + async function loadAudio() { + try { + const d=await fetchJSON('/api/audio/settings'); + $('liveVolume').value=d.volume;$('liveVolumeOut').textContent=d.volume+' %'; + $('liveMic').disabled = d.mic_gain == null; + if(d.mic_gain != null)$('liveMic').value=d.mic_gain; + $('liveMicOut').textContent=d.mic_gain == null ? 'N/D' : d.mic_gain+' %'; + $('muteMicro').checked=Boolean(d.mic_muted);$('muteSpeaker').checked=Boolean(d.speaker_muted); + } catch(err){safeText('audioResult',err.message);} + } + async function updateAudio(values) { + try { + const d=await fetchJSON('/api/audio/settings', {method:'POST',body:JSON.stringify(values)}); + safeText('audioResult','Réglage appliqué.'); + $('liveVolume').value=d.volume;$('liveVolumeOut').textContent=d.volume+' %'; + $('muteMicro').checked=Boolean(d.mic_muted);$('muteSpeaker').checked=Boolean(d.speaker_muted); + } catch(err){safeText('audioResult','Erreur : '+err.message);await loadAudio();} + } + async function onLogin(event) { + event.preventDefault(); + const password=$('adminPassword').value; + try { + const response=await fetchJSON('/api/control/login',{method:'POST',body:JSON.stringify({password})}); + state.csrf=response.csrf;$('adminPassword').value='';await enterApp(); + } catch(err){safeText('loginMessage',err.message);} + } + async function enterApp() { + showPage(); + if(page==='config'){ + await Promise.all([loadConfig(),loadAudio()]); + }else{ + await refreshHealth(); + polling=setInterval(refreshHealth,15000); + } + } + async function init() { + $('loginForm')?.addEventListener('submit', onLogin); + $('logoutButton')?.addEventListener('click', async ()=>{ + try{await fetchJSON('/api/control/logout',{method:'POST'});}catch(e){} + state.csrf=null;showLogin(); + }); + fields.forEach(el=>{el.addEventListener('change',markInput);el.addEventListener('input',markInput);}); + $('saveConfig')?.addEventListener('click',saveConfig); + $('restartButton')?.addEventListener('click',async ()=>{ + if(!confirm('Redémarrer Alfred maintenant ? Les opérations en cours seront interrompues.'))return; + try{const data=await fetchJSON('/api/control/restart',{method:'POST'});setMessage(data.message||'Demande transmise.');$('restartButton').disabled=true;setTimeout(()=>$('restartButton').disabled=false,5000);} + catch(err){setMessage(err.message,true);} + }); + $('testVoice')?.addEventListener('click',async()=>{ + try{await fetchJSON('/api/tts/say',{method:'POST',body:JSON.stringify({text:'Bonjour, je suis Alfred. Voici un test de ma voix actuelle.'})});setMessage('Test vocal demandé.');} + catch(err){setMessage(err.message,true);} + }); + let volDelay, gainDelay; + $('liveVolume')?.addEventListener('input',e=>{safeText('liveVolumeOut',e.target.value+' %');clearTimeout(volDelay);volDelay=setTimeout(()=>updateAudio({volume:Number(e.target.value)}),250);}); + $('liveMic')?.addEventListener('input',e=>{safeText('liveMicOut',e.target.value+' %');clearTimeout(gainDelay);gainDelay=setTimeout(()=>updateAudio({mic_gain:Number(e.target.value)}),250);}); + $('muteMicro')?.addEventListener('change',e=>updateAudio({mic_muted:e.target.checked})); + $('muteSpeaker')?.addEventListener('change',e=>updateAudio({speaker_muted:e.target.checked})); + $('refreshHealth')?.addEventListener('click',refreshHealth); + const login=await fetchJSON('/api/control/auth').catch(()=>({authenticated:false})); + if (login.authenticated){state.csrf=login.csrf;try{await enterApp();}catch(err){setMessage(err.message,true);}} + else{showLogin();if(!login.configured)safeText('loginMessage','Mot de passe non défini : exécute set_admin_password.py sur Alfred.');} + } + document.addEventListener('DOMContentLoaded',init); +})(); diff --git a/alfred/app/.alfred_identity_glass_v1_backups/20261028_214221_179952/original/templates/alfred_logs_v2.html b/alfred/app/.alfred_identity_glass_v1_backups/20261028_214221_179952/original/templates/alfred_logs_v2.html new file mode 100644 index 0000000..d102c2c --- /dev/null +++ b/alfred/app/.alfred_identity_glass_v1_backups/20261028_214221_179952/original/templates/alfred_logs_v2.html @@ -0,0 +1,31 @@ + + + + + +Journaux · Alfred + + + + +
+ AAlfredJournaux de l'assistant + +
+
+

OBSERVABILITÉ · ALFRED

Journaux

Activité du service Alfred, reconnaissance vocale, Hermes et appels d'outils lorsqu'ils sont journalisés.

+
+ + + + + +
+
+
Connexion…
+
+

Source : journal systemd alfred.service. Les commandes Hermes exécutées dans un autre processus ne sont visibles ici que si Alfred les journalise. Les secrets courants sont masqués, mais vérifie les données avant tout partage.

+
+
+ + diff --git a/alfred/app/.alfred_identity_glass_v1_backups/20261028_214221_179952/original/templates/config.html b/alfred/app/.alfred_identity_glass_v1_backups/20261028_214221_179952/original/templates/config.html new file mode 100644 index 0000000..160423e --- /dev/null +++ b/alfred/app/.alfred_identity_glass_v1_backups/20261028_214221_179952/original/templates/config.html @@ -0,0 +1,78 @@ + + + + +Alfred · Configuration + + + + + + + + + +
+
+
A
AlfredCentre de contrôle
+ +
+
+

PERSONNALISATION

Configuration

Paramètres de l’assistant, voix et services. Les modifications prennent effet après redémarrage.

Aucun changement
+ +
+
✦

Identité et voix

Nom affiché, mot d'activation et voix de synthèse

+
Visible dans l’interface après redémarrage.
+
Un seul mot. Le modèle Vosk doit pouvoir le reconnaître ; le mode OpenWakeWord nécessite un modèle entraîné.
+
Exemple : fr_FR-tom-medium. La voix doit exister sur ton serveur Wyoming ; pour Piper local, c’est le fichier modèle qui détermine la voix.
+
Utilise les réglages déjà actifs, sans redémarrage
+
+
☁

Illustrations météo

Qualité, visages de référence et accessoires ComfyUI

+
+
8.6 / 10
+
+
+
+
0.56
Une valeur élevée modifie davantage la tenue, mais risque de changer le visage.
+
+
+
+
♫

Audio en direct

Volume, sensibilité et coupures immédiates

+
—
+
—
+
+
+

Les réglages audio sont appliqués sans redémarrage.

+
+
⌘

Connexions et météo

Adresses des serveurs et source météo

+
Laisse vide pour conserver la configuration Hermes existante.
+
Cette adresse sert au contrôle de disponibilité ; Hermes garde sa propre configuration LLM.
+
Exemple : weather.forecast_home. Le jeton Home Assistant n’est pas exposé ici.
+ Voir la surveillance des services +
+
+ + +
+
◉

Reconnaissance vocale (STT)

Vosk local ou Whisper Wyoming distant · configuration effective après redémarrage

+
+ + Le mot d’activation et le texte provisoire restent gérés par Vosk. Whisper améliore la transcription finale envoyée à Hermes.
+
+
Adresse IP ou nom DNS, sans http://
+
+
+
+
+
+
+

Le modèle Whisper (small actuellement) et son vocabulaire technique (--initial-prompt pour Proxmox, Wazuh…) se configurent dans le conteneur Docker distant, pas sur Alfred. En cas de panne Whisper, retour automatique à Vosk si le correctif STT V1 est installé.

+
+

Maintenance

Redémarrer Alfred pour appliquer les paramètres enregistrés. Action protégée par mot de passe administrateur.

Le bouton requiert une autorisation systemd explicitement activée.
+

+
+
A

Accès administrateur

Connecte-toi pour modifier la configuration d'Alfred.

Accès local / réseau privé uniquement.
+
+ + + diff --git a/alfred/app/.alfred_identity_glass_v1_backups/20261028_214221_179952/original/templates/configuration.html b/alfred/app/.alfred_identity_glass_v1_backups/20261028_214221_179952/original/templates/configuration.html new file mode 100644 index 0000000..6746c73 --- /dev/null +++ b/alfred/app/.alfred_identity_glass_v1_backups/20261028_214221_179952/original/templates/configuration.html @@ -0,0 +1,130 @@ + + + + + + Alfred — Configuration audio + + + +
+
+

Configuration

+

Alfred · Paramètres audio

+
+
+
+
+ + 50 % +
+ +
+
+
+ + + Couper le haut-parleur + + +
+

Désactive les réponses vocales d’Alfred.

+
+
+
+ + + Couper le microphone + + +
+

Suspend l’écoute et la détection du mot « Alfred ».

+
+
+

+
+ + + diff --git a/alfred/app/.alfred_identity_glass_v1_backups/20261028_214221_179952/original/templates/index.html b/alfred/app/.alfred_identity_glass_v1_backups/20261028_214221_179952/original/templates/index.html new file mode 100644 index 0000000..6072f10 --- /dev/null +++ b/alfred/app/.alfred_identity_glass_v1_backups/20261028_214221_179952/original/templates/index.html @@ -0,0 +1,198 @@ + + + + + + + {{ name }} + + + + + + + + + + + + + + + +
+
+ +
+ + En attente +
+ +
+ + ⚙ + ▤ + + + + + + + + + + + +
+
+ + + + + + +
+

Bonjour.

À votre écoute.

+
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/alfred/app/.alfred_identity_glass_v1_backups/20261028_214221_179952/original/templates/surveillance.html b/alfred/app/.alfred_identity_glass_v1_backups/20261028_214221_179952/original/templates/surveillance.html new file mode 100644 index 0000000..fcf1acb --- /dev/null +++ b/alfred/app/.alfred_identity_glass_v1_backups/20261028_214221_179952/original/templates/surveillance.html @@ -0,0 +1,47 @@ +Alfred · Surveillance + + + + + + +
+
A
AlfredCentre de contrôle
+
+

DIAGNOSTIC

Surveillance

État des périphériques, de l’assistant et des services locaux.

Chargement…
+
+
▣
CAMÉRAWebcam—

Vérification…

+
◉
MICROPHONECapture audio—

Vérification…

+
♫
HAUT-PARLEURSortie audio—

Vérification…

+
◌
MOT DE RÉVEILVosk—

Vérification…

+
+
⇄

Services et connexions

Tests réels : HTTP, port TCP ou présence du binaire

Chargement…

+
▤

Système

Ressources du mini-PC hébergeant Alfred

Disque de l’application—

—

Mémoire RAM—

—

Charge système (1 min)—
Les contrôles de caméra n'ouvrent pas le flux vidéo, afin de ne pas bloquer la reconnaissance faciale.
+ + +
+
+
◉

Transcription vocale

Mode réellement actif et disponibilité du serveur Wyoming

+
Mode STT——
+

Vérification…

+
Serveur Whisper——
+

Vérification…

+
Wyoming est interrogé avec describe sans lancer de transcription. Une connexion réussie ne garantit pas la qualité STT.
+
+
+
◷

Minuteurs et chronomètres

État enregistré localement, indépendant des appels Hermes

+
Gestionnaire Alfred——
+

Chargement…

+
La présence d’un MCP dans la liste ci-dessous ne prouve pas qu’Hermes l’a appelé : consulter ses logs pour vérifier les outils exécutés.
+
+
+
✧

Maintenance et intégrations

Vérifications complémentaires des nouveautés

+
Redémarrage depuis /config——
+

MCP Autres (heure/date) et Alfred Timers : la grille ci-dessous signale les dossiers détectés dans /opt/hermes. La validation fonctionnelle se fait avec hermes mcp test.

+
+
⚙

MCP Hermes détectés

Présence des répertoires, pas un test de communication des outils MCP

Chargement…

+

+
+
A

Accès administrateur

Connecte-toi pour consulter l'état d'Alfred.

Accès local / réseau privé uniquement.
+
+ diff --git a/alfred/app/.alfred_lists_v1_backups/20261028_172354_094558/manifest.json b/alfred/app/.alfred_lists_v1_backups/20261028_172354_094558/manifest.json new file mode 100644 index 0000000..7c94709 --- /dev/null +++ b/alfred/app/.alfred_lists_v1_backups/20261028_172354_094558/manifest.json @@ -0,0 +1,49 @@ +{ + "version": 1, + "created_at": "2026-10-28T17:23:54.094691", + "installed": true, + "files": { + "app.py": { + "existed": true, + "before_sha256": "e4fe3af1db2e0d39231f0a3ae341818b2765a14c102f827dc7c6772bbdbc572b", + "after_sha256": "0123a9dcfd8a3d375bd551e9b7f9049042a7b7a13f5d41806e1e76c711aeb2ee", + "mode": 420 + }, + "templates/index.html": { + "existed": true, + "before_sha256": "73a4f976e301da24f0a59b8866be4f74e001931a641e0483d0b6d1cae133a971", + "after_sha256": "b469b5c988c4d17218da4db7ecac89f539a119fdfadb041611fade27b3234316", + "mode": 420 + }, + "services/alfred_lists.py": { + "existed": false, + "before_sha256": null, + "after_sha256": "7821a6e34bb5fa199b1a560214a8074bb2b47aea5ee7b014d0e3ad859c53ac8d", + "mode": 420 + }, + "static/js/alfred-listes-glass.js": { + "existed": false, + "before_sha256": null, + "after_sha256": "8ebb87ca8f117815e5f0a310108212ac14075cf6505dcb29233ebbbcb9d8b5bd", + "mode": 420 + }, + "static/css/alfred-listes-glass.css": { + "existed": false, + "before_sha256": null, + "after_sha256": "b56cd2540b910c65bb491ede4ff3acbc9c13347186dbea5e48054739c3fa5d72", + "mode": 420 + }, + "mcp/alfred_listes_mcp.py": { + "existed": false, + "before_sha256": null, + "after_sha256": "6bc55997125860a847d238b8884a5de83ddefd5a6f8402381977bf41d65bb205", + "mode": 420 + }, + "skills/alfred-listes/SKILL.md": { + "existed": false, + "before_sha256": null, + "after_sha256": "33be2ab40db1b726374c1713ebb23df598609870ba057a5d86342bc0297d0a49", + "mode": 420 + } + } +} \ No newline at end of file diff --git a/alfred/app/.alfred_lists_v1_backups/20261028_172354_094558/original/app.py b/alfred/app/.alfred_lists_v1_backups/20261028_172354_094558/original/app.py new file mode 100644 index 0000000..4730176 --- /dev/null +++ b/alfred/app/.alfred_lists_v1_backups/20261028_172354_094558/original/app.py @@ -0,0 +1,504 @@ +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_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}) +# 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} + + # 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() + 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//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=_done.set) + _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 + +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) + diff --git a/alfred/app/.alfred_lists_v1_backups/20261028_172354_094558/original/templates/index.html b/alfred/app/.alfred_lists_v1_backups/20261028_172354_094558/original/templates/index.html new file mode 100644 index 0000000..0684fbd --- /dev/null +++ b/alfred/app/.alfred_lists_v1_backups/20261028_172354_094558/original/templates/index.html @@ -0,0 +1,192 @@ + + + + + + + {{ name }} + + + + + + + + + + + +
+
+ +
+ + En attente +
+ +
+ + ⚙ + ▤ + + + + + + + + + + + +
+
+ + + + + + +
+

Bonjour.

À votre écoute.

+
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + diff --git a/alfred/app/.alfred_lists_v1_hermes_installed b/alfred/app/.alfred_lists_v1_hermes_installed new file mode 100644 index 0000000..e69de29 diff --git a/alfred/app/.alfred_main_v1_backups/20261028_160727_232470/manifest.json b/alfred/app/.alfred_main_v1_backups/20261028_160727_232470/manifest.json new file mode 100644 index 0000000..1991e37 --- /dev/null +++ b/alfred/app/.alfred_main_v1_backups/20261028_160727_232470/manifest.json @@ -0,0 +1,43 @@ +{ + "version": 1, + "created_at": "2026-10-28T16:07:27.232620", + "files": { + "templates/index.html": { + "existed": true, + "before_sha256": "0e4e6f58a93190bd8d81df7c448ca19e99b55fe8ece1a94342cf6f8578e63bda", + "after_sha256": "cc1fbc7cab0c80d7f2799ac565c45e94b8fddb0af26e67172afde8ffd39a6231", + "mode": 420 + }, + "app.py": { + "existed": true, + "before_sha256": "04040a8406b441d152bccdf7ca84c1e8b547f74c853f02c3850e55ce00ce2d15", + "after_sha256": "631ad0886437b59d4b8573ffd83bf2330511dfee92dc767baa5d42ac7d9c246e", + "mode": 420 + }, + "static/css/alfred-main-v1.css": { + "existed": false, + "before_sha256": null, + "after_sha256": "b537002d023eca871a6d0912e36eaf9c0b9fbb8a46105cf031ed22c4d89d1928", + "mode": null + }, + "static/js/alfred-main-v1.js": { + "existed": false, + "before_sha256": null, + "after_sha256": "5552cc33cfb1c236cb474a75d305a6df8302ef70051c057f8e8175352d014fc2", + "mode": null + }, + "static/img/alfred-ambience.webp": { + "existed": false, + "before_sha256": null, + "after_sha256": "9d5fb5e760ef0f6be5c4319c084b39e256137146fa7039bc9e41c44d72651839", + "mode": null + }, + "services/alfred_dashboard.py": { + "existed": false, + "before_sha256": null, + "after_sha256": "0cbaae4910ced0f677d76495905753cd2facab96e61bbd93d9335e2157c725fd", + "mode": null + } + }, + "installed": true +} \ No newline at end of file diff --git a/alfred/app/.alfred_main_v1_backups/20261028_160727_232470/original/app.py b/alfred/app/.alfred_main_v1_backups/20261028_160727_232470/original/app.py new file mode 100644 index 0000000..0cda174 --- /dev/null +++ b/alfred/app/.alfred_main_v1_backups/20261028_160727_232470/original/app.py @@ -0,0 +1,451 @@ +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_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}) +# 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} + + # 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() + 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//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) + diff --git a/alfred/app/.alfred_main_v1_backups/20261028_160727_232470/original/templates/index.html b/alfred/app/.alfred_main_v1_backups/20261028_160727_232470/original/templates/index.html new file mode 100644 index 0000000..4422045 --- /dev/null +++ b/alfred/app/.alfred_main_v1_backups/20261028_160727_232470/original/templates/index.html @@ -0,0 +1,187 @@ + + + + + + + {{ name }} + + + + + + + + + +
+
+ +
+ + En attente +
+ +
+ + ⚙ + ▤ + + + + + + + + + + + +
+
+ + + + + + +
+

Bonjour.

À votre écoute.

+
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + diff --git a/alfred/app/__pycache__/app.cpython-313.pyc b/alfred/app/__pycache__/app.cpython-313.pyc index 65f71e3..466a3d4 100644 Binary files a/alfred/app/__pycache__/app.cpython-313.pyc and b/alfred/app/__pycache__/app.cpython-313.pyc differ diff --git a/alfred/app/app.py b/alfred/app/app.py index 0cda174..7adc434 100644 --- a/alfred/app/app.py +++ b/alfred/app/app.py @@ -107,6 +107,28 @@ def conversation(score=1.0): 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=_lists_done.set) + _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: @@ -445,6 +467,77 @@ def wake_stop(): wake.stop(); return jsonify({"ok":True,**wake.status()}) 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=_done.set) + _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) + 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) diff --git a/alfred/app/cache/weather_outfit_cache.json b/alfred/app/cache/weather_outfit_cache.json index def1bfc..4829a68 100644 --- a/alfred/app/cache/weather_outfit_cache.json +++ b/alfred/app/cache/weather_outfit_cache.json @@ -539,5 +539,121 @@ }, "cached_at": 1793186782.712917, "focus_label": "aujourd’hui" + }, + "7f4ab9e5b21e1d02bb502799f4f443460d642ad9789c9a4bee3b006a1515a01e": { + "file": "7f4ab9e5b21e1d02bb502799f4f443460d642ad9789c9a4bee3b006a1515a01e.jpg", + "prompt_ids": [ + "2e1ef069-11ee-4ff3-9fcf-e2e187945dfa", + "a1da9b65-1b80-4ff0-a338-f6618dc015e1" + ], + "reviews": { + "child": { + "score": 0.0, + "ok": false, + "issues": [ + "review_unavailable" + ], + "reviewed": false, + "reference_applied": true + }, + "adult": { + "score": 0.0, + "ok": false, + "issues": [ + "review_unavailable" + ], + "reviewed": false, + "reference_applied": true + } + }, + "cached_at": 1793200532.4466634, + "focus_label": "aujourd’hui" + }, + "c482b5a8e1fff3a0d6589fdb1f37fbd24e829921ec8e73f13addf8a1647a9cf9": { + "file": "c482b5a8e1fff3a0d6589fdb1f37fbd24e829921ec8e73f13addf8a1647a9cf9.jpg", + "prompt_ids": [ + "bff3b169-a9ed-4b14-80e9-0db3bbac63d6", + "1a40205a-7093-4bcd-a051-6991648dd5e8" + ], + "reviews": { + "child": { + "score": 0.0, + "ok": false, + "issues": [ + "review_unavailable" + ], + "reviewed": false, + "reference_applied": true + }, + "adult": { + "score": 0.0, + "ok": false, + "issues": [ + "review_unavailable" + ], + "reviewed": false, + "reference_applied": true + } + }, + "cached_at": 1793201218.7548609, + "focus_label": "aujourd’hui" + }, + "7bd7476257fbcb9a6432c69e9f2de6ac77c840557f2f04499a53e92990377d00": { + "file": "7bd7476257fbcb9a6432c69e9f2de6ac77c840557f2f04499a53e92990377d00.jpg", + "prompt_ids": [ + "2d9829a8-3b9a-4238-b493-ac8e2d9bbe18", + "08bd9ae7-fd9e-48f5-a74a-9eddc961fc8c" + ], + "reviews": { + "child": { + "score": 0.0, + "ok": false, + "issues": [ + "review_unavailable" + ], + "reviewed": false, + "reference_applied": true + }, + "adult": { + "score": 0.0, + "ok": false, + "issues": [ + "review_unavailable" + ], + "reviewed": false, + "reference_applied": true + } + }, + "cached_at": 1793206780.4568262, + "focus_label": "aujourd’hui" + }, + "abc6e852574fa821f2b272ba2a783fb9d9f1ec25d2b8163b105015ad320703da": { + "file": "abc6e852574fa821f2b272ba2a783fb9d9f1ec25d2b8163b105015ad320703da.jpg", + "prompt_ids": [ + "d4f113f6-153e-4567-ad41-6ce50da74eca", + "3bbea9a0-2186-4a63-bee9-226cf4b44e74" + ], + "reviews": { + "child": { + "score": 0.0, + "ok": false, + "issues": [ + "review_unavailable" + ], + "reviewed": false, + "reference_applied": true + }, + "adult": { + "score": 0.0, + "ok": false, + "issues": [ + "review_unavailable" + ], + "reviewed": false, + "reference_applied": true + } + }, + "cached_at": 1793217002.2921214, + "focus_label": "aujourd’hui" } } \ No newline at end of file diff --git a/alfred/app/cache/weather_outfit_images/7bd7476257fbcb9a6432c69e9f2de6ac77c840557f2f04499a53e92990377d00.jpg b/alfred/app/cache/weather_outfit_images/7bd7476257fbcb9a6432c69e9f2de6ac77c840557f2f04499a53e92990377d00.jpg new file mode 100644 index 0000000..1ce4f1f Binary files /dev/null and b/alfred/app/cache/weather_outfit_images/7bd7476257fbcb9a6432c69e9f2de6ac77c840557f2f04499a53e92990377d00.jpg differ diff --git a/alfred/app/cache/weather_outfit_images/7f4ab9e5b21e1d02bb502799f4f443460d642ad9789c9a4bee3b006a1515a01e.jpg b/alfred/app/cache/weather_outfit_images/7f4ab9e5b21e1d02bb502799f4f443460d642ad9789c9a4bee3b006a1515a01e.jpg new file mode 100644 index 0000000..8f1d416 Binary files /dev/null and b/alfred/app/cache/weather_outfit_images/7f4ab9e5b21e1d02bb502799f4f443460d642ad9789c9a4bee3b006a1515a01e.jpg differ diff --git a/alfred/app/cache/weather_outfit_images/abc6e852574fa821f2b272ba2a783fb9d9f1ec25d2b8163b105015ad320703da.jpg b/alfred/app/cache/weather_outfit_images/abc6e852574fa821f2b272ba2a783fb9d9f1ec25d2b8163b105015ad320703da.jpg new file mode 100644 index 0000000..d7d387a Binary files /dev/null and b/alfred/app/cache/weather_outfit_images/abc6e852574fa821f2b272ba2a783fb9d9f1ec25d2b8163b105015ad320703da.jpg differ diff --git a/alfred/app/cache/weather_outfit_images/c482b5a8e1fff3a0d6589fdb1f37fbd24e829921ec8e73f13addf8a1647a9cf9.jpg b/alfred/app/cache/weather_outfit_images/c482b5a8e1fff3a0d6589fdb1f37fbd24e829921ec8e73f13addf8a1647a9cf9.jpg new file mode 100644 index 0000000..19dbd5f Binary files /dev/null and b/alfred/app/cache/weather_outfit_images/c482b5a8e1fff3a0d6589fdb1f37fbd24e829921ec8e73f13addf8a1647a9cf9.jpg differ diff --git a/alfred/app/config/control_center.env b/alfred/app/config/control_center.env index bd199a4..9a92c8a 100644 --- a/alfred/app/config/control_center.env +++ b/alfred/app/config/control_center.env @@ -1,7 +1,14 @@ # Configuration générale Alfred (page /config) # ALFRED_ALLOW_WEB_RESTART=true -ALFRED_NAME=Dona -WAKE_WORD=dona +ALFRED_NAME=Magalie +WAKE_WORD=magalie PIPER_VOICE=fr_FR-upmc-medium ALFRED_OLLAMA_URL= HA_WEATHER_ENTITY= +STT_ENGINE=wyoming +WYOMING_WHISPER_HOST=192.168.1.13 +WYOMING_WHISPER_PORT=10300 +WYOMING_WHISPER_LANGUAGE=fr +WYOMING_WHISPER_TIMEOUT=20 +WYOMING_WHISPER_CONNECT_TIMEOUT=3 +STT_MAX_AUDIO_SECONDS=30 diff --git a/alfred/app/config/home_card.json b/alfred/app/config/home_card.json new file mode 100644 index 0000000..3e74526 --- /dev/null +++ b/alfred/app/config/home_card.json @@ -0,0 +1,17 @@ +{ + "version": 1, + "rows": [ + { + "kind": "lights_on", + "label": "Lumières allumées" + }, + { + "kind": "indoor_temperature", + "label": "Volets fermés" + }, + { + "kind": "covers_closed", + "label": "Température intérieure" + } + ] +} diff --git a/alfred/app/config/identity.md b/alfred/app/config/identity.md index 2ac6084..a6bdaf5 100644 --- a/alfred/app/config/identity.md +++ b/alfred/app/config/identity.md @@ -1,7 +1,7 @@ -# Identité et comportement d'Alfred +# Identité et comportement de Magalie -Tu es Alfred, le majordome vocal personnel de la maison. -Tu t'appelles Alfred, même si le moteur logiciel qui te permet de raisonner est Hermes Agent. +Tu es Magalie, le majordome vocal personnel de la maison. +Tu t'appelles Magalie, même si le moteur logiciel qui te permet de raisonner est Hermes Agent. ## Langue et style @@ -18,10 +18,10 @@ Tu t'appelles Alfred, même si le moteur logiciel qui te permet de raisonner est - Si tu ne peux pas effectuer une action, explique-le simplement. ## Identité -- Si l'on te demande ton nom, réponds : « Je m'appelle Alfred. » +- Si l'on te demande ton nom, réponds : « Je m'appelle Magalie. » -# Alfred +# Magalie Tu disposes des outils et compétences configurés dans Hermes Agent. diff --git a/alfred/app/config/identity_history/20261028_214226_191641_avant_nom.md b/alfred/app/config/identity_history/20261028_214226_191641_avant_nom.md new file mode 100644 index 0000000..2ac6084 --- /dev/null +++ b/alfred/app/config/identity_history/20261028_214226_191641_avant_nom.md @@ -0,0 +1,31 @@ +# Identité et comportement d'Alfred + +Tu es Alfred, le majordome vocal personnel de la maison. +Tu t'appelles Alfred, même si le moteur logiciel qui te permet de raisonner est Hermes Agent. + + +## Langue et style +- Réponds toujours en français, sauf demande explicite de parler une autre langue. +- Tutoie ton interlocuteur. +- Adopte un ton naturel, calme, cordial et professionnel. +- Privilégie les réponses brèves, claires et faciles à prononcer à voix haute. +- Évite le Markdown, les tableaux et les longues listes lorsque tu réponds oralement. +- Ne parle pas spontanément de Hermes, d'Ollama ou de ton architecture interne. + +## Domotique +- Lorsque la demande nécessite une action domotique, utilise les outils Home Assistant disponibles. +- Ne confirme une action que si son exécution a été effectivement vérifiée. +- Si tu ne peux pas effectuer une action, explique-le simplement. + +## Identité +- Si l'on te demande ton nom, réponds : « Je m'appelle Alfred. » + + +# Alfred +Tu disposes des outils et compétences configurés +dans Hermes Agent. + +Utilise librement les outils disponibles lorsque +la demande le nécessite. + +Ne limite pas tes capacités à la domotique. diff --git a/alfred/app/config/identity_history/20261028_214309_009521_avant_nom.md b/alfred/app/config/identity_history/20261028_214309_009521_avant_nom.md new file mode 100644 index 0000000..abce580 --- /dev/null +++ b/alfred/app/config/identity_history/20261028_214309_009521_avant_nom.md @@ -0,0 +1,31 @@ +# Identité et comportement de Dona + +Tu es Dona, le majordome vocal personnel de la maison. +Tu t'appelles Dona, même si le moteur logiciel qui te permet de raisonner est Hermes Agent. + + +## Langue et style +- Réponds toujours en français, sauf demande explicite de parler une autre langue. +- Tutoie ton interlocuteur. +- Adopte un ton naturel, calme, cordial et professionnel. +- Privilégie les réponses brèves, claires et faciles à prononcer à voix haute. +- Évite le Markdown, les tableaux et les longues listes lorsque tu réponds oralement. +- Ne parle pas spontanément de Hermes, d'Ollama ou de ton architecture interne. + +## Domotique +- Lorsque la demande nécessite une action domotique, utilise les outils Home Assistant disponibles. +- Ne confirme une action que si son exécution a été effectivement vérifiée. +- Si tu ne peux pas effectuer une action, explique-le simplement. + +## Identité +- Si l'on te demande ton nom, réponds : « Je m'appelle Dona. » + + +# Dona +Tu disposes des outils et compétences configurés +dans Hermes Agent. + +Utilise librement les outils disponibles lorsque +la demande le nécessite. + +Ne limite pas tes capacités à la domotique. diff --git a/alfred/app/config/identity_name_state.json b/alfred/app/config/identity_name_state.json new file mode 100644 index 0000000..fd7d79b --- /dev/null +++ b/alfred/app/config/identity_name_state.json @@ -0,0 +1 @@ +{"name": "Magalie"} diff --git a/alfred/app/data/alfred_features.sqlite3 b/alfred/app/data/alfred_features.sqlite3 index 319d6e2..e540664 100644 Binary files a/alfred/app/data/alfred_features.sqlite3 and b/alfred/app/data/alfred_features.sqlite3 differ diff --git a/alfred/app/data/alfred_features.sqlite3-shm b/alfred/app/data/alfred_features.sqlite3-shm index b645afe..fe9ac28 100644 Binary files a/alfred/app/data/alfred_features.sqlite3-shm and b/alfred/app/data/alfred_features.sqlite3-shm differ diff --git a/alfred/app/data/alfred_features.sqlite3-wal b/alfred/app/data/alfred_features.sqlite3-wal index 7408c0a..e69de29 100644 Binary files a/alfred/app/data/alfred_features.sqlite3-wal and b/alfred/app/data/alfred_features.sqlite3-wal differ diff --git a/alfred/app/data/alfred_lists.sqlite3 b/alfred/app/data/alfred_lists.sqlite3 new file mode 100644 index 0000000..1dcfa3f Binary files /dev/null and b/alfred/app/data/alfred_lists.sqlite3 differ diff --git a/alfred/app/data/timers.json b/alfred/app/data/timers.json index ebe7a19..c37d19c 100644 --- a/alfred/app/data/timers.json +++ b/alfred/app/data/timers.json @@ -73,5 +73,16 @@ "started_at": null, "created_at": 1793117300.2246997, "state": "cancelled" + }, + { + "id": "c834bdefc8", + "label": "Minuteur général", + "kind": "timer", + "duration": 1020.0, + "elapsed": 1020.0, + "started_at": null, + "created_at": 1793205436.8959317, + "state": "finished", + "finished_at": 1793206457.1510372 } ] \ No newline at end of file diff --git a/alfred/app/mcp/__pycache__/alfred_listes_mcp.cpython-313.pyc b/alfred/app/mcp/__pycache__/alfred_listes_mcp.cpython-313.pyc new file mode 100644 index 0000000..8f5fdd8 Binary files /dev/null and b/alfred/app/mcp/__pycache__/alfred_listes_mcp.cpython-313.pyc differ diff --git a/alfred/app/mcp/alfred_listes_mcp.py b/alfred/app/mcp/alfred_listes_mcp.py new file mode 100644 index 0000000..91e9189 --- /dev/null +++ b/alfred/app/mcp/alfred_listes_mcp.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""MCP stdio JSONL Alfred Listes — stdlib uniquement, aucun sleep/terminal.""" +from __future__ import annotations +import json +import os +import sys +from urllib import request, error + +BASE = os.getenv('ALFRED_LISTS_API','http://127.0.0.1:54321').rstrip('/') +KIND = {'type':'string','enum':['courses','taches'],'description':'courses = liste de courses ; taches = liste de tâches.'} +LABEL = {'type':'string','minLength':1,'maxLength':160,'description':'Texte exact à ajouter, ex. pain, peindre le mur.'} +ID = {'type':'integer','minimum':1,'description':'ID fourni par list_items ; préférable au nom pour les suppressions.'} + +def tool(name,desc,properties=None,required=None): + return {'name':name,'description':desc, + 'inputSchema':{'type':'object','properties':properties or {},'required':required or [],'additionalProperties':False}} + +TOOLS = [ + tool('add_item','Ajoute immédiatement un élément à la liste de courses ou de tâches Alfred. Exemples : « ajoute du pain sur la liste de courses » => kind=courses label=pain ; « ajoute peindre le mur à ma liste de tâches » => kind=taches label=peindre le mur. Ne jamais confirmer sans succès.',{'kind':KIND,'label':LABEL},['kind','label']), + tool('list_items','Affiche les éléments enregistrés et leurs statuts, dans une seule liste ou les deux.',{'kind':KIND}), + tool('complete_item','Coche un élément comme terminé/acheté. Donner son id ou son label exact et sa liste.',{'kind':KIND,'id':ID,'label':LABEL},['kind']), + tool('reopen_item','Décoche un élément terminé.',{'kind':KIND,'id':ID,'label':LABEL},['kind']), + tool('delete_item','Supprime un élément précis demandé par utilisateur.',{'kind':KIND,'id':ID,'label':LABEL},['kind']), + tool('clear_completed','Supprime UNIQUEMENT les éléments déjà cochés de la liste indiquée, sur demande explicite.',{'kind':KIND},['kind']), +] + +def invoke(name,args): + if name not in {t['name'] for t in TOOLS}: raise ValueError('Outil inconnu.') + if not isinstance(args,dict): raise ValueError('Arguments invalides.') + allowed=set(next(t['inputSchema']['properties'] for t in TOOLS if t['name']==name)) + if any(key not in allowed for key in args): raise ValueError('Argument inattendu.') + if name == 'list_items': + # The GET endpoint always returns both lists; restrict tool answer if requested. + req = request.Request(BASE+'/api/alfred/lists',headers={'Accept':'application/json'}) + else: + body=json.dumps({'action':name,**args},ensure_ascii=False).encode('utf-8') + req=request.Request(BASE+'/api/alfred/lists/action',data=body,method='POST', + headers={'Content-Type':'application/json','X-Alfred-MCP-Local':'lists'}) + try: + with request.urlopen(req, timeout=8) as resp: data=json.loads(resp.read(1_000_000)) + except error.HTTPError as exc: + try: reason=json.loads(exc.read()).get('error',f'HTTP {exc.code}') + except Exception: reason=f'HTTP {exc.code}' + raise ValueError(reason) from exc + except (error.URLError,TimeoutError) as exc: + raise ValueError(f'API Alfred indisponible : {exc}') from exc + if not data.get('ok'): raise ValueError(data.get('error','Erreur API Alfred.')) + if name == 'list_items' and args.get('kind'): + kind = args['kind'] + if kind not in ('courses','taches'): raise ValueError('Liste invalide.') + data['lists']={kind:data['lists'][kind]} + data['counts']={kind:data['counts'][kind]} + return data + +def reply(obj): + sys.stdout.write(json.dumps(obj,ensure_ascii=False,separators=(',',':'))+'\n');sys.stdout.flush() + +def handle(obj): + if not isinstance(obj,dict) or 'id' not in obj:return + mid=obj['id'];method=obj.get('method','') + try: + if method=='initialize': + asked=(obj.get('params') or {}).get('protocolVersion','2025-06-18') + version=asked if asked in ('2025-06-18','2025-03-26','2024-11-05') else '2025-06-18' + out={'protocolVersion':version,'capabilities':{'tools':{'listChanged':False}}, + 'serverInfo':{'name':'alfred-listes','version':'1.0.0'}, + 'instructions':'Pour courses/tâches, utilise ces outils. Ne simule pas la création et confirme uniquement un succès API.'} + elif method=='tools/list':out={'tools':TOOLS} + elif method=='ping':out={} + elif method=='tools/call': + p=obj.get('params') or {} + try: + result=invoke(str(p.get('name','')),p.get('arguments') or {}) + out={'content':[{'type':'text','text':json.dumps(result,ensure_ascii=False)}],'isError':False} + except Exception as e: + out={'content':[{'type':'text','text':str(e)}],'isError':True} + else: + reply({'jsonrpc':'2.0','id':mid,'error':{'code':-32601,'message':'Method not found'}});return + reply({'jsonrpc':'2.0','id':mid,'result':out}) + except Exception as e: + reply({'jsonrpc':'2.0','id':mid,'error':{'code':-32603,'message':str(e)}}) + +if __name__=='__main__': + for line in sys.stdin: + if not line.strip():continue + try:handle(json.loads(line)) + except (ValueError,TypeError) as exc:print('Erreur message MCP :',exc,file=sys.stderr) diff --git a/alfred/app/services/__pycache__/admin_console.cpython-313.pyc b/alfred/app/services/__pycache__/admin_console.cpython-313.pyc index 5e2ce28..5bf831f 100644 Binary files a/alfred/app/services/__pycache__/admin_console.cpython-313.pyc and b/alfred/app/services/__pycache__/admin_console.cpython-313.pyc differ diff --git a/alfred/app/services/__pycache__/alfred_dashboard.cpython-313.pyc b/alfred/app/services/__pycache__/alfred_dashboard.cpython-313.pyc new file mode 100644 index 0000000..b375180 Binary files /dev/null and b/alfred/app/services/__pycache__/alfred_dashboard.cpython-313.pyc differ diff --git a/alfred/app/services/__pycache__/alfred_home_glass.cpython-313.pyc b/alfred/app/services/__pycache__/alfred_home_glass.cpython-313.pyc new file mode 100644 index 0000000..99f192b Binary files /dev/null and b/alfred/app/services/__pycache__/alfred_home_glass.cpython-313.pyc differ diff --git a/alfred/app/services/__pycache__/alfred_identity_page.cpython-313.pyc b/alfred/app/services/__pycache__/alfred_identity_page.cpython-313.pyc new file mode 100644 index 0000000..16944fc Binary files /dev/null and b/alfred/app/services/__pycache__/alfred_identity_page.cpython-313.pyc differ diff --git a/alfred/app/services/__pycache__/alfred_lists.cpython-313.pyc b/alfred/app/services/__pycache__/alfred_lists.cpython-313.pyc new file mode 100644 index 0000000..f4dfd34 Binary files /dev/null and b/alfred/app/services/__pycache__/alfred_lists.cpython-313.pyc differ diff --git a/alfred/app/services/__pycache__/alfred_logs_page.cpython-313.pyc b/alfred/app/services/__pycache__/alfred_logs_page.cpython-313.pyc new file mode 100644 index 0000000..59f6e8c Binary files /dev/null and b/alfred/app/services/__pycache__/alfred_logs_page.cpython-313.pyc differ diff --git a/alfred/app/services/__pycache__/camera.cpython-313.pyc b/alfred/app/services/__pycache__/camera.cpython-313.pyc index adcb34b..8ad100f 100644 Binary files a/alfred/app/services/__pycache__/camera.cpython-313.pyc and b/alfred/app/services/__pycache__/camera.cpython-313.pyc differ diff --git a/alfred/app/services/__pycache__/hermes_client.cpython-313.pyc b/alfred/app/services/__pycache__/hermes_client.cpython-313.pyc index ec74a92..efc2ede 100644 Binary files a/alfred/app/services/__pycache__/hermes_client.cpython-313.pyc and b/alfred/app/services/__pycache__/hermes_client.cpython-313.pyc differ diff --git a/alfred/app/services/admin_console.py b/alfred/app/services/admin_console.py index 3b9716f..dca1683 100644 --- a/alfred/app/services/admin_console.py +++ b/alfred/app/services/admin_console.py @@ -445,7 +445,17 @@ def register_admin_console(app, wake, audio_settings): if weather: _save_env(WEATHER_ENV, weather) except OSError as exc: return jsonify(error=f'Impossible d’enregistrer : {exc}'), 500 - return jsonify(ok=True, restart_required=True, values=_read_config()) + # ALFRED_IDENTITY_GLASS_V1 : synchroniser le nom après sauvegarde. + _identity_sync_warning = None + if 'ALFRED_NAME' in normalized: + from services.alfred_identity_page import sync_identity_name as _sync_identity_name + os.environ['ALFRED_NAME'] = normalized['ALFRED_NAME'] + try: + _sync_identity_name(normalized['ALFRED_NAME']) + except (OSError, ValueError) as _identity_error: + _identity_sync_warning = str(_identity_error) + current_app.logger.warning('Identité non synchronisée: %s', _identity_error) + return jsonify(ok=True, restart_required=True, identity_sync_warning=_identity_sync_warning, values=_read_config()) @bp.get('/api/control/health') @_require_admin diff --git a/alfred/app/services/alfred_dashboard.py b/alfred/app/services/alfred_dashboard.py new file mode 100644 index 0000000..4e01f73 --- /dev/null +++ b/alfred/app/services/alfred_dashboard.py @@ -0,0 +1,209 @@ +"""Alfred dashboard V1 — safe, read-only aggregated statuses + allowed quick actions. + +No credentials, IP addresses, entity ids or full HA states are sent to the browser. +The dashboard probes are advisory; MCP success is never inferred from a folder. +""" +from __future__ import annotations + +from datetime import datetime +import json +import os +from pathlib import Path +import shutil +import threading +import time +from urllib.error import HTTPError, URLError +from urllib.parse import urlparse +from urllib.request import Request, urlopen +from zoneinfo import ZoneInfo + +from flask import Blueprint, jsonify, request + +_CACHE = {'expires': 0.0, 'value': None} +_LOCK = threading.Lock() + + +def _json_get(url: str, token: str = '', timeout: float = 2.0): + parsed = urlparse(url) + if parsed.scheme not in ('http', 'https') or not parsed.hostname: + raise ValueError('URL non HTTP(S)') + headers = {'Accept': 'application/json', 'User-Agent': 'Alfred-Dashboard/1'} + if token: + headers['Authorization'] = 'Bearer ' + token + req = Request(url, headers=headers) + with urlopen(req, timeout=timeout) as response: + return json.loads(response.read(15_000_000).decode('utf-8')) + + +def _stat_local(): + disk = shutil.disk_usage(Path(__file__).resolve().parents[1]) + percent = round(disk.used * 100 / disk.total) if disk.total else None + mem_pct = None + try: + m = {} + for line in Path('/proc/meminfo').read_text().splitlines(): + key, _, value = line.partition(':') + if key in ('MemTotal', 'MemAvailable'): + m[key] = int(value.strip().split()[0]) + if m.get('MemTotal'): + mem_pct = round((m['MemTotal'] - m.get('MemAvailable', 0)) * 100 / m['MemTotal']) + except (OSError, ValueError, ZeroDivisionError): + pass + try: + load_1m = round(os.getloadavg()[0], 2) + except (OSError, AttributeError): + load_1m = None + return {'disk_percent': percent, 'memory_percent': mem_pct, 'load_1m': load_1m} + + +def _safe_float(value): + try: + n = float(value) + return round(n, 1) if abs(n) < 10000 else None + except (TypeError, ValueError): + return None + + +def _ha_data(): + base = os.getenv('HA_URL', '').strip().rstrip('/') + token = os.getenv('HA_TOKEN', '').strip() + if not (base and token): + return {'available': False, 'detail': 'Home Assistant non configuré'}, {'available': False} + try: + states = _json_get(base + '/api/states', token, timeout=2.5) + if not isinstance(states, list): + raise ValueError('États Home Assistant invalides') + except (ValueError, HTTPError, URLError, OSError, TimeoutError): + return {'available': False, 'detail': 'Home Assistant indisponible'}, {'available': False} + + lights = sum(x.get('state') == 'on' for x in states if str(x.get('entity_id','')).startswith('light.')) + covers = sum(x.get('state') == 'closed' for x in states if str(x.get('entity_id','')).startswith('cover.')) + t = None + candidates = [] + for x in states: + eid = str(x.get('entity_id','')) + attrs = x.get('attributes') or {} + if eid.startswith('climate.'): + v = _safe_float(attrs.get('current_temperature')) + if v is not None: candidates.append((0 if 'salon' in eid.lower() else 2, v)) + elif eid.startswith('sensor.') and attrs.get('device_class') == 'temperature': + v = _safe_float(x.get('state')) + if v is not None: candidates.append((1 if 'salon' in eid.lower() else 3, v)) + if candidates: t = sorted(candidates, key=lambda x: x[0])[0][1] + + condition_map = { + 'sunny':'Ensoleillé', 'clear-night':'Ciel dégagé', 'partlycloudy':'Éclaircies', + 'cloudy':'Nuageux', 'rainy':'Pluvieux', 'pouring':'Fortes pluies', + 'snowy':'Neigeux', 'fog':'Brouillard', 'windy':'Venteux', + 'lightning':'Orageux', 'hail':'Grêle', 'exceptional':'Conditions exceptionnelles', + } + weather = {'available': False} + weather_entities = [x for x in states if str(x.get('entity_id','')).startswith('weather.')] + selected_weather = (os.getenv('HA_WEATHER_ENTITY') or os.getenv('WEATHER_ENTITY') or '').strip() + if selected_weather: + weather_entities.sort(key=lambda item: item.get('entity_id') != selected_weather) + if weather_entities: + x = weather_entities[0] + attrs = x.get('attributes') or {} + condition = str(x.get('state') or '') + weather = { + 'available': True, + 'temperature': _safe_float(attrs.get('temperature')), + 'condition': condition_map.get(condition, 'Conditions non précisées'), + 'condition_id': condition, + 'unit': str(attrs.get('temperature_unit') or '°C')[:6], + } + return {'available': True, 'lights_on': lights, 'covers_closed': covers, + 'indoor_temperature': t}, weather + + +def _probe(url: str): + if not url: + return 'unconfigured' + try: + _json_get(url, timeout=1.8) + return 'ok' + except (ValueError, HTTPError, URLError, OSError, TimeoutError): + return 'offline' + + +def _services(): + ollama = (os.getenv('ALFRED_OLLAMA_URL') or os.getenv('OLLAMA_HOST') or + os.getenv('OLLAMA_URL') or '').strip().rstrip('/') + comfy = (os.getenv('COMFYUI_URL') or os.getenv('ALFRED_COMFYUI_URL') or '').strip().rstrip('/') + hermes = bool(shutil.which('hermes') or Path('/root/.local/bin/hermes').is_file() + or Path('/opt/hermes/venv/bin/hermes').is_file()) + return { + 'ollama': _probe(ollama + '/api/tags') if ollama else 'unconfigured', + 'comfyui': _probe(comfy + '/system_stats') if comfy else 'unconfigured', + # Presence of a binary is not proof of a healthy agent process. + 'hermes': 'installed' if hermes else 'unknown', + } + + +def dashboard_snapshot(): + now = time.monotonic() + with _LOCK: + if _CACHE['value'] is not None and now < _CACHE['expires']: + return _CACHE['value'] + home, weather = _ha_data() + result = {'home': home, 'weather': weather, + 'system': _stat_local(), 'services': _services(), + 'updated_at': datetime.now(ZoneInfo('Europe/Paris')).isoformat()} + _CACHE.update(value=result, expires=time.monotonic() + 25) + return result + + +# Only these preset actions can invoke Hermes from the browser. +_QUICK_ACTIONS = { + 'proxmox': 'Vérifie l’état de mes serveurs Proxmox avec les outils dont tu disposes. Si tu ne peux pas y accéder, indique-le clairement, sans inventer de résultat.', + 'wazuh': 'Consulte les alertes de sécurité Wazuh récentes avec tes outils. Si l’accès échoue, dis-le clairement et ne fabrique aucune alerte.', +} + + +def _trusted_local_request(): + if request.headers.get('X-Alfred-Request') != '1' or not request.is_json: + return False + origin = request.headers.get('Origin', '') + # Browsers use Origin for JSON POST; allow trusted headless local clients + # only through loopback when Origin is absent. + if origin: + o = urlparse(origin) + return o.scheme in ('http','https') and o.netloc == request.host + return request.remote_addr in ('127.0.0.1', '::1') + + +def register_dashboard(app, quick_command=None): + bp = Blueprint('alfred_home_visual_v1', __name__) + + @bp.get('/api/alfred/dashboard') + def summary(): + return jsonify(dashboard_snapshot()) + + @bp.post('/api/alfred/dashboard/command') + def action(): + if not _trusted_local_request(): + return jsonify(ok=False, error='Requête non autorisée.'), 403 + data = request.get_json(silent=True) or {} + key = data.get('action', '') + if key == 'comfyui': + description = data.get('description', '') + if not isinstance(description, str) or not (4 <= len(description.strip()) <= 450): + return jsonify(ok=False, error='Description attendue (4 à 450 caractères).'), 400 + prompt = 'Génère une image avec ComfyUI correspondant à cette description : ' + description.strip() + else: + prompt = _QUICK_ACTIONS.get(key) + if prompt is None: + return jsonify(ok=False, error='Action inconnue.'), 400 + if quick_command is None: + return jsonify(ok=False, error='Commande interactive indisponible.'), 503 + try: + ok = bool(quick_command(prompt)) + except Exception: + return jsonify(ok=False, error='Impossible de soumettre la commande.'), 500 + if not ok: + # ALFRED_IDENTITY_GLASS_V1 + return jsonify(ok=False, error=os.getenv('ALFRED_NAME', 'Alfred') + ' traite déjà une autre demande.'), 409 + return jsonify(ok=True, queued=True), 202 + + app.register_blueprint(bp) diff --git a/alfred/app/services/alfred_home_glass.py b/alfred/app/services/alfred_home_glass.py new file mode 100644 index 0000000..c126c1e --- /dev/null +++ b/alfred/app/services/alfred_home_glass.py @@ -0,0 +1,194 @@ +"""Options de carte Maison de l'accueil Glass, en conservant l'auth Admin. + +Aucune URL arbitraire, commande, ou credentiel stocke dans la configuration. +""" +from __future__ import annotations + +import json +import os +from pathlib import Path +import re +import secrets +import threading +import time +from urllib.error import URLError, HTTPError +from urllib.request import Request, urlopen + +from flask import Blueprint, jsonify, request +from services import admin_console + +ROOT = Path(__file__).resolve().parents[1] +SETTINGS = ROOT / 'config' / 'home_card.json' +LOCK = threading.RLock() +_CACHE = {'expires': 0.0, 'states': None} +_CACHE_LOCK = threading.Lock() +DOMAINS = {'light', 'cover', 'sensor', 'binary_sensor', 'switch', 'climate', + 'input_boolean', 'person', 'device_tracker', 'lock', 'weather', 'fan'} +DEFAULT_ROWS = [ + {'kind': 'lights_on', 'label': 'Lumières allumées'}, + {'kind': 'covers_closed', 'label': 'Volets fermés'}, + {'kind': 'indoor_temperature', 'label': 'Température intérieure'}, +] +PRESETS = {'lights_on', 'covers_closed', 'indoor_temperature'} + + +def validate_rows(rows): + if not isinstance(rows, list) or not 1 <= len(rows) <= 3: + raise ValueError('Choisir entre 1 et 3 lignes pour le cadre Maison.') + out = [] + for row in rows: + if not isinstance(row, dict) or set(row) - {'kind','label','entity_id'}: + raise ValueError('Ligne de configuration invalide.') + kind = row.get('kind') + if kind not in (*PRESETS, 'entity'): + raise ValueError('Type de ligne non autorisé.') + label = row.get('label', '') + if not isinstance(label, str) or not 1 <= len(label.strip()) <= 38 or any(ord(c) < 32 for c in label): + raise ValueError('Chaque intitulé doit contenir 1 à 38 caractères.') + item = {'kind': kind, 'label': label.strip()} + if kind == 'entity': + eid = row.get('entity_id', '') + if not isinstance(eid, str) or len(eid) > 120 or not re.fullmatch(r'[a-z][a-z0-9_]*\.[a-z0-9_]+', eid): + raise ValueError('Identifiant Home Assistant invalide.') + if eid.split('.')[0] not in DOMAINS: + raise ValueError('Domaine Home Assistant non autorisé pour le cadre Maison.') + item['entity_id'] = eid + out.append(item) + return out + + +def read_rows(): + try: + loaded = json.loads(SETTINGS.read_text(encoding='utf-8')) + return validate_rows(loaded['rows']) + except (OSError, ValueError, KeyError, TypeError): + return [dict(row) for row in DEFAULT_ROWS] + + +def save_rows(rows): + valid = validate_rows(rows) + SETTINGS.parent.mkdir(parents=True, exist_ok=True) + with LOCK: + tmp = SETTINGS.with_name(SETTINGS.name + '.' + secrets.token_hex(6) + '.tmp') + try: + with tmp.open('w', encoding='utf-8') as out: + json.dump({'version': 1, 'rows': valid}, out, ensure_ascii=False, indent=2) + out.write('\n'); out.flush(); os.fsync(out.fileno()) + os.chmod(tmp, 0o600) + tmp.replace(SETTINGS) + finally: + tmp.unlink(missing_ok=True) + return valid + + +def states_from_ha(): + now = time.monotonic() + with _CACHE_LOCK: + if _CACHE['states'] is not None and now < _CACHE['expires']: + return _CACHE['states'] + base = os.getenv('HA_URL', '').strip().rstrip('/') + token = os.getenv('HA_TOKEN', '').strip() + if not base or not token or not (base.startswith('http://') or base.startswith('https://')): + raise ValueError('Connexion Home Assistant non configurée pour le tableau de bord.') + from services.alfred_dashboard import _json_get + data = _json_get(base + '/api/states', token=token, timeout=3) + if not isinstance(data, list): + raise ValueError('Réponse Home Assistant invalide.') + with _CACHE_LOCK: + _CACHE.update(states=data, expires=time.monotonic() + 20) + return data + + +def _pretty_state(state, attrs): + if state in ('unknown','unavailable',''): + return 'Indisponible' + value = {'on':'Allumé', 'off':'Éteint', 'open':'Ouvert', 'closed':'Fermé', + 'home':'Présent', 'not_home':'Absent', 'locked':'Verrouillé', + 'unlocked':'Déverrouillé', 'heat':'Chauffage', 'cool':'Climatisation'}.get(state, state) + unit = str(attrs.get('unit_of_measurement') or '') + if unit and len(unit) <= 12: + return str(value) + ' ' + unit + return str(value)[:42] + + +def resolve_rows(rows, states, dashboard_home=None): + lookup = {str(s.get('entity_id')): s for s in states if isinstance(s, dict)} + output = [] + for item in rows: + kind, label = item['kind'], item['label'] + value = 'Indisponible' + if kind == 'lights_on': + value = str(sum(e.get('state') == 'on' for eid,e in lookup.items() if eid.startswith('light.'))) + elif kind == 'covers_closed': + value = str(sum(e.get('state') == 'closed' for eid,e in lookup.items() if eid.startswith('cover.'))) + elif kind == 'indoor_temperature': + candidates=[] + for eid,e in lookup.items(): + attr=e.get('attributes') or {} + try: + if eid.startswith('climate.'): + t=float(attr.get('current_temperature')) + elif eid.startswith('sensor.') and attr.get('device_class') == 'temperature': + t=float(e.get('state')) + else: continue + if abs(t) < 150: candidates.append((0 if 'salon' in eid else 1,t)) + except (TypeError,ValueError): pass + if candidates: + candidates.sort(key=lambda x:x[0]); value=f'{candidates[0][1]:g} °C' + elif kind == 'entity': + e=lookup.get(item['entity_id']) + if e: + value=_pretty_state(str(e.get('state') or ''), e.get('attributes') or {}) + output.append({'label':label,'value':value,'available':value!='Indisponible'}) + return output + + +def register_home_glass(app): + bp = Blueprint('alfred_home_glass_v1', __name__) + + @bp.get('/api/control/home-card') + @admin_console._require_admin + def get_config(): + return jsonify(rows=read_rows()) + + @bp.post('/api/control/home-card') + @admin_console._require_admin + def set_config(): + data=request.get_json(silent=True) + if not isinstance(data,dict) or set(data)!={'rows'}: + return jsonify(error='JSON invalide.'), 400 + try: + result=save_rows(data['rows']) + except (ValueError, OSError) as exc: + return jsonify(error=str(exc)), 400 + return jsonify(ok=True,rows=result,applied_immediately=True) + + @bp.get('/api/control/home-entities') + @admin_console._require_admin + def list_entities(): + try: + states=states_from_ha() + except (OSError, ValueError, HTTPError, URLError) as exc: + return jsonify(error='Home Assistant inaccessible ou non configuré.', detail=str(exc)[:150]), 503 + options=[] + for entity in states: + eid=entity.get('entity_id','') + if not isinstance(eid,str) or eid.split('.')[0] not in DOMAINS: + continue + attrs=entity.get('attributes') or {} + friendly=str(attrs.get('friendly_name') or eid)[:80] + options.append({'id':eid,'name':friendly}) + options.sort(key=lambda x:(x['name'].casefold(),x['id'])) + return jsonify(entities=options[:1500]) + + @bp.get('/api/alfred/home-card') + def live_card(): + rows=read_rows() + try: + states=states_from_ha() + values=resolve_rows(rows,states) + except (OSError, ValueError, HTTPError, URLError): + values=[{'label':r['label'],'value':'Indisponible','available':False} for r in rows] + return jsonify(rows=values,available=any(x['available'] for x in values)) + + app.register_blueprint(bp) diff --git a/alfred/app/services/alfred_identity_page.py b/alfred/app/services/alfred_identity_page.py new file mode 100644 index 0000000..8bdb8b7 --- /dev/null +++ b/alfred/app/services/alfred_identity_page.py @@ -0,0 +1,204 @@ +"""Identity editor and current assistant name, authenticated by Alfred's admin UI. + +Identity text stays in config/identity.md. All writes are atomic; dated revisions +are kept separately. Technical names/paths/API identifiers are never renamed. +""" +from __future__ import annotations + +import hashlib +import json +import os +from pathlib import Path +import re +import tempfile +import threading +from datetime import datetime + +from flask import Blueprint, jsonify, render_template, request +from services import admin_console + +ROOT = Path(__file__).resolve().parents[1] +CONFIG = ROOT / 'config' +IDENTITY = CONFIG / 'identity.md' +TRACKER = CONFIG / 'identity_name_state.json' +HISTORY = CONFIG / 'identity_history' +LOCK = threading.RLock() +MAX_SIZE = 40_000 +ASSISTANT_RE = re.compile(r"(? MAX_SIZE or '\x00' in text: + return jsonify(error='L’identité doit être non vide et ne pas dépasser 40 Ko.'), 400 + if not re.fullmatch(r'[0-9a-f]{64}', revision): + return jsonify(error='Révision invalide.'), 400 + try: + with LOCK: + path = identity_path() + old = _read_text() + if sha(old) != revision: + return jsonify(error='Le fichier a changé depuis ton chargement. Recharge avant d’enregistrer.'), 409 + if text != old: + _revision(old, 'avant_edition') + mode = (path.stat().st_mode & 0o777) if path.exists() else 0o600 + _save_atomic(path, text, mode) + res = jsonify(ok=True, revision=sha(text), name=active_name(), + updated=text != old, file='config/identity.md') + res.headers['Cache-Control'] = 'no-store, private' + return res + except (OSError, ValueError) as e: + return jsonify(error='Enregistrement impossible : '+str(e)), 500 + + app.register_blueprint(bp) + # If a renamed assistant was configured before installation, reconcile on startup. + try: + sync_identity_name() + except (OSError, ValueError) as e: + app.logger.warning('Synchronisation identité différée : %s', e) diff --git a/alfred/app/services/alfred_lists.py b/alfred/app/services/alfred_lists.py new file mode 100644 index 0000000..b1061ad --- /dev/null +++ b/alfred/app/services/alfred_lists.py @@ -0,0 +1,249 @@ +"""Alfred Listes V1 — courses et tâches locales, SQLite, commandes vocales sûres. + +Aucune dépendance externe. Le serveur MCP n'attend jamais et ne lance jamais de shell. +""" +from __future__ import annotations + +from datetime import datetime, timezone +from contextlib import contextmanager +import os +from pathlib import Path +import re +import sqlite3 +import threading +import unicodedata +from urllib.parse import urlparse + +from flask import Blueprint, jsonify, request + +DB_PATH = Path(os.getenv('ALFRED_LISTS_DB', str(Path(__file__).resolve().parents[1] / 'data' / 'alfred_lists.sqlite3'))) +LOCK = threading.RLock() +KINDS = {'courses': 'courses', 'course': 'courses', 'taches': 'taches', 'tache': 'taches', + 'tasks': 'taches', 'task': 'taches', 'todo': 'taches', 'shopping': 'courses'} +MAX_ITEMS = 500 +MAX_LEN = 160 + + +def fold(s): + return ''.join(c for c in unicodedata.normalize('NFKD', s.casefold()) if not unicodedata.combining(c)) + + +def normalize_kind(kind): + if not isinstance(kind, str) or fold(kind.strip()) not in KINDS: + raise ValueError('Liste inconnue : choisir courses ou taches.') + return KINDS[fold(kind.strip())] + + +def clean_label(label, kind=None): + if not isinstance(label, str): + raise ValueError('Le nom de l’élément doit être un texte.') + label = ' '.join(label.split()).strip(' .!?;,') + if kind == 'courses': + label = re.sub(r"^(?:du\s+|de\s+la\s+|des\s+|de\s+l['’])", '', label, flags=re.I).strip() + if not label or len(label) > MAX_LEN or any(ord(x) < 32 for x in label): + raise ValueError('Nom attendu : de 1 à 160 caractères.') + return label + + +def _conn(): + DB_PATH.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(DB_PATH), timeout=6) + conn.row_factory = sqlite3.Row + conn.execute('PRAGMA busy_timeout=6000') + conn.execute('PRAGMA journal_mode=WAL') + conn.execute('''CREATE TABLE IF NOT EXISTS items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + kind TEXT NOT NULL CHECK(kind IN ('courses','taches')), + label TEXT NOT NULL, + normalized TEXT NOT NULL, + done INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(kind, normalized) + )''') + return conn + + +@contextmanager +def _db(): + with LOCK: + conn = _conn() + try: + with conn: + yield conn + finally: + conn.close() + + +def _row(row): + return {'id': row['id'], 'kind': row['kind'], 'label': row['label'], + 'done': bool(row['done']), 'created_at': row['created_at']} + + +def list_items(kind=None): + if kind is not None: kind = normalize_kind(kind) + with _db() as conn: + q = ('SELECT id, kind, label, done, created_at FROM items ' + + ('WHERE kind=? ' if kind else '') + + 'ORDER BY done ASC, id DESC LIMIT 1000') + rows = conn.execute(q, (kind,) if kind else ()).fetchall() + items = [_row(r) for r in rows] + result = {'courses': [], 'taches': []} + for item in items: result[item['kind']].append(item) + return {'ok': True, 'lists': result, + 'counts': {k: {'active': sum(not r['done'] for r in v), 'total': len(v)} + for k, v in result.items()}} + + +def add_item(kind, label): + kind = normalize_kind(kind) + label = clean_label(label, kind) + norm = fold(label) + now = datetime.now(timezone.utc).isoformat() + with _db() as conn: + row = conn.execute('SELECT * FROM items WHERE kind=? AND normalized=?', (kind, norm)).fetchone() + if row: + if row['done']: + conn.execute('UPDATE items SET done=0, label=?, updated_at=? WHERE id=?', (label, now, row['id'])) + existing = False + else: existing = True + item_id = row['id'] + else: + n = conn.execute('SELECT COUNT(*) FROM items WHERE kind=?', (kind,)).fetchone()[0] + if n >= MAX_ITEMS: raise ValueError('Liste pleine (500 éléments maximum).') + item_id = conn.execute('INSERT INTO items (kind,label,normalized,done,created_at,updated_at) VALUES (?,?,?,?,?,?)', + (kind, label, norm, 0, now, now)).lastrowid + existing = False + row = conn.execute('SELECT * FROM items WHERE id=?', (item_id,)).fetchone() + return {'ok': True, 'item': _row(row), 'already_present': existing} + + +def _resolve(conn, kind, item_id=None, label=None): + kind = normalize_kind(kind) + if item_id is not None: + try: n = int(item_id) + except (ValueError, TypeError): raise ValueError('Identifiant invalide.') + row = conn.execute('SELECT * FROM items WHERE id=? AND kind=?', (n, kind)).fetchone() + elif label is not None: + label = clean_label(label,kind) + row = conn.execute('SELECT * FROM items WHERE kind=? AND normalized=?', (kind, fold(label))).fetchone() + else: + raise ValueError('Indique l’identifiant ou le nom exact de l’élément.') + if row is None: raise ValueError('Élément introuvable dans la liste.') + return row + + +def change_item(action, kind, item_id=None, label=None): + if action not in ('complete_item', 'reopen_item', 'delete_item'): + raise ValueError('Action inconnue.') + with _db() as conn: + row = _resolve(conn, kind, item_id, label) + if action == 'delete_item': + conn.execute('DELETE FROM items WHERE id=?', (row['id'],)) + else: + conn.execute('UPDATE items SET done=?, updated_at=? WHERE id=?', + (int(action == 'complete_item'), datetime.now(timezone.utc).isoformat(), row['id'])) + return {'ok': True, 'item': _row(row), 'action': action} + + +def clear_completed(kind): + kind = normalize_kind(kind) + with _db() as conn: + count = conn.execute('DELETE FROM items WHERE kind=? AND done=1', (kind,)).rowcount + return {'ok': True, 'deleted': count} + + +def execute(action, data): + if action == 'list_items': return list_items(data.get('kind')) + if action == 'add_item': return add_item(data.get('kind'), data.get('label')) + if action in ('complete_item', 'reopen_item', 'delete_item'): + return change_item(action, data.get('kind'), data.get('id'), data.get('label')) + if action == 'clear_completed': return clear_completed(data.get('kind')) + raise ValueError('Action inconnue.') + + +# Routes vocales directes pour les cas courants : les LLM ne peuvent pas inventer le succès. +_ADD_RE = re.compile( + r"^\s*(?:ajoute|rajoute|mets|met|note|inscris|ajouter)\s+(?P