"""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