v2.2 - Small improvement Interface change
This commit is contained in:
@@ -0,0 +1,544 @@
|
||||
import os, threading, hmac
|
||||
from datetime import datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
from flask import Flask, render_template, request, jsonify, Response
|
||||
from flask_socketio import SocketIO
|
||||
from dotenv import load_dotenv
|
||||
from services.audio import listen_once, speak_async, tts_backend_detect
|
||||
from services.wakeword import WakeWordListener
|
||||
from services import admin_console # ALFRED_ADMIN_PAGES_V1
|
||||
from services.hermes_client import ask_hermes
|
||||
from services import alfred_timers, alfred_context_images # ALFRED_TIMERS_CONTEXT_V1
|
||||
from services import alfred_features # ALFRED_FEATURES_V45
|
||||
from services import audio_settings # ALFRED_AUDIO_SETTINGS_V1
|
||||
from services.camera import video_stream
|
||||
from services import ha_views, alfred_views_controller
|
||||
from services import camera_settings, ha_weather, weather_outfits
|
||||
|
||||
load_dotenv()
|
||||
|
||||
load_dotenv(os.path.join(os.path.dirname(__file__), "config", "control_center.env"), override=True) # ALFRED_ADMIN_PAGES_V1
|
||||
##
|
||||
from services.audio import get_vosk_model
|
||||
print("[ALFRED] Préchargement des modèles vocaux...", flush=True)
|
||||
get_vosk_model(os.environ["WAKE_VOSK_MODEL_PATH"])
|
||||
get_vosk_model(os.environ["VOSK_MODEL_PATH"])
|
||||
print("[ALFRED] Modèles vocaux prêts.", flush=True)
|
||||
##
|
||||
app=Flask(__name__); socketio=SocketIO(app,cors_allowed_origins="*")
|
||||
STATE={"mode":"idle","title":"Alfred","content":{"type":"text","text":"À votre écoute."},"partial":""}
|
||||
STATE_LOCK = threading.RLock()
|
||||
IMAGE_REVISION = 0 # Incrémenté à chaque image envoyée par ComfyUI.
|
||||
conversation_lock=threading.Lock()
|
||||
mic_stop_event=threading.Event()
|
||||
# Service temps autonome (survit aux conversations Hermes)
|
||||
alfred_timer_store = alfred_timers.register(app, socketio, speak_async, audio_settings) # ALFRED_TIMERS_CONTEXT_V1
|
||||
audio_settings.initialize()
|
||||
audio_settings.update_settings({"mic_gain": 30})
|
||||
# Recharge les réglages V4L2 enregistrés, sans interrompre Alfred si la webcam est absente.
|
||||
camera_settings.restore_saved_settings()
|
||||
|
||||
def emit_state(mode=None, **extra):
|
||||
with STATE_LOCK:
|
||||
if mode: STATE["mode"] = mode
|
||||
STATE.update(extra)
|
||||
socketio.emit("alfred_state", dict(STATE))
|
||||
|
||||
def audio_level(level): socketio.emit("audio_level",{"level":max(0,min(float(level),1))})
|
||||
|
||||
def conversation(score=1.0):
|
||||
if not conversation_lock.acquire(blocking=False):
|
||||
return
|
||||
|
||||
try:
|
||||
emit_state("listening", partial="", transcript="", wake_score=score)
|
||||
|
||||
print("[ALFRED] Début écoute STT", flush=True)
|
||||
|
||||
r = listen_once(
|
||||
stop_event=mic_stop_event,
|
||||
on_partial=lambda p: emit_state("listening", partial=p),
|
||||
on_level=audio_level
|
||||
)
|
||||
|
||||
print(f"[ALFRED] Résultat STT : {r.text!r}, erreur : {r.error!r}", flush=True)
|
||||
|
||||
if audio_settings.mic_muted():
|
||||
emit_state("idle", partial="")
|
||||
return
|
||||
|
||||
if r.error:
|
||||
emit_state(
|
||||
"error",
|
||||
content={"type": "text", "text": r.error}
|
||||
)
|
||||
return
|
||||
|
||||
if not r.text:
|
||||
emit_state("idle", partial="")
|
||||
return
|
||||
|
||||
emit_state(
|
||||
"thinking",
|
||||
partial="",
|
||||
transcript=r.text,
|
||||
content={"type": "text", "text": r.text}
|
||||
)
|
||||
|
||||
# Vue dynamique : commandes déterministes exécutées sans faire
|
||||
# dépendre l'affichage des capacités ou sessions Hermes.
|
||||
visual = None
|
||||
try:
|
||||
visual = alfred_views_controller.route(r.text, (STATE.get('content') or {}).get('type'))
|
||||
except (ha_views.HAError, ValueError) as exc:
|
||||
print(f"[ALFRED] Vue indisponible : {exc}", flush=True)
|
||||
visual = ({"type":"text", "text":str(exc)}, str(exc))
|
||||
|
||||
if visual is not None:
|
||||
content, speech = visual
|
||||
with STATE_LOCK:
|
||||
STATE["content"] = content
|
||||
socketio.emit("alfred_render", content)
|
||||
if speech and not audio_settings.speaker_muted():
|
||||
emit_state("speaking")
|
||||
done = threading.Event()
|
||||
speak_async(speech, on_end=done.set)
|
||||
done.wait(timeout=120)
|
||||
emit_state("idle", partial="")
|
||||
return
|
||||
|
||||
# ALFRED_LISTES_GLASS_V1_VOICE_START
|
||||
# Commandes simples garanties locales ; jamais de confirmation inventée par Hermes.
|
||||
try:
|
||||
_lists_reply = _alfred_handle_list_voice(
|
||||
r.text, notify=lambda: socketio.emit('alfred_lists_changed', {'source':'voice'}))
|
||||
except Exception as _lists_error:
|
||||
print('[ALFRED LISTES] Erreur :', _lists_error, flush=True)
|
||||
_lists_reply = 'Je n’ai pas pu modifier la liste. Vérifie le stockage d’Alfred.'
|
||||
if _lists_reply is not None:
|
||||
_lists_display = {'type':'text', 'text':_lists_reply}
|
||||
with STATE_LOCK:
|
||||
STATE['content'] = _lists_display
|
||||
socketio.emit('alfred_render', _lists_display)
|
||||
if _lists_reply and not audio_settings.speaker_muted():
|
||||
emit_state('speaking')
|
||||
_lists_done = threading.Event()
|
||||
speak_async(_lists_reply, on_end=_lists_done.set)
|
||||
_lists_done.wait(timeout=120)
|
||||
emit_state('idle', partial='')
|
||||
return
|
||||
# ALFRED_LISTES_GLASS_V1_VOICE_END
|
||||
|
||||
print("[ALFRED] Envoi à Hermes", flush=True)
|
||||
# ComfyUI publie l'image pendant cet appel Hermes.
|
||||
with STATE_LOCK:
|
||||
image_revision_before = IMAGE_REVISION
|
||||
# ALFRED_FEATURES_V45 voice opt-in background mode
|
||||
if alfred_features.should_run_in_background(r.text):
|
||||
try:
|
||||
alfred_features.submit(r.text, source='voice')
|
||||
emit_state('idle', partial='', content={
|
||||
'type': 'text',
|
||||
'text': 'Hermes travaille sur la demande. Le résultat apparaîtra dans les cartes.'
|
||||
})
|
||||
except ValueError as exc:
|
||||
emit_state('error', content={'type': 'text', 'text': str(exc)})
|
||||
return
|
||||
answer = ask_hermes(r.text)
|
||||
print(f"[ALFRED] Réponse Hermes : {answer!r}", flush=True)
|
||||
if isinstance(answer, dict):
|
||||
speech = str(answer.get("speech") or answer.get("text") or "")
|
||||
display = answer.get("display") or {
|
||||
"type": "text",
|
||||
"text": speech
|
||||
}
|
||||
else:
|
||||
speech = str(answer or "")
|
||||
display = {"type": "text", "text": speech}
|
||||
|
||||
# Une illustration contextuelle (Wikipédia) peut compléter la réponse Hermes.
|
||||
# Elle n'interrompt pas la lecture TTS et ne remplace pas la scène centrale.
|
||||
alfred_context_images.schedule_context_image(
|
||||
r.text, speech, socketio,
|
||||
lambda: STATE.get("transcript") == r.text,
|
||||
) # ALFRED_TIMERS_CONTEXT_V1
|
||||
|
||||
# Si le MCP ComfyUI a affiché une image pendant la requête,
|
||||
# ne pas l'écraser par la réponse textuelle d'Hermes.
|
||||
with STATE_LOCK:
|
||||
if IMAGE_REVISION == image_revision_before:
|
||||
STATE["content"] = display
|
||||
alfred_features.publish_voice_display(display)
|
||||
socketio.emit("alfred_render", display)
|
||||
else:
|
||||
print("[ALFRED] Image ComfyUI conservée à l'écran", flush=True)
|
||||
|
||||
if speech and not audio_settings.speaker_muted():
|
||||
emit_state("speaking")
|
||||
done = threading.Event()
|
||||
speak_async(speech, on_end=done.set)
|
||||
done.wait(timeout=120)
|
||||
|
||||
emit_state("idle", partial="")
|
||||
|
||||
except Exception as exc:
|
||||
print(f"[ALFRED] Erreur conversation : {exc}", flush=True)
|
||||
emit_state(
|
||||
"error",
|
||||
content={"type": "text", "text": str(exc)}
|
||||
)
|
||||
|
||||
finally:
|
||||
conversation_lock.release()
|
||||
if not audio_settings.mic_muted():
|
||||
wake.resume()
|
||||
print("[ALFRED] Wake word réactivé", flush=True)
|
||||
|
||||
|
||||
|
||||
def on_wake(score):
|
||||
if audio_settings.mic_muted():
|
||||
return
|
||||
socketio.emit("wake_detected",{"word":os.getenv("WAKE_WORD","alfred"),"score":score})
|
||||
threading.Thread(target=conversation,args=(score,),daemon=True).start()
|
||||
|
||||
wake=WakeWordListener(on_wake=on_wake,on_level=lambda l: audio_level(l if STATE["mode"]!="idle" else l*.18))
|
||||
|
||||
admin_console.register_admin_console(app, wake, audio_settings) # ALFRED_ADMIN_PAGES_V1
|
||||
@app.get('/api/audio/settings')
|
||||
def get_audio_settings():
|
||||
return jsonify(audio_settings.get_settings())
|
||||
|
||||
@app.post('/api/audio/settings')
|
||||
def set_audio_settings():
|
||||
data = request.get_json(silent=True)
|
||||
try:
|
||||
old = audio_settings.get_settings()
|
||||
new = audio_settings.update_settings(data)
|
||||
except ValueError as exc:
|
||||
return jsonify({'error': str(exc)}), 400
|
||||
except Exception as exc:
|
||||
return jsonify({'error': f'ALSA : {exc}'}), 500
|
||||
if new['mic_muted'] != old['mic_muted']:
|
||||
if new['mic_muted']:
|
||||
mic_stop_event.set()
|
||||
wake.pause()
|
||||
else:
|
||||
mic_stop_event.clear()
|
||||
if not conversation_lock.locked():
|
||||
wake.resume()
|
||||
socketio.emit('audio_settings', new)
|
||||
return jsonify(new)
|
||||
|
||||
@app.get('/api/camera/settings')
|
||||
def get_camera_settings():
|
||||
try:
|
||||
return jsonify(camera_settings.get_settings())
|
||||
except camera_settings.CameraSettingsError as exc:
|
||||
return jsonify({'error': str(exc)}), 503
|
||||
|
||||
@app.post('/api/camera/settings')
|
||||
def set_camera_settings():
|
||||
# Même garde-fou que les commandes calendrier existantes.
|
||||
if not _same_origin_json():
|
||||
return jsonify({'error': 'Requête non autorisée.'}), 403
|
||||
data = request.get_json(silent=True)
|
||||
if not isinstance(data, dict):
|
||||
return jsonify({'error': 'JSON invalide.'}), 400
|
||||
try:
|
||||
result = camera_settings.update_setting(data.get('control'), data.get('value'))
|
||||
socketio.emit('camera_settings', result)
|
||||
return jsonify(result)
|
||||
except ValueError as exc:
|
||||
return jsonify({'error': str(exc)}), 400
|
||||
except camera_settings.CameraSettingsError as exc:
|
||||
return jsonify({'error': str(exc)}), 503
|
||||
|
||||
@app.post('/api/camera/reset')
|
||||
def reset_camera_settings():
|
||||
if not _same_origin_json():
|
||||
return jsonify({'error': 'Requête non autorisée.'}), 403
|
||||
try:
|
||||
result = camera_settings.reset_defaults()
|
||||
socketio.emit('camera_settings', result)
|
||||
return jsonify(result)
|
||||
except camera_settings.CameraSettingsError as exc:
|
||||
return jsonify({'error': str(exc)}), 503
|
||||
|
||||
@app.get('/api/camera/stream')
|
||||
def camera_stream():
|
||||
# Flux MJPEG à la demande ; ne démarre jamais la webcam en arrière-plan.
|
||||
try:
|
||||
frames = video_stream(index=int(os.getenv('CAMERA_INDEX', '0')))
|
||||
except (ImportError, ValueError, RuntimeError, OSError) as exc:
|
||||
return jsonify({'error': str(exc)}), 503
|
||||
return Response(
|
||||
frames,
|
||||
mimetype='multipart/x-mixed-replace; boundary=frame',
|
||||
headers={'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff'}
|
||||
)
|
||||
|
||||
# ALFRED_FEATURES_V45 init
|
||||
alfred_features.init_app(app, ask_hermes, socketio=socketio)
|
||||
|
||||
@app.get("/")
|
||||
def index(): return render_template("index.html",name=os.getenv("ALFRED_NAME","Alfred"))
|
||||
@app.get("/api/state")
|
||||
def state():
|
||||
with STATE_LOCK:
|
||||
snapshot = dict(STATE)
|
||||
return jsonify({**snapshot, "wake": wake.status(), "tts": tts_backend_detect()})
|
||||
|
||||
@app.post("/api/state")
|
||||
def set_state():
|
||||
data = request.get_json(force=True)
|
||||
with STATE_LOCK:
|
||||
STATE.update(data)
|
||||
socketio.emit("alfred_state", dict(STATE))
|
||||
return jsonify({"ok": True})
|
||||
@app.post("/api/render")
|
||||
def render_payload():
|
||||
global IMAGE_REVISION
|
||||
token = os.getenv("ALFRED_RENDER_TOKEN", "").strip()
|
||||
if token and not hmac.compare_digest(
|
||||
request.headers.get("X-Alfred-Render-Token", ""), token
|
||||
):
|
||||
return jsonify({"ok": False, "error": "Non autorisé"}), 401
|
||||
|
||||
data = request.get_json(silent=True)
|
||||
if not isinstance(data, dict):
|
||||
return jsonify({"ok": False, "error": "JSON objet attendu"}), 400
|
||||
if data.get("type") == "image":
|
||||
image_url = data.get("url")
|
||||
if not isinstance(image_url, str) or not (
|
||||
image_url.startswith(("http://", "https://", "/"))
|
||||
and not image_url.startswith("//")
|
||||
):
|
||||
return jsonify({"ok": False, "error": "URL image invalide"}), 400
|
||||
|
||||
with STATE_LOCK:
|
||||
if data.get("type") == "image":
|
||||
IMAGE_REVISION += 1
|
||||
STATE["content"] = data
|
||||
socketio.emit("alfred_render", data)
|
||||
return jsonify({"ok": True, "image_revision": IMAGE_REVISION})
|
||||
# --- Agenda / capteurs / caméras Home Assistant ---
|
||||
def _calendar_range():
|
||||
tz = ZoneInfo(os.getenv('ALFRED_TIMEZONE', 'Europe/Paris'))
|
||||
now = datetime.now(tz)
|
||||
def parse(name, default):
|
||||
raw = request.args.get(name)
|
||||
if not raw:return default
|
||||
value = datetime.fromisoformat(raw.replace('Z','+00:00'))
|
||||
if value.tzinfo is None: value = value.replace(tzinfo=tz)
|
||||
return value.astimezone(tz)
|
||||
start = parse('start',now.replace(hour=0,minute=0,second=0,microsecond=0))
|
||||
end = parse('end',start+timedelta(days=7))
|
||||
return start,end
|
||||
|
||||
def _json_error(exc):
|
||||
code = 400 if isinstance(exc,ValueError) else 502
|
||||
return jsonify({'error':str(exc)}),code
|
||||
|
||||
def _writes_enabled():
|
||||
return os.getenv('ALFRED_ALLOW_CALENDAR_WRITES','false').lower() in ('1','true','yes')
|
||||
|
||||
def _same_origin_json():
|
||||
# POSTs require a custom header and same-origin browser requests.
|
||||
if request.headers.get('X-Alfred-Request') != '1':return False
|
||||
origin=request.headers.get('Origin')
|
||||
if origin and origin.rstrip('/') != request.host_url.rstrip('/'):return False
|
||||
return True
|
||||
|
||||
@app.get('/api/calendar/calendars')
|
||||
def calendars_list():
|
||||
try:return jsonify({'calendars':ha_views.list_calendars(),'writes_enabled':_writes_enabled()})
|
||||
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
|
||||
|
||||
@app.get('/api/calendar/events')
|
||||
def calendar_events():
|
||||
try:
|
||||
start,end=_calendar_range()
|
||||
selected=request.args.getlist('calendar_id')
|
||||
return jsonify(ha_views.get_events(start,end,selected or None))
|
||||
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
|
||||
|
||||
@app.post('/api/calendar/create')
|
||||
def calendar_create():
|
||||
if not _same_origin_json():return jsonify({'error':'Requête non autorisée.'}),403
|
||||
if not _writes_enabled():return jsonify({'error':'Écriture calendrier désactivée (ALFRED_ALLOW_CALENDAR_WRITES).'}),403
|
||||
data=request.get_json(silent=True)
|
||||
if not isinstance(data,dict):return jsonify({'error':'JSON invalide.'}),400
|
||||
try:
|
||||
tz=ZoneInfo(os.getenv('ALFRED_TIMEZONE','Europe/Paris'))
|
||||
def read_dt(key):
|
||||
dt=datetime.fromisoformat(str(data[key]).replace('Z','+00:00'))
|
||||
return dt.replace(tzinfo=tz) if dt.tzinfo is None else dt.astimezone(tz)
|
||||
result=ha_views.create_event(str(data.get('calendar_id','')),str(data.get('title','')),
|
||||
read_dt('start'),read_dt('end'),str(data.get('description') or ''),
|
||||
str(data.get('location') or ''),bool(data.get('all_day',False)))
|
||||
from services import alfred_intents
|
||||
alfred_intents.clear_create()
|
||||
socketio.emit('calendar_changed', {'type':'created'})
|
||||
return jsonify(result)
|
||||
except (ha_views.HAError,ValueError,KeyError) as exc:return _json_error(exc)
|
||||
|
||||
@app.post('/api/calendar/delete')
|
||||
def calendar_delete():
|
||||
if not _same_origin_json():return jsonify({'error':'Requête non autorisée.'}),403
|
||||
if not _writes_enabled():return jsonify({'error':'Écriture calendrier désactivée.'}),403
|
||||
data=request.get_json(silent=True)
|
||||
if not isinstance(data,dict):return jsonify({'error':'JSON invalide.'}),400
|
||||
try:
|
||||
eid=str(data.get('calendar_id',''));uid=str(data.get('uid',''))
|
||||
start=datetime.fromisoformat(str(data['start']))
|
||||
end=datetime.fromisoformat(str(data['end']))
|
||||
if start.tzinfo is None or end.tzinfo is None:raise ValueError('Date avec fuseau requise.')
|
||||
matches=ha_views.get_events(start-timedelta(days=1),end+timedelta(days=1),[eid])['events']
|
||||
rid=str(data.get('recurrence_id') or '')
|
||||
if not any(e['id']==uid and e['start']==start.isoformat() and e['recurrence_id']==rid for e in matches):
|
||||
return jsonify({'error':'Événement introuvable ou modifié ; actualise le planning.'}),409
|
||||
result=ha_views.delete_event(eid,uid,rid or None)
|
||||
from services import alfred_intents
|
||||
alfred_intents.clear_delete()
|
||||
socketio.emit('calendar_changed',{'type':'deleted'})
|
||||
return jsonify(result)
|
||||
except (ha_views.HAError,ValueError,KeyError) as exc:return _json_error(exc)
|
||||
|
||||
@app.get('/api/ha/weather')
|
||||
def ha_weather_data():
|
||||
# Read-only route, token always stays on the Alfred server.
|
||||
try:
|
||||
q = request.args.get('q', '')
|
||||
return jsonify(ha_weather.get_weather(q))
|
||||
except (ha_views.HAError, ValueError) as exc:
|
||||
return _json_error(exc)
|
||||
|
||||
@app.get('/api/ha/weather/outfits')
|
||||
def ha_weather_outfits_data():
|
||||
try:
|
||||
q = request.args.get('q', '')
|
||||
refresh = request.args.get('refresh_illustration', '').lower() in ('1','true','yes','on')
|
||||
return jsonify(weather_outfits.build_payload(q=q, date=request.args.get('date', ''), label=request.args.get('label', ''), refresh=refresh))
|
||||
except (ha_views.HAError, ValueError, RuntimeError) as exc:
|
||||
return _json_error(exc)
|
||||
|
||||
@app.get('/api/ha/temperatures')
|
||||
def ha_temperatures():
|
||||
try:return jsonify({'items':ha_views.get_temperatures()})
|
||||
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
|
||||
|
||||
@app.get('/api/ha/temperature/history')
|
||||
def ha_temperature_history():
|
||||
try:
|
||||
eid=request.args.get('entity_id','')
|
||||
hours=int(request.args.get('hours','24'))
|
||||
return jsonify({'items':ha_views.temperature_history(eid,hours)})
|
||||
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
|
||||
|
||||
@app.get('/api/ha/cameras')
|
||||
def ha_cameras():
|
||||
try:return jsonify({'items':ha_views.list_cameras()})
|
||||
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
|
||||
|
||||
@app.get('/api/ha/camera/<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)})
|
||||
|
||||
|
||||
# ALFRED_MAIN_VISUAL_V1_BACKEND_START
|
||||
# Raccourcis visuels : même Hermes, même verrou de conversation et même moteur Piper.
|
||||
def _alfred_dashboard_quick_command(prompt):
|
||||
if not conversation_lock.acquire(blocking=False):
|
||||
return False
|
||||
|
||||
def _run():
|
||||
try:
|
||||
wake.pause()
|
||||
with STATE_LOCK:
|
||||
_image_revision_before = IMAGE_REVISION
|
||||
emit_state('thinking', transcript=prompt, partial='',
|
||||
content={'type':'text', 'text':prompt})
|
||||
_answer = ask_hermes(prompt)
|
||||
if isinstance(_answer, dict):
|
||||
_speech = str(_answer.get('speech') or _answer.get('text') or '')
|
||||
_display = _answer.get('display') or {'type':'text','text':_speech}
|
||||
else:
|
||||
_speech = str(_answer or '')
|
||||
_display = {'type':'text','text':_speech}
|
||||
# Une image ComfyUI publiee pendant Hermes ne doit pas etre effacee.
|
||||
with STATE_LOCK:
|
||||
if IMAGE_REVISION == _image_revision_before:
|
||||
STATE['content'] = _display
|
||||
socketio.emit('alfred_render', _display)
|
||||
if _speech and not audio_settings.speaker_muted():
|
||||
emit_state('speaking')
|
||||
_done = threading.Event()
|
||||
speak_async(_speech, on_end=_done.set)
|
||||
_done.wait(timeout=120)
|
||||
emit_state('idle', partial='')
|
||||
except Exception as _exc:
|
||||
print('[ALFRED DASHBOARD] Erreur raccourci :', _exc, flush=True)
|
||||
emit_state('error', content={'type':'text', 'text':str(_exc)})
|
||||
finally:
|
||||
conversation_lock.release()
|
||||
if not audio_settings.mic_muted():
|
||||
wake.resume()
|
||||
|
||||
threading.Thread(target=_run, daemon=True, name='alfred-dashboard-command').start()
|
||||
return True
|
||||
|
||||
from services.alfred_dashboard import register_dashboard as _register_dashboard_visual_v1
|
||||
_register_dashboard_visual_v1(app, quick_command=_alfred_dashboard_quick_command)
|
||||
# ALFRED_MAIN_VISUAL_V1_BACKEND_END
|
||||
|
||||
|
||||
# ALFRED_ADMIN_GLASS_MAISON_V1_START
|
||||
from services.alfred_home_glass import register_home_glass as _alfred_register_home_glass
|
||||
_alfred_register_home_glass(app)
|
||||
# ALFRED_ADMIN_GLASS_MAISON_V1_END
|
||||
|
||||
|
||||
# ALFRED_LISTES_GLASS_V1_BACKEND_START
|
||||
from services.alfred_lists import register_lists as _alfred_register_lists
|
||||
from services.alfred_lists import handle_voice as _alfred_handle_list_voice
|
||||
_alfred_register_lists(app, socketio)
|
||||
# ALFRED_LISTES_GLASS_V1_BACKEND_END
|
||||
|
||||
|
||||
# ALFRED_GLASS_V2_BACKEND_START
|
||||
from services.alfred_logs_page import register_logs_page as _alfred_register_logs_v2
|
||||
_alfred_register_logs_v2(app)
|
||||
# ALFRED_GLASS_V2_BACKEND_END
|
||||
|
||||
|
||||
# ALFRED_IDENTITY_GLASS_V1: page /identity et synchronisation du nom.
|
||||
from services.alfred_identity_page import register_identity_page as _alfred_identity_register
|
||||
_alfred_identity_register(app)
|
||||
|
||||
if __name__ == "__main__":
|
||||
if os.getenv("WAKE_ENABLED","true").lower() in ("1","true","yes","on") and not audio_settings.mic_muted(): wake.start()
|
||||
socketio.run(app,host=os.getenv("FLASK_HOST","0.0.0.0"),port=int(os.getenv("FLASK_PORT","54321")),allow_unsafe_werkzeug=True)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"version": "ALFRED_DUPLEX_BARGE_IN_V1",
|
||||
"files": {
|
||||
"app.py": {
|
||||
"before": "a96d4d7c5a78d5076934db6033f087cccc32c99f2493cadbffbff6c26cdf0602",
|
||||
"after": "f40d87d07e4b85b35bd87a681c804389b14598991b434f367445e2e575f7799a"
|
||||
},
|
||||
"services/piper_tts.py": {
|
||||
"before": "e087c9809730b619d05104fe0f6354affad1f4c8f22430ac34acab5b3ec0b43e",
|
||||
"after": "2bc8fb780da8630c64e6281ff6bd21357f5d7c20ff46cd6bbd2c2b9887a0a8b1"
|
||||
},
|
||||
"services/admin_console.py": {
|
||||
"before": "27eef32a894d9cecc9a77e70708ae58912355c2f7cc48bd7074068950dfc6c4e",
|
||||
"after": "168409185f801a9a492f2eafd2ff13c628415a57b81395d8cbc2155a7bb2f264"
|
||||
},
|
||||
"templates/config.html": {
|
||||
"before": "c98c0314923d054e8b56251657a33bb4c949684a94faed0a9a979dfd5ed6c8db",
|
||||
"after": "e45f60b54b028422a78809ba8d0b6194beb8f64c74abb562a6cc9e03db029fff"
|
||||
}
|
||||
},
|
||||
"added": {
|
||||
"services/alfred_duplex.py": "5c477fe854a84790c8929b0b30b4fa0f8e0be7cf5a3cb7f097fa790105156728"
|
||||
}
|
||||
}
|
||||
+492
@@ -0,0 +1,492 @@
|
||||
"""Authenticated local configuration and health UI for Alfred.
|
||||
|
||||
Registration is additive and doesn't replace the existing audio, camera, HA,
|
||||
weather or Hermes endpoints. Passwords are provisioned locally via CLI.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import requests
|
||||
from dotenv import dotenv_values
|
||||
from flask import Blueprint, current_app, jsonify, render_template, request, session
|
||||
from werkzeug.security import check_password_hash
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
AUTH_FILE = ROOT / 'config' / 'admin_auth.json'
|
||||
GENERAL_ENV = ROOT / 'config' / 'control_center.env'
|
||||
WEATHER_ENV = ROOT / 'config' / 'weather_outfits.env'
|
||||
CONFIG_LOCK = threading.RLock()
|
||||
LOGIN_LOCK = threading.Lock()
|
||||
LOGIN_ATTEMPTS = {}
|
||||
|
||||
# Only these keys are writable; no token, command, system file or shell input.
|
||||
FIELDS = {
|
||||
'ALFRED_NAME': ('general', 'text'),
|
||||
'WAKE_WORD': ('general', 'wake'),
|
||||
'PIPER_VOICE': ('general', 'voice'),
|
||||
'ALFRED_OLLAMA_URL': ('general', 'url'),
|
||||
'ALFRED_COMFYUI_URL': ('weather', 'url'),
|
||||
'HA_WEATHER_ENTITY': ('general', 'entity'),
|
||||
'ALFRED_WEATHER_ILLUSTRATION_ATTEMPTS': ('weather', 'attempts'),
|
||||
'ALFRED_WEATHER_ILLUSTRATION_TARGET_SCORE': ('weather', 'score'),
|
||||
'ALFRED_WEATHER_DISPLAY_SIZE': ('weather', 'size'),
|
||||
'ALFRED_WEATHER_REVIEW': ('weather', 'boolean'),
|
||||
'ALFRED_WEATHER_REFERENCE_MODE': ('weather', 'reference_mode'),
|
||||
'ALFRED_WEATHER_REFERENCE_DENOISE': ('weather', 'denoise'),
|
||||
'ALFRED_WEATHER_PRESERVE_FACE': ('weather', 'boolean'),
|
||||
'ALFRED_WEATHER_ACCESSORY_LIMIT': ('weather', 'accessory'),
|
||||
}
|
||||
DEFAULTS = {
|
||||
'ALFRED_NAME': 'Alfred', 'WAKE_WORD': 'alfred',
|
||||
'PIPER_VOICE': 'fr_FR-tom-medium', 'ALFRED_OLLAMA_URL': '',
|
||||
'ALFRED_COMFYUI_URL': '', 'HA_WEATHER_ENTITY': '',
|
||||
'ALFRED_WEATHER_ILLUSTRATION_ATTEMPTS': '2',
|
||||
'ALFRED_WEATHER_ILLUSTRATION_TARGET_SCORE': '8.6',
|
||||
'ALFRED_WEATHER_DISPLAY_SIZE': '350',
|
||||
'ALFRED_WEATHER_REVIEW': 'true',
|
||||
'ALFRED_WEATHER_REFERENCE_MODE': 'auto',
|
||||
'ALFRED_WEATHER_REFERENCE_DENOISE': '0.56',
|
||||
'ALFRED_WEATHER_PRESERVE_FACE': 'true',
|
||||
'ALFRED_WEATHER_ACCESSORY_LIMIT': '1',
|
||||
}
|
||||
|
||||
|
||||
# ALFRED_ADMIN_SURVEILLANCE_V2 : champs du moteur STT existant.
|
||||
FIELDS.update({
|
||||
'STT_ENGINE': ('general', 'stt_engine'),
|
||||
'WYOMING_WHISPER_HOST': ('general', 'stt_host'),
|
||||
'WYOMING_WHISPER_PORT': ('general', 'stt_port'),
|
||||
'WYOMING_WHISPER_LANGUAGE': ('general', 'stt_language'),
|
||||
'WYOMING_WHISPER_TIMEOUT': ('general', 'stt_timeout'),
|
||||
'WYOMING_WHISPER_CONNECT_TIMEOUT': ('general', 'stt_connect_timeout'),
|
||||
'STT_MAX_AUDIO_SECONDS': ('general', 'stt_max_audio'),
|
||||
})
|
||||
DEFAULTS.update({
|
||||
'STT_ENGINE': 'vosk',
|
||||
'WYOMING_WHISPER_HOST': '192.168.1.13',
|
||||
'WYOMING_WHISPER_PORT': '10300',
|
||||
'WYOMING_WHISPER_LANGUAGE': 'fr',
|
||||
'WYOMING_WHISPER_TIMEOUT': '20',
|
||||
'WYOMING_WHISPER_CONNECT_TIMEOUT': '3',
|
||||
'STT_MAX_AUDIO_SECONDS': '30',
|
||||
})
|
||||
from services.admin_extensions_v2 import extend_health as _admin_v2_extend_health
|
||||
|
||||
|
||||
def _auth_config():
|
||||
try:
|
||||
return json.loads(AUTH_FILE.read_text(encoding='utf-8'))
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
|
||||
|
||||
def _authenticated():
|
||||
config = _auth_config()
|
||||
password_hash = config.get('password_hash', '')
|
||||
if not password_hash:
|
||||
return False
|
||||
# Changing the admin password instantly invalidates old signed sessions.
|
||||
expected_version = __import__('hashlib').sha256(password_hash.encode()).hexdigest()[:24]
|
||||
return bool(session.get('alfred_admin') and
|
||||
hmac.compare_digest(str(session.get('auth_version', '')), expected_version))
|
||||
|
||||
|
||||
def _valid_csrf():
|
||||
token = request.headers.get('X-Alfred-CSRF', '')
|
||||
expected = session.get('alfred_csrf', '')
|
||||
return bool(token and expected and hmac.compare_digest(token, expected))
|
||||
|
||||
|
||||
def _trusted_request():
|
||||
origin = request.headers.get('Origin', '')
|
||||
if origin:
|
||||
parsed = urlsplit(origin)
|
||||
if parsed.netloc.lower() != request.host.lower() or parsed.scheme not in ('http', 'https'):
|
||||
return False
|
||||
if request.headers.get('Sec-Fetch-Site', '').lower() == 'cross-site':
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _require_admin(handler):
|
||||
@functools.wraps(handler)
|
||||
def wrapped(*args, **kwargs):
|
||||
if not _authenticated():
|
||||
return jsonify(error='Authentification administrateur nécessaire.'), 401
|
||||
if request.method != 'GET' and (not _valid_csrf() or not _trusted_request()):
|
||||
return jsonify(error='Protection anti-CSRF : requête refusée.'), 403
|
||||
return handler(*args, **kwargs)
|
||||
return wrapped
|
||||
|
||||
|
||||
def _validate(key, value):
|
||||
if key not in FIELDS:
|
||||
raise ValueError(f'Paramètre non autorisé : {key}.')
|
||||
kind = FIELDS[key][1]
|
||||
if kind == 'boolean':
|
||||
if value not in (True, False, 'true', 'false'):
|
||||
raise ValueError(f'{key} doit être vrai ou faux.')
|
||||
return 'true' if value in (True, 'true') else 'false'
|
||||
if isinstance(value, bool):
|
||||
raise ValueError(f'Valeur invalide pour {key}.')
|
||||
value = str(value).strip()
|
||||
if any(c in value for c in ('\r', '\n', '\x00', '"', "'", '\\', '$', '`')):
|
||||
raise ValueError(f'Caractères interdits pour {key}.')
|
||||
# ALFRED_ADMIN_SURVEILLANCE_V2: stricte validation des paramètres Whisper.
|
||||
if kind == 'stt_engine':
|
||||
if value not in ('vosk', 'wyoming'):
|
||||
raise ValueError('Moteur STT attendu : vosk ou wyoming.')
|
||||
elif kind == 'stt_host':
|
||||
if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9.\-]{0,252}', value):
|
||||
raise ValueError('Hôte Whisper invalide : indiquer une IP ou un nom DNS, sans protocole.')
|
||||
elif kind == 'stt_port':
|
||||
if not value.isdecimal() or not 1 <= int(value) <= 65535:
|
||||
raise ValueError('Port Whisper invalide (1 à 65535).')
|
||||
value = str(int(value))
|
||||
elif kind == 'stt_language':
|
||||
if not re.fullmatch(r'[a-z]{2}', value.lower()):
|
||||
raise ValueError('Langue Whisper invalide (exemple : fr).')
|
||||
value = value.lower()
|
||||
elif kind in ('stt_timeout', 'stt_connect_timeout'):
|
||||
try:
|
||||
number = float(value)
|
||||
except ValueError:
|
||||
raise ValueError('Délai Whisper non numérique.')
|
||||
bounds = (5, 90) if kind == 'stt_timeout' else (1, 15)
|
||||
if not bounds[0] <= number <= bounds[1]:
|
||||
raise ValueError(f'Délai Whisper entre {bounds[0]} et {bounds[1]} secondes.')
|
||||
value = str(number).rstrip('0').rstrip('.') if '.' in str(number) else str(number)
|
||||
elif kind == 'stt_max_audio':
|
||||
if not value.isdecimal() or not 5 <= int(value) <= 60:
|
||||
raise ValueError('Enregistrement entre 5 et 60 secondes.')
|
||||
value = str(int(value))
|
||||
elif kind == 'text':
|
||||
if not 2 <= len(value) <= 30:
|
||||
raise ValueError('Le nom doit contenir entre 2 et 30 caractères.')
|
||||
elif kind == 'wake':
|
||||
if not re.fullmatch(r'[A-Za-zÀ-ÖØ-öø-ÿ]{2,22}', value):
|
||||
raise ValueError('Le mot-clé Vosk doit être un mot simple de 2 à 22 lettres.')
|
||||
value = value.lower()
|
||||
elif kind == 'voice':
|
||||
if not re.fullmatch(r'[A-Za-z0-9_.-]{2,96}', value):
|
||||
raise ValueError('Identifiant de voix Piper invalide.')
|
||||
elif kind == 'url':
|
||||
if not value:
|
||||
return ''
|
||||
parsed = urlsplit(value)
|
||||
if (parsed.scheme not in ('http', 'https') or not parsed.hostname or
|
||||
parsed.username or parsed.password or parsed.path not in ('', '/') or
|
||||
parsed.query or parsed.fragment):
|
||||
raise ValueError(f'{key} attend une URL http(s) de serveur sans chemin ni identifiants.')
|
||||
if parsed.port and not 1 <= parsed.port <= 65535:
|
||||
raise ValueError('Port réseau invalide.')
|
||||
value = value.rstrip('/')
|
||||
elif kind == 'entity':
|
||||
if value and not re.fullmatch(r'weather\.[a-z0-9_]{2,80}', value):
|
||||
raise ValueError('Attendu : weather.identifiant ou une valeur vide.')
|
||||
elif kind == 'attempts':
|
||||
if value not in ('1', '2', '3'):
|
||||
raise ValueError('Nombre d’essais autorisé : 1, 2 ou 3 par personnage.')
|
||||
elif kind == 'score':
|
||||
value = str(float(value))
|
||||
if not 1 <= float(value) <= 10:
|
||||
raise ValueError('Score entre 1 et 10.')
|
||||
elif kind == 'size':
|
||||
if value not in ('250', '300', '350', '400', '450', '500'):
|
||||
raise ValueError('Taille autorisée : 250, 300, 350, 400, 450 ou 500 px.')
|
||||
elif kind == 'denoise':
|
||||
value = str(float(value))
|
||||
if not 0.25 <= float(value) <= 0.85:
|
||||
raise ValueError('Denoise entre 0.25 et 0.85.')
|
||||
elif kind == 'reference_mode':
|
||||
if value not in ('auto', 'required', 'off'):
|
||||
raise ValueError('Mode référence : auto, required ou off.')
|
||||
elif kind == 'accessory':
|
||||
if value not in ('0', '1', '2'):
|
||||
raise ValueError('Accessoires visibles autorisés : 0, 1 ou 2.')
|
||||
return value
|
||||
|
||||
|
||||
def _read_config():
|
||||
local = dict(dotenv_values(GENERAL_ENV)) if GENERAL_ENV.exists() else {}
|
||||
weather = dict(dotenv_values(WEATHER_ENV)) if WEATHER_ENV.exists() else {}
|
||||
effective = {}
|
||||
for key, (group, _) in FIELDS.items():
|
||||
env_file = local if group == 'general' else weather
|
||||
# File values take precedence on the next restart. Show saved values.
|
||||
effective[key] = str(env_file.get(key) if env_file.get(key) is not None
|
||||
else os.getenv(key, DEFAULTS[key]))
|
||||
return effective
|
||||
|
||||
|
||||
def _save_env(path: Path, changes: dict):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
existing = path.read_text(encoding='utf-8') if path.exists() else ''
|
||||
lines = existing.splitlines()
|
||||
updated = set()
|
||||
result = []
|
||||
for line in lines:
|
||||
match = re.match(r'^\s*([A-Z][A-Z0-9_]*)\s*=', line)
|
||||
if match and match.group(1) in changes:
|
||||
name = match.group(1)
|
||||
if name not in updated:
|
||||
result.append(f'{name}={changes[name]}')
|
||||
updated.add(name)
|
||||
else:
|
||||
result.append(line)
|
||||
for key, value in changes.items():
|
||||
if key not in updated:
|
||||
result.append(f'{key}={value}')
|
||||
tmp = path.with_name(path.name + f'.{secrets.token_hex(4)}.tmp')
|
||||
try:
|
||||
with tmp.open('w', encoding='utf-8') as fp:
|
||||
fp.write('\n'.join(result).rstrip() + '\n')
|
||||
fp.flush()
|
||||
os.fsync(fp.fileno())
|
||||
os.chmod(tmp, 0o600)
|
||||
tmp.replace(path)
|
||||
finally:
|
||||
tmp.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _probe_http(url, *, token=None, timeout=2):
|
||||
start = time.monotonic()
|
||||
try:
|
||||
response = requests.get(url, headers={'Authorization': f'Bearer {token}'} if token else {},
|
||||
timeout=timeout, allow_redirects=False)
|
||||
if 200 <= response.status_code < 300:
|
||||
state = 'ok'
|
||||
detail = f'HTTP {response.status_code}'
|
||||
elif response.status_code in (401, 403):
|
||||
state, detail = 'warning', f'Connexion établie, accès refusé ({response.status_code})'
|
||||
else:
|
||||
state, detail = 'warning', f'HTTP {response.status_code}'
|
||||
except requests.RequestException:
|
||||
state, detail = 'down', 'Aucune réponse HTTP'
|
||||
return {'state': state, 'detail': detail, 'latency_ms': round((time.monotonic()-start)*1000)}
|
||||
|
||||
|
||||
def _probe_tcp(host, port, timeout=1.5):
|
||||
try:
|
||||
with socket.create_connection((host, int(port)), timeout=timeout):
|
||||
return {'state': 'ok', 'detail': f'Port {port} accessible'}
|
||||
except (OSError, ValueError):
|
||||
return {'state': 'down', 'detail': f'Port {port} inaccessible'}
|
||||
|
||||
|
||||
def _weather_comfy_url():
|
||||
# Respect the V5 precedence if possible, without reaching into credentials.
|
||||
settings = dict(dotenv_values(WEATHER_ENV)) if WEATHER_ENV.exists() else {}
|
||||
return (_read_config()['ALFRED_COMFYUI_URL'] or settings.get('ALFRED_COMFYUI_URL') or
|
||||
os.getenv('ALFRED_COMFYUI_URL') or os.getenv('COMFYUI_URL') or
|
||||
dict(dotenv_values('/opt/hermes/MCP_comfyui/.env')).get('COMFYUI_URL') or '')
|
||||
|
||||
|
||||
def _health(wake, audio_settings):
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
audio = audio_settings.get_settings()
|
||||
try:
|
||||
wake_state = wake.status()
|
||||
except Exception:
|
||||
wake_state = {'running': False, 'paused': True, 'error': 'Impossible de lire Vosk'}
|
||||
cam_index = int(os.getenv('CAMERA_INDEX', '0'))
|
||||
camera_path = Path(f'/dev/video{cam_index}')
|
||||
camera_ok = camera_path.exists() and os.access(camera_path, os.R_OK)
|
||||
# Checking /dev/videoN doesn't open the device: no conflict with facial recognition.
|
||||
camera = {'state':'ok' if camera_ok else 'warning',
|
||||
'detail':f'{camera_path} détectée' if camera_ok else f'{camera_path} indisponible ou permissions insuffisantes'}
|
||||
mute_micro = audio.get('mic_muted', False)
|
||||
microphone = {'state':'muted' if mute_micro else ('ok' if wake_state.get('running') else 'warning'),
|
||||
'detail':'Micro coupé volontairement' if mute_micro else ('Détection Vosk active' if wake_state.get('running') and not wake_state.get('paused') else 'Vosk non disponible ou en pause')}
|
||||
loudspeaker = {'state':'muted' if audio.get('speaker_muted') else 'ok',
|
||||
'detail':'Son coupé' if audio.get('speaker_muted') else f"Volume {audio.get('volume', '?')} %"}
|
||||
|
||||
config = _read_config()
|
||||
comfy = _weather_comfy_url()
|
||||
ollama = config.get('ALFRED_OLLAMA_URL') or os.getenv('OLLAMA_HOST') or os.getenv('OLLAMA_URL')
|
||||
ha_base = os.getenv('HA_URL', '').rstrip('/')
|
||||
tts_host = os.getenv('PIPER_HOST', '').strip()
|
||||
tts_port = os.getenv('PIPER_PORT', '10200')
|
||||
services = {'alfred': {'state':'ok', 'detail':'Serveur Flask actif'},
|
||||
'hermes_cli': {'state':'ok' if shutil.which('hermes') else 'warning',
|
||||
'detail':'CLI détectée' if shutil.which('hermes') else 'CLI non détectée dans le PATH du service'}}
|
||||
services['hermes_dashboard'] = _probe_tcp('127.0.0.1', 9119)
|
||||
services['hermes_dashboard']['label'] = 'Tableau de bord (optionnel)'
|
||||
services['comfyui'] = _probe_http(comfy.rstrip('/') + '/system_stats') if comfy else {'state':'unknown','detail':'URL non configurée'}
|
||||
services['ollama'] = _probe_http(ollama.rstrip('/') + '/api/tags') if ollama else {'state':'unknown','detail':'URL non configurée'}
|
||||
services['home_assistant'] = _probe_http(ha_base + '/api/', token=os.getenv('HA_TOKEN')) if ha_base else {'state':'unknown','detail':'URL non configurée'}
|
||||
services['wyoming_piper'] = _probe_tcp(tts_host, tts_port) if tts_host else {'state':'unknown','detail':'Wyoming Piper non configuré (Piper local possible)'}
|
||||
|
||||
mcp_path = Path('/opt/hermes')
|
||||
mcps = {}
|
||||
for slug, label in [('MCP_home_assistant','Home Assistant'), ('MCP_comfyui','ComfyUI'), ('MCP_face_recognition','Reconnaissance faciale'),('MCP_wazuh','Wazuh')]:
|
||||
found = (mcp_path / slug).exists()
|
||||
mcps[slug] = {'state':'ok' if found else 'unknown', 'detail':'Dossier présent (connexion non vérifiée)' if found else 'Dossier absent', 'label':label}
|
||||
|
||||
disk = shutil.disk_usage(ROOT)
|
||||
mem = {}
|
||||
try:
|
||||
info = {}
|
||||
for line in Path('/proc/meminfo').read_text().splitlines():
|
||||
k, _, v = line.partition(':')
|
||||
if k in ('MemTotal','MemAvailable'):
|
||||
info[k] = int(v.strip().split()[0]) * 1024
|
||||
if info.get('MemTotal'):
|
||||
mem = {'total': info['MemTotal'], 'used': info['MemTotal'] - info.get('MemAvailable', 0)}
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
result = {'time': now, 'audio':audio, 'wake':wake_state, 'camera':camera,
|
||||
'microphone':microphone, 'speaker':loudspeaker, 'services':services, 'mcps':mcps,
|
||||
'system':{'disk_total':disk.total,'disk_used':disk.used, 'memory':mem,
|
||||
'load_1m':round(os.getloadavg()[0], 2) if hasattr(os,'getloadavg') else None}}
|
||||
return _admin_v2_extend_health(result, _read_config)
|
||||
|
||||
|
||||
def register_admin_console(app, wake, audio_settings):
|
||||
"""Register pages/API on the existing Flask app. Call exactly once."""
|
||||
secret_file = ROOT / 'config' / 'admin_secret.key'
|
||||
if not secret_file.exists():
|
||||
raise RuntimeError('Secret administrateur absent ; exécuter install_patch.py.')
|
||||
app.secret_key = secret_file.read_bytes()
|
||||
app.config.update(SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SAMESITE='Strict',
|
||||
SESSION_COOKIE_SECURE=os.getenv('ALFRED_ADMIN_HTTPS', 'false').lower() == 'true',
|
||||
PERMANENT_SESSION_LIFETIME=timedelta(hours=8))
|
||||
|
||||
bp = Blueprint('alfred_admin', __name__)
|
||||
|
||||
@bp.get('/config')
|
||||
@bp.get('/config.html')
|
||||
def config_page():
|
||||
return render_template('config.html')
|
||||
|
||||
@bp.get('/surveillance')
|
||||
@bp.get('/surveillance.html')
|
||||
def surveillance_page():
|
||||
return render_template('surveillance.html')
|
||||
|
||||
@bp.get('/api/control/auth')
|
||||
def auth_status():
|
||||
authed = _authenticated()
|
||||
return jsonify(authenticated=authed, configured=bool(_auth_config().get('password_hash')),
|
||||
csrf=session.get('alfred_csrf') if authed else None)
|
||||
|
||||
@bp.post('/api/control/login')
|
||||
def login():
|
||||
if not _trusted_request() or not request.is_json:
|
||||
return jsonify(error='Requête refusée.'), 403
|
||||
data = request.get_json(silent=True) or {}
|
||||
password = data.get('password', '')
|
||||
if not isinstance(password, str) or len(password) > 500:
|
||||
return jsonify(error='Mot de passe incorrect.'), 400
|
||||
now = time.monotonic()
|
||||
client = request.remote_addr or 'unknown'
|
||||
with LOGIN_LOCK:
|
||||
events = [x for x in LOGIN_ATTEMPTS.get(client, []) if x > now - 900]
|
||||
LOGIN_ATTEMPTS[client] = events
|
||||
if len(events) >= 5:
|
||||
return jsonify(error='Trop de tentatives : réessaie plus tard.'), 429
|
||||
config = _auth_config()
|
||||
password_hash = config.get('password_hash')
|
||||
if not password_hash:
|
||||
return jsonify(error='Définis d’abord le mot de passe avec set_admin_password.py.'), 503
|
||||
if not check_password_hash(password_hash, password):
|
||||
with LOGIN_LOCK:
|
||||
LOGIN_ATTEMPTS[client].append(now)
|
||||
return jsonify(error='Mot de passe incorrect.'), 401
|
||||
with LOGIN_LOCK:
|
||||
LOGIN_ATTEMPTS.pop(client, None)
|
||||
session.clear()
|
||||
session.permanent = True
|
||||
session['alfred_admin'] = True
|
||||
session['auth_version'] = __import__('hashlib').sha256(password_hash.encode()).hexdigest()[:24]
|
||||
session['alfred_csrf'] = secrets.token_urlsafe(32)
|
||||
return jsonify(authenticated=True, csrf=session['alfred_csrf'])
|
||||
|
||||
@bp.post('/api/control/logout')
|
||||
@_require_admin
|
||||
def logout():
|
||||
session.clear()
|
||||
return jsonify(ok=True)
|
||||
|
||||
@bp.get('/api/control/config')
|
||||
@_require_admin
|
||||
def config_get():
|
||||
return jsonify(values=_read_config(), note='La plupart des réglages nécessitent un redémarrage.')
|
||||
|
||||
@bp.post('/api/control/config')
|
||||
@_require_admin
|
||||
def config_update():
|
||||
data = request.get_json(silent=True)
|
||||
if not isinstance(data, dict) or len(data) > len(FIELDS):
|
||||
return jsonify(error='JSON invalide.'), 400
|
||||
try:
|
||||
normalized = {key: _validate(key, value) for key, value in data.items()}
|
||||
except (ValueError, TypeError, OverflowError) as exc:
|
||||
return jsonify(error=str(exc)), 400
|
||||
general = {k:v for k,v in normalized.items() if FIELDS[k][0]=='general'}
|
||||
weather = {k:v for k,v in normalized.items() if FIELDS[k][0]=='weather'}
|
||||
try:
|
||||
with CONFIG_LOCK:
|
||||
if general: _save_env(GENERAL_ENV, general)
|
||||
if weather: _save_env(WEATHER_ENV, weather)
|
||||
except OSError as exc:
|
||||
return jsonify(error=f'Impossible d’enregistrer : {exc}'), 500
|
||||
# ALFRED_IDENTITY_GLASS_V1 : synchroniser le nom après sauvegarde.
|
||||
_identity_sync_warning = None
|
||||
if 'ALFRED_NAME' in normalized:
|
||||
from services.alfred_identity_page import sync_identity_name as _sync_identity_name
|
||||
os.environ['ALFRED_NAME'] = normalized['ALFRED_NAME']
|
||||
try:
|
||||
_sync_identity_name(normalized['ALFRED_NAME'])
|
||||
except (OSError, ValueError) as _identity_error:
|
||||
_identity_sync_warning = str(_identity_error)
|
||||
current_app.logger.warning('Identité non synchronisée: %s', _identity_error)
|
||||
return jsonify(ok=True, restart_required=True, identity_sync_warning=_identity_sync_warning, values=_read_config())
|
||||
|
||||
@bp.get('/api/control/health')
|
||||
@_require_admin
|
||||
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)
|
||||
@@ -0,0 +1,109 @@
|
||||
import asyncio
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import wave
|
||||
|
||||
from wyoming.audio import AudioChunk, AudioStart, AudioStop
|
||||
from wyoming.client import AsyncTcpClient
|
||||
from wyoming.tts import Synthesize, SynthesizeVoice
|
||||
|
||||
|
||||
PIPER_HOST = os.getenv("PIPER_HOST", "127.0.0.1")
|
||||
PIPER_PORT = int(os.getenv("PIPER_PORT", "10200"))
|
||||
AUDIO_DEVICE = os.getenv("AUDIO_OUTPUT_DEVICE", "plughw:1,0")
|
||||
|
||||
|
||||
async def synthesize(text, wav_path):
|
||||
client = AsyncTcpClient(PIPER_HOST, PIPER_PORT)
|
||||
|
||||
await client.connect()
|
||||
|
||||
try:
|
||||
await client.write_event(
|
||||
Synthesize(
|
||||
text=text,
|
||||
voice=SynthesizeVoice(
|
||||
name=os.getenv("PIPER_VOICE", "fr_FR-tom-medium")
|
||||
)).event()
|
||||
)
|
||||
|
||||
wav_file = None
|
||||
|
||||
try:
|
||||
while True:
|
||||
event = await asyncio.wait_for(
|
||||
client.read_event(),
|
||||
timeout=30
|
||||
)
|
||||
|
||||
if event is None:
|
||||
raise RuntimeError("Connexion Piper interrompue")
|
||||
|
||||
if AudioStart.is_type(event.type):
|
||||
start = AudioStart.from_event(event)
|
||||
|
||||
wav_file = wave.open(wav_path, "wb")
|
||||
wav_file.setnchannels(start.channels)
|
||||
wav_file.setsampwidth(start.width)
|
||||
wav_file.setframerate(start.rate)
|
||||
|
||||
elif AudioChunk.is_type(event.type):
|
||||
chunk = AudioChunk.from_event(event)
|
||||
|
||||
if wav_file is None:
|
||||
wav_file = wave.open(wav_path, "wb")
|
||||
wav_file.setnchannels(chunk.channels)
|
||||
wav_file.setsampwidth(chunk.width)
|
||||
wav_file.setframerate(chunk.rate)
|
||||
|
||||
wav_file.writeframes(chunk.audio)
|
||||
|
||||
elif AudioStop.is_type(event.type):
|
||||
break
|
||||
|
||||
finally:
|
||||
if wav_file is not None:
|
||||
wav_file.close()
|
||||
|
||||
finally:
|
||||
await client.disconnect()
|
||||
|
||||
|
||||
def speak(text):
|
||||
if not text or not text.strip():
|
||||
return
|
||||
|
||||
fd, wav_path = tempfile.mkstemp(
|
||||
prefix="alfred_piper_",
|
||||
suffix=".wav"
|
||||
)
|
||||
os.close(fd)
|
||||
|
||||
start = time.monotonic()
|
||||
|
||||
try:
|
||||
print("[ALFRED PIPER] Synthèse...", flush=True)
|
||||
|
||||
asyncio.run(synthesize(text, wav_path))
|
||||
|
||||
print("[ALFRED PIPER] Lecture...", flush=True)
|
||||
|
||||
subprocess.run(
|
||||
["aplay", "-q", "-D", AUDIO_DEVICE, wav_path],
|
||||
check=True,
|
||||
timeout=120
|
||||
)
|
||||
|
||||
print(
|
||||
f"[ALFRED PIPER] Terminé en {time.monotonic() - start:.2f}s",
|
||||
flush=True
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ALFRED PIPER] Erreur : {e}", flush=True)
|
||||
raise
|
||||
finally:
|
||||
if os.path.exists(wav_path):
|
||||
os.remove(wav_path)
|
||||
@@ -0,0 +1,80 @@
|
||||
<!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>
|
||||
<link rel="stylesheet" href="/static/css/alfred-admin-v2.css?v=2">
|
||||
<script defer src="/static/js/alfred-admin-v2.js?v=2"></script>
|
||||
<!-- ALFRED_ADMIN_SURVEILLANCE_V2 -->
|
||||
<!-- ALFRED_ADMIN_GLASS_MAISON_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/alfred-admin-glass.css?v=1">
|
||||
</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>
|
||||
|
||||
<!-- ALFRED_ADMIN_SURVEILLANCE_V2 : reconnaissance vocale distante -->
|
||||
<section class="panel admin-v2-stt-panel">
|
||||
<div class="panel-title"><span class="panel-symbol">◉</span><div><h2>Reconnaissance vocale (STT)</h2><p>Vosk local ou Whisper Wyoming distant · configuration effective après redémarrage</p></div></div>
|
||||
<div class="field"><label for="STT_ENGINE">Moteur de transcription final</label>
|
||||
<select id="STT_ENGINE" data-setting><option value="vosk">Vosk local (mode historique)</option><option value="wyoming">Whisper Wyoming distant (conseillé)</option></select>
|
||||
<small>Le mot d’activation et le texte provisoire restent gérés par Vosk. Whisper améliore la transcription finale envoyée à Hermes.</small></div>
|
||||
<div class="admin-v2-form-grid">
|
||||
<div class="field"><label for="WYOMING_WHISPER_HOST">Serveur Wyoming Whisper</label><input id="WYOMING_WHISPER_HOST" data-setting placeholder="192.168.1.13" autocomplete="off"><small>Adresse IP ou nom DNS, sans http://</small></div>
|
||||
<div class="field"><label for="WYOMING_WHISPER_PORT">Port TCP Wyoming</label><input type="number" min="1" max="65535" id="WYOMING_WHISPER_PORT" data-setting placeholder="10300"></div>
|
||||
<div class="field"><label for="WYOMING_WHISPER_LANGUAGE">Langue</label><input id="WYOMING_WHISPER_LANGUAGE" data-setting maxlength="2" placeholder="fr"></div>
|
||||
<div class="field"><label for="WYOMING_WHISPER_TIMEOUT">Délai maximum transcription (s)</label><input id="WYOMING_WHISPER_TIMEOUT" type="number" min="5" max="90" step="0.5" data-setting></div>
|
||||
<div class="field"><label for="WYOMING_WHISPER_CONNECT_TIMEOUT">Délai de connexion (s)</label><input id="WYOMING_WHISPER_CONNECT_TIMEOUT" type="number" min="1" max="15" step="0.5" data-setting></div>
|
||||
<div class="field"><label for="STT_MAX_AUDIO_SECONDS">Durée audio maximale (s)</label><input id="STT_MAX_AUDIO_SECONDS" type="number" min="5" max="60" step="1" data-setting></div>
|
||||
</div>
|
||||
<p class="admin-v2-help">Le modèle Whisper (<strong>small</strong> actuellement) et son vocabulaire technique (<code>--initial-prompt</code> pour Proxmox, Wazuh…) se configurent <strong>dans le conteneur Docker distant</strong>, pas sur Alfred. En cas de panne Whisper, retour automatique à Vosk si le correctif STT V1 est installé.</p>
|
||||
</section>
|
||||
<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>
|
||||
<script src="/static/js/alfred-admin-glass.js?v=1"></script>
|
||||
<!-- ALFRED_IDENTITY_GLASS_V1 -->
|
||||
<script src="/static/js/alfred-name-dynamic.js?v=1" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user