v2.1 - Small improvement

This commit is contained in:
pporcheret
2026-10-28 15:38:38 +01:00
parent 5f82b1bdf2
commit a6169bcfdb
22 changed files with 1138 additions and 4 deletions
@@ -0,0 +1,431 @@
"""Authenticated local configuration and health UI for Alfred.
Registration is additive and doesn't replace the existing audio, camera, HA,
weather or Hermes endpoints. Passwords are provisioned locally via CLI.
"""
from __future__ import annotations
import functools
import hmac
import json
import os
import re
import secrets
import shutil
import socket
import subprocess
import threading
import time
from datetime import datetime, timedelta, timezone
from pathlib import Path
from urllib.parse import urlsplit
import requests
from dotenv import dotenv_values
from flask import Blueprint, current_app, jsonify, render_template, request, session
from werkzeug.security import check_password_hash
ROOT = Path(__file__).resolve().parents[1]
AUTH_FILE = ROOT / 'config' / 'admin_auth.json'
GENERAL_ENV = ROOT / 'config' / 'control_center.env'
WEATHER_ENV = ROOT / 'config' / 'weather_outfits.env'
CONFIG_LOCK = threading.RLock()
LOGIN_LOCK = threading.Lock()
LOGIN_ATTEMPTS = {}
# Only these keys are writable; no token, command, system file or shell input.
FIELDS = {
'ALFRED_NAME': ('general', 'text'),
'WAKE_WORD': ('general', 'wake'),
'PIPER_VOICE': ('general', 'voice'),
'ALFRED_OLLAMA_URL': ('general', 'url'),
'ALFRED_COMFYUI_URL': ('weather', 'url'),
'HA_WEATHER_ENTITY': ('general', 'entity'),
'ALFRED_WEATHER_ILLUSTRATION_ATTEMPTS': ('weather', 'attempts'),
'ALFRED_WEATHER_ILLUSTRATION_TARGET_SCORE': ('weather', 'score'),
'ALFRED_WEATHER_DISPLAY_SIZE': ('weather', 'size'),
'ALFRED_WEATHER_REVIEW': ('weather', 'boolean'),
'ALFRED_WEATHER_REFERENCE_MODE': ('weather', 'reference_mode'),
'ALFRED_WEATHER_REFERENCE_DENOISE': ('weather', 'denoise'),
'ALFRED_WEATHER_PRESERVE_FACE': ('weather', 'boolean'),
'ALFRED_WEATHER_ACCESSORY_LIMIT': ('weather', 'accessory'),
}
DEFAULTS = {
'ALFRED_NAME': 'Alfred', 'WAKE_WORD': 'alfred',
'PIPER_VOICE': 'fr_FR-tom-medium', 'ALFRED_OLLAMA_URL': '',
'ALFRED_COMFYUI_URL': '', 'HA_WEATHER_ENTITY': '',
'ALFRED_WEATHER_ILLUSTRATION_ATTEMPTS': '2',
'ALFRED_WEATHER_ILLUSTRATION_TARGET_SCORE': '8.6',
'ALFRED_WEATHER_DISPLAY_SIZE': '350',
'ALFRED_WEATHER_REVIEW': 'true',
'ALFRED_WEATHER_REFERENCE_MODE': 'auto',
'ALFRED_WEATHER_REFERENCE_DENOISE': '0.56',
'ALFRED_WEATHER_PRESERVE_FACE': 'true',
'ALFRED_WEATHER_ACCESSORY_LIMIT': '1',
}
def _auth_config():
try:
return json.loads(AUTH_FILE.read_text(encoding='utf-8'))
except (OSError, ValueError):
return {}
def _authenticated():
config = _auth_config()
password_hash = config.get('password_hash', '')
if not password_hash:
return False
# Changing the admin password instantly invalidates old signed sessions.
expected_version = __import__('hashlib').sha256(password_hash.encode()).hexdigest()[:24]
return bool(session.get('alfred_admin') and
hmac.compare_digest(str(session.get('auth_version', '')), expected_version))
def _valid_csrf():
token = request.headers.get('X-Alfred-CSRF', '')
expected = session.get('alfred_csrf', '')
return bool(token and expected and hmac.compare_digest(token, expected))
def _trusted_request():
origin = request.headers.get('Origin', '')
if origin:
parsed = urlsplit(origin)
if parsed.netloc.lower() != request.host.lower() or parsed.scheme not in ('http', 'https'):
return False
if request.headers.get('Sec-Fetch-Site', '').lower() == 'cross-site':
return False
return True
def _require_admin(handler):
@functools.wraps(handler)
def wrapped(*args, **kwargs):
if not _authenticated():
return jsonify(error='Authentification administrateur nécessaire.'), 401
if request.method != 'GET' and (not _valid_csrf() or not _trusted_request()):
return jsonify(error='Protection anti-CSRF : requête refusée.'), 403
return handler(*args, **kwargs)
return wrapped
def _validate(key, value):
if key not in FIELDS:
raise ValueError(f'Paramètre non autorisé : {key}.')
kind = FIELDS[key][1]
if kind == 'boolean':
if value not in (True, False, 'true', 'false'):
raise ValueError(f'{key} doit être vrai ou faux.')
return 'true' if value in (True, 'true') else 'false'
if isinstance(value, bool):
raise ValueError(f'Valeur invalide pour {key}.')
value = str(value).strip()
if any(c in value for c in ('\r', '\n', '\x00', '"', "'", '\\', '$', '`')):
raise ValueError(f'Caractères interdits pour {key}.')
if kind == 'text':
if not 2 <= len(value) <= 30:
raise ValueError('Le nom doit contenir entre 2 et 30 caractères.')
elif kind == 'wake':
if not re.fullmatch(r'[A-Za-zÀ-ÖØ-öø-ÿ]{2,22}', value):
raise ValueError('Le mot-clé Vosk doit être un mot simple de 2 à 22 lettres.')
value = value.lower()
elif kind == 'voice':
if not re.fullmatch(r'[A-Za-z0-9_.-]{2,96}', value):
raise ValueError('Identifiant de voix Piper invalide.')
elif kind == 'url':
if not value:
return ''
parsed = urlsplit(value)
if (parsed.scheme not in ('http', 'https') or not parsed.hostname or
parsed.username or parsed.password or parsed.path not in ('', '/') or
parsed.query or parsed.fragment):
raise ValueError(f'{key} attend une URL http(s) de serveur sans chemin ni identifiants.')
if parsed.port and not 1 <= parsed.port <= 65535:
raise ValueError('Port réseau invalide.')
value = value.rstrip('/')
elif kind == 'entity':
if value and not re.fullmatch(r'weather\.[a-z0-9_]{2,80}', value):
raise ValueError('Attendu : weather.identifiant ou une valeur vide.')
elif kind == 'attempts':
if value not in ('1', '2', '3'):
raise ValueError('Nombre d’essais autorisé : 1, 2 ou 3 par personnage.')
elif kind == 'score':
value = str(float(value))
if not 1 <= float(value) <= 10:
raise ValueError('Score entre 1 et 10.')
elif kind == 'size':
if value not in ('250', '300', '350', '400', '450', '500'):
raise ValueError('Taille autorisée : 250, 300, 350, 400, 450 ou 500 px.')
elif kind == 'denoise':
value = str(float(value))
if not 0.25 <= float(value) <= 0.85:
raise ValueError('Denoise entre 0.25 et 0.85.')
elif kind == 'reference_mode':
if value not in ('auto', 'required', 'off'):
raise ValueError('Mode référence : auto, required ou off.')
elif kind == 'accessory':
if value not in ('0', '1', '2'):
raise ValueError('Accessoires visibles autorisés : 0, 1 ou 2.')
return value
def _read_config():
local = dict(dotenv_values(GENERAL_ENV)) if GENERAL_ENV.exists() else {}
weather = dict(dotenv_values(WEATHER_ENV)) if WEATHER_ENV.exists() else {}
effective = {}
for key, (group, _) in FIELDS.items():
env_file = local if group == 'general' else weather
# File values take precedence on the next restart. Show saved values.
effective[key] = str(env_file.get(key) if env_file.get(key) is not None
else os.getenv(key, DEFAULTS[key]))
return effective
def _save_env(path: Path, changes: dict):
path.parent.mkdir(parents=True, exist_ok=True)
existing = path.read_text(encoding='utf-8') if path.exists() else ''
lines = existing.splitlines()
updated = set()
result = []
for line in lines:
match = re.match(r'^\s*([A-Z][A-Z0-9_]*)\s*=', line)
if match and match.group(1) in changes:
name = match.group(1)
if name not in updated:
result.append(f'{name}={changes[name]}')
updated.add(name)
else:
result.append(line)
for key, value in changes.items():
if key not in updated:
result.append(f'{key}={value}')
tmp = path.with_name(path.name + f'.{secrets.token_hex(4)}.tmp')
try:
with tmp.open('w', encoding='utf-8') as fp:
fp.write('\n'.join(result).rstrip() + '\n')
fp.flush()
os.fsync(fp.fileno())
os.chmod(tmp, 0o600)
tmp.replace(path)
finally:
tmp.unlink(missing_ok=True)
def _probe_http(url, *, token=None, timeout=2):
start = time.monotonic()
try:
response = requests.get(url, headers={'Authorization': f'Bearer {token}'} if token else {},
timeout=timeout, allow_redirects=False)
if 200 <= response.status_code < 300:
state = 'ok'
detail = f'HTTP {response.status_code}'
elif response.status_code in (401, 403):
state, detail = 'warning', f'Connexion établie, accès refusé ({response.status_code})'
else:
state, detail = 'warning', f'HTTP {response.status_code}'
except requests.RequestException:
state, detail = 'down', 'Aucune réponse HTTP'
return {'state': state, 'detail': detail, 'latency_ms': round((time.monotonic()-start)*1000)}
def _probe_tcp(host, port, timeout=1.5):
try:
with socket.create_connection((host, int(port)), timeout=timeout):
return {'state': 'ok', 'detail': f'Port {port} accessible'}
except (OSError, ValueError):
return {'state': 'down', 'detail': f'Port {port} inaccessible'}
def _weather_comfy_url():
# Respect the V5 precedence if possible, without reaching into credentials.
settings = dict(dotenv_values(WEATHER_ENV)) if WEATHER_ENV.exists() else {}
return (_read_config()['ALFRED_COMFYUI_URL'] or settings.get('ALFRED_COMFYUI_URL') or
os.getenv('ALFRED_COMFYUI_URL') or os.getenv('COMFYUI_URL') or
dict(dotenv_values('/opt/hermes/MCP_comfyui/.env')).get('COMFYUI_URL') or '')
def _health(wake, audio_settings):
now = datetime.now(timezone.utc).isoformat()
audio = audio_settings.get_settings()
try:
wake_state = wake.status()
except Exception:
wake_state = {'running': False, 'paused': True, 'error': 'Impossible de lire Vosk'}
cam_index = int(os.getenv('CAMERA_INDEX', '0'))
camera_path = Path(f'/dev/video{cam_index}')
camera_ok = camera_path.exists() and os.access(camera_path, os.R_OK)
# Checking /dev/videoN doesn't open the device: no conflict with facial recognition.
camera = {'state':'ok' if camera_ok else 'warning',
'detail':f'{camera_path} détectée' if camera_ok else f'{camera_path} indisponible ou permissions insuffisantes'}
mute_micro = audio.get('mic_muted', False)
microphone = {'state':'muted' if mute_micro else ('ok' if wake_state.get('running') else 'warning'),
'detail':'Micro coupé volontairement' if mute_micro else ('Détection Vosk active' if wake_state.get('running') and not wake_state.get('paused') else 'Vosk non disponible ou en pause')}
loudspeaker = {'state':'muted' if audio.get('speaker_muted') else 'ok',
'detail':'Son coupé' if audio.get('speaker_muted') else f"Volume {audio.get('volume', '?')} %"}
config = _read_config()
comfy = _weather_comfy_url()
ollama = config.get('ALFRED_OLLAMA_URL') or os.getenv('OLLAMA_HOST') or os.getenv('OLLAMA_URL')
ha_base = os.getenv('HA_URL', '').rstrip('/')
tts_host = os.getenv('PIPER_HOST', '').strip()
tts_port = os.getenv('PIPER_PORT', '10200')
services = {'alfred': {'state':'ok', 'detail':'Serveur Flask actif'},
'hermes_cli': {'state':'ok' if shutil.which('hermes') else 'warning',
'detail':'CLI détectée' if shutil.which('hermes') else 'CLI non détectée dans le PATH du service'}}
services['hermes_dashboard'] = _probe_tcp('127.0.0.1', 9119)
services['hermes_dashboard']['label'] = 'Tableau de bord (optionnel)'
services['comfyui'] = _probe_http(comfy.rstrip('/') + '/system_stats') if comfy else {'state':'unknown','detail':'URL non configurée'}
services['ollama'] = _probe_http(ollama.rstrip('/') + '/api/tags') if ollama else {'state':'unknown','detail':'URL non configurée'}
services['home_assistant'] = _probe_http(ha_base + '/api/', token=os.getenv('HA_TOKEN')) if ha_base else {'state':'unknown','detail':'URL non configurée'}
services['wyoming_piper'] = _probe_tcp(tts_host, tts_port) if tts_host else {'state':'unknown','detail':'Wyoming Piper non configuré (Piper local possible)'}
mcp_path = Path('/opt/hermes')
mcps = {}
for slug, label in [('MCP_home_assistant','Home Assistant'), ('MCP_comfyui','ComfyUI'), ('MCP_face_recognition','Reconnaissance faciale'),('MCP_wazuh','Wazuh')]:
found = (mcp_path / slug).exists()
mcps[slug] = {'state':'ok' if found else 'unknown', 'detail':'Dossier présent (connexion non vérifiée)' if found else 'Dossier absent', 'label':label}
disk = shutil.disk_usage(ROOT)
mem = {}
try:
info = {}
for line in Path('/proc/meminfo').read_text().splitlines():
k, _, v = line.partition(':')
if k in ('MemTotal','MemAvailable'):
info[k] = int(v.strip().split()[0]) * 1024
if info.get('MemTotal'):
mem = {'total': info['MemTotal'], 'used': info['MemTotal'] - info.get('MemAvailable', 0)}
except (OSError, ValueError):
pass
return {'time': now, 'audio':audio, 'wake':wake_state, 'camera':camera,
'microphone':microphone, 'speaker':loudspeaker, 'services':services, 'mcps':mcps,
'system':{'disk_total':disk.total,'disk_used':disk.used, 'memory':mem,
'load_1m':round(os.getloadavg()[0], 2) if hasattr(os,'getloadavg') else None}}
def register_admin_console(app, wake, audio_settings):
"""Register pages/API on the existing Flask app. Call exactly once."""
secret_file = ROOT / 'config' / 'admin_secret.key'
if not secret_file.exists():
raise RuntimeError('Secret administrateur absent ; exécuter install_patch.py.')
app.secret_key = secret_file.read_bytes()
app.config.update(SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SAMESITE='Strict',
SESSION_COOKIE_SECURE=os.getenv('ALFRED_ADMIN_HTTPS', 'false').lower() == 'true',
PERMANENT_SESSION_LIFETIME=timedelta(hours=8))
bp = Blueprint('alfred_admin', __name__)
@bp.get('/config')
@bp.get('/config.html')
def config_page():
return render_template('config.html')
@bp.get('/surveillance')
@bp.get('/surveillance.html')
def surveillance_page():
return render_template('surveillance.html')
@bp.get('/api/control/auth')
def auth_status():
authed = _authenticated()
return jsonify(authenticated=authed, configured=bool(_auth_config().get('password_hash')),
csrf=session.get('alfred_csrf') if authed else None)
@bp.post('/api/control/login')
def login():
if not _trusted_request() or not request.is_json:
return jsonify(error='Requête refusée.'), 403
data = request.get_json(silent=True) or {}
password = data.get('password', '')
if not isinstance(password, str) or len(password) > 500:
return jsonify(error='Mot de passe incorrect.'), 400
now = time.monotonic()
client = request.remote_addr or 'unknown'
with LOGIN_LOCK:
events = [x for x in LOGIN_ATTEMPTS.get(client, []) if x > now - 900]
LOGIN_ATTEMPTS[client] = events
if len(events) >= 5:
return jsonify(error='Trop de tentatives : réessaie plus tard.'), 429
config = _auth_config()
password_hash = config.get('password_hash')
if not password_hash:
return jsonify(error='Définis d’abord le mot de passe avec set_admin_password.py.'), 503
if not check_password_hash(password_hash, password):
with LOGIN_LOCK:
LOGIN_ATTEMPTS[client].append(now)
return jsonify(error='Mot de passe incorrect.'), 401
with LOGIN_LOCK:
LOGIN_ATTEMPTS.pop(client, None)
session.clear()
session.permanent = True
session['alfred_admin'] = True
session['auth_version'] = __import__('hashlib').sha256(password_hash.encode()).hexdigest()[:24]
session['alfred_csrf'] = secrets.token_urlsafe(32)
return jsonify(authenticated=True, csrf=session['alfred_csrf'])
@bp.post('/api/control/logout')
@_require_admin
def logout():
session.clear()
return jsonify(ok=True)
@bp.get('/api/control/config')
@_require_admin
def config_get():
return jsonify(values=_read_config(), note='La plupart des réglages nécessitent un redémarrage.')
@bp.post('/api/control/config')
@_require_admin
def config_update():
data = request.get_json(silent=True)
if not isinstance(data, dict) or len(data) > len(FIELDS):
return jsonify(error='JSON invalide.'), 400
try:
normalized = {key: _validate(key, value) for key, value in data.items()}
except (ValueError, TypeError, OverflowError) as exc:
return jsonify(error=str(exc)), 400
general = {k:v for k,v in normalized.items() if FIELDS[k][0]=='general'}
weather = {k:v for k,v in normalized.items() if FIELDS[k][0]=='weather'}
try:
with CONFIG_LOCK:
if general: _save_env(GENERAL_ENV, general)
if weather: _save_env(WEATHER_ENV, weather)
except OSError as exc:
return jsonify(error=f'Impossible d’enregistrer : {exc}'), 500
return jsonify(ok=True, restart_required=True, values=_read_config())
@bp.get('/api/control/health')
@_require_admin
def health():
return jsonify(_health(wake, audio_settings))
@bp.post('/api/control/restart')
@_require_admin
def restart():
if os.getenv('ALFRED_ALLOW_WEB_RESTART', 'false').lower() not in ('true', '1', 'yes'):
return jsonify(error='Redémarrage Web désactivé. Activer ALFRED_ALLOW_WEB_RESTART=true (voir README).'), 403
systemctl = shutil.which('systemctl')
runner = shutil.which('systemd-run')
if not systemctl or not runner:
return jsonify(error='systemd-run ou systemctl absent. Redémarrer depuis le terminal.'), 503
# Schedule restart in a DIFFERENT systemd unit/cgroup so stopping Alfred
# cannot kill the restart command mid-flight.
command = [runner, '--unit=alfred-web-restart', '--on-active=2s', '--collect',
systemctl, 'restart', 'alfred.service']
if os.geteuid() != 0:
command = ['sudo', '-n', *command]
def do_restart():
time.sleep(0.5)
try:
result = subprocess.run(command, capture_output=True, text=True, timeout=12, check=False)
if result.returncode:
app.logger.error('Planification du redémarrage refusée (%s): %s',
result.returncode, result.stderr[:350])
except (OSError, subprocess.TimeoutExpired) as exc:
app.logger.error('Planification du redémarrage échouée: %s', exc)
threading.Thread(target=do_restart, daemon=True).start()
return jsonify(accepted=True, message='Demande de redémarrage transmise ; contrôler le retour du service.') , 202
app.register_blueprint(bp)
@@ -0,0 +1,183 @@
(() => {
'use strict';
const $ = id => document.getElementById(id);
const page = document.body.dataset.page;
const state = {csrf: null, logged: false, baseline: {}};
const fields = [...document.querySelectorAll('[data-setting]')];
let polling = null;
const fetchJSON = async (url, options = {}) => {
const headers = { ...(options.headers || {}) };
if (options.body) headers['Content-Type'] = 'application/json';
if (options.method && options.method !== 'GET' && state.csrf) headers['X-Alfred-CSRF'] = state.csrf;
const response = await fetch(url, {...options, credentials: 'same-origin', cache:'no-store', headers});
let data;
try { data = await response.json(); } catch { data = {}; }
if (response.status === 401 && url !== '/api/control/login') { showLogin(); throw new Error('Session expirée. Reconnecte-toi.'); }
if (!response.ok) throw new Error(data.error || 'Erreur HTTP '+response.status);
return data;
};
const setMessage = (message, bad=false) => {
const el = $('message'); if (!el) return;
el.textContent = message; el.style.color = bad ? '#f1aaaa' : '#a1dcbe';
};
const showLogin = () => {
state.logged = false; $('mainArea').hidden = true; $('authScreen').hidden = false;
if (polling) clearInterval(polling); polling = null;
};
const showPage = () => { state.logged=true; $('mainArea').hidden=false; $('authScreen').hidden=true; };
const getFieldValue = el => el.type === 'checkbox' ? el.checked : el.value;
function configDirty() {
const changed = fields.some(el => String(getFieldValue(el)) !== String(state.baseline[el.id] ?? ''));
if ($('saveState')) $('saveState').textContent = changed ? 'Modifications non enregistrées' : 'Aucun changement';
}
function markInput() {
if ($('scoreOut')) $('scoreOut').textContent = Number($('ALFRED_WEATHER_ILLUSTRATION_TARGET_SCORE').value || 8.6).toFixed(1) + ' / 10';
if ($('denoiseOut')) $('denoiseOut').textContent = Number($('ALFRED_WEATHER_REFERENCE_DENOISE').value || .56).toFixed(2);
configDirty();
}
async function loadConfig() {
const values = (await fetchJSON('/api/control/config')).values || {};
fields.forEach(el => {
if (el.type==='checkbox') el.checked = String(values[el.id]).toLowerCase() === 'true';
else el.value = values[el.id] ?? '';
state.baseline[el.id] = getFieldValue(el);
});
markInput();
}
async function saveConfig() {
const values = {};
fields.forEach(el => { values[el.id] = getFieldValue(el); });
try {
const result = await fetchJSON('/api/control/config', {method:'POST', body:JSON.stringify(values)});
state.baseline = {};
fields.forEach(el => state.baseline[el.id] = getFieldValue(el));
configDirty();
$('pendingNotice').hidden = false;
setMessage(result.ok ? 'Configuration sauvegardée.' : 'Enregistrement inconnu.');
} catch (error) { setMessage(error.message, true); }
}
function serviceStatus(el, data) {
if (!el) return;
const labels = {ok:'Disponible',down:'Indisponible',warning:'Attention',muted:'Coupé',unknown:'Non vérifié'};
el.dataset.state = data.state || 'unknown';
el.textContent = labels[data.state] || 'Inconnu';
}
const safeText = (id, value) => { if ($(id)) $(id).textContent = String(value ?? '—'); };
const readable = value => {
const x = Number(value) || 0;
if (x >= 1073741824) return (x / 1073741824).toFixed(1) + ' Go';
return (x / 1048576).toFixed(1) + ' Mo';
};
function renderHealth(data) {
const timestamp = new Date(data.time).toLocaleTimeString('fr-FR', {hour:'2-digit',minute:'2-digit',second:'2-digit'});
safeText('updatedAt', timestamp);
serviceStatus($('cameraStatus'), data.camera); safeText('cameraDetail',data.camera?.detail);
serviceStatus($('microStatus'), data.microphone); safeText('microDetail',data.microphone?.detail);
serviceStatus($('speakerStatus'), data.speaker); safeText('speakerDetail',data.speaker?.detail);
const wake = data.wake || {};
serviceStatus($('wakeStatus'), {state:!wake.running?'down':wake.paused?'warning':'ok'});
safeText('wakeLabel', `Vosk · ${wake.word || 'alfred'}`);
safeText('wakeDetail', wake.error || (wake.paused ? 'Détection en pause' : 'Détection active'));
const labels = {alfred:'Alfred / Flask', hermes_cli:'Hermes Agent (CLI)', hermes_dashboard:'Hermes WebUI', comfyui:'ComfyUI', ollama:'Ollama', home_assistant:'Home Assistant', wyoming_piper:'Wyoming Piper'};
const list = $('serviceList'); list.replaceChildren();
for (const [key,service] of Object.entries(data.services || {})) {
const row = document.createElement('div'); row.className='service-row';
const desc = document.createElement('div');
const title=document.createElement('div');title.className='service-label';title.textContent=labels[key] || key;
const sub=document.createElement('div');sub.className='service-sub';sub.textContent=service.detail || '';
desc.append(title,sub);
const aside=document.createElement('div');aside.className='service-tail';
if (service.latency_ms != null) {const delay=document.createElement('span');delay.className='muted microtext';delay.textContent=service.latency_ms+' ms';aside.append(delay);}
const status=document.createElement('span');status.className='status-pill';serviceStatus(status,service);aside.append(status);
row.append(desc,aside);list.append(row);
}
const sys = data.system || {};
const diskPercent = sys.disk_total ? Math.round(100*sys.disk_used/sys.disk_total) : 0;
safeText('diskPercent', diskPercent+' %');$('diskBar').style.width=diskPercent+'%';
safeText('diskText', readable(sys.disk_used)+' utilisés sur '+readable(sys.disk_total));
const mem=sys.memory || {};
const memPercent=mem.total ? Math.round(100*mem.used/mem.total) : 0;
safeText('memPercent',mem.total ? memPercent+' %' : 'N/D');$('memBar').style.width=memPercent+'%';
safeText('memText',mem.total ? readable(mem.used)+' utilisés sur '+readable(mem.total) : 'Non disponible');
safeText('loadAvg',sys.load_1m);
const mcps=$('mcpList');mcps.replaceChildren();
for (const mcp of Object.values(data.mcps || {})) {
const card=document.createElement('div');card.className='mcp-item';
const title=document.createElement('strong');title.textContent=mcp.label || 'MCP';
const status=document.createElement('span');status.className='status-pill';serviceStatus(status,mcp);
const p=document.createElement('p');p.textContent=mcp.detail;
card.append(title,status,p);mcps.append(card);
}
}
let inFlight = false;
async function refreshHealth() {
if (inFlight || document.hidden) return;
inFlight = true;
try { renderHealth(await fetchJSON('/api/control/health'));setMessage(''); }
catch(err){setMessage('Surveillance : '+err.message,true);}
finally{inFlight=false;}
}
async function loadAudio() {
try {
const d=await fetchJSON('/api/audio/settings');
$('liveVolume').value=d.volume;$('liveVolumeOut').textContent=d.volume+' %';
$('liveMic').disabled = d.mic_gain == null;
if(d.mic_gain != null)$('liveMic').value=d.mic_gain;
$('liveMicOut').textContent=d.mic_gain == null ? 'N/D' : d.mic_gain+' %';
$('muteMicro').checked=Boolean(d.mic_muted);$('muteSpeaker').checked=Boolean(d.speaker_muted);
} catch(err){safeText('audioResult',err.message);}
}
async function updateAudio(values) {
try {
const d=await fetchJSON('/api/audio/settings', {method:'POST',body:JSON.stringify(values)});
safeText('audioResult','Réglage appliqué.');
$('liveVolume').value=d.volume;$('liveVolumeOut').textContent=d.volume+' %';
$('muteMicro').checked=Boolean(d.mic_muted);$('muteSpeaker').checked=Boolean(d.speaker_muted);
} catch(err){safeText('audioResult','Erreur : '+err.message);await loadAudio();}
}
async function onLogin(event) {
event.preventDefault();
const password=$('adminPassword').value;
try {
const response=await fetchJSON('/api/control/login',{method:'POST',body:JSON.stringify({password})});
state.csrf=response.csrf;$('adminPassword').value='';await enterApp();
} catch(err){safeText('loginMessage',err.message);}
}
async function enterApp() {
showPage();
if(page==='config'){
await Promise.all([loadConfig(),loadAudio()]);
}else{
await refreshHealth();
polling=setInterval(refreshHealth,15000);
}
}
async function init() {
$('loginForm')?.addEventListener('submit', onLogin);
$('logoutButton')?.addEventListener('click', async ()=>{
try{await fetchJSON('/api/control/logout',{method:'POST'});}catch(e){}
state.csrf=null;showLogin();
});
fields.forEach(el=>{el.addEventListener('change',markInput);el.addEventListener('input',markInput);});
$('saveConfig')?.addEventListener('click',saveConfig);
$('restartButton')?.addEventListener('click',async ()=>{
if(!confirm('Redémarrer Alfred maintenant ? Les opérations en cours seront interrompues.'))return;
try{const data=await fetchJSON('/api/control/restart',{method:'POST'});setMessage(data.message||'Demande transmise.');$('restartButton').disabled=true;setTimeout(()=>$('restartButton').disabled=false,5000);}
catch(err){setMessage(err.message,true);}
});
$('testVoice')?.addEventListener('click',async()=>{
try{await fetchJSON('/api/tts/say',{method:'POST',body:JSON.stringify({text:'Bonjour, je suis Alfred. Voici un test de ma voix actuelle.'})});setMessage('Test vocal demandé.');}
catch(err){setMessage(err.message,true);}
});
let volDelay, gainDelay;
$('liveVolume')?.addEventListener('input',e=>{safeText('liveVolumeOut',e.target.value+' %');clearTimeout(volDelay);volDelay=setTimeout(()=>updateAudio({volume:Number(e.target.value)}),250);});
$('liveMic')?.addEventListener('input',e=>{safeText('liveMicOut',e.target.value+' %');clearTimeout(gainDelay);gainDelay=setTimeout(()=>updateAudio({mic_gain:Number(e.target.value)}),250);});
$('muteMicro')?.addEventListener('change',e=>updateAudio({mic_muted:e.target.checked}));
$('muteSpeaker')?.addEventListener('change',e=>updateAudio({speaker_muted:e.target.checked}));
$('refreshHealth')?.addEventListener('click',refreshHealth);
const login=await fetchJSON('/api/control/auth').catch(()=>({authenticated:false}));
if (login.authenticated){state.csrf=login.csrf;try{await enterApp();}catch(err){setMessage(err.message,true);}}
else{showLogin();if(!login.configured)safeText('loginMessage','Mot de passe non défini : exécute set_admin_password.py sur Alfred.');}
}
document.addEventListener('DOMContentLoaded',init);
})();
@@ -0,0 +1,55 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#121b25"><title>Alfred · Configuration</title>
<link rel="stylesheet" href="/static/css/alfred-admin.css">
<script defer src="/static/js/alfred-admin.js"></script>
</head>
<body data-page="config">
<div class="admin-layout">
<header class="admin-header">
<div class="brand"><span class="brand-orb">A</span><div><strong>Alfred</strong><small>Centre de contrôle</small></div></div>
<nav aria-label="Navigation"><a href="/">Accueil</a><a class="selected" href="/config">Configuration</a><a href="/surveillance">Surveillance</a><button class="text-button" type="button" id="logoutButton">Déconnexion</button></nav>
</header>
<main class="admin-main" id="mainArea" hidden>
<div class="page-top"><div><p class="eyebrow">PERSONNALISATION</p><h1>Configuration</h1><p class="muted">Paramètres de l’assistant, voix et services. Les modifications prennent effet après redémarrage.</p></div><div class="page-actions"><span id="saveState" class="tag">Aucun changement</span><button id="saveConfig" class="button primary" type="button">Enregistrer</button></div></div>
<section class="notice" id="pendingNotice" hidden>Réglages enregistrés. <strong>Redémarre Alfred</strong> pour les appliquer.</section>
<div class="two-col">
<section class="panel"><div class="panel-title"><span class="panel-symbol">✦</span><div><h2>Identité et voix</h2><p>Nom affiché, mot d'activation et voix de synthèse</p></div></div>
<div class="field"><label for="ALFRED_NAME">Nom de l’assistant</label><input id="ALFRED_NAME" data-setting placeholder="Alfred" maxlength="30"><small>Visible dans l’interface après redémarrage.</small></div>
<div class="field"><label for="WAKE_WORD">Mot de réveil (Vosk)</label><input id="WAKE_WORD" data-setting placeholder="alfred" maxlength="22"><small>Un seul mot. Le modèle Vosk doit pouvoir le reconnaître ; le mode OpenWakeWord nécessite un modèle entraîné.</small></div>
<div class="field"><label for="PIPER_VOICE">Voix Wyoming Piper</label><input id="PIPER_VOICE" data-setting placeholder="fr_FR-tom-medium"><small>Exemple : fr_FR-tom-medium. La voix doit exister sur ton serveur Wyoming ; pour Piper local, c’est le fichier modèle qui détermine la voix.</small></div>
<div class="inline"><button class="button ghost" type="button" id="testVoice">Tester la voix actuelle</button><span class="muted microtext">Utilise les réglages déjà actifs, sans redémarrage</span></div>
</section>
<section class="panel"><div class="panel-title"><span class="panel-symbol">☁</span><div><h2>Illustrations météo</h2><p>Qualité, visages de référence et accessoires ComfyUI</p></div></div>
<div class="field"><label for="ALFRED_WEATHER_ILLUSTRATION_ATTEMPTS">Essais par personnage</label><select id="ALFRED_WEATHER_ILLUSTRATION_ATTEMPTS" data-setting><option value="1">1 — Rapide</option><option value="2">2 — Équilibré</option><option value="3">3 — Qualité</option></select></div>
<div class="field"><label for="ALFRED_WEATHER_ILLUSTRATION_TARGET_SCORE">Score qualité cible</label><div class="range-field"><input type="range" min="5" max="10" step="0.1" id="ALFRED_WEATHER_ILLUSTRATION_TARGET_SCORE" data-setting><output for="ALFRED_WEATHER_ILLUSTRATION_TARGET_SCORE" id="scoreOut">8.6 / 10</output></div></div>
<div class="field"><label for="ALFRED_WEATHER_DISPLAY_SIZE">Taille d’affichage</label><select id="ALFRED_WEATHER_DISPLAY_SIZE" data-setting><option value="250">250 px</option><option value="300">300 px</option><option value="350">350 px</option><option value="400">400 px</option><option value="450">450 px</option><option value="500">500 px</option></select></div>
<div class="field switch-field"><label for="ALFRED_WEATHER_REVIEW">Analyse automatique des candidats <small>Nécessite un workflow vision compatible</small></label><input id="ALFRED_WEATHER_REVIEW" type="checkbox" data-setting></div>
<div class="field"><label for="ALFRED_WEATHER_REFERENCE_MODE">Références des visages</label><select id="ALFRED_WEATHER_REFERENCE_MODE" data-setting><option value="auto">Auto — avec repli</option><option value="required">Obligatoires</option><option value="off">Désactivées</option></select></div>
<div class="field"><label for="ALFRED_WEATHER_REFERENCE_DENOISE">Liberté de modification des vêtements</label><div class="range-field"><input id="ALFRED_WEATHER_REFERENCE_DENOISE" data-setting type="range" min="0.25" max="0.85" step="0.01"><output id="denoiseOut">0.56</output></div><small>Une valeur élevée modifie davantage la tenue, mais risque de changer le visage.</small></div>
<div class="field switch-field"><label for="ALFRED_WEATHER_PRESERVE_FACE">Préserver les visages <small>Lorsque l’image de référence est utilisable</small></label><input id="ALFRED_WEATHER_PRESERVE_FACE" type="checkbox" data-setting></div>
<div class="field"><label for="ALFRED_WEATHER_ACCESSORY_LIMIT">Accessoires visibles par personnage</label><select id="ALFRED_WEATHER_ACCESSORY_LIMIT" data-setting><option value="0">Aucun</option><option value="1">1 — Recommandé</option><option value="2">2</option></select></div>
</section>
<section class="panel"><div class="panel-title"><span class="panel-symbol">♫</span><div><h2>Audio en direct</h2><p>Volume, sensibilité et coupures immédiates</p></div></div>
<div class="field"><label for="liveVolume">Volume de sortie</label><div class="range-field"><input id="liveVolume" type="range" min="0" max="100" step="1"><output id="liveVolumeOut">—</output></div></div>
<div class="field"><label for="liveMic">Sensibilité micro</label><div class="range-field"><input id="liveMic" type="range" min="0" max="100" step="1"><output id="liveMicOut">—</output></div></div>
<div class="field switch-field"><label for="muteMicro">Microphone coupé</label><input id="muteMicro" type="checkbox"></div>
<div class="field switch-field"><label for="muteSpeaker">Haut-parleur coupé</label><input id="muteSpeaker" type="checkbox"></div>
<p class="muted microtext" id="audioResult">Les réglages audio sont appliqués sans redémarrage.</p>
</section>
<section class="panel"><div class="panel-title"><span class="panel-symbol">⌘</span><div><h2>Connexions et météo</h2><p>Adresses des serveurs et source météo</p></div></div>
<div class="field"><label for="ALFRED_COMFYUI_URL">Serveur ComfyUI</label><input data-setting id="ALFRED_COMFYUI_URL" placeholder="http://192.168.1.13:8188"><small>Laisse vide pour conserver la configuration Hermes existante.</small></div>
<div class="field"><label for="ALFRED_OLLAMA_URL">Serveur Ollama (surveillance)</label><input data-setting id="ALFRED_OLLAMA_URL" placeholder="http://192.168.1.13:11434"><small>Cette adresse sert au contrôle de disponibilité ; Hermes garde sa propre configuration LLM.</small></div>
<div class="field"><label for="HA_WEATHER_ENTITY">Entité météo Home Assistant</label><input data-setting id="HA_WEATHER_ENTITY" placeholder="weather.forecast_home"><small>Exemple : weather.forecast_home. Le jeton Home Assistant n’est pas exposé ici.</small></div>
<a href="/surveillance" class="button ghost inline-link">Voir la surveillance des services</a>
</section>
</div>
<section class="panel restart-panel"><div><h2>Maintenance</h2><p class="muted">Redémarrer Alfred pour appliquer les paramètres enregistrés. Action protégée par mot de passe administrateur.</p><span class="microtext muted">Le bouton requiert une autorisation systemd explicitement activée.</span></div><button class="button danger" type="button" id="restartButton">Redémarrer Alfred</button></section>
<p role="status" aria-live="polite" class="message" id="message"></p>
</main>
<div class="auth-screen" id="authScreen"><div class="auth-box"><span class="brand-orb big">A</span><h1>Accès administrateur</h1><p>Connecte-toi pour modifier la configuration d'Alfred.</p><form id="loginForm"><label for="adminPassword">Mot de passe</label><input type="password" id="adminPassword" autocomplete="current-password" required><button class="button primary full" type="submit">Se connecter</button></form><p class="message" id="loginMessage" role="status"></p><small>Accès local / réseau privé uniquement.</small></div></div>
</div>
</body>
</html>
@@ -0,0 +1,19 @@
<!doctype html><html lang="fr"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="theme-color" content="#121b25"><title>Alfred · Surveillance</title><link rel="stylesheet" href="/static/css/alfred-admin.css"><script defer src="/static/js/alfred-admin.js"></script></head>
<body data-page="surveillance">
<div class="admin-layout">
<header class="admin-header"><div class="brand"><span class="brand-orb">A</span><div><strong>Alfred</strong><small>Centre de contrôle</small></div></div><nav aria-label="Navigation"><a href="/">Accueil</a><a href="/config">Configuration</a><a href="/surveillance" class="selected">Surveillance</a><button class="text-button" type="button" id="logoutButton">Déconnexion</button></nav></header>
<main class="admin-main" id="mainArea" hidden>
<div class="page-top"><div><p class="eyebrow">DIAGNOSTIC</p><h1>Surveillance</h1><p class="muted">État des périphériques, de l’assistant et des services locaux.</p></div><div class="page-actions"><span class="tag"><span class="pulse-dot"></span><span id="updatedAt">Chargement…</span></span><button class="button ghost" type="button" id="refreshHealth">Actualiser</button></div></div>
<section class="monitor-grid">
<article class="panel metric-card" id="cameraCard"><div class="metric-icon">▣</div><div class="metric-info"><span class="microtext muted">CAMÉRA</span><strong>Webcam</strong><span class="status-pill" id="cameraStatus">—</span><p id="cameraDetail" class="muted">Vérification…</p></div></article>
<article class="panel metric-card" id="microCard"><div class="metric-icon">◉</div><div class="metric-info"><span class="microtext muted">MICROPHONE</span><strong>Capture audio</strong><span class="status-pill" id="microStatus">—</span><p id="microDetail" class="muted">Vérification…</p></div></article>
<article class="panel metric-card" id="speakerCard"><div class="metric-icon">♫</div><div class="metric-info"><span class="microtext muted">HAUT-PARLEUR</span><strong>Sortie audio</strong><span class="status-pill" id="speakerStatus">—</span><p id="speakerDetail" class="muted">Vérification…</p></div></article>
<article class="panel metric-card"><div class="metric-icon">◌</div><div class="metric-info"><span class="microtext muted">MOT DE RÉVEIL</span><strong id="wakeLabel">Vosk</strong><span class="status-pill" id="wakeStatus">—</span><p id="wakeDetail" class="muted">Vérification…</p></div></article>
</section>
<div class="two-col monitor-lower"><section class="panel"><div class="panel-title"><span class="panel-symbol">⇄</span><div><h2>Services et connexions</h2><p>Tests réels : HTTP, port TCP ou présence du binaire</p></div></div><div class="service-list" id="serviceList"><p class="muted">Chargement…</p></div></section>
<section class="panel"><div class="panel-title"><span class="panel-symbol">▤</span><div><h2>Système</h2><p>Ressources du mini-PC hébergeant Alfred</p></div></div><div class="resource"><div class="resource-title"><span>Disque de l’application</span><strong id="diskPercent">—</strong></div><div class="meter"><span id="diskBar"></span></div><p id="diskText" class="muted microtext">—</p></div><div class="resource"><div class="resource-title"><span>Mémoire RAM</span><strong id="memPercent">—</strong></div><div class="meter"><span id="memBar"></span></div><p id="memText" class="muted microtext">—</p></div><div class="resource"><div class="resource-title"><span>Charge système (1 min)</span><strong id="loadAvg">—</strong></div></div><div class="info-note">Les contrôles de caméra n'ouvrent pas le flux vidéo, afin de ne pas bloquer la reconnaissance faciale.</div></section></div>
<section class="panel"><div class="panel-title"><span class="panel-symbol">⚙</span><div><h2>MCP Hermes détectés</h2><p>Présence des répertoires, pas un test de communication des outils MCP</p></div></div><div class="mcp-grid" id="mcpList"><p class="muted">Chargement…</p></div></section>
<p role="status" aria-live="polite" class="message" id="message"></p>
</main>
<div class="auth-screen" id="authScreen"><div class="auth-box"><span class="brand-orb big">A</span><h1>Accès administrateur</h1><p>Connecte-toi pour consulter l'état d'Alfred.</p><form id="loginForm"><label for="adminPassword">Mot de passe</label><input type="password" id="adminPassword" autocomplete="current-password" required><button class="button primary full" type="submit">Se connecter</button></form><p class="message" id="loginMessage" role="status"></p><small>Accès local / réseau privé uniquement.</small></div></div>
</div></body></html>
+116
View File
@@ -423,5 +423,121 @@
},
"cached_at": 1793111827.3695734,
"focus_label": "samedi 31 octobre"
},
"bcd2bb01a6eee2d756888ce1272d02f38fe5cdc2c8b94ee40851fe702d6e8e85": {
"file": "bcd2bb01a6eee2d756888ce1272d02f38fe5cdc2c8b94ee40851fe702d6e8e85.jpg",
"prompt_ids": [
"a40affa5-4232-46a2-8ab7-19ca0e731dd4",
"930a85ff-aa99-404b-9356-911324ff34e0"
],
"reviews": {
"child": {
"score": 0.0,
"ok": false,
"issues": [
"review_unavailable"
],
"reviewed": false,
"reference_applied": true
},
"adult": {
"score": 0.0,
"ok": false,
"issues": [
"review_unavailable"
],
"reviewed": false,
"reference_applied": true
}
},
"cached_at": 1793116044.4125576,
"focus_label": "aujourd’hui"
},
"9384429b2e4292835018049e1c181d50d6cf911bfb587c2b8b0036c2ecb619a1": {
"file": "9384429b2e4292835018049e1c181d50d6cf911bfb587c2b8b0036c2ecb619a1.jpg",
"prompt_ids": [
"dd15bd0c-ac93-4da8-a93c-17810a04237f",
"940eda05-2e4c-4c4c-a9fb-29259c907e1b"
],
"reviews": {
"child": {
"score": 0.0,
"ok": false,
"issues": [
"review_unavailable"
],
"reviewed": false,
"reference_applied": true
},
"adult": {
"score": 0.0,
"ok": false,
"issues": [
"review_unavailable"
],
"reviewed": false,
"reference_applied": true
}
},
"cached_at": 1793181286.3307507,
"focus_label": "aujourd’hui"
},
"a0e631c42d8a4fc9dc3abad674a6625d32d9fe3b471d7a101a62c4a790a3926e": {
"file": "a0e631c42d8a4fc9dc3abad674a6625d32d9fe3b471d7a101a62c4a790a3926e.jpg",
"prompt_ids": [
"edd9eaf1-9c36-4b5b-b765-f4346fae3699",
"e4c7e52d-00b3-4571-a8b8-dd9c6bf3ff96"
],
"reviews": {
"child": {
"score": 0.0,
"ok": false,
"issues": [
"review_unavailable"
],
"reviewed": false,
"reference_applied": true
},
"adult": {
"score": 0.0,
"ok": false,
"issues": [
"review_unavailable"
],
"reviewed": false,
"reference_applied": true
}
},
"cached_at": 1793183681.1423311,
"focus_label": "aujourd’hui"
},
"dd262938ddb64632659bedd83dd2d7e9f173b55663a3d155002468deee01a3b4": {
"file": "dd262938ddb64632659bedd83dd2d7e9f173b55663a3d155002468deee01a3b4.jpg",
"prompt_ids": [
"b1ae1d61-d434-409c-8d89-be73382dd328",
"f55b483c-eb69-4792-acb4-4c52b687801f"
],
"reviews": {
"child": {
"score": 0.0,
"ok": false,
"issues": [
"review_unavailable"
],
"reviewed": false,
"reference_applied": true
},
"adult": {
"score": 0.0,
"ok": false,
"issues": [
"review_unavailable"
],
"reviewed": false,
"reference_applied": true
}
},
"cached_at": 1793186782.712917,
"focus_label": "aujourd’hui"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
+10
View File
@@ -63,5 +63,15 @@
"created_at": 1793109993.4915156,
"state": "finished",
"finished_at": 1793110013.7477841
},
{
"id": "a0634d97d4",
"label": "Chronomètre",
"kind": "stopwatch",
"duration": null,
"elapsed": 3067.5718233585358,
"started_at": null,
"created_at": 1793117300.2246997,
"state": "cancelled"
}
]
+53 -2
View File
@@ -65,6 +65,28 @@ DEFAULTS = {
}
# 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'))
@@ -124,7 +146,35 @@ def _validate(key, value):
value = str(value).strip()
if any(c in value for c in ('\r', '\n', '\x00', '"', "'", '\\', '$', '`')):
raise ValueError(f'Caractères interdits pour {key}.')
if kind == 'text':
# 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':
@@ -299,10 +349,11 @@ def _health(wake, audio_settings):
mem = {'total': info['MemTotal'], 'used': info['MemTotal'] - info.get('MemAvailable', 0)}
except (OSError, ValueError):
pass
return {'time': now, 'audio':audio, 'wake':wake_state, 'camera':camera,
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):
+154
View File
@@ -0,0 +1,154 @@
"""Additive health information for Alfred admin console V2.
All checks are read-only. No model download, subprocess of Hermes, or microphone
stream is triggered by the monitoring endpoint.
"""
from __future__ import annotations
import json
import os
import socket
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
MAX_WYOMING_INFO = 65536
_WHISPER_CACHE = {'key': None, 'at': 0.0, 'result': None}
def _describe_whisper(host: str, port: int, timeout: float = 1.8) -> dict:
"""Check actual Wyoming info/ASR support; *not* a transcription test."""
start = time.monotonic()
try:
with socket.create_connection((host, port), timeout=timeout) as sock:
sock.settimeout(timeout)
sock.sendall(b'{"type":"describe"}\n')
with sock.makefile('rb') as stream:
header = None
# Other Wyoming events may precede info; never wait indefinitely.
for _ in range(4):
line = stream.readline(4096)
if not line:
break
event = json.loads(line.decode('utf-8'))
if event.get('type') == 'info':
header = event
break
if header is None:
raise ValueError('Réponse info Wyoming absente')
length = int(header.get('data_length', 0))
if length <= 0 or length > MAX_WYOMING_INFO:
raise ValueError('Taille de réponse Wyoming invalide')
payload = stream.read(length)
if len(payload) != length:
raise ValueError('Réponse Wyoming incomplète')
info = json.loads(payload.decode('utf-8'))
asrs = [x for x in info.get('asr', []) if isinstance(x, dict) and x.get('installed')]
if not asrs:
return {'state': 'warning', 'detail': 'Wyoming accessible, aucun moteur ASR installé',
'latency_ms': round((time.monotonic() - start) * 1000)}
asr = asrs[0]
models = [x.get('name') for x in asr.get('models', [])
if isinstance(x, dict) and x.get('installed') and x.get('name')]
model = ', '.join(models[:3]) or 'non indiqué'
name = asr.get('name', 'ASR')
return {'state': 'ok', 'detail': f'{name} · modèle {model} · protocole Wyoming',
'engine': name, 'model': model,
'streaming': bool(asr.get('supports_transcript_streaming')),
'latency_ms': round((time.monotonic() - start) * 1000)}
except (OSError, ValueError, TypeError, UnicodeError, json.JSONDecodeError):
return {'state': 'down', 'detail': f'Pas de réponse ASR Wyoming sur {host}:{port}'}
def _whisper_status(host: str, port: int) -> dict:
key = (host, port)
now = time.monotonic()
if _WHISPER_CACHE['key'] == key and (now - _WHISPER_CACHE['at']) < 10:
return dict(_WHISPER_CACHE['result'])
result = _describe_whisper(host, port)
_WHISPER_CACHE.update(key=key, at=now, result=dict(result))
return result
def _timer_snapshot() -> dict:
"""Read existing timer store without calling its mutating methods."""
installed = (ROOT / 'services' / 'alfred_timers.py').is_file()
store_path = Path(os.environ.get('ALFRED_TIMERS_DB', str(ROOT / 'data' / 'timers.json')))
if not installed:
return {'installed': False, 'state': 'unknown', 'detail': 'Module minuteurs non installé', 'items': []}
if not store_path.is_file():
return {'installed': True, 'state': 'ok', 'detail': 'Module présent, aucun minuteur enregistré', 'items': []}
try:
if store_path.stat().st_size > 1024 * 1024:
raise ValueError('Fichier minuteurs trop volumineux')
data = json.loads(store_path.read_text(encoding='utf-8'))
if not isinstance(data, list):
raise ValueError('Format minuteurs inattendu')
now = time.time()
items = []
for item in data:
if not isinstance(item, dict) or item.get('state') not in ('running', 'paused'):
continue
duration = item.get('duration')
elapsed = max(0, float(item.get('elapsed', 0)))
if item.get('state') == 'running':
elapsed += max(0, now - float(item['started_at']))
remaining = max(0, float(duration) - elapsed) if duration is not None else None
# A timer whose deadline passed is omitted even if its worker has not
# yet persisted the finished state.
if remaining == 0 and item.get('state') == 'running' and duration is not None:
continue
items.append({'label': str(item.get('label', 'Minuteur'))[:80],
'kind': item.get('kind') if item.get('kind') in ('timer', 'stopwatch') else 'timer',
'state': item['state'], 'remaining': round(remaining, 1) if remaining is not None else None,
'elapsed': round(elapsed, 1), 'duration': duration})
return {'installed': True, 'state': 'ok', 'detail': f'{len(items)} en cours ou en pause',
'items': items[:20]}
except (OSError, ValueError, TypeError, KeyError, OverflowError):
return {'installed': True, 'state': 'warning', 'detail': 'Stockage minuteurs illisible', 'items': []}
def extend_health(data: dict, read_config=None) -> dict:
"""Enrich existing _health() JSON. The base implementation is left intact."""
try:
saved = read_config() if callable(read_config) else {}
except (ValueError, OSError):
saved = {}
configured = str(saved.get('STT_ENGINE', os.getenv('STT_ENGINE', 'vosk'))).lower()
runtime = os.getenv('STT_ENGINE', 'vosk').lower()
runtime = 'wyoming' if runtime in ('whisper', 'wyoming_whisper', 'wyoming') else 'vosk'
host = os.getenv('WYOMING_WHISPER_HOST', '192.168.1.13')
try:
port = int(os.getenv('WYOMING_WHISPER_PORT', '10300'))
if not 1 <= port <= 65535:
raise ValueError('Port invalide')
except (ValueError, TypeError):
port = 10300
path = ROOT / 'services' / 'wyoming_stt.py'
installed = path.is_file()
probe = _whisper_status(host, port) if installed else {
'state': 'unknown', 'detail': 'Intégration Wyoming STT non installée'}
if configured != runtime:
state = 'warning'
detail = f'{runtime.upper()} actif ; {configured.upper()} sélectionné (redémarrage nécessaire)'
else:
state = 'ok' if runtime == 'vosk' or (installed and probe['state'] == 'ok') else 'warning'
detail = (f'{runtime.upper()} actif' + (' · retour Vosk si indisponible' if runtime == 'wyoming' else ''))
data['stt'] = {'state': state, 'active_engine': runtime, 'configured_engine': configured,
'detail': detail, 'host': host, 'port': port,
'language': os.getenv('WYOMING_WHISPER_LANGUAGE', 'fr'),
'whisper': probe, 'vosk_fallback': runtime == 'wyoming'}
data.setdefault('services', {})['wyoming_whisper'] = probe
timer = _timer_snapshot()
data['timers'] = timer
data['services']['alfred_timers'] = {'state': timer['state'], 'detail': timer['detail'] + ' (lecture locale)'}
mcps = data.setdefault('mcps', {})
for slug, label in [('MCP_alfred_timers', 'Alfred Timers'), ('MCP_autres', 'Alfred Autres')]:
found = (Path('/opt/hermes') / slug).is_dir()
mcps[slug] = {
'state': 'ok' if found else 'unknown',
'label': label,
'detail': 'Dossier présent · test MCP non exécuté' if found else 'Dossier absent',
}
data['maintenance'] = {'web_restart_enabled': os.getenv('ALFRED_ALLOW_WEB_RESTART', 'false').lower() in ('true', '1', 'yes')}
return data
+17
View File
@@ -0,0 +1,17 @@
/* Alfred Configuration & Surveillance V2: additive styles only */
.admin-v2-stt-panel {margin-top:18px}
.admin-v2-form-grid {display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:0 20px}
.admin-v2-form-grid .field {min-width:0}
.admin-v2-help {line-height:1.6;color:#a7c0d0;font-size:.82rem;background:#ffffff08;border:1px solid #ffffff18;padding:13px 15px;border-radius:11px;margin:1px 0 0}
.admin-v2-help code,.admin-v2-monitor code,.admin-v2-maintenance code{font-size:.84em;color:#bde0fc}
.admin-v2-monitor {margin:17px 0}
.admin-v2-summary {display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:10px;border-bottom:1px solid #ffffff16;padding:10px 0}
.admin-v2-summary strong {font-size:.85rem;color:#eaf4fd;overflow-wrap:anywhere}
.admin-v2-monitor p.microtext {line-height:1.6;margin:9px 0 18px}
.admin-v2-monitor .info-note{margin-top:12px}
.admin-v2-timer-list{max-height:200px;overflow:auto;margin-top:8px}
.admin-v2-timer-row{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 0;border-bottom:1px solid #ffffff13;font-size:.82rem}
.admin-v2-timer-row span{color:#adc5d7;text-align:right}
.admin-v2-maintenance{margin-bottom:17px}
.admin-v2-maintenance p{line-height:1.65}
@media(max-width:720px){.admin-v2-form-grid{grid-template-columns:1fr}.admin-v2-summary{align-items:flex-start}}
+49
View File
@@ -0,0 +1,49 @@
/* Alfred V2 monitoring extension: no credentials, no write calls. */
(() => {
'use strict';
const byId = id => document.getElementById(id);
const text = (id, value) => { const el = byId(id); if (el) el.textContent = String(value ?? '—'); };
const pill = (id, state) => {
const el = byId(id); if (!el) return;
const states = {ok:'Disponible', warning:'Attention', down:'Indisponible', unknown:'Non vérifié'};
el.dataset.state = state || 'unknown';
el.textContent = states[state] || 'Non vérifié';
};
const duration = seconds => {
const n = Math.max(0, Math.floor(Number(seconds) || 0));
if (n >= 3600) return `${Math.floor(n/3600)} h ${String(Math.floor((n%3600)/60)).padStart(2,'0')} min`;
return `${Math.floor(n/60)} min ${String(n%60).padStart(2,'0')} s`;
};
window.alfredAdminV2Health = data => {
if (document.body.dataset.page !== 'surveillance') return;
const stt = data.stt || {}, whisper = stt.whisper || {}, timers = data.timers || {};
text('v2SttActive', stt.active_engine === 'wyoming' ? 'Whisper distant' : 'Vosk local');
text('v2SttDetail', stt.detail || 'État indisponible');
pill('v2SttState', stt.state);
text('v2WhisperAddress', stt.host && stt.port ? `${stt.host}:${stt.port}` : 'Non configuré');
text('v2WhisperDetail', whisper.detail || 'Service non vérifié');
pill('v2WhisperState', whisper.state);
text('v2TimerCount', `${(timers.items || []).length} actif(s)`);
pill('v2TimerState', timers.state);
const list = byId('v2TimerList');
if (list) {
list.replaceChildren();
const items = timers.items || [];
if (!items.length) {
const p=document.createElement('p');p.className='muted microtext';
p.textContent=timers.detail || 'Aucun minuteur actif'; list.appendChild(p);
}
items.forEach(item => {
const row=document.createElement('div'); row.className='admin-v2-timer-row';
const name=document.createElement('strong'); name.textContent=item.label || 'Minuteur';
const val=document.createElement('span');
val.textContent=item.state === 'paused' ? 'En pause' :
(item.remaining == null ? duration(item.elapsed || 0) + ' écoulées' : duration(item.remaining) + ' restantes');
row.append(name,val); list.appendChild(row);
});
}
const allowed = Boolean(data.maintenance?.web_restart_enabled);
text('v2RestartLabel', allowed ? 'Autorisé' : 'Désactivé');
pill('v2RestartState', allowed ? 'ok' : 'warning');
};
})();
+5 -1
View File
@@ -78,7 +78,7 @@
serviceStatus($('wakeStatus'), {state:!wake.running?'down':wake.paused?'warning':'ok'});
safeText('wakeLabel', `Vosk · ${wake.word || 'alfred'}`);
safeText('wakeDetail', wake.error || (wake.paused ? 'Détection en pause' : 'Détection active'));
const labels = {alfred:'Alfred / Flask', hermes_cli:'Hermes Agent (CLI)', hermes_dashboard:'Hermes WebUI', comfyui:'ComfyUI', ollama:'Ollama', home_assistant:'Home Assistant', wyoming_piper:'Wyoming Piper'};
const labels = {alfred:'Alfred / Flask', hermes_cli:'Hermes Agent (CLI)', hermes_dashboard:'Hermes WebUI', comfyui:'ComfyUI', ollama:'Ollama', home_assistant:'Home Assistant', wyoming_piper:'Wyoming Piper', wyoming_whisper:'Whisper (STT)', alfred_timers:'Minuteurs Alfred'};
const list = $('serviceList'); list.replaceChildren();
for (const [key,service] of Object.entries(data.services || {})) {
const row = document.createElement('div'); row.className='service-row';
@@ -108,7 +108,11 @@
const p=document.createElement('p');p.textContent=mcp.detail;
card.append(title,status,p);mcps.append(card);
}
if (typeof window.alfredAdminV2Health === 'function') {
window.alfredAdminV2Health(data);
}
}
// ALFRED_ADMIN_SURVEILLANCE_V2: monitoring hook
let inFlight = false;
async function refreshHealth() {
if (inFlight || document.hidden) return;
+20
View File
@@ -5,6 +5,9 @@
<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 -->
</head>
<body data-page="config">
<div class="admin-layout">
@@ -46,6 +49,23 @@
<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>
+26 -1
View File
@@ -1,4 +1,7 @@
<!doctype html><html lang="fr"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="theme-color" content="#121b25"><title>Alfred · Surveillance</title><link rel="stylesheet" href="/static/css/alfred-admin.css"><script defer src="/static/js/alfred-admin.js"></script></head>
<!doctype html><html lang="fr"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="theme-color" content="#121b25"><title>Alfred · Surveillance</title><link rel="stylesheet" href="/static/css/alfred-admin.css"><script defer src="/static/js/alfred-admin.js"></script><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 -->
</head>
<body data-page="surveillance">
<div class="admin-layout">
<header class="admin-header"><div class="brand"><span class="brand-orb">A</span><div><strong>Alfred</strong><small>Centre de contrôle</small></div></div><nav aria-label="Navigation"><a href="/">Accueil</a><a href="/config">Configuration</a><a href="/surveillance" class="selected">Surveillance</a><button class="text-button" type="button" id="logoutButton">Déconnexion</button></nav></header>
@@ -12,6 +15,28 @@
</section>
<div class="two-col monitor-lower"><section class="panel"><div class="panel-title"><span class="panel-symbol">⇄</span><div><h2>Services et connexions</h2><p>Tests réels : HTTP, port TCP ou présence du binaire</p></div></div><div class="service-list" id="serviceList"><p class="muted">Chargement…</p></div></section>
<section class="panel"><div class="panel-title"><span class="panel-symbol">▤</span><div><h2>Système</h2><p>Ressources du mini-PC hébergeant Alfred</p></div></div><div class="resource"><div class="resource-title"><span>Disque de l’application</span><strong id="diskPercent">—</strong></div><div class="meter"><span id="diskBar"></span></div><p id="diskText" class="muted microtext">—</p></div><div class="resource"><div class="resource-title"><span>Mémoire RAM</span><strong id="memPercent">—</strong></div><div class="meter"><span id="memBar"></span></div><p id="memText" class="muted microtext">—</p></div><div class="resource"><div class="resource-title"><span>Charge système (1 min)</span><strong id="loadAvg">—</strong></div></div><div class="info-note">Les contrôles de caméra n'ouvrent pas le flux vidéo, afin de ne pas bloquer la reconnaissance faciale.</div></section></div>
<!-- ALFRED_ADMIN_SURVEILLANCE_V2 : services vocaux et minuteurs -->
<div class="two-col admin-v2-monitor">
<section class="panel" aria-labelledby="v2-stt-title">
<div class="panel-title"><span class="panel-symbol">◉</span><div><h2 id="v2-stt-title">Transcription vocale</h2><p>Mode réellement actif et disponibilité du serveur Wyoming</p></div></div>
<div class="admin-v2-summary"><span class="muted">Mode STT</span><strong id="v2SttActive">—</strong><span class="status-pill" id="v2SttState">—</span></div>
<p class="muted microtext" id="v2SttDetail">Vérification…</p>
<div class="admin-v2-summary"><span class="muted">Serveur Whisper</span><strong id="v2WhisperAddress">—</strong><span class="status-pill" id="v2WhisperState">—</span></div>
<p class="muted microtext" id="v2WhisperDetail">Vérification…</p>
<div class="info-note">Wyoming est interrogé avec <code>describe</code> sans lancer de transcription. Une connexion réussie ne garantit pas la qualité STT.</div>
</section>
<section class="panel" aria-labelledby="v2-timers-title">
<div class="panel-title"><span class="panel-symbol">◷</span><div><h2 id="v2-timers-title">Minuteurs et chronomètres</h2><p>État enregistré localement, indépendant des appels Hermes</p></div></div>
<div class="admin-v2-summary"><span class="muted">Gestionnaire Alfred</span><strong id="v2TimerCount">—</strong><span class="status-pill" id="v2TimerState">—</span></div>
<div id="v2TimerList" class="admin-v2-timer-list" aria-live="polite"><p class="muted microtext">Chargement…</p></div>
<div class="info-note">La présence d’un MCP dans la liste ci-dessous ne prouve pas qu’Hermes l’a appelé : consulter ses logs pour vérifier les outils exécutés.</div>
</section>
</div>
<section class="panel admin-v2-maintenance"><div class="panel-title"><span class="panel-symbol">✧</span><div><h2>Maintenance et intégrations</h2><p>Vérifications complémentaires des nouveautés</p></div></div>
<div class="admin-v2-summary"><span class="muted">Redémarrage depuis /config</span><strong id="v2RestartLabel">—</strong><span class="status-pill" id="v2RestartState">—</span></div>
<p class="muted microtext">MCP Autres (heure/date) et Alfred Timers : la grille ci-dessous signale les dossiers détectés dans <code>/opt/hermes</code>. La validation fonctionnelle se fait avec <code>hermes mcp test</code>.</p>
</section>
<section class="panel"><div class="panel-title"><span class="panel-symbol">⚙</span><div><h2>MCP Hermes détectés</h2><p>Présence des répertoires, pas un test de communication des outils MCP</p></div></div><div class="mcp-grid" id="mcpList"><p class="muted">Chargement…</p></div></section>
<p role="status" aria-live="polite" class="message" id="message"></p>
</main>