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 + + + + +
+
+
A
AlfredCentre de contrôle
+ +
+
+

PERSONNALISATION

Configuration

Paramètres de l’assistant, voix et services. Les modifications prennent effet après redémarrage.

Aucun changement
+ +
+
✦

Identité et voix

Nom affiché, mot d'activation et voix de synthèse

+
Visible dans l’interface après redémarrage.
+
Un seul mot. Le modèle Vosk doit pouvoir le reconnaître ; le mode OpenWakeWord nécessite un modèle entraîné.
+
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.
+
Utilise les réglages déjà actifs, sans redémarrage
+
+
☁

Illustrations météo

Qualité, visages de référence et accessoires ComfyUI

+
+
8.6 / 10
+
+
+
+
0.56
Une valeur élevée modifie davantage la tenue, mais risque de changer le visage.
+
+
+
+
♫

Audio en direct

Volume, sensibilité et coupures immédiates

+
—
+
—
+
+
+

Les réglages audio sont appliqués sans redémarrage.

+
+
⌘

Connexions et météo

Adresses des serveurs et source météo

+
Laisse vide pour conserver la configuration Hermes existante.
+
Cette adresse sert au contrôle de disponibilité ; Hermes garde sa propre configuration LLM.
+
Exemple : weather.forecast_home. Le jeton Home Assistant n’est pas exposé ici.
+ Voir la surveillance des services +
+
+

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.
+

+
+
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 + +
+
A
AlfredCentre de contrôle
+
+

DIAGNOSTIC

Surveillance

État des périphériques, de l’assistant et des services locaux.

Chargement…
+
+
▣
CAMÉRAWebcam—

Vérification…

+
◉
MICROPHONECapture audio—

Vérification…

+
♫
HAUT-PARLEURSortie audio—

Vérification…

+
◌
MOT DE RÉVEILVosk—

Vérification…

+
+
⇄

Services et connexions

Tests réels : HTTP, port TCP ou présence du binaire

Chargement…

+
▤

Système

Ressources du mini-PC hébergeant Alfred

Disque de l’application—

—

Mémoire RAM—

—

Charge système (1 min)—
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

Chargement…

+

+
+
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 + + +
@@ -46,6 +49,23 @@ Voir la surveillance des services
+ + +
+
◉

Reconnaissance vocale (STT)

Vosk local ou Whisper Wyoming distant · configuration effective après redémarrage

+
+ + Le mot d’activation et le texte provisoire restent gérés par Vosk. Whisper améliore la transcription finale envoyée à Hermes.
+
+
Adresse IP ou nom DNS, sans http://
+
+
+
+
+
+
+

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.

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 + + +
A
AlfredCentre de contrôle
@@ -12,6 +15,28 @@
⇄

Services et connexions

Tests réels : HTTP, port TCP ou présence du binaire

Chargement…

▤

Système

Ressources du mini-PC hébergeant Alfred

Disque de l’application—

—

Mémoire RAM—

—

Charge système (1 min)—
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——
+

Chargement…

+
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

Chargement…