v2.2 - Small improvement Interface change
This commit is contained in:
+514
@@ -0,0 +1,514 @@
|
||||
"""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
|
||||
|
||||
|
||||
# ALFRED_DUPLEX_BARGE_IN_V1: values saved with existing /config API.
|
||||
FIELDS.update({
|
||||
'DUPLEX_MODE': ('general', 'duplex_mode'),
|
||||
'DUPLEX_MIC_THRESHOLD': ('general', 'duplex_threshold'),
|
||||
})
|
||||
DEFAULTS.update({'DUPLEX_MODE': 'wake', 'DUPLEX_MIC_THRESHOLD': '0.025'})
|
||||
|
||||
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]
|
||||
# ALFRED_DUPLEX_BARGE_IN_V1
|
||||
if kind == 'duplex_mode':
|
||||
if value not in ('off', 'wake', 'natural'):
|
||||
raise ValueError('Mode duplex invalide.')
|
||||
return value
|
||||
if kind == 'duplex_threshold':
|
||||
if isinstance(value, bool):
|
||||
raise ValueError('Seuil microphone invalide.')
|
||||
try:
|
||||
threshold = float(value)
|
||||
except (ValueError, TypeError, OverflowError):
|
||||
raise ValueError('Seuil microphone invalide.')
|
||||
if not 0.008 <= threshold <= 0.30 or not __import__('math').isfinite(threshold):
|
||||
raise ValueError('Seuil microphone compris entre 0.008 et 0.30.')
|
||||
return f'{threshold:.3f}'
|
||||
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)
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
"""Barge-in local during a Wyoming Piper response.
|
||||
|
||||
NOT an acoustic echo canceller or full-duplex dialogue engine. The monitor
|
||||
listens only while Alfred is speaking and interrupts *playback*; the user can
|
||||
then utter the next command after the wake listener resumes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import queue
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import unicodedata
|
||||
from collections import deque
|
||||
|
||||
|
||||
def normalize(text: str) -> str:
|
||||
text = unicodedata.normalize('NFKD', (text or '').lower())
|
||||
cleaned = re.sub(r'[^a-z0-9 ]+', ' ', ''.join(c for c in text if not unicodedata.combining(c)))
|
||||
return ' '.join(cleaned.split())
|
||||
|
||||
|
||||
def looks_like_echo(candidate: str, spoken: str) -> bool:
|
||||
"""Conservative rejection of text already spoken by the assistant.
|
||||
|
||||
This is NOT AEC; overlapping real speech can still be missed or misheard.
|
||||
"""
|
||||
candidate = normalize(candidate)
|
||||
spoken = normalize(spoken)
|
||||
if not candidate or not spoken:
|
||||
return False
|
||||
if candidate in spoken:
|
||||
return True
|
||||
words = candidate.split()
|
||||
if len(words) >= 3:
|
||||
matching = sum(w in set(spoken.split()) for w in words)
|
||||
return matching / len(words) >= 0.85
|
||||
return False
|
||||
|
||||
|
||||
def config() -> dict:
|
||||
mode = os.getenv('DUPLEX_MODE', 'wake').strip().lower()
|
||||
if mode not in {'off', 'wake', 'natural'}:
|
||||
mode = 'off'
|
||||
try:
|
||||
threshold = float(os.getenv('DUPLEX_MIC_THRESHOLD', '0.025'))
|
||||
except ValueError:
|
||||
threshold = 0.025
|
||||
return {'mode': mode, 'threshold': max(0.008, min(threshold, 0.3)),
|
||||
'wake_word': os.getenv('WAKE_WORD', 'alfred').strip().lower(),
|
||||
'aec': 'external_required', 'limitations':
|
||||
'Interruption de la lecture Piper uniquement ; sans AEC externe le mode naturel peut se declencher sur le haut-parleur.'}
|
||||
|
||||
|
||||
class DuplexMonitor:
|
||||
def __init__(self, *, muted, on_interrupt, on_status=None):
|
||||
self.muted = muted
|
||||
self.on_interrupt = on_interrupt
|
||||
self.on_status = on_status or (lambda status: None)
|
||||
self._stop = threading.Event()
|
||||
self._thread = None
|
||||
self._lock = threading.RLock()
|
||||
self._interrupted = False
|
||||
self._last_error = None
|
||||
|
||||
def start(self, spoken_text: str) -> bool:
|
||||
settings = config()
|
||||
if settings['mode'] == 'off' or self.muted():
|
||||
return False
|
||||
with self._lock:
|
||||
if self._thread and self._thread.is_alive():
|
||||
return False
|
||||
self._stop.clear()
|
||||
self._interrupted = False
|
||||
self._last_error = None
|
||||
self._thread = threading.Thread(target=self._listen,
|
||||
args=(spoken_text, settings), daemon=True,
|
||||
name='alfred-duplex-monitor')
|
||||
self._thread.start()
|
||||
return True
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop.set()
|
||||
# Don't hold the lock when joining: callback also acquires it.
|
||||
t = self._thread
|
||||
if t and t is not threading.current_thread():
|
||||
t.join(timeout=0.8)
|
||||
|
||||
def status(self):
|
||||
return {'active': bool(self._thread and self._thread.is_alive() and not self._stop.is_set()),
|
||||
'interrupted': self._interrupted, 'error': self._last_error, **config()}
|
||||
|
||||
def _fire(self, mode):
|
||||
with self._lock:
|
||||
if self._interrupted or self._stop.is_set() or self.muted():
|
||||
return
|
||||
self._interrupted = True
|
||||
self._stop.set()
|
||||
print(f'[ALFRED DUPLEX] Interruption vocale : mode={mode}', flush=True)
|
||||
try:
|
||||
self.on_interrupt()
|
||||
except Exception as exc:
|
||||
self._last_error = str(exc)
|
||||
print(f'[ALFRED DUPLEX] Arrêt lecture impossible : {exc}', flush=True)
|
||||
try:
|
||||
self.on_status({'mode': mode, 'interrupted': True})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _listen(self, spoken_text, settings):
|
||||
try:
|
||||
import sounddevice as sd
|
||||
import numpy as np
|
||||
from vosk import KaldiRecognizer
|
||||
from services.audio import get_vosk_model
|
||||
rate = int(os.getenv('VOSK_SAMPLE_RATE', '16000'))
|
||||
model_path = os.getenv('WAKE_VOSK_MODEL_PATH') if settings['mode'] == 'wake' else os.getenv('VOSK_MODEL_PATH')
|
||||
if not model_path or not os.path.exists(model_path):
|
||||
raise RuntimeError('Modèle Vosk du mode duplex introuvable')
|
||||
if settings['mode'] == 'wake':
|
||||
grammar = json.dumps([settings['wake_word'], '[unk]'], ensure_ascii=False)
|
||||
recognizer = KaldiRecognizer(get_vosk_model(model_path), rate, grammar)
|
||||
else:
|
||||
recognizer = KaldiRecognizer(get_vosk_model(model_path), rate)
|
||||
frames = max(320, rate // 10)
|
||||
chunks = queue.Queue(maxsize=24)
|
||||
def callback(indata, _frames, _time_info, _status):
|
||||
try:
|
||||
chunks.put_nowait(bytes(indata))
|
||||
except queue.Full:
|
||||
pass
|
||||
device = os.getenv('DUPLEX_INPUT_DEVICE') or os.getenv('AUDIO_INPUT_DEVICE') or None
|
||||
started = time.monotonic()
|
||||
stable_text = None
|
||||
stable_since = 0.0
|
||||
recent_levels = deque(maxlen=10)
|
||||
# The initial audio output may contain a transient click.
|
||||
with sd.RawInputStream(samplerate=rate, blocksize=frames,
|
||||
dtype='int16', channels=1, callback=callback, device=device):
|
||||
while not self._stop.is_set() and not self.muted():
|
||||
try:
|
||||
block = chunks.get(timeout=0.12)
|
||||
except queue.Empty:
|
||||
continue
|
||||
if time.monotonic() - started < 0.55:
|
||||
continue
|
||||
pcm = np.frombuffer(block, dtype=np.int16).astype(np.float32)
|
||||
rms = float(np.sqrt(np.mean(pcm * pcm))) / 32768.0 if pcm.size else 0.0
|
||||
recent_levels.append(rms)
|
||||
speech_level = max(recent_levels, default=0.0)
|
||||
final = recognizer.AcceptWaveform(block)
|
||||
payload = json.loads(recognizer.Result() if final else recognizer.PartialResult())
|
||||
heard = normalize((payload.get('text') if final else payload.get('partial')) or '')
|
||||
if not heard or looks_like_echo(heard, spoken_text):
|
||||
stable_text = None
|
||||
continue
|
||||
if settings['mode'] == 'wake':
|
||||
# Vosk keyword grammar already constrains the vocabulary.
|
||||
if settings['wake_word'] in heard.split() and speech_level >= settings['threshold']:
|
||||
self._fire('wake')
|
||||
break
|
||||
else:
|
||||
if speech_level < settings['threshold']:
|
||||
continue
|
||||
words = heard.split()
|
||||
if len(words) < 2 and heard not in ('stop', 'arrete', 'attends', 'stoppe'):
|
||||
continue
|
||||
if final:
|
||||
self._fire('natural')
|
||||
break
|
||||
if heard != stable_text:
|
||||
stable_text = heard
|
||||
stable_since = time.monotonic()
|
||||
elif time.monotonic() - stable_since >= 0.3:
|
||||
self._fire('natural')
|
||||
break
|
||||
except Exception as exc:
|
||||
self._last_error = str(exc)
|
||||
print(f'[ALFRED DUPLEX] Capture indisponible : {exc}', flush=True)
|
||||
Reference in New Issue
Block a user