This commit is contained in:
pporcheret
2026-10-26 01:38:47 +01:00
parent 93f504e7cc
commit 5ccb0a48c2
21 changed files with 1638 additions and 16 deletions
Binary file not shown.
+3
View File
@@ -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())
+422
View File
@@ -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/<path:entity_id>/snapshot')
def ha_camera_snapshot(entity_id):
try:
binary,mime=ha_views.camera_snapshot(entity_id)
return Response(binary,mimetype=mime,headers={'Cache-Control':'no-store','X-Content-Type-Options':'nosniff'})
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
@app.post('/api/ui/home')
def ui_home():
with STATE_LOCK:
STATE['content']={'type':'home','text':'À ton écoute.'}
socketio.emit('alfred_render',STATE['content'])
return jsonify({'ok':True})
@app.post("/api/audio-level")
def api_audio_level(): audio_level((request.get_json(force=True) or {}).get("level",0)); return jsonify({"ok":True})
@app.get("/api/wake/status")
def wake_status(): return jsonify(wake.status())
@app.post("/api/wake/start")
def wake_start(): wake.start(); return jsonify({"ok":True,**wake.status()})
@app.post("/api/wake/stop")
def wake_stop(): wake.stop(); return jsonify({"ok":True,**wake.status()})
@app.post("/api/tts/say")
def tts_say():
text=(request.get_json(silent=True) or {}).get("text",""); return jsonify({"ok":speak_async(text)})
if __name__ == "__main__":
if os.getenv("WAKE_ENABLED","true").lower() in ("1","true","yes","on") and not audio_settings.mic_muted(): wake.start()
socketio.run(app,host=os.getenv("FLASK_HOST","0.0.0.0"),port=int(os.getenv("FLASK_PORT","54321")),allow_unsafe_werkzeug=True)
+6 -6
View File
@@ -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"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 25 KiB

+1
View File
@@ -0,0 +1 @@
{"password_hash": "scrypt:32768:8:1$q9lXd9HVYSjntQdD$8c3b6f13a94d499d31481a3a4f15046a71d26ebe65c6ed7d243f4a2c1916db7fdc5774a97d48ad3b2550ce06333ae5bc3ee225655825c097e3d65c0f28df9d1b"}
Binary file not shown.
+2
View File
@@ -0,0 +1,2 @@
# Configuration générale Alfred (page /config)
# ALFRED_ALLOW_WEB_RESTART=true
+431
View File
@@ -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)
File diff suppressed because one or more lines are too long
@@ -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;}
}
+183
View File
@@ -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);
})();
@@ -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);
})();
+55
View File
@@ -0,0 +1,55 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#121b25"><title>Alfred · Configuration</title>
<link rel="stylesheet" href="/static/css/alfred-admin.css">
<script defer src="/static/js/alfred-admin.js"></script>
</head>
<body data-page="config">
<div class="admin-layout">
<header class="admin-header">
<div class="brand"><span class="brand-orb">A</span><div><strong>Alfred</strong><small>Centre de contrôle</small></div></div>
<nav aria-label="Navigation"><a href="/">Accueil</a><a class="selected" href="/config">Configuration</a><a href="/surveillance">Surveillance</a><button class="text-button" type="button" id="logoutButton">Déconnexion</button></nav>
</header>
<main class="admin-main" id="mainArea" hidden>
<div class="page-top"><div><p class="eyebrow">PERSONNALISATION</p><h1>Configuration</h1><p class="muted">Paramètres de l’assistant, voix et services. Les modifications prennent effet après redémarrage.</p></div><div class="page-actions"><span id="saveState" class="tag">Aucun changement</span><button id="saveConfig" class="button primary" type="button">Enregistrer</button></div></div>
<section class="notice" id="pendingNotice" hidden>Réglages enregistrés. <strong>Redémarre Alfred</strong> pour les appliquer.</section>
<div class="two-col">
<section class="panel"><div class="panel-title"><span class="panel-symbol">✦</span><div><h2>Identité et voix</h2><p>Nom affiché, mot d'activation et voix de synthèse</p></div></div>
<div class="field"><label for="ALFRED_NAME">Nom de l’assistant</label><input id="ALFRED_NAME" data-setting placeholder="Alfred" maxlength="30"><small>Visible dans l’interface après redémarrage.</small></div>
<div class="field"><label for="WAKE_WORD">Mot de réveil (Vosk)</label><input id="WAKE_WORD" data-setting placeholder="alfred" maxlength="22"><small>Un seul mot. Le modèle Vosk doit pouvoir le reconnaître ; le mode OpenWakeWord nécessite un modèle entraîné.</small></div>
<div class="field"><label for="PIPER_VOICE">Voix Wyoming Piper</label><input id="PIPER_VOICE" data-setting placeholder="fr_FR-tom-medium"><small>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.</small></div>
<div class="inline"><button class="button ghost" type="button" id="testVoice">Tester la voix actuelle</button><span class="muted microtext">Utilise les réglages déjà actifs, sans redémarrage</span></div>
</section>
<section class="panel"><div class="panel-title"><span class="panel-symbol">☁</span><div><h2>Illustrations météo</h2><p>Qualité, visages de référence et accessoires ComfyUI</p></div></div>
<div class="field"><label for="ALFRED_WEATHER_ILLUSTRATION_ATTEMPTS">Essais par personnage</label><select id="ALFRED_WEATHER_ILLUSTRATION_ATTEMPTS" data-setting><option value="1">1 — Rapide</option><option value="2">2 — Équilibré</option><option value="3">3 — Qualité</option></select></div>
<div class="field"><label for="ALFRED_WEATHER_ILLUSTRATION_TARGET_SCORE">Score qualité cible</label><div class="range-field"><input type="range" min="5" max="10" step="0.1" id="ALFRED_WEATHER_ILLUSTRATION_TARGET_SCORE" data-setting><output for="ALFRED_WEATHER_ILLUSTRATION_TARGET_SCORE" id="scoreOut">8.6 / 10</output></div></div>
<div class="field"><label for="ALFRED_WEATHER_DISPLAY_SIZE">Taille d’affichage</label><select id="ALFRED_WEATHER_DISPLAY_SIZE" data-setting><option value="250">250 px</option><option value="300">300 px</option><option value="350">350 px</option><option value="400">400 px</option><option value="450">450 px</option><option value="500">500 px</option></select></div>
<div class="field switch-field"><label for="ALFRED_WEATHER_REVIEW">Analyse automatique des candidats <small>Nécessite un workflow vision compatible</small></label><input id="ALFRED_WEATHER_REVIEW" type="checkbox" data-setting></div>
<div class="field"><label for="ALFRED_WEATHER_REFERENCE_MODE">Références des visages</label><select id="ALFRED_WEATHER_REFERENCE_MODE" data-setting><option value="auto">Auto — avec repli</option><option value="required">Obligatoires</option><option value="off">Désactivées</option></select></div>
<div class="field"><label for="ALFRED_WEATHER_REFERENCE_DENOISE">Liberté de modification des vêtements</label><div class="range-field"><input id="ALFRED_WEATHER_REFERENCE_DENOISE" data-setting type="range" min="0.25" max="0.85" step="0.01"><output id="denoiseOut">0.56</output></div><small>Une valeur élevée modifie davantage la tenue, mais risque de changer le visage.</small></div>
<div class="field switch-field"><label for="ALFRED_WEATHER_PRESERVE_FACE">Préserver les visages <small>Lorsque l’image de référence est utilisable</small></label><input id="ALFRED_WEATHER_PRESERVE_FACE" type="checkbox" data-setting></div>
<div class="field"><label for="ALFRED_WEATHER_ACCESSORY_LIMIT">Accessoires visibles par personnage</label><select id="ALFRED_WEATHER_ACCESSORY_LIMIT" data-setting><option value="0">Aucun</option><option value="1">1 — Recommandé</option><option value="2">2</option></select></div>
</section>
<section class="panel"><div class="panel-title"><span class="panel-symbol">♫</span><div><h2>Audio en direct</h2><p>Volume, sensibilité et coupures immédiates</p></div></div>
<div class="field"><label for="liveVolume">Volume de sortie</label><div class="range-field"><input id="liveVolume" type="range" min="0" max="100" step="1"><output id="liveVolumeOut">—</output></div></div>
<div class="field"><label for="liveMic">Sensibilité micro</label><div class="range-field"><input id="liveMic" type="range" min="0" max="100" step="1"><output id="liveMicOut">—</output></div></div>
<div class="field switch-field"><label for="muteMicro">Microphone coupé</label><input id="muteMicro" type="checkbox"></div>
<div class="field switch-field"><label for="muteSpeaker">Haut-parleur coupé</label><input id="muteSpeaker" type="checkbox"></div>
<p class="muted microtext" id="audioResult">Les réglages audio sont appliqués sans redémarrage.</p>
</section>
<section class="panel"><div class="panel-title"><span class="panel-symbol">⌘</span><div><h2>Connexions et météo</h2><p>Adresses des serveurs et source météo</p></div></div>
<div class="field"><label for="ALFRED_COMFYUI_URL">Serveur ComfyUI</label><input data-setting id="ALFRED_COMFYUI_URL" placeholder="http://192.168.1.13:8188"><small>Laisse vide pour conserver la configuration Hermes existante.</small></div>
<div class="field"><label for="ALFRED_OLLAMA_URL">Serveur Ollama (surveillance)</label><input data-setting id="ALFRED_OLLAMA_URL" placeholder="http://192.168.1.13:11434"><small>Cette adresse sert au contrôle de disponibilité ; Hermes garde sa propre configuration LLM.</small></div>
<div class="field"><label for="HA_WEATHER_ENTITY">Entité météo Home Assistant</label><input data-setting id="HA_WEATHER_ENTITY" placeholder="weather.forecast_home"><small>Exemple : weather.forecast_home. Le jeton Home Assistant n’est pas exposé ici.</small></div>
<a href="/surveillance" class="button ghost inline-link">Voir la surveillance des services</a>
</section>
</div>
<section class="panel restart-panel"><div><h2>Maintenance</h2><p class="muted">Redémarrer Alfred pour appliquer les paramètres enregistrés. Action protégée par mot de passe administrateur.</p><span class="microtext muted">Le bouton requiert une autorisation systemd explicitement activée.</span></div><button class="button danger" type="button" id="restartButton">Redémarrer Alfred</button></section>
<p role="status" aria-live="polite" class="message" id="message"></p>
</main>
<div class="auth-screen" id="authScreen"><div class="auth-box"><span class="brand-orb big">A</span><h1>Accès administrateur</h1><p>Connecte-toi pour modifier la configuration d'Alfred.</p><form id="loginForm"><label for="adminPassword">Mot de passe</label><input type="password" id="adminPassword" autocomplete="current-password" required><button class="button primary full" type="submit">Se connecter</button></form><p class="message" id="loginMessage" role="status"></p><small>Accès local / réseau privé uniquement.</small></div></div>
</div>
</body>
</html>
+37 -10
View File
@@ -9,6 +9,7 @@
<link rel="stylesheet" href="/static/css/alfred-controls.css">
<link rel="stylesheet" href="/static/css/alfred-views.css">
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
<link rel="stylesheet" href="/static/css/alfred-camera-popup.css">
</head>
<body>
<main class="alfred-shell">
@@ -20,11 +21,25 @@
</div>
<div class="header-actions" aria-label="Navigation et commandes audio">
<!-- ALFRED_ADMIN_NAV_V1 : pages d’administration séparées -->
<a class="icon-button" style="text-decoration:none" href="/config" title="Configuration complète" aria-label="Configuration complète">⚙</a>
<a class="icon-button" style="text-decoration:none" href="/surveillance" title="Surveillance des services" aria-label="Surveillance des services">▤</a>
<button id="viewHome" class="icon-button" type="button" title="Accueil" aria-label="Accueil">⌂</button>
<button id="viewCalendar" class="icon-button" type="button" title="Agenda" aria-label="Agenda">▦</button>
<button id="viewWeather" class="icon-button" type="button" title="Météo Home Assistant" aria-label="Météo Home Assistant">☀</button>
<button id="viewTemperatures" class="icon-button" type="button" title="Températures" aria-label="Températures">°</button>
<button id="viewCameras" class="icon-button" type="button" title="Caméras Home Assistant" aria-label="Caméras Home Assistant">▣</button>
<!-- ALFRED_CAMERA_POPUP_V1 -->
<button id="cameraPopupButton" class="icon-button" type="button"
title="Afficher la caméra" aria-label="Afficher la caméra"
aria-haspopup="dialog" aria-controls="cameraPopup" aria-expanded="false">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="2.5" y="5" width="14" height="14" rx="3"/>
<path d="m16.5 10 5-3v10l-5-3"/>
</svg>
</button>
<button id="audioSettingsButton" class="icon-button" type="button"
aria-label="Réglages audio et caméra" aria-haspopup="dialog" aria-expanded="false"
aria-controls="audioSettingsPanel" title="Réglages audio et caméra">
@@ -80,7 +95,7 @@
<p id="audioSettingsMessage" class="settings-message" role="status" aria-live="polite"></p>
</div>
<div id="cameraSettingsSection" role="tabpanel" aria-labelledby="cameraTab" hidden>
<p class="camera-settings-note">Webcam locale · réglages matériels V4L2.<br>Tu peux voir le résultat dans l’aperçu « CAMÉRA » en bas à gauche.</p>
<p class="camera-settings-note">Webcam locale · réglages matériels V4L2.<br>Tu peux voir le résultat via le bouton Caméra en haut de l’écran.</p>
<div id="cameraSettingsItems" class="camera-settings-items" aria-live="off"></div>
<div class="camera-settings-actions">
<button id="cameraSettingsReload" class="camera-settings-action" type="button">Actualiser</button>
@@ -90,15 +105,15 @@
</div>
</div>
<section id="stage" aria-label="Réponse d'Alfred">
<div id="content"><h1>Bonjour.</h1><p>À votre écoute.</p></div>
</section>
<section id="wave" aria-hidden="true"><canvas id="waveCanvas"></canvas></section>
<!-- Caméra locale : désactivée tant que l'utilisateur ne l'ouvre pas,
pour éviter de monopoliser la webcam utilisée par le MCP visage. -->
<aside class="camera-panel" aria-label="Prévisualisation caméra">
<div id="cameraPopup" class="camera-popup-overlay" role="dialog"
aria-label="Prévisualisation caméra" aria-modal="true" hidden>
<div class="camera-popup-frame">
<div class="camera-popup-titlebar">
<strong class="camera-popup-title">Caméra en direct</strong>
<button id="cameraPopupClose" class="camera-popup-close" type="button"
aria-label="Fermer la caméra" title="Fermer">×</button>
</div>
<aside class="camera-panel camera-popup-body" aria-label="Prévisualisation caméra">
<div class="panel-heading">
<span class="panel-title"><span class="panel-indicator"></span> CAMÉRA</span>
<button id="cameraToggle" class="panel-action" type="button" aria-pressed="false">Activer</button>
@@ -113,6 +128,17 @@
</div>
</div>
</aside>
<p class="camera-popup-footnote">Le flux est interrompu à la fermeture pour laisser la webcam disponible aux autres fonctions d’Alfred.</p>
</div>
</div>
<section id="stage" aria-label="Réponse d'Alfred">
<div id="content"><h1>Bonjour.</h1><p>À votre écoute.</p></div>
</section>
<section id="wave" aria-hidden="true"><canvas id="waveCanvas"></canvas></section>
<!-- Affiche les résultats partiels ET définitifs de la reconnaissance Vosk. -->
<aside class="transcript-panel" aria-label="Transcription vocale">
@@ -129,5 +155,6 @@
<script src="/static/js/alfred-controls.js"></script>
<script src="/static/js/alfred-camera.js"></script>
<script src="/static/js/alfred-camera-settings.js"></script>
<script src="/static/js/alfred-camera-popup.js"></script>
</body>
</html>
@@ -0,0 +1,133 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#202326">
<title>{{ name }}</title>
<link rel="stylesheet" href="/static/css/alfred.css">
<link rel="stylesheet" href="/static/css/alfred-controls.css">
<link rel="stylesheet" href="/static/css/alfred-views.css">
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
</head>
<body>
<main class="alfred-shell">
<header class="topbar">
<!-- Le statut remplace l'ancien titre « Alfred ». -->
<div class="status" role="status" aria-live="polite">
<span id="statusDot" class="status-dot" data-mode="idle" aria-hidden="true"></span>
<span id="mode" data-mode="idle">En attente</span>
</div>
<div class="header-actions" aria-label="Navigation et commandes audio">
<button id="viewHome" class="icon-button" type="button" title="Accueil" aria-label="Accueil">⌂</button>
<button id="viewCalendar" class="icon-button" type="button" title="Agenda" aria-label="Agenda">▦</button>
<button id="viewWeather" class="icon-button" type="button" title="Météo Home Assistant" aria-label="Météo Home Assistant">☀</button>
<button id="viewTemperatures" class="icon-button" type="button" title="Températures" aria-label="Températures">°</button>
<button id="viewCameras" class="icon-button" type="button" title="Caméras Home Assistant" aria-label="Caméras Home Assistant">▣</button>
<button id="audioSettingsButton" class="icon-button" type="button"
aria-label="Réglages audio et caméra" aria-haspopup="dialog" aria-expanded="false"
aria-controls="audioSettingsPanel" title="Réglages audio et caméra">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4 7h16M4 17h16"/><circle cx="9" cy="7" r="2.5" fill="#272b30"/><circle cx="16" cy="17" r="2.5" fill="#272b30"/>
</svg>
</button>
<button id="micMute" class="icon-button" type="button"
aria-label="Couper le microphone" aria-pressed="false" title="Couper le microphone">
<svg class="mic-on-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="9" y="2.5" width="6" height="12" rx="3"/>
<path d="M5.5 10.5v1.5a6.5 6.5 0 0 0 13 0v-1.5M12 18.5v3M8 21.5h8"/>
</svg>
<svg class="mic-off-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M9.2 5.3V5.5a3 3 0 0 1 5.5-1.4M15 10.6v.9c0 .5-.1 1-.3 1.5M9 9.5v2A3 3 0 0 0 13.6 14"/>
<path d="M5.5 10.5V12a6.5 6.5 0 0 0 11.5 4.1M12 18.5v3M8 21.5h8M3 3l18 18"/>
</svg>
</button>
<button id="speakerMuteQuick" class="icon-button" type="button"
aria-label="Couper le son" aria-pressed="false" title="Couper le son">
<svg class="speaker-on-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m11 5-5 4H3v6h3l5 4V5Z"/><path d="M15 9a4 4 0 0 1 0 6M18 6a8 8 0 0 1 0 12"/>
</svg>
<svg class="speaker-off-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m11 5-5 4H3v6h3l5 4V5Z"/><path d="m16 9 5 6m0-6-5 6"/>
</svg>
</button>
</div>
</header>
<!-- Pop-up : commandes connectées à /api/audio/settings -->
<div id="audioSettingsPanel" class="audio-popover" role="dialog"
aria-label="Réglages audio et caméra" aria-modal="false" hidden>
<div class="popover-heading">
<strong>Réglages</strong>
<button id="closeAudioSettings" class="icon-button mini" type="button" aria-label="Fermer les réglages">×</button>
</div>
<div class="settings-tabs" role="tablist" aria-label="Catégories de réglages">
<button id="audioTab" class="settings-tab" type="button" role="tab" aria-selected="true" aria-controls="audioSettingsSection">Audio</button>
<button id="cameraTab" class="settings-tab" type="button" role="tab" aria-selected="false" aria-controls="cameraSettingsSection">Caméra</button>
</div>
<div id="audioSettingsSection" role="tabpanel" aria-labelledby="audioTab">
<div class="setting-row">
<label for="volumeSlider">Volume du haut-parleur</label>
<output id="volumeValue" for="volumeSlider">70 %</output>
</div>
<input id="volumeSlider" type="range" min="0" max="100" value="70" step="1">
<div class="setting-row">
<label for="micGainSlider">Sensibilité du microphone</label>
<output id="micGainValue" for="micGainSlider">—</output>
</div>
<input id="micGainSlider" type="range" min="0" max="100" value="50" step="1">
<p id="audioSettingsMessage" class="settings-message" role="status" aria-live="polite"></p>
</div>
<div id="cameraSettingsSection" role="tabpanel" aria-labelledby="cameraTab" hidden>
<p class="camera-settings-note">Webcam locale · réglages matériels V4L2.<br>Tu peux voir le résultat dans l’aperçu « CAMÉRA » en bas à gauche.</p>
<div id="cameraSettingsItems" class="camera-settings-items" aria-live="off"></div>
<div class="camera-settings-actions">
<button id="cameraSettingsReload" class="camera-settings-action" type="button">Actualiser</button>
<button id="cameraSettingsReset" class="camera-settings-action reset" type="button">Valeurs d’usine</button>
</div>
<p id="cameraSettingsMessage" class="settings-message" role="status" aria-live="polite"></p>
</div>
</div>
<section id="stage" aria-label="Réponse d'Alfred">
<div id="content"><h1>Bonjour.</h1><p>À votre écoute.</p></div>
</section>
<section id="wave" aria-hidden="true"><canvas id="waveCanvas"></canvas></section>
<!-- Caméra locale : désactivée tant que l'utilisateur ne l'ouvre pas,
pour éviter de monopoliser la webcam utilisée par le MCP visage. -->
<aside class="camera-panel" aria-label="Prévisualisation caméra">
<div class="panel-heading">
<span class="panel-title"><span class="panel-indicator"></span> CAMÉRA</span>
<button id="cameraToggle" class="panel-action" type="button" aria-pressed="false">Activer</button>
</div>
<div class="camera-viewport">
<img id="cameraFeed" alt="Vidéo en direct de la caméra d'Alfred" hidden>
<div id="cameraPlaceholder" class="camera-placeholder">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" aria-hidden="true">
<rect x="2.5" y="5" width="14" height="14" rx="3"/><path d="m16.5 10 5-3v10l-5-3"/>
</svg>
<span id="cameraMessage">Caméra désactivée</span>
</div>
</div>
</aside>
<!-- Affiche les résultats partiels ET définitifs de la reconnaissance Vosk. -->
<aside class="transcript-panel" aria-label="Transcription vocale">
<div class="panel-heading">
<span class="panel-title"><span class="panel-indicator stt-indicator"></span> RECONNAISSANCE VOCALE · VOSK</span>
<span id="transcriptState" class="transcript-state">En attente</span>
</div>
<p id="transcriptText" class="transcript-text" aria-live="polite">La dernière phrase reconnue s'affichera ici.</p>
</aside>
</main>
<script src="/static/js/alfred.js"></script>
<script src="/static/js/alfred-views.js"></script>
<script src="/static/js/alfred-controls.js"></script>
<script src="/static/js/alfred-camera.js"></script>
<script src="/static/js/alfred-camera-settings.js"></script>
</body>
</html>
@@ -0,0 +1,136 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#202326">
<title>{{ name }}</title>
<link rel="stylesheet" href="/static/css/alfred.css">
<link rel="stylesheet" href="/static/css/alfred-controls.css">
<link rel="stylesheet" href="/static/css/alfred-views.css">
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
</head>
<body>
<main class="alfred-shell">
<header class="topbar">
<!-- Le statut remplace l'ancien titre « Alfred ». -->
<div class="status" role="status" aria-live="polite">
<span id="statusDot" class="status-dot" data-mode="idle" aria-hidden="true"></span>
<span id="mode" data-mode="idle">En attente</span>
</div>
<div class="header-actions" aria-label="Navigation et commandes audio">
<!-- ALFRED_ADMIN_NAV_V1 : pages d’administration séparées -->
<a class="icon-button" style="text-decoration:none" href="/config" title="Configuration complète" aria-label="Configuration complète">⚙</a>
<a class="icon-button" style="text-decoration:none" href="/surveillance" title="Surveillance des services" aria-label="Surveillance des services">▤</a>
<button id="viewHome" class="icon-button" type="button" title="Accueil" aria-label="Accueil">⌂</button>
<button id="viewCalendar" class="icon-button" type="button" title="Agenda" aria-label="Agenda">▦</button>
<button id="viewWeather" class="icon-button" type="button" title="Météo Home Assistant" aria-label="Météo Home Assistant">☀</button>
<button id="viewTemperatures" class="icon-button" type="button" title="Températures" aria-label="Températures">°</button>
<button id="viewCameras" class="icon-button" type="button" title="Caméras Home Assistant" aria-label="Caméras Home Assistant">▣</button>
<button id="audioSettingsButton" class="icon-button" type="button"
aria-label="Réglages audio et caméra" aria-haspopup="dialog" aria-expanded="false"
aria-controls="audioSettingsPanel" title="Réglages audio et caméra">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4 7h16M4 17h16"/><circle cx="9" cy="7" r="2.5" fill="#272b30"/><circle cx="16" cy="17" r="2.5" fill="#272b30"/>
</svg>
</button>
<button id="micMute" class="icon-button" type="button"
aria-label="Couper le microphone" aria-pressed="false" title="Couper le microphone">
<svg class="mic-on-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="9" y="2.5" width="6" height="12" rx="3"/>
<path d="M5.5 10.5v1.5a6.5 6.5 0 0 0 13 0v-1.5M12 18.5v3M8 21.5h8"/>
</svg>
<svg class="mic-off-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M9.2 5.3V5.5a3 3 0 0 1 5.5-1.4M15 10.6v.9c0 .5-.1 1-.3 1.5M9 9.5v2A3 3 0 0 0 13.6 14"/>
<path d="M5.5 10.5V12a6.5 6.5 0 0 0 11.5 4.1M12 18.5v3M8 21.5h8M3 3l18 18"/>
</svg>
</button>
<button id="speakerMuteQuick" class="icon-button" type="button"
aria-label="Couper le son" aria-pressed="false" title="Couper le son">
<svg class="speaker-on-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m11 5-5 4H3v6h3l5 4V5Z"/><path d="M15 9a4 4 0 0 1 0 6M18 6a8 8 0 0 1 0 12"/>
</svg>
<svg class="speaker-off-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m11 5-5 4H3v6h3l5 4V5Z"/><path d="m16 9 5 6m0-6-5 6"/>
</svg>
</button>
</div>
</header>
<!-- Pop-up : commandes connectées à /api/audio/settings -->
<div id="audioSettingsPanel" class="audio-popover" role="dialog"
aria-label="Réglages audio et caméra" aria-modal="false" hidden>
<div class="popover-heading">
<strong>Réglages</strong>
<button id="closeAudioSettings" class="icon-button mini" type="button" aria-label="Fermer les réglages">×</button>
</div>
<div class="settings-tabs" role="tablist" aria-label="Catégories de réglages">
<button id="audioTab" class="settings-tab" type="button" role="tab" aria-selected="true" aria-controls="audioSettingsSection">Audio</button>
<button id="cameraTab" class="settings-tab" type="button" role="tab" aria-selected="false" aria-controls="cameraSettingsSection">Caméra</button>
</div>
<div id="audioSettingsSection" role="tabpanel" aria-labelledby="audioTab">
<div class="setting-row">
<label for="volumeSlider">Volume du haut-parleur</label>
<output id="volumeValue" for="volumeSlider">70 %</output>
</div>
<input id="volumeSlider" type="range" min="0" max="100" value="70" step="1">
<div class="setting-row">
<label for="micGainSlider">Sensibilité du microphone</label>
<output id="micGainValue" for="micGainSlider">—</output>
</div>
<input id="micGainSlider" type="range" min="0" max="100" value="50" step="1">
<p id="audioSettingsMessage" class="settings-message" role="status" aria-live="polite"></p>
</div>
<div id="cameraSettingsSection" role="tabpanel" aria-labelledby="cameraTab" hidden>
<p class="camera-settings-note">Webcam locale · réglages matériels V4L2.<br>Tu peux voir le résultat dans l’aperçu « CAMÉRA » en bas à gauche.</p>
<div id="cameraSettingsItems" class="camera-settings-items" aria-live="off"></div>
<div class="camera-settings-actions">
<button id="cameraSettingsReload" class="camera-settings-action" type="button">Actualiser</button>
<button id="cameraSettingsReset" class="camera-settings-action reset" type="button">Valeurs d’usine</button>
</div>
<p id="cameraSettingsMessage" class="settings-message" role="status" aria-live="polite"></p>
</div>
</div>
<section id="stage" aria-label="Réponse d'Alfred">
<div id="content"><h1>Bonjour.</h1><p>À votre écoute.</p></div>
</section>
<section id="wave" aria-hidden="true"><canvas id="waveCanvas"></canvas></section>
<!-- Caméra locale : désactivée tant que l'utilisateur ne l'ouvre pas,
pour éviter de monopoliser la webcam utilisée par le MCP visage. -->
<aside class="camera-panel" aria-label="Prévisualisation caméra">
<div class="panel-heading">
<span class="panel-title"><span class="panel-indicator"></span> CAMÉRA</span>
<button id="cameraToggle" class="panel-action" type="button" aria-pressed="false">Activer</button>
</div>
<div class="camera-viewport">
<img id="cameraFeed" alt="Vidéo en direct de la caméra d'Alfred" hidden>
<div id="cameraPlaceholder" class="camera-placeholder">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" aria-hidden="true">
<rect x="2.5" y="5" width="14" height="14" rx="3"/><path d="m16.5 10 5-3v10l-5-3"/>
</svg>
<span id="cameraMessage">Caméra désactivée</span>
</div>
</div>
</aside>
<!-- Affiche les résultats partiels ET définitifs de la reconnaissance Vosk. -->
<aside class="transcript-panel" aria-label="Transcription vocale">
<div class="panel-heading">
<span class="panel-title"><span class="panel-indicator stt-indicator"></span> RECONNAISSANCE VOCALE · VOSK</span>
<span id="transcriptState" class="transcript-state">En attente</span>
</div>
<p id="transcriptText" class="transcript-text" aria-live="polite">La dernière phrase reconnue s'affichera ici.</p>
</aside>
</main>
<script src="/static/js/alfred.js"></script>
<script src="/static/js/alfred-views.js"></script>
<script src="/static/js/alfred-controls.js"></script>
<script src="/static/js/alfred-camera.js"></script>
<script src="/static/js/alfred-camera-settings.js"></script>
</body>
</html>
+19
View File
@@ -0,0 +1,19 @@
<!doctype html><html lang="fr"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="theme-color" content="#121b25"><title>Alfred · Surveillance</title><link rel="stylesheet" href="/static/css/alfred-admin.css"><script defer src="/static/js/alfred-admin.js"></script></head>
<body data-page="surveillance">
<div class="admin-layout">
<header class="admin-header"><div class="brand"><span class="brand-orb">A</span><div><strong>Alfred</strong><small>Centre de contrôle</small></div></div><nav aria-label="Navigation"><a href="/">Accueil</a><a href="/config">Configuration</a><a href="/surveillance" class="selected">Surveillance</a><button class="text-button" type="button" id="logoutButton">Déconnexion</button></nav></header>
<main class="admin-main" id="mainArea" hidden>
<div class="page-top"><div><p class="eyebrow">DIAGNOSTIC</p><h1>Surveillance</h1><p class="muted">État des périphériques, de l’assistant et des services locaux.</p></div><div class="page-actions"><span class="tag"><span class="pulse-dot"></span><span id="updatedAt">Chargement…</span></span><button class="button ghost" type="button" id="refreshHealth">Actualiser</button></div></div>
<section class="monitor-grid">
<article class="panel metric-card" id="cameraCard"><div class="metric-icon">▣</div><div class="metric-info"><span class="microtext muted">CAMÉRA</span><strong>Webcam</strong><span class="status-pill" id="cameraStatus">—</span><p id="cameraDetail" class="muted">Vérification…</p></div></article>
<article class="panel metric-card" id="microCard"><div class="metric-icon">◉</div><div class="metric-info"><span class="microtext muted">MICROPHONE</span><strong>Capture audio</strong><span class="status-pill" id="microStatus">—</span><p id="microDetail" class="muted">Vérification…</p></div></article>
<article class="panel metric-card" id="speakerCard"><div class="metric-icon">♫</div><div class="metric-info"><span class="microtext muted">HAUT-PARLEUR</span><strong>Sortie audio</strong><span class="status-pill" id="speakerStatus">—</span><p id="speakerDetail" class="muted">Vérification…</p></div></article>
<article class="panel metric-card"><div class="metric-icon">◌</div><div class="metric-info"><span class="microtext muted">MOT DE RÉVEIL</span><strong id="wakeLabel">Vosk</strong><span class="status-pill" id="wakeStatus">—</span><p id="wakeDetail" class="muted">Vérification…</p></div></article>
</section>
<div class="two-col monitor-lower"><section class="panel"><div class="panel-title"><span class="panel-symbol">⇄</span><div><h2>Services et connexions</h2><p>Tests réels : HTTP, port TCP ou présence du binaire</p></div></div><div class="service-list" id="serviceList"><p class="muted">Chargement…</p></div></section>
<section class="panel"><div class="panel-title"><span class="panel-symbol">▤</span><div><h2>Système</h2><p>Ressources du mini-PC hébergeant Alfred</p></div></div><div class="resource"><div class="resource-title"><span>Disque de l’application</span><strong id="diskPercent">—</strong></div><div class="meter"><span id="diskBar"></span></div><p id="diskText" class="muted microtext">—</p></div><div class="resource"><div class="resource-title"><span>Mémoire RAM</span><strong id="memPercent">—</strong></div><div class="meter"><span id="memBar"></span></div><p id="memText" class="muted microtext">—</p></div><div class="resource"><div class="resource-title"><span>Charge système (1 min)</span><strong id="loadAvg">—</strong></div></div><div class="info-note">Les contrôles de caméra n'ouvrent pas le flux vidéo, afin de ne pas bloquer la reconnaissance faciale.</div></section></div>
<section class="panel"><div class="panel-title"><span class="panel-symbol">⚙</span><div><h2>MCP Hermes détectés</h2><p>Présence des répertoires, pas un test de communication des outils MCP</p></div></div><div class="mcp-grid" id="mcpList"><p class="muted">Chargement…</p></div></section>
<p role="status" aria-live="polite" class="message" id="message"></p>
</main>
<div class="auth-screen" id="authScreen"><div class="auth-box"><span class="brand-orb big">A</span><h1>Accès administrateur</h1><p>Connecte-toi pour consulter l'état d'Alfred.</p><form id="loginForm"><label for="adminPassword">Mot de passe</label><input type="password" id="adminPassword" autocomplete="current-password" required><button class="button primary full" type="submit">Se connecter</button></form><p class="message" id="loginMessage" role="status"></p><small>Accès local / réseau privé uniquement.</small></div></div>
</div></body></html>
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env python3
"""Provision a password for Alfred's local admin pages (never in shell history)."""
import getpass
import json
import os
import secrets
from pathlib import Path
from werkzeug.security import generate_password_hash
ROOT = Path(__file__).resolve().parents[1]
AUTH_FILE = ROOT / 'config' / 'admin_auth.json'
SECRET_FILE = ROOT / 'config' / 'admin_secret.key'
def atomic_bytes(path, content):
path.parent.mkdir(parents=True, exist_ok=True)
temp = path.with_name(path.name + '.tmp')
fd = os.open(temp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
try:
with os.fdopen(fd, 'wb') as stream:
stream.write(content)
stream.flush()
os.fsync(stream.fileno())
os.replace(temp, path)
os.chmod(path, 0o600)
finally:
temp.unlink(missing_ok=True)
def main():
if not SECRET_FILE.exists():
atomic_bytes(SECRET_FILE, secrets.token_bytes(32))
password = getpass.getpass('Nouveau mot de passe administrateur Alfred : ')
confirm = getpass.getpass('Confirmer le mot de passe : ')
if password != confirm or len(password) < 12:
raise SystemExit('Mot de passe non confirmé ou inférieur à 12 caractères.')
encoded = json.dumps({'password_hash': generate_password_hash(password, method='scrypt')}).encode('utf-8')
atomic_bytes(AUTH_FILE, encoded)
print('Mot de passe administrateur enregistré. Les anciennes sessions sont invalidées.')
if __name__ == '__main__':
main()