diff --git a/alfred/app/backup_admin_surveillance_v2_20261027_175910_468934/services/admin_console.py b/alfred/app/backup_admin_surveillance_v2_20261027_175910_468934/services/admin_console.py
new file mode 100644
index 0000000..eae961f
--- /dev/null
+++ b/alfred/app/backup_admin_surveillance_v2_20261027_175910_468934/services/admin_console.py
@@ -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)
diff --git a/alfred/app/backup_admin_surveillance_v2_20261027_175910_468934/static/js/alfred-admin.js b/alfred/app/backup_admin_surveillance_v2_20261027_175910_468934/static/js/alfred-admin.js
new file mode 100644
index 0000000..eed0c22
--- /dev/null
+++ b/alfred/app/backup_admin_surveillance_v2_20261027_175910_468934/static/js/alfred-admin.js
@@ -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);
+})();
diff --git a/alfred/app/backup_admin_surveillance_v2_20261027_175910_468934/templates/config.html b/alfred/app/backup_admin_surveillance_v2_20261027_175910_468934/templates/config.html
new file mode 100644
index 0000000..9b49a47
--- /dev/null
+++ b/alfred/app/backup_admin_surveillance_v2_20261027_175910_468934/templates/config.html
@@ -0,0 +1,55 @@
+
+
+
+
+Alfred · Configuration
+
+
+
+
+
+
+
+ PERSONNALISATION
Configuration Paramètres de l’assistant, voix et services. Les modifications prennent effet après redémarrage.
Aucun changement Enregistrer
+ Réglages enregistrés. Redémarre Alfred pour les appliquer.
+
+
+
☁ Illustrations météo Qualité, visages de référence et accessoires ComfyUI
+ Essais par personnage 1 — Rapide 2 — Équilibré 3 — Qualité
+
+ Taille d’affichage 250 px 300 px 350 px 400 px 450 px 500 px
+ Analyse automatique des candidats Nécessite un workflow vision compatible
+ Références des visages Auto — avec repli Obligatoires Désactivées
+
+ Préserver les visages Lorsque l’image de référence est utilisable
+ Accessoires visibles par personnage Aucun 1 — Recommandé 2
+
+
+
+
+ Maintenance Redémarrer Alfred pour appliquer les paramètres enregistrés. Action protégée par mot de passe administrateur.
Le bouton requiert une autorisation systemd explicitement activée. Redémarrer Alfred
+
+
+
A Accès administrateur Connecte-toi pour modifier la configuration d'Alfred.
Accès local / réseau privé uniquement.
+
+
+
diff --git a/alfred/app/backup_admin_surveillance_v2_20261027_175910_468934/templates/surveillance.html b/alfred/app/backup_admin_surveillance_v2_20261027_175910_468934/templates/surveillance.html
new file mode 100644
index 0000000..fddc276
--- /dev/null
+++ b/alfred/app/backup_admin_surveillance_v2_20261027_175910_468934/templates/surveillance.html
@@ -0,0 +1,19 @@
+Alfred · Surveillance
+
+
+
+
+DIAGNOSTIC
Surveillance État des périphériques, de l’assistant et des services locaux.
Chargement… Actualiser
+
+▣
CAMÉRA Webcam — Vérification…
+◉
MICROPHONE Capture audio — Vérification…
+♫
HAUT-PARLEUR Sortie audio — Vérification…
+◌
MOT DE RÉVEIL Vosk — Vérification…
+
+⇄ Services et connexions Tests réels : HTTP, port TCP ou présence du binaire
+
▤ Système Ressources du mini-PC hébergeant Alfred
Disque de l’application —
—
Les contrôles de caméra n'ouvrent pas le flux vidéo, afin de ne pas bloquer la reconnaissance faciale.
+⚙ MCP Hermes détectés Présence des répertoires, pas un test de communication des outils MCP
+
+
+
A Accès administrateur Connecte-toi pour consulter l'état d'Alfred.
Accès local / réseau privé uniquement.
+
diff --git a/alfred/app/cache/weather_outfit_cache.json b/alfred/app/cache/weather_outfit_cache.json
index 08ff115..def1bfc 100644
--- a/alfred/app/cache/weather_outfit_cache.json
+++ b/alfred/app/cache/weather_outfit_cache.json
@@ -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"
}
}
\ No newline at end of file
diff --git a/alfred/app/cache/weather_outfit_images/9384429b2e4292835018049e1c181d50d6cf911bfb587c2b8b0036c2ecb619a1.jpg b/alfred/app/cache/weather_outfit_images/9384429b2e4292835018049e1c181d50d6cf911bfb587c2b8b0036c2ecb619a1.jpg
new file mode 100644
index 0000000..cc78499
Binary files /dev/null and b/alfred/app/cache/weather_outfit_images/9384429b2e4292835018049e1c181d50d6cf911bfb587c2b8b0036c2ecb619a1.jpg differ
diff --git a/alfred/app/cache/weather_outfit_images/a0e631c42d8a4fc9dc3abad674a6625d32d9fe3b471d7a101a62c4a790a3926e.jpg b/alfred/app/cache/weather_outfit_images/a0e631c42d8a4fc9dc3abad674a6625d32d9fe3b471d7a101a62c4a790a3926e.jpg
new file mode 100644
index 0000000..c5f8ea1
Binary files /dev/null and b/alfred/app/cache/weather_outfit_images/a0e631c42d8a4fc9dc3abad674a6625d32d9fe3b471d7a101a62c4a790a3926e.jpg differ
diff --git a/alfred/app/cache/weather_outfit_images/bcd2bb01a6eee2d756888ce1272d02f38fe5cdc2c8b94ee40851fe702d6e8e85.jpg b/alfred/app/cache/weather_outfit_images/bcd2bb01a6eee2d756888ce1272d02f38fe5cdc2c8b94ee40851fe702d6e8e85.jpg
new file mode 100644
index 0000000..becc85d
Binary files /dev/null and b/alfred/app/cache/weather_outfit_images/bcd2bb01a6eee2d756888ce1272d02f38fe5cdc2c8b94ee40851fe702d6e8e85.jpg differ
diff --git a/alfred/app/cache/weather_outfit_images/dd262938ddb64632659bedd83dd2d7e9f173b55663a3d155002468deee01a3b4.jpg b/alfred/app/cache/weather_outfit_images/dd262938ddb64632659bedd83dd2d7e9f173b55663a3d155002468deee01a3b4.jpg
new file mode 100644
index 0000000..666ee97
Binary files /dev/null and b/alfred/app/cache/weather_outfit_images/dd262938ddb64632659bedd83dd2d7e9f173b55663a3d155002468deee01a3b4.jpg differ
diff --git a/alfred/app/data/alfred_features.sqlite3 b/alfred/app/data/alfred_features.sqlite3
index d9a60ec..319d6e2 100644
Binary files a/alfred/app/data/alfred_features.sqlite3 and b/alfred/app/data/alfred_features.sqlite3 differ
diff --git a/alfred/app/data/alfred_features.sqlite3-shm b/alfred/app/data/alfred_features.sqlite3-shm
index 89465dd..b645afe 100644
Binary files a/alfred/app/data/alfred_features.sqlite3-shm and b/alfred/app/data/alfred_features.sqlite3-shm differ
diff --git a/alfred/app/data/alfred_features.sqlite3-wal b/alfred/app/data/alfred_features.sqlite3-wal
index a2471f1..7408c0a 100644
Binary files a/alfred/app/data/alfred_features.sqlite3-wal and b/alfred/app/data/alfred_features.sqlite3-wal differ
diff --git a/alfred/app/data/timers.json b/alfred/app/data/timers.json
index 940740e..ebe7a19 100644
--- a/alfred/app/data/timers.json
+++ b/alfred/app/data/timers.json
@@ -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"
}
]
\ No newline at end of file
diff --git a/alfred/app/services/__pycache__/admin_console.cpython-313.pyc b/alfred/app/services/__pycache__/admin_console.cpython-313.pyc
index 7a9b009..5e2ce28 100644
Binary files a/alfred/app/services/__pycache__/admin_console.cpython-313.pyc and b/alfred/app/services/__pycache__/admin_console.cpython-313.pyc differ
diff --git a/alfred/app/services/__pycache__/admin_extensions_v2.cpython-313.pyc b/alfred/app/services/__pycache__/admin_extensions_v2.cpython-313.pyc
new file mode 100644
index 0000000..8742e59
Binary files /dev/null and b/alfred/app/services/__pycache__/admin_extensions_v2.cpython-313.pyc differ
diff --git a/alfred/app/services/admin_console.py b/alfred/app/services/admin_console.py
index eae961f..3b9716f 100644
--- a/alfred/app/services/admin_console.py
+++ b/alfred/app/services/admin_console.py
@@ -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):
diff --git a/alfred/app/services/admin_extensions_v2.py b/alfred/app/services/admin_extensions_v2.py
new file mode 100644
index 0000000..276403a
--- /dev/null
+++ b/alfred/app/services/admin_extensions_v2.py
@@ -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
diff --git a/alfred/app/static/css/alfred-admin-v2.css b/alfred/app/static/css/alfred-admin-v2.css
new file mode 100644
index 0000000..5857546
--- /dev/null
+++ b/alfred/app/static/css/alfred-admin-v2.css
@@ -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}}
diff --git a/alfred/app/static/js/alfred-admin-v2.js b/alfred/app/static/js/alfred-admin-v2.js
new file mode 100644
index 0000000..4f1f002
--- /dev/null
+++ b/alfred/app/static/js/alfred-admin-v2.js
@@ -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');
+ };
+})();
diff --git a/alfred/app/static/js/alfred-admin.js b/alfred/app/static/js/alfred-admin.js
index eed0c22..bf600d0 100644
--- a/alfred/app/static/js/alfred-admin.js
+++ b/alfred/app/static/js/alfred-admin.js
@@ -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;
diff --git a/alfred/app/templates/config.html b/alfred/app/templates/config.html
index 9b49a47..e61a605 100644
--- a/alfred/app/templates/config.html
+++ b/alfred/app/templates/config.html
@@ -5,6 +5,9 @@
Alfred · Configuration
+
+
+
+
+
+
+ ◉ Reconnaissance vocale (STT) Vosk local ou Whisper Wyoming distant · configuration effective après redémarrage
+ Moteur de transcription final
+ Vosk local (mode historique) Whisper Wyoming distant (conseillé)
+ Le mot d’activation et le texte provisoire restent gérés par Vosk. Whisper améliore la transcription finale envoyée à Hermes.
+
+ Le modèle Whisper (small actuellement) et son vocabulaire technique (--initial-prompt pour Proxmox, Wazuh…) se configurent dans le conteneur Docker distant , pas sur Alfred. En cas de panne Whisper, retour automatique à Vosk si le correctif STT V1 est installé.
+
Maintenance Redémarrer Alfred pour appliquer les paramètres enregistrés. Action protégée par mot de passe administrateur.
Le bouton requiert une autorisation systemd explicitement activée. Redémarrer Alfred
diff --git a/alfred/app/templates/surveillance.html b/alfred/app/templates/surveillance.html
index fddc276..9b6f1f2 100644
--- a/alfred/app/templates/surveillance.html
+++ b/alfred/app/templates/surveillance.html
@@ -1,4 +1,7 @@
-Alfred · Surveillance
+Alfred · Surveillance
+
+
+
@@ -12,6 +15,28 @@
⇄ Services et connexions Tests réels : HTTP, port TCP ou présence du binaire
▤ Système Ressources du mini-PC hébergeant Alfred
Disque de l’application —
—
Les contrôles de caméra n'ouvrent pas le flux vidéo, afin de ne pas bloquer la reconnaissance faciale.
+
+
+
+
+ ◉ Transcription vocale Mode réellement actif et disponibilité du serveur Wyoming
+ Mode STT — —
+ Vérification…
+ Serveur Whisper — —
+ Vérification…
+ Wyoming est interrogé avec describe sans lancer de transcription. Une connexion réussie ne garantit pas la qualité STT.
+
+
+ ◷ Minuteurs et chronomètres État enregistré localement, indépendant des appels Hermes
+ Gestionnaire Alfred — —
+
+ 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.
+
+
+
✧ Maintenance et intégrations Vérifications complémentaires des nouveautés
+ Redémarrage depuis /config — —
+ MCP Autres (heure/date) et Alfred Timers : la grille ci-dessous signale les dossiers détectés dans /opt/hermes. La validation fonctionnelle se fait avec hermes mcp test.
+
⚙ MCP Hermes détectés Présence des répertoires, pas un test de communication des outils MCP