diff --git a/alfred/app/__pycache__/app.cpython-313.pyc b/alfred/app/__pycache__/app.cpython-313.pyc index 6b7ea88..c4f59ae 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 b7f447b..06fd108 100644 --- a/alfred/app/app.py +++ b/alfred/app/app.py @@ -6,6 +6,7 @@ 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 audio_settings # ALFRED_AUDIO_SETTINGS_V1 from services.camera import video_stream @@ -14,6 +15,7 @@ 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) @@ -157,6 +159,7 @@ def on_wake(score): 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()) diff --git a/alfred/app/app.py.bak.admin.20261026_012149 b/alfred/app/app.py.bak.admin.20261026_012149 new file mode 100644 index 0000000..b7f447b --- /dev/null +++ b/alfred/app/app.py.bak.admin.20261026_012149 @@ -0,0 +1,422 @@ +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.hermes_client import ask_hermes +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() + +## +from services.audio import get_vosk_model +print("[ALFRED] Préchargement des modèles vocaux...", flush=True) +get_vosk_model(os.environ["WAKE_VOSK_MODEL_PATH"]) +get_vosk_model(os.environ["VOSK_MODEL_PATH"]) +print("[ALFRED] Modèles vocaux prêts.", flush=True) +## +app=Flask(__name__); socketio=SocketIO(app,cors_allowed_origins="*") +STATE={"mode":"idle","title":"Alfred","content":{"type":"text","text":"À votre écoute."},"partial":""} +STATE_LOCK = threading.RLock() +IMAGE_REVISION = 0 # Incrémenté à chaque image envoyée par ComfyUI. +conversation_lock=threading.Lock() +mic_stop_event=threading.Event() +audio_settings.initialize() +audio_settings.update_settings({"mic_gain": 30}) +# Recharge les réglages V4L2 enregistrés, sans interrompre Alfred si la webcam est absente. +camera_settings.restore_saved_settings() + +def emit_state(mode=None, **extra): + with STATE_LOCK: + if mode: STATE["mode"] = mode + STATE.update(extra) + socketio.emit("alfred_state", dict(STATE)) + +def audio_level(level): socketio.emit("audio_level",{"level":max(0,min(float(level),1))}) + +def conversation(score=1.0): + if not conversation_lock.acquire(blocking=False): + return + + try: + emit_state("listening", partial="", transcript="", wake_score=score) + + print("[ALFRED] Début écoute STT", flush=True) + + r = listen_once( + stop_event=mic_stop_event, + on_partial=lambda p: emit_state("listening", partial=p), + on_level=audio_level + ) + + print(f"[ALFRED] Résultat STT : {r.text!r}, erreur : {r.error!r}", flush=True) + + if audio_settings.mic_muted(): + emit_state("idle", partial="") + return + + if r.error: + emit_state( + "error", + content={"type": "text", "text": r.error} + ) + return + + if not r.text: + emit_state("idle", partial="") + return + + emit_state( + "thinking", + partial="", + transcript=r.text, + content={"type": "text", "text": r.text} + ) + + # Vue dynamique : commandes déterministes exécutées sans faire + # dépendre l'affichage des capacités ou sessions Hermes. + visual = None + try: + visual = alfred_views_controller.route(r.text, (STATE.get('content') or {}).get('type')) + except (ha_views.HAError, ValueError) as exc: + print(f"[ALFRED] Vue indisponible : {exc}", flush=True) + visual = ({"type":"text", "text":str(exc)}, str(exc)) + + if visual is not None: + content, speech = visual + with STATE_LOCK: + STATE["content"] = content + socketio.emit("alfred_render", content) + if speech and not audio_settings.speaker_muted(): + emit_state("speaking") + done = threading.Event() + speak_async(speech, on_end=done.set) + done.wait(timeout=120) + emit_state("idle", partial="") + return + + print("[ALFRED] Envoi à Hermes", flush=True) + # ComfyUI publie l'image pendant cet appel Hermes. + with STATE_LOCK: + image_revision_before = IMAGE_REVISION + answer = ask_hermes(r.text) + print(f"[ALFRED] Réponse Hermes : {answer!r}", flush=True) + if isinstance(answer, dict): + speech = str(answer.get("speech") or answer.get("text") or "") + display = answer.get("display") or { + "type": "text", + "text": speech + } + else: + speech = str(answer or "") + display = {"type": "text", "text": speech} + + # Si le MCP ComfyUI a affiché une image pendant la requête, + # ne pas l'écraser par la réponse textuelle d'Hermes. + with STATE_LOCK: + if IMAGE_REVISION == image_revision_before: + STATE["content"] = display + 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)) + +@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'} + ) + +@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/cache/weather_outfit_cache.json b/alfred/app/cache/weather_outfit_cache.json index 929e89a..576a120 100644 --- a/alfred/app/cache/weather_outfit_cache.json +++ b/alfred/app/cache/weather_outfit_cache.json @@ -166,8 +166,8 @@ "a4928b9950baed6aad15a8e69c6c09bb6b068eba6f2e78e1e126c42be66aced3": { "file": "a4928b9950baed6aad15a8e69c6c09bb6b068eba6f2e78e1e126c42be66aced3.jpg", "prompt_ids": [ - "e4a383a6-d6b8-4d54-944a-d4f6802d3951", - "dbec0a66-eb40-4988-b512-7a4c3503f02a" + "46f09a0f-205f-4ec5-ad9a-10b186710faf", + "4f043514-8981-4ff3-ab11-2cbaa991853d" ], "reviews": { "child": { @@ -189,14 +189,14 @@ "reference_applied": true } }, - "cached_at": 1792969491.7701209, + "cached_at": 1792973066.884501, "focus_label": "samedi 31 octobre" }, "b4b214f2749f5c44f6aeb3f82ad71808b567e9fc66dbd8459de0c4a9853d111a": { "file": "b4b214f2749f5c44f6aeb3f82ad71808b567e9fc66dbd8459de0c4a9853d111a.jpg", "prompt_ids": [ - "cdd79fd9-0eef-4a3b-b6c6-d4b5bac8db81", - "0efecd05-8734-4335-91ff-10e282a00095" + "8caaff76-5977-4634-ac8b-55ba72ad2160", + "7f7d868f-8168-4c3d-b0a7-f3ad9b14aac4" ], "reviews": { "child": { @@ -218,7 +218,7 @@ "reference_applied": true } }, - "cached_at": 1792970119.676846, + "cached_at": 1792973038.224328, "focus_label": "aujourd’hui" } } \ No newline at end of file diff --git a/alfred/app/cache/weather_outfit_images/a4928b9950baed6aad15a8e69c6c09bb6b068eba6f2e78e1e126c42be66aced3.jpg b/alfred/app/cache/weather_outfit_images/a4928b9950baed6aad15a8e69c6c09bb6b068eba6f2e78e1e126c42be66aced3.jpg index 0d9794f..01668a4 100644 Binary files a/alfred/app/cache/weather_outfit_images/a4928b9950baed6aad15a8e69c6c09bb6b068eba6f2e78e1e126c42be66aced3.jpg and b/alfred/app/cache/weather_outfit_images/a4928b9950baed6aad15a8e69c6c09bb6b068eba6f2e78e1e126c42be66aced3.jpg differ diff --git a/alfred/app/cache/weather_outfit_images/b4b214f2749f5c44f6aeb3f82ad71808b567e9fc66dbd8459de0c4a9853d111a.jpg b/alfred/app/cache/weather_outfit_images/b4b214f2749f5c44f6aeb3f82ad71808b567e9fc66dbd8459de0c4a9853d111a.jpg index 44aab42..b8f49a6 100644 Binary files a/alfred/app/cache/weather_outfit_images/b4b214f2749f5c44f6aeb3f82ad71808b567e9fc66dbd8459de0c4a9853d111a.jpg and b/alfred/app/cache/weather_outfit_images/b4b214f2749f5c44f6aeb3f82ad71808b567e9fc66dbd8459de0c4a9853d111a.jpg differ diff --git a/alfred/app/config/admin_auth.json b/alfred/app/config/admin_auth.json new file mode 100644 index 0000000..616a973 --- /dev/null +++ b/alfred/app/config/admin_auth.json @@ -0,0 +1 @@ +{"password_hash": "scrypt:32768:8:1$q9lXd9HVYSjntQdD$8c3b6f13a94d499d31481a3a4f15046a71d26ebe65c6ed7d243f4a2c1916db7fdc5774a97d48ad3b2550ce06333ae5bc3ee225655825c097e3d65c0f28df9d1b"} \ No newline at end of file diff --git a/alfred/app/config/admin_secret.key b/alfred/app/config/admin_secret.key new file mode 100644 index 0000000..3568ea3 Binary files /dev/null and b/alfred/app/config/admin_secret.key differ diff --git a/alfred/app/config/control_center.env b/alfred/app/config/control_center.env new file mode 100644 index 0000000..1798ac1 --- /dev/null +++ b/alfred/app/config/control_center.env @@ -0,0 +1,2 @@ +# Configuration générale Alfred (page /config) +# ALFRED_ALLOW_WEB_RESTART=true diff --git a/alfred/app/services/__pycache__/admin_console.cpython-313.pyc b/alfred/app/services/__pycache__/admin_console.cpython-313.pyc new file mode 100644 index 0000000..7a9b009 Binary files /dev/null and b/alfred/app/services/__pycache__/admin_console.cpython-313.pyc differ diff --git a/alfred/app/services/admin_console.py b/alfred/app/services/admin_console.py new file mode 100644 index 0000000..eae961f --- /dev/null +++ b/alfred/app/services/admin_console.py @@ -0,0 +1,431 @@ +"""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', +} + + +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}.') + if 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 + return {'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}} + + +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/static/css/alfred-admin.css b/alfred/app/static/css/alfred-admin.css new file mode 100644 index 0000000..1125ac4 --- /dev/null +++ b/alfred/app/static/css/alfred-admin.css @@ -0,0 +1,5 @@ +:root{color-scheme:dark;font-family:Inter,system-ui,-apple-system,"Segoe UI",sans-serif;color:#edf3f9;background:#101924;--panel:#1a2836;--border:#304050;--muted:#9eafbe;--accent:#60b0e5}*{box-sizing:border-box}[hidden]{display:none!important} +body{margin:0;min-height:100vh;background:radial-gradient(ellipse at 60% 10%,#1b2d40 0,#101924 48%,#0d141d 100%)}button,input,select{font:inherit}a{color:inherit;text-decoration:none}button{cursor:pointer} +.admin-layout{max-width:1510px;margin:0 auto;min-height:100vh;padding:0 clamp(16px,3vw,48px) 48px}.admin-header{height:91px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #ffffff16;gap:20px}.brand{display:flex;align-items:center;gap:13px}.brand-orb{width:41px;height:41px;border-radius:13px;display:grid;place-items:center;background:linear-gradient(130deg,#65b5e6,#276eac);color:#fff;font-weight:700;box-shadow:0 10px 30px #1688d32c}.brand-orb.big{width:55px;height:55px;margin:auto;font-size:1.5rem}.brand strong{display:block;font-size:1.24rem;letter-spacing:-.025em}.brand small{display:block;color:#8fa4b6;font-size:.75rem;margin-top:2px}.admin-header nav{display:flex;align-items:center;gap:9px;flex-wrap:wrap}.admin-header nav a,.text-button{padding:11px 15px;color:#a8bbce;border-radius:10px;font-size:.9rem;background:transparent;border:0}.admin-header nav .selected{background:#4b92bc27;color:#d9f1fe;border:1px solid #73bbeb42}.admin-header nav a:hover,.text-button:hover{background:#ffffff0e;color:#fff} +.admin-main{padding-top:35px}.page-top{display:flex;align-items:flex-end;justify-content:space-between;gap:28px;margin-bottom:27px}.eyebrow{color:#7ac5f4;font-size:.72rem;letter-spacing:.16em;font-weight:700;margin:0 0 8px}.page-top h1{font-weight:620;font-size:clamp(1.85rem,3vw,2.8rem);letter-spacing:-.045em;margin:0 0 10px}.muted{color:var(--muted)}.page-top p.muted{margin:0;max-width:650px;line-height:1.6}.page-actions{display:flex;align-items:center;gap:13px;flex-shrink:0}.tag{background:#1c2f3e;border:1px solid #3c576d;color:#b4d0e0;border-radius:40px;padding:9px 12px;font-size:.81rem;white-space:nowrap}.button{border:1px solid #486071;border-radius:11px;color:#e1edf6;background:#243544;padding:11px 17px;font-weight:600;font-size:.88rem}.button:hover{filter:brightness(1.18)}.button.primary{background:#278dcb;border-color:#4baae2;color:#fff}.button.ghost{background:#213444;border-color:#466174}.button.danger{background:#703838;color:#ffded9;border-color:#aa6060;white-space:nowrap}.button.full{width:100%}.two-col{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:17px}.panel{border:1px solid var(--border);border-radius:17px;background:linear-gradient(145deg,#1a2938f2,#162230ef);padding:24px;box-shadow:0 16px 40px #0002}.panel-title{display:flex;gap:13px;align-items:center;padding-bottom:17px;margin-bottom:22px;border-bottom:1px solid #ffffff16}.panel-title h2{font-size:1.1rem;margin:0 0 4px;letter-spacing:-.015em}.panel-title p{font-size:.81rem;color:#91a6b6;margin:0;line-height:1.5}.panel-symbol{background:#2a5a7b52;color:#9bd9ff;border-radius:11px;display:grid;place-items:center;font-weight:700;width:42px;height:42px;flex-shrink:0;font-size:1.3rem}.field{display:flex;flex-direction:column;gap:8px;margin-bottom:20px}.field:last-child{margin-bottom:0}.field label{font-size:.89rem;font-weight:620;color:#e9eff4}.field small{font-size:.76rem;color:#8299ab;line-height:1.5}.field input:not([type=checkbox]):not([type=range]),.field select,.auth-box input{width:100%;background:#0f1c29;border:1px solid #3a5266;border-radius:10px;color:#f1f6fa;padding:12px 14px;outline:none;min-height:44px}.field input:focus-visible,.field select:focus-visible,.button:focus-visible{outline:2px solid #87d5ff;outline-offset:2px}.field select{cursor:pointer}.field input[type=range]{width:100%;accent-color:#65b9ee;cursor:pointer}.range-field{display:flex;align-items:center;gap:14px}.range-field output{white-space:nowrap;font-size:.86rem;color:#b1d8ee;min-width:60px;text-align:right}.switch-field{flex-direction:row;align-items:center;justify-content:space-between;gap:12px}.switch-field label small{display:block;margin-top:5px;font-weight:400}.switch-field input{width:20px;height:20px;accent-color:#63b7e9}.inline{display:flex;gap:14px;align-items:center;flex-wrap:wrap}.inline-link{display:inline-flex;margin:4px 0}.microtext{font-size:.79rem}.message{margin:18px 0 0;min-height:20px;color:#a6c4da;font-size:.9rem}.notice{padding:13px 16px;background:#3e67343a;border:1px solid #77976566;border-radius:10px;margin-bottom:21px;color:#d8efca}.restart-panel{display:flex;justify-content:space-between;align-items:center;gap:25px;margin-top:17px}.restart-panel h2{font-size:1.1rem;margin:0 0 7px}.restart-panel p{margin:0 0 5px}.auth-screen{display:grid;place-items:center;position:fixed;inset:0;background:radial-gradient(ellipse at 50% 35%,#1c3042 0%,#0b141d 80%);z-index:99;padding:20px}.auth-box{text-align:center;width:min(410px,100%);padding:34px;background:#192839;border:1px solid #395169;border-radius:21px;box-shadow:0 32px 80px #0005}.auth-box h1{font-size:1.5rem;margin:21px 0 8px}.auth-box p{color:#a7bacb;line-height:1.55;margin:0 0 22px}.auth-box form{display:flex;flex-direction:column;gap:11px;text-align:left}.auth-box label{font-weight:600;font-size:.87rem}.auth-box small{color:#8399ad}.auth-box .message{margin:13px 0}.monitor-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:15px;margin-bottom:17px}.metric-card{display:flex;gap:13px;min-height:162px}.metric-icon{font-size:1.65rem;background:#294d673d;border:1px solid #5e90ac4a;color:#9ecce6;display:grid;place-items:center;width:47px;height:47px;flex-shrink:0;border-radius:12px}.metric-info{display:flex;flex-direction:column;gap:9px;align-items:flex-start;min-width:0}.metric-info strong{font-size:1rem}.metric-info p{margin:0;font-size:.8rem;line-height:1.5}.status-pill{font-size:.74rem;border-radius:30px;padding:4px 9px;background:#6c778025;color:#b8c2cb;border:1px solid #8796a133;white-space:nowrap}.status-pill[data-state="ok"]{background:#388c6433;color:#80dbab;border-color:#54ae8252}.status-pill[data-state="down"]{background:#ba4a4a29;color:#f4abab;border-color:#d7686852}.status-pill[data-state="muted"],.status-pill[data-state="warning"]{background:#ad81382c;color:#f2d18c;border-color:#b19b6466}.status-pill[data-state="unknown"]{background:#ffffff0d;color:#a6b3bf;border-color:#ffffff25}.monitor-lower{margin-bottom:17px}.service-list{display:flex;flex-direction:column;gap:4px}.service-row{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:11px 5px;border-bottom:1px solid #ffffff0d}.service-row:last-child{border:0}.service-label{font-weight:600;font-size:.91rem;margin-bottom:3px}.service-sub{font-size:.77rem;color:#92a8b9;overflow-wrap:anywhere}.service-tail{display:flex;align-items:center;gap:10px;flex-shrink:0}.resource{margin:20px 0}.resource-title{display:flex;justify-content:space-between;font-size:.87rem}.resource-title strong{color:#acdfff}.meter{height:9px;background:#0d1924;border-radius:100px;overflow:hidden;margin-top:12px}.meter span{display:block;height:100%;width:0;background:linear-gradient(90deg,#368cbe,#85c8f4);border-radius:100px;transition:width .3s}.resource p{margin:9px 0 0}.info-note{font-size:.8rem;color:#abc4d4;padding:14px 15px;border:1px solid #4a73823f;background:#36536824;border-radius:11px;line-height:1.5}.mcp-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px}.mcp-item{border:1px solid #ffffff17;border-radius:11px;background:#ffffff08;padding:15px}.mcp-item strong{display:block;margin-bottom:10px}.mcp-item p{font-size:.74rem;color:#91a6b6;line-height:1.5;margin:10px 0 0}.pulse-dot{width:8px;height:8px;border-radius:50%;background:#5dda9f;display:inline-block;margin-right:7px} +@media(max-width:1120px){.monitor-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.mcp-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:780px){.admin-header{height:auto;padding:18px 0;flex-direction:column;align-items:flex-start}.admin-header nav{gap:3px}.admin-header nav a,.text-button{font-size:.8rem;padding:9px}.page-top{align-items:flex-start;flex-direction:column;gap:15px}.two-col{grid-template-columns:1fr}.restart-panel{flex-direction:column;align-items:flex-start}.monitor-grid{grid-template-columns:1fr}.mcp-grid{grid-template-columns:1fr 1fr}.metric-card{min-height:120px}}@media(prefers-reduced-motion:reduce){*{transition:none!important}} diff --git a/alfred/app/static/css/alfred-camera-popup.css b/alfred/app/static/css/alfred-camera-popup.css new file mode 100644 index 0000000..f122f17 --- /dev/null +++ b/alfred/app/static/css/alfred-camera-popup.css @@ -0,0 +1,89 @@ +/* ALFRED_CAMERA_POPUP_V1 : modal caméra, sans vignette sur l'accueil */ +.camera-popup-overlay[hidden] { display:none !important; } +.camera-popup-overlay { + position:fixed; + inset:0; + z-index:1000; + background:rgba(8,14,20,.78); + backdrop-filter:blur(8px); + -webkit-backdrop-filter:blur(8px); + display:grid; + place-items:center; + padding:20px; +} +.alfred-camera-dialog-open { overflow:hidden; } +.camera-popup-frame { + width:min(760px, 100%); + max-height:calc(100dvh - 40px); + overflow:auto; + background:linear-gradient(145deg,#2b343c,#1d242b); + border:1px solid #ffffff30; + border-radius:19px; + box-shadow:0 24px 75px #0009; + padding:18px; +} +.camera-popup-titlebar { + display:flex; + justify-content:space-between; + align-items:center; + gap:16px; + margin:0 0 12px; +} +.camera-popup-title { + font-size:1.15rem; + font-weight:600; + color:#edf2f6; +} +.camera-popup-close { + width:38px; + height:38px; + flex:0 0 38px; + border-radius:11px; + border:1px solid #ffffff28; + background:#ffffff10; + color:#eef3f7; + font-size:26px; + line-height:1; + cursor:pointer; +} +.camera-popup-close:hover { background:#ffffff21; } +.camera-popup-overlay .camera-panel.camera-popup-body { + position:relative; + display:block; + left:auto; + right:auto; + bottom:auto; + width:100%; + max-width:none; + margin:0; + overflow:hidden; + border-radius:12px; + background:#171f25; + box-shadow:none; + backdrop-filter:none; +} +.camera-popup-overlay .camera-viewport { + aspect-ratio:16 / 9; + width:100%; + min-height:180px; +} +.camera-popup-overlay .camera-viewport img { + width:100%; + height:100%; + object-fit:contain; +} +.camera-popup-overlay .panel-heading { min-height:46px; } +.camera-popup-footnote { + margin:11px 4px 0; + color:#b1bfc9; + font-size:.83rem; + line-height:1.45; +} +.camera-popup-close:focus-visible, +#cameraPopupButton:focus-visible { outline:2px solid #9ccbe7; outline-offset:3px; } +@media(max-width:760px) { + .camera-popup-overlay {padding:10px;} + .camera-popup-frame {width:100%;max-height:calc(100dvh - 20px);padding:12px;} + .camera-popup-title {font-size:1rem;} + .camera-popup-overlay .camera-panel.camera-popup-body {grid-row:auto;} +} diff --git a/alfred/app/static/js/alfred-admin.js b/alfred/app/static/js/alfred-admin.js new file mode 100644 index 0000000..eed0c22 --- /dev/null +++ b/alfred/app/static/js/alfred-admin.js @@ -0,0 +1,183 @@ +(() => { + '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'}; + 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); + } + } + 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/static/js/alfred-camera-popup.js b/alfred/app/static/js/alfred-camera-popup.js new file mode 100644 index 0000000..df3953f --- /dev/null +++ b/alfred/app/static/js/alfred-camera-popup.js @@ -0,0 +1,74 @@ +/* ALFRED_CAMERA_POPUP_V1 + * Utilise le pilote caméra MJPEG existant (alfred-camera.js), sans ouvrir + * un deuxième flux concurrent : ouverture = clic sur cameraToggle ; + * fermeture = arrêt immédiat du flux, libérant la webcam pour le MCP visage. + */ +(() => { + 'use strict'; + const trigger = document.getElementById('cameraPopupButton'); + const overlay = document.getElementById('cameraPopup'); + const closeButton = document.getElementById('cameraPopupClose'); + const toggle = document.getElementById('cameraToggle'); + if (!trigger || !overlay || !closeButton || !toggle) return; + let previousFocus = null; + + function active() { return toggle.getAttribute('aria-pressed') === 'true'; } + function opened() { return !overlay.hidden; } + function setExpanded(value) { trigger.setAttribute('aria-expanded', String(value)); } + + function openPopup() { + if (opened()) return; + previousFocus = document.activeElement; + overlay.hidden = false; + document.body.classList.add('alfred-camera-dialog-open'); + setExpanded(true); + closeButton.focus(); + // Réutilise la logique précédente pour accéder à /api/camera/stream. + if (!active()) toggle.click(); + } + + function closePopup() { + if (!opened()) return; + // L'ancienne implémentation du bouton ferme le flux MJPEG et retire src. + if (active()) toggle.click(); + // Garde-fou contre un éventuel gestionnaire caméra incomplet. + const feed = document.getElementById('cameraFeed'); + if (feed && feed.hasAttribute('src')) feed.removeAttribute('src'); + overlay.hidden = true; + document.body.classList.remove('alfred-camera-dialog-open'); + setExpanded(false); + if (previousFocus && document.contains(previousFocus) && typeof previousFocus.focus === 'function') { + previousFocus.focus(); + } else { + trigger.focus(); + } + previousFocus = null; + } + + trigger.addEventListener('click', () => opened() ? closePopup() : openPopup()); + closeButton.addEventListener('click', closePopup); + overlay.addEventListener('pointerdown', evt => { + if (evt.target === overlay) closePopup(); + }); + document.addEventListener('keydown', evt => { + if (!opened()) return; + if (evt.key === 'Escape') { + evt.preventDefault(); + closePopup(); + } else if (evt.key === 'Tab') { + // La page principale ne doit pas recevoir le focus derrière la modale. + const nodes = [...overlay.querySelectorAll('button:not([disabled]),a[href],input:not([disabled])')] + .filter(node => node.getClientRects().length); + if (!nodes.length) return; + const first = nodes[0], last = nodes[nodes.length - 1]; + if (evt.shiftKey && document.activeElement === first) { + evt.preventDefault(); last.focus(); + } else if (!evt.shiftKey && document.activeElement === last) { + evt.preventDefault(); first.focus(); + } + } + }); + // Le flux est également fermé par alfred-camera.js en cas de page masquée. + window.addEventListener('pagehide', () => { if (opened()) closePopup(); }); + setExpanded(false); +})(); diff --git a/alfred/app/templates/config.html b/alfred/app/templates/config.html new file mode 100644 index 0000000..9b49a47 --- /dev/null +++ b/alfred/app/templates/config.html @@ -0,0 +1,55 @@ + + + + +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 +
+
+

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/templates/index.html b/alfred/app/templates/index.html index 54bd6bc..517b3d8 100644 --- a/alfred/app/templates/index.html +++ b/alfred/app/templates/index.html @@ -9,6 +9,7 @@ +
@@ -20,11 +21,25 @@
+ + ⚙ + ▤ + + +
-
-

Bonjour.

À votre écoute.

-
- - - - - +

Le flux est interrompu à la fermeture pour laisser la webcam disponible aux autres fonctions d’Alfred.

+ + + +
+

Bonjour.

À votre écoute.

+
+ + + +