V1
This commit is contained in:
Binary file not shown.
@@ -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())
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1 @@
|
||||
{"password_hash": "scrypt:32768:8:1$q9lXd9HVYSjntQdD$8c3b6f13a94d499d31481a3a4f15046a71d26ebe65c6ed7d243f4a2c1916db7fdc5774a97d48ad3b2550ce06333ae5bc3ee225655825c097e3d65c0f28df9d1b"}
|
||||
Binary file not shown.
@@ -0,0 +1,2 @@
|
||||
# Configuration générale Alfred (page /config)
|
||||
# ALFRED_ALLOW_WEB_RESTART=true
|
||||
Binary file not shown.
@@ -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;}
|
||||
}
|
||||
@@ -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);
|
||||
})();
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user